diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 51a8af0..0912ca9 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -469,6 +469,8 @@ class _ChatScreenState extends State final ValueNotifier _animojiHold = ValueNotifier(true); final ValueNotifier> _selectedIds = ValueNotifier(const {}); + final ValueNotifier<({String id, Offset pos})?> _textSelection = + ValueNotifier(null); late final AnimationController _selectionAnim; bool get _selectionMode => _selectedIds.value.isNotEmpty; @@ -503,6 +505,7 @@ class _ChatScreenState extends State _scrollController.addListener(_maybeLoadMoreHistory); _scrollController.addListener(_recordScrollPixels); _scrollController.addListener(_scheduleReadMarker); + _scrollController.addListener(_exitTextSelectionOnScroll); AppVisualStyle.current.addListener(_onVisualStyleChanged); AppChatChrome.current.addListener(_onVisualStyleChanged); _shimmerController = AnimationController( @@ -1322,6 +1325,7 @@ class _ChatScreenState extends State _scrollController.removeListener(_maybeLoadMoreHistory); _scrollController.removeListener(_recordScrollPixels); _scrollController.removeListener(_scheduleReadMarker); + _scrollController.removeListener(_exitTextSelectionOnScroll); _readMarkTimer?.cancel(); AppVisualStyle.current.removeListener(_onVisualStyleChanged); AppChatChrome.current.removeListener(_onVisualStyleChanged); @@ -1370,6 +1374,7 @@ class _ChatScreenState extends State _searchFocusNode.dispose(); _search.dispose(); _selectedIds.dispose(); + _textSelection.dispose(); _messageController.dispose(); _messageFocusNode.dispose(); _stickers.dispose(); @@ -1512,15 +1517,38 @@ class _ChatScreenState extends State if (!next.remove(message.id)) next.add(message.id); Haptics.selection(); _selectedIds.value = next; + if (!next.contains(message.id)) _exitTextSelection(message.id); _syncSelectionAnim(); } void _clearSelection() { + _exitTextSelection(); if (_selectedIds.value.isEmpty) return; _selectedIds.value = const {}; _syncSelectionAnim(); } + void _startTextSelection(CachedMessage message, Offset globalPosition) { + if (message.isControl || (message.text?.isEmpty ?? true)) return; + _textSelection.value = (id: message.id, pos: globalPosition); + } + + void _exitTextSelection([String? onlyId]) { + final current = _textSelection.value; + if (current == null) return; + if (onlyId != null && current.id != onlyId) return; + _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(); @@ -3964,13 +3992,21 @@ class _ChatScreenState extends State ? math.max(mq.viewInsets.bottom, _keyboardReserve) : mq.viewInsets.bottom; return ListenableBuilder( - listenable: Listenable.merge([_selectedIds, _search.searchMode]), + listenable: Listenable.merge([ + _selectedIds, + _search.searchMode, + _textSelection, + ]), builder: (context, child) => PopScope( - canPop: _selectedIds.value.isEmpty && !_search.searchMode.value, + canPop: _selectedIds.value.isEmpty && + !_search.searchMode.value && + _textSelection.value == null, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (_search.searchMode.value) { _closeSearch(); + } else if (_textSelection.value != null) { + _exitTextSelection(); } else { _clearSelection(); } @@ -4338,6 +4374,8 @@ class _ChatScreenState extends State : (emoji) => _reactToMessage(message, emoji), peerName: widget.name, peerAvatarUrl: widget.imageUrl, + textSelection: _textSelection, + onExitTextSelection: _exitTextSelection, ); final canReport = !isMe && !message.isControl; @@ -4353,6 +4391,8 @@ class _ChatScreenState extends State isSelectionActive: () => _selectionMode, onToggleSelection: () => _toggleSelection(message), onEnterSelection: () => _enterSelection(message), + onStartTextSelection: (pos) => + _startTextSelection(message, pos), onDelete: () => _confirmDeleteMessage(message, isMe), onEdit: _canEditMessage(message) ? () => _startEditMessage(message) @@ -5664,6 +5704,7 @@ class _SelectableMessageRow extends StatefulWidget { final bool Function() isSelectionActive; final VoidCallback onToggleSelection; final VoidCallback onEnterSelection; + final void Function(Offset globalPosition) onStartTextSelection; final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; @@ -5685,6 +5726,7 @@ class _SelectableMessageRow extends StatefulWidget { required this.isSelectionActive, required this.onToggleSelection, required this.onEnterSelection, + required this.onStartTextSelection, required this.onDelete, this.onEdit, this.onReply, @@ -5847,11 +5889,17 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { }); } - void _handleLongPress() { - if (widget.isSelectionActive()) { - widget.onToggleSelection(); - } else { + void _handleLongPressStart(Offset globalPosition) { + if (!widget.isSelectionActive()) { widget.onEnterSelection(); + return; + } + final selected = widget.selectedIds.value.contains(widget.message.id); + final hasText = widget.message.text?.isNotEmpty ?? false; + if (selected && hasText && !widget.message.isControl) { + widget.onStartTextSelection(globalPosition); + } else { + widget.onToggleSelection(); } } @@ -5896,7 +5944,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { behavior: HitTestBehavior.opaque, onTapDown: (d) => _lastTapDown = d.globalPosition, onTap: _handleTap, - onLongPress: _handleLongPress, + onLongPressStart: (d) => _handleLongPressStart(d.globalPosition), onSecondaryTapDown: active ? null : _onSecondaryTapDown, child: ColoredBox( color: isSelected diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 16ba6f2..81bcf0f 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -18,6 +18,7 @@ import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; +import 'selectable_message_text.dart'; import '../../models/attachment.dart'; import '../../models/reaction_info.dart'; import 'attachment/bubbles/voice_bubble.dart'; @@ -134,6 +135,8 @@ class MessageBubble extends StatelessWidget { final void Function(String emoji)? onReactionTap; final String? peerName; final String? peerAvatarUrl; + final ValueListenable<({String id, Offset pos})?>? textSelection; + final VoidCallback? onExitTextSelection; const MessageBubble({ super.key, @@ -153,6 +156,8 @@ class MessageBubble extends StatelessWidget { this.onReactionTap, this.peerName, this.peerAvatarUrl, + this.textSelection, + this.onExitTextSelection, }); bool _computeHasPhotoWithCaption() { @@ -1082,6 +1087,25 @@ class MessageBubble extends StatelessWidget { ); } + 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) { final attachments = message.attachments; final isForwardedContact = @@ -1102,7 +1126,7 @@ class MessageBubble extends StatelessWidget { final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3); final ranges = message.formatRanges; - final textWidget = isForwarded + final baseTextWidget = isForwarded ? _buildForwardedInlineText(ctx, forwarded) : (FormattedMessageText.isFormatted(message.text, ranges) ? FormattedMessageText( @@ -1111,6 +1135,7 @@ class MessageBubble extends StatelessWidget { style: textStyle, ) : Text(message.text ?? '', style: textStyle)); + final textWidget = _wrapSelectable(baseTextWidget); final metaWidget = Text( message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText, diff --git a/lib/frontend/widgets/selectable_message_text.dart b/lib/frontend/widgets/selectable_message_text.dart new file mode 100644 index 0000000..8971ad2 --- /dev/null +++ b/lib/frontend/widgets/selectable_message_text.dart @@ -0,0 +1,482 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +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; +} + +bool _isSpace(int c) => + c == 0x20 || + c == 0x09 || + c == 0x0A || + c == 0x0D || + c == 0x0C || + c == 0xA0; + +class SelectableMessageText extends StatefulWidget { + final Widget child; + final Offset initialGlobalPosition; + final VoidCallback onExit; + + const SelectableMessageText({ + super.key, + required this.child, + required this.initialGlobalPosition, + required this.onExit, + }); + + @override + State createState() => _SelectableMessageTextState(); +} + +class _SelectableMessageTextState extends State + with SingleTickerProviderStateMixin { + static const double _ballRadius = 8.0; + static const double _hitSize = 44.0; + + final GlobalKey _textKey = GlobalKey(); + 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; + + RenderParagraph? _cachedParagraph; + + RenderParagraph? get _paragraph { + final cached = _cachedParagraph; + if (cached != null && cached.attached) return cached; + return _cachedParagraph = _findParagraph( + _textKey.currentContext?.findRenderObject(), + ); + } + + String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText(); + + @override + void initState() { + super.initState(); + _entrance = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + )..addListener(() => _overlay?.markNeedsBuild()); + WidgetsBinding.instance.addPostFrameCallback((_) => _init(4)); + } + + @override + void dispose() { + _settle?.cancel(); + _entrance.dispose(); + _overlay?.remove(); + _overlay = null; + _toolbarVisible.dispose(); + super.dispose(); + } + + void _init(int retries) { + if (!mounted) return; + final rp = _paragraph; + if (rp == null || !rp.hasSize) { + if (retries > 0) { + WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1)); + } else { + _requestExit(); + } + return; + } + _selectWordAt(widget.initialGlobalPosition, rp); + Haptics.selection(); + _ensureOverlay(); + _toolbarVisible.value = true; + } + + void _ensureOverlay() { + if (_overlay != null || !mounted) return; + _overlay = OverlayEntry(builder: _buildOverlay); + Overlay.of(context, rootOverlay: true).insert(_overlay!); + } + + void _requestExit() { + if (_exiting) return; + _exiting = true; + _settle?.cancel(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onExit(); + }); + } + + 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; + if (animate) _entrance.forward(from: 0); + if (mounted) setState(() {}); + _overlay?.markNeedsBuild(); + } + + TextRange _wordRange(RenderParagraph rp, Offset globalPos) { + final text = _text(rp); + 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); + 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); + var s = off; + var e = off; + while (s > 0 && !_isSpace(text.codeUnitAt(s - 1))) { + s--; + } + while (e < len && !_isSpace(text.codeUnitAt(e))) { + e++; + } + return TextRange(start: s, end: e); + } + + void _selectWordAt(Offset globalPos, RenderParagraph rp) { + final range = _wordRange(rp, globalPos); + if (range.isCollapsed) { + _applySelection(_normalize(0, _text(rp).length), animate: true); + } else { + _applySelection(_normalize(range.start, range.end), animate: true); + } + } + + void _onBackgroundTap(Offset globalPos) { + final rp = _paragraph; + if (rp == null || !rp.hasSize) { + _requestExit(); + return; + } + final local = rp.globalToLocal(globalPos); + if (rp.size.contains(local)) { + _dragging = false; + _settle?.cancel(); + _selectWordAt(globalPos, rp); + _toolbarVisible.value = true; + } else { + _requestExit(); + } + } + + void _onHandleDragStart() { + _dragging = true; + _settle?.cancel(); + _entrance.value = 1.0; + _toolbarVisible.value = false; + } + + void _onHandleDrag(Offset globalPos, bool isStart) { + final rp = _paragraph; + if (rp == null || !rp.hasSize) return; + final len = _text(rp).length; + final off = rp.getPositionForOffset(rp.globalToLocal(globalPos)).offset; + if (isStart) { + final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt(); + _applySelection( + TextSelection(baseOffset: ns, extentOffset: _selection.end), + ); + } else { + final ne = off.clamp(math.min(_selection.start + 1, len), len).toInt(); + _applySelection( + TextSelection(baseOffset: _selection.start, extentOffset: ne), + ); + } + } + + void _onHandleDragEnd() { + _dragging = false; + _settle?.cancel(); + _settle = Timer(const Duration(milliseconds: 140), () { + if (!mounted || _dragging) return; + _overlay?.markNeedsBuild(); + _toolbarVisible.value = true; + }); + } + + void _copy() { + final rp = _paragraph; + if (rp != null && _selection.isValid && !_selection.isCollapsed) { + final text = _text(rp); + final sub = text.substring( + _selection.start.clamp(0, text.length), + _selection.end.clamp(0, text.length), + ); + if (sub.isNotEmpty) { + Clipboard.setData(ClipboardData(text: sub)); + Haptics.tap(); + showCustomNotification( + context, + AppLocalizations.of(context)!.msgActionsCopied, + ); + } + } + _requestExit(); + } + + void _selectAll() { + final rp = _paragraph; + if (rp == null) return; + Haptics.tap(); + _applySelection(_normalize(0, _text(rp).length), animate: true); + _toolbarVisible.value = true; + } + + Widget _buildOverlay(BuildContext ctx) { + final rp = _paragraph; + if (rp == null || !rp.hasSize || !rp.attached) { + return const SizedBox.shrink(); + } + final cs = Theme.of(ctx).colorScheme; + + final List boxes = + (_selection.isValid && !_selection.isCollapsed) + ? rp.getBoxesForSelection(_selection) + : const []; + + final children = [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + 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)); + + children.add(_handle(cs, startBottom, isStart: true)); + children.add(_handle(cs, endBottom, isStart: false)); + children.add(_toolbar(ctx, rp, first, last)); + } + + return Stack(children: children); + } + + Widget _handle(ColorScheme cs, Offset lineBottomGlobal, {required bool isStart}) { + final center = Offset( + lineBottomGlobal.dx, + lineBottomGlobal.dy + _ballRadius, + ); + return Positioned( + left: center.dx - _hitSize / 2, + top: center.dy - _hitSize / 2, + width: _hitSize, + height: _hitSize, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => _onHandleDragStart(), + onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart), + onPanEnd: (_) => _onHandleDragEnd(), + onPanCancel: _onHandleDragEnd, + child: Center( + 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), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) { + final media = MediaQuery.of(ctx); + final size = media.size; + 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; + 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)), + ), + ), + ), + ); + } + + Widget _pill(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final l10n = AppLocalizations.of(ctx)!; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _toolbarButton(cs, l10n.msgActionsCopy, _copy), + Container( + width: 1, + height: 24, + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + _toolbarButton(cs, l10n.msgActionsSelectAll, _selectAll), + ], + ), + ); + } + + Widget _toolbarButton(ColorScheme cs, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 13), + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + @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, + ), + child: KeyedSubtree(key: _textKey, child: widget.child), + ); + } +} + +class _HighlightPainter extends CustomPainter { + final RenderParagraph? paragraph; + final TextSelection selection; + final Animation animation; + final Color fill; + final Color stem; + + _HighlightPainter({ + required this.paragraph, + required this.selection, + required this.animation, + required this.fill, + required this.stem, + }) : super(repaint: animation); + + @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 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, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(animRect, const Radius.circular(3)), + fillPaint, + ); + } + + final stemPaint = Paint() + ..color = stem.withValues(alpha: stem.a * eased) + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round; + final first = boxes.first.toRect(); + final last = boxes.last.toRect(); + canvas.drawLine( + Offset(first.left, first.top), + Offset(first.left, first.bottom), + stemPaint, + ); + canvas.drawLine( + Offset(last.right, last.top), + Offset(last.right, last.bottom), + stemPaint, + ); + } + + @override + bool shouldRepaint(_HighlightPainter old) => + old.selection != selection || + old.paragraph != paragraph || + old.fill != fill || + old.stem != stem; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cc4c732..ec0de00 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -183,6 +183,7 @@ "registrationSubtitle": "Add your name and pick an avatar", "registrationChooseAvatar": "Choose an avatar", "msgActionsCopy": "Copy", + "msgActionsSelectAll": "Select all", "emojiSearchHint": "Search emoji", "msgActionsEdit": "Edit", "msgActionsReply": "Reply", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 683fd31..9a0c6c5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1094,6 +1094,12 @@ abstract class AppLocalizations { /// **'Copy'** String get msgActionsCopy; + /// No description provided for @msgActionsSelectAll. + /// + /// In en, this message translates to: + /// **'Select all'** + String get msgActionsSelectAll; + /// No description provided for @emojiSearchHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d575ca9..231fbdf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -524,6 +524,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsCopy => 'Copy'; + @override + String get msgActionsSelectAll => 'Select all'; + @override String get emojiSearchHint => 'Search emoji'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 2b72e9a..81651aa 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -527,6 +527,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsCopy => 'Копировать'; + @override + String get msgActionsSelectAll => 'Выбрать всё'; + @override String get emojiSearchHint => 'Поиск эмодзи'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2769a06..dba03dd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -183,6 +183,7 @@ "registrationSubtitle": "Укажите имя и выберите аватар", "registrationChooseAvatar": "Выберите аватар", "msgActionsCopy": "Копировать", + "msgActionsSelectAll": "Выбрать всё", "emojiSearchHint": "Поиск эмодзи", "msgActionsEdit": "Изменить", "msgActionsReply": "Ответить",