import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; final _pngBytes = Uint8List.fromList([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, ]); final _gifBytes = Uint8List.fromList([ ...ascii.encode('GIF89a'), 0x02, 0x00, 0x02, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xfe, 0x05, ...ascii.encode('hello'), 0x00, 0x21, 0xff, 0x0b, ...ascii.encode('NETSCAPE2.0'), 0x03, 0x01, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, ]); final _apngBytes = Uint8List.fromList([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ..._testPngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]), ..._testPngChunk('IEND', const []), ]); List _testPngChunk(String type, List payload) { return [ payload.length >> 24 & 0xff, payload.length >> 16 & 0xff, payload.length >> 8 & 0xff, payload.length & 0xff, ...ascii.encode(type), ...payload, 0, 0, 0, 0, ]; } const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload'); void _setMockMediaUploadPlatformHandler( Future Function(MethodCall call)? handler, ) { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_mediaUploadPlatformChannel, handler); } /// Shared mock prefs for the compose bar's draft store. Initialized in /// [main]. late SharedPreferences _testPrefs; Widget _buildComposeBar({ required MediaUploadService uploadService, required ComposeBarOnSend onSend, List members = const [], Future>? membersFuture, List relayAgents = const [], List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, List customEmoji = const [], RelayConfigNotifier Function()? relayConfig, }) { return ProviderScope( overrides: [ customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), currentPubkeyProvider.overrideWith((ref) => currentPubkey), channelMembersProvider( 'channel-1', ).overrideWith((ref) => membersFuture ?? Future.value(members)), agentDirectoryProvider.overrideWith((ref) async => relayAgents), agentOwnersProvider.overrideWith((ref) async => const {}), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), relayConfigProvider.overrideWith( relayConfig ?? _FakeRelayConfigNotifier.new, ), savedPrefsProvider.overrideWithValue(_testPrefs), channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)), ], child: MaterialApp( theme: AppTheme.light(), builder: supportsShowingSystemContextMenu == null ? null : (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( supportsShowingSystemContextMenu: supportsShowingSystemContextMenu, ), child: child!, ), home: Scaffold( body: SafeArea( child: Align( alignment: Alignment.bottomCenter, child: ComposeBar(channelId: 'channel-1', onSend: onSend), ), ), ), ), ); } class _FakeRelayConfigNotifier extends RelayConfigNotifier { @override RelayConfig build() => RelayConfig( baseUrl: 'http://localhost:3000', nsec: nostr.Keys.generate().nsec, ); } /// Relay config that starts from a fixed identity and can be switched /// in place via [RelayConfigNotifier.update] — simulates a community or /// account switch while widgets stay mounted. class _SwitchableRelayConfigNotifier extends RelayConfigNotifier { final RelayConfig initial; _SwitchableRelayConfigNotifier(this.initial); @override RelayConfig build() => initial; } class _RecordingRelaySocket extends RelaySocket { final List> events; final void Function(List message) handleMessage; _RecordingRelaySocket(this.events, this.handleMessage) : super( wsUrl: 'ws://localhost', nsec: null, onMessage: handleMessage, onConnected: () {}, onDisconnected: (_) {}, ); @override SocketState get state => SocketState.connected; @override void send(List payload) { if (payload case ['EVENT', final Map event]) { events.add(event); final id = event['id'] as String; super.debugHandleOkForTest(['OK', id, true, '']); } } @override Future disconnect() async {} @override void dispose() {} } class _FakeChannelsNotifier extends ChannelsNotifier { final List _channels; _FakeChannelsNotifier(this._channels); @override Future> build() async => _channels; @override Future refresh() async { state = AsyncData(_channels); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); }); setUpAll(() { _setMockMediaUploadPlatformHandler((call) async { switch (call.method) { case 'sanitizeImageForUpload': final arguments = call.arguments as Map; return arguments['bytes'] as Uint8List; case 'transcodeImageToJpeg': return _pngBytes; case 'clipboardHasImage': return true; default: return null; } }); }); tearDownAll(() { _setMockMediaUploadPlatformHandler(null); }); group('ComposeBar', () { testWidgets('mounted composer does not carry draft text across an in-place ' 'identity switch', (tester) async { final keysA = nostr.Keys.generate(); final keysB = nostr.Keys.generate(); const relayUrl = 'http://localhost:3000'; await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(keysA.nsec), relayConfig: () => _SwitchableRelayConfigNotifier( RelayConfig(baseUrl: relayUrl, nsec: keysA.nsec), ), onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); String? storedText(nostr.Keys keys) { final raw = _testPrefs.getString( 'compose_drafts_v1:$relayUrl:${keys.public}', ); if (raw == null) return null; final drafts = jsonDecode(raw) as List; if (drafts.isEmpty) return null; return (drafts.first as Map)['text'] as String?; } // Identity A types a draft; it persists into A's namespaced store. await _expandComposer(tester); await tester.enterText(find.byType(TextField), 'identity A secret draft'); await tester.pump(); expect(storedText(keysA), 'identity A secret draft'); // Switch identity in place while the composer stays mounted. final container = ProviderScope.containerOf( tester.element(find.byType(ComposeBar)), ); container .read(relayConfigProvider.notifier) .update(baseUrl: relayUrl, nsec: keysB.nsec); await tester.pumpAndSettle(); // The mounted composer must not carry identity A's text forward. final textField = tester.widget(find.byType(TextField)); expect(textField.controller!.text, isEmpty); expect(storedText(keysB), isNull); // Identity B's edits persist only into B's store; A's is untouched. await tester.enterText(find.byType(TextField), 'identity B text'); await tester.pump(); expect(storedText(keysB), 'identity B text'); expect(storedText(keysA), 'identity A secret draft'); // Switching back restores identity A's own draft into the composer. container .read(relayConfigProvider.notifier) .update(baseUrl: relayUrl, nsec: keysA.nsec); await tester.pumpAndSettle(); expect(textField.controller!.text, 'identity A secret draft'); expect(storedText(keysB), 'identity B text'); }); testWidgets('inserts a community emoji at the cursor from the action row', ( tester, ) async { await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(nostr.Keys.generate().nsec), customEmoji: const [ CustomEmoji(shortcode: 'meow', url: 'https://example.com/meow.png'), ], onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); await tester.enterText(find.byType(TextField), 'hello world'); final textField = tester.widget(find.byType(TextField)); textField.controller!.selection = const TextSelection.collapsed( offset: 6, ); await tester.tap(find.byIcon(LucideIcons.smilePlus)); await tester.pumpAndSettle(); await tester.tap(find.byIcon(LucideIcons.sparkles)); await tester.pump(); await tester.tap(find.byTooltip(':meow:')); await tester.pumpAndSettle(); expect(textField.controller!.text, 'hello :meow:world'); expect(textField.controller!.selection.baseOffset, 12); }); testWidgets('uploads an image and sends markdown plus imeta tags', ( tester, ) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( jsonEncode({ 'url': 'https://relay.example/media/test.png', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 16, 'type': 'image/png', 'uploaded': 1, 'thumb': 'https://relay.example/media/test.thumb.jpg', }), 200, ); }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); String? sentContent; List> sentMediaTags = const []; await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { sentContent = content; sentMediaTags = mediaTags; }, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); await _expandComposer(tester); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); expect(sentContent, '\n![image](https://relay.example/media/test.png)'); expect(sentMediaTags, hasLength(1)); expect(sentMediaTags.first.first, 'imeta'); expect( sentMediaTags.first, contains('url https://relay.example/media/test.png'), ); expect(find.byTooltip('Remove attachment'), findsNothing); }); testWidgets('keeps upload progress visible after the picker closes', ( tester, ) async { final pickedImage = Completer(); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, pickGalleryVideo: () async => null, pickGalleryImage: () => pickedImage.future, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pump(); expect( find.byKey(const ValueKey('compose-upload-progress')), findsOneWidget, ); expect(find.text('Uploading attachment…'), findsOneWidget); pickedImage.complete(null); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('compose-upload-progress')), findsNothing, ); }); testWidgets('renders markdown formatting without visible delimiters', ( tester, ) async { await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(nostr.Keys.generate().nsec), onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); await tester.enterText( find.byType(TextField), '**bold** _italic_ ~~strike~~ `code`', ); final textField = tester.widget(find.byType(TextField)); final textSpan = textField.controller!.buildTextSpan( context: tester.element(find.byType(TextField)), style: tester.element(find.byType(TextField)).textTheme.bodyLarge, withComposing: false, ); final spans = _flattenStyledTextSpans(textSpan); expect(textSpan.toPlainText(), '**bold** _italic_ ~~strike~~ `code`'); expect( spans.singleWhere((span) => span.text == 'bold').style.fontWeight, FontWeight.w700, ); expect( spans.singleWhere((span) => span.text == 'italic').style.fontStyle, FontStyle.italic, ); expect( spans.singleWhere((span) => span.text == 'strike').style.decoration, TextDecoration.lineThrough, ); expect( spans.singleWhere((span) => span.text == 'code').style.fontFamily, 'GeistMono', ); expect( spans .where((span) => {'**', '_', '~~', '`'}.contains(span.text)) .every((span) => (span.style.fontSize ?? 1) < 1), isTrue, ); textField.controller!.value = textField.controller!.value.copyWith( composing: const TextRange(start: 2, end: 6), ); final composingSpan = textField.controller!.buildTextSpan( context: tester.element(find.byType(TextField)), style: tester.element(find.byType(TextField)).textTheme.bodyLarge, withComposing: true, ); final composingSpans = _flattenStyledTextSpans(composingSpan); expect( composingSpans .where((span) => span.text == '**') .every((span) => (span.style.fontSize ?? 1) < 1), isTrue, ); expect( composingSpans .singleWhere((span) => span.text == 'bold') .style .decoration, TextDecoration.underline, ); }); testWidgets('uses the primary color for formatting actions', ( tester, ) async { await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(nostr.Keys.generate().nsec), onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); await tester.tap(find.byIcon(LucideIcons.aLargeSmall)); await tester.pumpAndSettle(); final boldFinder = find.byIcon(LucideIcons.bold); final boldIcon = tester.widget(boldFinder); final colors = tester.element(boldFinder).colors; expect(boldIcon.color, colors.primary); }); testWidgets('pasted image follows the attachment preview and send path', ( tester, ) async { final keychain = nostr.Keys.generate(); var galleryPickerCalled = false; Uint8List? uploadedBytes; String? uploadedMimeType; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: keychain.nsec, httpClient: http_testing.MockClient((request) async { uploadedBytes = request.bodyBytes; uploadedMimeType = request.headers['Content-Type']; return http.Response( jsonEncode({ 'url': 'https://relay.example/media/pasted.png', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 16, 'type': 'image/png', 'uploaded': 1, 'thumb': 'https://relay.example/media/pasted.thumb.jpg', }), 200, ); }), pickGalleryVideo: () async => null, pickGalleryImage: () async { galleryPickerCalled = true; return null; }, ); String? sentContent; List> sentMediaTags = const []; await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { sentContent = content; sentMediaTags = mediaTags; }, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final insertionConfiguration = textField.contentInsertionConfiguration; expect(insertionConfiguration, isNotNull); expect( insertionConfiguration!.allowedMimeTypes, containsAll(['image/jpeg', 'image/png', 'image/webp']), ); insertionConfiguration.onContentInserted( KeyboardInsertedContent( mimeType: 'image/png', uri: 'content://clipboard/pasted.png', data: _pngBytes, ), ); await tester.pumpAndSettle(); expect(galleryPickerCalled, isFalse); expect(uploadedBytes, _pngBytes); expect(uploadedMimeType, 'image/png'); expect( find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/pasted.png', ), ), findsOneWidget, ); expect(find.byTooltip('Remove attachment'), findsOneWidget); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); expect(sentMediaTags, hasLength(1)); expect( sentMediaTags.single, contains('url https://relay.example/media/pasted.png'), ); }); testWidgets('iOS native context menu preserves defaults and pastes image', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, httpClient: http_testing.MockClient( (request) async => http.Response( jsonEncode({ 'url': 'https://relay.example/media/ios-native-paste.png', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 16, 'type': 'image/png', 'uploaded': 1, }), 200, ), ), pickGalleryVideo: () async => null, pickGalleryImage: () async => null, readClipboardImage: () async => _pngBytes, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, supportsShowingSystemContextMenu: true, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), ); final defaultItems = SystemContextMenu.getDefaultItems( editableTextState, ); final menu = textField.contextMenuBuilder!( tester.element(find.byType(TextField)), editableTextState, ) as SystemContextMenu; final pasteImage = menu.items.first as IOSSystemContextMenuItemCustom; expect(pasteImage.title, 'Paste Image'); expect(menu.items.skip(1), orderedEquals(defaultItems)); pasteImage.onPressed(); await tester.pumpAndSettle(); expect( find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/ios-native-paste.png', ), ), findsOneWidget, ); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('iOS hides Paste Image when clipboard has no image', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; _setMockMediaUploadPlatformHandler((call) async { if (call.method == 'clipboardHasImage') return false; return null; }); try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => null, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, supportsShowingSystemContextMenu: true, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await tester.pump(); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), ); final menu = textField.contextMenuBuilder!( tester.element(find.byType(TextField)), editableTextState, ) as SystemContextMenu; expect(menu.items.whereType(), isEmpty); } finally { _setMockMediaUploadPlatformHandler((call) async { switch (call.method) { case 'sanitizeImageForUpload': final arguments = call.arguments as Map; return arguments['bytes'] as Uint8List; case 'transcodeImageToJpeg': return _pngBytes; case 'clipboardHasImage': return true; default: return null; } }); debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('iOS adaptive Paste Image reads the clipboard into shared path', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, httpClient: http_testing.MockClient( (request) async => http.Response( jsonEncode({ 'url': 'https://relay.example/media/ios-paste.png', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 16, 'type': 'image/png', 'uploaded': 1, }), 200, ), ), pickGalleryVideo: () async => null, pickGalleryImage: () async => null, readClipboardImage: () async => _pngBytes, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), ); final menu = textField.contextMenuBuilder!( tester.element(find.byType(TextField)), editableTextState, ) as AdaptiveTextSelectionToolbar; final pasteImage = menu.buttonItems!.singleWhere( (item) => item.label == 'Paste Image', ); pasteImage.onPressed!(); await tester.pumpAndSettle(); expect( find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/ios-paste.png', ), ), findsOneWidget, ); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('shows an error when pasted image bytes are unavailable', ( tester, ) async { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => null, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); textField.contentInsertionConfiguration!.onContentInserted( const KeyboardInsertedContent( mimeType: 'image/png', uri: 'content://clipboard/unavailable.png', ), ); await tester.pump(); expect(find.text('Unable to read pasted image'), findsOneWidget); expect(find.byTooltip('Remove attachment'), findsNothing); }); testWidgets('iOS Paste Image reports an unavailable clipboard image', ( tester, ) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => null, readClipboardImage: () async => null, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), ); final menu = textField.contextMenuBuilder!( tester.element(find.byType(TextField)), editableTextState, ) as AdaptiveTextSelectionToolbar; menu.buttonItems! .singleWhere((item) => item.label == 'Paste Image') .onPressed!(); await tester.pumpAndSettle(); expect(find.text('Unable to read pasted image'), findsOneWidget); expect(find.byTooltip('Remove attachment'), findsNothing); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } }); testWidgets('does not add Paste Image to non-iOS context menus', ( tester, ) async { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nostr.Keys.generate().nsec, pickGalleryVideo: () async => null, pickGalleryImage: () async => null, ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), ); final menu = textField.contextMenuBuilder!( tester.element(find.byType(TextField)), editableTextState, ) as AdaptiveTextSelectionToolbar; expect( menu.buttonItems!.where((item) => item.label == 'Paste Image'), isEmpty, ); }); testWidgets('keeps the remove button pinned to the attachment corner', ( tester, ) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( jsonEncode({ 'url': 'https://relay.example/media/test.png', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 16, 'type': 'image/png', 'uploaded': 1, }), 200, ); }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/test.png', ), ); final removeButtonFinder = find.byTooltip('Remove attachment'); expect(attachmentFinder, findsOneWidget); expect(removeButtonFinder, findsOneWidget); final attachmentTopRight = tester.getTopRight(attachmentFinder); final attachmentTopLeft = tester.getTopLeft(attachmentFinder); final removeButtonCenter = tester.getCenter(removeButtonFinder); expect( attachmentTopRight.dx - removeButtonCenter.dx, lessThanOrEqualTo(16), ); expect( removeButtonCenter.dy - attachmentTopLeft.dy, lessThanOrEqualTo(16), ); }); testWidgets('shows an upload error when gallery upload fails', ( tester, ) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response('bad upload', 401); }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); }); for (final statusCode in [ HttpStatus.unsupportedMediaType, HttpStatus.unprocessableEntity, ]) { testWidgets('shows friendly copy for a $statusCode upload response', ( tester, ) async { final keychain = nostr.Keys.generate(); final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: keychain.nsec, httpClient: http_testing.MockClient( (request) async => http.Response( '{"error":"media contains metadata or a non-canonical metadata channel"}', statusCode, ), ), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( find.text("We couldn't prepare this image for upload."), findsOneWidget, ); expect(find.textContaining('media contains metadata'), findsNothing); expect(find.textContaining('$statusCode'), findsNothing); }); } testWidgets('adds a sanitized GIF attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( jsonEncode({ 'url': 'https://relay.example/media/animated.gif', 'sha256': '4444444444444444444444444444444444444444444444444444444444444444', 'size': request.bodyBytes.length, 'type': 'image/gif', 'uploaded': 1, }), 200, ); }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/animated.gif', ), ), findsOneWidget, ); }); testWidgets('adds a selected non-member agent as a bot before sending', ( tester, ) async { final agentPubkey = 'c' * 64; final signer = nostr.Keys.generate(); final publishedEvents = >[]; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: signer.nsec, pickGalleryImage: () async => null, pickGalleryVideo: () async => null, ); String? sentContent; List sentMentionPubkeys = const []; await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, currentPubkey: signer.public, relayAgents: [ AgentDirectoryEntry( pubkey: agentPubkey, displayName: 'Helper Bot', respondTo: 'anyone', channelIds: const ['shared-channel'], ), ], channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { sentContent = content; sentMentionPubkeys = mentionPubkeys; }, ), ); final container = ProviderScope.containerOf( tester.element(find.byType(ComposeBar)), ); final session = container.read(relaySessionProvider.notifier); final socket = _RecordingRelaySocket( publishedEvents, session.debugHandleSocketMessageForTest, ); session.debugAttachSocketForTest(socket); await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, 'hello @Helper Bot'); expect(sentMentionPubkeys, [agentPubkey]); final addMemberEvent = publishedEvents.singleWhere( (event) => event['kind'] == 9000, ); expect(addMemberEvent['tags'], [ ['h', 'channel-1'], ['p', agentPubkey], ['role', 'bot'], ]); }); testWidgets('does not mutate a DM when mentioning a non-member agent', ( tester, ) async { final agentPubkey = 'd' * 64; final signer = nostr.Keys.generate(); final publishedEvents = >[]; String? sentContent; await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(signer.nsec), currentPubkey: signer.public, relayAgents: [_testAgent(agentPubkey)], channels: [ _makeCurrentChannel(channelType: 'dm'), _makeSharedMemberChannel(), ], onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { sentContent = content; }, ), ); final container = ProviderScope.containerOf( tester.element(find.byType(ComposeBar)), ); final session = container.read(relaySessionProvider.notifier); final socket = _RecordingRelaySocket( publishedEvents, session.debugHandleSocketMessageForTest, ); session.debugAttachSocketForTest(socket); await _selectAndSendAgentMention(tester); expect(sentContent, 'hello @Helper Bot'); expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); }); testWidgets('waits for current member data before adding an agent', ( tester, ) async { final agentPubkey = 'e' * 64; final signer = nostr.Keys.generate(); final membersCompleter = Completer>(); final publishedEvents = >[]; var didSend = false; await tester.pumpWidget( _buildComposeBar( uploadService: _testUploadService(signer.nsec), currentPubkey: signer.public, membersFuture: membersCompleter.future, relayAgents: [_testAgent(agentPubkey)], channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { didSend = true; }, ), ); final container = ProviderScope.containerOf( tester.element(find.byType(ComposeBar)), ); final session = container.read(relaySessionProvider.notifier); final socket = _RecordingRelaySocket( publishedEvents, session.debugHandleSocketMessageForTest, ); session.debugAttachSocketForTest(socket); await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); expect(didSend, isFalse); expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); membersCompleter.complete([ ChannelMember( pubkey: agentPubkey, role: 'admin', joinedAt: DateTime(2024), ), ]); await tester.pumpAndSettle(); expect(didSend, isTrue); expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); }); testWidgets('adds a sanitized animated PNG attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( jsonEncode({ 'url': 'https://relay.example/media/animated.png', 'sha256': '5555555555555555555555555555555555555555555555555555555555555555', 'size': request.bodyBytes.length, 'type': 'image/png', 'uploaded': 1, }), 200, ); }), pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async {}, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( find.byKey( const ValueKey( 'compose-attachment:https://relay.example/media/animated.png', ), ), findsOneWidget, ); }); // Skip: video upload relies on native platform bridging // (transcodeVideoToMp4) that can't be fully mocked in widget tests. testWidgets('taps Video in chooser sheet and uploads video', skip: true, ( tester, ) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; // Build a temp file with a valid MP4 ftyp header (isom brand). final mp4Bytes = Uint8List(32); mp4Bytes[3] = 32; mp4Bytes[4] = 0x66; // f mp4Bytes[5] = 0x74; // t mp4Bytes[6] = 0x79; // y mp4Bytes[7] = 0x70; // p mp4Bytes[8] = 0x69; // i mp4Bytes[9] = 0x73; // s mp4Bytes[10] = 0x6F; // o mp4Bytes[11] = 0x6D; // m final tempDir = await Directory.systemTemp.createTemp('compose_video_'); final tempFile = File('${tempDir.path}/clip.mp4'); await tempFile.writeAsBytes(mp4Bytes); try { final uploadService = MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, httpClient: http_testing.MockClient((request) async { return http.Response( jsonEncode({ 'url': 'https://relay.example/media/test.mp4', 'sha256': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'size': 1024, 'type': 'video/mp4', 'uploaded': 1, }), 200, ); }), pickGalleryVideo: () async => XFile(tempFile.path), pickGalleryImage: () async => null, ); String? sentContent; await tester.pumpWidget( _buildComposeBar( uploadService: uploadService, onSend: ( content, mentionPubkeys, { mediaTags = const >[], }) async { sentContent = content; }, ), ); await _openAttachmentMenu(tester); await tester.tap(find.text('Video')); // Pump enough frames for the async file read + upload to complete. // Can't use pumpAndSettle here — the upload spinner's animation // prevents settling while the async upload is in-flight. for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 100)); } // Video attachment should show a video icon (not a broken image). expect(find.byIcon(LucideIcons.video), findsOneWidget); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); } finally { await tempDir.delete(recursive: true); } }); }); group('findTrigger', () { test('finds @ at start of text', () { expect(findTrigger('@alice', 6, '@', stopAtSpace: false), 0); }); test('finds @ after a space', () { expect(findTrigger('hello @bob', 10, '@', stopAtSpace: false), 6); }); test('finds @ after a newline', () { expect(findTrigger('line1\n@bob', 10, '@', stopAtSpace: false), 6); }); test('returns null when @ is mid-word (no word boundary)', () { expect(findTrigger('foo@bar', 7, '@', stopAtSpace: false), isNull); }); test('@ with stopAtSpace:false walks through spaces', () { // "@Alice Smith" — cursor at end, should find @ at index 0. expect(findTrigger('@Alice Smith', 12, '@', stopAtSpace: false), 0); }); test('@ with stopAtSpace:false walks through spaces after prefix', () { expect(findTrigger('hey @Alice Smith', 16, '@', stopAtSpace: false), 4); }); test('finds # at start of text', () { expect(findTrigger('#general', 8, '#'), 0); }); test('finds # after a space', () { expect(findTrigger('hello #general', 14, '#'), 6); }); test('# with stopAtSpace:true stops at space', () { // "hello #chan name" with cursor at end — space stops the walk before #. expect(findTrigger('hello #chan name', 15, '#'), isNull); }); test('# stops at newline', () { expect(findTrigger('line1\n#foo', 10, '#'), 6); }); test('returns null when # is mid-word', () { expect(findTrigger('foo#bar', 7, '#'), isNull); }); test('returns null when cursor is 0', () { expect(findTrigger('@hello', 0, '@'), isNull); }); test('returns null on empty text', () { expect(findTrigger('', 0, '@'), isNull); }); test('finds trigger right at cursor boundary', () { // cursor=1, text="#", should find # at index 0. expect(findTrigger('#', 1, '#'), 0); }); }); group('filterChannels', () { final channels = [ _makeChannel(name: 'general', channelType: 'stream'), _makeChannel(name: 'random', channelType: 'stream'), _makeChannel(name: 'announcements', channelType: 'forum'), _makeChannel(name: 'design-team', channelType: 'stream'), _makeChannel(name: 'dm-alice-bob', channelType: 'dm'), ]; test('returns empty list when query is null', () { expect(filterChannels(channels, null), isEmpty); }); test('returns all non-DM channels when query is empty string', () { final result = filterChannels(channels, ''); expect(result.length, 4); expect(result.every((c) => c.channelType != 'dm'), isTrue); }); test('filters by substring match', () { final result = filterChannels(channels, 'gen'); expect(result.length, 1); expect(result.first.name, 'general'); }); test('is case-insensitive', () { final result = filterChannels(channels, 'RANDOM'); expect(result.length, 1); expect(result.first.name, 'random'); }); test('excludes DM channels', () { final result = filterChannels(channels, 'dm'); expect(result, isEmpty); }); test('matches partial channel names', () { final result = filterChannels(channels, 'design'); expect(result.length, 1); expect(result.first.name, 'design-team'); }); test('limits to 8 results', () { final manyChannels = List.generate( 15, (i) => _makeChannel(name: 'channel-$i', channelType: 'stream'), ); final result = filterChannels(manyChannels, ''); expect(result.length, 8); }); }); group('spliceAndMoveCursor', () { test('replaces text range and moves cursor', () { // Simulates "@ali|" with cursor right after the query (no trailing space // in the query portion). The replacement includes a trailing space, so // the original space before "world" is preserved as-is. final controller = TextEditingController(text: 'hello @ali world'); controller.selection = const TextSelection.collapsed(offset: 10); spliceAndMoveCursor( controller, FocusNode(), start: 6, replacement: '@Alice ', ); // [start=6, cursor=10) → "hello " + "@Alice " + " world" expect(controller.text, 'hello @Alice world'); expect(controller.selection.baseOffset, 13); // after "@Alice " }); test('replaces #channel query with channel name', () { final controller = TextEditingController(text: 'see #gen for details'); controller.selection = const TextSelection.collapsed(offset: 8); spliceAndMoveCursor( controller, FocusNode(), start: 4, replacement: '#general ', ); expect(controller.text, 'see #general for details'); expect(controller.selection.baseOffset, 13); // after "#general " }); test('handles replacement at start of text', () { final controller = TextEditingController(text: '@bo rest'); controller.selection = const TextSelection.collapsed(offset: 3); spliceAndMoveCursor( controller, FocusNode(), start: 0, replacement: '@Bob ', ); expect(controller.text, '@Bob rest'); expect(controller.selection.baseOffset, 5); }); test('handles replacement at end of text', () { final controller = TextEditingController(text: 'hello #gen'); controller.selection = const TextSelection.collapsed(offset: 10); spliceAndMoveCursor( controller, FocusNode(), start: 6, replacement: '#general ', ); expect(controller.text, 'hello #general '); expect(controller.selection.baseOffset, 15); }); test('clamps start to text bounds', () { final controller = TextEditingController(text: 'hi'); controller.selection = const TextSelection.collapsed(offset: 2); // start beyond text length should be clamped spliceAndMoveCursor( controller, FocusNode(), start: 0, replacement: '@Name ', ); expect(controller.text, '@Name '); expect(controller.selection.baseOffset, 6); }); }); } MediaUploadService _testUploadService(String nsec) { return MediaUploadService( baseUrl: 'https://relay.example', nsec: nsec, pickGalleryImage: () async => null, pickGalleryVideo: () async => null, ); } AgentDirectoryEntry _testAgent(String pubkey) { return AgentDirectoryEntry( pubkey: pubkey, displayName: 'Helper Bot', respondTo: 'anyone', channelIds: const ['shared-channel'], ); } Future _expandComposer(WidgetTester tester) async { if (find.byType(TextField).evaluate().isNotEmpty) return; await tester.tap(find.text('Message\u2026')); await tester.pumpAndSettle(); } Future _openAttachmentMenu(WidgetTester tester) async { await tester.tap(find.byTooltip('Add attachment').hitTestable()); await tester.pumpAndSettle(); } Future _selectAndSendAgentMention(WidgetTester tester) async { await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); } List<({String text, TextStyle style})> _flattenStyledTextSpans( InlineSpan root, ) { final result = <({String text, TextStyle style})>[]; void visit(InlineSpan span, TextStyle inheritedStyle) { if (span is! TextSpan) return; final effectiveStyle = inheritedStyle.merge(span.style); if (span.text case final text?) { result.add((text: text, style: effectiveStyle)); } for (final child in span.children ?? const []) { visit(child, effectiveStyle); } } visit(root, const TextStyle()); return result; } Channel _makeCurrentChannel({String channelType = 'stream'}) { return Channel( id: 'channel-1', name: 'current', channelType: channelType, visibility: 'open', description: '', createdBy: 'pubkey123', createdAt: DateTime(2024), memberCount: 2, isMember: true, ); } Channel _makeSharedMemberChannel() { return Channel( id: 'shared-channel', name: 'shared', channelType: 'stream', visibility: 'open', description: '', createdBy: 'pubkey123', createdAt: DateTime(2024), memberCount: 5, isMember: true, ); } Channel _makeChannel({required String name, required String channelType}) { return Channel( id: 'id-$name', name: name, channelType: channelType, visibility: 'open', description: '', createdBy: 'pubkey123', createdAt: DateTime(2024), memberCount: 5, ); }