Files
buzz/mobile/lib/shared/widgets/avatar_image.dart
Tom Brow 9f8913dd1b mobile: keep a thumbnail fallback apart from a direct load of the same URL (#8913)
## Summary

Follow-up to [#8896](https://github.com/block/buzz/pull/8896), from its
review threads.

**A fallback and a direct load of the same thumbnail no longer share a
download.** `MediaImageProvider.isThumbnailFallback` decides what a 404
means: for a thumbnail `MediaImage` picked in place of an original, it
records a missing thumbnail so the next build loads the original; for
any other URL it starts the 30s failure cooldown. It was left out of
equality and the in-flight download key, so when a profile URL that is
itself a thumbnail and a fallback to that same thumbnail were on screen
together, both joined one download and only one of them ran its own 404
handling. Now each keeps its own provider and download, and each gets
its own handling.

The failure cooldown is keyed the same way. Before, a direct 404 of a
thumbnail put its URL on cooldown (30s, up to 5 min with Retry-After),
and an avatar that fell back to that thumbnail during the cooldown hit
the cooldown error instead of a 404, so it showed initials instead of
loading the original.

Also:

- Reworded the `preferThumbnail` doc so it no longer names community
icons, which never set it.
- Removed the community-icon avatar test, which fed a URL production
never produces.

## Testing

- New test: a direct thumbnail URL and a fallback to it, shown together,
make two requests; after a 404 the direct URL is in cooldown and the
fallback's original loads (not the initials). It fails with the fetch
key change reverted.
- New test: after a direct thumbnail 404, an avatar that falls back to
the same thumbnail still loads the original. It fails with the cooldown
key change reverted.
- `just mobile-check` and `just mobile-test` pass.

🤖 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-02 09:38:34 -08:00

207 lines
5.8 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../animated_avatar.dart';
import '../emoji/emoji_avatar.dart';
import '../emoji/native_emoji_glyph.dart';
import '../relay/relay.dart';
import 'avatar_shape.dart';
part 'avatar_image_native.dart';
/// An avatar that supports both remote URLs and inline image data.
///
/// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also
/// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs,
/// so mobile must decode those before rendering them.
class AvatarImage extends StatelessWidget {
final String? imageUrl;
final double radius;
final Color? backgroundColor;
final Widget fallback;
final bool isAgent;
const AvatarImage({
super.key,
required this.imageUrl,
required this.radius,
required this.fallback,
this.backgroundColor,
this.isAgent = false,
});
@override
Widget build(BuildContext context) {
final animatedAvatar = parseAnimatedAvatarUrl(imageUrl);
final color = animatedAvatar == null ? backgroundColor : Colors.transparent;
final content = SizedBox.square(
dimension: radius * 2,
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? imageUrl,
fallback: fallback,
preferThumbnail: true,
),
);
final clipped = AvatarClip(
isAgent: isAgent,
child: isAgent && usesInsetAgentArtwork(imageUrl)
? Transform.scale(scale: animatedAvatarArtworkScale, child: content)
: content,
);
// CircleAvatar keeps its theme default when people have no background.
if (!isAgent) {
return CircleAvatar(
radius: radius,
backgroundColor: color,
child: clipped,
);
}
return DecoratedBox(
decoration: ShapeDecoration(
color: color,
shape: avatarShape(isAgent: true),
),
child: clipped,
);
}
}
/// Image content for avatar surfaces whose shape is supplied by their parent.
class AvatarImageContent extends ConsumerStatefulWidget {
final String? imageUrl;
final Widget fallback;
final BoxFit fit;
/// Load the relay's small JPEG thumbnail for a small relay-hosted image
/// (see [MediaImage.preferThumbnail]). People and agent avatars set this,
/// matching desktop. Leave it off for images that may be transparent: the
/// thumbnail renders transparent areas black.
final bool preferThumbnail;
const AvatarImageContent({
super.key,
required this.imageUrl,
required this.fallback,
this.fit = BoxFit.cover,
this.preferThumbnail = false,
});
@override
ConsumerState<AvatarImageContent> createState() => _AvatarImageContentState();
}
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
@override
void didUpdateWidget(AvatarImageContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imageUrl != oldWidget.imageUrl) {
_source = _AvatarSource.parse(widget.imageUrl);
}
}
@override
Widget build(BuildContext context) {
final centeredFallback = Center(child: widget.fallback);
return switch (_source) {
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
color: color,
child: LayoutBuilder(
builder: (_, constraints) {
final glyphSize = constraints.biggest.shortestSide * 258 / 512;
return Center(
child: NativeEmojiGlyph(
emoji: emoji,
size: glyphSize,
opticalBoxSize: glyphSize,
),
);
},
),
),
_SvgAvatarSource(:final svg) => SvgPicture.string(
svg,
fit: widget.fit,
placeholderBuilder: (_) => centeredFallback,
errorBuilder: (_, _, _) => centeredFallback,
),
_RasterDataAvatarSource(:final bytes) => _rasterImage(
bytes: bytes,
fallback: centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
preferThumbnail: widget.preferThumbnail,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
}
Widget _rasterImage({required Uint8List bytes, required Widget fallback}) {
return Image.memory(
bytes,
fit: widget.fit,
errorBuilder: (_, _, _) => fallback,
);
}
}
sealed class _AvatarSource {
const _AvatarSource();
static _AvatarSource? parse(String? value) {
final url = value?.trim();
if (url == null || url.isEmpty) return null;
if (!url.startsWith('data:image/')) return _NetworkAvatarSource(url);
try {
final data = UriData.parse(url);
if (data.mimeType == 'image/svg+xml') {
final Uint8List bytes = data.contentAsBytes();
final svg = utf8.decode(bytes);
final emojiAvatar = parseEmojiAvatarSvg(svg);
return emojiAvatar == null
? _SvgAvatarSource(svg)
: _EmojiAvatarSource(
emojiAvatar.emoji,
Color(emojiAvatar.colorValue),
);
}
return _RasterDataAvatarSource(data.contentAsBytes());
} on FormatException {
return null;
}
}
}
class _EmojiAvatarSource extends _AvatarSource {
final String emoji;
final Color color;
const _EmojiAvatarSource(this.emoji, this.color);
}
class _SvgAvatarSource extends _AvatarSource {
final String svg;
const _SvgAvatarSource(this.svg);
}
class _RasterDataAvatarSource extends _AvatarSource {
final Uint8List bytes;
const _RasterDataAvatarSource(this.bytes);
}
class _NetworkAvatarSource extends _AvatarSource {
final String url;
const _NetworkAvatarSource(this.url);
}