fix: вроде починил пересланные сообщения с каналов
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user