Files
buzz/mobile/test/features/channels/message_attachment_rendering_test.dart
Tom Brow cec918ae7a perf(mobile): show a static placeholder for posterless videos (#9138)
Video messages without a NIP-71 poster (older events, or uploads where
poster generation failed) used to start a network
`VideoPlayerController` for each bubble, seek to 100 ms and show that
paused frame. Nothing cached the frame, and the decoder was disposed
whenever the bubble left the tree, so every scroll back opened a new
stream and decoded again.

Those bubbles now show the existing static "Video attachment"
placeholder with the play button. Nothing loads until the user taps, and
the full-screen viewer behaves as before. Videos that have a poster are
unchanged.

### Validation

- Full mobile Flutter test suite passes, and `flutter analyze` is clean.
- The change is almost pure deletion: the frame loader, its provider and
test seam, and the tests that covered it are removed.
- Limitation: no simulator run. A cloned golden sim built from this
branch signed in to the internal community, but its channel list never
finished loading, so I couldn't open a channel with a posterless video.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-05 14:36:05 -08:00

266 lines
8.7 KiB
Dart

import 'dart:convert';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/media_loading_placeholder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../helpers/media_codec_fixtures.dart';
const _base = 'https://relay.example';
Widget _app(
String content, {
List<List<String>> tags = const [],
List<Override> overrides = const [],
}) => ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: MessageContent(
content: content,
tags: tags,
channelNames: const {'general': 'channel-id'},
),
),
),
);
// Image codecs complete outside the fake test clock. Wait for actual pixels,
// rather than waiting for an indefinitely animated loading shimmer to settle.
Future<void> _waitForImages(WidgetTester tester) async {
for (var attempt = 0; attempt < 200; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump(const Duration(milliseconds: 16));
final images = tester.widgetList<RawImage>(find.byType(RawImage));
if (images.any((image) => image.image != null) &&
find.byType(MediaLoadingPlaceholder).evaluate().isEmpty) {
return;
}
}
fail('Media did not produce a decoded image frame');
}
void main() {
setUp(() {
MediaImageProvider.debugResetCooldowns();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
for (final fixture in mediaCodecFixtures.entries) {
for (final angled in [false, true]) {
testWidgets(
'${fixture.key} ${angled ? 'angle' : 'plain'} image downloads and opens',
(tester) async {
final url = '$_base/media/photo.${fixture.key}?download=1&v=2';
final requests = <http.Request>[];
final client = MockClient((request) async {
requests.add(request);
return http.Response.bytes(base64Decode(fixture.value), 200);
});
final auth = MediaGetAuthService(
baseUrl: _base,
nsec: nostr.Keys.generate().nsec,
);
await tester.pumpWidget(
_app(
'${angled ? 'oops [ text ' : 'Look\n'}'
'![photo \\[Q4\\]](${angled ? '<$url>' : url})',
tags: [
[
'imeta',
'url $url',
'm image/${fixture.key}',
'dim 2x2',
'alt Photo',
],
],
overrides: [
mediaHttpClientProvider.overrideWithValue(client),
mediaGetAuthServiceProvider.overrideWithValue(auth),
],
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(requests, hasLength(1));
expect(requests.single.url.toString(), url);
expect(
requests.single.headers['Authorization'],
startsWith('Nostr '),
);
expect(find.text('Image unavailable'), findsNothing);
expect(tester.takeException(), isNull);
await tester.tap(
find.byKey(ValueKey('message-media-image-preview:$url')),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(
tester
.widget<MediaImageViewerPage>(find.byType(MediaImageViewerPage))
.imageUrl,
url,
);
expect(find.text('Failed to load image'), findsNothing);
},
);
}
}
for (final title in ['(Video title)', r'"A \"preview\""']) {
for (final item in [
('mp4', 'video/mp4'),
('mov', 'video/quicktime'),
('webm', 'video/webm'),
('bin', 'video/mp4'),
]) {
testWidgets('${item.$1} uses video metadata with title $title', (
tester,
) async {
final url = '$_base/media/clip (1).${item.$1}';
final expected = '$_base/media/clip%20%281%29.${item.$1}';
await tester.pumpWidget(
_app(
'oops [ text ![clip \\[Q4\\]](<$url> $title)',
tags: [
['imeta', 'url $url', 'm ${item.$2}'],
],
),
);
await tester.pumpAndSettle();
expect(
find.byKey(ValueKey('message-media-video-preview:$expected')),
findsOneWidget,
);
expect(find.text('Image unavailable'), findsNothing);
});
}
for (final (stem, angled) in [
('report [Q4]', false),
('report [Q4]', true),
('report [Q4', true),
('report Q4]', true),
(r'report \ [Q4]', true),
]) {
for (final extension in ['pdf', 'txt', 'zip', 'mp3', 'svg']) {
testWidgets(
'$stem.$extension angled=$angled title $title opens with auth and filename',
(tester) async {
final url = '$_base/media/report.$extension';
final blob = BlobDescriptor(
url: url,
sha256: 'a' * 64,
size: 1,
type: 'application/octet-stream',
uploaded: 0,
filename: '$stem.$extension',
);
final markdown = blob.toMarkdownImage().replaceFirst(
'($url)',
angled ? '(<$url> $title)' : '($url)',
);
String? opened;
String? filename;
Map<String, String>? headers;
await tester.pumpWidget(
_app(
'${angled ? 'oops [ then ' : ''}$markdown',
overrides: [
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(
baseUrl: _base,
nsec: nostr.Keys.generate().nsec,
),
),
openDownloadedFileProvider.overrideWithValue((
url,
auth,
name,
) async {
opened = url;
headers = auth;
filename = name;
}),
],
),
);
await tester.tap(find.text('$stem.$extension'));
await tester.pump();
expect(opened, url);
expect(filename, '$stem.$extension');
expect(headers?['Authorization'], startsWith('Nostr '));
expect(find.byType(MediaImage), findsNothing);
},
);
}
}
}
testWidgets(
'mixed angle/plain gallery retains URLs, metadata and viewer items',
(tester) async {
const first = '$_base/media/photo(1).png';
const second = '$_base/media/photo2.png';
const normalizedFirst = '$_base/media/photo%281%29.png';
final client = MockClient(
(_) async =>
http.Response.bytes(base64Decode(mediaCodecFixtures['png']!), 200),
);
await tester.pumpWidget(
_app(
'oops [ text ![inline](<$second>)\n![first](<$first>)\n![second \\[Q4\\]]($second)',
tags: [
[
'imeta',
'url $first',
'm image/png',
'dim 2x2',
'alt First photo',
],
['imeta', 'url $second', 'm image/png', 'dim 2x2'],
],
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-preview:$second')),
findsOneWidget,
);
expect(find.text('2 images'), findsOneWidget);
expect(find.bySemanticsLabel('Open second [Q4]'), findsOneWidget);
expect(find.bySemanticsLabel('Open First photo'), findsOneWidget);
expect(find.text('Image unavailable'), findsNothing);
await tester.tap(
find.byKey(
const ValueKey('message-media-carousel-item:$normalizedFirst'),
),
);
await _waitForImages(tester);
await tester.pumpAndSettle();
final viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
expect(viewer.galleryItems!.map((item) => item.url), [
normalizedFirst,
second,
]);
},
);
}