1818 lines
55 KiB
Dart
1818 lines
55 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:komet/main.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import '../../backend/modules/messages.dart';
|
|
import '../screens/webapp/web_app_screen.dart';
|
|
import '../../core/config/app_bubble_behavior.dart';
|
|
import '../../core/config/app_bubble_shape.dart';
|
|
import '../../core/crypto/message_decryption_cache.dart';
|
|
import 'decrypted_text.dart';
|
|
import '../../core/utils/bubble_radius.dart';
|
|
import '../../core/utils/emoji_keyword_index.dart';
|
|
import '../../core/utils/link_opener.dart';
|
|
import '../../core/utils/text_format.dart';
|
|
import '../../core/utils/webview_support.dart';
|
|
import '../../core/config/app_link_preview.dart';
|
|
import 'custom_notification.dart';
|
|
import 'formatted_message_text.dart';
|
|
import 'photo_viewer.dart';
|
|
import 'selectable_message_text.dart';
|
|
import '../../models/attachment.dart';
|
|
import '../../models/animoji.dart';
|
|
import '../../models/reaction_info.dart';
|
|
import 'attachment/bubbles/voice_bubble.dart';
|
|
import 'attachment/bubbles/bubble_context.dart';
|
|
import 'attachment/bubbles/poll_bubble.dart';
|
|
import 'attachment/bubbles/share_bubble.dart';
|
|
import 'attachment/bubbles/call_bubble.dart';
|
|
import 'attachment/bubbles/control_bubble.dart';
|
|
import 'attachment/bubbles/location_bubble.dart';
|
|
import 'attachment/bubbles/contact_bubble.dart';
|
|
import 'attachment/bubbles/sticker_bubble.dart';
|
|
import 'attachment/bubbles/photo_bubble.dart';
|
|
import 'attachment/bubbles/video_bubble.dart';
|
|
import 'attachment/bubbles/file_bubble.dart';
|
|
import 'attachment/bubbles/forwarded_bubble.dart';
|
|
import 'lottie_image.dart';
|
|
|
|
final Expando<MessageType> _contentTypeCache = Expando<MessageType>();
|
|
|
|
class ReactionAnimationEvent {
|
|
final String messageId;
|
|
final String emoji;
|
|
final int token;
|
|
|
|
const ReactionAnimationEvent({
|
|
required this.messageId,
|
|
required this.emoji,
|
|
required this.token,
|
|
});
|
|
}
|
|
|
|
typedef ReactionAnimojiResolver = Animoji? Function(String emoji);
|
|
|
|
class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget {
|
|
const _ZeroIntrinsicWidth({required Widget super.child});
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) =>
|
|
_RenderZeroIntrinsicWidth();
|
|
}
|
|
|
|
class _RenderZeroIntrinsicWidth extends RenderProxyBox {
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) => 0;
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) => 0;
|
|
}
|
|
|
|
/// Stacks [bottom] directly beneath [top] and forces [bottom] to take exactly
|
|
/// [top]'s rendered width. Used to keep an inline keyboard and a comments footer
|
|
/// pinned to their post's natural width instead of stretching to the bubble max
|
|
/// — the latter would otherwise inflate a narrow post (single photo, link
|
|
/// preview) to the full bubble width.
|
|
class _StackMatchTopWidth extends MultiChildRenderObjectWidget {
|
|
_StackMatchTopWidth({
|
|
required Widget top,
|
|
required Widget bottom,
|
|
this.growForBottom = false,
|
|
}) : super(children: [top, bottom]);
|
|
|
|
final bool growForBottom;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) =>
|
|
_RenderStackMatchTopWidth(growForBottom);
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context,
|
|
_RenderStackMatchTopWidth renderObject,
|
|
) {
|
|
renderObject.growForBottom = growForBottom;
|
|
}
|
|
}
|
|
|
|
class _StackMatchTopWidthParentData extends ContainerBoxParentData<RenderBox> {}
|
|
|
|
class _RenderStackMatchTopWidth extends RenderBox
|
|
with
|
|
ContainerRenderObjectMixin<RenderBox, _StackMatchTopWidthParentData>,
|
|
RenderBoxContainerDefaultsMixin<
|
|
RenderBox,
|
|
_StackMatchTopWidthParentData
|
|
> {
|
|
_RenderStackMatchTopWidth(this._growForBottom);
|
|
|
|
bool _growForBottom;
|
|
set growForBottom(bool value) {
|
|
if (value == _growForBottom) return;
|
|
_growForBottom = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
@override
|
|
void setupParentData(RenderBox child) {
|
|
if (child.parentData is! _StackMatchTopWidthParentData) {
|
|
child.parentData = _StackMatchTopWidthParentData();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
final RenderBox top = firstChild!;
|
|
final RenderBox bottom = childAfter(top)!;
|
|
|
|
top.layout(constraints.loosen(), parentUsesSize: true);
|
|
final Size topSize = top.size;
|
|
(top.parentData! as _StackMatchTopWidthParentData).offset = Offset.zero;
|
|
|
|
double width = topSize.width;
|
|
if (_growForBottom) {
|
|
width = math.max(width, bottom.getMaxIntrinsicWidth(double.infinity));
|
|
}
|
|
width = constraints.constrainWidth(width);
|
|
|
|
bottom.layout(
|
|
BoxConstraints.tightFor(width: width).enforce(constraints),
|
|
parentUsesSize: true,
|
|
);
|
|
final Size bottomSize = bottom.size;
|
|
(bottom.parentData! as _StackMatchTopWidthParentData).offset = Offset(
|
|
0,
|
|
topSize.height,
|
|
);
|
|
|
|
size = constraints.constrain(
|
|
Size(math.max(width, topSize.width), topSize.height + bottomSize.height),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
defaultPaint(context, offset);
|
|
}
|
|
|
|
@override
|
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
|
return defaultHitTestChildren(result, position: position);
|
|
}
|
|
}
|
|
|
|
/// A [Wrap] that reports its single-line width as the max intrinsic width, so an
|
|
/// enclosing [IntrinsicWidth] grows the bubble to fit the chips on one line
|
|
/// instead of collapsing to the widest single chip (which makes them stack).
|
|
/// It still wraps to multiple lines when the available width is smaller.
|
|
class _ReactionsWrap extends Wrap {
|
|
const _ReactionsWrap({
|
|
super.spacing,
|
|
super.runSpacing,
|
|
required super.children,
|
|
});
|
|
|
|
@override
|
|
RenderWrap createRenderObject(BuildContext context) {
|
|
return _RenderReactionsWrap(
|
|
direction: direction,
|
|
alignment: alignment,
|
|
spacing: spacing,
|
|
runAlignment: runAlignment,
|
|
runSpacing: runSpacing,
|
|
crossAxisAlignment: crossAxisAlignment,
|
|
textDirection: textDirection ?? Directionality.maybeOf(context),
|
|
verticalDirection: verticalDirection,
|
|
clipBehavior: clipBehavior,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RenderReactionsWrap extends RenderWrap {
|
|
_RenderReactionsWrap({
|
|
super.direction,
|
|
super.alignment,
|
|
super.spacing,
|
|
super.runAlignment,
|
|
super.runSpacing,
|
|
super.crossAxisAlignment,
|
|
super.textDirection,
|
|
super.verticalDirection,
|
|
super.clipBehavior,
|
|
});
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
var total = 0.0;
|
|
var count = 0;
|
|
RenderBox? child = firstChild;
|
|
while (child != null) {
|
|
total += child.getMaxIntrinsicWidth(double.infinity);
|
|
count++;
|
|
child = childAfter(child);
|
|
}
|
|
if (count > 1) total += spacing * (count - 1);
|
|
return total;
|
|
}
|
|
}
|
|
|
|
class _ReactionAnimojiGlyph extends StatefulWidget {
|
|
final String messageId;
|
|
final String emoji;
|
|
final Animoji animoji;
|
|
final ValueListenable<ReactionAnimationEvent?>? animation;
|
|
|
|
const _ReactionAnimojiGlyph({
|
|
super.key,
|
|
required this.messageId,
|
|
required this.emoji,
|
|
required this.animoji,
|
|
this.animation,
|
|
});
|
|
|
|
@override
|
|
State<_ReactionAnimojiGlyph> createState() => _ReactionAnimojiGlyphState();
|
|
}
|
|
|
|
class _ReactionAnimojiGlyphState extends State<_ReactionAnimojiGlyph> {
|
|
static const double _size = 18;
|
|
static const double _effectSize = _size * 2;
|
|
|
|
int? _playingToken;
|
|
bool _bodyPlaying = false;
|
|
bool _effectPlaying = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.animation?.addListener(_onAnimation);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_ReactionAnimojiGlyph oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (!identical(oldWidget.animation, widget.animation)) {
|
|
oldWidget.animation?.removeListener(_onAnimation);
|
|
widget.animation?.addListener(_onAnimation);
|
|
}
|
|
if (oldWidget.messageId != widget.messageId ||
|
|
EmojiKeywordIndex.normalize(oldWidget.emoji) !=
|
|
EmojiKeywordIndex.normalize(widget.emoji)) {
|
|
_playingToken = null;
|
|
_bodyPlaying = false;
|
|
_effectPlaying = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.animation?.removeListener(_onAnimation);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onAnimation() {
|
|
final event = widget.animation?.value;
|
|
if (event == null ||
|
|
event.messageId != widget.messageId ||
|
|
EmojiKeywordIndex.normalize(event.emoji) !=
|
|
EmojiKeywordIndex.normalize(widget.emoji) ||
|
|
event.token == _playingToken) {
|
|
return;
|
|
}
|
|
final bodyUrl = widget.animoji.lottieUrl;
|
|
final effectUrl = widget.animoji.lottiePlayUrl;
|
|
setState(() {
|
|
_playingToken = event.token;
|
|
_bodyPlaying = bodyUrl != null && bodyUrl.isNotEmpty;
|
|
_effectPlaying = effectUrl != null && effectUrl.isNotEmpty;
|
|
});
|
|
}
|
|
|
|
void _onBodyCompleted() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bodyPlaying = false;
|
|
if (!_effectPlaying) _playingToken = null;
|
|
});
|
|
}
|
|
|
|
void _onEffectCompleted() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_effectPlaying = false;
|
|
if (!_bodyPlaying) _playingToken = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final staticUrl = widget.animoji.iconUrl;
|
|
final bodyAnimationUrl = widget.animoji.lottieUrl;
|
|
final effectAnimationUrl = widget.animoji.lottiePlayUrl;
|
|
final Widget body;
|
|
if (_bodyPlaying) {
|
|
body = LottieImage(
|
|
key: ValueKey(('body', _playingToken)),
|
|
url: staticUrl,
|
|
lottieUrl: bodyAnimationUrl,
|
|
size: _size,
|
|
memCacheWidth: 64,
|
|
shimmer: false,
|
|
eager: true,
|
|
repeat: false,
|
|
onCompleted: _onBodyCompleted,
|
|
);
|
|
} else if (staticUrl != null && staticUrl.isNotEmpty) {
|
|
body = LottieImage(
|
|
url: staticUrl,
|
|
size: _size,
|
|
memCacheWidth: 64,
|
|
shimmer: false,
|
|
);
|
|
} else if (bodyAnimationUrl != null && bodyAnimationUrl.isNotEmpty) {
|
|
body = LottieImage(
|
|
lottieUrl: bodyAnimationUrl,
|
|
size: _size,
|
|
memCacheWidth: 64,
|
|
shimmer: false,
|
|
animate: false,
|
|
repeat: false,
|
|
);
|
|
} else {
|
|
body = Text(widget.emoji, style: const TextStyle(fontSize: 13));
|
|
}
|
|
|
|
return SizedBox(
|
|
width: _size,
|
|
height: _size,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
body,
|
|
if (_effectPlaying)
|
|
Positioned(
|
|
left: -(_effectSize - _size) / 2,
|
|
top: -(_effectSize - _size) / 2,
|
|
width: _effectSize,
|
|
height: _effectSize,
|
|
child: LottieImage(
|
|
key: ValueKey(('effect', _playingToken)),
|
|
lottieUrl: effectAnimationUrl,
|
|
size: _effectSize,
|
|
memCacheWidth: 128,
|
|
shimmer: false,
|
|
eager: true,
|
|
repeat: false,
|
|
onCompleted: _onEffectCompleted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MessageBubble extends StatelessWidget {
|
|
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
|
|
static const BorderRadius _reactionChipRadius = BorderRadius.all(
|
|
Radius.circular(10),
|
|
);
|
|
|
|
static Color bubbleTextColor(BuildContext context) =>
|
|
Theme.of(context).brightness == Brightness.dark
|
|
? Colors.white
|
|
: Colors.black;
|
|
|
|
final CachedMessage message;
|
|
final bool isMe;
|
|
final int myId;
|
|
final CachedMessage? prevMessage;
|
|
final CachedMessage? nextMessage;
|
|
final String chatType;
|
|
final int? chatId;
|
|
final PhotoViewerActions? photoActions;
|
|
final String? overrideStatus;
|
|
final ValueListenable<int>? otherReadTime;
|
|
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
|
final ValueListenable<ReactionAnimationEvent?>? reactionAnimation;
|
|
final ReactionAnimojiResolver? reactionAnimojiResolver;
|
|
final ValueListenable<List<double>>? uploadProgress;
|
|
final void Function(String messageId)? onReplyTap;
|
|
final void Function(int senderId)? onAvatarTap;
|
|
final ForwardedSourceTap? onForwardedSourceTap;
|
|
final void Function(StickerAttachment sticker)? onStickerTap;
|
|
final void Function(String emoji)? onReactionTap;
|
|
final String? peerName;
|
|
final String? peerAvatarUrl;
|
|
final String? senderNameOverride;
|
|
final String? senderAvatarOverride;
|
|
final ValueListenable<({String id, Offset pos})?>? textSelection;
|
|
final VoidCallback? onExitTextSelection;
|
|
final String? commentsLabel;
|
|
final VoidCallback? onCommentsTap;
|
|
|
|
const MessageBubble({
|
|
super.key,
|
|
required this.message,
|
|
required this.isMe,
|
|
required this.myId,
|
|
this.prevMessage,
|
|
this.nextMessage,
|
|
required this.chatType,
|
|
this.chatId,
|
|
this.photoActions,
|
|
this.overrideStatus,
|
|
this.otherReadTime,
|
|
this.reactionsListenable,
|
|
this.reactionAnimation,
|
|
this.reactionAnimojiResolver,
|
|
this.uploadProgress,
|
|
this.onReplyTap,
|
|
this.onAvatarTap,
|
|
this.onForwardedSourceTap,
|
|
this.onStickerTap,
|
|
this.onReactionTap,
|
|
this.peerName,
|
|
this.peerAvatarUrl,
|
|
this.senderNameOverride,
|
|
this.senderAvatarOverride,
|
|
this.textSelection,
|
|
this.onExitTextSelection,
|
|
this.commentsLabel,
|
|
this.onCommentsTap,
|
|
});
|
|
|
|
bool _computeHasPhotoWithCaption() {
|
|
final attachments = message.attachments;
|
|
if (attachments == null || attachments.isEmpty) return false;
|
|
final hasPhoto = attachments.any((a) => a is PhotoAttachment);
|
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
|
return hasPhoto && hasCaption;
|
|
}
|
|
|
|
bool _computeHasMultiplePhotosNoCaption() {
|
|
final attachments = message.attachments;
|
|
if (attachments == null || attachments.isEmpty) return false;
|
|
final photoCount = attachments.whereType<PhotoAttachment>().length;
|
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
|
return photoCount >= 2 && !hasCaption;
|
|
}
|
|
|
|
bool get _showsSenderName =>
|
|
!isMe &&
|
|
chatType == "CHAT" &&
|
|
prevMessage?.senderId != message.senderId;
|
|
|
|
bool get _stretchesTextRow =>
|
|
message.replyInfo != null || _showsSenderName;
|
|
|
|
BubbleShape _computeShape() {
|
|
if (message.isControl) return BubbleShape.singleMiddle;
|
|
|
|
final hasPrevFromMe =
|
|
prevMessage?.senderId == message.senderId && !prevMessage!.isControl;
|
|
final prevTimeDiff = hasPrevFromMe
|
|
? message.time - prevMessage!.time
|
|
: 999999999;
|
|
|
|
final hasNextFromMe =
|
|
nextMessage?.senderId == message.senderId && !nextMessage!.isControl;
|
|
final nextTimeDiff = hasNextFromMe
|
|
? nextMessage!.time - message.time
|
|
: 999999999;
|
|
|
|
final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000;
|
|
final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000;
|
|
|
|
if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle;
|
|
if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop;
|
|
if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom;
|
|
return BubbleShape.groupedMiddle;
|
|
}
|
|
|
|
bool get _hasShareAttachment {
|
|
final a = message.attachments;
|
|
return a != null && a.isNotEmpty && a.first is ShareAttachment;
|
|
}
|
|
|
|
bool get _isVideoNote {
|
|
final a = message.attachments;
|
|
if (a == null || a.isEmpty) return false;
|
|
final first = a.first;
|
|
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;
|
|
}
|
|
|
|
static const int _jumboAnimojiLimit = 4;
|
|
|
|
List<String>? get _jumboAnimojiUrls {
|
|
if (message.attachments?.isNotEmpty ?? false) return null;
|
|
return animojiOnlyLottieUrls(
|
|
message.text,
|
|
message.formatRanges,
|
|
limit: _jumboAnimojiLimit,
|
|
);
|
|
}
|
|
|
|
MessageType get _contentType {
|
|
if (_hasShareAttachment) return _computeContentType();
|
|
return _contentTypeCache[message] ??= _computeContentType();
|
|
}
|
|
|
|
InlineKeyboardAttachment? get _inlineKeyboard {
|
|
final attachments = message.attachments;
|
|
if (attachments == null) return null;
|
|
for (final a in attachments) {
|
|
if (a is InlineKeyboardAttachment && !a.isEmpty) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
MessageType _computeContentType() {
|
|
if (message.isControl) return MessageType.control;
|
|
final attachments = message.attachments
|
|
?.where((a) => a is! InlineKeyboardAttachment)
|
|
.toList();
|
|
if (attachments != null && 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 is ShareAttachment) {
|
|
return AppLinkPreview.current.value
|
|
? MessageType.attachment
|
|
: MessageType.text;
|
|
}
|
|
return MessageType.attachment;
|
|
}
|
|
|
|
final payload = message.payload;
|
|
if (payload == null) return MessageType.text;
|
|
if (payload['voice'] != null) return MessageType.voice;
|
|
return MessageType.text;
|
|
}
|
|
|
|
EdgeInsets _paddingFor(MessageType contentType, BubbleShape shape) {
|
|
switch (contentType) {
|
|
case MessageType.text:
|
|
if (shape == BubbleShape.groupedMiddle) {
|
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
|
}
|
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
|
case MessageType.attachment:
|
|
return EdgeInsets.zero;
|
|
case MessageType.voice:
|
|
if (shape == BubbleShape.singleTop ||
|
|
shape == BubbleShape.singleBottom) {
|
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
|
}
|
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
|
case MessageType.control:
|
|
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
|
}
|
|
}
|
|
|
|
double _topMarginFor(MessageType contentType, BubbleShape shape) {
|
|
switch (contentType) {
|
|
case MessageType.text:
|
|
switch (shape) {
|
|
case BubbleShape.singleTop:
|
|
return 6;
|
|
case BubbleShape.singleBottom:
|
|
case BubbleShape.groupedMiddle:
|
|
return 1;
|
|
case BubbleShape.singleMiddle:
|
|
return 4;
|
|
}
|
|
case MessageType.attachment:
|
|
switch (shape) {
|
|
case BubbleShape.singleBottom:
|
|
return 6;
|
|
case BubbleShape.singleTop:
|
|
case BubbleShape.groupedMiddle:
|
|
return 1;
|
|
case BubbleShape.singleMiddle:
|
|
return 4;
|
|
}
|
|
case MessageType.voice:
|
|
return shape == BubbleShape.singleMiddle ? 4 : 1;
|
|
case MessageType.control:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
double _bottomMarginFor(MessageType contentType, BubbleShape shape) {
|
|
switch (contentType) {
|
|
case MessageType.text:
|
|
case MessageType.attachment:
|
|
case MessageType.voice:
|
|
return shape == BubbleShape.singleMiddle ? 4 : 1;
|
|
case MessageType.control:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
BorderRadius _borderRadiusFor(
|
|
BubbleStyle bubbleStyle,
|
|
BubbleBehavior bubbleBehavior,
|
|
BubbleShape shape,
|
|
bool hasPhotoWithCaption,
|
|
bool hasMultiplePhotosNoCaption,
|
|
) {
|
|
final isTop =
|
|
shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
|
|
final isBottom =
|
|
shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
|
|
return computeBubbleRadius(
|
|
isMe: isMe,
|
|
isTop: isTop,
|
|
isBottom: isBottom,
|
|
style: bubbleStyle,
|
|
behavior: bubbleBehavior,
|
|
hasPhotoWithCaption: hasPhotoWithCaption,
|
|
hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption,
|
|
);
|
|
}
|
|
|
|
static const List<Color> _senderPalette = [
|
|
Color(0xFFE57373),
|
|
Color(0xFF64B5F6),
|
|
Color(0xFF81C784),
|
|
Color(0xFFFFB74D),
|
|
Color(0xFFBA68C8),
|
|
Color(0xFF4DD0E1),
|
|
Color(0xFFF06292),
|
|
Color(0xFFA1887F),
|
|
];
|
|
|
|
Color _senderColor(int id) =>
|
|
_senderPalette[id.abs() % _senderPalette.length];
|
|
|
|
Widget _buildSenderHeader(ColorScheme cs, bool needsInset) {
|
|
final name = senderNameOverride ?? ContactCache.get(message.senderId);
|
|
if (name == null || name.isEmpty) return const SizedBox.shrink();
|
|
final header = Padding(
|
|
padding: needsInset
|
|
? const EdgeInsets.fromLTRB(12, 6, 12, 2)
|
|
: const EdgeInsets.only(bottom: 2),
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: _senderColor(message.senderId),
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
|
|
final cb = onAvatarTap;
|
|
if (cb == null) return header;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => cb(message.senderId),
|
|
child: header,
|
|
);
|
|
}
|
|
|
|
Widget _buildLeadingAvatar(ColorScheme cs) {
|
|
final senderAvatar =
|
|
senderAvatarOverride ?? ContactCache.getAvatar(message.senderId);
|
|
final displaySender = senderNameOverride ?? ContactCache.get(message.senderId);
|
|
final Widget avatar;
|
|
if (senderAvatar != null && senderAvatar.isNotEmpty) {
|
|
avatar = CircleAvatar(
|
|
radius: 15,
|
|
backgroundImage: CachedNetworkImageProvider(
|
|
senderAvatar,
|
|
maxWidth: 96,
|
|
maxHeight: 96,
|
|
),
|
|
backgroundColor: cs.primaryContainer,
|
|
);
|
|
} else {
|
|
avatar = CircleAvatar(
|
|
radius: 15,
|
|
backgroundColor: cs.primaryContainer,
|
|
child: Text(
|
|
displaySender != null && displaySender.isNotEmpty
|
|
? displaySender[0].toUpperCase()
|
|
: '?',
|
|
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
|
|
),
|
|
);
|
|
}
|
|
|
|
final cb = onAvatarTap;
|
|
if (cb == null) return avatar;
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => cb(message.senderId),
|
|
child: avatar,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_hasShareAttachment) {
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: AppLinkPreview.current,
|
|
builder: (context, _, _) => _buildBubble(context),
|
|
);
|
|
}
|
|
return _buildBubble(context);
|
|
}
|
|
|
|
Widget _buildBubble(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final contentType = _contentType;
|
|
|
|
if (message.isControl) {
|
|
const controlShape = BubbleShape.singleMiddle;
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: _topMarginFor(contentType, controlShape),
|
|
bottom: _bottomMarginFor(contentType, controlShape),
|
|
),
|
|
child: Center(child: _buildControlContent(cs)),
|
|
);
|
|
}
|
|
|
|
final shape = _computeShape();
|
|
final hasPhotoCap = _computeHasPhotoWithCaption();
|
|
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
|
final textColor = bubbleTextColor(context);
|
|
|
|
final topMargin = _topMarginFor(contentType, shape);
|
|
final bottomMargin = _bottomMarginFor(contentType, shape);
|
|
final jumboAnimoji = _jumboAnimojiUrls;
|
|
final padding = jumboAnimoji != null
|
|
? EdgeInsets.zero
|
|
: _paddingFor(contentType, shape);
|
|
|
|
final showAvatarSlot = !isMe;
|
|
final showAvatar =
|
|
showAvatarSlot &&
|
|
chatType == "CHAT" &&
|
|
nextMessage?.senderId != message.senderId;
|
|
final showSenderName = _showsSenderName;
|
|
|
|
final maxBubbleWidth = math.min(MediaQuery.sizeOf(context).width * 0.75, 560.0);
|
|
final keyboard = _inlineKeyboard;
|
|
final isVideoNote = _isVideoNote;
|
|
final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null;
|
|
final bubbleColor = noBubbleBackground
|
|
? Colors.transparent
|
|
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
|
|
|
|
BubbleContext makeCtx() => BubbleContext(
|
|
context: context,
|
|
cs: cs,
|
|
text: textColor,
|
|
shape: shape,
|
|
contentType: contentType,
|
|
hasPhotoWithCaption: hasPhotoCap,
|
|
hasMultiplePhotosNoCaption: hasMultiPhotos,
|
|
message: message,
|
|
isMe: isMe,
|
|
myId: myId,
|
|
chatType: chatType,
|
|
chatId: chatId,
|
|
photoActions: photoActions,
|
|
overrideStatus: overrideStatus,
|
|
otherReadTime: otherReadTime,
|
|
uploadProgress: uploadProgress,
|
|
onStickerTap: onStickerTap,
|
|
onForwardedSourceTap: onForwardedSourceTap,
|
|
reactionInfo: _resolveReactionInfo(),
|
|
);
|
|
|
|
final Widget bubbleContent =
|
|
reactionsListenable != null && contentType == MessageType.text
|
|
? ValueListenableBuilder<Map<String, dynamic>?>(
|
|
valueListenable: reactionsListenable!,
|
|
builder: (context, _, _) => _buildContent(makeCtx()),
|
|
)
|
|
: _buildContent(makeCtx());
|
|
|
|
final reactionsUnder = _reactionsUnderBubble(contentType);
|
|
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
|
|
|
final reply = message.replyInfo;
|
|
|
|
final bool hasCommentsFooter = onCommentsTap != null;
|
|
final EdgeInsets containerPadding = hasCommentsFooter
|
|
? EdgeInsets.zero
|
|
: padding;
|
|
|
|
final Widget contentWithReactions = reactionsInside
|
|
? Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [bubbleContent, _reactionsBar(cs)],
|
|
)
|
|
: bubbleContent;
|
|
|
|
final Widget? senderHeader = showSenderName
|
|
? _buildSenderHeader(cs, padding == EdgeInsets.zero)
|
|
: null;
|
|
|
|
final Widget innerContent =
|
|
contentType == MessageType.text &&
|
|
jumboAnimoji == null &&
|
|
_stretchesTextRow
|
|
? IntrinsicWidth(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (senderHeader != null)
|
|
Align(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: senderHeader,
|
|
),
|
|
if (reply != null) ...[
|
|
_ZeroIntrinsicWidth(
|
|
child: _buildReplyQuote(context, cs, textColor, reply),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
contentWithReactions,
|
|
],
|
|
),
|
|
)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
?senderHeader,
|
|
if (reply != null) ...[
|
|
_buildReplyQuote(context, cs, textColor, reply),
|
|
const SizedBox(height: 4),
|
|
],
|
|
contentWithReactions,
|
|
],
|
|
);
|
|
|
|
final Widget bubbleBox = ListenableBuilder(
|
|
listenable: Listenable.merge([
|
|
AppBubbleShape.current,
|
|
AppBubbleBehavior.current,
|
|
]),
|
|
builder: (context, child) => Container(
|
|
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
|
decoration: BoxDecoration(
|
|
color: bubbleColor,
|
|
borderRadius: noBubbleBackground
|
|
? null
|
|
: _borderRadiusFor(
|
|
AppBubbleShape.current.value,
|
|
AppBubbleBehavior.current.value,
|
|
shape,
|
|
hasPhotoCap,
|
|
hasMultiPhotos,
|
|
),
|
|
),
|
|
padding: containerPadding,
|
|
child: child,
|
|
),
|
|
child: hasCommentsFooter
|
|
? _StackMatchTopWidth(
|
|
growForBottom: true,
|
|
top: Padding(padding: padding, child: innerContent),
|
|
bottom: _buildCommentsFooter(cs),
|
|
)
|
|
: innerContent,
|
|
);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
top: topMargin,
|
|
bottom: bottomMargin,
|
|
),
|
|
child: Align(
|
|
child: Row(
|
|
mainAxisAlignment: isMe
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.start,
|
|
spacing: 8,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
if (showAvatar)
|
|
_buildLeadingAvatar(cs)
|
|
else if (showAvatarSlot && chatType == "CHAT")
|
|
const SizedBox(width: 30),
|
|
Column(
|
|
crossAxisAlignment: isMe
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
if (keyboard != null)
|
|
_StackMatchTopWidth(
|
|
top: bubbleBox,
|
|
bottom: _buildInlineKeyboard(context, cs, keyboard),
|
|
)
|
|
else
|
|
bubbleBox,
|
|
if (reactionsUnder) _reactionsBar(cs),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCommentsFooter(ColorScheme cs) {
|
|
final label = commentsLabel ?? 'Комментарии';
|
|
final accent = isMe ? cs.onPrimaryContainer : cs.primary;
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onCommentsTap,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Divider(
|
|
height: 0.5,
|
|
thickness: 0.5,
|
|
color: cs.onSurfaceVariant.withValues(alpha: 0.18),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 11,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Symbols.mode_comment, size: 19, color: accent),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: accent,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
Symbols.chevron_right,
|
|
size: 20,
|
|
color: cs.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInlineKeyboard(
|
|
BuildContext context,
|
|
ColorScheme cs,
|
|
InlineKeyboardAttachment keyboard,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (final row in keyboard.rows)
|
|
if (row.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
children: [
|
|
for (var i = 0; i < row.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: 4),
|
|
Expanded(
|
|
child: _buildInlineKeyboardButton(
|
|
context,
|
|
cs,
|
|
keyboard,
|
|
row[i],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInlineKeyboardButton(
|
|
BuildContext context,
|
|
ColorScheme cs,
|
|
InlineKeyboardAttachment keyboard,
|
|
InlineKeyboardButton button,
|
|
) {
|
|
final trailingIcon = switch (button.type) {
|
|
'LINK' => Symbols.open_in_new,
|
|
'OPEN_APP' => Symbols.chevron_right,
|
|
_ => null,
|
|
};
|
|
return Material(
|
|
color: cs.primary.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(12),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () => _onInlineButtonTap(context, keyboard, button),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
button.text,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: cs.primary,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (trailingIcon != null) ...[
|
|
const SizedBox(width: 4),
|
|
Icon(trailingIcon, size: 16, color: cs.primary),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onInlineButtonTap(
|
|
BuildContext context,
|
|
InlineKeyboardAttachment keyboard,
|
|
InlineKeyboardButton button,
|
|
) async {
|
|
switch (button.type) {
|
|
case 'LINK':
|
|
final url = button.url;
|
|
if (url != null && url.isNotEmpty) {
|
|
await openExternalUrl(context, url);
|
|
}
|
|
return;
|
|
case 'OPEN_APP':
|
|
await _openMiniApp(context, button);
|
|
return;
|
|
default:
|
|
final callbackId = keyboard.callbackId;
|
|
if (callbackId == null || callbackId.isEmpty) {
|
|
showCustomNotification(context, 'Кнопка не поддерживается');
|
|
return;
|
|
}
|
|
final answer = await messagesModule.sendButtonCallback(
|
|
chatId: message.chatId,
|
|
messageId: message.id,
|
|
callbackId: callbackId,
|
|
payload: button.payload,
|
|
);
|
|
if (!context.mounted) return;
|
|
final url = answer?['url']?.toString();
|
|
if (url != null && url.isNotEmpty) {
|
|
await openExternalUrl(context, url);
|
|
return;
|
|
}
|
|
final text = answer?['text']?.toString();
|
|
if (text != null && text.isNotEmpty) {
|
|
showCustomNotification(context, text);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _openMiniApp(
|
|
BuildContext context,
|
|
InlineKeyboardButton button,
|
|
) async {
|
|
if (!webViewSupported) {
|
|
showCustomNotification(context, 'На вашей платформе это недоступно');
|
|
return;
|
|
}
|
|
|
|
final deeplink = button.webApp != null
|
|
? Uri.tryParse(button.webApp!)
|
|
: null;
|
|
final startParam =
|
|
button.payload ??
|
|
deeplink?.queryParameters['startapp'] ??
|
|
deeplink?.queryParameters['startApp'];
|
|
final chatId =
|
|
int.tryParse(deeplink?.queryParameters['chat_id'] ?? '') ??
|
|
message.chatId;
|
|
final botId = button.contactId;
|
|
|
|
if (botId == null) {
|
|
showCustomNotification(context, 'Не удалось открыть приложение');
|
|
return;
|
|
}
|
|
|
|
if (kDebugMode) {
|
|
debugPrint(
|
|
'[inline-kb] OPEN_APP botId=$botId chatId=$chatId startParam=$startParam',
|
|
);
|
|
}
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => WebAppScreen(
|
|
title: button.text,
|
|
loader: () => webAppModule.fetchLaunch(
|
|
botId,
|
|
startParam: startParam,
|
|
chatId: chatId,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Map? _resolveReactionInfo() {
|
|
final listenable = reactionsListenable;
|
|
if (listenable != null) {
|
|
final v = listenable.value;
|
|
return v is Map ? v : null;
|
|
}
|
|
final info = message.payload?['reactionInfo'];
|
|
if (info is Map) return info;
|
|
return null;
|
|
}
|
|
|
|
bool _reactionsUnderBubble(MessageType contentType) {
|
|
if (contentType != MessageType.attachment) return false;
|
|
final attachments = message.attachments;
|
|
if (attachments == null || attachments.isEmpty) return false;
|
|
if (attachments.first is ForwardedMessageAttachment) return false;
|
|
if (attachments.any((a) => a is ContactAttachment)) return false;
|
|
if (attachments.whereType<PhotoAttachment>().length >= 2) return false;
|
|
return true;
|
|
}
|
|
|
|
Widget _reactionsBar(ColorScheme cs) {
|
|
final listenable = reactionsListenable;
|
|
if (listenable != null) {
|
|
return ValueListenableBuilder<Map<String, dynamic>?>(
|
|
valueListenable: listenable,
|
|
builder: (context, info, _) => _buildReactionsBarFor(cs, info),
|
|
);
|
|
}
|
|
return _buildReactionsBar(cs);
|
|
}
|
|
|
|
Widget _buildContent(BubbleContext ctx) {
|
|
final jumbo = _jumboAnimojiUrls;
|
|
if (jumbo != null) return _buildJumboAnimojiContent(ctx, jumbo);
|
|
switch (ctx.contentType) {
|
|
case MessageType.control:
|
|
return _buildControlContent(ctx.cs);
|
|
case MessageType.attachment:
|
|
return _buildAttachmentContent(ctx);
|
|
case MessageType.voice:
|
|
return _buildVoiceContent(ctx);
|
|
case MessageType.text:
|
|
return _buildTextContent(ctx);
|
|
}
|
|
}
|
|
|
|
Widget _buildJumboAnimojiContent(BubbleContext ctx, List<String> urls) {
|
|
final n = urls.length;
|
|
final size = switch (n) {
|
|
1 => 96.0,
|
|
2 => 76.0,
|
|
3 => 64.0,
|
|
_ => 56.0,
|
|
};
|
|
final cache = (size * 2).round();
|
|
|
|
final animations = Stack(
|
|
children: [
|
|
Wrap(
|
|
spacing: 2,
|
|
runSpacing: 2,
|
|
alignment: ctx.isMe ? WrapAlignment.end : WrapAlignment.start,
|
|
children: [
|
|
for (final url in urls)
|
|
SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: LottieImage(
|
|
lottieUrl: url,
|
|
size: size,
|
|
memCacheWidth: cache,
|
|
eager: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Positioned(
|
|
bottom: BubbleContext.compactTimePadding,
|
|
right: BubbleContext.compactTimePadding,
|
|
child: _buildJumboAnimojiMeta(ctx),
|
|
),
|
|
],
|
|
);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: ctx.isMe
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [animations, _buildReactionsBarFor(ctx.cs, ctx.reactionInfo)],
|
|
);
|
|
}
|
|
|
|
Widget _buildJumboAnimojiMeta(BubbleContext ctx) {
|
|
final status = ctx.overrideStatus ?? ctx.message.status;
|
|
final statusVisual = messageStatusVisual(status, dimColor: Colors.white);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.35),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
ctx.clockText,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
if (ctx.isMe) ...[
|
|
const SizedBox(width: 3),
|
|
Icon(statusVisual.icon, size: 13, color: statusVisual.color),
|
|
],
|
|
if (ctx.message.deleted) ...[
|
|
const SizedBox(width: 3),
|
|
const Icon(Symbols.delete, size: 12, color: Colors.white),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildReactionsBar(ColorScheme cs) {
|
|
final info = message.payload?['reactionInfo'];
|
|
return _buildReactionsBarFor(cs, info is Map ? info : null);
|
|
}
|
|
|
|
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
|
|
final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info));
|
|
if (chips.isEmpty) return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildReactionChipsFor(ColorScheme cs, ReactionInfo? info) {
|
|
if (info == null) return const [];
|
|
final yourReaction = info.yourReaction;
|
|
final isDialog = chatType == 'DIALOG';
|
|
|
|
final chips = <Widget>[];
|
|
for (final c in info.counters) {
|
|
final isYours = yourReaction == c.reaction;
|
|
|
|
Widget? avatar;
|
|
if (isDialog) {
|
|
final peerReacted = (c.count - (isYours ? 1 : 0)) >= 1;
|
|
avatar = peerReacted
|
|
? _reactionAvatar(cs, peerAvatarUrl, peerName)
|
|
: _reactionAvatar(
|
|
cs,
|
|
ContactCache.getAvatar(myId),
|
|
ContactCache.get(myId),
|
|
);
|
|
}
|
|
|
|
Widget chip = Container(
|
|
padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2),
|
|
decoration: BoxDecoration(
|
|
color: isYours ? cs.primary.withValues(alpha: 0.22) : _reactionChipBg,
|
|
borderRadius: _reactionChipRadius,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_resolveReactionAnimoji(c.reaction) case final animoji?)
|
|
_ReactionAnimojiGlyph(
|
|
key: ValueKey((message.id, c.reaction)),
|
|
messageId: message.id,
|
|
emoji: c.reaction,
|
|
animoji: animoji,
|
|
animation: reactionAnimation,
|
|
)
|
|
else
|
|
Text(c.reaction, style: const TextStyle(fontSize: 13)),
|
|
if (c.count > 1) ...[
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
c.count.toString(),
|
|
style: TextStyle(
|
|
color: isYours ? cs.primary : cs.onSurfaceVariant,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
if (avatar != null) ...[const SizedBox(width: 5), avatar],
|
|
],
|
|
),
|
|
);
|
|
|
|
final onTap = onReactionTap;
|
|
if (onTap != null) {
|
|
chip = GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => onTap(c.reaction),
|
|
child: chip,
|
|
);
|
|
}
|
|
|
|
chips.add(chip);
|
|
}
|
|
return chips;
|
|
}
|
|
|
|
Animoji? _resolveReactionAnimoji(String emoji) =>
|
|
reactionAnimojiResolver?.call(emoji) ?? animojiModule.findByEmoji(emoji);
|
|
|
|
Widget _reactionAvatar(ColorScheme cs, String? url, String? name) {
|
|
const double diameter = 17;
|
|
if (url != null && url.isNotEmpty) {
|
|
return CircleAvatar(
|
|
radius: diameter / 2,
|
|
backgroundColor: cs.primaryContainer,
|
|
backgroundImage: CachedNetworkImageProvider(
|
|
url,
|
|
maxWidth: 64,
|
|
maxHeight: 64,
|
|
),
|
|
);
|
|
}
|
|
final letter = (name != null && name.isNotEmpty)
|
|
? name[0].toUpperCase()
|
|
: '?';
|
|
return CircleAvatar(
|
|
radius: diameter / 2,
|
|
backgroundColor: cs.primaryContainer,
|
|
child: Text(
|
|
letter,
|
|
style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildControlContent(ColorScheme cs) => ControlBubble(
|
|
key: ValueKey('control_${message.id}'),
|
|
message: message,
|
|
cs: cs,
|
|
onUserTap: onAvatarTap,
|
|
);
|
|
|
|
Widget _wrapSelectable(Widget textWidget) {
|
|
final listenable = textSelection;
|
|
if (listenable == null || (message.text?.isEmpty ?? true)) {
|
|
return textWidget;
|
|
}
|
|
return ValueListenableBuilder<({String id, Offset pos})?>(
|
|
valueListenable: listenable,
|
|
builder: (context, req, child) {
|
|
if (req == null || req.id != message.id) return child!;
|
|
return SelectableMessageText(
|
|
initialGlobalPosition: req.pos,
|
|
onExit: onExitTextSelection ?? () {},
|
|
child: child!,
|
|
);
|
|
},
|
|
child: textWidget,
|
|
);
|
|
}
|
|
|
|
Widget _buildTextContent(BubbleContext ctx) => DecryptedContent(
|
|
accountId: message.accountId,
|
|
chatId: message.chatId,
|
|
messageId: message.id,
|
|
cipherText: message.text ?? '',
|
|
builder: (decryption) => _buildTextContentBody(ctx, decryption),
|
|
);
|
|
|
|
Widget _buildTextContentBody(
|
|
BubbleContext ctx,
|
|
MessageDecryption? decryption,
|
|
) {
|
|
final attachments = message.attachments;
|
|
final isForwardedContact =
|
|
attachments != null &&
|
|
attachments.isNotEmpty &&
|
|
attachments.first is ForwardedMessageAttachment &&
|
|
(attachments.first as ForwardedMessageAttachment).originalContact !=
|
|
null;
|
|
|
|
final forwarded = _getForwardedAttachment();
|
|
final isForwarded = forwarded != null && !isForwardedContact;
|
|
|
|
final reactionChips = _buildReactionChipsFor(
|
|
ctx.cs,
|
|
ReactionInfo.fromMap(ctx.reactionInfo),
|
|
);
|
|
final hasReactions = reactionChips.isNotEmpty;
|
|
|
|
final activeFontFamily =
|
|
Theme.of(ctx.context).textTheme.bodyLarge?.fontFamily;
|
|
final textStyle = TextStyle(
|
|
color: ctx.text,
|
|
fontSize: 16,
|
|
height: 1.3,
|
|
fontFamily: activeFontFamily,
|
|
fontVariations: activeFontFamily == 'Inter'
|
|
? const [FontVariation('wght', 300)]
|
|
: null,
|
|
);
|
|
final ranges = message.formatRanges;
|
|
final decryptedText = decryption?.plaintext;
|
|
final Widget baseTextWidget;
|
|
if (decryption?.state == MessageDecryptionState.wrongKey) {
|
|
baseTextWidget = Text(
|
|
'неверный ключ',
|
|
style: textStyle.copyWith(
|
|
color: ctx.cs.error,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
);
|
|
} else if (decryptedText != null) {
|
|
baseTextWidget = Text(decryptedText, style: textStyle);
|
|
} else if (isForwarded) {
|
|
baseTextWidget = _buildForwardedInlineText(ctx, forwarded);
|
|
} else if (FormattedMessageText.isFormatted(message.text, ranges)) {
|
|
baseTextWidget = FormattedMessageText(
|
|
text: message.text!,
|
|
ranges: ranges,
|
|
style: textStyle,
|
|
);
|
|
} else {
|
|
baseTextWidget = Text(message.text ?? '', style: textStyle);
|
|
}
|
|
final textWidget = _wrapSelectable(baseTextWidget);
|
|
|
|
final metaWidget = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (decryption?.isDecrypted ?? false) ...[
|
|
Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim),
|
|
const SizedBox(width: 3),
|
|
],
|
|
Text(
|
|
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
|
|
style: TextStyle(color: ctx.dim, fontSize: 10),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (hasReactions) {
|
|
return IntrinsicWidth(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
textWidget,
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: _ReactionsWrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children: reactionChips,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 2),
|
|
child: metaWidget,
|
|
),
|
|
if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()],
|
|
if (message.deleted) ...[
|
|
const SizedBox(width: 4),
|
|
ctx.deletedIcon(),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
_stretchesTextRow
|
|
? Expanded(child: textWidget)
|
|
: Flexible(child: textWidget),
|
|
const SizedBox(width: 8),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 2),
|
|
child: metaWidget,
|
|
),
|
|
if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()],
|
|
if (message.deleted) ...[
|
|
const SizedBox(width: 4),
|
|
ctx.deletedIcon(),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildReplyQuote(
|
|
BuildContext context,
|
|
ColorScheme cs,
|
|
Color textColor,
|
|
ReplyInfo reply,
|
|
) {
|
|
final accent = isMe ? cs.onPrimaryContainer : cs.primary;
|
|
final name = reply.senderId == myId
|
|
? 'Вы'
|
|
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
|
|
final rawPreview = reply.previewText();
|
|
final quotedId = reply.messageId;
|
|
|
|
final quote = Container(
|
|
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(6),
|
|
color: accent.withValues(alpha: 0.10),
|
|
border: Border(left: BorderSide(color: accent, width: 3)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: accent,
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (rawPreview.isNotEmpty)
|
|
DecryptedContent(
|
|
accountId: message.accountId,
|
|
chatId: message.chatId,
|
|
messageId: quotedId ?? '',
|
|
cipherText: quotedId == null ? '' : rawPreview,
|
|
builder: (decryption) => Text(
|
|
decryption?.state == MessageDecryptionState.wrongKey
|
|
? 'неверный ключ'
|
|
: (decryption?.plaintext ?? rawPreview),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: decryption?.state == MessageDecryptionState.wrongKey
|
|
? cs.error
|
|
: textColor.withValues(alpha: 0.85),
|
|
fontSize: 13,
|
|
fontStyle:
|
|
decryption?.state == MessageDecryptionState.wrongKey
|
|
? FontStyle.italic
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final mid = reply.messageId;
|
|
final cb = onReplyTap;
|
|
if (mid != null && mid != '0' && cb != null) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => cb(mid),
|
|
child: quote,
|
|
);
|
|
}
|
|
return quote;
|
|
}
|
|
|
|
Widget _buildForwardedInlineText(
|
|
BubbleContext ctx,
|
|
ForwardedMessageAttachment forwarded,
|
|
) {
|
|
final origText = forwarded.originalText;
|
|
final hasOrigText = origText != null && origText.isNotEmpty;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ForwardedHeader(
|
|
ctx: ctx,
|
|
forwarded: forwarded,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
if (hasOrigText) ...[
|
|
const SizedBox(height: 2),
|
|
buildForwardedMessageText(ctx, forwarded),
|
|
] else ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
message.text ?? '',
|
|
style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
ForwardedMessageAttachment? _getForwardedAttachment() {
|
|
final attachments = message.attachments;
|
|
if (attachments == null || attachments.isEmpty) return null;
|
|
for (final a in attachments) {
|
|
if (a is ForwardedMessageAttachment) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Widget _buildAttachmentContent(BubbleContext ctx) {
|
|
final attachments = message.attachments;
|
|
if (attachments == null || attachments.isEmpty) {
|
|
return _buildTextContent(ctx);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
final contacts = attachments.whereType<ContactAttachment>().toList();
|
|
if (contacts.isNotEmpty) {
|
|
return ContactBubble(ctx: ctx, contact: contacts.first);
|
|
}
|
|
|
|
final polls = attachments.whereType<PollAttachment>().toList();
|
|
if (polls.isNotEmpty) {
|
|
return PollBubble(ctx: ctx, poll: polls.first);
|
|
}
|
|
|
|
final shares = attachments.whereType<ShareAttachment>().toList();
|
|
if (shares.isNotEmpty) {
|
|
return ShareBubble(ctx: ctx, share: shares.first);
|
|
}
|
|
|
|
final photos = attachments.whereType<PhotoAttachment>().toList();
|
|
if (photos.isEmpty) {
|
|
return _buildGenericAttachment(ctx, attachments.first);
|
|
}
|
|
|
|
return PhotoBubble(ctx: ctx, photos: photos);
|
|
}
|
|
|
|
Widget _buildGenericAttachment(
|
|
BubbleContext ctx,
|
|
MessageAttachment attachment,
|
|
) {
|
|
switch (attachment.type) {
|
|
case AttachmentType.video:
|
|
return VideoBubble(ctx: ctx, video: attachment as VideoAttachment);
|
|
case AttachmentType.file:
|
|
return FileBubble(ctx: ctx, file: attachment as FileAttachment);
|
|
case AttachmentType.sticker:
|
|
return StickerBubble(ctx: ctx, sticker: attachment);
|
|
case AttachmentType.location:
|
|
return LocationBubble(
|
|
ctx: ctx,
|
|
location: attachment as LocationAttachment,
|
|
);
|
|
case AttachmentType.call:
|
|
return CallBubble(ctx: ctx, call: attachment as CallAttachment);
|
|
default:
|
|
return _buildTextContent(ctx);
|
|
}
|
|
}
|
|
|
|
Widget _buildVoiceContent(BubbleContext ctx) {
|
|
int duration = 0;
|
|
String url = '';
|
|
String? waveData;
|
|
int? audioId;
|
|
|
|
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;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (duration == 0 && url.isEmpty) {
|
|
final payload = message.payload;
|
|
final voice = payload?['voice'] as Map<String, dynamic>?;
|
|
duration = ((voice?['duration'] as int? ?? 0) / 1000).round();
|
|
url = voice?['url']?.toString() ?? '';
|
|
}
|
|
|
|
final cachedTranscription = TranscriptionCache.get(message.id);
|
|
|
|
return VoiceMessageBubble(
|
|
duration: duration,
|
|
url: url,
|
|
textColor: ctx.text,
|
|
isMe: isMe,
|
|
deleted: message.deleted,
|
|
status: overrideStatus ?? message.status,
|
|
otherReadTime: otherReadTime,
|
|
time: message.time,
|
|
cs: ctx.cs,
|
|
waveData: waveData,
|
|
chatId: message.chatId,
|
|
messageId: message.id,
|
|
audioId: audioId,
|
|
preloadedText: cachedTranscription?.text,
|
|
);
|
|
}
|
|
}
|