diff --git a/lib/frontend/screens/chats/chat/view/selection_bar.dart b/lib/frontend/screens/chats/chat/view/selection_bar.dart index 913064f..882c2f8 100644 --- a/lib/frontend/screens/chats/chat/view/selection_bar.dart +++ b/lib/frontend/screens/chats/chat/view/selection_bar.dart @@ -8,10 +8,10 @@ class SelectionTopBar extends StatelessWidget { final ColorScheme cs; final Set selected; final bool glossy; - final CachedMessage? copyMsg; + final List copyMsgs; final CachedMessage? editMsg; final VoidCallback onClear; - final void Function(CachedMessage) onCopy; + final void Function(List) onCopy; final void Function(CachedMessage) onEdit; final VoidCallback onDelete; @@ -20,7 +20,7 @@ class SelectionTopBar extends StatelessWidget { required this.cs, required this.selected, required this.glossy, - required this.copyMsg, + required this.copyMsgs, required this.editMsg, required this.onClear, required this.onCopy, @@ -56,10 +56,10 @@ class SelectionTopBar extends StatelessWidget { ), ), ), - if (copyMsg != null) + if (copyMsgs.isNotEmpty) IconButton( icon: Icon(Symbols.content_copy, color: cs.onSurface), - onPressed: () => onCopy(copyMsg!), + onPressed: () => onCopy(copyMsgs), ), if (editMsg != null) IconButton( @@ -130,8 +130,8 @@ class SelectionTopBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (copyMsg != null) - actionBtn(Symbols.content_copy, () => onCopy(copyMsg!)), + if (copyMsgs.isNotEmpty) + actionBtn(Symbols.content_copy, () => onCopy(copyMsgs)), if (editMsg != null) actionBtn(Symbols.edit, () => onEdit(editMsg!)), actionBtn(Symbols.delete, onDelete), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5bf178b..0f46899 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -2383,17 +2383,10 @@ class _ChatScreenState extends State List _selectedMessages(Set ids) => _messages.where((m) => ids.contains(m.id)).toList(); - CachedMessage? _singleCopyableText(Set ids) { - CachedMessage? found; - var textCount = 0; - for (final m in _messages) { - if (!ids.contains(m.id)) continue; - if ((m.text ?? '').isEmpty) continue; - if (++textCount > 1) return null; - found = m; - } - return found; - } + List _copyableSelection(Set ids) => [ + for (final m in _messages) + if (ids.contains(m.id) && (m.selectableText ?? '').isNotEmpty) m, + ]; CachedMessage? _singleEditable(Set ids) { if (ids.length != 1) return null; @@ -2402,9 +2395,9 @@ class _ChatScreenState extends State return _canEditMessage(list.first) ? list.first : null; } - void _copySelected(CachedMessage message) { - final text = message.text; - if (text == null || text.isEmpty) return; + void _copySelected(List messages) { + if (messages.isEmpty) return; + final text = messages.map((m) => m.selectableText!).join('\n\n'); Clipboard.setData(ClipboardData(text: text)); Haptics.tap(); showCustomNotification(context, 'Скопировано'); @@ -3223,7 +3216,7 @@ class _ChatScreenState extends State cs: cs, selected: selected, glossy: glossy, - copyMsg: _singleCopyableText(selected), + copyMsgs: _copyableSelection(selected), editMsg: _singleEditable(selected), onClear: _clearSelection, onCopy: _copySelected, @@ -6330,11 +6323,9 @@ class _ChatScreenState extends State if (info.durationMs > 0) durationMs = info.durationMs; if (info.width > 0 && info.height > 0) dims = (info.width, info.height); } - final frames = await VideoTranscoder.frames( - edited.path, - const [0], - size: 512, - ); + final frames = await VideoTranscoder.frames(edited.path, const [ + 0, + ], size: 512); if (frames.isNotEmpty) thumbBytes = frames.first; } if (durationMs == null && DesktopVideoProbe.supported) { @@ -7212,6 +7203,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { tapPoint: _lastTapDown ?? rect.center, isMe: widget.isMe, messageText: widget.message.text, + copyText: widget.message.selectableText, controller: controller, style: AppMessageActionsStyle.current.value, interaction: MessageActionsInteraction.tap, @@ -7281,6 +7273,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { tapPoint: details.globalPosition, isMe: widget.isMe, messageText: widget.message.text, + copyText: widget.message.selectableText, controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 363ff7d..07a1728 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -100,6 +100,7 @@ void showMessageActions({ required Offset tapPoint, required bool isMe, required String? messageText, + required String? copyText, required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, @@ -140,6 +141,7 @@ void showMessageActions({ tapPoint: tapPoint, isMe: isMe, messageText: messageText, + copyText: copyText, controller: controller, style: style, interaction: interaction, @@ -176,6 +178,7 @@ class _MessageActionsLayer extends StatefulWidget { final Offset tapPoint; final bool isMe; final String? messageText; + final String? copyText; final MessageActionsController controller; final MessageActionsStyle style; final MessageActionsInteraction interaction; @@ -205,6 +208,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.tapPoint, required this.isMe, required this.messageText, + required this.copyText, required this.controller, required this.style, required this.interaction, @@ -496,15 +500,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> List<_Action> _buildActions() { final l10n = AppLocalizations.of(context)!; - final hasText = - widget.messageText != null && widget.messageText!.isNotEmpty; + final copyText = widget.copyText; + final canCopy = widget.allowCopy && copyText != null && copyText.isNotEmpty; return <_Action>[ if (widget.onReply != null) _Action(Symbols.reply, l10n.msgActionsReply, _reply), if (widget.onForward != null) _Action(Symbols.forward, l10n.msgActionsForward, _forward), - if (hasText && widget.allowCopy) - _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), + if (canCopy) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), if (widget.isMe && widget.onEdit != null) _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onPin != null) @@ -642,7 +645,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } Future _copy() async { - final text = widget.messageText; + final text = widget.copyText; if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; diff --git a/test/selection_copy_test.dart b/test/selection_copy_test.dart new file mode 100644 index 0000000..3439cc9 --- /dev/null +++ b/test/selection_copy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/screens/chats/chat/view/selection_bar.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const int _me = 1; +const int _chatId = 2; + +CachedMessage _text(String id, String? text) => CachedMessage( + id: id, + accountId: _me, + chatId: _chatId, + senderId: _me, + text: text, + time: DateTime(2026, 1, 1, 12).millisecondsSinceEpoch, +); + +CachedMessage _forwarded(String id, String originalText) => + CachedMessage.fromPushPayload(_me, _chatId, { + 'id': id, + 'time': DateTime(2026, 1, 1, 12).millisecondsSinceEpoch, + 'type': 'USER', + 'sender': _me, + 'link': { + 'type': 'FORWARD', + 'message': { + 'id': '900', + 'time': 1000, + 'type': 'USER', + 'sender': 5, + 'text': originalText, + 'attaches': const [], + }, + 'chatId': -30, + 'chatName': 'Synthetic Channel', + }, + }); + +Future?> _tapCopy( + WidgetTester tester, + List copyMsgs, { + required bool glossy, +}) async { + List? copied; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SelectionTopBar( + cs: ThemeData.light().colorScheme, + selected: copyMsgs.map((m) => m.id).toSet(), + glossy: glossy, + copyMsgs: copyMsgs, + editMsg: null, + onClear: () {}, + onCopy: (msgs) => copied = msgs, + onEdit: (_) {}, + onDelete: () {}, + ), + ), + ), + ); + final copyButton = find.widgetWithIcon(IconButton, Symbols.content_copy); + if (copyButton.evaluate().isEmpty) return null; + await tester.tap(copyButton); + await tester.pump(); + return copied; +} + +void main() { + testWidgets('copy stays available for several selected messages', ( + tester, + ) async { + final msgs = [_text('1', 'первое'), _text('2', 'второе')]; + for (final glossy in [false, true]) { + expect(await _tapCopy(tester, msgs, glossy: glossy), msgs); + } + }); + + testWidgets('copy is hidden when nothing carries text', (tester) async { + expect(await _tapCopy(tester, const [], glossy: false), isNull); + }); + + testWidgets('a forwarded message exposes its original text', (tester) async { + final forwarded = _forwarded('3', 'исходный текст'); + expect(forwarded.text, isNull); + expect(forwarded.selectableText, 'исходный текст'); + expect(await _tapCopy(tester, [forwarded], glossy: false), [forwarded]); + }); +}