fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150)

On iOS, the reactions sheet (long-press a reaction pill) could show a
custom emoji as truncated `:shortcode:` text, for example `:eye…`, even
though the same emoji rendered as an image on the message pill a moment
before.

The sheet is a native UIKit view that downloaded each custom emoji again
with its own `URLSession` request and showed the shortcode label until
that download finished, and for good when it failed. A shortcode is
illegible in a chip that size.

Now:

- Flutter keeps the encoded bytes of custom emoji it has downloaded (a
bounded LRU: 512 KiB per emoji, 8 MiB total, rebuilt when the media auth
scope changes so accounts never share bytes) and hands them to the
sheet, which decodes them without a fetch. Emoji the timeline never
loaded still download natively as before.
- A custom emoji in the sheet never shows its shortcode: it stays blank
while loading and shows the sparkles icon, matching the native emoji
picker, if the image cannot load. Reaction pills in Flutter get the same
sparkles fallback. Inline emoji in message text keep the shortcode
fallback, where it reads as text. Avatars in the sheet keep their
initial.

### Before / after

Reactions sheet header and chips right after opening, on a message with
two custom-emoji reactions (synthetic test channel, captured from a 30
fps screen recording).

| Before (main) | After (this branch) |
| --- | --- |
| <img width="300" alt="Before: custom emoji chips show truncated
:qa-... shortcodes"
src="https://github.com/user-attachments/assets/593e555b-9c82-4eec-9049-3d909f987c76">
| <img width="300" alt="After: custom emoji chips show images"
src="https://github.com/user-attachments/assets/a6739997-b39f-4062-9e5c-d22a8a38b368">
|

### Validation

- Added tests: the native sheet's payload carries the bytes the pill
already downloaded; the combined reaction tray shows sparkles, not the
shortcode, when a custom emoji fails to load (the existing alignment
test previously asserted the shortcode).
- Full mobile suite and `just mobile-check` pass; the iOS simulator
build compiles.
- Automated simulator run (iPhone 17 Pro, iOS 27, main vs this branch,
signed in to a test community): channel list, opening a channel, the
message action sheet, the emoji picker, and the reactions sheet on a
message carrying two custom emoji I added plus a thumbs-up. On main the
sheet's custom-emoji chips show truncated `:qa-...` shortcodes for a few
frames before the images arrive; on this branch they render as images
from the first frame (see Before / after). Limitations: the emoji images
load fine over the network in this setup, so the failed-download
sparkles fallback was covered by tests only, not seen in the app; a long
press on the combined reaction tray opened the message menu in the
simulator, a tap opened the sheet.

🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-09 06:59:03 -08:00
committed by GitHub
parent 40cfa72cd4
commit ac2c5831f3
5 changed files with 191 additions and 17 deletions

View File

