From ac2c5831f32fd8b2e38f77a3a68de7a8701b11bd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 9 Nov 2026 06:59:03 -0800 Subject: [PATCH] fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) | | --- | --- | | Before: custom emoji chips show truncated
:qa-... shortcodes | After: custom emoji chips show images | ### 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 Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- mobile/ios/Runner/NativeReactionDetails.swift | 49 ++++++++--- .../lib/features/channels/reaction_row.dart | 6 ++ .../custom_emoji/custom_emoji_render.dart | 64 ++++++++++++-- .../features/channels/reaction_row_test.dart | 85 +++++++++++++++++++ .../reaction_row_test/combined_tests.dart | 4 +- 5 files changed, 191 insertions(+), 17 deletions(-) diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 637da5fffe..673cb9e15b 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -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))) diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 2cabcac4cc..5bf5e68b7b 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -449,6 +449,7 @@ class _ReactionEmoji extends StatelessWidget { shortcode: shortcode, url: emojiUrl, size: size, + compact: true, ), ), ); @@ -811,6 +812,7 @@ Future _showNativeReactionDetails( (_, _) => scheduleProfiles(), ); final dataset = container.read(emojiDatasetOrEmptyProvider); + final emojiBytes = container.read(customEmojiBytesCacheProvider); List> reactionData() => [ for (final reaction in reactions.value) { @@ -823,6 +825,10 @@ Future _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, }, }, ]; diff --git a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart index d193f006c0..e0b3e29001 100644 --- a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart @@ -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 = {}; + 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((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), ); } } diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 16b17e9227..20b9d10ba7 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -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? payload; + final completion = Completer>(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + return {}; + }, + ); + 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( + 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({}); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = null; + }, + ); + group('ReactionRow', () { testWidgets('shows the count even at one, matching desktop', ( tester, diff --git a/mobile/test/features/channels/reaction_row_test/combined_tests.dart b/mobile/test/features/channels/reaction_row_test/combined_tests.dart index 058f6c62fe..fa13dae6b9 100644 --- a/mobile/test/features/channels/reaction_row_test/combined_tests.dart +++ b/mobile/test/features/channels/reaction_row_test/combined_tests.dart @@ -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;