refactor: зарефакторил пересланные сообщения

This commit is contained in:
Jganenokk
2026-08-09 01:10:03 +07:00
parent 45297e4aff
commit 05ca3a9413
10 changed files with 513 additions and 275 deletions
@@ -6,6 +6,7 @@ import '../../../../backend/modules/messages.dart';
import '../../../../core/config/app_colors.dart';
import '../../../../core/config/komet_settings.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/text_format.dart';
import '../../../../models/attachment.dart';
import '../../formatted_message_text.dart';
import '../../sending_clock_icon.dart';
@@ -20,6 +21,20 @@ typedef ForwardedSourceTap =
final Expando<({bool full, String text})> _clockTextCache = Expando();
class BubblePresentation {
final String? text;
final List<FormatRange> formatRanges;
final String? sourceMessageId;
final int? sourceChatId;
const BubblePresentation({
this.text,
this.formatRanges = const [],
this.sourceMessageId,
this.sourceChatId,
});
}
({IconData icon, Color color}) messageStatusVisual(
String? status, {
required Color dimColor,
@@ -75,6 +90,7 @@ class BubbleContext {
final ValueListenable<List<double>>? uploadProgress;
final void Function(StickerAttachment sticker)? onStickerTap;
final ForwardedSourceTap? onForwardedSourceTap;
final BubblePresentation? presentation;
BubbleContext({
required this.context,
@@ -97,8 +113,43 @@ class BubbleContext {
this.onStickerTap,
this.onForwardedSourceTap,
this.reactionInfo,
this.presentation,
}) : dim = text.withValues(alpha: 0.7);
String? get contentText =>
presentation == null ? message.text : presentation!.text;
List<FormatRange> get contentFormatRanges =>
presentation == null ? message.formatRanges : presentation!.formatRanges;
String get sourceMessageId => presentation?.sourceMessageId ?? message.id;
int get sourceChatId => presentation?.sourceChatId ?? message.chatId;
BubbleContext withPresentation(BubblePresentation value) => BubbleContext(
context: context,
cs: cs,
text: text,
shape: shape,
contentType: contentType,
hasPhotoWithCaption: hasPhotoWithCaption,
hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption,
message: message,
isMe: isMe,
myId: myId,
chatType: chatType,
chatId: chatId,
chatName: chatName,
photoActions: photoActions,
overrideStatus: overrideStatus,
otherReadTime: otherReadTime,
uploadProgress: uploadProgress,
onStickerTap: onStickerTap,
onForwardedSourceTap: onForwardedSourceTap,
reactionInfo: reactionInfo,
presentation: value,
);
String get clockText {
final full = KometSettings.fullTimestamp.value;
final cached = _clockTextCache[message];
@@ -115,15 +166,16 @@ class BubbleContext {
Widget caption() {
final style = TextStyle(color: text, fontSize: 16, height: 1.3);
final ranges = message.formatRanges;
if (FormattedMessageText.isFormatted(message.text, ranges)) {
final captionText = contentText;
final ranges = contentFormatRanges;
if (FormattedMessageText.isFormatted(captionText, ranges)) {
return FormattedMessageText(
text: message.text!,
text: captionText!,
ranges: ranges,
style: style,
);
}
return Text(message.text ?? '', style: style);
return Text(captionText ?? '', style: style);
}
Widget meta() {
@@ -4,12 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../../backend/modules/messages.dart';
import '../../../../models/attachment.dart';
import '../../formatted_message_text.dart';
import 'bubble_context.dart';
import 'contact_bubble.dart';
import 'file_bubble.dart';
import 'photo_bubble.dart';
import 'sticker_bubble.dart';
String _forwardedSourceName(ForwardedMessageAttachment forwarded) {
final resolved =
@@ -96,175 +91,30 @@ class ForwardedHeader extends StatelessWidget {
}
}
Widget buildForwardedMessageText(
BubbleContext ctx,
ForwardedMessageAttachment forwarded, {
double fontSize = 14,
}) {
final text = forwarded.originalText ?? '';
final style = TextStyle(color: ctx.text, fontSize: fontSize, height: 1.3);
if (FormattedMessageText.isFormatted(text, forwarded.originalFormatRanges)) {
return FormattedMessageText(
text: text,
ranges: forwarded.originalFormatRanges,
style: style,
);
}
return Text(text, style: style);
}
class ForwardedPhotoBubble extends StatelessWidget {
class ForwardedHeaderFloating extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final List<PhotoAttachment> photos;
const ForwardedPhotoBubble({
const ForwardedHeaderFloating({
super.key,
required this.ctx,
required this.forwarded,
required this.photos,
});
@override
Widget build(BuildContext context) {
final hasCaption = forwarded.originalText?.isNotEmpty ?? false;
return SizedBox(
width: PhotoBubble.layoutWidth(photos),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4),
PhotoBubble(
ctx: ctx,
photos: photos,
caption: hasCaption
? buildForwardedMessageText(ctx, forwarded, fontSize: 16)
: null,
hasContentAbove: true,
),
],
return Material(
color: ctx.isMe
? ctx.cs.primaryContainer
: ctx.cs.surfaceContainerHighest,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(18),
child: ForwardedHeader(
ctx: ctx,
forwarded: forwarded,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
);
}
}
class ForwardedGenericBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final List<MessageAttachment> attachments;
const ForwardedGenericBubble({
super.key,
required this.ctx,
required this.forwarded,
required this.attachments,
});
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4),
if (forwarded.originalText?.isNotEmpty ?? false) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: buildForwardedMessageText(ctx, forwarded),
),
const SizedBox(height: 6),
],
...attachments.map((a) {
if (a is FileAttachment) {
return FileBubble(ctx: ctx, file: a, fill: true);
}
if (a is StickerAttachment) {
return StickerBubble(ctx: ctx, sticker: a);
}
return const SizedBox.shrink();
}),
],
),
);
}
}
class ForwardedStickerBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
final MessageAttachment sticker;
const ForwardedStickerBubble({
super.key,
required this.ctx,
required this.forwarded,
required this.sticker,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4),
if (forwarded.originalText?.isNotEmpty ?? false) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: buildForwardedMessageText(ctx, forwarded),
),
const SizedBox(height: 6),
],
StickerBubble(ctx: ctx, sticker: sticker),
],
);
}
}
class ForwardedContactBubble extends StatelessWidget {
final BubbleContext ctx;
final ForwardedMessageAttachment forwarded;
const ForwardedContactBubble({
super.key,
required this.ctx,
required this.forwarded,
});
@override
Widget build(BuildContext context) {
final contact = forwarded.originalContact!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ForwardedHeader(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 4),
if (forwarded.originalText?.isNotEmpty ?? false) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: buildForwardedMessageText(ctx, forwarded),
),
const SizedBox(height: 6),
],
buildContactCard(
ctx,
firstName: contact.firstName,
lastName: contact.lastName,
name: contact.name,
photoUrl: contact.photoUrl ?? contact.baseUrl,
phoneNumber: contact.phoneNumber,
contactId: contact.contactId,
userId: contact.userId,
),
],
);
}
}
@@ -21,14 +21,12 @@ class PhotoBubble extends StatelessWidget {
final BubbleContext ctx;
final List<PhotoAttachment> photos;
final Widget? caption;
final bool hasContentAbove;
const PhotoBubble({
super.key,
required this.ctx,
required this.photos,
this.caption,
this.hasContentAbove = false,
});
@@ -42,10 +40,8 @@ class PhotoBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final message = ctx.message;
final hasMessageCaption = message.text != null && message.text!.isNotEmpty;
final resolvedCaption =
caption ?? (hasMessageCaption ? ctx.caption() : null);
final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false;
final resolvedCaption = hasMessageCaption ? ctx.caption() : null;
final hasCaption = resolvedCaption != null;
final count = photos.length;
@@ -20,11 +20,11 @@ class PollBubble extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PollView(
chatId: ctx.message.chatId,
messageId: ctx.message.id,
chatId: ctx.sourceChatId,
messageId: ctx.sourceMessageId,
pollId: poll.pollId,
myId: ctx.myId,
fallbackTitle: poll.title ?? ctx.message.text,
fallbackTitle: poll.title ?? ctx.contentText,
textColor: ctx.text,
dimColor: ctx.dim,
accentColor: ctx.isMe
@@ -16,8 +16,8 @@ class ShareBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isMe = ctx.isMe;
final message = ctx.message;
final hasText = message.text != null && message.text!.isNotEmpty;
final text = ctx.contentText;
final hasText = text?.isNotEmpty ?? false;
final image = share.image;
final imageUrl = image?.baseUrl ?? image?.previewData ?? '';
final cardColor = isMe
@@ -123,8 +123,8 @@ class ShareBubble extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FormattedMessageText(
text: message.text!,
ranges: message.formatRanges,
text: text!,
ranges: ctx.contentFormatRanges,
style: TextStyle(
color: ctx.text,
fontSize: 16,
@@ -19,6 +19,13 @@ class VideoBubble extends StatelessWidget {
const VideoBubble({super.key, required this.ctx, required this.video});
static double layoutWidth(VideoAttachment video) {
return (video.width?.toDouble() ?? 200.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
}
@override
Widget build(BuildContext context) {
final message = ctx.message;
@@ -27,6 +34,8 @@ class VideoBubble extends StatelessWidget {
attachment: video,
messageId: message.id,
chatId: message.chatId,
sourceMessageId: ctx.sourceMessageId,
sourceChatId: ctx.sourceChatId,
senderId: message.senderId,
isMe: ctx.isMe,
time: message.time,
@@ -36,7 +45,9 @@ class VideoBubble extends StatelessWidget {
uploadProgress: ctx.uploadProgress,
);
}
final hasCaption = message.text != null && message.text!.isNotEmpty;
final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false;
final resolvedCaption = hasMessageCaption ? ctx.caption() : null;
final hasCaption = resolvedCaption != null;
final thumb = video.thumbnail;
final durationMs = video.duration;
final previewUrl = (thumb != null && thumb.isNotEmpty)
@@ -45,12 +56,8 @@ class VideoBubble extends StatelessWidget {
? video.baseUrl!
: (video.previewData ?? '');
final w = video.width;
final h = video.height;
final width = (w?.toDouble() ?? 200.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
final width = layoutWidth(video);
final height = (h?.toDouble() ?? 150.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
@@ -192,7 +199,7 @@ class VideoBubble extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: ctx.caption()),
Expanded(child: resolvedCaption),
ctx.meta(),
],
),
@@ -212,8 +219,8 @@ class VideoBubble extends StatelessWidget {
Haptics.tap();
final sources = await messagesModule.getVideoSources(
messageId: ctx.message.id,
chatId: ctx.message.chatId,
messageId: ctx.sourceMessageId,
chatId: ctx.sourceChatId,
token: token,
videoId: videoId,
);
@@ -23,6 +23,8 @@ class VideoNoteBubble extends StatefulWidget {
final VideoAttachment attachment;
final String messageId;
final int chatId;
final String? sourceMessageId;
final int? sourceChatId;
final int senderId;
final bool isMe;
final int time;
@@ -36,6 +38,8 @@ class VideoNoteBubble extends StatefulWidget {
required this.attachment,
required this.messageId,
required this.chatId,
this.sourceMessageId,
this.sourceChatId,
required this.senderId,
required this.isMe,
required this.time,
@@ -217,8 +221,8 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
return VideoNotePreloader.load(
_cacheName,
() => messagesModule.getVideoUrl(
messageId: widget.messageId,
chatId: widget.chatId,
messageId: widget.sourceMessageId ?? widget.messageId,
chatId: widget.sourceChatId ?? widget.chatId,
token: token,
videoId: videoId,
),
@@ -26,6 +26,8 @@ class VoiceMessageBubble extends StatefulWidget {
final String? waveData;
final int chatId;
final String messageId;
final int? sourceChatId;
final String? sourceMessageId;
final int senderId;
final int? audioId;
final String? preloadedText;
@@ -45,6 +47,8 @@ class VoiceMessageBubble extends StatefulWidget {
this.waveData,
required this.chatId,
required this.messageId,
this.sourceChatId,
this.sourceMessageId,
required this.senderId,
this.audioId,
this.preloadedText,
@@ -87,7 +91,11 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
super.dispose();
}
String get _cacheName => '${widget.audioId ?? widget.messageId}.ogg';
String get _cacheName => '${widget.audioId ?? _sourceMessageId}.ogg';
int get _sourceChatId => widget.sourceChatId ?? widget.chatId;
String get _sourceMessageId => widget.sourceMessageId ?? widget.messageId;
void _claimPlayback() {
MediaPlayback.instance.activateVoice(
@@ -434,8 +442,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return;
}
if (TranscriptionCache.has(widget.messageId)) {
final cached = TranscriptionCache.get(widget.messageId)!;
if (TranscriptionCache.has(_sourceMessageId)) {
final cached = TranscriptionCache.get(_sourceMessageId)!;
setState(() {
_transcriptionText = cached.text ?? 'не удалось распознать текст';
_transcriptionVisible = true;
@@ -449,12 +457,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
try {
final result = await messagesModule.requestTranscription(
widget.chatId,
int.tryParse(widget.messageId) ?? 0,
_sourceChatId,
int.tryParse(_sourceMessageId) ?? 0,
widget.audioId!,
);
TranscriptionCache.put(widget.messageId, result);
TranscriptionCache.put(_sourceMessageId, result);
if (!mounted) return;
setState(() {
+141 -76
View File
@@ -529,21 +529,50 @@ class MessageBubble extends StatelessWidget {
});
bool _computeHasPhotoWithCaption() {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) return false;
final attachments = _contentAttachments;
if (attachments.isEmpty) return false;
final hasPhoto = attachments.any((a) => a is PhotoAttachment);
final hasCaption = message.text != null && message.text!.isNotEmpty;
final hasCaption = _contentText?.isNotEmpty ?? false;
return hasPhoto && hasCaption;
}
bool _computeHasMultiplePhotosNoCaption() {
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) return false;
final attachments = _contentAttachments;
if (attachments.isEmpty) return false;
final photoCount = attachments.whereType<PhotoAttachment>().length;
final hasCaption = message.text != null && message.text!.isNotEmpty;
final hasCaption = _contentText?.isNotEmpty ?? false;
return photoCount >= 2 && !hasCaption;
}
ForwardedMessageAttachment? get _forwarded => message.forwardedAttachment;
List<MessageAttachment> get _contentAttachments {
final forwarded = _forwarded;
if (forwarded != null) {
if (forwarded.originalContact != null) {
return [forwarded.originalContact!];
}
return forwarded.originalAttachments
?.where((a) => a is! InlineKeyboardAttachment)
.toList() ??
const [];
}
return message.attachments
?.where((a) => a is! InlineKeyboardAttachment)
.toList() ??
const [];
}
MessageAttachment? get _primaryAttachment {
final attachments = _contentAttachments;
return attachments.isEmpty ? null : attachments.first;
}
String? get _contentText {
final forwarded = _forwarded;
return forwarded == null ? message.text : forwarded.originalText;
}
bool get _showsSenderName =>
!isMe &&
chatType == "CHAT" &&
@@ -577,22 +606,15 @@ class MessageBubble extends StatelessWidget {
}
bool get _hasShareAttachment {
final a = message.attachments;
return a != null && a.isNotEmpty && a.first is ShareAttachment;
return _primaryAttachment is ShareAttachment;
}
bool get _isVideoNote {
final a = message.attachments;
if (a == null || a.isEmpty) return false;
final first = a.first;
final first = _primaryAttachment;
return first is VideoAttachment && first.isNote;
}
bool get _isSticker {
final a = message.attachments;
if (a == null || a.isEmpty) return false;
return a.first is StickerAttachment;
}
bool get _isSticker => _primaryAttachment is StickerAttachment;
static const int _jumboAnimojiLimit = 4;
@@ -621,26 +643,14 @@ class MessageBubble extends StatelessWidget {
MessageType _computeContentType() {
if (message.isControl) return MessageType.control;
final attachments = message.attachments
?.where((a) => a is! InlineKeyboardAttachment)
.toList();
if (attachments != null && attachments.isNotEmpty) {
final attachments = _contentAttachments;
if (attachments.isNotEmpty) {
final first = attachments.first;
if (first is ForwardedMessageAttachment) {
final fwd = first;
final hasContact = fwd.originalContact != null;
final hasPhoto =
fwd.originalAttachments != null &&
fwd.originalAttachments!.any((a) => a is PhotoAttachment);
final hasOther =
fwd.originalAttachments != null &&
fwd.originalAttachments!.isNotEmpty;
if (hasContact || hasPhoto || hasOther) return MessageType.attachment;
return MessageType.text;
}
if (first is ContactAttachment) return MessageType.attachment;
if (first is UnknownAttachment) return MessageType.text;
if (first.type == AttachmentType.audio) return MessageType.voice;
if (first.type == AttachmentType.audio) {
return _forwarded == null ? MessageType.voice : MessageType.attachment;
}
if (first is ShareAttachment) {
return AppLinkPreview.current.value
? MessageType.attachment
@@ -649,6 +659,8 @@ class MessageBubble extends StatelessWidget {
return MessageType.attachment;
}
if (_forwarded != null) return MessageType.text;
final payload = message.payload;
if (payload == null) return MessageType.text;
if (payload['voice'] != null) return MessageType.voice;
@@ -1781,6 +1793,7 @@ class MessageBubble extends StatelessWidget {
) {
final origText = forwarded.originalText;
final hasOrigText = origText != null && origText.isNotEmpty;
final forwardedCtx = _forwardedContext(ctx, forwarded);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1793,7 +1806,7 @@ class MessageBubble extends StatelessWidget {
),
if (hasOrigText) ...[
const SizedBox(height: 2),
_wrapSelectable(buildForwardedMessageText(ctx, forwarded)),
_wrapSelectable(forwardedCtx.caption()),
] else ...[
const SizedBox(height: 2),
_wrapSelectable(
@@ -1815,35 +1828,76 @@ class MessageBubble extends StatelessWidget {
final first = attachments.first;
if (first is ForwardedMessageAttachment) {
final fwd = first;
if (fwd.originalContact != null) {
return ForwardedContactBubble(ctx: ctx, forwarded: fwd);
}
final photos = fwd.originalAttachments
?.whereType<PhotoAttachment>()
.toList();
if (photos != null && photos.isNotEmpty) {
return ForwardedPhotoBubble(ctx: ctx, forwarded: fwd, photos: photos);
}
final stickers = fwd.originalAttachments
?.whereType<StickerAttachment>()
.toList();
if (stickers != null && stickers.isNotEmpty) {
return ForwardedStickerBubble(
ctx: ctx,
forwarded: fwd,
sticker: stickers.first,
);
}
final files = fwd.originalAttachments;
if (files != null && files.isNotEmpty) {
return ForwardedGenericBubble(
ctx: ctx,
forwarded: fwd,
attachments: files,
);
}
return _buildTextContent(ctx);
return _buildForwardedAttachmentContent(ctx, first);
}
return _buildNativeAttachmentContent(ctx, attachments);
}
BubbleContext _forwardedContext(
BubbleContext ctx,
ForwardedMessageAttachment forwarded,
) => ctx.withPresentation(
BubblePresentation(
text: forwarded.originalText,
formatRanges: forwarded.originalFormatRanges,
sourceMessageId: forwarded.originalMessageId,
sourceChatId: forwarded.originalChatId,
),
);
Widget _buildForwardedAttachmentContent(
BubbleContext ctx,
ForwardedMessageAttachment forwarded,
) {
final forwardedCtx = _forwardedContext(ctx, forwarded);
final attachments =
forwarded.originalAttachments
?.where((a) => a is! InlineKeyboardAttachment)
.toList() ??
const <MessageAttachment>[];
final content = _buildNativeAttachmentContent(
forwardedCtx,
attachments,
contact: forwarded.originalContact,
hasContentAbove: true,
);
final primary =
forwarded.originalContact ??
(attachments.isEmpty ? null : attachments.first);
final floatingHeader =
primary is StickerAttachment ||
(primary is VideoAttachment && primary.isNote);
if (floatingHeader) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ForwardedHeaderFloating(ctx: ctx, forwarded: forwarded),
const SizedBox(height: 6),
content,
],
);
}
return _HeaderAboveMatchWidth(
content: content,
header: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ForwardedHeader(ctx: ctx, forwarded: forwarded),
),
);
}
Widget _buildNativeAttachmentContent(
BubbleContext ctx,
List<MessageAttachment> attachments, {
ContactAttachment? contact,
bool hasContentAbove = false,
}) {
if (contact != null) {
return ContactBubble(ctx: ctx, contact: contact);
}
final contacts = attachments.whereType<ContactAttachment>().toList();
@@ -1866,7 +1920,11 @@ class MessageBubble extends StatelessWidget {
return _buildGenericAttachment(ctx, attachments.first);
}
return PhotoBubble(ctx: ctx, photos: photos);
return PhotoBubble(
ctx: ctx,
photos: photos,
hasContentAbove: hasContentAbove,
);
}
Widget _buildGenericAttachment(
@@ -1887,30 +1945,35 @@ class MessageBubble extends StatelessWidget {
);
case AttachmentType.call:
return CallBubble(ctx: ctx, call: attachment as CallAttachment);
case AttachmentType.audio:
return Padding(
padding: _paddingFor(MessageType.voice, ctx.shape),
child: _buildVoiceAttachment(ctx, attachment as AudioAttachment),
);
default:
return _buildTextContent(ctx);
}
}
Widget _buildVoiceContent(BubbleContext ctx) {
int duration = 0;
String url = '';
String? waveData;
int? audioId;
AudioAttachment? audio;
final attaches = message.attachments;
if (attaches != null && attaches.isNotEmpty) {
for (final a in attaches) {
if (a is AudioAttachment) {
duration = ((a.duration ?? 0) / 1000).round();
url = a.fileUrl ?? a.baseUrl ?? '';
waveData = a.waveform;
audioId = a.audioId;
audio = a;
break;
}
}
}
return _buildVoiceAttachment(ctx, audio);
}
Widget _buildVoiceAttachment(BubbleContext ctx, AudioAttachment? audio) {
var duration = ((audio?.duration ?? 0) / 1000).round();
var url = audio?.fileUrl ?? audio?.baseUrl ?? '';
if (duration == 0 && url.isEmpty) {
final payload = message.payload;
final voice = payload?['voice'] as Map<String, dynamic>?;
@@ -1918,7 +1981,7 @@ class MessageBubble extends StatelessWidget {
url = voice?['url']?.toString() ?? '';
}
final cachedTranscription = TranscriptionCache.get(message.id);
final cachedTranscription = TranscriptionCache.get(ctx.sourceMessageId);
return VoiceMessageBubble(
duration: duration,
@@ -1930,11 +1993,13 @@ class MessageBubble extends StatelessWidget {
otherReadTime: otherReadTime,
time: message.time,
cs: ctx.cs,
waveData: waveData,
waveData: audio?.waveform,
chatId: message.chatId,
messageId: message.id,
sourceChatId: ctx.sourceChatId,
sourceMessageId: ctx.sourceMessageId,
senderId: message.senderId,
audioId: audioId,
audioId: audio?.audioId,
preloadedText: cachedTranscription?.text,
uploadProgress: ctx.uploadProgress,
);
+258 -2
View File
@@ -2,16 +2,29 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:komet/core/utils/text_format.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/bubble_context.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/call_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/contact_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/file_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/forwarded_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/location_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/photo_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/share_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/sticker_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/video_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/video_note_bubble.dart';
import 'package:komet/frontend/widgets/attachment/bubbles/voice_bubble.dart';
import 'package:komet/frontend/widgets/formatted_message_text.dart';
import 'package:komet/frontend/widgets/message_bubble.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:komet/models/attachment.dart';
import 'package:material_symbols_icons/symbols.dart';
Future<void> _pumpBubble(
WidgetTester tester,
CachedMessage message, {
void Function(ForwardedMessageAttachment forwarded)? onSourceTap,
bool isMe = false,
}) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.5;
@@ -27,7 +40,7 @@ Future<void> _pumpBubble(
alignment: Alignment.topLeft,
child: MessageBubble(
message: message,
isMe: false,
isMe: isMe,
myId: 1,
chatType: 'CHAT',
onForwardedSourceTap: onSourceTap,
@@ -242,9 +255,13 @@ void main() {
expect(formatted.ranges.single.format, TextFormat.heading);
expect(find.text('0'), findsNothing);
expect(
tester.getSize(find.byType(ForwardedPhotoBubble)).width,
tester.getSize(find.byType(ForwardedHeader)).width,
closeTo(200, 0.1),
);
expect(
tester.getSize(find.byType(ForwardedHeader)).width,
closeTo(tester.getSize(find.byType(PhotoBubble)).width, 0.1),
);
expect(
tester.getBottomLeft(find.byType(ClipRRect).first).dy,
lessThanOrEqualTo(tester.getTopLeft(find.text(caption)).dy),
@@ -255,6 +272,245 @@ void main() {
expect(tappedSource?.isChannel, isTrue);
});
testWidgets('renders a forwarded video with its original metadata', (
tester,
) async {
final attachment = ForwardedMessageAttachment.fromMap({
'link': {
'type': 'FORWARD',
'message': {
'id': 'synthetic-source-message',
'time': 9000,
'type': 'USER',
'sender': 44,
'text': 'Synthetic video caption',
'attaches': [
{
'_type': 'VIDEO',
'videoId': 7001,
'token': 'synthetic-video-token',
'videoType': 0,
'duration': 9000,
'width': 720,
'height': 1280,
},
],
},
'chatId': -50,
},
});
final video = attachment.originalAttachments!.single as VideoAttachment;
final message = CachedMessage(
id: 'synthetic-forward-message',
accountId: 1,
chatId: 2,
senderId: 43,
time: 10000,
attachments: [attachment],
);
expect(video.videoId, 7001);
expect(video.videoToken, 'synthetic-video-token');
expect(video.videoType, 0);
await _pumpBubble(tester, message);
expect(find.byType(ForwardedHeader), findsOneWidget);
expect(find.byType(VideoBubble), findsOneWidget);
expect(find.text('Synthetic video caption'), findsOneWidget);
expect(find.text('0:09'), findsOneWidget);
final forwardedWidth = tester.getSize(find.byType(ForwardedHeader)).width;
final videoWidth = tester.getSize(find.byType(VideoBubble)).width;
expect(forwardedWidth, closeTo(BubbleContext.photoMaxSize, 0.1));
expect(forwardedWidth, closeTo(videoWidth, 0.1));
final preview = find.descendant(
of: find.byType(VideoBubble),
matching: find.byType(ClipRRect),
);
expect(
tester.getBottomLeft(preview.first).dy,
lessThanOrEqualTo(
tester.getTopLeft(find.text('Synthetic video caption')).dy,
),
);
final bubble = tester.widget<VideoBubble>(find.byType(VideoBubble));
expect(bubble.ctx.sourceMessageId, 'synthetic-source-message');
expect(bubble.ctx.sourceChatId, -50);
final forwardedVideoSize = tester.getSize(find.byType(VideoBubble));
final forwardedPreviewSize = tester.getSize(preview.first);
final regularMessage = CachedMessage(
id: 'synthetic-regular-message',
accountId: 1,
chatId: 2,
senderId: 43,
time: 10000,
text: 'Synthetic video caption',
attachments: [video],
);
await _pumpBubble(tester, regularMessage);
final regularPreview = find.descendant(
of: find.byType(VideoBubble),
matching: find.byType(ClipRRect),
);
expect(tester.getSize(find.byType(VideoBubble)), forwardedVideoSize);
expect(tester.getSize(regularPreview.first), forwardedPreviewSize);
expect(find.byType(ForwardedHeader), findsNothing);
});
final nativeAttachmentCases =
<({String name, MessageAttachment attachment, Type bubbleType})>[
(
name: 'file',
attachment: const FileAttachment(
fileId: 7101,
name: 'synthetic.txt',
size: 128,
),
bubbleType: FileBubble,
),
(
name: 'sticker',
attachment: const StickerAttachment(
stickerId: 'synthetic-sticker',
width: 128,
height: 128,
),
bubbleType: StickerBubble,
),
(
name: 'location',
attachment: const LocationAttachment(
latitude: 1,
longitude: 2,
title: 'Synthetic location',
),
bubbleType: LocationBubble,
),
(
name: 'call',
attachment: const CallAttachment(isVideo: false, durationMs: 1000),
bubbleType: CallBubble,
),
(
name: 'share',
attachment: const ShareAttachment(
shareId: 7102,
title: 'Synthetic preview',
url: 'https://example.test/synthetic',
),
bubbleType: ShareBubble,
),
(
name: 'audio',
attachment: const AudioAttachment(
audioId: 7103,
duration: 1000,
baseUrl: 'https://example.test/synthetic.ogg',
),
bubbleType: VoiceMessageBubble,
),
(
name: 'video note',
attachment: const VideoAttachment(
videoId: 7104,
videoToken: 'synthetic-note-token',
videoType: 1,
duration: 1000,
width: 480,
height: 480,
),
bubbleType: VideoNoteBubble,
),
];
for (final item in nativeAttachmentCases) {
testWidgets('decorates forwarded ${item.name} native bubble', (
tester,
) async {
final forwarded = ForwardedMessageAttachment(
originalSenderId: 71,
originalMessageId: 'synthetic-source-${item.name}',
originalChatId: 72,
originalText: item.name == 'share'
? 'Synthetic forwarded caption'
: null,
originalAttachments: [item.attachment],
);
final message = CachedMessage(
id: 'synthetic-forward-${item.name}',
accountId: 1,
chatId: 2,
senderId: 70,
time: 11000,
text: 'Synthetic outer text',
attachments: [forwarded],
);
await _pumpBubble(tester, message, isMe: item.name == 'audio');
expect(find.byType(ForwardedHeader), findsOneWidget);
expect(find.byType(item.bubbleType), findsOneWidget);
final usesFloatingHeader =
item.name == 'sticker' || item.name == 'video note';
if (usesFloatingHeader) {
expect(find.byType(ForwardedHeaderFloating), findsOneWidget);
expect(
tester.getRect(find.byType(ForwardedHeaderFloating)).bottom,
lessThanOrEqualTo(tester.getRect(find.byType(item.bubbleType)).top),
);
} else {
expect(find.byType(ForwardedHeaderFloating), findsNothing);
}
if (item.name == 'audio') {
final headerRect = tester.getRect(find.byType(ForwardedHeader));
final voiceRect = tester.getRect(find.byType(VoiceMessageBubble));
expect(voiceRect.left - headerRect.left, closeTo(14, 0.1));
expect(headerRect.right - voiceRect.right, closeTo(14, 0.1));
expect(
find.descendant(
of: find.byType(VoiceMessageBubble),
matching: find.byIcon(Symbols.check),
),
findsOneWidget,
);
}
if (item.name == 'share') {
expect(find.text('Synthetic forwarded caption'), findsOneWidget);
expect(find.text('Synthetic outer text'), findsNothing);
}
});
}
testWidgets('decorates the native forwarded contact bubble', (
tester,
) async {
const forwarded = ForwardedMessageAttachment(
originalSenderId: 73,
originalMessageId: 'synthetic-contact-source',
originalChatId: 74,
originalContact: ContactAttachment(
firstName: 'Synthetic',
lastName: 'Contact',
phoneNumber: '+10000000000',
),
);
const message = CachedMessage(
id: 'synthetic-contact-forward',
accountId: 1,
chatId: 2,
senderId: 70,
time: 12000,
attachments: [forwarded],
);
await _pumpBubble(tester, message);
expect(find.byType(ForwardedHeader), findsOneWidget);
expect(find.byType(ContactBubble), findsOneWidget);
});
testWidgets('makes the forwarded user name clickable', (tester) async {
const attachment = ForwardedMessageAttachment(
originalSenderId: 42,