Files
buzz/mobile/lib/shared/widgets/progressive_animated_avatar.dart
Tom Brow 220cec677a fix(mobile): keep community icons on originals and record thumbnail 404s at fetch (#8896)
Follow-up to #8879, addressing its review.

## Changes

- **Community icons load the original again.** `AvatarImageContent`
gained a `preferThumbnail` flag, off by default. People and agent
avatars turn it on (as #8879 did for every caller); `CommunityAvatar`
leaves it off. Community logos are often transparent PNGs, and the
relay's JPEG thumbnail renders transparency black. This matches desktop,
which shows community icons from the original.
- **A missing thumbnail is recorded where its 404 arrives.** Before,
only the mounted `Image`'s error builder recorded it. If the avatar
scrolled away before a slow 404 came back, the thumbnail URL kept a 30s
failure cooldown instead, and showing that avatar again at any size
during the cooldown rendered initials and never requested the original.
The fetch now records a 404 on a relay `/media/<sha256>.thumb.jpg` URL
in the missing-thumbnail set and puts no cooldown on it.

## Tests

Two journey tests in `avatar_image_test.dart`, both failing on main and
passing here:

- An avatar unmounts before its slow thumbnail 404 arrives, then mounts
again at another size: it requests the original once, renders it (valid
image bytes, no initials), and leaves no cooldown.
- A 36pt `CommunityAvatar` with a relay-hosted icon requests and shows
only the original.

`flutter analyze` is clean. In the full local `flutter test` run, only
12 push and notification tests fail, and they fail the same way on main
in this environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:51:13 -08:00

96 lines
3.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../animated_avatar.dart';
import '../relay/media_image.dart';
import 'avatar_image.dart';
/// Paints an animated avatar's persisted poster until its first frame is ready.
///
/// Callers own playback policy, which keeps animation opt-in at individual
/// profile surfaces.
class ProgressiveAnimatedAvatar extends HookWidget {
const ProgressiveAnimatedAvatar({
super.key,
required this.descriptor,
required this.fallback,
this.fit = BoxFit.cover,
this.loadingImage,
this.onAnimationReady,
});
final AnimatedAvatarDescriptor descriptor;
final Widget fallback;
final BoxFit fit;
/// A local image shown instead of the poster while remote media warms up.
final ImageProvider? loadingImage;
/// Called after the remote animation has produced its first frame.
final VoidCallback? onAnimationReady;
@override
Widget build(BuildContext context) {
final readyAnimationUrl = useState<String?>(null);
final scheduledAnimationUrl = useRef<String?>(null);
final animationKey = useMemoized(GlobalKey.new, [descriptor.animationUrl]);
final isReady = readyAnimationUrl.value == descriptor.animationUrl;
final animation = KeyedSubtree(
key: const ValueKey('progressive-animated-avatar-animation'),
child: MediaImage(
key: animationKey,
url: descriptor.animationUrl,
fit: fit,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if ((wasSynchronouslyLoaded || frame != null) &&
!isReady &&
scheduledAnimationUrl.value != descriptor.animationUrl) {
scheduledAnimationUrl.value = descriptor.animationUrl;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
readyAnimationUrl.value = descriptor.animationUrl;
onAnimationReady?.call();
}
});
}
return child;
},
),
);
if (isReady) {
return KeyedSubtree(
key: const ValueKey('progressive-animated-avatar-animation-ready'),
child: animation,
);
}
return Stack(
fit: StackFit.expand,
children: [
if (loadingImage case final image?)
Image(
key: const ValueKey('progressive-animated-avatar-local-handoff'),
image: image,
fit: fit,
gaplessPlayback: true,
)
else
AvatarImageContent(
key: const ValueKey('progressive-animated-avatar-poster'),
imageUrl: descriptor.posterUrl,
preferThumbnail: true,
fallback: fallback,
fit: fit,
),
Offstage(
key: const ValueKey('progressive-animated-avatar-animation-loading'),
offstage: true,
child: animation,
),
],
);
}
}