feat: кто прочитал?
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Пожаловаться';
|
||||
|
||||
|
||||
@@ -193,6 +193,9 @@
|
||||
"msgActionsUnpin": "Открепить",
|
||||
"pinnedMessageTitle": "Закреплённое сообщение",
|
||||
"msgActionsEditHistory": "История изменений",
|
||||
"msgActionsReadBy": "Кем прочитано",
|
||||
"msgActionsReadByEmpty": "Пока никто не прочитал",
|
||||
"msgActionsReadByUnknownUser": "Пользователь",
|
||||
"msgActionsReport": "Пожаловаться",
|
||||
"msgActionsDelete": "Удалить",
|
||||
"msgActionsCopied": "Скопировано",
|
||||
|
||||
Reference in New Issue
Block a user