fix(mobile): show transparent avatars without a black background (#9105)

## Summary

Transparent PNG and GIF avatars render on a black background in mobile
avatars and iOS notification avatars. Both load the relay's small
thumbnail, which was always a JPEG with no alpha channel.

[block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays
write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside
the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP
first.

- `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then
`.thumb.jpg`, then the original. Only a 404 moves on to the next source;
any other failure still shows the error widget. Missing thumbnails are
remembered as before, so later builds skip straight to the source that
exists.
- `BuzzNotificationAvatarLoader` follows the same order for notification
avatars.

Relays without 9003, and images uploaded before it, keep rendering from
the JPEG as they do today.

### Screenshots

A transparent PNG avatar rendered by `MediaImage` on the same relay,
before and after (widget harness on an iOS simulator).

| Before | After |
| --- | --- |
| ![Before: black square behind the
avatar](https://github.com/user-attachments/assets/5da0a704-df15-4917-9d5c-9745bc83224d)
| ![After: transparent
corners](https://github.com/user-attachments/assets/3d31f4d4-cf65-4923-b367-4fd8cf3af679)
|

### Related issue

AIDA-2308 (internal). The desktop counterpart is
[block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477).

### Testing

- `missing thumbnails fall back to the JPEG, then the original`
(`avatar_image_test.dart`): when the relay 404s every thumbnail, the
avatar requests the WebP, then the JPEG, then the original. When the
WebP fails with a 500, the avatar shows its fallback and requests
neither the JPEG nor the original.
- The relay thumbnail journey tests run against a relay that 404s
`.thumb.webp`, so they cover the older-relay path end to end.
- `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG
thumbnail order for relay media, and that non-relay media gets no
thumbnails.

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

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
John Matthew Tennant
2026-11-05 21:32:58 -05:00
committed by GitHub
parent a1efbf63bd
commit b894fb02ff
5 changed files with 99 additions and 60 deletions

View File

@@ -54,11 +54,19 @@ public final class BuzzNotificationAvatarLoader {
completion(.failure(BuzzNotificationCacheError.invalidRecord))
return
}
let thumbnail = Self.thumbnailURL(original, scope: scope)
fetch(thumbnail ?? original, scope: scope) { result in
if thumbnail != nil, case .failure(BuzzNotificationDownload.Failure.status(404)) = result {
// Older or externally populated media may not have a generated thumbnail.
self.fetch(original, scope: scope, completion: completion)
fetchFirst(
Self.thumbnailURLs(original, scope: scope) + [original], scope: scope, completion: completion)
}
/// Fetches the first of `urls` the relay has: older relays, and media uploaded before WebP
/// thumbnails, have only the JPEG one, and externally populated media may have neither.
private func fetchFirst(
_ urls: [URL], scope: BuzzNotificationCacheScope,
completion: @escaping (Result<Data, Error>) -> Void
) {
fetch(urls[0], scope: scope) { result in
if urls.count > 1, case .failure(BuzzNotificationDownload.Failure.status(404)) = result {
self.fetchFirst(Array(urls.dropFirst()), scope: scope, completion: completion)
} else {
completion(result)
}
@@ -173,14 +181,17 @@ public final class BuzzNotificationAvatarLoader {
return components.url
}
static func thumbnailURL(_ url: URL, scope: BuzzNotificationCacheScope) -> URL? {
guard isRelayMedia(url, scope: scope) else { return nil }
/// The relay's thumbnails, best first: the WebP keeps transparency; every relay writes the JPEG.
static func thumbnailURLs(_ url: URL, scope: BuzzNotificationCacheScope) -> [URL] {
guard isRelayMedia(url, scope: scope) else { return [] }
let parts = url.lastPathComponent.split(separator: ".")
guard let hash = parts.first, hash.count == 64,
VerifiedNostrEvent.hexBytes(String(hash))?.count == 32,
parts.count <= 2
else { return nil }
return url.deletingLastPathComponent().appendingPathComponent("\(hash).thumb.jpg")
else { return [] }
return ["webp", "jpg"].map {
url.deletingLastPathComponent().appendingPathComponent("\(hash).thumb.\($0)")
}
}
private static func isRelayMedia(_ url: URL, scope: BuzzNotificationCacheScope) -> Bool {

View File

@@ -39,7 +39,9 @@ struct BuzzNotificationAvatarLoaderTests {
id: "community", name: "Test", relayUrl: relay, pubkey: "account", policies: [],
notificationSessionID: "session")))
let url = try #require(URL(string: "\(media)/media/\(hash).png"))
#expect((BuzzNotificationAvatarLoader.thumbnailURL(url, scope: scope) != nil) == expected)
#expect(
BuzzNotificationAvatarLoader.thumbnailURLs(url, scope: scope).map(\.lastPathComponent)
== (expected ? ["\(hash).thumb.webp", "\(hash).thumb.jpg"] : []))
}
}

View File

@@ -368,19 +368,23 @@ class MediaImageCooldownException implements Exception {
final _relayBlobPath = RegExp(r'^/media/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$');
/// The relay's generated thumbnail for a relay-hosted image, or null when
/// [url] is not one. Every image uploaded to a relay gets a JPEG thumbnail at
/// `/media/<sha256>.thumb.jpg`, at most 320px on its longer side. This
/// matches desktop's small avatars (buzz-app `mediaUrl`), including the cost:
/// the thumbnail is a still JPEG, so transparent areas render black and
/// animation is lost.
String? relayThumbnailUrl(String url, MediaGetAuthService auth) {
if (!auth.isRelayMediaUrl(url)) return null;
/// The relay's generated thumbnails for a relay-hosted image, best first, or
/// empty when [url] is not one. Relays write `/media/<sha256>.thumb.webp`,
/// which keeps transparency, beside the `.thumb.jpg` that every relay
/// writes. Older relays, and images uploaded before WebP thumbnails, have
/// only the JPEG. Both are at most 320px on their longer side and still, so
/// animation is lost. This matches desktop's small avatars (buzz-app
/// `mediaUrl`).
List<String> relayThumbnailUrls(String url, MediaGetAuthService auth) {
if (!auth.isRelayMediaUrl(url)) return const [];
final uri = Uri.parse(url);
if (uri.hasQuery || uri.hasFragment) return null;
if (uri.hasQuery || uri.hasFragment) return const [];
final match = _relayBlobPath.firstMatch(uri.path);
if (match == null) return null;
return uri.replace(path: '/media/${match[1]}.thumb.jpg').toString();
if (match == null) return const [];
return [
for (final ext in ['webp', 'jpg'])
uri.replace(path: '/media/${match[1]}.thumb.$ext').toString(),
];
}
/// Drop-in replacement for the media `Image.network` call sites.
@@ -397,7 +401,7 @@ String? relayThumbnailUrl(String url, MediaGetAuthService auth) {
///
/// With [preferThumbnail], a relay image laid out small enough loads the
/// relay's generated thumbnail instead of the original upload, falling back
/// to the original when the relay has no thumbnail for it.
/// to the JPEG thumbnail and then the original when the relay has none.
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
@@ -475,27 +479,31 @@ class MediaImage extends ConsumerWidget {
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
final candidate =
final thumbnails =
preferThumbnail && _fitsThumbnail(context, constraints)
? relayThumbnailUrl(url, auth)
: null;
final thumbnail = _missingThumbnails.contains(candidate)
? null
: candidate;
if (thumbnail == null) return _image(providerFor(url), cacheWidth);
return _image(
providerFor(thumbnail, isThumbnailFallback: true),
cacheWidth,
errorBuilder: (context, error, stackTrace) {
if (error is! NetworkImageLoadException ||
error.statusCode != 404) {
return errorBuilder!(context, error, stackTrace);
}
// The fetch already recorded the missing thumbnail, so later
// builds go straight to the original.
return _image(providerFor(url), cacheWidth);
},
);
? relayThumbnailUrls(url, auth)
: const <String>[];
Widget source(Iterable<String> candidates) {
final rest = candidates
.skipWhile(_missingThumbnails.contains)
.toList();
if (rest.isEmpty) return _image(providerFor(url), cacheWidth);
return _image(
providerFor(rest.first, isThumbnailFallback: true),
cacheWidth,
errorBuilder: (context, error, stackTrace) {
if (error is! NetworkImageLoadException ||
error.statusCode != 404) {
return errorBuilder!(context, error, stackTrace);
}
// The fetch already recorded the missing thumbnail, so later
// builds go straight to the next source.
return source(rest.skip(1));
},
);
}
return source(thumbnails);
},
);
}

View File

@@ -73,29 +73,33 @@ void main() {
});
});
group('relayThumbnailUrl', () {
group('relayThumbnailUrls', () {
final hash = 'ab' * 32;
final auth = _auth();
test('maps relay blobs of any format to the thumbnail', () {
for (final ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '']) {
expect(
relayThumbnailUrl('$_relayBase/media/$hash$ext', auth),
'$_relayBase/media/$hash.thumb.jpg',
);
}
});
test(
'maps relay blobs of any format to the WebP, then JPEG, thumbnail',
() {
for (final ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '']) {
expect(relayThumbnailUrls('$_relayBase/media/$hash$ext', auth), [
'$_relayBase/media/$hash.thumb.webp',
'$_relayBase/media/$hash.thumb.jpg',
]);
}
},
);
test('leaves everything else alone', () {
for (final url in [
'https://elsewhere.example/media/$hash.jpg',
'$_relayBase/media/$hash.thumb.jpg',
'$_relayBase/media/$hash.thumb.webp',
'$_relayBase/media/$hash.jpg?v=2',
'$_relayBase/media/not-a-hash.jpg',
'$_relayBase/avatars/$hash.jpg',
'$_relayBase/media/$hash.JPG',
]) {
expect(relayThumbnailUrl(url, auth), isNull, reason: url);
expect(relayThumbnailUrls(url, auth), isEmpty, reason: url);
}
});
});

