import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:qlyra/backend/modules/messages.dart'; import 'package:qlyra/core/utils/text_format.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/bubble_context.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/call_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/contact_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/file_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/forwarded_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/location_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/photo_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/share_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/sticker_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/video_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/video_note_bubble.dart'; import 'package:qlyra/frontend/widgets/attachment/bubbles/voice_bubble.dart'; import 'package:qlyra/frontend/widgets/formatted_message_text.dart'; import 'package:qlyra/frontend/widgets/message_bubble.dart'; import 'package:qlyra/l10n/app_localizations.dart'; import 'package:qlyra/models/attachment.dart'; import 'package:material_symbols_icons/symbols.dart'; Future _pumpBubble( WidgetTester tester, CachedMessage message, { void Function(ForwardedMessageAttachment forwarded)? onSourceTap, bool isMe = false, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2.5; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( locale: const Locale('ru'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: Align( alignment: Alignment.topLeft, child: MessageBubble( message: message, isMe: isMe, myId: 1, chatType: 'CHAT', onForwardedSourceTap: onSourceTap, ), ), ), ), ); await tester.pump(); } void main() { group('ForwardedMessageAttachment', () { test('styles a server heading', () { final style = applyTextFormats(const TextStyle(fontSize: 16), { TextFormat.heading, }); expect(style.fontWeight, FontWeight.w700); expect(style.fontSize, greaterThan(16)); }); test('uses channel metadata as the original author', () { final attachment = ForwardedMessageAttachment.fromMap({ 'link': { 'type': 'FORWARD', 'message': { 'id': '101', 'time': 1000, 'type': 'CHANNEL', 'text': 'Synthetic channel message', 'attaches': [ {'_type': 'PHOTO', 'photoId': 11}, ], 'elements': [ {'type': 'HEADING', 'length': 9}, ], }, 'chatId': -10, 'chatName': 'Example Channel', 'chatIconUrl': 'https://example.test/channel.jpg', }, }); expect(attachment.originalSenderId, 0); expect(attachment.originalSenderName, 'Example Channel'); expect(attachment.isChannel, isTrue); expect(attachment.originalMessageId, '101'); expect(attachment.originalTime, 1000); expect( attachment.originalSenderAvatar, 'https://example.test/channel.jpg', ); expect(attachment.originalChatId, -10); expect(attachment.originalAttachments, hasLength(1)); expect(attachment.originalAttachments!.single, isA()); expect(attachment.originalFormatRanges, hasLength(1)); expect(attachment.originalFormatRanges.single.format, TextFormat.heading); }); test('keeps the user as the original author', () { final attachment = ForwardedMessageAttachment.fromMap({ 'link': { 'type': 'FORWARD', 'message': { 'id': '102', 'time': 2000, 'type': 'USER', 'sender': 42, 'text': '', 'attaches': const [], }, 'chatId': -20, 'chatName': 'Example Group', 'chatIconUrl': 'https://example.test/group.jpg', }, }); expect(attachment.originalSenderId, 42); expect(attachment.isChannel, isFalse); expect(attachment.originalSenderName, isNull); expect(attachment.originalSenderAvatar, isNull); }); test('keeps channel metadata in an optimistic forward', () { final forwarded = MessagesModule.buildForwardMessage( myId: 1, targetChatId: 2, sourceChatId: -10, source: const CachedMessage( id: '101', accountId: 1, chatId: -10, senderId: 0, text: 'Synthetic channel message', time: 1000, payload: { 'type': 'CHANNEL', 'attaches': [], 'elements': [ {'type': 'STRONG', 'length': 9}, ], }, ), tempId: 'temp_1', time: 3000, status: 'sending', sourceChatName: 'Example Channel', sourceChatIconUrl: 'https://example.test/channel.jpg', sourceChatType: 'CHANNEL', ); final attachment = forwarded.attachments!.single as ForwardedMessageAttachment; expect(attachment.originalSenderName, 'Example Channel'); expect( attachment.originalSenderAvatar, 'https://example.test/channel.jpg', ); expect(attachment.originalFormatRanges, hasLength(1)); }); test('keeps the original channel when forwarding a forward', () { final forwarded = MessagesModule.buildForwardMessage( myId: 1, targetChatId: 2, sourceChatId: 3, source: const CachedMessage( id: '201', accountId: 1, chatId: 3, senderId: 42, time: 3000, payload: { 'link': { 'type': 'FORWARD', 'message': { 'id': '101', 'time': 1000, 'type': 'CHANNEL', 'text': 'Synthetic channel message', 'attaches': [], }, 'chatId': -10, 'chatName': 'Example Channel', 'chatIconUrl': 'https://example.test/channel.jpg', }, }, ), tempId: 'temp_2', time: 4000, status: 'sending', sourceChatName: 'Current Chat', sourceChatIconUrl: 'https://example.test/current-chat.jpg', sourceChatType: 'CHAT', ); final attachment = forwarded.attachments!.single as ForwardedMessageAttachment; expect(attachment.originalSenderName, 'Example Channel'); expect( attachment.originalSenderAvatar, 'https://example.test/channel.jpg', ); }); testWidgets('renders a formatted caption with a forwarded photo', ( tester, ) async { const caption = 'Bold synthetic caption that wraps within the synthetic photo width'; final attachment = ForwardedMessageAttachment.fromMap({ 'link': { 'type': 'FORWARD', 'message': { 'id': '103', 'time': 5000, 'type': 'CHANNEL', 'text': caption, 'attaches': [ {'_type': 'PHOTO', 'photoId': 13, 'width': 200, 'height': 200}, ], 'elements': [ {'type': 'HEADING', 'length': 4}, ], }, 'chatId': -30, 'chatName': 'Another Example Channel', }, }); final message = CachedMessage( id: '202', accountId: 1, chatId: 2, senderId: 42, time: 6000, attachments: [attachment], ); ForwardedMessageAttachment? tappedSource; await _pumpBubble( tester, message, onSourceTap: (forwarded) => tappedSource = forwarded, ); expect(find.text('Another Example Channel'), findsOneWidget); expect(find.text(caption), findsOneWidget); final formatted = tester.widget( find.byType(FormattedMessageText), ); expect(formatted.ranges.single.format, TextFormat.heading); expect(find.text('0'), findsNothing); expect( tester.getSize(find.byType(ForwardedHeader)).width, closeTo(200, 0.1), ); expect( tester.getSize(find.byType(ForwardedHeader)).width, closeTo(tester.getSize(find.byType(PhotoBubble)).width, 0.1), ); expect( tester.getBottomLeft(find.byType(ClipRRect).first).dy, lessThanOrEqualTo(tester.getTopLeft(find.text(caption)).dy), ); await tester.tap(find.text('Another Example Channel')); expect(tappedSource, same(attachment)); expect(tappedSource?.isChannel, isTrue); }); testWidgets('renders a forwarded video with its original metadata', ( tester, ) async { final attachment = ForwardedMessageAttachment.fromMap({ 'link': { 'type': 'FORWARD', 'message': { 'id': 'synthetic-source-message', 'time': 9000, 'type': 'USER', 'sender': 44, 'text': 'Synthetic video caption', 'attaches': [ { '_type': 'VIDEO', 'videoId': 7001, 'token': 'synthetic-video-token', 'videoType': 0, 'duration': 9000, 'width': 720, 'height': 1280, }, ], }, 'chatId': -50, }, }); final video = attachment.originalAttachments!.single as VideoAttachment; final message = CachedMessage( id: 'synthetic-forward-message', accountId: 1, chatId: 2, senderId: 43, time: 10000, attachments: [attachment], ); expect(video.videoId, 7001); expect(video.videoToken, 'synthetic-video-token'); expect(video.videoType, 0); await _pumpBubble(tester, message); expect(find.byType(ForwardedHeader), findsOneWidget); expect(find.byType(VideoBubble), findsOneWidget); expect(find.text('Synthetic video caption'), findsOneWidget); expect(find.text('0:09'), findsOneWidget); final forwardedWidth = tester.getSize(find.byType(ForwardedHeader)).width; final videoWidth = tester.getSize(find.byType(VideoBubble)).width; expect(forwardedWidth, closeTo(BubbleContext.photoMaxSize, 0.1)); expect(forwardedWidth, closeTo(videoWidth, 0.1)); final preview = find.descendant( of: find.byType(VideoBubble), matching: find.byType(ClipRRect), ); expect( tester.getBottomLeft(preview.first).dy, lessThanOrEqualTo( tester.getTopLeft(find.text('Synthetic video caption')).dy, ), ); final bubble = tester.widget(find.byType(VideoBubble)); expect(bubble.ctx.sourceMessageId, 'synthetic-source-message'); expect(bubble.ctx.sourceChatId, -50); final forwardedVideoSize = tester.getSize(find.byType(VideoBubble)); final forwardedPreviewSize = tester.getSize(preview.first); final regularMessage = CachedMessage( id: 'synthetic-regular-message', accountId: 1, chatId: 2, senderId: 43, time: 10000, text: 'Synthetic video caption', attachments: [video], ); await _pumpBubble(tester, regularMessage); final regularPreview = find.descendant( of: find.byType(VideoBubble), matching: find.byType(ClipRRect), ); expect(tester.getSize(find.byType(VideoBubble)), forwardedVideoSize); expect(tester.getSize(regularPreview.first), forwardedPreviewSize); expect(find.byType(ForwardedHeader), findsNothing); }); final nativeAttachmentCases = <({String name, MessageAttachment attachment, Type bubbleType})>[ ( name: 'file', attachment: const FileAttachment( fileId: 7101, name: 'synthetic.txt', size: 128, ), bubbleType: FileBubble, ), ( name: 'sticker', attachment: const StickerAttachment( stickerId: 'synthetic-sticker', width: 128, height: 128, ), bubbleType: StickerBubble, ), ( name: 'location', attachment: const LocationAttachment( latitude: 1, longitude: 2, title: 'Synthetic location', ), bubbleType: LocationBubble, ), ( name: 'call', attachment: const CallAttachment(isVideo: false, durationMs: 1000), bubbleType: CallBubble, ), ( name: 'share', attachment: const ShareAttachment( shareId: 7102, title: 'Synthetic preview', url: 'https://example.test/synthetic', ), bubbleType: ShareBubble, ), ( name: 'audio', attachment: const AudioAttachment( audioId: 7103, duration: 1000, baseUrl: 'https://example.test/synthetic.ogg', ), bubbleType: VoiceMessageBubble, ), ( name: 'video note', attachment: const VideoAttachment( videoId: 7104, videoToken: 'synthetic-note-token', videoType: 1, duration: 1000, width: 480, height: 480, ), bubbleType: VideoNoteBubble, ), ]; for (final item in nativeAttachmentCases) { testWidgets('decorates forwarded ${item.name} native bubble', ( tester, ) async { final forwarded = ForwardedMessageAttachment( originalSenderId: 71, originalMessageId: 'synthetic-source-${item.name}', originalChatId: 72, originalText: item.name == 'share' ? 'Synthetic forwarded caption' : null, originalAttachments: [item.attachment], ); final message = CachedMessage( id: 'synthetic-forward-${item.name}', accountId: 1, chatId: 2, senderId: 70, time: 11000, text: 'Synthetic outer text', attachments: [forwarded], ); await _pumpBubble(tester, message, isMe: item.name == 'audio'); expect(find.byType(ForwardedHeader), findsOneWidget); expect(find.byType(item.bubbleType), findsOneWidget); final usesFloatingHeader = item.name == 'sticker' || item.name == 'video note'; if (usesFloatingHeader) { expect(find.byType(ForwardedHeaderFloating), findsOneWidget); expect( tester.getRect(find.byType(ForwardedHeaderFloating)).bottom, lessThanOrEqualTo(tester.getRect(find.byType(item.bubbleType)).top), ); } else { expect(find.byType(ForwardedHeaderFloating), findsNothing); } if (item.name == 'audio') { final headerRect = tester.getRect(find.byType(ForwardedHeader)); final voiceRect = tester.getRect(find.byType(VoiceMessageBubble)); expect(voiceRect.left - headerRect.left, closeTo(14, 0.1)); expect(headerRect.right - voiceRect.right, closeTo(14, 0.1)); expect( find.descendant( of: find.byType(VoiceMessageBubble), matching: find.byIcon(Symbols.check), ), findsOneWidget, ); } if (item.name == 'share') { expect(find.text('Synthetic forwarded caption'), findsOneWidget); expect(find.text('Synthetic outer text'), findsNothing); } }); } testWidgets('decorates the native forwarded contact bubble', ( tester, ) async { const forwarded = ForwardedMessageAttachment( originalSenderId: 73, originalMessageId: 'synthetic-contact-source', originalChatId: 74, originalContact: ContactAttachment( firstName: 'Synthetic', lastName: 'Contact', phoneNumber: '+10000000000', ), ); const message = CachedMessage( id: 'synthetic-contact-forward', accountId: 1, chatId: 2, senderId: 70, time: 12000, attachments: [forwarded], ); await _pumpBubble(tester, message); expect(find.byType(ForwardedHeader), findsOneWidget); expect(find.byType(ContactBubble), findsOneWidget); }); testWidgets('makes the forwarded user name clickable', (tester) async { const attachment = ForwardedMessageAttachment( originalSenderId: 42, originalSenderName: 'Example Person', originalType: 'USER', originalMessageId: '104', originalTime: 7000, originalText: 'Synthetic user message', originalChatId: -40, originalFormatRanges: [ FormatRange(format: TextFormat.strong, start: 0, length: 9), ], ); const message = CachedMessage( id: '203', accountId: 1, chatId: 2, senderId: 43, time: 8000, attachments: [attachment], ); ForwardedMessageAttachment? tappedSource; await _pumpBubble( tester, message, onSourceTap: (forwarded) => tappedSource = forwarded, ); expect(find.byType(FormattedMessageText), findsOneWidget); await tester.tap(find.text('Example Person')); expect(tappedSource, same(attachment)); expect(tappedSource?.originalSenderId, 42); }); }); }