From 6af20bc4c226bb79f915121d0c3c5e006c91e4cb Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 14 Sep 2026 14:42:48 -0700 Subject: [PATCH] perf(mobile): retain unchanged Markdown components Signed-off-by: Tom Brow --- .../features/channels/message_content.dart | 97 +++--- .../message_content/inline_components.dart | 136 ++++++++ .../message_content_stability_test.dart | 291 ++++++++++++++++++ 3 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/inline_components.dart create mode 100644 mobile/test/features/channels/message_content_stability_test.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index f2cccbaa237..07d4f86a141 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -33,6 +34,7 @@ import 'message_media.dart'; import 'voice_note_attachment.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/inline_components.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; @@ -176,13 +178,33 @@ class MessageContent extends HookConsumerWidget { in ref.watch(channelsProvider).asData?.value ?? const []) channel.name.toLowerCase(): channel.id, }; - final resolvedChannelTap = - onChannelTap ?? - (String channelId) { + final channelHandler = useRef(onChannelTap)..value = onChannelTap; + final resolvedChannelTap = useMemoized( + () => (String channelId) { + final handler = channelHandler.value; + if (handler != null) { + handler(channelId); + } else { ref .read(pendingDeepLinkProvider.notifier) .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); - }; + } + }, + const [], + ); + final replyHandler = useRef(onMediaReply)..value = onMediaReply; + final moreHandler = useRef(onMediaMore)..value = onMediaMore; + final mediaReply = useMemoized( + () => + () => replyHandler.value?.call(), + const [], + ); + final mediaMore = useMemoized( + () => + (BuildContext context, String url) => + moreHandler.value?.call(context, url), + const [], + ); final channelPresentationKey = [ for (final entry in (resolvedChannelNames.entries.toList() @@ -265,7 +287,23 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [linkNormalizedContent, resolvedMentionNames]); + }, [linkNormalizedContent, mentionPresentationKey]); + + final inlineComponents = _useMessageInlineComponents( + content: content, + finalContent: finalContent, + mentionNames: resolvedMentionNames, + bindings: mentionBindings, + agentPubkeys: resolvedAgentMentionPubkeys, + channelNames: resolvedChannelNames, + customEmoji: customEmoji, + emojiSize: inlineCustomEmojiSize, + tags: tags, + hasMediaReply: onMediaReply != null, + hasMediaMore: onMediaMore != null, + onMentionTap: onMentionTap, + onChannelTap: resolvedChannelTap, + ); final markdown = KeyedSubtree( key: ValueKey( @@ -291,35 +329,16 @@ class MessageContent extends HookConsumerWidget { resolvedChannelTap, resolvedChannelNames, ), - imageBuilder: (context, imageUrl, _, _) => - _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + imageBuilder: (context, imageUrl, _, _) => _buildMedia( + context, + imageUrl, + imetaByUrl[imageUrl], + onReply: onMediaReply == null ? null : mediaReply, + onMore: onMediaMore == null ? null : mediaMore, + ), textAlign: textAlign, maxLines: maxLines, - inlineComponents: [ - _MentionMd( - mentionNames: resolvedMentionNames, - bindings: mentionBindings, - displayLabels: { - for (final range in mentionOccurrences( - content, - mentionBindings.keys, - )) - range.label: content.substring(range.start + 1, range.end), - }, - agentMentionPubkeys: resolvedAgentMentionPubkeys, - onMentionTap: onMentionTap, - ), - CustomEmojiMd( - customEmoji, - content: finalContent, - size: inlineCustomEmojiSize, - ), - _ChannelLinkMd( - channelNames: resolvedChannelNames, - onChannelTap: resolvedChannelTap, - ), - ...MarkdownComponent.inlineComponents, - ], + inlineComponents: inlineComponents, ), ); if (trailingGallery == null) return markdown; @@ -343,7 +362,13 @@ class MessageContent extends HookConsumerWidget { ); } - Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) { + Widget _buildMedia( + BuildContext context, + String imageUrl, + ImetaEntry? imeta, { + VoidCallback? onReply, + MediaViewerMoreAction? onMore, + }) { final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta); if (mediaKind == MessageMediaKind.audio) { return Padding( @@ -360,15 +385,15 @@ class MessageContent extends HookConsumerWidget { return _MessageVideoPreview( url: imageUrl, imeta: imeta, - onReply: onMediaReply, + onReply: onReply, ); } return _MessageImagePreview( url: imageUrl, imeta: imeta, semanticLabel: imeta?.alt ?? 'Message image', - onReply: onMediaReply, - onMore: onMediaMore, + onReply: onReply, + onMore: onMore, ); } diff --git a/mobile/lib/features/channels/message_content/inline_components.dart b/mobile/lib/features/channels/message_content/inline_components.dart new file mode 100644 index 00000000000..6e17db769e5 --- /dev/null +++ b/mobile/lib/features/channels/message_content/inline_components.dart @@ -0,0 +1,136 @@ +part of '../message_content.dart'; + +List _useMessageInlineComponents({ + required String content, + required String finalContent, + required Map mentionNames, + required Map> bindings, + required Set agentPubkeys, + required Map channelNames, + required List customEmoji, + required double emojiSize, + required List> tags, + required bool hasMediaReply, + required bool hasMediaMore, + required void Function(String)? onMentionTap, + required void Function(String) onChannelTap, +}) { + // Parsed spans retain these callbacks. Forward to the current handlers without + // invalidating the parser for a new closure from an otherwise unchanged row. + final mentionHandler = useRef(onMentionTap)..value = onMentionTap; + final channelHandler = useRef(onChannelTap)..value = onChannelTap; + final mentionTap = useMemoized( + () => + (String pubkey) => mentionHandler.value?.call(pubkey), + const [], + ); + final channelTap = useMemoized( + () => + (String channelId) => channelHandler.value(channelId), + const [], + ); + final inputs = _InlineComponentInputs( + content: content, + finalContent: finalContent, + mentionNames: mentionNames, + bindings: bindings, + agentPubkeys: agentPubkeys, + channelNames: channelNames, + customEmoji: customEmoji, + emojiSize: emojiSize, + tags: tags, + hasMediaReply: hasMediaReply, + hasMediaMore: hasMediaMore, + hasMentionHandler: onMentionTap != null, + ); + + // gpt_markdown compares components by identity. Recreating them on every + // parent rebuild reparses unchanged message bodies on the UI isolate. + return useMemoized( + () => [ + _MentionMd( + mentionNames: inputs.mentionNames, + bindings: inputs.bindings, + displayLabels: { + for (final range in mentionOccurrences(content, bindings.keys)) + range.label: content.substring(range.start + 1, range.end), + }, + agentMentionPubkeys: inputs.agentPubkeys, + onMentionTap: inputs.hasMentionHandler ? mentionTap : null, + ), + CustomEmojiMd(inputs.customEmoji, content: finalContent, size: emojiSize), + _ChannelLinkMd( + channelNames: inputs.channelNames, + onChannelTap: channelTap, + ), + ...MarkdownComponent.inlineComponents, + ], + [inputs], + ); +} + +class _InlineComponentInputs { + final String content; + final String finalContent; + final Map mentionNames; + final Map> bindings; + final Set agentPubkeys; + final Map channelNames; + final List customEmoji; + final double emojiSize; + final bool hasMentionHandler; + final List> tags; + final bool hasMediaReply; + final bool hasMediaMore; + + _InlineComponentInputs({ + required this.content, + required this.finalContent, + required Map mentionNames, + required Map> bindings, + required Set agentPubkeys, + required Map channelNames, + required List customEmoji, + required this.emojiSize, + required this.hasMentionHandler, + required List> tags, + required this.hasMediaReply, + required this.hasMediaMore, + }) : tags = List.unmodifiable( + tags.map((tag) => List.unmodifiable(tag)), + ), + mentionNames = Map.unmodifiable(mentionNames), + bindings = Map.unmodifiable({ + for (final entry in bindings.entries) + entry.key: Set.unmodifiable(entry.value), + }), + agentPubkeys = Set.unmodifiable(agentPubkeys), + channelNames = Map.unmodifiable(channelNames), + customEmoji = List.unmodifiable(customEmoji); + + @override + bool operator ==(Object other) => + other is _InlineComponentInputs && + content == other.content && + finalContent == other.finalContent && + emojiSize == other.emojiSize && + hasMentionHandler == other.hasMentionHandler && + hasMediaReply == other.hasMediaReply && + hasMediaMore == other.hasMediaMore && + tags.length == other.tags.length && + tags.indexed.every( + (entry) => listEquals(entry.$2, other.tags[entry.$1]), + ) && + mapEquals(mentionNames, other.mentionNames) && + mapEquals(channelNames, other.channelNames) && + setEquals(agentPubkeys, other.agentPubkeys) && + listEquals(customEmoji, other.customEmoji) && + bindings.length == other.bindings.length && + bindings.entries.every( + (entry) => setEquals(entry.value, other.bindings[entry.key]), + ); + + @override + int get hashCode => + Object.hash(content, finalContent, emojiSize, hasMentionHandler); +} diff --git a/mobile/test/features/channels/message_content_stability_test.dart b/mobile/test/features/channels/message_content_stability_test.dart new file mode 100644 index 00000000000..3499cc18ca6 --- /dev/null +++ b/mobile/test/features/channels/message_content_stability_test.dart @@ -0,0 +1,291 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/emoji/emoji_only.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +class _Fixture { + final revision = ValueNotifier(0); + String content = 'Hello @Alice, visit #sample.'; + Map mentions = {'alice-key': 'Alice'}; + final channels = {'sample': 'channel-one'}; + Set agents = {}; + List> tags = []; + List palette = []; + bool scaleEmoji = false; + bool mentionHandler = true; + bool mediaHandlers = false; + int? tappedRevision; + String? tappedId; + + Widget build() => ProviderScope( + overrides: [customEmojiListProvider.overrideWith((ref) => palette)], + child: MaterialApp( + theme: AppTheme.light(), + home: AppMarkdownTheme( + child: Scaffold( + body: ValueListenableBuilder( + valueListenable: revision, + builder: (_, generation, _) => MessageContent( + content: content, + mentionNames: Map.of(mentions), + channelNames: Map.of(channels), + agentMentionPubkeys: Set.of(agents), + tags: tags, + scaleEmojiOnly: scaleEmoji, + onMediaReply: mediaHandlers + ? () { + tappedRevision = generation; + tappedId = 'reply'; + } + : null, + onMediaMore: mediaHandlers + ? (_, url) { + tappedRevision = generation; + tappedId = url; + } + : null, + onMentionTap: mentionHandler + ? (id) { + tappedRevision = generation; + tappedId = id; + } + : null, + onChannelTap: (id) { + tappedRevision = generation; + tappedId = id; + }, + ), + ), + ), + ), + ), + ); + + Future refresh(WidgetTester tester) async { + revision.value++; + await tester.pump(); + } +} + +List _components(WidgetTester tester) => tester + .widget(find.byType(GptMarkdown).first) + .inlineComponents!; + +void main() { + testWidgets('explicit channel links retain the current callback', ( + tester, + ) async { + const id = '580ca78b-9dae-46f3-8854-bd671853ba32'; + final fixture = _Fixture()..content = '[Open channel](buzz://channel/$id)'; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await fixture.refresh(tester); + expect(_components(tester), same(before)); + await tester.tap(find.text('Open channel', findRichText: true)); + expect(fixture.tappedId, id); + expect(fixture.tappedRevision, fixture.revision.value); + }); + + testWidgets( + 'inline media retains current actions and invalidates metadata and action presence', + (tester) async { + const url = 'https://relay.example/image.png'; + final fixture = _Fixture() + ..content = '![image]($url)\n\nAfter image' + ..mediaHandlers = true + ..tags = [ + ['imeta', 'url $url', 'm image/png', 'alt First label'], + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await fixture.refresh(tester); + expect(_components(tester), same(before)); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + var viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + viewer.onReply!(); + expect(fixture.tappedId, 'reply'); + expect(fixture.tappedRevision, fixture.revision.value); + viewer.onMore!(tester.element(find.byType(MediaImageViewerPage)), url); + expect(fixture.tappedId, url); + expect(fixture.tappedRevision, fixture.revision.value); + Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop(); + await tester.pumpAndSettle(); + fixture.tags[0][3] = 'alt Second label'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + expect(viewer.semanticLabel, 'Second label'); + Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop(); + await tester.pumpAndSettle(); + final beforeRemoval = _components(tester); + fixture.mediaHandlers = false; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(beforeRemoval))); + await tester.tap( + find.byKey(const ValueKey('message-media-image-preview:$url')), + ); + await tester.pumpAndSettle(); + viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + expect(viewer.onReply, isNull); + expect(viewer.onMore, isNull); + }, + ); + + testWidgets( + 'equal presentation retains parser components and current callbacks', + (tester) async { + final fixture = _Fixture(); + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + for (var i = 0; i < 3; i++) { + await fixture.refresh(tester); + // This is the production dependency seam: gpt_markdown's config.isSame + // compares these component identities before deciding whether to parse. + expect(_components(tester), same(before)); + } + await tester.tap(find.text('Alice')); + expect(fixture.tappedRevision, fixture.revision.value); + expect(fixture.tappedId, 'alice-key'); + await tester.tap(find.text('sample')); + expect(fixture.tappedRevision, fixture.revision.value); + expect(fixture.tappedId, 'channel-one'); + + fixture.channels['sample'] = 'channel-two'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap(find.text('sample')); + expect(fixture.tappedId, 'channel-two'); + }, + ); + + testWidgets( + 'mention labels, bindings, agent status and handler presence invalidate', + (tester) async { + final fixture = _Fixture(); + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + var before = _components(tester); + fixture.mentions['alice-key'] = 'ALICE'; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.text('ALICE'), findsOneWidget); + before = _components(tester); + fixture.agents.add('alice-key'); + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + before = _components(tester); + fixture.mentionHandler = false; + fixture.tappedId = null; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + await tester.tap(find.text('ALICE')); + expect(fixture.tappedId, isNull); + fixture.mentionHandler = true; + fixture.mentions = {'second-key': 'ALICE'}; + await fixture.refresh(tester); + await tester.tap(find.text('ALICE')); + expect(fixture.tappedId, 'second-key'); + expect(find.byIcon(LucideIcons.bot), findsNothing); + }, + ); + + testWidgets('signed qualified mention changes cannot retain an old binding', ( + tester, + ) async { + final key = 'a' * 64; + final fixture = _Fixture() + ..content = '@Alice ($key)' + ..mentions = {} + ..tags = [ + ['p', key], + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + final before = _components(tester); + await tester.tap(find.text('Alice (aaaaaaaa…aaaa)')); + expect(fixture.tappedId, key); + fixture.tags = []; + fixture.tappedId = null; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect(find.text('Alice (aaaaaaaa…aaaa)'), findsNothing); + }); + + testWidgets('community emoji palette and emoji-only size invalidate', ( + tester, + ) async { + final fixture = _Fixture() + ..content = ':sample:' + ..palette = [ + const CustomEmoji( + shortcode: 'sample', + url: 'https://relay.example/one.png', + ), + ]; + addTearDown(fixture.revision.dispose); + await tester.pumpWidget(fixture.build()); + var before = _components(tester); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/one.png', + ); + fixture.palette = [ + const CustomEmoji( + shortcode: 'sample', + url: 'https://relay.example/two.png', + ), + ]; + ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ).invalidate(customEmojiListProvider); + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/two.png', + ); + before = _components(tester); + fixture.scaleEmoji = true; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).size, + kEmojiOnlyCustomEmojiSize, + ); + before = _components(tester); + fixture.tags = [ + ['emoji', 'sample', 'https://relay.example/tag.png'], + ]; + await fixture.refresh(tester); + expect(_components(tester), isNot(same(before))); + expect( + tester.widget(find.byType(CustomEmojiImage)).url, + 'https://relay.example/tag.png', + ); + }); +}