feat: кто прочитал?

This commit is contained in:
Jganenokk
2026-07-13 15:02:08 +07:00
parent 53e549214e
commit 10d071bdff
9 changed files with 384 additions and 60 deletions
+12
View File
@@ -1275,6 +1275,18 @@ class ChatsModule {
return Map<String, dynamic>.from(chats.first as Map);
}
Future<Map<int, int>> 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<dynamic> searchById(Api api, int userId) async {
final packet = await api.sendRequest(Opcode.publicSearch, {
'query': userId.toString(),
+36
View File
@@ -1085,6 +1085,42 @@ class MessagesModule {
return _applyReactionResponse(chatId, messageId, response);
}
Future<Map<int, String>> 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<int, String> _parseDetailedReactions(dynamic raw) {
if (raw is! List) return const {};
final result = <int, String>{};
for (final entry in raw.whereType<Map>()) {
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<String, dynamic>? info})> _applyReactionResponse(
int chatId,
String messageId,
+135 -50
View File
@@ -223,7 +223,8 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<ChatScreen>
}
Future<void> _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<ChatScreen>
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<List<MessageReader>> _loadReadBy(CachedMessage message) async {
final marks = await chats.getReadMarks(api, _myId, widget.chatId);
final reactions = await messagesModule.getDetailedReactions(
widget.chatId,
message.id,
);
final readerIds = <int>{
...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<ChatScreen>
Future<void> _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<ChatScreen>
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<ChatScreen>
}
}
Future<void> _loadMoreHistory() async {
Future<void> _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<void> _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<ChatScreen>
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<ChatScreen>
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<ChatScreen>
await WidgetsBinding.instance.endOfFrame;
}
} finally {
_suppressHistoryAutoload = false;
_historyAutoloadSuppressCount--;
}
}
@@ -4102,7 +4176,8 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
}
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<ChatScreen>
: 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<List<MessageReader>> Function()? loadReadBy;
final void Function(int userId)? onReaderTap;
final Future<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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,
+159 -10
View File
@@ -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<Map<String, dynamic>>? editHistory,
Future<List<MessageReader>> Function()? loadReadBy,
void Function(int userId)? onReaderTap,
Future<List<({int id, String title})>> Function()? loadReportReasons,
Future<bool> 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<Map<String, dynamic>>? editHistory;
final Future<List<MessageReader>> Function()? loadReadBy;
final void Function(int userId)? onReaderTap;
final Future<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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<MessageReader>? _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<void> _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<void> _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 <MessageReader>[];
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<void> _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)!;
+3
View File
@@ -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",
+18
View File
@@ -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:
+9
View File
@@ -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';
+9
View File
@@ -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 => 'Пожаловаться';
+3
View File
@@ -193,6 +193,9 @@
"msgActionsUnpin": "Открепить",
"pinnedMessageTitle": "Закреплённое сообщение",
"msgActionsEditHistory": "История изменений",
"msgActionsReadBy": "Кем прочитано",
"msgActionsReadByEmpty": "Пока никто не прочитал",
"msgActionsReadByUnknownUser": "Пользователь",
"msgActionsReport": "Пожаловаться",
"msgActionsDelete": "Удалить",
"msgActionsCopied": "Скопировано",