View File

@@ -109,10 +109,12 @@ void main() {
final provider = (image.image as ResizeImage).imageProvider;
return (provider as MediaImageProvider).url;
});
expect(shown, ['$relay/media/$hash.thumb.jpg', original]);
expect(shown, ['$relay/media/$hash.thumb.webp', original]);
});
testWidgets('a missing thumbnail falls back to the original', (tester) async {
testWidgets('missing thumbnails fall back to the JPEG, then the original', (
tester,
) async {
const relay = 'https://relay.example';
final missing = '$relay/media/${'cd' * 32}.jpg';
final broken = '$relay/media/${'ef' * 32}.jpg';
@@ -157,9 +159,19 @@ void main() {
await tester.pump();
}
String thumb(String url) => url.replaceFirst('.jpg', '.thumb.jpg');
// A 404 retries the original; any other failure shows the fallback.
expect(requested, containsAll([thumb(missing), missing, thumb(broken)]));
String thumb(String url, String ext) =>
url.replaceFirst('.jpg', '.thumb.$ext');
// A 404 tries the next source; any other failure shows the fallback.
expect(
requested,
containsAll([
thumb(missing, 'webp'),
thumb(missing, 'jpg'),
missing,
thumb(broken, 'webp'),
]),
);
expect(requested, isNot(contains(thumb(broken, 'jpg'))));
expect(requested, isNot(contains(broken)));
expect(find.text('B'), findsOneWidget);
});
@@ -213,7 +225,7 @@ void main() {
expect(MediaImage.debugMissingThumbnailCount, 256);
expect(
MediaImageProvider.debugCooldownUrls.where(
(url) => url.endsWith('.thumb.jpg'),
(url) => url.contains('.thumb.'),
),
isEmpty,
);
@@ -243,9 +255,11 @@ void main() {
}) async {
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.resetDevicePixelRatio);
final client = http_testing.MockClient((request) {
final client = http_testing.MockClient((request) async {
final url = request.url.toString();
requested.add(url);
// These journeys run against a relay from before WebP thumbnails.
if (url.endsWith('.thumb.webp')) return http.Response('', 404);
return respond(url);
});
await tester.pumpWidget(
@@ -306,7 +320,7 @@ void main() {
fallback: const Text('M'),
),
);
expect(requested, [thumbnail]);
expect(requested, [thumbnail.replaceFirst('.jpg', '.webp'), thumbnail]);
await show(tester, const SizedBox());
slow404.complete(http.Response('', 404));
await show(tester, const SizedBox());