From de95fd7c65577c2e86303acc2693f531f6815fc9 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 27 Jul 2026 14:48:38 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B2=D1=80=D0=BE=D0=B4=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D0=BB=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CLAUDE.md | 1 + lib/backend/modules/messages.dart | 30 +- lib/core/utils/text_format.dart | 8 +- lib/frontend/screens/chats/chat_screen.dart | 69 ++++- .../attachment/bubbles/bubble_context.dart | 5 + .../attachment/bubbles/forwarded_bubble.dart | 207 ++++++++----- .../attachment/bubbles/photo_bubble.dart | 54 +++- lib/frontend/widgets/message_bubble.dart | 56 +--- lib/models/attachment.dart | 32 +- test/forwarded_message_attachment_test.dart | 293 ++++++++++++++++++ 11 files changed, 613 insertions(+), 143 deletions(-) create mode 100644 test/forwarded_message_attachment_test.dart diff --git a/AGENTS.md b/AGENTS.md index c894244..c83c9f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,4 @@ Лучше качество чем количество Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно. ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') +Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. diff --git a/CLAUDE.md b/CLAUDE.md index fa0a52d..360cacb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ Incoming packets: transport → dispatcher → backend module → state → UI r - **Use `showCustomNotification(context, 'text')`** for all user-facing notifications — never use SnackBars. - When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**. - Quality over quantity. +- **Never leave real data in test files**, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead. ## Localization diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6d7806e..c39312d 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -903,23 +903,41 @@ class MessagesModule { required String tempId, required int time, required String status, + String? sourceChatName, + String? sourceChatIconUrl, + String? sourceChatType, }) { final srcPayload = source.payload; final srcLink = srcPayload?['link']; + final isForwardedSource = + srcLink is Map && + srcLink['type']?.toString().toUpperCase() == 'FORWARD' && + srcLink['message'] is Map; Map originalMsg; - if (srcLink is Map && - srcLink['type'] == 'FORWARD' && - srcLink['message'] is Map) { + if (isForwardedSource) { originalMsg = Map.from(srcLink['message'] as Map); } else { + final originalType = srcPayload?['type']?.toString() ?? sourceChatType; originalMsg = { 'id': int.tryParse(source.id) ?? source.id, + 'type': ?originalType, 'sender': source.senderId, 'time': source.time, 'text': source.text, 'attaches': (srcPayload?['attaches'] as List?) ?? const [], + 'elements': (srcPayload?['elements'] as List?) ?? const [], }; } + final isChannelSource = + originalMsg['type']?.toString().toUpperCase() == 'CHANNEL'; + final rawChannelName = isForwardedSource + ? srcLink['chatName'] + : sourceChatName; + final rawChannelIconUrl = isForwardedSource + ? srcLink['chatIconUrl'] + : sourceChatIconUrl; + final channelName = rawChannelName?.toString().trim(); + final channelIconUrl = rawChannelIconUrl?.toString().trim(); final payload = { 'elements': const [], 'attaches': const [], @@ -928,6 +946,12 @@ class MessagesModule { 'chatId': sourceChatId, 'messageId': int.tryParse(source.id) ?? source.id, 'message': originalMsg, + if (isChannelSource && channelName != null && channelName.isNotEmpty) + 'chatName': channelName, + if (isChannelSource && + channelIconUrl != null && + channelIconUrl.isNotEmpty) + 'chatIconUrl': channelIconUrl, }, }; return CachedMessage( diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart index 15b870f..e32cd33 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; enum TextFormat { + heading, strong, emphasized, underline, @@ -13,6 +14,7 @@ enum TextFormat { } const Map _formatToServer = { + TextFormat.heading: 'HEADING', TextFormat.strong: 'STRONG', TextFormat.emphasized: 'EMPHASIZED', TextFormat.underline: 'UNDERLINE', @@ -249,9 +251,13 @@ TextStyle applyTextFormats( formats.contains(TextFormat.quote); final isMention = formats.contains(TextFormat.userMention); + final isHeading = formats.contains(TextFormat.heading); return base.copyWith( - fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null, + fontSize: isHeading ? (base.fontSize ?? 16) * 1.08 : null, + fontWeight: formats.contains(TextFormat.strong) || isHeading + ? FontWeight.w700 + : null, fontStyle: isItalic ? FontStyle.italic : null, fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null, color: isMention diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1e8fbe0..55b0503 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -2236,6 +2236,9 @@ class _ChatScreenState extends State tempId: _nextTempId(), time: now + i, status: 'sending', + sourceChatName: widget.name, + sourceChatIconUrl: widget.imageUrl, + sourceChatType: widget.chatType, ); optimistic.add(msg); _messages.add(msg); @@ -2280,6 +2283,9 @@ class _ChatScreenState extends State tempId: _nextTempId(), time: now + i, status: 'sending', + sourceChatName: widget.name, + sourceChatIconUrl: widget.imageUrl, + sourceChatType: widget.chatType, ); optimistic.add(msg); await AppDatabase.saveMessages([msg.toDbRow()]); @@ -3447,6 +3453,8 @@ class _ChatScreenState extends State static String _formatLabel(TextFormat format) { switch (format) { + case TextFormat.heading: + return 'Заголовок'; case TextFormat.strong: return 'Жирный'; case TextFormat.emphasized: @@ -4021,7 +4029,8 @@ class _ChatScreenState extends State if (msg.attachments != null) { for (final a in msg.attachments!) { if (a is ForwardedMessageAttachment) { - if (a.originalSenderName == null && + if (a.originalSenderId != 0 && + a.originalSenderName == null && ContactCache.get(a.originalSenderId) == null) { forwardIds.add(a.originalSenderId); } @@ -4057,10 +4066,12 @@ class _ChatScreenState extends State originalSenderId: a.originalSenderId, originalSenderName: r.name, originalSenderAvatar: r.avatar, + originalType: a.originalType, originalMessageId: a.originalMessageId, originalTime: a.originalTime, originalText: a.originalText, originalChatId: a.originalChatId, + originalFormatRanges: a.originalFormatRanges, originalAttachments: a.originalAttachments, originalContact: a.originalContact, ); @@ -4271,6 +4282,61 @@ class _ChatScreenState extends State ); } + void _openForwardedSource(ForwardedMessageAttachment forwarded) { + if (forwarded.isChannel) { + unawaited(_openForwardedChannel(forwarded)); + return; + } + final senderId = forwarded.originalSenderId; + if (senderId == 0 || senderId == _myId) return; + unawaited( + openContactDialogProfile( + context, + contactId: senderId, + name: + forwarded.originalSenderName ?? + ContactCache.get(senderId) ?? + 'User #$senderId', + avatarUrl: + forwarded.originalSenderAvatar ?? ContactCache.getAvatar(senderId), + ), + ); + } + + Future _openForwardedChannel( + ForwardedMessageAttachment forwarded, + ) async { + final sourceChatId = forwarded.originalChatId; + if (sourceChatId == null) { + showCustomNotification(context, 'Канал недоступен'); + return; + } + final sourceMessageId = forwarded.originalMessageId; + if (sourceChatId == widget.chatId) { + if (sourceMessageId == null) return; + _beginTargetNavigation(); + await _runGoToMessage(sourceMessageId, forwarded.originalTime ?? 0); + return; + } + + await chats.ensureChatCached(api, _myId, sourceChatId); + if (!mounted) return; + final cached = await chats.getChat(_myId, sourceChatId); + if (!mounted) return; + final channel = cached.isEmpty ? null : cached.first; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: sourceChatId, + name: channel?.title ?? forwarded.originalSenderName ?? 'Канал', + imageUrl: channel?.iconUrl ?? forwarded.originalSenderAvatar ?? '', + chatType: channel?.type ?? 'CHANNEL', + initialMessageId: sourceMessageId, + initialMessageTime: forwarded.originalTime, + ), + ); + } + void _openStickerPack(StickerAttachment sticker) { final stickerId = int.tryParse(sticker.stickerId ?? ''); if (stickerId == null) { @@ -5238,6 +5304,7 @@ class _ChatScreenState extends State onReplyTap: (id) => _jumpToMessage(id, fromId: message.id), onAvatarTap: _openSenderProfile, + onForwardedSourceTap: _openForwardedSource, onStickerTap: _openStickerPack, onReactionTap: message.isControl ? null diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index 17f37f9..724b080 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -14,6 +14,9 @@ enum MessageType { text, attachment, voice, control } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } +typedef ForwardedSourceTap = + void Function(ForwardedMessageAttachment forwarded); + final Expando<({bool full, String text})> _clockTextCache = Expando(); ({IconData icon, Color color}) messageStatusVisual( @@ -69,6 +72,7 @@ class BubbleContext { final ValueListenable? otherReadTime; final ValueListenable>? uploadProgress; final void Function(StickerAttachment sticker)? onStickerTap; + final ForwardedSourceTap? onForwardedSourceTap; BubbleContext({ required this.context, @@ -88,6 +92,7 @@ class BubbleContext { this.otherReadTime, this.uploadProgress, this.onStickerTap, + this.onForwardedSourceTap, this.reactionInfo, }) : dim = text.withValues(alpha: 0.7); diff --git a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart index 10b73d5..428ec0c 100644 --- a/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/forwarded_bubble.dart @@ -4,66 +4,113 @@ 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'; -Widget _forwardedHeader( - BubbleContext ctx, - ForwardedMessageAttachment forwarded, -) { - final headerColor = ctx.dim; - final displaySender = +String _forwardedSourceName(ForwardedMessageAttachment forwarded) { + final resolved = forwarded.originalSenderName ?? - ContactCache.get(forwarded.originalSenderId) ?? - forwarded.originalSenderId.toString(); - final senderAvatar = - forwarded.originalSenderAvatar ?? - ContactCache.getAvatar(forwarded.originalSenderId); - return Padding( - padding: const EdgeInsets.only(left: 8, top: 8, right: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, + ContactCache.get(forwarded.originalSenderId); + if (resolved != null && resolved.isNotEmpty) return resolved; + if (forwarded.isChannel) return 'Канал'; + if (forwarded.originalSenderId != 0) { + return forwarded.originalSenderId.toString(); + } + return 'Сообщение'; +} + +String? _forwardedSourceAvatar(ForwardedMessageAttachment forwarded) => + forwarded.originalSenderAvatar ?? + ContactCache.getAvatar(forwarded.originalSenderId); + +class ForwardedHeader extends StatelessWidget { + final BubbleContext ctx; + final ForwardedMessageAttachment forwarded; + final EdgeInsetsGeometry padding; + + const ForwardedHeader({ + super.key, + required this.ctx, + required this.forwarded, + this.padding = const EdgeInsets.only(left: 8, top: 8, right: 8), + }); + + @override + Widget build(BuildContext context) { + final headerColor = ctx.dim; + final displaySender = _forwardedSourceName(forwarded); + final senderAvatar = _forwardedSourceAvatar(forwarded); + final content = Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), + backgroundColor: ctx.cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: ctx.cs.primaryContainer, + child: Text( + displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', + style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), + ), ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, + const SizedBox(width: 6), + Flexible( child: Text( - displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', - style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), + displaySender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), ), - const SizedBox(width: 6), - Flexible( - child: Text( - displaySender, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); + ], + ), + ); + final onTap = ctx.onForwardedSourceTap; + if (onTap == null) return content; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(forwarded), + child: content, + ); + } +} + +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 { @@ -80,27 +127,26 @@ class ForwardedPhotoBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final message = ctx.message; - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasCaption = forwarded.originalText?.isNotEmpty ?? false; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _forwardedHeader(ctx, forwarded), - const SizedBox(height: 4), - if (hasCaption) ...[ - Padding( - padding: const EdgeInsets.only(left: 8), - child: Text( - message.text ?? '', - style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), - ), + 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, ), - const SizedBox(height: 6), ], - PhotoBubble(ctx: ctx, photos: photos), - ], + ), ); } } @@ -124,8 +170,15 @@ class ForwardedGenericBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - _forwardedHeader(ctx, forwarded), + 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); @@ -159,8 +212,15 @@ class ForwardedStickerBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - _forwardedHeader(ctx, forwarded), + 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), ], ); @@ -185,8 +245,15 @@ class ForwardedContactBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - _forwardedHeader(ctx, forwarded), + 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, diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index aefc2c1..cff635d 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -21,18 +21,42 @@ 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}); + const PhotoBubble({ + super.key, + required this.ctx, + required this.photos, + this.caption, + this.hasContentAbove = false, + }); + + static double layoutWidth(List photos) { + if (photos.length != 1) return BubbleContext.photoMaxSize; + return (photos.single.width?.toDouble() ?? 200).clamp( + BubbleContext.photoMinSize, + BubbleContext.photoMaxSize, + ); + } @override Widget build(BuildContext context) { final message = ctx.message; - final hasCaption = message.text != null && message.text!.isNotEmpty; + final hasMessageCaption = message.text != null && message.text!.isNotEmpty; + final resolvedCaption = + caption ?? (hasMessageCaption ? ctx.caption() : null); + final hasCaption = resolvedCaption != null; final count = photos.length; Widget photosWidget; if (count == 1) { - photosWidget = _buildSinglePhoto(ctx, photos[0]); + photosWidget = _buildSinglePhoto( + ctx, + photos[0], + hasCaption: hasCaption, + hasContentAbove: hasContentAbove, + ); } else if (count == 2) { photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); } else { @@ -53,15 +77,8 @@ class PhotoBubble extends StatelessWidget { } if (count == 1) { - final photo = photos[0]; - final pw = photo.width?.toDouble() ?? 200; - final photoWidth = pw.clamp( - BubbleContext.photoMinSize, - BubbleContext.photoMaxSize, - ); - return SizedBox( - width: photoWidth, + width: layoutWidth(photos), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -76,7 +93,7 @@ class PhotoBubble extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -100,7 +117,7 @@ class PhotoBubble extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: ctx.caption()), + Expanded(child: resolvedCaption), ctx.meta(), ], ), @@ -109,7 +126,12 @@ class PhotoBubble extends StatelessWidget { ); } - Widget _buildSinglePhoto(BubbleContext ctx, PhotoAttachment photo) { + Widget _buildSinglePhoto( + BubbleContext ctx, + PhotoAttachment photo, { + required bool hasCaption, + required bool hasContentAbove, + }) { final width = photo.width?.toDouble() ?? 200; final height = photo.height?.toDouble() ?? 200; @@ -123,8 +145,8 @@ class PhotoBubble extends StatelessWidget { ); final dpr = MediaQuery.of(ctx.context).devicePixelRatio; - final matchTop = ctx.hasPhotoWithCaption; - final matchBottom = !ctx.hasPhotoWithCaption; + final matchTop = hasCaption && !hasContentAbove; + final matchBottom = !hasCaption; final topR = matchTop ? _bigRadius : _photoRadius; final bottomL = matchBottom diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a45e508..86bcd1b 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -230,6 +230,7 @@ class MessageBubble extends StatelessWidget { final ValueListenable>? uploadProgress; final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; + final ForwardedSourceTap? onForwardedSourceTap; final void Function(StickerAttachment sticker)? onStickerTap; final void Function(String emoji)? onReactionTap; final String? peerName; @@ -257,6 +258,7 @@ class MessageBubble extends StatelessWidget { this.uploadProgress, this.onReplyTap, this.onAvatarTap, + this.onForwardedSourceTap, this.onStickerTap, this.onReactionTap, this.peerName, @@ -628,6 +630,7 @@ class MessageBubble extends StatelessWidget { otherReadTime: otherReadTime, uploadProgress: uploadProgress, onStickerTap: onStickerTap, + onForwardedSourceTap: onForwardedSourceTap, reactionInfo: _resolveReactionInfo(), ); @@ -1457,14 +1460,6 @@ class MessageBubble extends StatelessWidget { BubbleContext ctx, ForwardedMessageAttachment forwarded, ) { - final headerColor = ctx.dim; - final displaySender = - forwarded.originalSenderName ?? - ContactCache.get(forwarded.originalSenderId) ?? - forwarded.originalSenderId.toString(); - final senderAvatar = - forwarded.originalSenderAvatar ?? - ContactCache.getAvatar(forwarded.originalSenderId); final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; @@ -1472,49 +1467,14 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - if (senderAvatar != null && senderAvatar.isNotEmpty) - CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - senderAvatar, - maxWidth: 96, - maxHeight: 96, - ), - backgroundColor: ctx.cs.primaryContainer, - ) - else - CircleAvatar( - radius: 10, - backgroundColor: ctx.cs.primaryContainer, - child: Text( - displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 9, - color: ctx.cs.onPrimaryContainer, - ), - ), - ), - const SizedBox(width: 6), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], + ForwardedHeader( + ctx: ctx, + forwarded: forwarded, + padding: EdgeInsets.zero, ), if (hasOrigText) ...[ const SizedBox(height: 2), - Text(origText, style: TextStyle(color: ctx.text, fontSize: 14)), + buildForwardedMessageText(ctx, forwarded), ] else ...[ const SizedBox(height: 2), Text( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index b40d021..fd1f3e4 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../core/utils/parse.dart'; +import '../core/utils/text_format.dart'; enum AttachmentType { photo, @@ -677,10 +678,12 @@ class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; final String? originalSenderAvatar; + final String? originalType; final String? originalMessageId; final int? originalTime; final String? originalText; final int? originalChatId; + final List originalFormatRanges; final List? originalAttachments; final ContactAttachment? originalContact; @@ -688,14 +691,18 @@ class ForwardedMessageAttachment extends MessageAttachment { required this.originalSenderId, this.originalSenderName, this.originalSenderAvatar, + this.originalType, this.originalMessageId, this.originalTime, this.originalText, this.originalChatId, + this.originalFormatRanges = const [], this.originalAttachments, this.originalContact, }) : super(type: AttachmentType.forward); + bool get isChannel => originalType == 'CHANNEL'; + factory ForwardedMessageAttachment.fromMap(Map map) { final linkRaw = map['link']; Map? link; @@ -736,12 +743,27 @@ class ForwardedMessageAttachment extends MessageAttachment { } } + final originalType = message?['type']?.toString().toUpperCase(); + final isChannel = originalType == 'CHANNEL'; + final channelName = link?['chatName']?.toString().trim(); + final channelAvatar = link?['chatIconUrl']?.toString().trim(); + return ForwardedMessageAttachment( - originalSenderId: (message?['sender'] as int?) ?? 0, + originalSenderId: parseIntOrNull(message?['sender']) ?? 0, + originalSenderName: + isChannel && channelName != null && channelName.isNotEmpty + ? channelName + : null, + originalSenderAvatar: + isChannel && channelAvatar != null && channelAvatar.isNotEmpty + ? channelAvatar + : null, + originalType: originalType, originalMessageId: message?['id']?.toString(), - originalTime: message?['time'] as int?, - originalText: message?['text'] as String?, - originalChatId: link?['chatId'] as int?, + originalTime: parseIntOrNull(message?['time']), + originalText: message?['text']?.toString(), + originalChatId: parseIntOrNull(link?['chatId']), + originalFormatRanges: parseFormatElements(message?['elements']), originalAttachments: originalAttaches, originalContact: originalContact, ); @@ -753,10 +775,12 @@ class ForwardedMessageAttachment extends MessageAttachment { 'originalSenderId': originalSenderId, 'originalSenderName': originalSenderName, 'originalSenderAvatar': originalSenderAvatar, + 'originalType': originalType, 'originalMessageId': originalMessageId, 'originalTime': originalTime, 'originalText': originalText, 'originalChatId': originalChatId, + 'originalElements': serializeFormatElements(originalFormatRanges), }; } diff --git a/test/forwarded_message_attachment_test.dart b/test/forwarded_message_attachment_test.dart new file mode 100644 index 0000000..5899285 --- /dev/null +++ b/test/forwarded_message_attachment_test.dart @@ -0,0 +1,293 @@ +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/forwarded_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'; + +Future _pumpBubble( + WidgetTester tester, + CachedMessage message, { + void Function(ForwardedMessageAttachment forwarded)? onSourceTap, +}) 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: false, + 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(ForwardedPhotoBubble)).width, + closeTo(200, 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('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); + }); + }); +}