mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
## 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>
207 lines
5.8 KiB
Dart
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);
|
|
}
|