mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
perf(mobile): avoid reparsing unchanged Markdown (#7649)
When a channel screen updates, Buzz Mobile can parse and format a message again even when its text has not changed. This repeated work runs on the same thread that handles taps and updates the screen. The current Markdown library, `gpt_markdown` 1.2.1, checks whether the objects used to render mentions, emoji, and channel links have changed. Buzz creates new objects on every message rebuild, so the library parses the message again. This change reuses those objects while their inputs stay the same. Changes to labels, mention targets, emoji, media details, or available actions still update the message. Taps continue to use the latest action handlers. In a synthetic test with the production message widget, ten unchanged rebuilds caused 30 parser calls before this change and zero after it. The first render still requires parsing. This test measures repeated parsing, not overall screen speed. Validation: - All 91 focused message tests and 2,127 mobile tests passed. The new regression test fails with the original code. Tests also cover changed message data and current tap actions. - Static checks across the repository, desktop and Tauri tests, and desktop and web builds passed. The full local test run hit an existing Rust ACP keepalive timing failure. Its exact retry passed, and all checks skipped after that failure were run separately and passed. Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
part of '../message_content.dart';
|
||||
|
||||
List<MarkdownComponent> _useMessageInlineComponents({
|
||||
required String content,
|
||||
required String finalContent,
|
||||
required Map<String, String> mentionNames,
|
||||
required Map<String, Set<String>> bindings,
|
||||
required Set<String> agentPubkeys,
|
||||
required Map<String, String> channelNames,
|
||||
required List<CustomEmoji> customEmoji,
|
||||
required double emojiSize,
|
||||
required List<List<String>> 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<String, String> mentionNames;
|
||||
final Map<String, Set<String>> bindings;
|
||||
final Set<String> agentPubkeys;
|
||||
final Map<String, String> channelNames;
|
||||
final List<CustomEmoji> customEmoji;
|
||||
final double emojiSize;
|
||||
final bool hasMentionHandler;
|
||||
final List<List<String>> tags;
|
||||
final bool hasMediaReply;
|
||||
final bool hasMediaMore;
|
||||
|
||||
_InlineComponentInputs({
|
||||
required this.content,
|
||||
required this.finalContent,
|
||||
required Map<String, String> mentionNames,
|
||||
required Map<String, Set<String>> bindings,
|
||||
required Set<String> agentPubkeys,
|
||||
required Map<String, String> channelNames,
|
||||
required List<CustomEmoji> customEmoji,
|
||||
required this.emojiSize,
|
||||
required this.hasMentionHandler,
|
||||
required List<List<String>> tags,
|
||||
required this.hasMediaReply,
|
||||
required this.hasMediaMore,
|
||||
}) : tags = List.unmodifiable(
|
||||
tags.map((tag) => List<String>.unmodifiable(tag)),
|
||||
),
|
||||
mentionNames = Map.unmodifiable(mentionNames),
|
||||
bindings = Map.unmodifiable({
|
||||
for (final entry in bindings.entries)
|
||||
entry.key: Set<String>.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);
|
||||
}
|
||||
@@ -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<String, String> mentions = {'alice-key': 'Alice'};
|
||||
final channels = {'sample': 'channel-one'};
|
||||
Set<String> agents = {};
|
||||
List<List<String>> tags = [];
|
||||
List<CustomEmoji> 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<int>(
|
||||
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<void> refresh(WidgetTester tester) async {
|
||||
revision.value++;
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
List<MarkdownComponent> _components(WidgetTester tester) => tester
|
||||
.widget<GptMarkdown>(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 = '\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<MediaImageViewerPage>(
|
||||
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<MediaImageViewerPage>(
|
||||
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<MediaImageViewerPage>(
|
||||
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<CustomEmojiImage>(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<CustomEmojiImage>(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<CustomEmojiImage>(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<CustomEmojiImage>(find.byType(CustomEmojiImage)).url,
|
||||
'https://relay.example/tag.png',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user