diff --git a/lib/core/utils/text_format.dart b/lib/core/utils/text_format.dart index e32cd33..90ae1d4 100644 --- a/lib/core/utils/text_format.dart +++ b/lib/core/utils/text_format.dart @@ -235,6 +235,7 @@ TextStyle applyTextFormats( Set formats, { Color? quoteColor, Color? mentionColor, + Paint? quoteBackground, }) { if (formats.isEmpty) return base; @@ -247,9 +248,9 @@ TextStyle applyTextFormats( decorations.add(TextDecoration.lineThrough); } - final isItalic = formats.contains(TextFormat.emphasized) || - formats.contains(TextFormat.quote); + final isItalic = formats.contains(TextFormat.emphasized); + final isQuote = formats.contains(TextFormat.quote); final isMention = formats.contains(TextFormat.userMention); final isHeading = formats.contains(TextFormat.heading); @@ -260,9 +261,8 @@ TextStyle applyTextFormats( : null, fontStyle: isItalic ? FontStyle.italic : null, fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null, - color: isMention - ? mentionColor - : (formats.contains(TextFormat.quote) ? quoteColor : null), + background: isQuote ? quoteBackground : null, + color: isMention ? mentionColor : (isQuote ? quoteColor : null), decoration: decorations.isEmpty ? null : TextDecoration.combine(decorations), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 45d80bc..b2ad926 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -635,6 +635,7 @@ class _ChatScreenState extends State final ValueNotifier _animojiHold = ValueNotifier(true); final ValueNotifier> _selectedIds = ValueNotifier(const {}); + final ValueNotifier _textSelectionDrag = ValueNotifier(null); final ValueNotifier<({String id, Offset pos})?> _textSelection = ValueNotifier(null); late final AnimationController _selectionAnim; @@ -683,7 +684,6 @@ class _ChatScreenState extends State _scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_recordScrollPixels); _scrollController.addListener(_scheduleReadMarker); - _scrollController.addListener(_exitTextSelectionOnScroll); _scrollController.addListener(_updateScrollDownVisible); MediaPlayback.instance.enterChat(widget.chatId); AppVisualStyle.current.addListener(_onVisualStyleChanged); @@ -2107,7 +2107,6 @@ class _ChatScreenState extends State _scrollController.removeListener(_maybeLoadMoreHistory); _scrollController.removeListener(_recordScrollPixels); _scrollController.removeListener(_scheduleReadMarker); - _scrollController.removeListener(_exitTextSelectionOnScroll); _scrollController.removeListener(_updateScrollDownVisible); _readMarkTimer?.cancel(); AppVisualStyle.current.removeListener(_onVisualStyleChanged); @@ -2172,6 +2171,7 @@ class _ChatScreenState extends State _search.dispose(); _selectedIds.dispose(); _textSelection.dispose(); + _textSelectionDrag.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _stickers.dispose(); @@ -2352,6 +2352,7 @@ class _ChatScreenState extends State void _startTextSelection(CachedMessage message, Offset globalPosition) { if (message.isControl || message.selectableText == null) return; + _textSelectionDrag.value = null; _textSelection.value = (id: message.id, pos: globalPosition); } @@ -2362,15 +2363,6 @@ class _ChatScreenState extends State _textSelection.value = null; } - void _exitTextSelectionOnScroll() { - if (_textSelection.value == null) return; - if (!_scrollController.hasClients) return; - if (_scrollController.position.userScrollDirection != - ScrollDirection.idle) { - _exitTextSelection(); - } - } - void _syncSelectionAnim() { if (_selectedIds.value.isEmpty) { _selectionAnim.reverse(); @@ -5754,6 +5746,7 @@ class _ChatScreenState extends State ? widget.imageUrl : null, textSelection: _textSelection, + textSelectionDrag: _textSelectionDrag, onExitTextSelection: _exitTextSelection, commentsLabel: isChannelPost ? _commentsLabelFor(message.id) @@ -5780,6 +5773,8 @@ class _ChatScreenState extends State _enterSelection(message), onStartTextSelection: (pos) => _startTextSelection(message, pos), + onDragTextSelection: (pos) => + _textSelectionDrag.value = pos, onDelete: () => _confirmDeleteMessage(message.id, isMe), onEdit: _canEditMessage(message) @@ -7103,6 +7098,7 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback onToggleSelection; final VoidCallback onEnterSelection; final void Function(Offset globalPosition) onStartTextSelection; + final void Function(Offset? globalPosition) onDragTextSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; @@ -7128,6 +7124,7 @@ class _SelectableMessageRow extends StatefulWidget { required this.onToggleSelection, required this.onEnterSelection, required this.onStartTextSelection, + required this.onDragTextSelection, required this.onDelete, this.onEdit, this.onReply, @@ -7299,7 +7296,21 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { }); } + bool _textSelectionPress = false; + + void _handleLongPressMove(Offset globalPosition) { + if (!_textSelectionPress) return; + widget.onDragTextSelection(globalPosition); + } + + void _handleLongPressEnd() { + if (!_textSelectionPress) return; + _textSelectionPress = false; + widget.onDragTextSelection(null); + } + void _handleLongPressStart(Offset globalPosition) { + _textSelectionPress = false; if (!widget.isSelectionActive()) { widget.onEnterSelection(); return; @@ -7307,6 +7318,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { final selected = widget.selectedIds.value.contains(widget.message.id); final hasText = widget.message.selectableText != null; if (selected && hasText && !widget.message.isControl) { + _textSelectionPress = true; widget.onStartTextSelection(globalPosition); } else { widget.onToggleSelection(); @@ -7355,6 +7367,10 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onTapDown: (d) => _lastTapDown = d.globalPosition, onTap: _handleTap, onLongPressStart: (d) => _handleLongPressStart(d.globalPosition), + onLongPressMoveUpdate: (d) => + _handleLongPressMove(d.globalPosition), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressEnd, onSecondaryTapDown: active ? null : _onSecondaryTapDown, child: ColoredBox( color: isSelected diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index fe52aea..8ca1cc7 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -92,6 +92,8 @@ class BubbleContext { final void Function(StickerAttachment sticker)? onStickerTap; final ForwardedSourceTap? onForwardedSourceTap; final BubblePresentation? presentation; + final bool metaInFooter; + final Widget Function(Widget)? selectable; BubbleContext({ required this.context, @@ -115,6 +117,8 @@ class BubbleContext { this.onForwardedSourceTap, this.reactionInfo, this.presentation, + this.metaInFooter = false, + this.selectable, }) : dim = text.withValues(alpha: 0.7); String? get contentText => @@ -149,6 +153,8 @@ class BubbleContext { onForwardedSourceTap: onForwardedSourceTap, reactionInfo: reactionInfo, presentation: value, + metaInFooter: metaInFooter, + selectable: selectable, ); String get clockText { @@ -169,17 +175,25 @@ class BubbleContext { final style = TextStyle(color: text, fontSize: 16, height: 1.3); final captionText = contentText; final ranges = contentFormatRanges; + final Widget body; if (FormattedMessageText.isFormatted(captionText, ranges)) { - return FormattedMessageText( + body = FormattedMessageText( text: captionText!, ranges: ranges, style: style, ); + } else { + body = Text(captionText ?? '', style: style); } - return Text(captionText ?? '', style: style); + final wrap = selectable; + return wrap == null ? body : wrap(body); } - Widget meta() { + Widget meta() => metaInFooter ? const SizedBox.shrink() : _metaRow(); + + Widget footerMeta() => _metaRow(); + + Widget _metaRow() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( @@ -195,6 +209,8 @@ class BubbleContext { } Widget compactTime() { + if (metaInFooter) return const SizedBox.shrink(); + final bgColor = isMe ? Colors.black.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.5); diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index bb4ecf3..8065ce6 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -90,7 +90,7 @@ class FileBubble extends StatelessWidget { ), ), const SizedBox(width: 10), - Flexible( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/frontend/widgets/chat_menu_overlay.dart b/lib/frontend/widgets/chat_menu_overlay.dart index 1c1ea83..f9afad6 100644 --- a/lib/frontend/widgets/chat_menu_overlay.dart +++ b/lib/frontend/widgets/chat_menu_overlay.dart @@ -30,6 +30,7 @@ void showChatMenu({ required List items, Widget? header, Widget? footer, + bool compact = false, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -39,6 +40,7 @@ void showChatMenu({ items: items, header: header, footer: footer, + compact: compact, onDismiss: () { if (entry.mounted) entry.remove(); }, @@ -53,6 +55,7 @@ class _ChatMenuLayer extends StatefulWidget { final List items; final Widget? header; final Widget? footer; + final bool compact; final VoidCallback onDismiss; const _ChatMenuLayer({ @@ -61,6 +64,7 @@ class _ChatMenuLayer extends StatefulWidget { required this.onDismiss, this.header, this.footer, + this.compact = false, }); @override @@ -69,17 +73,26 @@ class _ChatMenuLayer extends StatefulWidget { class _MenuLayout extends SingleChildLayoutDelegate { static const double menuWidth = 290.0; + static const double compactMenuWidth = 226.0; static const double margin = 8.0; static const double gap = 6.0; final Rect anchor; final EdgeInsets safeArea; + final bool compact; - const _MenuLayout({required this.anchor, required this.safeArea}); + const _MenuLayout({ + required this.anchor, + required this.safeArea, + this.compact = false, + }); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - final width = math.min(menuWidth, constraints.maxWidth - margin * 2); + final width = math.min( + compact ? compactMenuWidth : menuWidth, + constraints.maxWidth - margin * 2, + ); final available = constraints.maxHeight - safeArea.top - safeArea.bottom - margin * 2; return BoxConstraints( @@ -112,7 +125,9 @@ class _MenuLayout extends SingleChildLayoutDelegate { @override bool shouldRelayout(_MenuLayout oldDelegate) => - oldDelegate.anchor != anchor || oldDelegate.safeArea != safeArea; + oldDelegate.anchor != anchor || + oldDelegate.safeArea != safeArea || + oldDelegate.compact != compact; } class _ChatMenuLayerState extends State<_ChatMenuLayer> @@ -154,6 +169,7 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> delegate: _MenuLayout( anchor: widget.anchorRect, safeArea: safeArea, + compact: widget.compact, ), child: Opacity( opacity: t, @@ -187,9 +203,13 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> color: cs.onSurface.withValues(alpha: 0.07), ), ], - const SizedBox(height: 6), + SizedBox(height: widget.compact ? 4 : 6), for (final item in widget.items) ...[ - _ChatMenuRow(item: item, onTap: () => _onItemTap(item)), + _ChatMenuRow( + item: item, + compact: widget.compact, + onTap: () => _onItemTap(item), + ), if (item.dividerAfter) Divider( height: 1, @@ -197,7 +217,7 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> color: cs.onSurface.withValues(alpha: 0.07), ), ], - const SizedBox(height: 6), + SizedBox(height: widget.compact ? 4 : 6), if (widget.footer != null) ...[ Divider( height: 1, @@ -217,8 +237,13 @@ class _ChatMenuLayerState extends State<_ChatMenuLayer> class _ChatMenuRow extends StatelessWidget { final ChatMenuItem item; final VoidCallback onTap; + final bool compact; - const _ChatMenuRow({required this.item, required this.onTap}); + const _ChatMenuRow({ + required this.item, + required this.onTap, + this.compact = false, + }); @override Widget build(BuildContext context) { @@ -227,11 +252,14 @@ class _ChatMenuRow extends StatelessWidget { return InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15), + padding: EdgeInsets.symmetric( + horizontal: compact ? 14 : 18, + vertical: compact ? 10 : 15, + ), child: Row( children: [ - Icon(item.icon, size: 24, weight: 350, color: fg), - const SizedBox(width: 18), + Icon(item.icon, size: compact ? 20 : 24, weight: 350, color: fg), + SizedBox(width: compact ? 12 : 18), Expanded( child: Text( item.label, @@ -239,7 +267,7 @@ class _ChatMenuRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: fg, - fontSize: 16, + fontSize: compact ? 14 : 16, fontWeight: FontWeight.w500, ), ), diff --git a/lib/frontend/widgets/formatted_message_text.dart b/lib/frontend/widgets/formatted_message_text.dart index ad0827e..cd614b5 100644 --- a/lib/frontend/widgets/formatted_message_text.dart +++ b/lib/frontend/widgets/formatted_message_text.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart' show ContactCache; import '../../core/utils/link_opener.dart'; @@ -251,31 +252,27 @@ class _FormattedMessageTextState extends State { final segments = segmentizeFormats(widget.text, ranges); final cs = Theme.of(context).colorScheme; final baseColor = widget.style.color ?? cs.onSurface; - final barColor = baseColor.withValues(alpha: 0.4); final quoteColor = baseColor.withValues(alpha: 0.85); final mentionColor = mentionTextColor(cs); - final spans = []; - var prevQuote = false; + final blocks = <_TextBlock>[]; + var spans = []; + var blockIsQuote = false; + + void closeBlock() { + _trimBlockEdges(spans); + if (spans.isNotEmpty) { + blocks.add(_TextBlock(quote: blockIsQuote, spans: spans)); + } + spans = []; + } + for (final segment in segments) { final isQuote = segment.formats.contains(TextFormat.quote); - if (isQuote && !prevQuote) { - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Container( - width: 3, - height: (widget.style.fontSize ?? 16) * 1.15, - margin: const EdgeInsets.only(right: 6, left: 1), - decoration: BoxDecoration( - color: barColor, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ); + if (isQuote != blockIsQuote) { + closeBlock(); + blockIsQuote = isQuote; } - prevQuote = isQuote; final style = applyTextFormats( widget.style, @@ -375,11 +372,115 @@ class _FormattedMessageTextState extends State { } } - return Text.rich( - TextSpan(style: widget.style, children: spans), - textAlign: widget.textAlign, - maxLines: widget.maxLines, - overflow: widget.overflow ?? TextOverflow.clip, + closeBlock(); + + if (blocks.length == 1 && !blocks.first.quote) { + return _paragraph(blocks.first.spans); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < blocks.length; i++) ...[ + if (i > 0) const SizedBox(height: 4), + blocks[i].quote + ? _quoteBlock(blocks[i].spans, baseColor) + : _paragraph(blocks[i].spans), + ], + ], + ); + } + + Widget _paragraph(List spans) => Text.rich( + TextSpan(style: widget.style, children: spans), + textAlign: widget.textAlign, + maxLines: widget.maxLines, + overflow: widget.overflow ?? TextOverflow.clip, + ); + + Widget _quoteBlock(List spans, Color baseColor) { + final glyphSize = (widget.style.fontSize ?? 16) * 0.85; + return Container( + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.fromLTRB(9, 5, 9, 6), + child: Stack( + children: [ + Padding( + padding: EdgeInsets.only(right: glyphSize + 2), + child: _paragraph(spans), + ), + Positioned( + top: 0, + right: 0, + child: Icon( + Symbols.format_quote, + size: glyphSize, + fill: 1, + color: baseColor.withValues(alpha: 0.55), + ), + ), + ], + ), ); } } + +class _TextBlock { + final bool quote; + final List spans; + + const _TextBlock({required this.quote, required this.spans}); +} + +void _trimBlockEdges(List spans) { + while (spans.isNotEmpty) { + final trimmed = _withText( + spans.first, + (t) => t.replaceFirst(_leadingNewlines, ''), + ); + if (trimmed == null) break; + if (_isEmptyText(trimmed)) { + spans.removeAt(0); + continue; + } + spans[0] = trimmed; + break; + } + while (spans.isNotEmpty) { + final trimmed = _withText( + spans.last, + (t) => t.replaceFirst(_trailingNewlines, ''), + ); + if (trimmed == null) break; + if (_isEmptyText(trimmed)) { + spans.removeLast(); + continue; + } + spans[spans.length - 1] = trimmed; + break; + } +} + +final RegExp _leadingNewlines = RegExp(r'^\n+'); +final RegExp _trailingNewlines = RegExp(r'\n+$'); + +InlineSpan? _withText(InlineSpan span, String Function(String) transform) { + if (span is! TextSpan) return null; + final text = span.text; + if (text == null) return null; + final next = transform(text); + if (next == text) return span; + return TextSpan( + text: next, + style: span.style, + recognizer: span.recognizer, + children: span.children, + ); +} + +bool _isEmptyText(InlineSpan span) => + span is TextSpan && (span.text?.isEmpty ?? false) && span.children == null; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index d6f8bf8..c3c5e13 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -21,6 +21,7 @@ import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; +import 'text_entity_actions.dart'; import 'sending_clock_icon.dart'; import 'photo_viewer.dart'; import 'selectable_message_text.dart'; @@ -58,20 +59,173 @@ class ReactionAnimationEvent { typedef ReactionAnimojiResolver = Animoji? Function(String emoji); -class _ZeroIntrinsicWidth extends SingleChildRenderObjectWidget { - const _ZeroIntrinsicWidth({required Widget super.child}); +class _TextWithMeta extends MultiChildRenderObjectWidget { + _TextWithMeta({required Widget text, required Widget meta}) + : super(children: [text, meta]); @override RenderObject createRenderObject(BuildContext context) => - _RenderZeroIntrinsicWidth(); + _RenderTextWithMeta(); } -class _RenderZeroIntrinsicWidth extends RenderProxyBox { - @override - double computeMinIntrinsicWidth(double height) => 0; +class _TextWithMetaParentData extends ContainerBoxParentData {} + +class _RenderTextWithMeta extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + static const double _gap = 8; + static const double _baselineNudge = 2; + + RenderBox get _text => firstChild!; + RenderBox get _meta => lastChild!; @override - double computeMaxIntrinsicWidth(double height) => 0; + void setupParentData(RenderBox child) { + if (child.parentData is! _TextWithMetaParentData) { + child.parentData = _TextWithMetaParentData(); + } + } + + RenderParagraph? _soleParagraph() { + RenderParagraph? found; + var seen = 0; + void visit(RenderObject node) { + if (node is RenderParagraph) { + found = node; + seen++; + return; + } + node.visitChildren(visit); + } + + _text.visitChildren(visit); + if (_text is RenderParagraph) { + found = _text as RenderParagraph; + seen = 1; + } + return seen == 1 ? found : null; + } + + @override + double computeMinIntrinsicWidth(double height) => + _text.getMinIntrinsicWidth(height); + + @override + double computeMaxIntrinsicWidth(double height) => + _text.getMaxIntrinsicWidth(height) + + _gap + + _meta.getMaxIntrinsicWidth(height); + + @override + double computeMinIntrinsicHeight(double width) => + _text.getMinIntrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) => + _text.getMaxIntrinsicHeight(width) + _meta.getMaxIntrinsicHeight(width); + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) => + BaselineOffset(_text.getDistanceToActualBaseline(baseline)).offset; + + @override + void performLayout() { + _meta.layout(const BoxConstraints(), parentUsesSize: true); + final metaSize = _meta.size; + + _text.layout(constraints.loosen(), parentUsesSize: true); + final textSize = _text.size; + + final paragraph = _soleParagraph(); + final needed = _gap + metaSize.width; + + double width; + double height; + var metaOnOwnLine = false; + + if (paragraph != null) { + final length = paragraph.text.toPlainText().length; + final caret = paragraph.getOffsetForCaret( + TextPosition(offset: length), + Rect.zero, + ); + final lastLine = caret.dx; + final singleLine = caret.dy < 0.5; + if (lastLine + needed <= textSize.width) { + width = textSize.width; + height = textSize.height; + } else if (singleLine) { + width = lastLine + needed; + height = textSize.height; + } else { + width = textSize.width; + height = textSize.height + metaSize.height; + metaOnOwnLine = true; + } + } else { + width = math.max(textSize.width, metaSize.width); + height = textSize.height + metaSize.height; + metaOnOwnLine = true; + } + + size = constraints.constrain(Size(width, height)); + + (_text.parentData! as _TextWithMetaParentData).offset = Offset.zero; + (_meta.parentData! as _TextWithMetaParentData).offset = Offset( + math.max(0, size.width - metaSize.width), + metaOnOwnLine + ? size.height - metaSize.height + : size.height - metaSize.height - _baselineNudge, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } +} + +class _CapIntrinsicWidth extends SingleChildRenderObjectWidget { + final double cap; + + const _CapIntrinsicWidth({required this.cap, required Widget super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderCapIntrinsicWidth(cap); + + @override + void updateRenderObject( + BuildContext context, + _RenderCapIntrinsicWidth renderObject, + ) { + renderObject.cap = cap; + } +} + +class _RenderCapIntrinsicWidth extends RenderProxyBox { + _RenderCapIntrinsicWidth(this._cap); + + double _cap; + set cap(double value) { + if (value == _cap) return; + _cap = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => + math.min(super.computeMinIntrinsicWidth(height), _cap); + + @override + double computeMaxIntrinsicWidth(double height) => + math.min(super.computeMaxIntrinsicWidth(height), _cap); } class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget { @@ -83,7 +237,8 @@ class _HeaderAboveMatchWidth extends MultiChildRenderObjectWidget { _RenderHeaderAboveMatchWidth(); } -class _HeaderAboveMatchWidthParentData extends ContainerBoxParentData {} +class _HeaderAboveMatchWidthParentData + extends ContainerBoxParentData {} class _RenderHeaderAboveMatchWidth extends RenderBox with @@ -493,6 +648,7 @@ class MessageBubble extends StatelessWidget { final String? senderNameOverride; final String? senderAvatarOverride; final ValueListenable<({String id, Offset pos})?>? textSelection; + final ValueListenable? textSelectionDrag; final VoidCallback? onExitTextSelection; final String? commentsLabel; final VoidCallback? onCommentsTap; @@ -523,6 +679,7 @@ class MessageBubble extends StatelessWidget { this.senderNameOverride, this.senderAvatarOverride, this.textSelection, + this.textSelectionDrag, this.onExitTextSelection, this.commentsLabel, this.onCommentsTap, @@ -574,12 +731,9 @@ class MessageBubble extends StatelessWidget { } bool get _showsSenderName => - !isMe && - chatType == "CHAT" && - prevMessage?.senderId != message.senderId; + !isMe && chatType == "CHAT" && prevMessage?.senderId != message.senderId; - bool get _stretchesTextRow => - message.replyInfo != null || _showsSenderName; + bool get _stretchesTextRow => message.replyInfo != null || _showsSenderName; BubbleShape _computeShape() { if (message.isControl) return BubbleShape.singleMiddle; @@ -749,6 +903,8 @@ class MessageBubble extends StatelessWidget { ); } + static const double _replyWidthShare = 0.75; + static const List _senderPalette = [ Color(0xFFE57373), Color(0xFF64B5F6), @@ -794,7 +950,8 @@ class MessageBubble extends StatelessWidget { Widget _buildLeadingAvatar(ColorScheme cs) { final senderAvatar = senderAvatarOverride ?? ContactCache.getAvatar(message.senderId); - final displaySender = senderNameOverride ?? ContactCache.get(message.senderId); + final displaySender = + senderNameOverride ?? ContactCache.get(message.senderId); final Widget avatar; if (senderAvatar != null && senderAvatar.isNotEmpty) { avatar = CircleAvatar( @@ -887,15 +1044,17 @@ class MessageBubble extends StatelessWidget { final maxBubbleWidth = isVideoNote ? math.min(screenWidth - 24, 560.0) : math.min(screenWidth * 0.75, 560.0); - final noBubbleBackground = isVideoNote || _isSticker || jumboAnimoji != null; + final noBubbleBackground = + isVideoNote || _isSticker || jumboAnimoji != null; final bubbleColor = noBubbleBackground ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); - BubbleContext makeCtx() => BubbleContext( + BubbleContext makeCtx({bool metaInFooter = false}) => BubbleContext( context: context, cs: cs, text: textColor, + metaInFooter: metaInFooter, shape: shape, contentType: contentType, hasPhotoWithCaption: hasPhotoCap, @@ -913,16 +1072,9 @@ class MessageBubble extends StatelessWidget { onStickerTap: onStickerTap, onForwardedSourceTap: onForwardedSourceTap, reactionInfo: _resolveReactionInfo(), + selectable: _wrapSelectable, ); - final Widget bubbleContent = - reactionsListenable != null && contentType == MessageType.text - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, _, _) => _buildContent(makeCtx()), - ) - : _buildContent(makeCtx()); - final reactionsInside = contentType != MessageType.text; final reply = message.replyInfo; @@ -933,20 +1085,19 @@ class MessageBubble extends StatelessWidget { : padding; final Widget contentWithReactions = reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - bubbleContent, - _reactionsBar( - cs, - inset: padding == EdgeInsets.zero - ? const EdgeInsets.fromLTRB(8, 4, 8, 6) - : const EdgeInsets.only(top: 4), - ), - ], + ? _contentWithReactionsFooter( + cs, + makeCtx, + inset: padding == EdgeInsets.zero + ? const EdgeInsets.fromLTRB(8, 4, 8, 6) + : const EdgeInsets.only(top: 4), ) - : bubbleContent; + : reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); final Widget? senderHeader = showSenderName ? _buildSenderHeader(cs, padding == EdgeInsets.zero) @@ -967,7 +1118,8 @@ class MessageBubble extends StatelessWidget { child: senderHeader, ), if (reply != null) ...[ - _ZeroIntrinsicWidth( + _CapIntrinsicWidth( + cap: maxBubbleWidth * _replyWidthShare, child: _buildReplyQuote(context, cs, textColor, reply), ), const SizedBox(height: 4), @@ -1084,10 +1236,7 @@ class MessageBubble extends StatelessWidget { color: cs.onSurfaceVariant.withValues(alpha: 0.18), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 11, - ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), child: Row( children: [ Icon(Symbols.mode_comment, size: 19, color: accent), @@ -1162,37 +1311,53 @@ class MessageBubble extends StatelessWidget { 'OPEN_APP' => Symbols.chevron_right, _ => null, }; + final isClipboard = button.type == 'CLIPBOARD'; 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, + child: Stack( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(isClipboard ? 26 : 12, 10, 12, 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), + ], + ], + ), + ), + if (isClipboard) + Positioned( + top: 6, + right: 8, + child: Icon( + Symbols.content_copy, + size: 15, + weight: 500, + color: cs.primary.withValues(alpha: 0.85), ), ), - if (trailingIcon != null) ...[ - const SizedBox(width: 4), - Icon(trailingIcon, size: 16, color: cs.primary), - ], - ], - ), + ], ), ), ); @@ -1213,6 +1378,11 @@ class MessageBubble extends StatelessWidget { case 'OPEN_APP': await _openMiniApp(context, button); return; + case 'CLIPBOARD': + final payload = button.payload; + if (payload == null || payload.isEmpty) return; + await copyTextEntity(context, payload, 'Скопировано'); + return; default: final callbackId = keyboard.callbackId; if (callbackId == null || callbackId.isEmpty) { @@ -1300,17 +1470,70 @@ class MessageBubble extends StatelessWidget { return info != null && info.counters.isNotEmpty; } - Widget _reactionsBar(ColorScheme cs, {required EdgeInsets inset}) { + Widget _contentWithReactionsFooter( + ColorScheme cs, + BubbleContext Function({bool metaInFooter}) makeCtx, { + required EdgeInsets inset, + }) { final listenable = reactionsListenable; if (listenable != null) { return ValueListenableBuilder?>( valueListenable: listenable, builder: (context, info, _) => - _buildReactionsBarFor(cs, info, inset: inset), + _reactionsFooterLayout(cs, makeCtx, info, inset: inset), ); } final info = message.payload?['reactionInfo']; - return _buildReactionsBarFor(cs, info is Map ? info : null, inset: inset); + return _reactionsFooterLayout( + cs, + makeCtx, + info is Map ? info : null, + inset: inset, + ); + } + + Widget _reactionsFooterLayout( + ColorScheme cs, + BubbleContext Function({bool metaInFooter}) makeCtx, + Map? info, { + required EdgeInsets inset, + }) { + final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info)); + if (chips.isEmpty) return _buildContent(makeCtx()); + + final carriesMeta = + _contentType == MessageType.attachment || + _contentType == MessageType.voice; + final ctx = makeCtx(metaInFooter: carriesMeta); + + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildContent(ctx), + Padding( + padding: inset, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _ReactionsWrap( + spacing: 4, + runSpacing: 4, + children: chips, + ), + ), + if (carriesMeta) ...[ + const SizedBox(width: 8), + ctx.footerMeta(), + ], + ], + ), + ), + ], + ), + ); } Widget _buildContent(BubbleContext ctx) { @@ -1541,6 +1764,7 @@ class MessageBubble extends StatelessWidget { if (req == null || req.id != message.id) return child!; return SelectableMessageText( initialGlobalPosition: req.pos, + dragPosition: textSelectionDrag, onExit: onExitTextSelection ?? () {}, child: child!, ); @@ -1578,8 +1802,9 @@ class MessageBubble extends StatelessWidget { ); final hasReactions = reactionChips.isNotEmpty; - final activeFontFamily = - Theme.of(ctx.context).textTheme.bodyLarge?.fontFamily; + final activeFontFamily = Theme.of( + ctx.context, + ).textTheme.bodyLarge?.fontFamily; final textStyle = TextStyle( color: ctx.text, fontSize: 16, @@ -1591,6 +1816,24 @@ class MessageBubble extends StatelessWidget { ); final ranges = message.formatRanges; final decryptedText = decryption?.plaintext; + + final metaRow = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + 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 (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], + if (message.deleted) ...[const SizedBox(width: 4), ctx.deletedIcon()], + ], + ); + final Widget textWidget; if (decryption?.state == MessageDecryptionState.wrongKey) { textWidget = _wrapSelectable( @@ -1618,20 +1861,6 @@ class MessageBubble extends StatelessWidget { textWidget = _wrapSelectable(Text(message.text ?? '', style: textStyle)); } - 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( @@ -1652,13 +1881,8 @@ class MessageBubble extends StatelessWidget { const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(bottom: 2), - child: metaWidget, + child: metaRow, ), - if (isMe) ...[const SizedBox(width: 4), ctx.statusIcon()], - if (message.deleted) ...[ - const SizedBox(width: 4), - ctx.deletedIcon(), - ], ], ), ], @@ -1666,30 +1890,7 @@ class MessageBubble extends StatelessWidget { ); } - 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(), - ], - ], - ), - ], - ); + return _TextWithMeta(text: textWidget, meta: metaRow); } Widget _buildReplyQuote( @@ -1698,7 +1899,7 @@ class MessageBubble extends StatelessWidget { Color textColor, ReplyInfo reply, ) { - final accent = isMe ? cs.onPrimaryContainer : cs.primary; + final accent = _senderColor(reply.senderId); final name = reply.senderId == myId ? 'Вы' : (ContactCache.get(reply.senderId) ?? 'Сообщение'); @@ -1806,7 +2007,7 @@ class MessageBubble extends StatelessWidget { ), if (hasOrigText) ...[ const SizedBox(height: 2), - _wrapSelectable(forwardedCtx.caption()), + forwardedCtx.caption(), ] else ...[ const SizedBox(height: 2), _wrapSelectable( diff --git a/lib/frontend/widgets/rich_message_controller.dart b/lib/frontend/widgets/rich_message_controller.dart index 65a1564..665d6a5 100644 --- a/lib/frontend/widgets/rich_message_controller.dart +++ b/lib/frontend/widgets/rich_message_controller.dart @@ -109,7 +109,8 @@ class RichMessageController extends TextEditingController { return (text: src, elements: elementsForSend()); } - final entities = [..._animoji]..sort((a, b) => a.offset.compareTo(b.offset)); + final entities = [..._animoji] + ..sort((a, b) => a.offset.compareTo(b.offset)); final sb = StringBuffer(); var last = 0; @@ -386,6 +387,9 @@ class RichMessageController extends TextEditingController { final ranges = _toFormatRanges(); final baseColor = baseStyle.color; final quoteColor = baseColor?.withValues(alpha: 0.85); + final quoteBackground = baseColor == null + ? null + : (Paint()..color = baseColor.withValues(alpha: 0.12)); final mentionColor = mentionTextColor(Theme.of(context).colorScheme); final segments = segmentizeFormats(content, ranges); final entityByOffset = {for (final e in _animoji) e.offset: e}; @@ -398,6 +402,7 @@ class RichMessageController extends TextEditingController { segment.formats, quoteColor: quoteColor, mentionColor: mentionColor, + quoteBackground: quoteBackground, ); var runStart = segment.start; var i = segment.start; diff --git a/lib/frontend/widgets/selectable_message_text.dart b/lib/frontend/widgets/selectable_message_text.dart index 2f23235..55d1587 100644 --- a/lib/frontend/widgets/selectable_message_text.dart +++ b/lib/frontend/widgets/selectable_message_text.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -10,34 +11,64 @@ import '../../core/utils/haptics.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; -RenderParagraph? _findParagraph(RenderObject? ro) { - if (ro == null) return null; - if (ro is RenderParagraph) return ro; - RenderParagraph? found; - ro.visitChildren((child) { - found ??= _findParagraph(child); - }); - return found; +void _collectParagraphs(RenderObject ro, List out) { + if (ro is RenderParagraph) { + out.add(ro); + return; + } + ro.visitChildren((child) => _collectParagraphs(child, out)); } bool _isSpace(int c) => - c == 0x20 || - c == 0x09 || - c == 0x0A || - c == 0x0D || - c == 0x0C || - c == 0xA0; + c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0xA0; + +bool _isGlyphOnly(String text) { + if (text.isEmpty) return true; + for (final rune in text.runes) { + final private = + (rune >= 0xE000 && rune <= 0xF8FF) || + (rune >= 0xF0000 && rune <= 0xFFFFD) || + (rune >= 0x100000 && rune <= 0x10FFFD); + if (!private) return false; + } + return true; +} + +class _ParaSlice { + final RenderParagraph rp; + final int start; + final String text; + + const _ParaSlice({required this.rp, required this.start, required this.text}); + + int get end => start + text.length; + + bool get usable => rp.attached && rp.hasSize; + + Offset get origin => rp.localToGlobal(Offset.zero); + + Rect get globalRect => origin & rp.size; + + TextSelection? clip(TextSelection selection) { + final s = selection.start.clamp(start, end) - start; + final e = selection.end.clamp(start, end) - start; + if (e <= s) return null; + return TextSelection(baseOffset: s, extentOffset: e); + } +} class SelectableMessageText extends StatefulWidget { final Widget child; final Offset initialGlobalPosition; final VoidCallback onExit; + final ValueListenable? dragPosition; const SelectableMessageText({ super.key, required this.child, required this.initialGlobalPosition, required this.onExit, + this.dragPosition, }); @override @@ -53,30 +84,111 @@ class _SelectableMessageTextState extends State static const double _hitBelow = 42.0; final GlobalKey _textKey = GlobalKey(); + final LayerLink _link = LayerLink(); final ValueNotifier _toolbarVisible = ValueNotifier(false); late final AnimationController _entrance; OverlayEntry? _overlay; Timer? _settle; TextSelection _selection = const TextSelection.collapsed(offset: 0); - String? _cachedText; bool _dragging = false; bool _exiting = false; - double? _dragStartLocalY; + double? _dragStartGlobalY; double _dragAnchorY = 0; double _dragLineHeight = 0; - RenderParagraph? _cachedParagraph; + List<_ParaSlice>? _cachedSlices; + String _cachedJoined = ''; + ScrollPosition? _scrollPosition; + TextSelection? _anchor; - RenderParagraph? get _paragraph { - final cached = _cachedParagraph; - if (cached != null && cached.attached) return cached; - return _cachedParagraph = _findParagraph( - _textKey.currentContext?.findRenderObject(), - ); + List<_ParaSlice> get _slices { + final cached = _cachedSlices; + if (cached != null && cached.isNotEmpty && cached.every((s) => s.usable)) { + return cached; + } + final root = _textKey.currentContext?.findRenderObject(); + final paragraphs = []; + if (root != null) _collectParagraphs(root, paragraphs); + + final slices = <_ParaSlice>[]; + var offset = 0; + for (final rp in paragraphs) { + if (!rp.attached || !rp.hasSize) continue; + final text = rp.text.toPlainText(); + if (_isGlyphOnly(text)) continue; + slices.add(_ParaSlice(rp: rp, start: offset, text: text)); + offset += text.length + 1; + } + _cachedJoined = slices.map((s) => s.text).join('\n'); + return _cachedSlices = slices; } - String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText(); + String get _joined { + _slices; + return _cachedJoined; + } + + _ParaSlice? _sliceAt(Offset globalPos) { + final slices = _slices; + if (slices.isEmpty) return null; + _ParaSlice? nearest; + var best = double.infinity; + for (final slice in slices) { + final rect = slice.globalRect; + if (rect.contains(globalPos)) return slice; + final dy = globalPos.dy < rect.top + ? rect.top - globalPos.dy + : globalPos.dy - rect.bottom; + final distance = math.max(0.0, dy); + if (distance < best) { + best = distance; + nearest = slice; + } + } + return nearest; + } + + int? _offsetAt(Offset globalPos) { + final slice = _sliceAt(globalPos); + if (slice == null) return null; + final local = slice.rp.globalToLocal(globalPos); + final off = slice.rp + .getPositionForOffset(local) + .offset + .clamp(0, slice.text.length); + return slice.start + off; + } + + RenderBox? get _rootBox { + final ro = _textKey.currentContext?.findRenderObject(); + if (ro is! RenderBox || !ro.attached || !ro.hasSize) return null; + return ro; + } + + List _localBoxes(TextSelection selection) { + if (!selection.isValid || selection.isCollapsed) return const []; + final root = _rootBox; + if (root == null) return const []; + final rootOrigin = root.localToGlobal(Offset.zero); + final out = []; + for (final slice in _slices) { + final local = slice.clip(selection); + if (local == null) continue; + final delta = slice.origin - rootOrigin; + for (final box in slice.rp.getBoxesForSelection(local)) { + out.add(box.toRect().shift(delta)); + } + } + return out; + } + + List _globalBoxes(TextSelection selection) { + final root = _rootBox; + if (root == null) return const []; + final rootOrigin = root.localToGlobal(Offset.zero); + return [for (final rect in _localBoxes(selection)) rect.shift(rootOrigin)]; + } @override void initState() { @@ -85,12 +197,57 @@ class _SelectableMessageTextState extends State vsync: this, duration: const Duration(milliseconds: 260), )..addListener(() => _overlay?.markNeedsBuild()); + widget.dragPosition?.addListener(_onDragPosition); WidgetsBinding.instance.addPostFrameCallback((_) => _init(4)); } + @override + void didUpdateWidget(SelectableMessageText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.dragPosition == widget.dragPosition) return; + oldWidget.dragPosition?.removeListener(_onDragPosition); + widget.dragPosition?.addListener(_onDragPosition); + } + + void _onDragPosition() { + if (!mounted) return; + final pos = widget.dragPosition?.value; + if (pos == null) { + if (_anchor != null) _toolbarVisible.value = true; + return; + } + final anchor = _anchor; + if (anchor == null) return; + final off = _offsetAt(pos); + if (off == null) return; + _toolbarVisible.value = false; + if (off > anchor.end) { + _applySelection( + TextSelection(baseOffset: anchor.start, extentOffset: off), + ); + } else if (off < anchor.start) { + _applySelection(TextSelection(baseOffset: off, extentOffset: anchor.end)); + } else { + _applySelection(anchor); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final position = Scrollable.maybeOf(context)?.position; + if (identical(position, _scrollPosition)) return; + _scrollPosition?.removeListener(_onScroll); + _scrollPosition = position?..addListener(_onScroll); + } + + void _onScroll() => _overlay?.markNeedsBuild(); + @override void dispose() { _settle?.cancel(); + widget.dragPosition?.removeListener(_onDragPosition); + _scrollPosition?.removeListener(_onScroll); _entrance.dispose(); _overlay?.remove(); _overlay = null; @@ -100,8 +257,7 @@ class _SelectableMessageTextState extends State void _init(int retries) { if (!mounted) return; - final rp = _paragraph; - if (rp == null || !rp.hasSize) { + if (_slices.isEmpty) { if (retries > 0) { WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1)); } else { @@ -109,7 +265,8 @@ class _SelectableMessageTextState extends State } return; } - _selectWordAt(widget.initialGlobalPosition, rp); + _selectWordAt(widget.initialGlobalPosition); + _anchor = _selection; Haptics.selection(); _ensureOverlay(); _toolbarVisible.value = true; @@ -130,10 +287,8 @@ class _SelectableMessageTextState extends State }); } - TextSelection _normalize(int a, int b) => TextSelection( - baseOffset: math.min(a, b), - extentOffset: math.max(a, b), - ); + TextSelection _normalize(int a, int b) => + TextSelection(baseOffset: math.min(a, b), extentOffset: math.max(a, b)); void _applySelection(TextSelection sel, {bool animate = false}) { _selection = sel; @@ -142,12 +297,11 @@ class _SelectableMessageTextState extends State _overlay?.markNeedsBuild(); } - TextRange _wordRange(RenderParagraph rp, Offset globalPos) { - final text = _text(rp); + TextRange _wordRange(Offset globalPos) { + final text = _joined; final len = text.length; if (len == 0) return const TextRange.collapsed(0); - final local = rp.globalToLocal(globalPos); - var off = rp.getPositionForOffset(local).offset.clamp(0, len); + var off = (_offsetAt(globalPos) ?? 0).clamp(0, len); bool ws(int i) => i < 0 || i >= len || _isSpace(text.codeUnitAt(i)); if (ws(off) && off > 0 && !ws(off - 1)) off -= 1; if (ws(off)) return TextRange.collapsed(off); @@ -162,72 +316,59 @@ class _SelectableMessageTextState extends State return TextRange(start: s, end: e); } - void _selectWordAt(Offset globalPos, RenderParagraph rp) { - final range = _wordRange(rp, globalPos); + void _selectWordAt(Offset globalPos) { + final range = _wordRange(globalPos); if (range.isCollapsed) { - _applySelection(_normalize(0, _text(rp).length), animate: true); + _applySelection(_normalize(0, _joined.length), animate: true); } else { _applySelection(_normalize(range.start, range.end), animate: true); } } void _onBackgroundTap(Offset globalPos) { - final rp = _paragraph; - if (rp == null || !rp.hasSize) { + final slices = _slices; + if (slices.isEmpty) { _requestExit(); return; } - final local = rp.globalToLocal(globalPos); - if (rp.size.contains(local)) { + final inside = slices.any((s) => s.globalRect.contains(globalPos)); + if (inside) { _dragging = false; _settle?.cancel(); - _selectWordAt(globalPos, rp); _toolbarVisible.value = true; } else { _requestExit(); } } - ui.TextBox? _edgeBox(RenderParagraph rp, bool isStart) { - if (!_selection.isValid || _selection.isCollapsed) return null; - final boxes = rp.getBoxesForSelection(_selection); - if (boxes.isEmpty) return null; - return isStart ? boxes.first : boxes.last; - } - void _onHandleDragStart(Offset globalPos, bool isStart) { _dragging = true; _settle?.cancel(); _entrance.value = 1.0; _toolbarVisible.value = false; - _dragStartLocalY = null; - final rp = _paragraph; - if (rp == null || !rp.hasSize) return; - final box = _edgeBox(rp, isStart); - if (box == null) return; - final rect = box.toRect(); - _dragStartLocalY = rp.globalToLocal(globalPos).dy; + _dragStartGlobalY = null; + final boxes = _globalBoxes(_selection); + if (boxes.isEmpty) return; + final rect = isStart ? boxes.first : boxes.last; + _dragStartGlobalY = globalPos.dy; _dragAnchorY = rect.center.dy; _dragLineHeight = rect.height; } - double _lineSnappedY(double fingerLocalY) { - final startY = _dragStartLocalY; - if (startY == null || _dragLineHeight <= 0) return fingerLocalY; - final dragged = fingerLocalY - startY; + double _lineSnappedY(double fingerGlobalY) { + final startY = _dragStartGlobalY; + if (startY == null || _dragLineHeight <= 0) return fingerGlobalY; + final dragged = fingerGlobalY - startY; final direction = dragged < 0 ? -1 : 1; final lines = direction * (dragged.abs() / _dragLineHeight).floor(); return _dragAnchorY + lines * _dragLineHeight; } void _onHandleDrag(Offset globalPos, bool isStart) { - final rp = _paragraph; - if (rp == null || !rp.hasSize) return; - final len = _text(rp).length; - final local = rp.globalToLocal(globalPos); - final off = rp - .getPositionForOffset(Offset(local.dx, _lineSnappedY(local.dy))) - .offset; + final len = _joined.length; + if (len == 0) return; + final off = _offsetAt(Offset(globalPos.dx, _lineSnappedY(globalPos.dy))); + if (off == null) return; if (isStart) { final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt(); _applySelection( @@ -243,7 +384,7 @@ class _SelectableMessageTextState extends State void _onHandleDragEnd() { _dragging = false; - _dragStartLocalY = null; + _dragStartGlobalY = null; _settle?.cancel(); _settle = Timer(const Duration(milliseconds: 140), () { if (!mounted || _dragging) return; @@ -253,9 +394,8 @@ class _SelectableMessageTextState extends State } void _copy() { - final rp = _paragraph; - if (rp != null && _selection.isValid && !_selection.isCollapsed) { - final text = _text(rp); + if (_selection.isValid && !_selection.isCollapsed) { + final text = _joined; final sub = text.substring( _selection.start.clamp(0, text.length), _selection.end.clamp(0, text.length), @@ -273,48 +413,53 @@ class _SelectableMessageTextState extends State } void _selectAll() { - final rp = _paragraph; - if (rp == null) return; + if (_slices.isEmpty) return; Haptics.tap(); - _applySelection(_normalize(0, _text(rp).length), animate: true); + _applySelection(_normalize(0, _joined.length), animate: true); _toolbarVisible.value = true; } Widget _buildOverlay(BuildContext ctx) { - final rp = _paragraph; - if (rp == null || !rp.hasSize || !rp.attached) { - return const SizedBox.shrink(); - } + if (_slices.isEmpty) return const SizedBox.shrink(); final cs = Theme.of(ctx).colorScheme; - - final List boxes = - (_selection.isValid && !_selection.isCollapsed) - ? rp.getBoxesForSelection(_selection) - : const []; + final rects = _localBoxes(_selection); final children = [ Positioned.fill( child: GestureDetector( - behavior: HitTestBehavior.opaque, + behavior: HitTestBehavior.translucent, onTapUp: (d) => _onBackgroundTap(d.globalPosition), ), ), ]; - if (boxes.isNotEmpty) { - final first = boxes.first.toRect(); - final last = boxes.last.toRect(); - final startBottom = rp.localToGlobal(Offset(first.left, first.bottom)); - final endBottom = rp.localToGlobal(Offset(last.right, last.bottom)); + if (rects.isNotEmpty) { + final first = rects.first; + final last = rects.last; - children.add(_handle(cs, startBottom, isStart: true)); - children.add(_handle(cs, endBottom, isStart: false)); - children.add(_toolbar(ctx, rp, first, last)); + children.add( + _handle(cs, Offset(first.left, first.bottom), isStart: true), + ); + children.add( + _handle(cs, Offset(last.right, last.bottom), isStart: false), + ); + children.add(_toolbar(ctx, first, last)); } return Stack(children: children); } + Widget _follow(Offset offset, Widget child) => Positioned( + left: 0, + top: 0, + child: CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + offset: offset, + child: child, + ), + ); + Widget _handle( ColorScheme cs, Offset lineBottomGlobal, { @@ -325,41 +470,42 @@ class _SelectableMessageTextState extends State lineBottomGlobal.dy + _ballRadius, ); final leftInset = isStart ? _hitOuter : _hitInner; - return Positioned( - left: center.dx - leftInset, - top: center.dy - _hitAbove, - width: _hitInner + _hitOuter, - height: _hitAbove + _hitBelow, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart), - onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart), - onPanEnd: (_) => _onHandleDragEnd(), - onPanCancel: _onHandleDragEnd, - child: Align( - alignment: Alignment.topLeft, - child: Padding( - padding: EdgeInsets.only( - left: leftInset - _ballRadius, - top: _hitAbove - _ballRadius, - ), - child: Transform.scale( - scale: Curves.easeOutBack.transform( - _entrance.value.clamp(0.0, 1.0), + return _follow( + Offset(center.dx - leftInset, center.dy - _hitAbove), + SizedBox( + width: _hitInner + _hitOuter, + height: _hitAbove + _hitBelow, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart), + onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart), + onPanEnd: (_) => _onHandleDragEnd(), + onPanCancel: _onHandleDragEnd, + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: EdgeInsets.only( + left: leftInset - _ballRadius, + top: _hitAbove - _ballRadius, ), - child: Container( - width: _ballRadius * 2, - height: _ballRadius * 2, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], + child: Transform.scale( + scale: Curves.easeOutBack.transform( + _entrance.value.clamp(0.0, 1.0), + ), + child: Container( + width: _ballRadius * 2, + height: _ballRadius * 2, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), ), ), ), @@ -369,34 +515,36 @@ class _SelectableMessageTextState extends State ); } - Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) { + Widget _toolbar(BuildContext ctx, Rect first, Rect last) { final media = MediaQuery.of(ctx); final size = media.size; + final rootOrigin = _rootBox?.localToGlobal(Offset.zero) ?? Offset.zero; final safeTop = media.padding.top + 8; final safeBottom = size.height - media.padding.bottom - 8; const height = 48.0; const gap = 10.0; - final topGlobal = rp.localToGlobal(first.topLeft).dy; - final bottomGlobal = rp.localToGlobal(Offset(last.right, last.bottom)).dy; - - double top = topGlobal - gap - height; - if (top < safeTop) top = bottomGlobal + gap; + double top = rootOrigin.dy + first.top - gap - height; + if (top < safeTop) top = rootOrigin.dy + last.bottom + gap; top = top.clamp(safeTop, math.max(safeTop, safeBottom - height)); - return Positioned( - left: 12, - right: 12, - top: top, - child: ValueListenableBuilder( - valueListenable: _toolbarVisible, - builder: (ctx, visible, _) => IgnorePointer( - ignoring: !visible, - child: AnimatedOpacity( - opacity: visible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - child: Center(child: _pill(ctx)), + return _follow( + Offset(-rootOrigin.dx, top - rootOrigin.dy), + SizedBox( + width: size.width, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: ValueListenableBuilder( + valueListenable: _toolbarVisible, + builder: (ctx, visible, _) => IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Center(child: _pill(ctx)), + ), + ), ), ), ), @@ -447,28 +595,34 @@ class _SelectableMessageTextState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return CustomPaint( - painter: _HighlightPainter( - paragraph: _paragraph, - selection: _selection, - animation: _entrance, - fill: cs.primary.withValues(alpha: 0.28), - stem: cs.primary, + return CompositedTransformTarget( + link: _link, + child: CustomPaint( + painter: _HighlightPainter( + rootKey: _textKey, + slices: _slices, + selection: _selection, + animation: _entrance, + fill: cs.primary.withValues(alpha: 0.28), + stem: cs.primary, + ), + child: KeyedSubtree(key: _textKey, child: widget.child), ), - child: KeyedSubtree(key: _textKey, child: widget.child), ); } } class _HighlightPainter extends CustomPainter { - final RenderParagraph? paragraph; + final GlobalKey rootKey; + final List<_ParaSlice> slices; final TextSelection selection; final Animation animation; final Color fill; final Color stem; _HighlightPainter({ - required this.paragraph, + required this.rootKey, + required this.slices, required this.selection, required this.animation, required this.fill, @@ -478,28 +632,36 @@ class _HighlightPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { if (!selection.isValid || selection.isCollapsed) return; - final rp = paragraph; - if (rp == null || !rp.hasSize || !rp.attached) return; - final boxes = rp.getBoxesForSelection(selection); - if (boxes.isEmpty) return; + final root = rootKey.currentContext?.findRenderObject(); + if (root is! RenderBox || !root.attached || !root.hasSize) return; + final rootOrigin = root.localToGlobal(Offset.zero); + + final rects = []; + for (final slice in slices) { + if (!slice.usable) continue; + final local = slice.clip(selection); + if (local == null) continue; + final delta = slice.origin - rootOrigin; + for (final box in slice.rp.getBoxesForSelection(local)) { + rects.add(box.toRect().shift(delta)); + } + } + if (rects.isEmpty) return; final t = animation.value.clamp(0.0, 1.0); final eased = Curves.easeOut.transform(t); final grow = 0.72 + 0.28 * eased; final fillPaint = Paint()..color = fill.withValues(alpha: fill.a * eased); - for (final box in boxes) { - final rect = box.toRect().inflate(0.5); - final cy = rect.center.dy; - final h = rect.height * grow; - final animRect = Rect.fromLTRB( - rect.left, - cy - h / 2, - rect.right, - cy + h / 2, - ); + for (final rect in rects) { + final inflated = rect.inflate(0.5); + final cy = inflated.center.dy; + final h = inflated.height * grow; canvas.drawRRect( - RRect.fromRectAndRadius(animRect, const Radius.circular(3)), + RRect.fromRectAndRadius( + Rect.fromLTRB(inflated.left, cy - h / 2, inflated.right, cy + h / 2), + const Radius.circular(3), + ), fillPaint, ); } @@ -508,8 +670,8 @@ class _HighlightPainter extends CustomPainter { ..color = stem.withValues(alpha: stem.a * eased) ..strokeWidth = 2.5 ..strokeCap = StrokeCap.round; - final first = boxes.first.toRect(); - final last = boxes.last.toRect(); + final first = rects.first; + final last = rects.last; canvas.drawLine( Offset(first.left, first.top), Offset(first.left, first.bottom), @@ -525,7 +687,7 @@ class _HighlightPainter extends CustomPainter { @override bool shouldRepaint(_HighlightPainter old) => old.selection != selection || - old.paragraph != paragraph || + old.slices.length != slices.length || old.fill != fill || old.stem != stem; } diff --git a/lib/frontend/widgets/text_entity_actions.dart b/lib/frontend/widgets/text_entity_actions.dart index d1e9e94..879a044 100644 --- a/lib/frontend/widgets/text_entity_actions.dart +++ b/lib/frontend/widgets/text_entity_actions.dart @@ -37,6 +37,7 @@ void showPhoneEntityMenu( showChatMenu( context: context, anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + compact: true, header: _PhoneOwnerHeader(phone: phone), items: [ ChatMenuItem( @@ -62,6 +63,7 @@ void showCardEntityMenu( showChatMenu( context: context, anchorRect: Rect.fromLTWH(at.dx, at.dy, 0, 0), + compact: true, items: [ ChatMenuItem( icon: Symbols.content_copy, @@ -95,19 +97,19 @@ class _CardFooter extends StatelessWidget { final cs = Theme.of(context).colorScheme; final title = cardBrandTitle(digits); return Padding( - padding: const EdgeInsets.fromLTRB(18, 12, 18, 14), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 11), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( cardMask(digits), - style: TextStyle(color: cs.onSurface, fontSize: 15), + style: TextStyle(color: cs.onSurface, fontSize: 14), ), if (title != null) ...[ const SizedBox(height: 2), Text( title, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), ), ], ], @@ -160,7 +162,7 @@ class _PhoneOwnerHeaderState extends State<_PhoneOwnerHeader> { : _OwnerRow(found: found, phone: widget.phone); } return Padding( - padding: const EdgeInsets.fromLTRB(18, 14, 18, 12), + padding: const EdgeInsets.fromLTRB(14, 11, 14, 10), child: content, ); }, @@ -199,7 +201,7 @@ class _OwnerRow extends StatelessWidget { ), Text( phone, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), ), ], ), diff --git a/test/message_bubble_layout_test.dart b/test/message_bubble_layout_test.dart index 4f2af64..0312369 100644 --- a/test/message_bubble_layout_test.dart +++ b/test/message_bubble_layout_test.dart @@ -8,11 +8,13 @@ import 'package:komet/models/attachment.dart'; const int _me = 1; const int _peer = 7; const double _photoWidth = 180; +const double _maxBubbleWidth = 324; CachedMessage _message({ required String text, bool withReply = false, int senderId = _peer, + String replyText = 'Пётр Синицын написал очень длинный ответ', }) => CachedMessage( id: '1', accountId: _me, @@ -28,7 +30,7 @@ CachedMessage _message({ 'message': { 'id': '9', 'sender': _me, - 'text': 'Пётр Синицын написал очень длинный ответ', + 'text': replyText, 'time': 0, 'attaches': [], }, @@ -111,13 +113,17 @@ Future _pumpColumn( Rect _bubbleRect(WidgetTester tester, int index, String text) { final label = find.descendant( of: find.byKey(ValueKey('bubble$index')), - matching: find.text(text), + matching: find.textContaining(text, findRichText: true), ); final box = find.ancestor(of: label, matching: find.byType(Container)).first; return tester.getTopLeft(box) & tester.getSize(box); } -Future _pumpBubble(WidgetTester tester, CachedMessage message) async { +Future _pumpBubble( + WidgetTester tester, + CachedMessage message, { + String chatType = 'CHAT', +}) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2.5; addTearDown(tester.view.reset); @@ -134,7 +140,7 @@ Future _pumpBubble(WidgetTester tester, CachedMessage message) async { message: message, isMe: false, myId: _me, - chatType: 'CHAT', + chatType: chatType, ), ), ), @@ -149,6 +155,16 @@ Rect _rectOf(WidgetTester tester, Finder finder) { return topLeft & size; } +Rect _clockRect(WidgetTester tester) { + var rect = Rect.zero; + for (final element in find.textContaining('05:46').evaluate()) { + final box = element.renderObject! as RenderBox; + final candidate = box.localToGlobal(Offset.zero) & box.size; + if (candidate.right > rect.right) rect = candidate; + } + return rect; +} + void main() { setUp(() => ContactCache.put(_peer, 'Пётр Синицын')); @@ -158,17 +174,23 @@ void main() { await _pumpBubble(tester, _message(text: 'нет')); final header = _rectOf(tester, find.text('Пётр Синицын')); - final clock = _rectOf(tester, find.textContaining('05:46')); - final body = _rectOf(tester, find.text('нет')); + final clock = _clockRect(tester); + final body = _rectOf( + tester, + find.textContaining('нет', findRichText: true), + ); - expect(header.width, greaterThan(body.width + clock.width)); + expect(header.width, greaterThan(body.width)); expect(clock.right, closeTo(header.right, 1)); }); - testWidgets('the reply quote fills the width the sender name opened up', ( + testWidgets('a short reply quote fills the width the sender name opened up', ( tester, ) async { - await _pumpBubble(tester, _message(text: 'нет', withReply: true)); + await _pumpBubble( + tester, + _message(text: 'нет', withReply: true, replyText: 'ок'), + ); final header = _rectOf(tester, find.text('Пётр Синицын')); final label = _rectOf(tester, find.text('Вы')); @@ -178,13 +200,36 @@ void main() { .ancestor(of: find.text('Вы'), matching: find.byType(Container)) .first, ); - final clock = _rectOf(tester, find.textContaining('05:46')); + final clock = _clockRect(tester); expect(quote.right, greaterThan(label.right)); expect(quote.right, closeTo(header.right, 1)); expect(clock.right, closeTo(header.right, 1)); }); + testWidgets('a long reply quote widens the bubble past its own text', ( + tester, + ) async { + await _pumpBubble(tester, _message(text: 'т')); + final withoutReply = _clockRect(tester).right; + + await _pumpBubble(tester, _message(text: 'т', withReply: true)); + + final header = _rectOf(tester, find.text('Пётр Синицын')); + final quote = _rectOf( + tester, + find + .ancestor(of: find.text('Вы'), matching: find.byType(Container)) + .first, + ); + final clock = _clockRect(tester); + + expect(clock.right, greaterThan(withoutReply + 40)); + expect(quote.right, greaterThan(header.right)); + expect(clock.right, closeTo(quote.right, 1)); + expect(quote.width, lessThanOrEqualTo(_maxBubbleWidth * 0.75 + 1)); + }); + testWidgets('grouped bubbles keep the same gap with and without avatars', ( tester, ) async { @@ -226,7 +271,10 @@ void main() { .ancestor(of: find.text('Вы'), matching: find.byType(Container)) .first, ); - final caption = _rectOf(tester, find.text('Вот те раз, не может быть')); + final caption = _rectOf( + tester, + find.textContaining('Вот те раз', findRichText: true), + ); expect(quote.width, closeTo(_photoWidth - 16, 1)); expect(quote.left, greaterThan(0)); @@ -238,9 +286,29 @@ void main() { ) async { await _pumpBubble(tester, _message(text: 'нет', senderId: 404)); - final clock = _rectOf(tester, find.textContaining('05:46')); - final body = _rectOf(tester, find.text('нет')); + final clock = _clockRect(tester); + final body = _rectOf( + tester, + find.textContaining('нет', findRichText: true), + ); expect(clock.left, closeTo(body.right + 8, 1)); + expect(clock.center.dy, closeTo(body.center.dy, 4)); + }); + + testWidgets('the clock drops below wrapped text instead of widening it', ( + tester, + ) async { + await _pumpBubble( + tester, + _message(text: '${'ф' * 14}\n${'ф' * 14}', senderId: 404), + chatType: 'DIALOG', + ); + + final clock = _clockRect(tester); + final body = _rectOf(tester, find.textContaining('ф', findRichText: true)); + + expect(clock.top, greaterThanOrEqualTo(body.bottom - 2)); + expect(clock.right, lessThanOrEqualTo(body.right + 1)); }); }