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;