From 10d071bdfffda61dead2e6c144a502f8af83ea69 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 13 Jul 2026 15:02:08 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D1=82=D0=BE=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=BB=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 12 ++ lib/backend/modules/messages.dart | 36 ++++ lib/frontend/screens/chats/chat_screen.dart | 185 +++++++++++++----- .../widgets/message_actions_overlay.dart | 169 +++++++++++++++- lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_ru.dart | 9 + lib/l10n/app_ru.arb | 3 + 9 files changed, 384 insertions(+), 60 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index d071013..56136ae 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1275,6 +1275,18 @@ class ChatsModule { return Map.from(chats.first as Map); } + Future> getReadMarks(Api api, int accountId, int chatId) async { + try { + final info = await getChatInfo(api, chatId); + final fresh = parseParticipants(info?['participants']); + if (fresh.isNotEmpty) return fresh; + } catch (e) { + logger.w('Не удалось получить отметки прочтения для $chatId: $e'); + } + final rows = await getChat(accountId, chatId); + return rows.isEmpty ? const {} : rows.first.participants; + } + Future searchById(Api api, int userId) async { final packet = await api.sendRequest(Opcode.publicSearch, { 'query': userId.toString(), diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6ef434b..3be215c 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1085,6 +1085,42 @@ class MessagesModule { return _applyReactionResponse(chatId, messageId, response); } + Future> getDetailedReactions( + int chatId, + String messageId, { + int count = 100, + }) async { + final id = int.tryParse(messageId); + if (id == null) return const {}; + if (_api.state != SessionState.online) return const {}; + try { + final response = await _api.sendRequest(Opcode.msgGetDetailedReactions, { + 'chatId': chatId, + 'messageId': id, + 'count': count, + }); + if (!response.isOk) return const {}; + final payload = response.payload; + if (payload is! Map) return const {}; + return _parseDetailedReactions(payload['reactions']); + } catch (e) { + logger.e('getDetailedReactions error: $e'); + return const {}; + } + } + + static Map _parseDetailedReactions(dynamic raw) { + if (raw is! List) return const {}; + final result = {}; + for (final entry in raw.whereType()) { + final userId = entry['userId']; + final reaction = entry['reaction']; + if (userId is! int || reaction is! String || reaction.isEmpty) continue; + result[userId] = reaction; + } + return result; + } + Future<({bool ok, Map? info})> _applyReactionResponse( int chatId, String messageId, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 0912ca9..7890b13 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -223,7 +223,8 @@ class _ChatScreenState extends State bool _initialPositionDone = false; bool _positioningInFlight = false; bool _initialTargetHandled = false; - bool _suppressHistoryAutoload = false; + int _historyAutoloadSuppressCount = 0; + bool get _historyAutoloadSuppressed => _historyAutoloadSuppressCount > 0; int _readMarkTime = 0; Timer? _readMarkTimer; final GlobalKey _listKey = GlobalKey(); @@ -435,6 +436,7 @@ class _ChatScreenState extends State static const double _historyPrefetchExtent = _avgMessageHeight * 8; static const double _glossyHeaderHeight = 76.0; static const double _glossySearchHeight = 58.0; + static const double _pinnedBannerLift = 6.0; bool get _isLoadingMore => _chatController.isLoadingMore; set _isLoadingMore(bool v) => _chatController.isLoadingMore = v; bool get _hasMoreHistory => _chatController.hasMoreHistory; @@ -890,18 +892,14 @@ class _ChatScreenState extends State } Future _loadUntilUnreadReady() async { - var guard = 0; - while (mounted && guard < 80 && _hasMoreHistory) { - if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); - final ua = _unreadAnchorTime; - if (ua != null && _messages.indexWhere((m) => m.time > ua) > 0) break; - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } + await _walkHistoryBack( + reached: () { + if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); + final ua = _unreadAnchorTime; + return ua != null && _messages.indexWhere((m) => m.time > ua) > 0; + }, + maxPages: 80, + ); if (!mounted) return; if (_unreadAnchorTime == null) _resolveCountBasedAnchor(); final ua = _unreadAnchorTime; @@ -1014,6 +1012,67 @@ class _ChatScreenState extends State Navigator.of(context).pop(); } + bool _canShowReadBy(CachedMessage message) { + if (message.isControl || message.deleted) return false; + if (int.tryParse(message.id) == null) return false; + final type = chat?.type ?? widget.chatType; + return type == 'CHAT' || type == 'GROUP'; + } + + Future> _loadReadBy(CachedMessage message) async { + final marks = await chats.getReadMarks(api, _myId, widget.chatId); + final reactions = await messagesModule.getDetailedReactions( + widget.chatId, + message.id, + ); + + final readerIds = { + ...marks.entries.where((e) => e.value >= message.time).map((e) => e.key), + ...reactions.keys, + }..removeAll({_myId, message.senderId}); + if (readerIds.isEmpty || !mounted) return const []; + + await messagesModule.ensureContactNames(readerIds); + await animojiModule.ensureLoaded(); + if (!mounted) return const []; + + final animojiByEmoji = { + for (final animoji in animojiModule.animojis) + EmojiKeywordIndex.normalize(animoji.emoji): animoji, + }; + final unknownName = AppLocalizations.of( + context, + )!.msgActionsReadByUnknownUser; + + final readers = readerIds.map((id) { + final emoji = reactions[id]; + final animoji = emoji == null + ? null + : animojiByEmoji[EmojiKeywordIndex.normalize(emoji)]; + final name = ContactCache.get(id); + return MessageReader( + id: id, + name: name == null || name.isEmpty ? unknownName : name, + avatarUrl: ContactCache.getAvatar(id), + reaction: emoji == null + ? null + : ReactionEmoji( + emoji: emoji, + animationUrl: animoji?.lottieUrl, + staticUrl: animoji?.iconUrl, + ), + ); + }).toList(); + + readers.sort((a, b) { + final aReacted = a.reaction != null; + final bReacted = b.reaction != null; + if (aReacted != bReacted) return aReacted ? -1 : 1; + return (marks[b.id] ?? 0).compareTo(marks[a.id] ?? 0); + }); + return readers; + } + bool _canPinMessage(CachedMessage message) { if (message.isControl) return false; if (int.tryParse(message.id) == null) return false; @@ -1109,19 +1168,11 @@ class _ChatScreenState extends State Future _openPinnedMessage(String messageId, int time) async { if (!_messages.any((m) => m.id == messageId)) { - var guard = 0; - while (mounted && - guard < 60 && - _hasMoreHistory && - !_messages.any((m) => m.id == messageId) && - (_messages.isEmpty || _messages.first.time > time)) { - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } + await _walkHistoryBack( + reached: () => _messages.any((m) => m.id == messageId), + maxPages: 60, + targetTime: time, + ); if (!mounted) return; await WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -1217,7 +1268,7 @@ class _ChatScreenState extends State void _maybeLoadMoreHistory() { if (!_scrollController.hasClients) return; - if (_suppressHistoryAutoload) return; + if (_historyAutoloadSuppressed) return; if (_isLoading || _isLoadingMore || !_hasMoreHistory) return; if (_messages.isEmpty) return; final pos = _scrollController.position; @@ -1227,14 +1278,45 @@ class _ChatScreenState extends State } } - Future _loadMoreHistory() async { + Future _walkHistoryBack({ + required bool Function() reached, + required int maxPages, + int targetTime = 0, + }) async { + if (reached()) return; + _historyAutoloadSuppressCount++; + try { + var page = 0; + while (mounted && + page < maxPages && + _hasMoreHistory && + !reached() && + (_messages.isEmpty || _messages.first.time > targetTime)) { + page++; + final before = _messages.isEmpty ? 0 : _messages.first.time; + await _loadMoreHistory(resolveSenderNames: false); + if (!mounted) return; + final after = _messages.isEmpty ? 0 : _messages.first.time; + if (after == before) break; + } + } finally { + _historyAutoloadSuppressCount--; + } + if (!mounted) return; + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } + + Future _loadMoreHistory({bool resolveSenderNames = true}) async { await _chatController.loadMoreHistory( onLoadingStarted: _bumpMessages, onLoaded: (added) { if (added > 0) _syncReactionNotifiersFromMessages(); _bumpMessages(); - _loadForwardedSenderNames(); - _loadGroupSenderNames(); + if (resolveSenderNames) { + _loadForwardedSenderNames(); + _loadGroupSenderNames(); + } }, onError: (_) { if (mounted) { @@ -3488,19 +3570,11 @@ class _ChatScreenState extends State if (!mounted) return; if (!_messages.any((m) => m.id == id)) { - var guard = 0; - while (mounted && - guard < 80 && - _hasMoreHistory && - !_messages.any((m) => m.id == id) && - (_messages.isEmpty || _messages.first.time > targetTime)) { - guard++; - final before = _messages.isEmpty ? 0 : _messages.first.time; - await _loadMoreHistory(); - if (!mounted) return; - final after = _messages.isEmpty ? 0 : _messages.first.time; - if (after == before) break; - } + await _walkHistoryBack( + reached: () => _messages.any((m) => m.id == id), + maxPages: 80, + targetTime: targetTime, + ); if (!mounted) return; await WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -3548,7 +3622,7 @@ class _ChatScreenState extends State if (!mounted || !_scrollController.hasClients) return; if (_messages.indexWhere((m) => m.id == id) == -1) return; - _suppressHistoryAutoload = true; + _historyAutoloadSuppressCount++; try { var stable = 0; for (var iter = 0; iter < 120; iter++) { @@ -3604,7 +3678,7 @@ class _ChatScreenState extends State await WidgetsBinding.instance.endOfFrame; } } finally { - _suppressHistoryAutoload = false; + _historyAutoloadSuppressCount--; } } @@ -4102,7 +4176,8 @@ class _ChatScreenState extends State double _pinnedBannerTop() { final glossy = AppVisualStyle.current.value == VisualStyle.glossy; return MediaQuery.paddingOf(context).top + - (glossy ? _glossyHeaderHeight : kToolbarHeight); + (glossy ? _glossyHeaderHeight : kToolbarHeight) - + _pinnedBannerLift; } void _resetPinnedBannerHeight() { @@ -4252,16 +4327,14 @@ class _ChatScreenState extends State } double _floatingDateTop(double pinnedHeight) { - final glossy = AppVisualStyle.current.value == VisualStyle.glossy; if (AppChatChrome.current.value == ChatChromeStyle.color) { + final glossy = AppVisualStyle.current.value == VisualStyle.glossy; return glossy ? 2 : 4; } if (chat?.hasPinnedMessage == true && pinnedHeight > 0) { return _pinnedBannerTop() + pinnedHeight + 2; } - return MediaQuery.paddingOf(context).top + - (glossy ? _glossyHeaderHeight : kToolbarHeight) + - 2; + return _pinnedBannerTop() + 2; } Widget _buildLoadMoreIndicator() { @@ -4411,6 +4484,10 @@ class _ChatScreenState extends State : null, isPinned: () => chat?.pinnedMsgId == int.tryParse(message.id), + loadReadBy: _canShowReadBy(message) + ? () => _loadReadBy(message) + : null, + onReaderTap: _openSenderProfile, loadReportReasons: canReport ? () => _loadReportReasons(reportTypeId) : null, @@ -5712,6 +5789,8 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback? onMarkUnread; final VoidCallback? onPin; final bool Function() isPinned; + final Future> Function()? loadReadBy; + final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final void Function(String emoji)? onReact; @@ -5734,6 +5813,8 @@ class _SelectableMessageRow extends StatefulWidget { this.onMarkUnread, this.onPin, required this.isPinned, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, this.onReact, @@ -5791,6 +5872,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { style: AppMessageActionsStyle.current.value, interaction: MessageActionsInteraction.tap, editHistory: widget.message.editHistory, + loadReadBy: widget.loadReadBy, + onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, @@ -5857,6 +5940,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, editHistory: widget.message.editHistory, + loadReadBy: widget.loadReadBy, + onReaderTap: widget.onReaderTap, loadReportReasons: widget.loadReportReasons, onReport: widget.onReport, onDelete: widget.onDelete, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 2ea8f6d..5dda793 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -12,6 +12,7 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; +import 'komet_avatar.dart'; import 'lottie_image.dart'; class ReactionEmoji { @@ -26,6 +27,20 @@ class ReactionEmoji { }); } +class MessageReader { + final int id; + final String name; + final String? avatarUrl; + final ReactionEmoji? reaction; + + const MessageReader({ + required this.id, + required this.name, + this.avatarUrl, + this.reaction, + }); +} + enum MessageActionsInteraction { dragAndRelease, click, tap } enum _RadialSide { below, above, left, right } @@ -92,6 +107,8 @@ void showMessageActions({ required MessageActionsStyle style, required VoidCallback onDispose, List>? editHistory, + Future> Function()? loadReadBy, + void Function(int userId)? onReaderTap, Future> Function()? loadReportReasons, Future Function(int reasonId)? onReport, VoidCallback? onDelete, @@ -128,6 +145,8 @@ void showMessageActions({ style: style, interaction: interaction, editHistory: editHistory, + loadReadBy: loadReadBy, + onReaderTap: onReaderTap, loadReportReasons: loadReportReasons, onReport: onReport, onDelete: onDelete, @@ -161,6 +180,8 @@ class _MessageActionsLayer extends StatefulWidget { final MessageActionsInteraction interaction; final VoidCallback onDismiss; final List>? editHistory; + final Future> Function()? loadReadBy; + final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final VoidCallback? onDelete; @@ -186,6 +207,8 @@ class _MessageActionsLayer extends StatefulWidget { required this.interaction, required this.onDismiss, this.editHistory, + this.loadReadBy, + this.onReaderTap, this.loadReportReasons, this.onReport, this.onDelete, @@ -234,9 +257,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> bool _committedFired = false; bool _showHistory = false; bool _showReport = false; + bool _showReadBy = false; bool _reportLoading = false; bool _reportSending = false; + bool _readByLoading = false; List<({int id, String title})>? _reasons; + List? _readers; + + bool get _panelOpen => _showHistory || _showReport || _showReadBy; @override void initState() { @@ -487,6 +515,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), if (widget.editHistory != null && widget.editHistory!.isNotEmpty) _Action(Symbols.history, l10n.msgActionsEditHistory, _showHistoryView), + if (widget.loadReadBy != null) + _Action(Symbols.visibility, l10n.msgActionsReadBy, _showReadByView), if (widget.onReport != null && widget.loadReportReasons != null) _Action( Symbols.flag, @@ -508,6 +538,21 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> setState(() => _showHistory = true); } + Future _showReadByView() async { + if (!mounted) return; + setState(() { + _showReadBy = true; + _readByLoading = _readers == null; + }); + if (_readers != null) return; + final loaded = await widget.loadReadBy?.call(); + if (!mounted) return; + setState(() { + _readers = loaded ?? const []; + _readByLoading = false; + }); + } + Future _showReportView() async { if (!mounted) return; setState(() { @@ -541,6 +586,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> setState(() { _showHistory = false; _showReport = false; + _showReadBy = false; }); } @@ -648,7 +694,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final t = _animation.value.clamp(0.0, 1.0); final e = showReactions ? _expandAnim.value.clamp(0.0, 1.0) : 0.0; final bubbleScale = 1.0 + 0.02 * t; - final menuHidden = _showHistory || _showReport || _reactionsExpanded; + final menuHidden = _panelOpen || _reactionsExpanded; return GestureDetector( onTap: _close, @@ -702,9 +748,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), Positioned.fill( child: IgnorePointer( - ignoring: !(_showHistory || _showReport), + ignoring: !_panelOpen, child: AnimatedOpacity( - opacity: (_showHistory || _showReport) ? 1.0 : 0.0, + opacity: _panelOpen ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: Stack( @@ -712,14 +758,26 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_showReport) _buildReportMenu() else if (_showHistory) - _buildHistoryMenu(), + _buildHistoryMenu() + else if (_showReadBy) + _buildReadByMenu(), ], ), ), ), ), if (showReactions) - Positioned.fill(child: _buildReactionStrip(t, e)), + Positioned.fill( + child: IgnorePointer( + ignoring: _panelOpen, + child: AnimatedOpacity( + opacity: _panelOpen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: _buildReactionStrip(t, e), + ), + ), + ), ], ), ); @@ -1041,10 +1099,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } - Widget _buildAnchoredPanel({required String title, required Widget body}) { + Widget _buildAnchoredPanel({ + required String title, + required Widget body, + double width = 220.0, + }) { final cs = Theme.of(context).colorScheme; final size = MediaQuery.sizeOf(context); - const menuWidth = 220.0; + final panelWidth = math.min(width, size.width - 16.0); double left; double top; @@ -1053,21 +1115,21 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> top = _menuRect.top; } else { left = widget.isMe - ? widget.originRect.right - menuWidth + ? widget.originRect.right - panelWidth : widget.originRect.left; top = _showBelow ? widget.originRect.bottom + 10 : widget.originRect.top - 10; } final bottomLimit = size.height - MediaQuery.viewInsetsOf(context).bottom; - left = left.clamp(8.0, size.width - menuWidth - 8.0); + left = left.clamp(8.0, math.max(8.0, size.width - panelWidth - 8.0)); top = top.clamp(8.0, math.max(8.0, bottomLimit - 160.0)); final maxHeight = math.min(size.height * 0.6, bottomLimit - top - 8.0); return Positioned( left: left, top: top, - width: menuWidth, + width: panelWidth, child: GestureDetector( onTap: () {}, child: Material( @@ -1157,6 +1219,93 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + Widget _buildReadByMenu() { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final Widget body; + if (_readByLoading) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.4), + ), + ), + ); + } else { + final readers = _readers ?? const []; + if (readers.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + child: Text( + l10n.msgActionsReadByEmpty, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ); + } else { + body = SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [for (final reader in readers) _readerRow(cs, reader)], + ), + ); + } + } + return _buildAnchoredPanel( + title: l10n.msgActionsReadBy, + body: body, + width: 250, + ); + } + + Future _openReaderProfile(MessageReader reader) async { + final onTap = widget.onReaderTap; + if (onTap == null) return; + Haptics.tap(); + await _close(); + onTap(reader.id); + } + + Widget _readerRow(ColorScheme cs, MessageReader reader) { + final reaction = reader.reaction; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onReaderTap == null + ? null + : () => _openReaderProfile(reader), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + KometAvatar( + name: reader.name, + imageUrl: reader.avatarUrl, + size: 30, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + reader.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurface, fontSize: 14), + ), + ), + if (reaction != null) ...[ + const SizedBox(width: 8), + _ReactionGlyph(reaction: reaction, size: 20), + ], + ], + ), + ), + ), + ); + } + Widget _buildReportMenu() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec0de00..e973007 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -193,6 +193,9 @@ "msgActionsUnpin": "Unpin", "pinnedMessageTitle": "Pinned message", "msgActionsEditHistory": "Edit history", + "msgActionsReadBy": "Read by", + "msgActionsReadByEmpty": "Nobody has read it yet", + "msgActionsReadByUnknownUser": "User", "msgActionsReport": "Report", "msgActionsDelete": "Delete", "msgActionsCopied": "Copied", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9a0c6c5..a7d2884 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1154,6 +1154,24 @@ abstract class AppLocalizations { /// **'Edit history'** String get msgActionsEditHistory; + /// No description provided for @msgActionsReadBy. + /// + /// In en, this message translates to: + /// **'Read by'** + String get msgActionsReadBy; + + /// No description provided for @msgActionsReadByEmpty. + /// + /// In en, this message translates to: + /// **'Nobody has read it yet'** + String get msgActionsReadByEmpty; + + /// No description provided for @msgActionsReadByUnknownUser. + /// + /// In en, this message translates to: + /// **'User'** + String get msgActionsReadByUnknownUser; + /// No description provided for @msgActionsReport. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 231fbdf..744e0d6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -554,6 +554,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get msgActionsEditHistory => 'Edit history'; + @override + String get msgActionsReadBy => 'Read by'; + + @override + String get msgActionsReadByEmpty => 'Nobody has read it yet'; + + @override + String get msgActionsReadByUnknownUser => 'User'; + @override String get msgActionsReport => 'Report'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 81651aa..8dbb85c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -557,6 +557,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get msgActionsEditHistory => 'История изменений'; + @override + String get msgActionsReadBy => 'Кем прочитано'; + + @override + String get msgActionsReadByEmpty => 'Пока никто не прочитал'; + + @override + String get msgActionsReadByUnknownUser => 'Пользователь'; + @override String get msgActionsReport => 'Пожаловаться'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index dba03dd..725a0fc 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -193,6 +193,9 @@ "msgActionsUnpin": "Открепить", "pinnedMessageTitle": "Закреплённое сообщение", "msgActionsEditHistory": "История изменений", + "msgActionsReadBy": "Кем прочитано", + "msgActionsReadByEmpty": "Пока никто не прочитал", + "msgActionsReadByUnknownUser": "Пользователь", "msgActionsReport": "Пожаловаться", "msgActionsDelete": "Удалить", "msgActionsCopied": "Скопировано",