@@ -1,3 +1,4 @@
import Flutter
import UIKit
import ImageIO
import CoreText
@@ -202,7 +203,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
button.accessibilityLabel = reaction.map { "\($0["label"] as? String ?? emoji ?? "") \(title)" } ?? title
if selected { button.accessibilityTraits.insert(.selected) }
if let reaction {
let glyph = NativeMessageGlyph(data: reaction, size: 22)
let glyph = NativeMessageGlyph(data: reaction, size: 22, isReaction: true)
glyph.isUserInteractionEnabled = false
glyph.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(glyph)
@@ -326,7 +327,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
let emoji = NativeMessageGlyph(data: reaction, size: 26)
let emoji = NativeMessageGlyph(data: reaction, size: 26, isReaction: true)
let row = UIStackView(arrangedSubviews: [avatar, label, emoji])
row.alignment = .center
row.spacing = 12
@@ -357,14 +358,21 @@ private final class NativeReactionCloseButton: UIButton {
/// Short-lived image loader. Auth is supplied for the specific URL by Buzz's
/// existing media auth service; redirects are refused to avoid forwarding it.
/// Encoded `bytes` Flutter already downloaded are shown without a fetch. A
/// custom emoji ([isReaction] with a `url`) never shows its `:shortcode:` text,
/// which is illegible at this size: it stays blank while loading and shows
/// sparkles, like the native emoji picker, if the image cannot be loaded. An
/// avatar keeps its initial behind the image.
final class NativeMessageGlyph: UIView {
private var cancelLoad: (() -> Void)?
private let label = UILabel()
private let image = UIImageView()
init(data: [String: Any], size: CGFloat) {
init(data: [String: Any], size: CGFloat, isReaction: Bool = false) {
super.init(frame: .zero)
label.text = data["emoji"] as? String
let raw = data["url"] as? String
let customEmoji = isReaction && raw != nil
label.text = customEmoji ? nil : data["emoji"] as? String
label.textAlignment = .center
label.font = .systemFont(ofSize: size)
label.adjustsFontSizeToFitWidth = true
@@ -380,18 +388,39 @@ final class NativeMessageGlyph: UIView {
child.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
guard let raw = data["url"] as? String, let url = URL(string: raw),
["https", "http"].contains(url.scheme?.lowercased() ?? "") else { return }
guard let raw else { return }
let fallback = { [weak self] in
guard let self, customEmoji else { return }
image.image = NativeTabler.image("sparkles", size: size)
image.tintColor = .secondaryLabel
image.contentMode = .center
}
if let bytes = data["bytes"] as? FlutterStandardTypedData {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let decoded = NativeMessageImageLoader.decode(bytes.data)
DispatchQueue.main.async {
guard let self else { return }
if let decoded { self.show(decoded) } else { fallback() }
}
}
return
}
guard let url = URL(string: raw),
["https", "http"].contains(url.scheme?.lowercased() ?? "") else { fallback(); return }
var request = URLRequest(url: url)
request.allHTTPHeaderFields = data["headers"] as? [String: String]
request.timeoutInterval = 10
cancelLoad = NativeMessageImageLoader.shared.load(request) { [weak self] loaded in
guard let self, let loaded else { return }
image.image = UIAccessibility.isReduceMotionEnabled ? loaded.images?.first ?? loaded : loaded
label.isHidden = true
guard let self else { return }
if let loaded { show(loaded) } else { fallback() }
}
}
private func show(_ loaded: UIImage) {
image.image = UIAccessibility.isReduceMotionEnabled ? loaded.images?.first ?? loaded : loaded
label.isHidden = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
@@ -527,7 +556,7 @@ final class NativeMessageImageLoader: NSObject, URLSessionDataDelegate {
completionHandler(nil) // Never forward supplied auth to another URL.
}
private static func decode(_ bytes: Data) -> UIImage? {
static func decode(_ bytes: Data) -> UIImage? {
guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
let count = CGImageSourceGetCount(source)
let step = max(1, Int(ceil(Double(count) / 60)))

View File

@@ -449,6 +449,7 @@ class _ReactionEmoji extends StatelessWidget {
shortcode: shortcode,
url: emojiUrl,
size: size,
compact: true,
),
),
);
@@ -811,6 +812,7 @@ Future<bool> _showNativeReactionDetails(
(_, _) => scheduleProfiles(),
);
final dataset = container.read(emojiDatasetOrEmptyProvider);
final emojiBytes = container.read(customEmojiBytesCacheProvider);
List<Map<String, Object?>> reactionData() => [
for (final reaction in reactions.value)
{
@@ -823,6 +825,10 @@ Future<bool> _showNativeReactionDetails(
if (reaction.emojiUrl case final String url) ...{
'url': url,
'headers': nativeMediaHeaders(url),
// The timeline usually downloaded this emoji already; reuse it so
// the sheet never depends on a second fetch succeeding.
if (emojiBytes.lookup(url) case final Uint8List bytes)
'bytes': bytes,
},
},
];

View File

@@ -1,7 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import '../theme/buzz_icons.dart';
import 'custom_emoji.dart';
@@ -9,27 +12,68 @@ import 'custom_emoji.dart';
/// Roughly matches a line of body text so emoji sit on the baseline cleanly.
const double kCustomEmojiInlineSize = 20.0;
/// Encoded bytes of custom emoji that [CustomEmojiImage] has downloaded, so a
/// native surface such as the iOS reactions sheet shows an emoji the timeline
/// already displays without fetching it again. Least recently used entries
/// are evicted past [maxTotalBytes]; an emoji over [maxEntryBytes] is not kept.
class CustomEmojiBytesCache {
static const maxEntryBytes = 512 * 1024;
static const maxTotalBytes = 8 * 1024 * 1024;
final _entries = <String, Uint8List>{};
var _totalBytes = 0;
void remember(String url, Uint8List bytes) {
if (bytes.lengthInBytes > maxEntryBytes) return;
final previous = _entries.remove(url);
if (previous != null) _totalBytes -= previous.lengthInBytes;
_entries[url] = bytes;
_totalBytes += bytes.lengthInBytes;
while (_totalBytes > maxTotalBytes) {
final oldest = _entries.keys.first;
_totalBytes -= _entries.remove(oldest)!.lengthInBytes;
}
}
Uint8List? lookup(String url) {
final bytes = _entries.remove(url);
if (bytes != null) _entries[url] = bytes;
return bytes;
}
}
/// Rebuilt with the media auth scope, like [MediaImageProvider]'s cache key,
/// so one account's downloads never reach another account's surfaces.
final customEmojiBytesCacheProvider = Provider<CustomEmojiBytesCache>((ref) {
ref.watch(mediaGetAuthServiceProvider);
return CustomEmojiBytesCache();
});
/// A single custom-emoji image rendered as a square, network-loaded glyph.
///
/// Reused everywhere a `:shortcode:` resolves to an image: inline in message
/// bodies, reaction pills, status, and the picker. Falls back to the literal
/// `:shortcode:` text if the image fails to load, so a broken URL never leaves
/// a blank gap.
class CustomEmojiImage extends StatelessWidget {
/// bodies, reaction pills, status, and the picker. If the image fails to load,
/// it falls back to the literal `:shortcode:` text, so a broken URL never
/// leaves a blank gap in prose; a [compact] glyph, such as a reaction pill,
/// shows a sparkles icon instead, because a truncated shortcode is illegible.
class CustomEmojiImage extends ConsumerWidget {
final String shortcode;
final String url;
final double size;
final bool compact;
const CustomEmojiImage({
super.key,
required this.shortcode,
required this.url,
this.size = kCustomEmojiInlineSize,
this.compact = false,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fallbackStyle = DefaultTextStyle.of(context).style;
final bytesCache = ref.watch(customEmojiBytesCacheProvider);
return MediaImage(
url: url,
width: size,
@@ -38,7 +82,15 @@ class CustomEmojiImage extends StatelessWidget {
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
onBytesLoaded: (bytes) => bytesCache.remember(url, bytes),
errorBuilder: (_, _, _) => compact
? Icon(
BuzzIcons.sparkles,
size: size,
color: fallbackStyle.color?.withValues(alpha: 0.6),
semanticLabel: ':$shortcode:',
)
: Text(':$shortcode:', style: fallbackStyle),
);
}
}

View File

@@ -809,6 +809,91 @@ void main() {
},
);
testWidgets(
'native sheet reuses custom emoji bytes the pill already downloaded',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const shortcode = ':partyparrot:';
final png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
);
var fetches = 0;
Map<Object?, Object?>? payload;
final completion = Completer<Map<String, Object?>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
(call) async {
if (call.method == 'reactions') {
payload = call.arguments as Map<Object?, Object?>;
return completion.future;
}
return <String, Object?>{};
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
NativeMessagePresentation.channel,
null,
),
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
mediaHttpClientProvider.overrideWithValue(
MockClient((request) async {
fetches++;
return http.Response.bytes(png, 200);
}),
),
],
child: ReactionRow(
messageId: _messageId,
channelId: 'channel',
reactions: const [
TimelineReaction(
emoji: shortcode,
count: 1,
reactedByCurrentUser: false,
userPubkeys: [_alice],
emojiUrl: 'https://relay.example/media/party.png',
),
],
onToggle: (_) {},
),
),
);
await tester.pumpAndSettle();
final source = tester.widget<Image>(
find.descendant(
of: find.byType(CustomEmojiImage),
matching: find.byType(Image),
),
);
await tester.runAsync(
() => precacheImage(
source.image,
tester.element(find.byType(CustomEmojiImage)),
),
);
await tester.pumpAndSettle();
expect(fetches, 1);
await tester.longPress(
find.byKey(const ValueKey('reaction-pill-$shortcode')),
);
await tester.pumpAndSettle();
final reaction = (payload!['reactions'] as List).single as Map;
expect(reaction['url'], 'https://relay.example/media/party.png');
expect(reaction['bytes'], png);
completion.complete(<String, Object?>{});
await tester.pumpAndSettle();
debugDefaultTargetPlatformOverride = null;
},
);
group('ReactionRow', () {
testWidgets('shows the count even at one, matching desktop', (
tester,

View File

@@ -172,7 +172,9 @@ void combinedReactionTests() {
);
expect(image.image, isNotNull);
} else {
expect(find.text(':custom:'), findsOneWidget);
// A truncated shortcode is illegible in a pill: show sparkles.
expect(find.byIcon(BuzzIcons.sparkles), findsOneWidget);
expect(find.text(':custom:'), findsNothing);
}
final tray = find.byKey(const ValueKey('combined-reaction-tray'));
final center = tester.getCenter(tray).dy;