From 05ca3a94135fb5f4c5292e51d2f396ce35ba70e1 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 9 Aug 2026 01:10:03 +0700 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=B7=D0=B0=D1=80=D0=B5=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BB=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/bubbles/bubble_context.dart | 60 +++- .../attachment/bubbles/forwarded_bubble.dart | 176 +----------- .../attachment/bubbles/photo_bubble.dart | 8 +- .../attachment/bubbles/poll_bubble.dart | 6 +- .../attachment/bubbles/share_bubble.dart | 8 +- .../attachment/bubbles/video_bubble.dart | 25 +- .../attachment/bubbles/video_note_bubble.dart | 8 +- .../attachment/bubbles/voice_bubble.dart | 20 +- lib/frontend/widgets/message_bubble.dart | 217 ++++++++++----- test/forwarded_message_attachment_test.dart | 260 +++++++++++++++++- 10 files changed, 513 insertions(+), 275 deletions(-) diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index d2a6d16..23849ad 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -6,6 +6,7 @@ import '../../../../backend/modules/messages.dart'; import '../../../../core/config/app_colors.dart'; import '../../../../core/config/komet_settings.dart'; import '../../../../core/utils/format.dart'; +import '../../../../core/utils/text_format.dart'; import '../../../../models/attachment.dart'; import '../../formatted_message_text.dart'; import '../../sending_clock_icon.dart'; @@ -20,6 +21,20 @@ typedef ForwardedSourceTap = final Expando<({bool full, String text})> _clockTextCache = Expando(); +class BubblePresentation { + final String? text; + final List formatRanges; + final String? sourceMessageId; + final int? sourceChatId; + + const BubblePresentation({ + this.text, + this.formatRanges = const [], + this.sourceMessageId, + this.sourceChatId, + }); +} + ({IconData icon, Color color}) messageStatusVisual( String? status, { required Color dimColor, @@ -75,6 +90,7 @@ class BubbleContext { final ValueListenable>? uploadProgress; final void Function(StickerAttachment sticker)? onStickerTap; final ForwardedSourceTap? onForwardedSourceTap; + final BubblePresentation? presentation; BubbleContext({ required this.context, @@ -97,8 +113,43 @@ class BubbleContext { this.onStickerTap, this.onForwardedSourceTap, this.reactionInfo, + this.presentation, }) : dim = text.withValues(alpha: 0.7); + String? get contentText => + presentation == null ? message.text : presentation!.text; + + List get contentFormatRanges => + presentation == null ? message.formatRanges : presentation!.formatRanges; + + String get sourceMessageId => presentation?.sourceMessageId ?? message.id; + + int get sourceChatId => presentation?.sourceChatId ?? message.chatId; + + BubbleContext withPresentation(BubblePresentation value) => BubbleContext( + context: context, + cs: cs, + text: text, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoWithCaption, + hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption, + message: message, + isMe: isMe, + myId: myId, + chatType: chatType, + chatId: chatId, + chatName: chatName, + photoActions: photoActions, + overrideStatus: overrideStatus, + otherReadTime: otherReadTime, + uploadProgress: uploadProgress, + onStickerTap: onStickerTap, + onForwardedSourceTap: onForwardedSourceTap, + reactionInfo: reactionInfo, + presentation: value, + ); + String get clockText { final full = KometSettings.fullTimestamp.value; final cached = _clockTextCache[message]; @@ -115,15 +166,16 @@ class BubbleContext { Widget caption() { final style = TextStyle(color: text, fontSize: 16, height: 1.3); - final ranges = message.formatRanges; - if (FormattedMessageText.isFormatted(message.text, ranges)) { + final captionText = contentText; + final ranges = contentFormatRanges; + if (FormattedMessageText.isFormatted(captionText, ranges)) { return FormattedMessageText( - text: message.text!, + text: captionText!, ranges: ranges, style: style, ); } - return Text(message.text ?? '', style: style); + return Text(captionText ?? '', style: style); } Widget meta() { diff --git a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart index 335300c..8dba8a3 100644 --- a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart @@ -4,12 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../../backend/modules/messages.dart'; import '../../../../models/attachment.dart'; -import '../../formatted_message_text.dart'; import 'bubble_context.dart'; -import 'contact_bubble.dart'; -import 'file_bubble.dart'; -import 'photo_bubble.dart'; -import 'sticker_bubble.dart'; String _forwardedSourceName(ForwardedMessageAttachment forwarded) { final resolved = @@ -96,175 +91,30 @@ class ForwardedHeader extends StatelessWidget { } } -Widget buildForwardedMessageText( - BubbleContext ctx, - ForwardedMessageAttachment forwarded, { - double fontSize = 14, -}) { - final text = forwarded.originalText ?? ''; - final style = TextStyle(color: ctx.text, fontSize: fontSize, height: 1.3); - if (FormattedMessageText.isFormatted(text, forwarded.originalFormatRanges)) { - return FormattedMessageText( - text: text, - ranges: forwarded.originalFormatRanges, - style: style, - ); - } - return Text(text, style: style); -} - -class ForwardedPhotoBubble extends StatelessWidget { +class ForwardedHeaderFloating extends StatelessWidget { final BubbleContext ctx; final ForwardedMessageAttachment forwarded; - final List photos; - const ForwardedPhotoBubble({ + const ForwardedHeaderFloating({ super.key, required this.ctx, required this.forwarded, - required this.photos, }); @override Widget build(BuildContext context) { - final hasCaption = forwarded.originalText?.isNotEmpty ?? false; - - return SizedBox( - width: PhotoBubble.layoutWidth(photos), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ForwardedHeader(ctx: ctx, forwarded: forwarded), - const SizedBox(height: 4), - PhotoBubble( - ctx: ctx, - photos: photos, - caption: hasCaption - ? buildForwardedMessageText(ctx, forwarded, fontSize: 16) - : null, - hasContentAbove: true, - ), - ], + return Material( + color: ctx.isMe + ? ctx.cs.primaryContainer + : ctx.cs.surfaceContainerHighest, + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(18), + child: ForwardedHeader( + ctx: ctx, + forwarded: forwarded, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), ); } } - -class ForwardedGenericBubble extends StatelessWidget { - final BubbleContext ctx; - final ForwardedMessageAttachment forwarded; - final List attachments; - - const ForwardedGenericBubble({ - super.key, - required this.ctx, - required this.forwarded, - required this.attachments, - }); - - @override - Widget build(BuildContext context) { - return IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ForwardedHeader(ctx: ctx, forwarded: forwarded), - const SizedBox(height: 4), - if (forwarded.originalText?.isNotEmpty ?? false) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: buildForwardedMessageText(ctx, forwarded), - ), - const SizedBox(height: 6), - ], - ...attachments.map((a) { - if (a is FileAttachment) { - return FileBubble(ctx: ctx, file: a, fill: true); - } - if (a is StickerAttachment) { - return StickerBubble(ctx: ctx, sticker: a); - } - return const SizedBox.shrink(); - }), - ], - ), - ); - } -} - -class ForwardedStickerBubble extends StatelessWidget { - final BubbleContext ctx; - final ForwardedMessageAttachment forwarded; - final MessageAttachment sticker; - - const ForwardedStickerBubble({ - super.key, - required this.ctx, - required this.forwarded, - required this.sticker, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ForwardedHeader(ctx: ctx, forwarded: forwarded), - const SizedBox(height: 4), - if (forwarded.originalText?.isNotEmpty ?? false) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: buildForwardedMessageText(ctx, forwarded), - ), - const SizedBox(height: 6), - ], - StickerBubble(ctx: ctx, sticker: sticker), - ], - ); - } -} - -class ForwardedContactBubble extends StatelessWidget { - final BubbleContext ctx; - final ForwardedMessageAttachment forwarded; - - const ForwardedContactBubble({ - super.key, - required this.ctx, - required this.forwarded, - }); - - @override - Widget build(BuildContext context) { - final contact = forwarded.originalContact!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ForwardedHeader(ctx: ctx, forwarded: forwarded), - const SizedBox(height: 4), - if (forwarded.originalText?.isNotEmpty ?? false) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: buildForwardedMessageText(ctx, forwarded), - ), - const SizedBox(height: 6), - ], - buildContactCard( - ctx, - firstName: contact.firstName, - lastName: contact.lastName, - name: contact.name, - photoUrl: contact.photoUrl ?? contact.baseUrl, - phoneNumber: contact.phoneNumber, - contactId: contact.contactId, - userId: contact.userId, - ), - ], - ); - } -} diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 43fa62f..62a598a 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -21,14 +21,12 @@ class PhotoBubble extends StatelessWidget { final BubbleContext ctx; final List photos; - final Widget? caption; final bool hasContentAbove; const PhotoBubble({ super.key, required this.ctx, required this.photos, - this.caption, this.hasContentAbove = false, }); @@ -42,10 +40,8 @@ class PhotoBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final message = ctx.message; - final hasMessageCaption = message.text != null && message.text!.isNotEmpty; - final resolvedCaption = - caption ?? (hasMessageCaption ? ctx.caption() : null); + final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false; + final resolvedCaption = hasMessageCaption ? ctx.caption() : null; final hasCaption = resolvedCaption != null; final count = photos.length; diff --git a/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart index ca67681..fb529f0 100644 --- a/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/poll_bubble.dart @@ -20,11 +20,11 @@ class PollBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ PollView( - chatId: ctx.message.chatId, - messageId: ctx.message.id, + chatId: ctx.sourceChatId, + messageId: ctx.sourceMessageId, pollId: poll.pollId, myId: ctx.myId, - fallbackTitle: poll.title ?? ctx.message.text, + fallbackTitle: poll.title ?? ctx.contentText, textColor: ctx.text, dimColor: ctx.dim, accentColor: ctx.isMe diff --git a/lib/frontend/widgets/attachment/bubbles/share_bubble.dart b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart index f8b510b..a767f06 100644 --- a/lib/frontend/widgets/attachment/bubbles/share_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/share_bubble.dart @@ -16,8 +16,8 @@ class ShareBubble extends StatelessWidget { @override Widget build(BuildContext context) { final isMe = ctx.isMe; - final message = ctx.message; - final hasText = message.text != null && message.text!.isNotEmpty; + final text = ctx.contentText; + final hasText = text?.isNotEmpty ?? false; final image = share.image; final imageUrl = image?.baseUrl ?? image?.previewData ?? ''; final cardColor = isMe @@ -123,8 +123,8 @@ class ShareBubble extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: FormattedMessageText( - text: message.text!, - ranges: message.formatRanges, + text: text!, + ranges: ctx.contentFormatRanges, style: TextStyle( color: ctx.text, fontSize: 16, diff --git a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart index 5dd19d5..660e380 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_bubble.dart @@ -19,6 +19,13 @@ class VideoBubble extends StatelessWidget { const VideoBubble({super.key, required this.ctx, required this.video}); + static double layoutWidth(VideoAttachment video) { + return (video.width?.toDouble() ?? 200.0).clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + } + @override Widget build(BuildContext context) { final message = ctx.message; @@ -27,6 +34,8 @@ class VideoBubble extends StatelessWidget { attachment: video, messageId: message.id, chatId: message.chatId, + sourceMessageId: ctx.sourceMessageId, + sourceChatId: ctx.sourceChatId, senderId: message.senderId, isMe: ctx.isMe, time: message.time, @@ -36,7 +45,9 @@ class VideoBubble extends StatelessWidget { uploadProgress: ctx.uploadProgress, ); } - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false; + final resolvedCaption = hasMessageCaption ? ctx.caption() : null; + final hasCaption = resolvedCaption != null; final thumb = video.thumbnail; final durationMs = video.duration; final previewUrl = (thumb != null && thumb.isNotEmpty) @@ -45,12 +56,8 @@ class VideoBubble extends StatelessWidget { ? video.baseUrl! : (video.previewData ?? ''); - final w = video.width; final h = video.height; - final width = (w?.toDouble() ?? 200.0).clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); + final width = layoutWidth(video); final height = (h?.toDouble() ?? 150.0).clamp( BubbleContext.photoMinSize, BubbleContext.photoMaxSize, @@ -192,7 +199,7 @@ class VideoBubble extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -212,8 +219,8 @@ class VideoBubble extends StatelessWidget { Haptics.tap(); final sources = await messagesModule.getVideoSources( - messageId: ctx.message.id, - chatId: ctx.message.chatId, + messageId: ctx.sourceMessageId, + chatId: ctx.sourceChatId, token: token, videoId: videoId, ); diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 9abb6d8..737a5a4 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -23,6 +23,8 @@ class VideoNoteBubble extends StatefulWidget { final VideoAttachment attachment; final String messageId; final int chatId; + final String? sourceMessageId; + final int? sourceChatId; final int senderId; final bool isMe; final int time; @@ -36,6 +38,8 @@ class VideoNoteBubble extends StatefulWidget { required this.attachment, required this.messageId, required this.chatId, + this.sourceMessageId, + this.sourceChatId, required this.senderId, required this.isMe, required this.time, @@ -217,8 +221,8 @@ class _VideoNoteBubbleState extends State return VideoNotePreloader.load( _cacheName, () => messagesModule.getVideoUrl( - messageId: widget.messageId, - chatId: widget.chatId, + messageId: widget.sourceMessageId ?? widget.messageId, + chatId: widget.sourceChatId ?? widget.chatId, token: token, videoId: videoId, ), diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index c41e915..90de289 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -26,6 +26,8 @@ class VoiceMessageBubble extends StatefulWidget { final String? waveData; final int chatId; final String messageId; + final int? sourceChatId; + final String? sourceMessageId; final int senderId; final int? audioId; final String? preloadedText; @@ -45,6 +47,8 @@ class VoiceMessageBubble extends StatefulWidget { this.waveData, required this.chatId, required this.messageId, + this.sourceChatId, + this.sourceMessageId, required this.senderId, this.audioId, this.preloadedText, @@ -87,7 +91,11 @@ class _VoiceMessageBubbleState extends State { super.dispose(); } - String get _cacheName => '${widget.audioId ?? widget.messageId}.ogg'; + String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg'; + + int get _sourceChatId => widget.sourceChatId ?? widget.chatId; + + String get _sourceMessageId => widget.sourceMessageId ?? widget.messageId; void _claimPlayback() { MediaPlayback.instance.activateVoice( @@ -434,8 +442,8 @@ class _VoiceMessageBubbleState extends State { return; } - if (TranscriptionCache.has(widget.messageId)) { - final cached = TranscriptionCache.get(widget.messageId)!; + if (TranscriptionCache.has(_sourceMessageId)) { + final cached = TranscriptionCache.get(_sourceMessageId)!; setState(() { _transcriptionText = cached.text ?? 'не удалось распознать текст'; _transcriptionVisible = true; @@ -449,12 +457,12 @@ class _VoiceMessageBubbleState extends State { try { final result = await messagesModule.requestTranscription( - widget.chatId, - int.tryParse(widget.messageId) ?? 0, + _sourceChatId, + int.tryParse(_sourceMessageId) ?? 0, widget.audioId!, ); - TranscriptionCache.put(widget.messageId, result); + TranscriptionCache.put(_sourceMessageId, result); if (!mounted) return; setState(() { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 293788a..d6f8bf8 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -529,21 +529,50 @@ class MessageBubble extends StatelessWidget { }); bool _computeHasPhotoWithCaption() { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; + final attachments = _contentAttachments; + if (attachments.isEmpty) return false; final hasPhoto = attachments.any((a) => a is PhotoAttachment); - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasCaption = _contentText?.isNotEmpty ?? false; return hasPhoto && hasCaption; } bool _computeHasMultiplePhotosNoCaption() { - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; + final attachments = _contentAttachments; + if (attachments.isEmpty) return false; final photoCount = attachments.whereType().length; - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasCaption = _contentText?.isNotEmpty ?? false; return photoCount >= 2 && !hasCaption; } + ForwardedMessageAttachment? get _forwarded => message.forwardedAttachment; + + List get _contentAttachments { + final forwarded = _forwarded; + if (forwarded != null) { + if (forwarded.originalContact != null) { + return [forwarded.originalContact!]; + } + return forwarded.originalAttachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + } + return message.attachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + } + + MessageAttachment? get _primaryAttachment { + final attachments = _contentAttachments; + return attachments.isEmpty ? null : attachments.first; + } + + String? get _contentText { + final forwarded = _forwarded; + return forwarded == null ? message.text : forwarded.originalText; + } + bool get _showsSenderName => !isMe && chatType == "CHAT" && @@ -577,22 +606,15 @@ class MessageBubble extends StatelessWidget { } bool get _hasShareAttachment { - final a = message.attachments; - return a != null && a.isNotEmpty && a.first is ShareAttachment; + return _primaryAttachment is ShareAttachment; } bool get _isVideoNote { - final a = message.attachments; - if (a == null || a.isEmpty) return false; - final first = a.first; + final first = _primaryAttachment; return first is VideoAttachment && first.isNote; } - bool get _isSticker { - final a = message.attachments; - if (a == null || a.isEmpty) return false; - return a.first is StickerAttachment; - } + bool get _isSticker => _primaryAttachment is StickerAttachment; static const int _jumboAnimojiLimit = 4; @@ -621,26 +643,14 @@ class MessageBubble extends StatelessWidget { MessageType _computeContentType() { if (message.isControl) return MessageType.control; - final attachments = message.attachments - ?.where((a) => a is! InlineKeyboardAttachment) - .toList(); - if (attachments != null && attachments.isNotEmpty) { + final attachments = _contentAttachments; + if (attachments.isNotEmpty) { final first = attachments.first; - if (first is ForwardedMessageAttachment) { - final fwd = first; - final hasContact = fwd.originalContact != null; - final hasPhoto = - fwd.originalAttachments != null && - fwd.originalAttachments!.any((a) => a is PhotoAttachment); - final hasOther = - fwd.originalAttachments != null && - fwd.originalAttachments!.isNotEmpty; - if (hasContact || hasPhoto || hasOther) return MessageType.attachment; - return MessageType.text; - } if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; - if (first.type == AttachmentType.audio) return MessageType.voice; + if (first.type == AttachmentType.audio) { + return _forwarded == null ? MessageType.voice : MessageType.attachment; + } if (first is ShareAttachment) { return AppLinkPreview.current.value ? MessageType.attachment @@ -649,6 +659,8 @@ class MessageBubble extends StatelessWidget { return MessageType.attachment; } + if (_forwarded != null) return MessageType.text; + final payload = message.payload; if (payload == null) return MessageType.text; if (payload['voice'] != null) return MessageType.voice; @@ -1781,6 +1793,7 @@ class MessageBubble extends StatelessWidget { ) { final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; + final forwardedCtx = _forwardedContext(ctx, forwarded); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1793,7 +1806,7 @@ class MessageBubble extends StatelessWidget { ), if (hasOrigText) ...[ const SizedBox(height: 2), - _wrapSelectable(buildForwardedMessageText(ctx, forwarded)), + _wrapSelectable(forwardedCtx.caption()), ] else ...[ const SizedBox(height: 2), _wrapSelectable( @@ -1815,35 +1828,76 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { - final fwd = first; - if (fwd.originalContact != null) { - return ForwardedContactBubble(ctx: ctx, forwarded: fwd); - } - final photos = fwd.originalAttachments - ?.whereType() - .toList(); - if (photos != null && photos.isNotEmpty) { - return ForwardedPhotoBubble(ctx: ctx, forwarded: fwd, photos: photos); - } - final stickers = fwd.originalAttachments - ?.whereType() - .toList(); - if (stickers != null && stickers.isNotEmpty) { - return ForwardedStickerBubble( - ctx: ctx, - forwarded: fwd, - sticker: stickers.first, - ); - } - final files = fwd.originalAttachments; - if (files != null && files.isNotEmpty) { - return ForwardedGenericBubble( - ctx: ctx, - forwarded: fwd, - attachments: files, - ); - } - return _buildTextContent(ctx); + return _buildForwardedAttachmentContent(ctx, first); + } + + return _buildNativeAttachmentContent(ctx, attachments); + } + + BubbleContext _forwardedContext( + BubbleContext ctx, + ForwardedMessageAttachment forwarded, + ) => ctx.withPresentation( + BubblePresentation( + text: forwarded.originalText, + formatRanges: forwarded.originalFormatRanges, + sourceMessageId: forwarded.originalMessageId, + sourceChatId: forwarded.originalChatId, + ), + ); + + Widget _buildForwardedAttachmentContent( + BubbleContext ctx, + ForwardedMessageAttachment forwarded, + ) { + final forwardedCtx = _forwardedContext(ctx, forwarded); + final attachments = + forwarded.originalAttachments + ?.where((a) => a is! InlineKeyboardAttachment) + .toList() ?? + const []; + final content = _buildNativeAttachmentContent( + forwardedCtx, + attachments, + contact: forwarded.originalContact, + hasContentAbove: true, + ); + final primary = + forwarded.originalContact ?? + (attachments.isEmpty ? null : attachments.first); + final floatingHeader = + primary is StickerAttachment || + (primary is VideoAttachment && primary.isNote); + + if (floatingHeader) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ForwardedHeaderFloating(ctx: ctx, forwarded: forwarded), + const SizedBox(height: 6), + content, + ], + ); + } + + return _HeaderAboveMatchWidth( + content: content, + header: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ForwardedHeader(ctx: ctx, forwarded: forwarded), + ), + ); + } + + Widget _buildNativeAttachmentContent( + BubbleContext ctx, + List attachments, { + ContactAttachment? contact, + bool hasContentAbove = false, + }) { + if (contact != null) { + return ContactBubble(ctx: ctx, contact: contact); } final contacts = attachments.whereType().toList(); @@ -1866,7 +1920,11 @@ class MessageBubble extends StatelessWidget { return _buildGenericAttachment(ctx, attachments.first); } - return PhotoBubble(ctx: ctx, photos: photos); + return PhotoBubble( + ctx: ctx, + photos: photos, + hasContentAbove: hasContentAbove, + ); } Widget _buildGenericAttachment( @@ -1887,30 +1945,35 @@ class MessageBubble extends StatelessWidget { ); case AttachmentType.call: return CallBubble(ctx: ctx, call: attachment as CallAttachment); + case AttachmentType.audio: + return Padding( + padding: _paddingFor(MessageType.voice, ctx.shape), + child: _buildVoiceAttachment(ctx, attachment as AudioAttachment), + ); default: return _buildTextContent(ctx); } } Widget _buildVoiceContent(BubbleContext ctx) { - int duration = 0; - String url = ''; - String? waveData; - int? audioId; - + AudioAttachment? audio; final attaches = message.attachments; if (attaches != null && attaches.isNotEmpty) { for (final a in attaches) { if (a is AudioAttachment) { - duration = ((a.duration ?? 0) / 1000).round(); - url = a.fileUrl ?? a.baseUrl ?? ''; - waveData = a.waveform; - audioId = a.audioId; + audio = a; break; } } } + return _buildVoiceAttachment(ctx, audio); + } + + Widget _buildVoiceAttachment(BubbleContext ctx, AudioAttachment? audio) { + var duration = ((audio?.duration ?? 0) / 1000).round(); + var url = audio?.fileUrl ?? audio?.baseUrl ?? ''; + if (duration == 0 && url.isEmpty) { final payload = message.payload; final voice = payload?['voice'] as Map?; @@ -1918,7 +1981,7 @@ class MessageBubble extends StatelessWidget { url = voice?['url']?.toString() ?? ''; } - final cachedTranscription = TranscriptionCache.get(message.id); + final cachedTranscription = TranscriptionCache.get(ctx.sourceMessageId); return VoiceMessageBubble( duration: duration, @@ -1930,11 +1993,13 @@ class MessageBubble extends StatelessWidget { otherReadTime: otherReadTime, time: message.time, cs: ctx.cs, - waveData: waveData, + waveData: audio?.waveform, chatId: message.chatId, messageId: message.id, + sourceChatId: ctx.sourceChatId, + sourceMessageId: ctx.sourceMessageId, senderId: message.senderId, - audioId: audioId, + audioId: audio?.audioId, preloadedText: cachedTranscription?.text, uploadProgress: ctx.uploadProgress, ); diff --git a/test/forwarded_message_attachment_test.dart b/test/forwarded_message_attachment_test.dart index 5899285..05380e7 100644 --- a/test/forwarded_message_attachment_test.dart +++ b/test/forwarded_message_attachment_test.dart @@ -2,16 +2,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:komet/core/utils/text_format.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/bubble_context.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/call_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/contact_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/file_bubble.dart'; import 'package:komet/frontend/widgets/attachment/bubbles/forwarded_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/location_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/photo_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/share_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/sticker_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart'; +import 'package:komet/frontend/widgets/attachment/bubbles/voice_bubble.dart'; import 'package:komet/frontend/widgets/formatted_message_text.dart'; import 'package:komet/frontend/widgets/message_bubble.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:komet/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; @@ -27,7 +40,7 @@ Future _pumpBubble( alignment: Alignment.topLeft, child: MessageBubble( message: message, - isMe: false, + isMe: isMe, myId: 1, chatType: 'CHAT', onForwardedSourceTap: onSourceTap, @@ -242,9 +255,13 @@ void main() { expect(formatted.ranges.single.format, TextFormat.heading); expect(find.text('0'), findsNothing); expect( - tester.getSize(find.byType(ForwardedPhotoBubble)).width, + 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), @@ -255,6 +272,245 @@ void main() { 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,