From 53bf892f048d9451fb46138e1311312c1174cae4 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Thu, 9 Jul 2026 22:18:29 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=8F=20=D0=B7=D0=B0=D0=B1=D1=8B=D0=BB?= =?UTF-8?q?=20=D0=B0=20=D1=85=D1=83=D0=BB=D0=B8=20=D1=8F=20=D1=81=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 28 +++++- lib/frontend/widgets/message_bubble.dart | 104 +++++++++++++++++++- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 79ae7e5..79b65e7 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -529,6 +529,14 @@ class _ChatScreenState extends State final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; _myId = p?.id ?? 0; + if (p != null && p.id != 0) { + final myName = [ + p.firstName, + p.lastName, + ].whereType().where((s) => s.isNotEmpty).join(' '); + if (myName.isNotEmpty) ContactCache.put(p.id, myName); + ContactCache.putAvatar(p.id, p.baseUrl); + } _restoreDraft(); unawaited(_loadPeerKind()); unawaited(_loadWallpaper()); @@ -4208,6 +4216,8 @@ class _ChatScreenState extends State onReplyTap: _jumpToMessage, onAvatarTap: _openSenderProfile, onStickerTap: _openStickerPack, + peerName: widget.name, + peerAvatarUrl: widget.imageUrl, ); final canReport = !isMe && !message.isControl; @@ -5669,12 +5679,20 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { void _handleTap() { if (widget.isSelectionActive()) { widget.onToggleSelection(); - } else { - _openTimer?.cancel(); - _openTimer = Timer(const Duration(milliseconds: 200), () { - if (mounted && !widget.isSelectionActive()) _openMenu(); - }); + return; } + final react = widget.onReact; + if (react != null && (_openTimer?.isActive ?? false)) { + _openTimer?.cancel(); + _openTimer = null; + Haptics.tap(); + react('❤️'); + return; + } + _openTimer?.cancel(); + _openTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted && !widget.isSelectionActive()) _openMenu(); + }); } void _handleLongPress() { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 16e0e91..d2811e6 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -49,6 +49,61 @@ class _RenderZeroIntrinsicWidth extends RenderProxyBox { double computeMaxIntrinsicWidth(double height) => 0; } +/// 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 MessageBubble extends StatelessWidget { static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); static const BorderRadius _reactionChipRadius = BorderRadius.all( @@ -73,6 +128,8 @@ class MessageBubble extends StatelessWidget { final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; final void Function(StickerAttachment sticker)? onStickerTap; + final String? peerName; + final String? peerAvatarUrl; const MessageBubble({ super.key, @@ -89,6 +146,8 @@ class MessageBubble extends StatelessWidget { this.onReplyTap, this.onAvatarTap, this.onStickerTap, + this.peerName, + this.peerAvatarUrl, }); bool _computeHasPhotoWithCaption() { @@ -778,13 +837,27 @@ class MessageBubble extends StatelessWidget { List _buildReactionChipsFor(ColorScheme cs, ReactionInfo? info) { if (info == null) return const []; final yourReaction = info.yourReaction; + final isDialog = chatType == 'DIALOG'; final chips = []; 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), + ); + } + chips.add( Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2), decoration: BoxDecoration( color: isYours ? cs.primary.withValues(alpha: 0.22) @@ -806,6 +879,7 @@ class MessageBubble extends StatelessWidget { ), ), ], + if (avatar != null) ...[const SizedBox(width: 5), avatar], ], ), ), @@ -814,6 +888,32 @@ class MessageBubble extends StatelessWidget { return chips; } + 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) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { @@ -921,7 +1021,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Wrap( + child: _ReactionsWrap( spacing: 4, runSpacing: 4, children: reactionChips,