fix: вроде починил пересланные сообщения с каналов

This commit is contained in:
Jganenokk
2026-07-27 14:48:38 +07:00
parent 3b34ef0535
commit de95fd7c65
11 changed files with 613 additions and 143 deletions
+1
View File
@@ -3,3 +3,4 @@
Лучше качество чем количество Лучше качество чем количество
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно. Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
Never leave real data in test files, including existing message contents or real IDs captured from requests. Use synthetic fixtures instead.
+1
View File
@@ -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. - **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**. - When a fix can be done quickly with a hack or properly with a rewrite, **choose the proper rewrite**.
- Quality over quantity. - 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 ## Localization
+27 -3
View File
@@ -903,23 +903,41 @@ class MessagesModule {
required String tempId, required String tempId,
required int time, required int time,
required String status, required String status,
String? sourceChatName,
String? sourceChatIconUrl,
String? sourceChatType,
}) { }) {
final srcPayload = source.payload; final srcPayload = source.payload;
final srcLink = srcPayload?['link']; final srcLink = srcPayload?['link'];
final isForwardedSource =
srcLink is Map &&
srcLink['type']?.toString().toUpperCase() == 'FORWARD' &&
srcLink['message'] is Map;
Map<String, dynamic> originalMsg; Map<String, dynamic> originalMsg;
if (srcLink is Map && if (isForwardedSource) {
srcLink['type'] == 'FORWARD' &&
srcLink['message'] is Map) {
originalMsg = Map<String, dynamic>.from(srcLink['message'] as Map); originalMsg = Map<String, dynamic>.from(srcLink['message'] as Map);
} else { } else {
final originalType = srcPayload?['type']?.toString() ?? sourceChatType;
originalMsg = { originalMsg = {
'id': int.tryParse(source.id) ?? source.id, 'id': int.tryParse(source.id) ?? source.id,
'type': ?originalType,
'sender': source.senderId, 'sender': source.senderId,
'time': source.time, 'time': source.time,
'text': source.text, 'text': source.text,
'attaches': (srcPayload?['attaches'] as List?) ?? const [], '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 = <String, dynamic>{ final payload = <String, dynamic>{
'elements': const [], 'elements': const [],
'attaches': const [], 'attaches': const [],
@@ -928,6 +946,12 @@ class MessagesModule {
'chatId': sourceChatId, 'chatId': sourceChatId,
'messageId': int.tryParse(source.id) ?? source.id, 'messageId': int.tryParse(source.id) ?? source.id,
'message': originalMsg, 'message': originalMsg,
if (isChannelSource && channelName != null && channelName.isNotEmpty)
'chatName': channelName,
if (isChannelSource &&
channelIconUrl != null &&
channelIconUrl.isNotEmpty)
'chatIconUrl': channelIconUrl,
}, },
}; };
return CachedMessage( return CachedMessage(
+7 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum TextFormat { enum TextFormat {
heading,
strong, strong,
emphasized, emphasized,
underline, underline,
@@ -13,6 +14,7 @@ enum TextFormat {
} }
const Map<TextFormat, String> _formatToServer = { const Map<TextFormat, String> _formatToServer = {
TextFormat.heading: 'HEADING',
TextFormat.strong: 'STRONG', TextFormat.strong: 'STRONG',
TextFormat.emphasized: 'EMPHASIZED', TextFormat.emphasized: 'EMPHASIZED',
TextFormat.underline: 'UNDERLINE', TextFormat.underline: 'UNDERLINE',
@@ -249,9 +251,13 @@ TextStyle applyTextFormats(
formats.contains(TextFormat.quote); formats.contains(TextFormat.quote);
final isMention = formats.contains(TextFormat.userMention); final isMention = formats.contains(TextFormat.userMention);
final isHeading = formats.contains(TextFormat.heading);
return base.copyWith( 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, fontStyle: isItalic ? FontStyle.italic : null,
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null, fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null,
color: isMention color: isMention
+68 -1
View File
@@ -2236,6 +2236,9 @@ class _ChatScreenState extends State<ChatScreen>
tempId: _nextTempId(), tempId: _nextTempId(),
time: now + i, time: now + i,
status: 'sending', status: 'sending',
sourceChatName: widget.name,
sourceChatIconUrl: widget.imageUrl,
sourceChatType: widget.chatType,
); );
optimistic.add(msg); optimistic.add(msg);
_messages.add(msg); _messages.add(msg);
@@ -2280,6 +2283,9 @@ class _ChatScreenState extends State<ChatScreen>
tempId: _nextTempId(), tempId: _nextTempId(),
time: now + i, time: now + i,
status: 'sending', status: 'sending',
sourceChatName: widget.name,
sourceChatIconUrl: widget.imageUrl,
sourceChatType: widget.chatType,
); );
optimistic.add(msg); optimistic.add(msg);
await AppDatabase.saveMessages([msg.toDbRow()]); await AppDatabase.saveMessages([msg.toDbRow()]);
@@ -3447,6 +3453,8 @@ class _ChatScreenState extends State<ChatScreen>
static String _formatLabel(TextFormat format) { static String _formatLabel(TextFormat format) {
switch (format) { switch (format) {
case TextFormat.heading:
return 'Заголовок';
case TextFormat.strong: case TextFormat.strong:
return 'Жирный'; return 'Жирный';
case TextFormat.emphasized: case TextFormat.emphasized:
@@ -4021,7 +4029,8 @@ class _ChatScreenState extends State<ChatScreen>
if (msg.attachments != null) { if (msg.attachments != null) {
for (final a in msg.attachments!) { for (final a in msg.attachments!) {
if (a is ForwardedMessageAttachment) { if (a is ForwardedMessageAttachment) {
if (a.originalSenderName == null && if (a.originalSenderId != 0 &&
a.originalSenderName == null &&
ContactCache.get(a.originalSenderId) == null) { ContactCache.get(a.originalSenderId) == null) {
forwardIds.add(a.originalSenderId); forwardIds.add(a.originalSenderId);
} }
@@ -4057,10 +4066,12 @@ class _ChatScreenState extends State<ChatScreen>
originalSenderId: a.originalSenderId, originalSenderId: a.originalSenderId,
originalSenderName: r.name, originalSenderName: r.name,
originalSenderAvatar: r.avatar, originalSenderAvatar: r.avatar,
originalType: a.originalType,
originalMessageId: a.originalMessageId, originalMessageId: a.originalMessageId,
originalTime: a.originalTime, originalTime: a.originalTime,
originalText: a.originalText, originalText: a.originalText,
originalChatId: a.originalChatId, originalChatId: a.originalChatId,
originalFormatRanges: a.originalFormatRanges,
originalAttachments: a.originalAttachments, originalAttachments: a.originalAttachments,
originalContact: a.originalContact, originalContact: a.originalContact,
); );
@@ -4271,6 +4282,61 @@ class _ChatScreenState extends State<ChatScreen>
); );
} }
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<void> _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) { void _openStickerPack(StickerAttachment sticker) {
final stickerId = int.tryParse(sticker.stickerId ?? ''); final stickerId = int.tryParse(sticker.stickerId ?? '');
if (stickerId == null) { if (stickerId == null) {
@@ -5238,6 +5304,7 @@ class _ChatScreenState extends State<ChatScreen>
onReplyTap: (id) => onReplyTap: (id) =>
_jumpToMessage(id, fromId: message.id), _jumpToMessage(id, fromId: message.id),
onAvatarTap: _openSenderProfile, onAvatarTap: _openSenderProfile,
onForwardedSourceTap: _openForwardedSource,
onStickerTap: _openStickerPack, onStickerTap: _openStickerPack,
onReactionTap: message.isControl onReactionTap: message.isControl
? null ? null
@@ -14,6 +14,9 @@ enum MessageType { text, attachment, voice, control }
enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle }
typedef ForwardedSourceTap =
void Function(ForwardedMessageAttachment forwarded);
final Expando<({bool full, String text})> _clockTextCache = Expando(); final Expando<({bool full, String text})> _clockTextCache = Expando();
({IconData icon, Color color}) messageStatusVisual( ({IconData icon, Color color}) messageStatusVisual(
@@ -69,6 +72,7 @@ class BubbleContext {
final ValueListenable<int>? otherReadTime; final ValueListenable<int>? otherReadTime;
final ValueListenable<List<double>>? uploadProgress; final ValueListenable<List<double>>? uploadProgress;
final void Function(StickerAttachment sticker)? onStickerTap; final void Function(StickerAttachment sticker)? onStickerTap;
final ForwardedSourceTap? onForwardedSourceTap;
BubbleContext({ BubbleContext({
required this.context, required this.context,
@@ -88,6 +92,7 @@ class BubbleContext {
this.otherReadTime, this.otherReadTime,
this.uploadProgress, this.uploadProgress,
this.onStickerTap, this.onStickerTap,
this.onForwardedSourceTap,
this.reactionInfo, this.reactionInfo,
}) : dim = text.withValues(alpha: 0.7); }) : dim = text.withValues(alpha: 0.7);
@@ -4,66 +4,113 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../../backend/modules/messages.dart'; import '../../../../backend/modules/messages.dart';
import '../../../../models/attachment.dart'; import '../../../../models/attachment.dart';
import '../../formatted_message_text.dart';
import 'bubble_context.dart'; import 'bubble_context.dart';
import 'contact_bubble.dart'; import 'contact_bubble.dart';
import 'file_bubble.dart'; import 'file_bubble.dart';
import 'photo_bubble.dart'; import 'photo_bubble.dart';
import 'sticker_bubble.dart'; import 'sticker_bubble.dart';
Widget _forwardedHeader( String _forwardedSourceName(ForwardedMessageAttachment forwarded) {
BubbleContext ctx, final resolved =
ForwardedMessageAttachment forwarded,
) {
final headerColor = ctx.dim;
final displaySender =
forwarded.originalSenderName ?? forwarded.originalSenderName ??
ContactCache.get(forwarded.originalSenderId) ?? ContactCache.get(forwarded.originalSenderId);
forwarded.originalSenderId.toString(); if (resolved != null && resolved.isNotEmpty) return resolved;
final senderAvatar = if (forwarded.isChannel) return 'Канал';
forwarded.originalSenderAvatar ?? if (forwarded.originalSenderId != 0) {
ContactCache.getAvatar(forwarded.originalSenderId); return forwarded.originalSenderId.toString();
return Padding( }
padding: const EdgeInsets.only(left: 8, top: 8, right: 8), return 'Сообщение';
child: Row( }
mainAxisSize: MainAxisSize.min,
children: [ String? _forwardedSourceAvatar(ForwardedMessageAttachment forwarded) =>
Icon(Symbols.forward, size: 14, color: headerColor), forwarded.originalSenderAvatar ??
const SizedBox(width: 4), ContactCache.getAvatar(forwarded.originalSenderId);
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar( class ForwardedHeader extends StatelessWidget {
radius: 10, final BubbleContext ctx;
backgroundImage: CachedNetworkImageProvider( final ForwardedMessageAttachment forwarded;
senderAvatar, final EdgeInsetsGeometry padding;
maxWidth: 96,
maxHeight: 96, 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, const SizedBox(width: 6),
) Flexible(
else
CircleAvatar(
radius: 10,
backgroundColor: ctx.cs.primaryContainer,
child: Text( child: Text(
displaySender.isNotEmpty ? displaySender[0].toUpperCase() : '?', displaySender,
style: TextStyle(fontSize: 9, color: ctx.cs.onPrimaryContainer), maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: headerColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
), ),
), ),
const SizedBox(width: 6), ],
Flexible( ),
child: Text( );
displaySender, final onTap = ctx.onForwardedSourceTap;
maxLines: 1, if (onTap == null) return content;
overflow: TextOverflow.ellipsis, return GestureDetector(
style: TextStyle( behavior: HitTestBehavior.opaque,
color: headerColor, onTap: () => onTap(forwarded),
fontSize: 12, child: content,
fontWeight: FontWeight.w500, );
), }
), }
),
], 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 ForwardedPhotoBubble extends StatelessWidget {
@@ -80,27 +127,26 @@ class ForwardedPhotoBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final message = ctx.message; final hasCaption = forwarded.originalText?.isNotEmpty ?? false;
final hasCaption = message.text != null && message.text!.isNotEmpty;
return Column( return SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: PhotoBubble.layoutWidth(photos),
mainAxisSize: MainAxisSize.min, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
_forwardedHeader(ctx, forwarded), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 4), children: [
if (hasCaption) ...[ ForwardedHeader(ctx: ctx, forwarded: forwarded),
Padding( const SizedBox(height: 4),
padding: const EdgeInsets.only(left: 8), PhotoBubble(
child: Text( ctx: ctx,
message.text ?? '', photos: photos,
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), 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, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_forwardedHeader(ctx, forwarded), ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4), 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) { ...attachments.map((a) {
if (a is FileAttachment) { if (a is FileAttachment) {
return FileBubble(ctx: ctx, file: a, fill: true); return FileBubble(ctx: ctx, file: a, fill: true);
@@ -159,8 +212,15 @@ class ForwardedStickerBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_forwardedHeader(ctx, forwarded), ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4), 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), StickerBubble(ctx: ctx, sticker: sticker),
], ],
); );
@@ -185,8 +245,15 @@ class ForwardedContactBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_forwardedHeader(ctx, forwarded), ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4), 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( buildContactCard(
ctx, ctx,
firstName: contact.firstName, firstName: contact.firstName,
@@ -21,18 +21,42 @@ class PhotoBubble extends StatelessWidget {
final BubbleContext ctx; final BubbleContext ctx;
final List<PhotoAttachment> photos; final List<PhotoAttachment> 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<PhotoAttachment> photos) {
if (photos.length != 1) return BubbleContext.photoMaxSize;
return (photos.single.width?.toDouble() ?? 200).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final message = ctx.message; 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; final count = photos.length;
Widget photosWidget; Widget photosWidget;
if (count == 1) { if (count == 1) {
photosWidget = _buildSinglePhoto(ctx, photos[0]); photosWidget = _buildSinglePhoto(
ctx,
photos[0],
hasCaption: hasCaption,
hasContentAbove: hasContentAbove,
);
} else if (count == 2) { } else if (count == 2) {
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
} else { } else {
@@ -53,15 +77,8 @@ class PhotoBubble extends StatelessWidget {
} }
if (count == 1) { if (count == 1) {
final photo = photos[0];
final pw = photo.width?.toDouble() ?? 200;
final photoWidth = pw.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
return SizedBox( return SizedBox(
width: photoWidth, width: layoutWidth(photos),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -76,7 +93,7 @@ class PhotoBubble extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded(child: ctx.caption()), Expanded(child: resolvedCaption),
ctx.meta(), ctx.meta(),
], ],
), ),
@@ -100,7 +117,7 @@ class PhotoBubble extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded(child: ctx.caption()), Expanded(child: resolvedCaption),
ctx.meta(), 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 width = photo.width?.toDouble() ?? 200;
final height = photo.height?.toDouble() ?? 200; final height = photo.height?.toDouble() ?? 200;
@@ -123,8 +145,8 @@ class PhotoBubble extends StatelessWidget {
); );
final dpr = MediaQuery.of(ctx.context).devicePixelRatio; final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
final matchTop = ctx.hasPhotoWithCaption; final matchTop = hasCaption && !hasContentAbove;
final matchBottom = !ctx.hasPhotoWithCaption; final matchBottom = !hasCaption;
final topR = matchTop ? _bigRadius : _photoRadius; final topR = matchTop ? _bigRadius : _photoRadius;
final bottomL = matchBottom final bottomL = matchBottom
+8 -48
View File
@@ -230,6 +230,7 @@ class MessageBubble extends StatelessWidget {
final ValueListenable<List<double>>? uploadProgress; final ValueListenable<List<double>>? uploadProgress;
final void Function(String messageId)? onReplyTap; final void Function(String messageId)? onReplyTap;
final void Function(int senderId)? onAvatarTap; final void Function(int senderId)? onAvatarTap;
final ForwardedSourceTap? onForwardedSourceTap;
final void Function(StickerAttachment sticker)? onStickerTap; final void Function(StickerAttachment sticker)? onStickerTap;
final void Function(String emoji)? onReactionTap; final void Function(String emoji)? onReactionTap;
final String? peerName; final String? peerName;
@@ -257,6 +258,7 @@ class MessageBubble extends StatelessWidget {
this.uploadProgress, this.uploadProgress,
this.onReplyTap, this.onReplyTap,
this.onAvatarTap, this.onAvatarTap,
this.onForwardedSourceTap,
this.onStickerTap, this.onStickerTap,
this.onReactionTap, this.onReactionTap,
this.peerName, this.peerName,
@@ -628,6 +630,7 @@ class MessageBubble extends StatelessWidget {
otherReadTime: otherReadTime, otherReadTime: otherReadTime,
uploadProgress: uploadProgress, uploadProgress: uploadProgress,
onStickerTap: onStickerTap, onStickerTap: onStickerTap,
onForwardedSourceTap: onForwardedSourceTap,
reactionInfo: _resolveReactionInfo(), reactionInfo: _resolveReactionInfo(),
); );
@@ -1457,14 +1460,6 @@ class MessageBubble extends StatelessWidget {
BubbleContext ctx, BubbleContext ctx,
ForwardedMessageAttachment forwarded, 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 origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty; final hasOrigText = origText != null && origText.isNotEmpty;
@@ -1472,49 +1467,14 @@ class MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( ForwardedHeader(
mainAxisSize: MainAxisSize.min, ctx: ctx,
children: [ forwarded: forwarded,
Icon(Symbols.forward, size: 14, color: headerColor), padding: EdgeInsets.zero,
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,
),
),
],
), ),
if (hasOrigText) ...[ if (hasOrigText) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text(origText, style: TextStyle(color: ctx.text, fontSize: 14)), buildForwardedMessageText(ctx, forwarded),
] else ...[ ] else ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
+28 -4
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../core/utils/parse.dart'; import '../core/utils/parse.dart';
import '../core/utils/text_format.dart';
enum AttachmentType { enum AttachmentType {
photo, photo,
@@ -677,10 +678,12 @@ class ForwardedMessageAttachment extends MessageAttachment {
final int originalSenderId; final int originalSenderId;
final String? originalSenderName; final String? originalSenderName;
final String? originalSenderAvatar; final String? originalSenderAvatar;
final String? originalType;
final String? originalMessageId; final String? originalMessageId;
final int? originalTime; final int? originalTime;
final String? originalText; final String? originalText;
final int? originalChatId; final int? originalChatId;
final List<FormatRange> originalFormatRanges;
final List<MessageAttachment>? originalAttachments; final List<MessageAttachment>? originalAttachments;
final ContactAttachment? originalContact; final ContactAttachment? originalContact;
@@ -688,14 +691,18 @@ class ForwardedMessageAttachment extends MessageAttachment {
required this.originalSenderId, required this.originalSenderId,
this.originalSenderName, this.originalSenderName,
this.originalSenderAvatar, this.originalSenderAvatar,
this.originalType,
this.originalMessageId, this.originalMessageId,
this.originalTime, this.originalTime,
this.originalText, this.originalText,
this.originalChatId, this.originalChatId,
this.originalFormatRanges = const [],
this.originalAttachments, this.originalAttachments,
this.originalContact, this.originalContact,
}) : super(type: AttachmentType.forward); }) : super(type: AttachmentType.forward);
bool get isChannel => originalType == 'CHANNEL';
factory ForwardedMessageAttachment.fromMap(Map<String, dynamic> map) { factory ForwardedMessageAttachment.fromMap(Map<String, dynamic> map) {
final linkRaw = map['link']; final linkRaw = map['link'];
Map<String, dynamic>? link; Map<String, dynamic>? 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( 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(), originalMessageId: message?['id']?.toString(),
originalTime: message?['time'] as int?, originalTime: parseIntOrNull(message?['time']),
originalText: message?['text'] as String?, originalText: message?['text']?.toString(),
originalChatId: link?['chatId'] as int?, originalChatId: parseIntOrNull(link?['chatId']),
originalFormatRanges: parseFormatElements(message?['elements']),
originalAttachments: originalAttaches, originalAttachments: originalAttaches,
originalContact: originalContact, originalContact: originalContact,
); );
@@ -753,10 +775,12 @@ class ForwardedMessageAttachment extends MessageAttachment {
'originalSenderId': originalSenderId, 'originalSenderId': originalSenderId,
'originalSenderName': originalSenderName, 'originalSenderName': originalSenderName,
'originalSenderAvatar': originalSenderAvatar, 'originalSenderAvatar': originalSenderAvatar,
'originalType': originalType,
'originalMessageId': originalMessageId, 'originalMessageId': originalMessageId,
'originalTime': originalTime, 'originalTime': originalTime,
'originalText': originalText, 'originalText': originalText,
'originalChatId': originalChatId, 'originalChatId': originalChatId,
'originalElements': serializeFormatElements(originalFormatRanges),
}; };
} }
+293
View File
@@ -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<void> _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<PhotoAttachment>());
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<FormattedMessageText>(
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);
});
});
}