Files
buzz/mobile/test/features/channels/message_content_test.dart
Mohammad Asfour 1a54eec3db fix(mobile): keep token labels visible in authored Markdown links (#7545)
## Summary

On iOS, an authored Markdown link renders as blank when its label is
recognized as a Buzz channel, mention, or custom emoji. For example:

```markdown
[#2959](https://example.com/)
```

The surrounding text remains visible, but the link has no visible label
or tap target.

Buzz's `_ChannelLinkMd`, `_MentionMd`, and `CustomEmojiMd` components
return `WidgetSpan`s. `gpt_markdown` also renders an authored link
through a `WidgetSpan` and reparses its label through the registered
components. Because the Buzz components were allowed to run inside link
labels, the result was a placeholder nested inside another placeholder,
which does not paint on iOS.

This PR adds the same scope restriction used by `gpt_markdown`'s
built-in link component:

```dart
@override
Set<MarkdownScope> get scopes =>
    MarkdownComponent.allScopesExceptLinkLabel;
```

The override is applied to all three widget-based components. Authored
labels such as `[#2959](url)`, `[@Alice](url)`, and `[👋](url)`
therefore remain ordinary link text, while the same tokens outside links
continue rendering as Buzz pills and custom emoji.

The PR also updates the shared test helpers to find `RichText`
subclasses. Since the upgrade to `gpt_markdown` 1.2.1, body paragraphs
can render through `BidiRichText`; `find.byType(RichText)` does not
match that subclass and could allow an absence assertion to pass without
inspecting the message text.

The handling of unknown bare `#tokens` is deliberately unchanged.
Whether those should remain non-interactive pills or become plain text
is a separate product decision.

### Related issue

Fixes #6124.

This supersedes #6135. That PR solves the same issue by maintaining a
custom fork of `gpt_markdown`'s link component; this PR instead uses the
scoped-component API already available in `gpt_markdown` 1.2.1.

### Testing

Focused suites:

```bash
flutter test \
  test/features/channels/message_content_test.dart \
  test/features/channels/message_content_custom_emoji_test.dart
```

Result: 95 tests passed, 0 failed.

Additional checks:

- `just mobile-check` — passed; analyzer reported no issues.
- `just mobile-fmt` — passed; 549 files formatted with 0 changes.
- Six new regression tests cover channel, mention, emoji, plain-link
control, and outside-link behavior.
- Removing the `_ChannelLinkMd` override causes two regression tests to
fail; removing `_MentionMd` or `CustomEmojiMd` causes one test to fail
in each case.
- Three tests in `message_content_test.dart` directly assert that the
nested-placeholder structure is absent. The custom-emoji tests
separately verify literal text and the absence or presence of
`CustomEmojiImage`, depending on whether the shortcode is inside or
outside a link.

The complete local mobile suite reports 15 failures that reproduce
identically on a clean checkout of `main` at `44316ff7`. In [CI for that
same commit](https://github.com/block/buzz/actions/runs/34238736818),
`Clients / Mobile`, `Mobile`, and `Mobile Swift Domain / Mobile Swift`
all passed.

No native-device screenshot is included. The nested-placeholder fix was
verified through the rendered widget structure, but an iOS simulator
build was blocked by the existing `MLImage.framework` arm64-simulator
incompatibility.

Signed-off-by: Mohammad Asfour <masfour700@gmail.com>
Signed-off-by: Samin Yeasar Sohag <sssohag172@gmail.com>
Co-authored-by: Samin Yeasar Sohag <sssohag172@gmail.com>
2026-10-21 10:24:32 -07:00

3262 lines
103 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
import 'package:buzz/features/channels/local_send_bubble_surface.dart';
import 'dart:async';
import 'dart:convert';
import 'package:http/testing.dart';
import 'package:buzz/shared/widgets/media_loading_placeholder.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:buzz/shared/widgets/page_indicator.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:http/http.dart' as http;
import 'package:just_audio/just_audio.dart' as audio;
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/features/channels/voice_note_attachment.dart';
import 'package:buzz/features/channels/voice_note_waveform.dart';
import 'package:buzz/features/channels/voice_note_recording.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
import 'package:buzz/shared/emoji/emoji_only.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
part 'message_content_bubble_cases.dart';
part 'message_carousel_preview_cases.dart';
Widget _testable(
Widget child, {
List<Override> overrides = const [],
bool disableAnimations = false,
bool dark = false,
ThemeData? theme,
VideoPreviewFrameLoader? videoPreviewFrameLoader,
}) {
return ProviderScope(
overrides: [
videoPreviewFrameLoaderProvider.overrideWithValue(
videoPreviewFrameLoader ?? (_) async => null,
),
...overrides,
],
child: MaterialApp(
theme: theme ?? (dark ? AppTheme.dark() : AppTheme.light()),
// Use MaterialApp.builder so the MediaQuery override (including
// disableAnimations) applies to every pushed route, not just the
// home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage)
// skip a home-level Builder wrapper entirely.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
// AppMarkdownTheme must wrap all routes that render message content.
child: AppMarkdownTheme(child: child!),
),
home: Scaffold(body: child),
),
);
}
void _setSurfaceSize(WidgetTester tester, Size size) {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = size;
}
class _FakeVoiceNotePlayer extends VoiceNotePlayerController {
VoiceNotePlaybackState _state = const VoiceNotePlaybackState();
@override
VoiceNotePlaybackState get state => _state;
double speed = 1;
@override
Future<void> loadLocal(
String path, {
required Duration fallbackDuration,
}) async {
_state = VoiceNotePlaybackState(duration: fallbackDuration);
notifyListeners();
}
@override
Future<void> loadRemote(
String url, {
required Map<String, String> Function() headers,
required Duration fallbackDuration,
}) => loadLocal(url, fallbackDuration: fallbackDuration);
@override
Future<void> pause() async {
_state = _state.copyWith(isPlaying: false);
notifyListeners();
}
@override
Future<void> seek(Duration position) async {
_state = _state.copyWith(position: position);
notifyListeners();
}
@override
Future<void> setSpeed(double value) async => speed = value;
@override
Future<void> toggle() async {
_state = _state.copyWith(isPlaying: !_state.isPlaying);
notifyListeners();
}
}
class _LoadingVoiceNotePlayer extends _FakeVoiceNotePlayer {
@override
VoiceNotePlaybackState get state =>
const VoiceNotePlaybackState(isLoading: true, canCancelLoading: true);
@override
Future<void> loadRemote(
String url, {
required Map<String, String> Function() headers,
required Duration fallbackDuration,
}) async {}
}
class _ReadyTransitionVoiceNotePlayer extends _LoadingVoiceNotePlayer {
bool loading = true;
@override
VoiceNotePlaybackState get state => VoiceNotePlaybackState(
isLoading: loading,
canCancelLoading: loading,
duration: const Duration(seconds: 3),
);
void finishLoading() {
loading = false;
notifyListeners();
}
}
class _ToggleTrackingLoadingVoiceNotePlayer extends _LoadingVoiceNotePlayer {
int toggleCount = 0;
@override
Future<void> toggle() async {
toggleCount += 1;
}
}
class _BufferingVoiceNotePlayer extends _FakeVoiceNotePlayer {
int toggleCount = 0;
@override
VoiceNotePlaybackState get state => const VoiceNotePlaybackState(
duration: Duration(seconds: 3),
isPlaying: true,
isLoading: true,
canCancelLoading: true,
);
@override
Future<void> loadRemote(
String url, {
required Map<String, String> Function() headers,
required Duration fallbackDuration,
}) async {}
@override
Future<void> toggle() async {
toggleCount += 1;
}
}
class _HeldAudioPlayerBackend implements VoiceNoteAudioPlayerBackend {
final positions = const Stream<Duration>.empty();
final durations = const Stream<Duration?>.empty();
final states = const Stream<audio.PlayerState>.empty();
final pathLoad = Completer<Duration?>();
@override
Stream<Duration> get positionStream => positions;
@override
Stream<Duration?> get durationStream => durations;
@override
Stream<audio.PlayerState> get playerStateStream => states;
@override
bool get playing => false;
@override
Future<Duration?> setFilePath(String path) => pathLoad.future;
@override
Future<Duration?> setUrl(String url, {Map<String, String>? headers}) async =>
null;
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> cancelPendingLoad() async {}
@override
Future<void> seek(Duration position) async {}
@override
Future<void> setSpeed(double speed) async {}
@override
Future<void> dispose() async {}
}
class _NoopHttpClient extends http.BaseClient {
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
throw UnsupportedError('Local playback must not issue HTTP requests');
}
}
class _RetryableVoiceNotePlayer extends _FakeVoiceNotePlayer {
_RetryableVoiceNotePlayer() {
_state = const VoiceNotePlaybackState(hasError: true);
}
int toggleCount = 0;
@override
Future<void> loadRemote(
String url, {
required Map<String, String> Function() headers,
required Duration fallbackDuration,
}) async {}
@override
Future<void> toggle() async {
toggleCount += 1;
_state = _state.copyWith(hasError: false, isPlaying: true);
notifyListeners();
}
}
Finder _imagePreview(String imageUrl) {
return find.byKey(ValueKey('message-media-image-preview:$imageUrl'));
}
Finder _imageViewerHeroMode() {
return find.byKey(const ValueKey('message-media-image-viewer-hero-mode'));
}
Future<TransformationController> _openImageViewer(
WidgetTester tester,
String imageUrl,
) async {
await tester.tap(_imagePreview(imageUrl));
await tester.pumpAndSettle();
final interactiveViewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
final transformationController = interactiveViewer.transformationController;
expect(transformationController, isNotNull);
return transformationController!;
}
void _applyImageViewerTransform(
TransformationController controller, {
required double dx,
required double dy,
required double scale,
}) {
controller.value = Matrix4.identity()
..translateByDouble(dx, dy, 0, 1)
..scaleByDouble(scale, scale, scale, 1);
}
bool _isImageViewerHeroEnabled(WidgetTester tester) {
return tester.widget<HeroMode>(_imageViewerHeroMode()).enabled;
}
/// Whether any placeholder span in the tree contains a further placeholder.
///
/// This is the shape that renders as nothing on iOS: a token component's
/// `WidgetSpan` nested inside the `WidgetSpan` a link is already drawn in.
/// Each placeholder becomes its own paragraph, so a paragraph that both sits
/// under another paragraph's placeholder and holds a placeholder of its own is
/// the defect.
bool _hasNestedPlaceholder(WidgetTester tester) {
for (final rich in tester.widgetList<RichText>(_anyRichText())) {
var placeholders = 0;
rich.text.visitChildren((span) {
if (span is PlaceholderSpan) placeholders++;
return true;
});
if (placeholders == 0) continue;
// A paragraph holding a placeholder is fine on its own — the message body
// does that for every link. It is only a defect when that paragraph is
// itself inside another paragraph's placeholder.
final ancestors = find.ancestor(
of: find.byWidget(rich),
matching: _anyRichText(),
);
if (ancestors.evaluate().isNotEmpty) return true;
}
return false;
}
/// Matches every `RichText` in the tree, including subclasses.
///
/// gpt_markdown renders body paragraphs through `BidiRichText`, a `RichText`
/// subclass. `find.byType` matches the exact runtime type only, so it silently
/// skips those paragraphs — an assertion that text is rendered then fails, and
/// an assertion that text is absent then passes for the wrong reason.
Finder _anyRichText() {
return find.byWidgetPredicate(
(widget) => widget is RichText,
description: 'any RichText (including subclasses)',
);
}
/// Extracts all plain text from all RichText widgets in the tree.
String _allRichText(WidgetTester tester) {
final richTexts = tester.widgetList<RichText>(_anyRichText());
return richTexts.map((rt) => rt.text.toPlainText()).join('\n');
}
/// Finds a RichText widget whose plain text contains [text].
Finder _findRich(String text) {
return find.byWidgetPredicate(
(widget) => widget is RichText && widget.text.toPlainText().contains(text),
description: 'RichText containing "$text"',
);
}
/// Checks that the given text appears as bold (fontWeight >= w600) in some
/// TextSpan within any RichText widget.
bool _hasBoldSpan(WidgetTester tester, String text) {
for (final rt in tester.widgetList<RichText>(_anyRichText())) {
if (_spanHasStyle(
rt.text,
text,
(s) =>
s.fontWeight != null && s.fontWeight!.value >= FontWeight.w600.value,
)) {
return true;
}
}
return false;
}
bool _hasItalicSpan(WidgetTester tester, String text) {
for (final rt in tester.widgetList<RichText>(_anyRichText())) {
if (_spanHasStyle(rt.text, text, (s) => s.fontStyle == FontStyle.italic)) {
return true;
}
}
return false;
}
bool _hasStrikethroughSpan(WidgetTester tester, String text) {
for (final rt in tester.widgetList<RichText>(_anyRichText())) {
if (_spanHasStyle(
rt.text,
text,
(s) => s.decoration == TextDecoration.lineThrough,
)) {
return true;
}
}
return false;
}
bool _spanHasStyle(
InlineSpan root,
String text,
bool Function(TextStyle) check,
) {
bool visit(InlineSpan span, TextStyle? inheritedStyle) {
if (span is! TextSpan) return false;
final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style;
if (span.text != null &&
span.text!.contains(text) &&
effectiveStyle != null &&
check(effectiveStyle)) {
return true;
}
for (final child in span.children ?? const <InlineSpan>[]) {
if (visit(child, effectiveStyle)) return true;
}
return false;
}
return visit(root, null);
}
class _TestChannelsNotifier extends ChannelsNotifier {
_TestChannelsNotifier(this.channels);
final Future<List<Channel>> channels;
@override
Future<List<Channel>> build() => channels;
}
void main() {
carouselPreviewTests();
bubbleContentTests();
test('wide voice-note waveforms distribute bars across their full width', () {
const width = 320.0;
const sampleCount = 48;
final layout = voiceNoteWaveformBarLayout(
width: width,
sampleCount: sampleCount,
);
expect(layout.barWidth, 3);
expect(
(layout.barWidth * sampleCount) + (layout.gap * (sampleCount - 1)),
closeTo(width, 0.001),
);
});
testWidgets('voice-note waveform semantics seek within bounded steps', (
tester,
) async {
final progress = ValueNotifier(0.0);
addTearDown(progress.dispose);
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<double>(
valueListenable: progress,
builder: (context, value, _) => VoiceNoteWaveform(
samples: const [0.2, 0.8],
progress: value,
onSeek: (next) => progress.value = next,
),
),
),
);
final semantics = tester.getSemantics(
find.bySemanticsLabel('Voice note waveform'),
);
expect(semantics.value, '0 percent');
semantics.owner!.performAction(semantics.id, SemanticsAction.increase);
await tester.pump();
expect(
tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value,
'10 percent',
);
progress.value = 0.5;
await tester.pump();
final middle = tester.getSemantics(
find.bySemanticsLabel('Voice note waveform'),
);
middle.owner!.performAction(middle.id, SemanticsAction.decrease);
await tester.pump();
expect(
tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value,
'40 percent',
);
progress.value = 1;
await tester.pump();
final end = tester.getSemantics(
find.bySemanticsLabel('Voice note waveform'),
);
end.owner!.performAction(end.id, SemanticsAction.increase);
await tester.pump();
expect(
tester.getSemantics(find.bySemanticsLabel('Voice note waveform')).value,
'100 percent',
);
});
group('MessageContent', () {
testWidgets('forwards text alignment to markdown rendering', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Centered status',
textAlign: TextAlign.center,
),
),
);
expect(
tester.widget<GptMarkdown>(find.byType(GptMarkdown)).textAlign,
TextAlign.center,
);
});
testWidgets('opens local file links through an authenticated download', (
tester,
) async {
const url = 'https://relay.example/media/report.pdf';
String? openedUrl;
Map<String, String>? openedHeaders;
String? openedFilename;
final auth = MediaGetAuthService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
);
await tester.pumpWidget(
_testable(
const MessageContent(content: '[report.pdf]($url)'),
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(auth),
openDownloadedFileProvider.overrideWithValue((
url,
headers,
filename,
) async {
openedUrl = url;
openedHeaders = headers;
openedFilename = filename;
}),
],
),
);
await tester.tap(find.text('report.pdf'));
await tester.pump();
expect(openedUrl, url);
expect(openedFilename, 'report.pdf');
expect(openedHeaders?['Authorization'], startsWith('Nostr '));
});
test('buildImageViewerRoute uses modal-style page route builder', () {
final route = buildImageViewerRoute(
imageUrl: 'https://example.com/media/image.png',
heroTag: Object(),
);
expect(route, isA<PageRouteBuilder<void>>());
expect(route.transitionDuration, const Duration(milliseconds: 260));
expect(
route.reverseTransitionDuration,
const Duration(milliseconds: 170),
);
});
test('buildImageViewerRoute disables motion when requested', () {
final route = buildImageViewerRoute(
imageUrl: 'https://example.com/media/image.png',
heroTag: Object(),
disableAnimations: true,
);
expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
});
group('plain text', () {
testWidgets('renders simple text', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'Hello world')),
);
expect(_findRich('Hello world'), findsOneWidget);
});
testWidgets('renders empty content', (tester) async {
await tester.pumpWidget(_testable(const MessageContent(content: '')));
// Should not crash.
expect(find.byType(MessageContent), findsOneWidget);
});
});
group('custom emoji', () {
testWidgets('renders tagged custom emoji as inline image', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Narf :shipit:',
tags: [
['emoji', 'shipit', 'https://relay.example/shipit.png'],
],
),
),
);
expect(find.byType(Image), findsOneWidget);
final image = tester.widget<Image>(find.byType(Image));
expect(image.semanticLabel, ':shipit:');
expect(_allRichText(tester), contains('Narf'));
expect(_allRichText(tester), isNot(contains(':shipit:')));
});
testWidgets('leaves untagged custom emoji shortcode as text', (
tester,
) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'Missing :shipit:')),
);
expect(find.byType(Image), findsNothing);
expect(_allRichText(tester), contains(':shipit:'));
});
});
group('emoji-only bodies', () {
/// Font size the body text actually rendered at.
///
/// gpt_markdown resolves the style onto the spans rather than the root
/// RichText, which keeps the ambient 14px — so read the span carrying the
/// text, not the root.
double bodyFontSize(WidgetTester tester, String text) {
final richText = tester.widget<RichText>(_findRich(text).first);
double? size;
richText.text.visitChildren((span) {
if (span is TextSpan && (span.text ?? '').contains(text)) {
size ??= span.style?.fontSize;
}
return size == null;
});
return size ?? richText.text.style!.fontSize!;
}
testWidgets('an all-emoji body renders larger, like desktop', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: '\u{1F389}', scaleEmojiOnly: true),
),
);
expect(bodyFontSize(tester, '\u{1F389}'), kEmojiOnlyFontSize);
});
testWidgets('one word alongside the emoji keeps body size', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'ship it \u{1F389}',
scaleEmojiOnly: true,
),
),
);
expect(bodyFontSize(tester, 'ship it'), lessThan(kEmojiOnlyFontSize));
});
testWidgets('the scale is opt-in, so previews are unaffected', (
tester,
) async {
await tester.pumpWidget(
_testable(const MessageContent(content: '\u{1F389}')),
);
expect(bodyFontSize(tester, '\u{1F389}'), lessThan(kEmojiOnlyFontSize));
});
testWidgets('a tagged custom emoji alone scales its image too', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: ':shipit:',
scaleEmojiOnly: true,
tags: [
['emoji', 'shipit', 'https://relay.example/shipit.png'],
],
),
),
);
final image = tester.widget<Image>(find.byType(Image));
expect(image.height, kEmojiOnlyCustomEmojiSize);
});
testWidgets('an unresolvable shortcode stays body size', (tester) async {
// Without a matching emoji tag it renders as literal text, so scaling it
// would blow up a `:word:` that was never emoji at all.
await tester.pumpWidget(
_testable(
const MessageContent(content: ':shipit:', scaleEmojiOnly: true),
),
);
expect(bodyFontSize(tester, ':shipit:'), lessThan(kEmojiOnlyFontSize));
});
});
group('inline formatting', () {
testWidgets('renders bold text', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'This is **bold** text')),
);
final allText = _allRichText(tester);
expect(allText, contains('bold'));
expect(allText, isNot(contains('**')));
expect(_hasBoldSpan(tester, 'bold'), isTrue);
});
testWidgets('renders italic text', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'This is *italic* text')),
);
final allText = _allRichText(tester);
expect(allText, contains('italic'));
expect(_hasItalicSpan(tester, 'italic'), isTrue);
});
testWidgets('renders strikethrough text', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'This is ~~struck~~ text')),
);
final allText = _allRichText(tester);
expect(allText, contains('struck'));
expect(allText, isNot(contains('~~')));
expect(_hasStrikethroughSpan(tester, 'struck'), isTrue);
});
testWidgets('renders inline code', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: 'Use `flutter test` to run')),
);
// Inline code is rendered inside a styled span.
expect(_findRich('flutter test'), findsWidgets);
});
testWidgets('renders markdown link', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: 'Check [Buzz](https://example.com)'),
),
);
final allText = _allRichText(tester);
expect(allText, contains('Buzz'));
// Should not show raw markdown syntax.
expect(allText, isNot(contains('[Buzz]')));
expect(allText, isNot(contains('(https://example.com)')));
});
testWidgets('renders and routes a buzz message link', (tester) async {
const url =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&thread=dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd';
await tester.pumpWidget(
_testable(const MessageContent(content: '[Open message]($url)')),
);
expect(find.text('Open message'), findsOneWidget);
await tester.tap(find.text('Open message'));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
threadRootId:
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
),
);
});
testWidgets('renders and routes bare Buzz message links', (tester) async {
const url =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await tester.pumpWidget(
_testable(const MessageContent(content: 'See $url now')),
);
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget);
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
);
});
testWidgets('keeps Markdown delimiters outside bare Buzz links', (
tester,
) async {
const url =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await tester.pumpWidget(
_testable(const MessageContent(content: '**$url**. and _${url}_')),
);
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsNWidgets(2));
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')).first);
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
);
});
testWidgets('keeps non-adjacent Markdown delimiters outside links', (
tester,
) async {
const url =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'*join $url* and **open $url** and '
'~~visit $url~~ and **_${url}_**.',
),
),
);
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsNWidgets(4));
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
for (final link
in find.byKey(ValueKey('buzz-link-chip:$url')).evaluate()) {
await tester.tap(find.byWidget(link.widget));
await tester.pump();
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
);
container.read(pendingDeepLinkProvider.notifier).state = null;
}
});
testWidgets('excludes sentence punctuation from bare Buzz links', (
tester,
) async {
const messageUrl =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const joinUrl =
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1';
await tester.pumpWidget(
_testable(
const MessageContent(content: 'See $messageUrl. Then $joinUrl!'),
),
);
expect(
find.byKey(ValueKey('buzz-link-chip:$messageUrl')),
findsOneWidget,
);
expect(find.text(joinUrl), findsOneWidget);
expect(_allRichText(tester), contains('See \u{FFFC}. Then \u{FFFC}!'));
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$messageUrl')));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
),
);
container.read(pendingDeepLinkProvider.notifier).consume();
await tester.tap(find.text(joinUrl));
await tester.pump();
expect(
container.read(pendingDeepLinkProvider),
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-1',
),
);
});
testWidgets('renders and routes autolinked Buzz thread links', (
tester,
) async {
const url =
'buzz://message?channel=580ca78b-9dae-46f3-8854-bd671853ba32&id=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&thread=dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd';
await tester.pumpWidget(
_testable(const MessageContent(content: '<$url>')),
);
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget);
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const MessageDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
messageId:
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
threadRootId:
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
),
);
});
testWidgets('renders and routes bare Buzz join links', (tester) async {
const url =
'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1';
await tester.pumpWidget(
_testable(const MessageContent(content: 'Join with $url')),
);
expect(find.text(url), findsOneWidget);
await tester.tap(find.text(url));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'invite-1',
),
);
});
testWidgets('renders and routes bare Buzz channel links', (tester) async {
const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32';
await tester.pumpWidget(
_testable(const MessageContent(content: 'See $url now')),
);
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget);
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
testWidgets('renders and routes labeled Buzz channel links', (
tester,
) async {
const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32';
await tester.pumpWidget(
_testable(const MessageContent(content: '[Open channel]($url)')),
);
await tester.tap(find.text('Open channel'));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
testWidgets('routes rendered Buzz channel links through callback', (
tester,
) async {
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
const url = 'buzz://channel/$channelId';
String? tappedChannelId;
await tester.pumpWidget(
_testable(
MessageContent(
content: '[Open channel]($url)',
onChannelTap: (id) => tappedChannelId = id,
),
),
);
await tester.tap(find.text('Open channel'));
await tester.pump();
expect(tappedChannelId, channelId);
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(container.read(pendingDeepLinkProvider), isNull);
});
testWidgets('renders and routes autolinked Buzz channel links', (
tester,
) async {
const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32';
await tester.pumpWidget(
_testable(const MessageContent(content: '<$url>')),
);
await tester.tap(find.byKey(ValueKey('buzz-link-chip:$url')));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
testWidgets('leaves malformed Buzz channel forms as plain text', (
tester,
) async {
const url =
'buzz://channel?channel=580ca78b-9dae-46f3-8854-bd671853ba32';
await tester.pumpWidget(
_testable(const MessageContent(content: 'See $url now')),
);
expect(find.text(url), findsNothing);
expect(_allRichText(tester), contains(url));
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(container.read(pendingDeepLinkProvider), isNull);
});
testWidgets('renders bare URL as link', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: 'Visit https://example.com today'),
),
);
// The URL text should be rendered and tappable.
expect(find.text('https://example.com'), findsOneWidget);
final linkText = tester.widget<Text>(find.text('https://example.com'));
expect(
linkText.style?.decoration ?? linkText.textSpan?.style?.decoration,
TextDecoration.underline,
);
});
});
group('code blocks', () {
testWidgets('renders fenced code block', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: 'Before\n```\ncode here\n```\nAfter'),
),
);
expect(_findRich('code here'), findsWidgets);
expect(_findRich('Before'), findsWidgets);
expect(_findRich('After'), findsWidgets);
});
testWidgets('renders code block with language tag', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: '```dart\nvoid main() {}\n```'),
),
);
expect(_findRich('void main() {}'), findsWidgets);
expect(find.text('dart'), findsOneWidget);
});
});
group('media attachments', () {
testWidgets('reserves image metadata dimensions while bytes load', (
tester,
) async {
const url = 'https://example.com/content-shaped-loading.png';
final response = Completer<http.Response>();
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![image]($url)',
channelNames: {'general': 'general-id'},
tags: [
['imeta', 'url $url', 'm image/png', 'dim 1200x2400'],
],
),
disableAnimations: true,
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://example.com', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(
MockClient((_) => response.future),
),
],
),
);
await tester.pump();
final preview = find.byKey(
const ValueKey('message-media-image-preview:$url'),
);
final before = tester.getSize(preview);
expect(before.height, 240);
expect(before.width, 120);
expect(find.byType(MediaLoadingPlaceholder), findsOneWidget);
response.complete(
http.Response.bytes(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=',
),
200,
),
);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pumpAndSettle();
expect(find.byType(MediaLoadingPlaceholder), findsNothing);
expect(tester.getSize(preview), before);
expect(tester.takeException(), isNull);
});
testWidgets('keeps video loading and decoded frame the same size', (
tester,
) async {
final frame = Completer<LoadedVideoPreviewFrame?>();
const url = 'https://example.com/loading-video.mp4';
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video]($url)',
channelNames: {'general': 'general-id'},
tags: [
['imeta', 'url $url', 'm video/mp4', 'dim 1920x1080'],
],
),
disableAnimations: true,
videoPreviewFrameLoader: (_) => frame.future,
),
);
final preview = find.byKey(
const ValueKey('message-media-video-preview:$url'),
);
final before = tester.getSize(preview);
expect(find.byType(MediaLoadingPlaceholder), findsOneWidget);
frame.complete(
LoadedVideoPreviewFrame(
child: const ColoredBox(color: Colors.black),
aspectRatio: 16 / 9,
dispose: () async {},
),
);
await tester.pumpAndSettle();
expect(find.byType(MediaLoadingPlaceholder), findsNothing);
expect(tester.getSize(preview), before);
});
testWidgets('uses the shared Buzz loader while a voice note loads', (
tester,
) async {
const url = 'https://example.com/media/loading-voice-note.mp4';
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![audio]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-loading.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(
_LoadingVoiceNotePlayer.new,
),
],
),
);
await tester.pump();
expect(
find.descendant(
of: find.byType(SkeletonShimmer),
matching: find.byType(VoiceNoteWaveform),
),
findsOneWidget,
);
expect(
tester
.widget<VoiceNoteWaveform>(find.byType(VoiceNoteWaveform))
.onSeek,
isNull,
);
expect(find.text('0:03'), findsOneWidget);
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
final spinner = find.byType(BuzzLoadingIndicator);
expect(tester.getSize(spinner), const Size.square(18));
expect(
tester.widget<BuzzLoadingIndicator>(spinner).color,
tester.element(spinner).colors.onSecondaryContainer,
);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(
find.bySemanticsLabel('Cancel voice note loading'),
findsOneWidget,
);
expect(find.bySemanticsLabel('Loading voice note'), findsNothing);
});
testWidgets(
'voice waveform semantics return only when playback is ready',
(tester) async {
final semantics = tester.ensureSemantics();
final player = _ReadyTransitionVoiceNotePlayer();
await tester.pumpWidget(
_testable(
const VoiceNoteAttachment.remote(
url: 'https://example.com/voice.m4a',
duration: Duration(seconds: 3),
waveform: [],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
expect(find.bySemanticsLabel('Voice note waveform'), findsNothing);
player.finishLoading();
await tester.pumpAndSettle();
final node = tester.getSemantics(
find.bySemanticsLabel('Voice note waveform'),
);
expect(
node.getSemanticsData().hasAction(SemanticsAction.increase),
isTrue,
);
expect(
node.getSemanticsData().hasAction(SemanticsAction.decrease),
isTrue,
);
semantics.dispose();
},
);
testWidgets('routes repeated loading-control taps through toggle', (
tester,
) async {
const url = 'https://example.com/media/cancel-loading-voice-note.mp4';
final player = _ToggleTrackingLoadingVoiceNotePlayer();
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![audio]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-loading.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
final control = find.bySemanticsLabel('Cancel voice note loading');
final controlSemantics = tester.getSemantics(control);
expect(
controlSemantics.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
);
tester.binding.performSemanticsAction(
SemanticsActionEvent(
type: SemanticsAction.tap,
viewId: tester.view.viewId,
nodeId: controlSemantics.id,
),
);
await tester.pump();
expect(player.toggleCount, 1);
});
testWidgets('active buffering playback keeps its pause action', (
tester,
) async {
const url = 'https://example.com/media/buffering-voice-note.mp4';
final player = _BufferingVoiceNotePlayer();
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![audio]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-buffering.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
final control = find.bySemanticsLabel('Pause voice note');
expect(control, findsOneWidget);
expect(
tester
.getSemantics(control)
.getSemanticsData()
.hasAction(SemanticsAction.tap),
isTrue,
);
await tester.tap(find.byKey(const ValueKey('voice-note-play-pause')));
await tester.pump();
expect(player.toggleCount, 1);
});
testWidgets(
'local preview loading is non-actionable while remote loading cancels',
(tester) async {
final backend = _HeldAudioPlayerBackend();
final player = DeviceVoiceNotePlayerController(
coordinator: VoiceNotePlaybackCoordinator(),
client: _NoopHttpClient(),
player: backend,
);
addTearDown(() {
if (!backend.pathLoad.isCompleted) backend.pathLoad.complete(null);
});
await tester.pumpWidget(
_testable(
const VoiceNoteAttachment.local(
path: '/tmp/local-voice-note.m4a',
duration: Duration(seconds: 3),
waveform: [],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
expect(backend.pathLoad.isCompleted, isFalse);
final control = find.bySemanticsLabel('Loading voice note');
final controlSemantics = tester.getSemantics(control);
expect(control, findsOneWidget);
expect(
controlSemantics.getSemanticsData().hasAction(SemanticsAction.tap),
isFalse,
);
expect(
find.bySemanticsLabel('Cancel voice note loading'),
findsNothing,
);
await tester.tap(find.byKey(const ValueKey('voice-note-play-pause')));
await tester.pump();
expect(player.state.canCancelLoading, isFalse);
expect(backend.pathLoad.isCompleted, isFalse);
},
);
testWidgets('offers an accessible retry after a voice note fails', (
tester,
) async {
const url = 'https://example.com/media/retry-voice-note.mp4';
final player = _RetryableVoiceNotePlayer();
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![audio]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-retry.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
expect(find.text('Voice note unavailable'), findsOneWidget);
expect(find.byTooltip('Retry voice note'), findsOneWidget);
expect(find.bySemanticsLabel('Retry voice note'), findsOneWidget);
expect(
find.byKey(const ValueKey('voice-note-retry-icon')),
findsOneWidget,
);
await tester.tap(find.byTooltip('Retry voice note'));
await tester.pump();
expect(player.toggleCount, 1);
expect(find.byTooltip('Pause voice note'), findsOneWidget);
expect(find.text('Voice note unavailable'), findsNothing);
});
testWidgets('renders desktop packaged voice-note links as audio cards', (
tester,
) async {
const url = 'https://example.com/media/desktop-voice-note.mp4';
await tester.pumpWidget(
_testable(
const MessageContent(
content: '[voice-note-desktop.mp4]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-desktop.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(
_FakeVoiceNotePlayer.new,
),
],
),
);
await tester.pump();
expect(
find.byKey(const ValueKey('voice-note-attachment:$url')),
findsOneWidget,
);
expect(find.text('voice-note-desktop.mp4'), findsNothing);
});
testWidgets('keeps audio-looking links without imeta as ordinary links', (
tester,
) async {
const url = 'https://example.com/media/not-an-attachment.mp4';
await tester.pumpWidget(
_testable(const MessageContent(content: '[recording.mp4]($url)')),
);
await tester.pump();
expect(
find.byKey(const ValueKey('voice-note-attachment:$url')),
findsNothing,
);
expect(find.text('recording.mp4'), findsOneWidget);
});
testWidgets('renders an audio imeta attachment as a voice note card', (
tester,
) async {
const url = 'https://example.com/media/voice-note.mp4';
final player = _FakeVoiceNotePlayer();
final hapticCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![audio]($url)',
tags: [
[
'imeta',
'url $url',
'm video/mp4',
'duration 3.0',
'filename voice-note-test.mp4',
],
],
),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(() => player),
],
),
);
await tester.pump();
expect(
find.byKey(const ValueKey('voice-note-attachment:$url')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('voice-note-play-pause')),
findsOneWidget,
);
final cardFinder = find.byKey(
const ValueKey('voice-note-attachment:$url'),
);
final rateFinder = find.byKey(
const ValueKey('voice-note-playback-rate'),
);
final card = tester.widget<Container>(cardFinder);
expect(card.padding, const EdgeInsets.all(Grid.twelve));
expect(
tester.getTopLeft(find.byType(VoiceNoteWaveform)).dx,
tester
.getTopLeft(find.byKey(const ValueKey('voice-note-duration')))
.dx,
);
final leadingInset =
tester
.getTopLeft(find.byKey(const ValueKey('voice-note-play-pause')))
.dx -
tester.getTopLeft(cardFinder).dx;
final trailingInset =
tester.getTopRight(cardFinder).dx -
tester.getTopRight(rateFinder).dx;
expect(leadingInset, Grid.twelve + 1);
expect(trailingInset, leadingInset);
expect(rateFinder, findsOneWidget);
final rateSize = tester.getSize(rateFinder);
final ratePadding = tester
.widgetList<Padding>(
find.descendant(of: rateFinder, matching: find.byType(Padding)),
)
.singleWhere(
(widget) =>
widget.padding ==
const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.half + Grid.quarter,
),
);
expect(
ratePadding.padding,
const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.half + Grid.quarter,
),
);
final rateValueFinder = find.byKey(
const ValueKey('voice-note-playback-rate-value'),
);
hapticCalls.clear();
await tester.tap(find.byKey(const ValueKey('voice-note-play-pause')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
tester
.widget<VoiceNoteWaveform>(find.byType(VoiceNoteWaveform))
.progress,
greaterThan(0),
);
final waveformRect = tester.getRect(find.byType(VoiceNoteWaveform));
await tester.dragFrom(
Offset(
waveformRect.left + waveformRect.width * 0.25,
waveformRect.center.dy,
),
Offset(waveformRect.width * 0.5, 0),
);
await tester.pump();
expect(player.state.position.inMilliseconds, closeTo(2250, 80));
expect(tester.widget<Text>(rateValueFinder).data, '1×');
hapticCalls.clear();
await tester.tap(rateFinder);
await tester.pump();
expect(tester.widget<Text>(rateValueFinder).data, '1.5×');
expect(player.speed, 1.5);
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(tester.getSize(rateFinder), rateSize);
await tester.tap(rateFinder);
await tester.pump();
expect(tester.widget<Text>(rateValueFinder).data, '2×');
expect(tester.getSize(rateFinder), rateSize);
await tester.tap(rateFinder);
await tester.pump();
expect(tester.widget<Text>(rateValueFinder).data, '.5×');
expect(tester.getSize(rateFinder), rateSize);
});
testWidgets(
'renders image markdown as a media preview and opens viewer',
(tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Look\n![image](https://example.com/media/image.png)',
tags: [
[
'imeta',
'url https://example.com/media/image.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final preview = find.byKey(
const ValueKey(
'message-media-image-preview:https://example.com/media/image.png',
),
);
expect(preview, findsOneWidget);
await tester.tap(preview);
await tester.pumpAndSettle();
final viewer = tester.widget<Scaffold>(
find.byKey(const ValueKey('message-media-image-viewer')),
);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
expect(viewer.backgroundColor, Colors.black);
expect(find.byType(AppBar), findsNothing);
expect(
find.byKey(const ValueKey('message-media-image-viewer-close')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('message-media-image-viewer-close')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
},
);
testWidgets('uses unique hero tags for repeated identical image urls', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '''
![image](https://example.com/media/repeated.png)
![image](https://example.com/media/repeated.png)
''',
tags: [
[
'imeta',
'url https://example.com/media/repeated.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final heroes = tester.widgetList<Hero>(find.byType(Hero)).toList();
final heroTags = heroes.map((hero) => hero.tag).toSet();
expect(heroes, hasLength(2));
expect(heroTags, hasLength(2));
await tester.tap(find.byType(Image).first);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
});
testWidgets(
'keeps voice notes out of image carousels for audio-only and mixed media',
(tester) async {
const firstAudio = 'https://example.com/media/voice-note-first.mp4';
const secondAudio = 'https://example.com/media/voice-note-second.mp4';
const image = 'https://example.com/media/photo.png';
Widget message(String content, List<List<String>> tags) => _testable(
MessageContent(content: content, tags: tags),
overrides: [
voiceNotePlayerFactoryProvider.overrideWithValue(
_FakeVoiceNotePlayer.new,
),
],
);
await tester.pumpWidget(
message('![audio]($firstAudio)\n![audio]($secondAudio)', const [
[
'imeta',
'url $firstAudio',
'm video/mp4',
'filename voice-note-first.mp4',
],
[
'imeta',
'url $secondAudio',
'm video/mp4',
'filename voice-note-second.mp4',
],
]),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-carousel')),
findsNothing,
);
expect(find.byType(VoiceNoteAttachment), findsNWidgets(2));
await tester.pumpWidget(
message('![image]($image)\n![audio]($firstAudio)', const [
['imeta', 'url $image', 'm image/png'],
[
'imeta',
'url $firstAudio',
'm video/mp4',
'filename voice-note-first.mp4',
],
]),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-carousel')),
findsNothing,
);
expect(find.byType(VoiceNoteAttachment), findsOneWidget);
expect(_imagePreview(image), findsOneWidget);
},
);
testWidgets(
'groups uploaded photos into a carousel and opens the full gallery',
(tester) async {
const first = 'https://example.com/media/one.png';
const second = 'https://example.com/media/two.png';
const third = 'https://example.com/media/three.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'''
Photos
![image]($first)
![image]($second)
![image]($third)
''',
tags: [
['imeta', 'url $first', 'm image/png', 'alt First photo'],
['imeta', 'url $second', 'm image/png', 'alt Second photo'],
['imeta', 'url $third', 'm image/png', 'alt Third photo'],
],
),
),
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
expect(carousel, findsOneWidget);
expect(find.text('3 images'), findsOneWidget);
await tester.drag(carousel, const Offset(-600, 0));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('message-media-carousel-item:$second')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-media-image-viewer-pagination')),
findsOneWidget,
);
final displayedImage = tester.widget<MediaImage>(
find.byKey(const ValueKey('message-media-image-viewer-image:1')),
);
expect(displayedImage.decodeWidth, isNotNull);
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
1,
);
await tester.fling(
find.byKey(const ValueKey('message-media-image-viewer-pages')),
const Offset(-700, 0),
1200,
);
await tester.pumpAndSettle();
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
);
expect(find.bySemanticsLabel('Image 3 of 3'), findsOneWidget);
},
);
testWidgets(
'paints neighboring photos through the avatar gutter after paging',
(tester) async {
const connectivity = MethodChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
connectivity,
(_) async => null,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(connectivity, null),
);
_setSurfaceSize(tester, const Size(390, 844));
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const urls = [
'https://example.com/media/gutter-one.png',
'https://example.com/media/gutter-two.png',
'https://example.com/media/gutter-three.png',
];
final boundaryKey = GlobalKey();
await tester.pumpWidget(
_testable(
RepaintBoundary(
key: boundaryKey,
child: Padding(
padding: const EdgeInsets.only(left: 64, right: 16),
child: MessageContent(
mediaCarouselTrailingOverflow: 16,
content: urls.map((url) => '![image]($url)').join('\n'),
tags: [
for (final url in urls)
['imeta', 'url $url', 'm image/png'],
],
),
),
),
),
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
final scroll = tester.widget<CustomScrollView>(
find.descendant(
of: carousel,
matching: find.byType(CustomScrollView),
),
);
final controller = scroll.controller! as PageController;
final first = find.byKey(
ValueKey('message-media-carousel-item:${urls[0]}'),
);
expect(tester.getRect(first).left, 64);
final sampleY = tester.getRect(first).top.toInt() + 30;
Future<List<int>> pixel(int x) async {
return (await tester.runAsync(() async {
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 1);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final offset = (sampleY * image.width + x) * 4;
final rgba = bytes.buffer.asUint8List().sublist(
offset,
offset + 4,
);
image.dispose();
return rgba;
}))!;
}
final photoColor = await pixel(100);
expect(await pixel(20), isNot(photoColor));
controller.jumpToPage(1);
await tester.pumpAndSettle();
// A layout-only assertion missed the original bug: the old sliver
// retained the image but stopped painting it in this visible gutter.
expect(tester.getRect(first).right, closeTo(64 - Grid.half, 0.01));
expect(await pixel(20), photoColor);
await tester.pump(const Duration(seconds: 2));
expect(await pixel(20), photoColor);
controller.jumpToPage(2);
await tester.pumpAndSettle();
final last = find.byKey(
ValueKey('message-media-carousel-item:${urls[2]}'),
);
expect(
tester.getRect(carousel).right - tester.getRect(last).right,
closeTo(Grid.gutter, 0.01),
);
expect(await pixel(20), photoColor);
controller.jumpToPage(0);
await tester.pumpAndSettle();
expect(tester.getRect(first).left, 64);
expect(await pixel(20), isNot(photoColor));
},
);
testWidgets('jumps to a selected gallery dot when motion is disabled', (
tester,
) async {
const first = 'https://example.com/media/reduced-motion-one.png';
const second = 'https://example.com/media/reduced-motion-two.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'''
![image]($first)
![image]($second)
''',
tags: [
['imeta', 'url $first', 'm image/png'],
['imeta', 'url $second', 'm image/png'],
],
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('message-media-carousel-item:$first')),
);
await tester.pumpAndSettle();
final indicatorFinder = find.byType(PageIndicator);
await tester.tap(find.byKey(const ValueKey('page-indicator-dot-1')));
await tester.pump();
expect(tester.widget<PageIndicator>(indicatorFinder).selected, 1);
final pages = tester.widget<PageView>(
find.byKey(const ValueKey('message-media-image-viewer-pages')),
);
expect(pages.controller!.page, 1);
expect(tester.takeException(), isNull);
});
testWidgets('uses glass photo controls and image pagination on iOS', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
_testable(
MediaImageViewerPage(
imageUrl: 'https://example.com/one.png',
heroTag: 'one',
galleryItems: const [
MediaViewerImage(
url: 'https://example.com/one.png',
heroTag: 'one',
),
MediaViewerImage(
url: 'https://example.com/two.png',
heroTag: 'two',
),
],
onReply: () {},
onMore: (_, _) {},
),
),
);
await tester.pumpAndSettle();
final views = tester.widgetList<UiKitView>(find.byType(UiKitView));
final pagination = views.singleWhere(
(view) => view.viewType == 'buzz/theme_pagination_glass',
);
final params = pagination.creationParams! as Map<String, Object>;
expect(params['accessibilityLabel'], 'Image');
expect(params['containerHeight'], 48);
expect(params['brightness'], 'dark');
expect(params['count'], 2);
expect(params['selected'], 0);
final buttons = views.where(
(view) => view.viewType == 'buzz/navigation_glass',
);
expect(
buttons.map(
(view) => (view.creationParams! as Map<String, Object>)['icon'],
),
unorderedEquals(['reply', 'more']),
);
expect(tester.takeException(), isNull);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('resets carousel paging when gallery images change', (
tester,
) async {
const firstGallery = [
'https://example.com/media/first-a.png',
'https://example.com/media/first-b.png',
'https://example.com/media/first-c.png',
];
const secondGallery = [
'https://example.com/media/second-a.png',
'https://example.com/media/second-b.png',
];
Widget gallery(List<String> urls) => _testable(
MessageContent(
content: urls.map((url) => '![image]($url)').join('\n'),
tags: [
for (final url in urls) ['imeta', 'url $url', 'm image/png'],
],
),
);
await tester.pumpWidget(gallery(firstGallery));
await tester.pumpAndSettle();
final firstCarousel = tester.widget<CustomScrollView>(
find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(CustomScrollView),
),
);
await tester.fling(
find.byKey(const ValueKey('message-media-carousel')),
const Offset(-700, 0),
1200,
);
await tester.pumpAndSettle();
expect(
(firstCarousel.controller! as PageController).page,
greaterThan(0),
);
await tester.pumpWidget(gallery(secondGallery));
await tester.pumpAndSettle();
final secondCarousel = tester.widget<CustomScrollView>(
find.descendant(
of: find.byKey(const ValueKey('message-media-carousel')),
matching: find.byType(CustomScrollView),
),
);
expect(
secondCarousel.controller,
isNot(same(firstCarousel.controller)),
);
expect((secondCarousel.controller! as PageController).page, 0);
});
testWidgets(
'disables hero on close after the fullscreen image is transformed',
(tester) async {
const imageUrl = 'https://example.com/media/transformed.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/transformed.png)',
tags: [
[
'imeta',
'url https://example.com/media/transformed.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final transformationController = await _openImageViewer(
tester,
imageUrl,
);
expect(_isImageViewerHeroEnabled(tester), isTrue);
_applyImageViewerTransform(
transformationController,
dx: 24.0,
dy: 18.0,
scale: 1.5,
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('message-media-image-viewer-close')),
);
await tester.pump();
expect(_isImageViewerHeroEnabled(tester), isFalse);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
},
);
testWidgets('double tap resets the fullscreen image transform', (
tester,
) async {
const imageUrl = 'https://example.com/media/double-tap-reset.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/double-tap-reset.png)',
tags: [
[
'imeta',
'url https://example.com/media/double-tap-reset.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final transformationController = await _openImageViewer(
tester,
imageUrl,
);
_applyImageViewerTransform(
transformationController,
dx: 32,
dy: 24,
scale: 2,
);
await tester.pump();
final gestureSurface = find.byKey(
const ValueKey('message-media-image-viewer-gesture:0'),
);
await tester.tap(gestureSurface);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(gestureSurface);
await tester.pumpAndSettle();
expect(
transformationController.value.storage,
orderedEquals(Matrix4.identity().storage),
);
expect(_isImageViewerHeroEnabled(tester), isTrue);
});
testWidgets('swiping down dismisses the fullscreen gallery', (
tester,
) async {
const imageUrl = 'https://example.com/media/swipe-dismiss.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/swipe-dismiss.png)',
tags: [
[
'imeta',
'url https://example.com/media/swipe-dismiss.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
await _openImageViewer(tester, imageUrl);
await tester.drag(
find.byKey(const ValueKey('message-media-image-viewer-gesture:0')),
const Offset(0, 180),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
});
testWidgets(
'disables hero on back navigation after the fullscreen image is transformed',
(tester) async {
const imageUrl = 'https://example.com/media/transformed-back.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'Look\n![image](https://example.com/media/transformed-back.png)',
tags: [
[
'imeta',
'url https://example.com/media/transformed-back.png',
'm image/png',
],
],
),
),
);
await tester.pumpAndSettle();
final transformationController = await _openImageViewer(
tester,
imageUrl,
);
_applyImageViewerTransform(
transformationController,
dx: 32.0,
dy: 20.0,
scale: 1.4,
);
await tester.pump();
final popRouteFuture = tester.binding.handlePopRoute();
await tester.pump();
await popRouteFuture;
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
},
);
testWidgets('caps tall image previews to a bounded inline size', (
tester,
) async {
_setSurfaceSize(tester, const Size(400, 800));
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![image](https://example.com/media/tall.png)',
tags: [
[
'imeta',
'url https://example.com/media/tall.png',
'm image/png',
'dim 1200x2400',
],
],
),
),
);
await tester.pumpAndSettle();
final preview = find.byKey(
const ValueKey(
'message-media-image-preview:https://example.com/media/tall.png',
),
);
final size = tester.getSize(preview);
expect(size.height, closeTo(240, 0.1));
expect(size.width, closeTo(120, 0.1));
});
testWidgets(
'reserves no-dim image bounds without cropping decoded content',
(tester) async {
_setSurfaceSize(tester, const Size(400, 800));
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
const previewKey = ValueKey(
'message-media-image-preview:https://example.com/media/no-dim.png',
);
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![image](https://example.com/media/no-dim.png)',
tags: [
[
'imeta',
'url https://example.com/media/no-dim.png',
'm image/png',
],
],
),
),
);
await tester.pump();
final preview = tester.widget<Container>(find.byKey(previewKey));
final image = tester.widget<Image>(
find.descendant(
of: find.byKey(previewKey),
matching: find.byType(Image),
),
);
expect(preview.constraints, isNotNull);
expect(preview.constraints!.minWidth, closeTo(288, 0.1));
expect(preview.constraints!.minHeight, closeTo(240, 0.1));
expect(preview.constraints!.maxWidth, closeTo(288, 0.1));
expect(preview.constraints!.maxHeight, closeTo(240, 0.1));
expect(image.fit, BoxFit.contain);
},
);
testWidgets('renders video markdown as a video preview', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video](https://example.com/media/clip.mp4)',
tags: [
[
'imeta',
'url https://example.com/media/clip.mp4',
'm video/mp4',
'image https://example.com/media/poster.jpg',
],
],
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mp4',
),
),
findsOneWidget,
);
expect(find.byIcon(BuzzIcons.play), findsOneWidget);
});
testWidgets('derives a first frame for a posterless video event', (
tester,
) async {
const videoUrl = 'https://example.com/media/legacy-video.mp4';
var disposed = false;
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video]($videoUrl)',
tags: [
['imeta', 'url $videoUrl', 'm video/mp4', 'dim 1080x1920'],
],
),
videoPreviewFrameLoader: (url) async {
expect(url, videoUrl);
return LoadedVideoPreviewFrame(
aspectRatio: 9 / 16,
child: const ColoredBox(
key: ValueKey('derived-video-frame'),
color: Colors.blue,
),
dispose: () async => disposed = true,
);
},
),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey('message-media-video-first-frame:$videoUrl'),
),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('derived-video-frame')),
findsOneWidget,
);
expect(find.text('Video attachment'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(disposed, isTrue);
});
testWidgets(
'tapping video preview opens overlay viewer with close button',
(tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video](https://example.com/media/clip.mp4)',
tags: [
[
'imeta',
'url https://example.com/media/clip.mp4',
'm video/mp4',
],
],
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
final preview = find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mp4',
),
);
expect(preview, findsOneWidget);
await tester.tap(preview);
await tester.pumpAndSettle();
// Video viewer opens as a modal overlay (no AppBar)
final viewer = tester.widget<Scaffold>(
find.byKey(const ValueKey('message-media-video-viewer')),
);
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsOneWidget,
);
expect(viewer.backgroundColor, Colors.black);
expect(viewer.appBar, isNull);
expect(
find.descendant(
of: find.byKey(
const ValueKey('message-media-video-viewer-reply-thread'),
),
matching: find.byType(IconButton),
),
findsNothing,
);
// Close button is present
expect(
find.byKey(const ValueKey('message-media-video-viewer-close')),
findsOneWidget,
);
// Tapping close dismisses the viewer
await tester.tap(
find.byKey(const ValueKey('message-media-video-viewer-close')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsNothing,
);
},
);
testWidgets('swiping down on the video dismisses its viewer', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video](https://example.com/media/clip.mp4)',
tags: [
[
'imeta',
'url https://example.com/media/clip.mp4',
'm video/mp4',
],
],
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mp4',
),
),
);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('message-media-video-viewer-gesture')),
const Offset(0, 140),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsNothing,
);
});
testWidgets('treats only mp4 fallback URLs as videos', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '''
![mp4](https://example.com/media/clip.mp4)
![mov](https://example.com/media/clip.mov)
''',
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mp4',
),
),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mov',
),
),
findsNothing,
);
expect(
find.byKey(
const ValueKey(
'message-media-image-preview:https://example.com/media/clip.mov',
),
),
findsOneWidget,
);
});
testWidgets('renders an explicitly tagged non-mp4 video preview', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '![video](https://example.com/media/clip.mov)',
tags: [
[
'imeta',
'url https://example.com/media/clip.mov',
'm video/quicktime',
],
],
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(
const ValueKey(
'message-media-video-preview:https://example.com/media/clip.mov',
),
),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey(
'message-media-image-preview:https://example.com/media/clip.mov',
),
),
findsNothing,
);
});
});
group('blockquotes', () {
testWidgets('renders blockquote with left border', (tester) async {
await tester.pumpWidget(
_testable(const MessageContent(content: '> This is a quote')),
);
final allText = _allRichText(tester);
expect(allText, contains('This is a quote'));
// Should strip the > prefix.
expect(allText, isNot(contains('> This')));
});
});
group('Buzz permalink chips', () {
testWidgets('keeps authored Buzz labels as ordinary links', (
tester,
) async {
final owner = 'ab' * 32;
final id = 'cd' * 32;
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
final links = {
'Open message': 'buzz://message?channel=$channelId&id=$id',
'Open channel': 'buzz://channel/$channelId',
'Release candidate': 'buzz://pr?id=$id&owner=$owner&d=buzz',
};
await tester.pumpWidget(
_testable(
MessageContent(
content: links.entries
.map((entry) => '[${entry.key}](${entry.value})')
.join(' '),
),
),
);
await tester.pump();
for (final entry in links.entries) {
expect(
find.byKey(ValueKey('buzz-link-chip:${entry.value}')),
findsNothing,
);
expect(find.text(entry.key), findsOneWidget);
}
});
testWidgets('preserves formatting in authored Buzz labels', (
tester,
) async {
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
await tester.pumpWidget(
_testable(
const MessageContent(
content: '[**design discussion**](buzz://channel/$channelId)',
),
),
);
await tester.pump();
expect(_hasBoldSpan(tester, 'design discussion'), isTrue);
});
testWidgets(
'renders message, channel, repo, PR, and issue links as chips',
(tester) async {
final owner = 'ab' * 32;
final id = 'cd' * 32;
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
final urls = [
'buzz://message?channel=$channelId&id=$id',
'buzz://channel/$channelId',
'buzz://repo?owner=$owner&d=buzz',
'buzz://pr?id=$id&owner=$owner&d=buzz',
'buzz://issue?id=$id&owner=$owner&d=buzz',
];
await tester.pumpWidget(
_testable(
MessageContent(
content: urls.join(' '),
channelNames: const {'engineering': channelId},
),
),
);
await tester.pump();
for (final url in urls) {
expect(find.byKey(ValueKey('buzz-link-chip:$url')), findsOneWidget);
}
expect(find.text('engineering · cdcdcdcd'), findsOneWidget);
expect(find.text('engineering'), findsOneWidget);
expect(find.text('buzz'), findsOneWidget);
expect(find.text('buzz · cdcdcdcd'), findsNWidgets(2));
expect(find.byIcon(BuzzIcons.messageSquare), findsOneWidget);
expect(find.byIcon(BuzzIcons.hash), findsOneWidget);
expect(find.byIcon(BuzzIcons.folderGit2), findsOneWidget);
expect(find.byIcon(BuzzIcons.gitPullRequest), findsOneWidget);
expect(find.byIcon(BuzzIcons.circleDot), findsOneWidget);
expect(
find.bySemanticsLabel(
'Open message cdcdcdcd in channel engineering',
),
findsOneWidget,
);
expect(
find.bySemanticsLabel('Pull request cdcdcdcd in repository buzz'),
findsOneWidget,
);
for (final url in urls.skip(2)) {
final chipKey = ValueKey('buzz-link-chip:$url');
final ignoredChip = find.ancestor(
of: find.byKey(chipKey),
matching: find.byWidgetPredicate(
(widget) => widget is IgnorePointer && widget.ignoring,
),
);
expect(ignoredChip, findsOneWidget, reason: url);
expect(
tester.widget<IgnorePointer>(ignoredChip).ignoring,
isTrue,
reason: url,
);
}
},
);
testWidgets('uses shortened channel identifiers when names are missing', (
tester,
) async {
final id = 'cd' * 32;
const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32';
final messageUrl = 'buzz://message?channel=$channelId&id=$id';
const channelUrl = 'buzz://channel/$channelId';
await tester.pumpWidget(
_testable(MessageContent(content: '$messageUrl $channelUrl')),
);
await tester.pump();
expect(find.text('580ca78b · cdcdcdcd'), findsOneWidget);
expect(find.text('580ca78b'), findsOneWidget);
});
});
group('@mentions', () {
testWidgets('renders @mention with highlight', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Hey @Alice check this out',
mentionNames: {'pk1': 'Alice'},
),
),
);
// The desktop-style mention chip renders the prefix and label
// separately so they can be aligned independently.
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('renders a known agent mention with the bot chip', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Ask @Helper Bot to investigate',
mentionNames: {'agent-pubkey': 'Helper Bot'},
agentMentionPubkeys: {'agent-pubkey'},
maxLines: 2,
),
),
);
expect(find.byType(BuzzAgentIcon), findsOneWidget);
expect(find.text('@'), findsNothing);
expect(find.text('Helper Bot'), findsOneWidget);
});
testWidgets('normalizes passed multi-word agent mentions', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Ask @Helper Bot to investigate',
mentionNames: {'agent-pubkey': 'Helper Bot'},
agentMentionPubkeys: {'agent-pubkey'},
),
),
);
expect(find.byType(BuzzAgentIcon), findsOneWidget);
expect(find.text('Helper Bot'), findsOneWidget);
expect(_allRichText(tester), isNot(contains('Bot Bot')));
});
testWidgets('highlights an entire multi-word display name', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Hey @Kenny Lopez can you review this?',
mentionNames: {'pk1': 'Kenny Lopez'},
),
),
);
expect(find.text('@'), findsOneWidget);
expect(find.text('Kenny Lopez'), findsOneWidget);
expect(find.text('@Kenny'), findsNothing);
expect(_allRichText(tester), isNot(contains('Lopez Lopez')));
});
testWidgets('renders unknown @mention as-is', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Hey @unknown check this',
mentionNames: {},
),
),
);
expect(find.text('@'), findsOneWidget);
expect(find.text('unknown'), findsOneWidget);
});
testWidgets('does not treat email addresses as mentions', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Email alice@example.com for access',
mentionNames: {'pk1': 'Alice'},
),
),
);
expect(_allRichText(tester), contains('alice@example.com'));
expect(find.text('@example.com'), findsNothing);
});
testWidgets('mention tap callback fires with pubkey', (tester) async {
String? tappedPubkey;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @Alice check this out',
mentionNames: const {'pk1': 'Alice'},
onMentionTap: (pubkey) => tappedPubkey = pubkey,
),
),
);
await tester.tap(find.text('Alice'));
expect(tappedPubkey, 'pk1');
});
testWidgets('multi-word mention tap callback fires with pubkey', (
tester,
) async {
String? tappedPubkey;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @Kenny Lopez can you review this?',
mentionNames: const {'pk1': 'Kenny Lopez'},
onMentionTap: (pubkey) => tappedPubkey = pubkey,
),
),
);
await tester.tap(find.text('Kenny Lopez'));
expect(tappedPubkey, 'pk1');
});
testWidgets('unknown mention renders without tap', (tester) async {
var tapped = false;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'Hey @unknown check this',
mentionNames: const {},
onMentionTap: (_) => tapped = true,
),
),
);
await tester.tap(find.text('unknown'), warnIfMissed: false);
expect(tapped, isFalse);
});
});
group('#channel links', () {
testWidgets('renders #channel with highlight', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Check out #general',
channelNames: {'general': 'ch-id-1'},
),
),
);
expect(find.byIcon(BuzzIcons.hash), findsOneWidget);
expect(find.text('general'), findsOneWidget);
expect(find.text('#general'), findsNothing);
});
testWidgets('channel tap callback fires', (tester) async {
String? tappedId;
await tester.pumpWidget(
_testable(
MessageContent(
content: 'See #general',
channelNames: const {'general': 'ch-id-1'},
onChannelTap: (id) => tappedId = id,
),
),
);
await tester.tap(find.text('general'));
expect(tappedId, 'ch-id-1');
});
testWidgets('resolved #channel defaults to in-app navigation', (
tester,
) async {
final channels = Future.value([
Channel(
id: '580ca78b-9dae-46f3-8854-bd671853ba32',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'creator',
createdAt: DateTime(2026),
memberCount: 1,
isMember: true,
),
]);
await tester.pumpWidget(
_testable(
const MessageContent(content: 'See #general'),
overrides: [
channelsProvider.overrideWith(
() => _TestChannelsNotifier(channels),
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('general'));
await tester.pump();
final container = ProviderScope.containerOf(
tester.element(find.byType(MessageContent)),
);
expect(
container.read(pendingDeepLinkProvider),
const ChannelDeepLink(
channelId: '580ca78b-9dae-46f3-8854-bd671853ba32',
),
);
});
testWidgets('unknown channel renders without tap', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(content: 'Check #unknown', channelNames: {}),
),
);
expect(find.byIcon(BuzzIcons.hash), findsOneWidget);
expect(find.text('unknown'), findsOneWidget);
expect(find.text('#unknown'), findsNothing);
});
testWidgets('does not treat URL fragments as channel links', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'See https://example.com/docs#frag',
channelNames: {'frag': 'ch-id-1'},
),
),
);
expect(_allRichText(tester), contains('https://example.com/docs#frag'));
expect(find.text('#frag'), findsNothing);
});
});
// Regression: https://github.com/block/buzz/issues/6124
//
// gpt_markdown renders a link's label by recursing with
// `MarkdownScope.linkLabel`. A token component that claims the label
// returns a WidgetSpan, which then sits inside the link's own WidgetSpan
// — and a placeholder nested in a placeholder does not paint on iOS, so
// the whole link disappears. The components opt out of that scope; these
// tests fail if any of the `scopes` overrides is removed.
group('authored link labels', () {
testWidgets('a #channel label stays link text, not a channel pill', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'See [#2959](https://example.com/x) for details.',
// Known name and generic token both reach the component; use the
// known one so the pill would definitely render if not excluded.
channelNames: {'2959': 'ch-id-1'},
),
),
);
expect(_findRich('#2959'), findsOneWidget);
expect(find.byIcon(BuzzIcons.hash), findsNothing);
expect(_hasNestedPlaceholder(tester), isFalse);
});
testWidgets('an @mention label stays link text, not a mention pill', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Ask [@Alice](https://example.com/x) about it.',
mentionNames: {'pk1': 'Alice'},
),
),
);
expect(_findRich('@Alice'), findsOneWidget);
// The pill splits the label into a separate '@' and name; the link
// must not.
expect(find.text('@'), findsNothing);
expect(_hasNestedPlaceholder(tester), isFalse);
});
testWidgets('a label with no token renders unchanged', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'See [ticket 2959](https://example.com/x) for details.',
channelNames: {'2959': 'ch-id-1'},
),
),
);
expect(_findRich('ticket 2959'), findsOneWidget);
expect(_hasNestedPlaceholder(tester), isFalse);
});
testWidgets('tokens outside a link label still render as pills', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'See #2959 and @Alice, plus [#2959](https://x.test/y).',
channelNames: {'2959': 'ch-id-1'},
mentionNames: {'pk1': 'Alice'},
),
),
);
// Excluding the link-label scope must not disable the components
// everywhere else: the bare tokens keep their pills.
expect(find.byIcon(BuzzIcons.hash), findsOneWidget);
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(_findRich('#2959'), findsOneWidget);
});
});
group('mixed content', () {
testWidgets('renders bold with mentions', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '**Important** @Alice please review',
mentionNames: {'pk1': 'Alice'},
),
),
);
expect(_hasBoldSpan(tester, 'Important'), isTrue);
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('preserves markdown around mentions', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: '**@Alice** please review',
mentionNames: {'pk1': 'Alice'},
),
),
);
expect(find.text('@'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(_allRichText(tester), isNot(contains('**')));
});
testWidgets('renders inline code in the app code style', (tester) async {
// The message surfaces pass this style in; the widget's own fallback
// is the smaller `bodyMedium`, which would move the expected size.
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Run `just test` now',
baseStyle: messageBodyTextStyle,
),
),
);
// gpt_markdown tags inline code with a CodeTextSpan carrying both the
// resolved text style and the colours the chip behind it is painted
// with. It renders through BidiRichText, a RichText subclass, so
// find.byType(RichText) would miss it.
final codeSpans = <CodeTextSpan>[];
for (final rich in tester.widgetList<RichText>(
find.byWidgetPredicate((widget) => widget is RichText),
)) {
rich.text.visitChildren((span) {
if (span is CodeTextSpan && span.text == 'just test') {
codeSpans.add(span);
}
return true;
});
}
expect(codeSpans, hasLength(1));
final code = codeSpans.single;
const scheme = lightColorScheme;
// Face, size and ink — the values a fenced code block is drawn with,
// not the package's bundled mono at its own 0.94 of the body size.
expect(code.style?.fontFamily, CodeStyle.fontFamily);
expect(code.style?.fontSize, closeTo(CodeStyle.fontSize, 0.001));
expect(code.style?.color, scheme.onSurface);
// Chip fill and outline come from the app's code surface rather than
// the package's `onSurface` tints.
expect(code.codeStyle.backgroundColor, CodeStyle.background(scheme));
expect(code.codeStyle.borderColor, CodeStyle.border(scheme));
});
testWidgets('renders code block between paragraphs', (tester) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Try this:\n```\nflutter test\n```\nDid it work?',
),
),
);
expect(_findRich('flutter test'), findsWidgets);
expect(_findRich('Try this:'), findsWidgets);
expect(_findRich('Did it work?'), findsWidgets);
});
});
});
}