diff --git a/lib/chat/chat_message_merge.dart b/lib/chat/chat_message_merge.dart index 9667fe9e..3d73b8aa 100644 --- a/lib/chat/chat_message_merge.dart +++ b/lib/chat/chat_message_merge.dart @@ -87,6 +87,22 @@ List mergeChatMessages( message.senderPhoto ??= existing.senderPhoto; message.senderRole ??= existing.senderRole; message.senderTitle ??= existing.senderTitle; + if (message.replyToMessageId == existing.replyToMessageId && + message.replyToChatId == existing.replyToChatId) { + message.replyToSender ??= existing.replyToSender; + if (!message.isContentRestricted) { + // A restriction-redacted refresh withdraws the quoted content: the + // cached quote text/entities must go with the image, not survive. + message.replyToPreview ??= existing.replyToPreview; + message.replyToDate ??= existing.replyToDate; + if (message.replyToEntities.isEmpty) { + message.replyToEntities = existing.replyToEntities; + } + message.replyToImage ??= existing.replyToImage; + message.replyToImageWidth ??= existing.replyToImageWidth; + message.replyToImageHeight ??= existing.replyToImageHeight; + } + } if (!message.commentThreadMetadataKnown && existing.commentThreadMetadataKnown) { message.hasCommentThread = existing.hasCommentThread; diff --git a/lib/chat/chat_view_model.dart b/lib/chat/chat_view_model.dart index e5ef8aff..acd84f11 100644 --- a/lib/chat/chat_view_model.dart +++ b/lib/chat/chat_view_model.dart @@ -6804,40 +6804,62 @@ class ChatViewModel extends ChangeNotifier { // map for each history page (or scanning the transcript for a live reply) // made quoting increasingly expensive as more history was loaded. _ensureMessageIndexes(); - final unresolved = >{}; + final unresolvedByChat = >>{}; for (final m in repliesToResolve) { final rid = m.replyToMessageId!; - final quoted = _messagesById[rid]; + final rChatId = m.replyToChatId ?? m.chatId ?? chatId; + final quoted = rChatId == chatId + ? (_messagesById[rid] ?? _allMessagesById[rid]) + : null; if (quoted != null) { - _applyReply(m, quoted); + // The locally cached target is trusted only across the restriction + // boundary: a redacted message must not lend its text to this anchor, + // and no remote lookup can repair the hole it would leave. + if (!quoted.isContentRestricted) { + _applyReply(m, quoted); + } continue; } + final unresolved = unresolvedByChat.putIfAbsent( + rChatId, + () => >{}, + ); unresolved.putIfAbsent(rid, () => []).add(m); } - if (unresolved.isEmpty) return; + if (unresolvedByChat.isEmpty) return; // One getMessage per distinct target meant 10-25 concurrent round trips for // a single reply-heavy history page, repeated for every page scrolled up. // getMessages answers them all at once, so the previews fill in together. - final targets = unresolved.keys.toList(); - for (var start = 0; start < targets.length; start += 100) { - final end = start + 100 > targets.length ? targets.length : start + 100; - _resolveReplyTargets(targets.sublist(start, end), unresolved); + for (final entry in unresolvedByChat.entries) { + final targetChatId = entry.key; + final unresolved = entry.value; + final targets = unresolved.keys.toList(); + for (var start = 0; start < targets.length; start += 100) { + final end = start + 100 > targets.length ? targets.length : start + 100; + _resolveReplyTargets( + targetChatId, + targets.sublist(start, end), + unresolved, + ); + } } } /// Fetches one batch of reply targets and patches every message waiting on it. void _resolveReplyTargets( + int targetChatId, List messageIds, Map> unresolved, ) { _client .query({ '@type': 'getMessages', - 'chat_id': chatId, + 'chat_id': targetChatId, 'message_ids': messageIds, }) .then((response) { - var changed = false; + if (_chatOpenWorkIsStale) return; + final updatedIds = {}; // Unavailable ids come back as nulls that carry no identity, so the // bucket is looked up by the id on each returned message, not by // position. @@ -6848,16 +6870,42 @@ class ChatViewModel extends ChangeNotifier { if (quoted == null) continue; final waiting = unresolved[quoted.id]; if (waiting == null) continue; - for (final message in waiting) { - if (message.replyToMessageId != quoted.id || - message.replyToPreview != null) { - continue; + for (final m in waiting) { + final refs = _messageRefs(m.id); + final targets = refs.isEmpty ? [m] : refs; + for (final message in targets) { + // A held lookup belongs to the COMPLETE target captured when it + // was issued. The message id alone is not identity: the refresh + // may have retargeted this very reply to another chat that uses + // the same numeric id, and applying the old chat's payload + // would hydrate the new target with another message's text. + final liveChatId = + message.replyToChatId ?? message.chatId ?? chatId; + if (message.replyToMessageId != quoted.id || + liveChatId != targetChatId || + message.replyToPreview != null) { + continue; + } + // Returned quotes must come from the requested chat: a + // mis-addressed response would otherwise smuggle a same-id + // message of a foreign chat into the reply anchor. + final quotedChatId = quoted.chatId ?? targetChatId; + if (quotedChatId != targetChatId) continue; + // A restriction-redacted reply must never be hydrated from + // cached quote plaintext, and a redacted response is a + // withdrawal, not an anchor to copy. + if (message.isContentRestricted || quoted.isContentRestricted) { + continue; + } + _applyReply(message, quoted); + updatedIds.add(message.id); } - _applyReply(message, quoted); - changed = true; } } - if (changed) _scheduleCoalescedNotify(); + if (updatedIds.isNotEmpty) { + _bumpMaterializedMessageRevisions(updatedIds); + _scheduleCoalescedNotify(); + } }) .catchError((_) {}); } @@ -6895,8 +6943,9 @@ class ChatViewModel extends ChangeNotifier { /// the reply in place. Reuses the sender cache populated for the transcript. Future _resolveQuotedSender(ChatMessage m, int senderId) async { final existing = _senderCache[senderId]; - if (existing != null) { + if (existing != null && existing.name.isNotEmpty) { m.replyToSender = existing.name; + _bumpMaterializedMessageRevisions([m.id]); _scheduleCoalescedNotify(); return; } @@ -6918,6 +6967,7 @@ class ChatViewModel extends ChangeNotifier { } catch (_) {} if (name != null && name.isNotEmpty) { m.replyToSender = name; + _bumpMaterializedMessageRevisions([m.id]); _scheduleCoalescedNotify(); } } diff --git a/lib/chat/message_replies_sheet.dart b/lib/chat/message_replies_sheet.dart index 800c3148..588e689e 100644 --- a/lib/chat/message_replies_sheet.dart +++ b/lib/chat/message_replies_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:mithka/l10n/app_localizations.dart'; @@ -189,11 +190,38 @@ class _MessageRepliesSheet extends StatefulWidget { State<_MessageRepliesSheet> createState() => _MessageRepliesSheetState(); } +/// How far one sweep step of the in-sheet jump moves, as a fraction of the +/// viewport. The step is applied only in the direction the materialized window +/// proves the target lies in, so the sweep always makes progress. +const double _sheetReplyScrollStepFraction = 0.8; + +/// Frame budget of the in-sheet jump. The sweep steps toward the target by +/// less than a viewport at a time and each step is decided by the rows the +/// sliver actually built, so a whole 100-row thread is reachable well inside +/// this budget; exceeding it means the target cannot be reached at all. +const int _sheetReplyScrollMaxSteps = 240; + +/// Precision below which two scroll offsets count as the same position. +const double _sheetReplyScrollTolerance = 0.5; + +/// Test seam: makes the in-sheet reply jump report failure, so the verified +/// navigation fallback can be exercised without an unreachable row. +@visibleForTesting +bool debugForceSheetReplyScrollFailure = false; + +/// Identity of a reply-sheet row: a channel post and a discussion comment can +/// carry the same numeric message id, so every row key, cache and lookup in +/// the sheet must be scoped by the chat owning the message. +typedef _SheetMessageKey = ({int chatId, int messageId}); + class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { final _replyController = TextEditingController(); final _replyFocus = FocusNode(); + final _scrollController = ScrollController(); final _messages = []; + final _rowKeys = <_SheetMessageKey, GlobalKey>{}; final _senders = {}; + final _chatTitles = {}; _ReplyThreadTarget? _replyTarget; String? _replyTargetTitle; ChatMessage? _replyTo; @@ -216,6 +244,7 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { void dispose() { _replyController.dispose(); _replyFocus.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -264,31 +293,87 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { .map(TDParse.message) .whereType() .where((message) => !message.isService) - .where((message) => message.id != widget.message.id) - .where( - (message) => message.id != replyTarget?.historyRootMessageId, - ) + // The channel root and a discussion comment can share a numeric + // message id; only the composite identity proves a row IS one of + // the thread's roots, so a same-id comment of another chat is + // kept instead of vanishing with the root. + .where((message) => !_isSheetRootMessage(message, replyTarget)) .toList() ..sort((a, b) => a.date.compareTo(b.date)); + await _hydrateMessageSender(widget.message); + for (final message in loaded) { + await _hydrateMessageSender(message); + } + final byChatAndId = <({int chatId, int messageId}), ChatMessage>{ + (chatId: widget.chatId, messageId: widget.message.id): widget.message, + for (final message in loaded) + ( + chatId: message.chatId ?? replyTarget?.chatId ?? widget.chatId, + messageId: message.id, + ): message, + }; + final missingByChatId = >{}; for (final message in loaded) { - final senderId = message.senderId; - if (senderId == null) continue; - var sender = _senders[senderId]; - if (sender == null) { - sender = await _resolveSender(senderId); - if (sender != null) _senders[senderId] = sender; + final rid = message.replyToMessageId; + if (rid == null) continue; + final rChatId = + message.replyToChatId ?? + message.chatId ?? + replyTarget?.chatId ?? + widget.chatId; + if (byChatAndId.containsKey((chatId: rChatId, messageId: rid))) { + continue; } - if (sender != null) { - message.senderName ??= sender.name; - message.senderPhoto ??= sender.photo; + missingByChatId.putIfAbsent(rChatId, () => {}).add(rid); + } + for (final entry in missingByChatId.entries) { + try { + final res = await TdClient.shared.query({ + '@type': 'getMessages', + 'chat_id': entry.key, + 'message_ids': entry.value.toList(), + }); + for (final raw + in res.objects('messages') ?? const >[]) { + final quoted = TDParse.message(raw); + if (quoted == null) continue; + await _hydrateMessageSender(quoted); + final qChatId = quoted.chatId ?? entry.key; + byChatAndId[(chatId: qChatId, messageId: quoted.id)] = quoted; + } + } catch (_) {} + } + final extraChatIds = { + for (final message in loaded) + if (message.replyToChatId case final rChatId? + when rChatId != widget.chatId && rChatId != replyTarget?.chatId) + rChatId, + }; + for (final chatId in extraChatIds) { + if (_chatTitles.containsKey(chatId)) continue; + final info = await _targetChatInfo(chatId); + final title = info?.title?.trim(); + if (title != null && title.isNotEmpty) { + _chatTitles[chatId] = title; } } - final byId = {for (final message in loaded) message.id: message}; for (final message in loaded) { - final quoted = byId[message.replyToMessageId]; + final rid = message.replyToMessageId; + if (rid == null) continue; + final rChatId = + message.replyToChatId ?? + message.chatId ?? + replyTarget?.chatId ?? + widget.chatId; + final quoted = byChatAndId[(chatId: rChatId, messageId: rid)]; if (quoted == null) continue; - message.replyToSender ??= - quoted.senderName ?? quoted.senderTitle ?? widget.peerTitle; + // The sender name is a trust boundary of the same quote: a + // restriction-redacted parent must not lend its cached plaintext or + // identity to the row quoting it. + message.replyToSender ??= quoted.isContentRestricted + ? widget.peerTitle + : quoted.senderName ?? quoted.senderTitle ?? widget.peerTitle; + if (quoted.isContentRestricted) continue; message.replyToPreview ??= quoted.text; message.replyToDate ??= quoted.date; message.replyToEntities = quoted.textEntities; @@ -306,6 +391,10 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { _messages ..clear() ..addAll(loaded); + final loadedKeys = <_SheetMessageKey>{ + for (final message in loaded) _sheetKeyFor(message), + }; + _rowKeys.removeWhere((key, _) => !loadedKeys.contains(key)); _loading = false; }); } on _RepliesUnavailable { @@ -372,19 +461,23 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { '@type': 'getChat', 'chat_id': chatId, }); + final title = chat.str('title'); + if (title != null && title.trim().isNotEmpty) { + _chatTitles[chatId] = title.trim(); + } return _ReplyTargetChatInfo( canSend: chat.obj('permissions')?.boolean('can_send_basic_messages') ?? true, - title: chat.str('title'), + title: title, ); } catch (_) { return null; } } - MessageRepliesViewTarget _viewInChatTarget() { + MessageRepliesViewTarget _viewInChatTarget({int? targetMessageId}) { final target = _replyTarget; - return resolveMessageRepliesViewTarget( + final viewTarget = resolveMessageRepliesViewTarget( sourceChatId: widget.chatId, sourceMessageId: widget.message.id, sourceTitle: widget.peerTitle, @@ -393,6 +486,26 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { rootReplyToMessageId: target?.rootReplyToMessageId, threadTitle: _replyTargetTitle, ); + if (targetMessageId == null) return viewTarget; + return MessageRepliesViewTarget( + chatId: viewTarget.chatId, + messageId: targetMessageId, + title: viewTarget.title, + ); + } + + Future _hydrateMessageSender(ChatMessage message) async { + final senderId = message.senderId; + if (senderId == null) return; + var sender = _senders[senderId]; + if (sender == null) { + sender = await _resolveSender(senderId); + if (sender != null) _senders[senderId] = sender; + } + if (sender != null) { + message.senderName ??= sender.name; + message.senderPhoto ??= sender.photo; + } } Future<_ReplySender?> _resolveSender(int senderId) async { @@ -675,6 +788,7 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { return _emptyState(AppStringKeys.messageRepliesEmpty); } return ListView.separated( + controller: _scrollController, padding: const EdgeInsets.fromLTRB(4, 7, 4, 12), itemCount: _messages.length, separatorBuilder: (_, _) => const SizedBox.shrink(), @@ -1093,31 +1207,280 @@ class _MessageRepliesSheetState extends State<_MessageRepliesSheet> { widget.peerTitle; message.senderName ??= senderName; message.senderPhoto ??= sender?.photo; - return MessageReplySheetItem( - message: message, - peerTitle: widget.peerTitle, - senderName: senderName, - senderPhoto: sender?.photo ?? message.senderPhoto, - onReply: _canReply ? _beginReply : null, - onLongPress: (message, _, source) => unawaited(_showMessageMenu(message)), - onAvatarTap: widget.onAvatarTap, - onOpenReply: widget.onOpenReply == null - ? null - : (messageId) { - Navigator.of(context).pop(); - widget.onOpenReply!(messageId); - }, - onOpenImage: _openImage, - onOpenImageGallery: _openImageGallery, - onOpenSticker: widget.onOpenSticker, - onPlayVideo: widget.onPlayVideo, - onPlayMusic: widget.onPlayMusic, - onButtonTap: widget.onButtonTap, - onBotCommandTap: widget.onBotCommandTap, - onHashtagTap: widget.onHashtagTap, + final rowKey = _rowKeys.putIfAbsent(_sheetKeyFor(message), GlobalKey.new); + final replyTargetChatId = + message.replyToChatId ?? + message.chatId ?? + _replyTarget?.chatId ?? + widget.chatId; + final hasInSheetReplyTarget = + message.replyToMessageId != null && + _messages.any( + (candidate) => + candidate.id == message.replyToMessageId && + (candidate.chatId ?? _replyTarget?.chatId ?? widget.chatId) == + replyTargetChatId, + ); + // A row's reply target resolves inside the sheet when the sheet's own chat + // owns a row with that message id. + final canOpenReply = + (message.replyToMessageId != null && + _sheetRowIndexOf(message.replyToMessageId!) >= 0) || + widget.onOpenReply != null || + widget.onViewInChat != null || + hasInSheetReplyTarget; + return KeyedSubtree( + key: rowKey, + child: MessageReplySheetItem( + message: message, + peerTitle: widget.peerTitle, + senderName: senderName, + senderPhoto: sender?.photo ?? message.senderPhoto, + onReply: _canReply ? _beginReply : null, + onLongPress: (message, _, source) => + unawaited(_showMessageMenu(message)), + onAvatarTap: widget.onAvatarTap, + onOpenReply: canOpenReply + ? (messageId) => unawaited(_openReplyTarget(message, messageId)) + : null, + onOpenImage: _openImage, + onOpenImageGallery: _openImageGallery, + onOpenSticker: widget.onOpenSticker, + onPlayVideo: widget.onPlayVideo, + onPlayMusic: widget.onPlayMusic, + onButtonTap: widget.onButtonTap, + onBotCommandTap: widget.onBotCommandTap, + onHashtagTap: widget.onHashtagTap, + ), ); } + Future _openReplyTarget(ChatMessage source, int targetMessageId) async { + final targetChatId = + source.replyToChatId ?? + source.chatId ?? + _replyTarget?.chatId ?? + widget.chatId; + // The sheet lists one chat's rows, so an in-sheet target is identified by + // (row chat == sheet chat, message id) — never by the id alone, which a + // cross-chat reply can collide with. + final targetIndex = targetChatId == _sheetChatId + ? _sheetRowIndexOf(targetMessageId, chatId: targetChatId) + : -1; + if (targetIndex >= 0) { + final reached = await _scrollToSheetReply(targetIndex, targetMessageId); + if (reached) return; + if (!mounted) return; + // The in-sheet sweep could not prove the target visible. Do not swallow + // the tap: fall through to the explicit navigation below, which opens + // the same (chat, message) pair through a verified route. + } + if (!mounted) return; + if (targetChatId != widget.chatId && widget.onViewInChat != null) { + final resolvedTitle = targetChatId == _replyTarget?.chatId + ? (_replyTargetTitle?.trim().isNotEmpty == true + ? _replyTargetTitle!.trim() + : widget.peerTitle) + : (_chatTitles[targetChatId] ?? widget.peerTitle); + final viewTarget = MessageRepliesViewTarget( + chatId: targetChatId, + messageId: targetMessageId, + title: resolvedTitle, + ); + Navigator.of(context).pop(); + widget.onViewInChat!(viewTarget); + return; + } + if (widget.onOpenReply != null) { + Navigator.of(context).pop(); + widget.onOpenReply!(targetMessageId); + return; + } + if (widget.onViewInChat != null) { + Navigator.of(context).pop(); + widget.onViewInChat!( + MessageRepliesViewTarget( + chatId: targetChatId, + messageId: targetMessageId, + title: widget.peerTitle, + ), + ); + } + } + + /// The chat whose messages this sheet lists: the linked discussion group + /// when the thread root resolves to another chat, else the source chat. + int get _sheetChatId => _replyTarget?.chatId ?? widget.chatId; + + /// Identity of a sheet row. Messages parsed from this thread's history carry + /// their own chat id; older/local fixtures leave it null and belong to the + /// sheet's chat. + ({int chatId, int messageId}) _sheetKeyFor(ChatMessage message) => + (chatId: message.chatId ?? _sheetChatId, messageId: message.id); + + /// Index of the in-sheet row with [messageId] owned by [chatId] (the sheet's + /// own chat when the caller has none), or -1 when the sheet has no such row. + int _sheetRowIndexOf(int messageId, {int? chatId}) { + final expectedChatId = chatId ?? _sheetChatId; + for (var index = 0; index < _messages.length; index++) { + final candidate = _messages[index]; + if (candidate.id != messageId) continue; + if ((candidate.chatId ?? _sheetChatId) == expectedChatId) return index; + } + return -1; + } + + /// Whether [message], parsed out of this thread's history, IS one of the + /// thread's roots. The comparison is (chatId, messageId): a discussion + /// comment that reuses the channel post's numeric id is a real row and must + /// not be dropped with the root. + bool _isSheetRootMessage(ChatMessage message, _ReplyThreadTarget? target) { + final sheetChatId = target?.chatId ?? widget.chatId; + final key = (chatId: message.chatId ?? sheetChatId, messageId: message.id); + if (key == (chatId: widget.chatId, messageId: widget.message.id)) { + return true; + } + final historyRoot = target?.historyRootMessageId; + return historyRoot != null && + key == (chatId: sheetChatId, messageId: historyRoot); + } + + /// Scrolls the sheet until the row at [targetIndex] is on screen, and + /// reports whether it actually got there. + /// + /// A row that has never been materialized has no render object, and with + /// strongly non-uniform heights neither a proportional offset nor an + /// estimate of the remaining extent can stand in for its position: ten + /// hundred-line comments followed by ninety short ones make any pixel guess + /// wrong by thousands of pixels. So nothing here is estimated. Every step is + /// decided by the window the sliver has actually built: + /// + /// - the target is materialized: jump to its measured reveal offset and the + /// visibility check decides; + /// - the target is above that window: step up by most of a viewport; + /// - the target is below it: step down by most of a viewport. + /// + /// Scroll offset is monotone in item index, so each step moves strictly + /// toward the target and the sweep terminates. Success is only reported when + /// the target row is materialized AND intersects the viewport — never because + /// some offset changed. + Future _scrollToSheetReply(int targetIndex, int targetMessageId) async { + if (debugForceSheetReplyScrollFailure) return false; + if (!mounted || !_scrollController.hasClients) return false; + if (targetIndex < 0 || targetIndex >= _messages.length) return false; + + for (var step = 0; step < _sheetReplyScrollMaxSteps; step++) { + if (!mounted || !_scrollController.hasClients) return false; + + // Materialized: land on the row itself, by measurement. + final reveal = _revealOffsetFor(targetIndex, targetMessageId); + if (reveal != null) { + final position = _scrollController.position; + final target = reveal.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((target - position.pixels).abs() >= _sheetReplyScrollTolerance) { + _scrollController.jumpTo(target); + await WidgetsBinding.instance.endOfFrame; + } + if (_sheetReplyVisible(targetIndex, targetMessageId)) return true; + // The measured reveal did not bring it into view (the row can be + // taller than the viewport): fall through to the sweep below, which + // still only moves in a proven direction. + } + + if (!mounted || !_scrollController.hasClients) return false; + final position = _scrollController.position; + final window = _sheetMaterializedRange(); + final viewportStep = + position.viewportDimension * _sheetReplyScrollStepFraction; + final double next; + if (window == null || targetIndex < window.$1) { + // Nothing built, or the target sits above what is built. + next = position.pixels - viewportStep; + } else if (targetIndex > window.$2) { + next = position.pixels + viewportStep; + } else { + // Inside the built range yet not visible: the row's own reveal offset + // was already tried, so step toward the side it was measured on. + final measuredBelow = reveal == null || reveal > position.pixels; + next = position.pixels + (measuredBelow ? viewportStep : -viewportStep); + } + final clamped = next.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((clamped - position.pixels).abs() < _sheetReplyScrollTolerance) { + // The scroll range ends here and the target was not reached. + return _sheetReplyVisible(targetIndex, targetMessageId); + } + _scrollController.jumpTo(clamped); + await WidgetsBinding.instance.endOfFrame; + if (_sheetReplyVisible(targetIndex, targetMessageId)) return true; + } + return _sheetReplyVisible(targetIndex, targetMessageId); + } + + /// Inclusive index range of the rows the sliver currently has built, or null + /// when none is measurable. Used to decide which way the jump must sweep; + /// the heights themselves are never assumed. + (int, int)? _sheetMaterializedRange() { + int? first; + int? last; + for (var i = 0; i < _messages.length; i++) { + final render = _rowRenderObject(i, _messages[i].id); + if (render == null) continue; + first ??= i; + last = i; + } + return first == null || last == null ? null : (first, last); + } + + double? _revealOffsetFor(int targetIndex, int targetMessageId) { + final render = _rowRenderObject(targetIndex, targetMessageId); + if (render == null) return null; + final viewport = RenderAbstractViewport.maybeOf(render); + if (viewport == null) return null; + return viewport.getOffsetToReveal(render, 0.15).offset; + } + + RenderBox? _rowRenderObject(int targetIndex, int targetMessageId) { + final context = _rowContextFor(targetIndex, targetMessageId); + if (context == null) return null; + final render = context.findRenderObject(); + return render is RenderBox && render.hasSize && render.attached + ? render + : null; + } + + /// Mounted element of the row at [targetIndex] when it is the row carrying + /// [targetMessageId]. The key comes from the row's OWN composite identity, so + /// a same-id row of another chat can never stand in for this target. + BuildContext? _rowContextFor(int targetIndex, int targetMessageId) { + if (targetIndex < 0 || targetIndex >= _messages.length) return null; + final row = _messages[targetIndex]; + if (row.id != targetMessageId) return null; + final context = _rowKeys[_sheetKeyFor(row)]?.currentContext; + return context != null && context.mounted ? context : null; + } + + bool _sheetReplyVisible(int targetIndex, int targetMessageId) { + final context = _rowContextFor(targetIndex, targetMessageId); + if (context == null) return false; + final render = context.findRenderObject(); + if (render is! RenderBox || !render.hasSize || !render.attached) { + return false; + } + final viewport = RenderAbstractViewport.maybeOf(render); + if (viewport == null || !_scrollController.hasClients) return false; + final reveal = viewport.getOffsetToReveal(render, 0.0); + final position = _scrollController.position; + final top = reveal.offset - position.pixels; + final bottom = top + render.size.height; + return bottom > 0 && top < position.viewportDimension; + } + void _openImage(ChatMessage message) { final selected = message.image; if (selected == null) { diff --git a/lib/moments/moments_view.dart b/lib/moments/moments_view.dart index ec5b607e..1b729f9a 100644 --- a/lib/moments/moments_view.dart +++ b/lib/moments/moments_view.dart @@ -170,7 +170,9 @@ class ChannelPostComment { this.entities = const [], this.senderPhoto, this.date = 0, + this.replyToChatId, this.replyToMessageId, + this.replyToSenderName, this.reactionCount = 0, }); @@ -181,7 +183,9 @@ class ChannelPostComment { final List entities; final TdFileRef? senderPhoto; final int date; + final int? replyToChatId; final int? replyToMessageId; + final String? replyToSenderName; final int reactionCount; } @@ -1525,18 +1529,20 @@ class _ChannelMomentsViewState extends State { return; } try { - final raw = await TdClient.shared.query({ + final raw = await TdClient.shared.queryForSlot({ '@type': 'getMessage', 'chat_id': post.channel.id, 'message_id': replyToMessageId, - }); + }, post.accountSlot); final quoted = TDParse.message(raw); if (quoted == null) return; message.replyToPreview = _replyPreview(quoted); message.replyToImage = quoted.previewImage; message.replyToImageWidth = quoted.imageWidth; message.replyToImageHeight = quoted.imageHeight; - message.replyToSender = await _senderName(quoted) ?? post.channel.title; + message.replyToSender = + await _senderName(quoted, accountSlot: post.accountSlot) ?? + post.channel.title; } catch (_) { message.replyToPreview ??= ''; message.replyToSender ??= post.channel.title; @@ -1575,11 +1581,11 @@ class _ChannelMomentsViewState extends State { var shouldNotify = false; try { try { - final properties = await TdClient.shared.query({ + final properties = await TdClient.shared.queryForSlot({ '@type': 'getMessageProperties', 'chat_id': post.channel.id, 'message_id': post.message.id, - }); + }, post.accountSlot); if (properties.boolean('can_get_message_thread') == false) return null; } catch (_) {} final target = await _fetchChannelPostThreadTarget(post); @@ -1598,14 +1604,14 @@ class _ChannelMomentsViewState extends State { Future _loadCommentsForPost(ChannelPost post, String key) async { try { final target = await _resolveThreadTarget(post); - final response = await TdClient.shared.query({ + final response = await TdClient.shared.queryForSlot({ '@type': 'getMessageThreadHistory', 'chat_id': post.channel.id, 'message_id': post.message.id, 'from_message_id': 0, 'offset': 0, 'limit': 5, - }); + }, post.accountSlot); final rawMessages = response.objects('messages') ?? const >[]; final entries = <_LoadedPostComment>[]; @@ -1629,7 +1635,10 @@ class _ChannelMomentsViewState extends State { final comments = []; for (final entry in entries.take(4)) { final message = entry.message; - final senderName = await _senderName(message); + final senderName = await _senderName( + message, + accountSlot: post.accountSlot, + ); comments.add( ChannelPostComment( chatId: entry.chatId, @@ -1703,11 +1712,19 @@ class _ChannelMomentsViewState extends State { post.authorName = previous.authorName; post.authorPhoto = previous.authorPhoto; } - post.message.replyToSender = previous.message.replyToSender; - post.message.replyToPreview = previous.message.replyToPreview; - post.message.replyToImage = previous.message.replyToImage; - post.message.replyToImageWidth = previous.message.replyToImageWidth; - post.message.replyToImageHeight = previous.message.replyToImageHeight; + if (post.message.replyToMessageId == + previous.message.replyToMessageId && + post.message.replyToChatId == previous.message.replyToChatId) { + post.message.replyToSender ??= previous.message.replyToSender; + if (!post.message.isContentRestricted) { + post.message.replyToPreview ??= previous.message.replyToPreview; + post.message.replyToImage ??= previous.message.replyToImage; + post.message.replyToImageWidth ??= + previous.message.replyToImageWidth; + post.message.replyToImageHeight ??= + previous.message.replyToImageHeight; + } + } final likesChanged = _reactionCount(previous.message) != _reactionCount(post.message); final commentsChanged = @@ -1763,18 +1780,18 @@ class _ChannelMomentsViewState extends State { final previousPhoto = post.authorPhoto; try { if (senderId != null && senderId > 0) { - final user = await TdClient.shared.query({ + final user = await TdClient.shared.queryForSlot({ '@type': 'getUser', 'user_id': senderId, - }); + }, post.accountSlot); final name = TDParse.userName(user).trim(); if (name.isNotEmpty) post.authorName = name; post.authorPhoto = TDParse.smallPhoto(user.obj('profile_photo')); } else if (senderId != null && senderId < 0) { - final chat = await TdClient.shared.query({ + final chat = await TdClient.shared.queryForSlot({ '@type': 'getChat', 'chat_id': senderId, - }); + }, post.accountSlot); final name = chat.str('title')?.trim(); if (name != null && name.isNotEmpty) post.authorName = name; post.authorPhoto = TDParse.smallPhoto(chat.obj('photo')); @@ -1794,21 +1811,24 @@ class _ChannelMomentsViewState extends State { Future _loadLikeNamesForPost(ChannelPost post, String key) async { try { - final response = await TdClient.shared.query({ + final response = await TdClient.shared.queryForSlot({ '@type': 'getMessageAddedReactions', 'chat_id': post.channel.id, 'message_id': post.message.id, 'reaction_type': {'@type': 'reactionTypeEmoji', 'emoji': '👍'}, 'offset': '', 'limit': 4, - }); + }, post.accountSlot); final raw = response.objects('added_reactions') ?? response.objects('reactions') ?? const >[]; final names = []; for (final item in raw) { - final name = await _reactionSenderName(item.obj('sender_id')); + final name = await _reactionSenderName( + item.obj('sender_id'), + accountSlot: post.accountSlot, + ); if (name != null && !names.contains(name)) names.add(name); } post.likeNames = names; @@ -1820,25 +1840,29 @@ class _ChannelMomentsViewState extends State { } } - Future _reactionSenderName(Map? sender) async { + Future _reactionSenderName( + Map? sender, { + int? accountSlot, + }) async { + final slot = accountSlot ?? TdClient.shared.activeSlot; try { switch (sender?.type) { case 'messageSenderUser': final userId = sender?.int64('user_id'); if (userId == null) return null; - final user = await TdClient.shared.query({ + final user = await TdClient.shared.queryForSlot({ '@type': 'getUser', 'user_id': userId, - }); + }, slot); final name = TDParse.userName(user).trim(); return name.isEmpty ? null : name; case 'messageSenderChat': final chatId = sender?.int64('chat_id'); if (chatId == null) return null; - final chat = await TdClient.shared.query({ + final chat = await TdClient.shared.queryForSlot({ '@type': 'getChat', 'chat_id': chatId, - }); + }, slot); final name = chat.str('title')?.trim(); return name == null || name.isEmpty ? null : name; } @@ -1846,7 +1870,8 @@ class _ChannelMomentsViewState extends State { return null; } - Future _senderName(ChatMessage message) async { + Future _senderName(ChatMessage message, {int? accountSlot}) async { + final slot = accountSlot ?? TdClient.shared.activeSlot; final senderId = message.senderId; if (senderId == null) { final title = message.senderTitle?.trim(); @@ -1854,17 +1879,17 @@ class _ChannelMomentsViewState extends State { } try { if (senderId > 0) { - final user = await TdClient.shared.query({ + final user = await TdClient.shared.queryForSlot({ '@type': 'getUser', 'user_id': senderId, - }); + }, slot); final name = TDParse.userName(user).trim(); return name.isEmpty ? null : name; } - final chat = await TdClient.shared.query({ + final chat = await TdClient.shared.queryForSlot({ '@type': 'getChat', 'chat_id': senderId, - }); + }, slot); final name = chat.str('title')?.trim(); return name == null || name.isEmpty ? null : name; } catch (_) { @@ -1930,7 +1955,7 @@ class _ChannelMomentsViewState extends State { } return; } - await _sendThreadReply(target, text); + await _sendThreadReply(target, text, accountSlot: post.accountSlot); _replyController.clear(); if (mounted) showToast(context, AppStringKeys.momentsReplied); } catch (e) { @@ -1945,14 +1970,16 @@ class _ChannelMomentsViewState extends State { Future _sendThreadReply( ChannelPostThreadTarget target, - String text, - ) async { + String text, { + int? accountSlot, + }) async { + final slot = accountSlot ?? TdClient.shared.activeSlot; final content = { '@type': 'inputMessageText', 'text': {'@type': 'formattedText', 'text': text}, }; try { - await TdClient.shared.query({ + await TdClient.shared.queryForSlot({ '@type': 'sendMessage', 'chat_id': target.chatId, 'topic_id': { @@ -1960,9 +1987,9 @@ class _ChannelMomentsViewState extends State { 'message_thread_id': target.messageThreadId, }, 'input_message_content': content, - }); + }, slot); } catch (_) { - await TdClient.shared.query({ + await TdClient.shared.queryForSlot({ '@type': 'sendMessage', 'chat_id': target.chatId, 'reply_to': { @@ -1970,7 +1997,7 @@ class _ChannelMomentsViewState extends State { 'message_id': target.messageThreadId, }, 'input_message_content': content, - }); + }, slot); } } @@ -2939,7 +2966,9 @@ class _ChannelPostDetailViewState extends State { Future _loadMe() async { try { - final me = await TdClient.shared.query({'@type': 'getMe'}); + final me = await TdClient.shared.queryForSlot({ + '@type': 'getMe', + }, post.accountSlot); if (!mounted) return; final name = TDParse.userName(me).trim(); setState(() { @@ -2989,14 +3018,14 @@ class _ChannelPostDetailViewState extends State { if (mounted) setState(() => _loading = false); return; } - final response = await TdClient.shared.query({ + final response = await TdClient.shared.queryForSlot({ '@type': 'getMessageThreadHistory', 'chat_id': post.channel.id, 'message_id': post.message.id, 'from_message_id': 0, 'offset': 0, 'limit': 120, - }); + }, post.accountSlot); final rawMessages = response.objects('messages') ?? const >[]; final kept = <_LoadedPostComment>[]; @@ -3016,6 +3045,45 @@ class _ChannelPostDetailViewState extends State { ), ); } + final keptByChatAndId = + <({int chatId, int messageId}), _LoadedPostComment>{ + for (final entry in kept) + (chatId: entry.chatId, messageId: entry.message.id): entry, + }; + final missingRepliesByChat = >{}; + for (final entry in kept) { + final rid = entry.message.replyToMessageId; + final rChatId = entry.message.replyToChatId ?? entry.chatId; + final isPostRoot = + (rChatId == post.channel.id && rid == post.message.id) || + (rChatId == target.chatId && rid == target.messageThreadId); + if (rid == null || + isPostRoot || + keptByChatAndId.containsKey((chatId: rChatId, messageId: rid))) { + continue; + } + missingRepliesByChat.putIfAbsent(rChatId, () => {}).add(rid); + } + final externalReplyTargets = + <({int chatId, int messageId}), ChatMessage>{}; + for (final batch in missingRepliesByChat.entries) { + try { + final res = await TdClient.shared.queryForSlot({ + '@type': 'getMessages', + 'chat_id': batch.key, + 'message_ids': batch.value.toList(), + }, post.accountSlot); + for (final raw + in res.objects('messages') ?? const >[]) { + final quoted = TDParse.message(raw); + if (quoted != null) { + final qChatId = quoted.chatId ?? batch.key; + externalReplyTargets[(chatId: qChatId, messageId: quoted.id)] = + quoted; + } + } + } catch (_) {} + } // One query per distinct sender, in parallel. Resolving inline below // serialized up to 120 getUser/getChat round-trips, mostly repeats. final firstBySender = {}; @@ -3025,18 +3093,34 @@ class _ChannelPostDetailViewState extends State { firstBySender.putIfAbsent(senderId, () => entry.message); } } + for (final quoted in externalReplyTargets.values) { + final senderId = quoted.senderId; + if (senderId != null) { + firstBySender.putIfAbsent(senderId, () => quoted); + } + } final senders = {}; await Future.wait( firstBySender.entries.map((entry) async { senders[entry.key] = await _commentSender(entry.value); }), ); + final externalReplySenders = <({int chatId, int messageId}), String>{}; + for (final entry in externalReplyTargets.entries) { + final quoted = entry.value; + final senderId = quoted.senderId; + final cached = senderId == null ? null : senders[senderId]; + final sender = cached ?? await _commentSender(quoted); + externalReplySenders[entry.key] = sender.name; + } final comments = []; for (final entry in kept) { final message = entry.message; final senderId = message.senderId; final cached = senderId == null ? null : senders[senderId]; final sender = cached ?? await _commentSender(message); + final rid = message.replyToMessageId; + final rChatId = message.replyToChatId ?? entry.chatId; comments.add( ChannelPostComment( chatId: entry.chatId, @@ -3046,7 +3130,11 @@ class _ChannelPostDetailViewState extends State { text: _commentText(message), entities: _commentEntities(message), date: message.date, - replyToMessageId: message.replyToMessageId, + replyToChatId: rid == null ? null : rChatId, + replyToMessageId: rid, + replyToSenderName: rid == null + ? null + : externalReplySenders[(chatId: rChatId, messageId: rid)], reactionCount: _reactionCount(message), ), ); @@ -3080,10 +3168,10 @@ class _ChannelPostDetailViewState extends State { final senderId = message.senderId; try { if (senderId != null && senderId > 0) { - final user = await TdClient.shared.query({ + final user = await TdClient.shared.queryForSlot({ '@type': 'getUser', 'user_id': senderId, - }); + }, post.accountSlot); final name = TDParse.userName(user).trim(); return _CommentSender( name: name.isNotEmpty @@ -3093,10 +3181,10 @@ class _ChannelPostDetailViewState extends State { ); } if (senderId != null && senderId < 0) { - final chat = await TdClient.shared.query({ + final chat = await TdClient.shared.queryForSlot({ '@type': 'getChat', 'chat_id': senderId, - }); + }, post.accountSlot); final name = chat.str('title')?.trim(); return _CommentSender( name: name == null || name.isEmpty @@ -3131,7 +3219,7 @@ class _ChannelPostDetailViewState extends State { try { final replyMessageId = replyTo?.messageId; if (replyMessageId == null) { - await TdClient.shared.query({ + await TdClient.shared.queryForSlot({ '@type': 'sendMessage', 'chat_id': target.chatId, 'topic_id': { @@ -3139,10 +3227,10 @@ class _ChannelPostDetailViewState extends State { 'message_thread_id': target.messageThreadId, }, 'input_message_content': content, - }); + }, post.accountSlot); } else { try { - await TdClient.shared.query({ + await TdClient.shared.queryForSlot({ '@type': 'sendMessage', 'chat_id': target.chatId, 'topic_id': { @@ -3154,9 +3242,9 @@ class _ChannelPostDetailViewState extends State { 'message_id': replyMessageId, }, 'input_message_content': content, - }); + }, post.accountSlot); } catch (_) { - await TdClient.shared.query({ + await TdClient.shared.queryForSlot({ '@type': 'sendMessage', 'chat_id': target.chatId, 'reply_to': { @@ -3164,7 +3252,7 @@ class _ChannelPostDetailViewState extends State { 'message_id': replyMessageId, }, 'input_message_content': content, - }); + }, post.accountSlot); } } _replyController.clear(); @@ -3407,12 +3495,17 @@ class _CommentThreadList extends StatelessWidget { ), ); } - final byId = {for (final comment in comments) comment.messageId: comment}; - final roots = comments.where((comment) => _isRoot(comment, byId)).toList(); + final byChatAndId = <({int chatId, int messageId}), ChannelPostComment>{ + for (final comment in comments) + (chatId: comment.chatId, messageId: comment.messageId): comment, + }; + final roots = comments + .where((comment) => _isRoot(comment, byChatAndId)) + .toList(); final childrenByRoot = >{}; for (final comment in comments) { - if (_isRoot(comment, byId)) continue; - final rootId = _rootId(comment, byId); + if (_isRoot(comment, byChatAndId)) continue; + final rootId = _rootId(comment, byChatAndId); childrenByRoot.putIfAbsent(rootId, () => []).add(comment); } // Flattened once per build so the tiles themselves can be built lazily. @@ -3420,12 +3513,18 @@ class _CommentThreadList extends StatelessWidget { for (final root in roots) { final children = childrenByRoot[root.messageId] ?? const []; - entries.add(_ThreadEntry(comment: root, endsGroup: children.isEmpty)); + entries.add( + _ThreadEntry( + comment: root, + prefix: _rootReplyPrefix(root), + endsGroup: children.isEmpty, + ), + ); for (var i = 0; i < children.length; i++) { entries.add( _ThreadEntry( comment: children[i], - prefix: _replyPrefix(children[i], root, byId), + prefix: _replyPrefix(children[i], root, byChatAndId), nested: true, endsGroup: i == children.length - 1, ), @@ -3456,38 +3555,76 @@ class _CommentThreadList extends StatelessWidget { ); } - bool _isRoot(ChannelPostComment comment, Map byId) { - final replyTo = comment.replyToMessageId; - return replyTo == null || - replyTo == post.message.id || - replyTo == post.threadTarget?.messageThreadId || - !byId.containsKey(replyTo); + ({int chatId, int messageId})? _replyKey(ChannelPostComment comment) { + final rid = comment.replyToMessageId; + if (rid == null) return null; + return (chatId: comment.replyToChatId ?? comment.chatId, messageId: rid); } - int _rootId(ChannelPostComment comment, Map byId) { + bool _isPostRootReply(ChannelPostComment comment) { + final rid = comment.replyToMessageId; + if (rid == null) return true; + final rChatId = comment.replyToChatId ?? comment.chatId; + if (rChatId == post.channel.id && rid == post.message.id) return true; + final target = post.threadTarget; + if (target != null && + rChatId == target.chatId && + rid == target.messageThreadId) { + return true; + } + return false; + } + + bool _isRoot( + ChannelPostComment comment, + Map<({int chatId, int messageId}), ChannelPostComment> byChatAndId, + ) { + if (_isPostRootReply(comment)) return true; + final key = _replyKey(comment); + return key == null || !byChatAndId.containsKey(key); + } + + int _rootId( + ChannelPostComment comment, + Map<({int chatId, int messageId}), ChannelPostComment> byChatAndId, + ) { var current = comment; - final seen = {}; - while (current.replyToMessageId != null && - byId.containsKey(current.replyToMessageId) && - seen.add(current.messageId)) { - final parent = byId[current.replyToMessageId]!; - if (_isRoot(parent, byId)) return parent.messageId; + final seen = <({int chatId, int messageId})>{}; + while (seen.add((chatId: current.chatId, messageId: current.messageId))) { + final key = _replyKey(current); + if (key == null) break; + final parent = byChatAndId[key]; + if (parent == null) break; + if (_isRoot(parent, byChatAndId)) return parent.messageId; current = parent; } return current.messageId; } + String? _rootReplyPrefix(ChannelPostComment comment) { + if (_isPostRootReply(comment)) return null; + final parentName = comment.replyToSenderName?.trim(); + if (parentName == null || parentName.isEmpty) return null; + return AppStrings.t(AppStringKeys.momentsReplyPrefix, { + 'value1': parentName, + }); + } + String? _replyPrefix( ChannelPostComment comment, ChannelPostComment root, - Map byId, + Map<({int chatId, int messageId}), ChannelPostComment> byChatAndId, ) { - final replyTo = comment.replyToMessageId; - if (replyTo == null || replyTo == root.messageId) return null; - final parent = byId[replyTo]; - if (parent == null) return null; + final key = _replyKey(comment); + if (key == null || + (key.chatId == root.chatId && key.messageId == root.messageId)) { + return null; + } + final parent = byChatAndId[key]; + final parentName = parent?.senderName ?? comment.replyToSenderName; + if (parentName == null || parentName.trim().isEmpty) return null; return AppStrings.t(AppStringKeys.momentsReplyPrefix, { - 'value1': parent.senderName, + 'value1': parentName, }); } } @@ -6519,11 +6656,11 @@ class MomentsViewModel extends ChangeNotifier { Future _fetchChannelPostThreadTarget( ChannelPost post, ) async { - final thread = await TdClient.shared.query({ + final thread = await TdClient.shared.queryForSlot({ '@type': 'getMessageThread', 'chat_id': post.channel.id, 'message_id': post.message.id, - }); + }, post.accountSlot); final chatId = thread.int64('chat_id'); final threadId = thread.int64('message_thread_id'); if (chatId == null || threadId == null || threadId == 0) return null; diff --git a/lib/tdlib/td_models.dart b/lib/tdlib/td_models.dart index af9d9872..45dc6fdc 100644 --- a/lib/tdlib/td_models.dart +++ b/lib/tdlib/td_models.dart @@ -747,6 +747,7 @@ class ChatMessage { this.summaryLanguageCode = '', this.canRecognizeSpeech = false, this.replyToMessageId, + this.replyToChatId, this.replyToQuote, this.textQuoteSource, this.textQuoteSourceEntities, @@ -865,6 +866,7 @@ class ChatMessage { // 引用 / reply: the message this one replies to, resolved lazily for the quote. int? replyToMessageId; + int? replyToChatId; MessageTextQuote? replyToQuote; // Rendering can extract tables from the original formatted text. Quotes // must still address the unmodified source text, not that rendered subset. @@ -1642,6 +1644,12 @@ abstract final class TDParse { final replyToMessageId = replyTo?.type == 'messageReplyToMessage' ? replyTo?.int64('message_id') : null; + final rawReplyToChatId = replyTo?.type == 'messageReplyToMessage' + ? replyTo?.int64('chat_id') + : null; + final replyToChatId = rawReplyToChatId == null || rawReplyToChatId == 0 + ? null + : rawReplyToChatId; // Which forum topic this message belongs to. TDLib reports it as a // MessageTopic: messageTopicForum carries the forum topic id, while @@ -1741,6 +1749,7 @@ abstract final class TDParse { summaryCard: summaryCard(message, content), summaryLanguageCode: message.str('summary_language_code') ?? '', replyToMessageId: isContentRestricted ? null : replyToMessageId, + replyToChatId: isContentRestricted ? null : replyToChatId, replyToQuote: isContentRestricted ? null : textQuote(replyTo?.obj('quote')), diff --git a/test/message_replies_sheet_test.dart b/test/message_replies_sheet_test.dart index ef2391bb..c5c95cd9 100644 --- a/test/message_replies_sheet_test.dart +++ b/test/message_replies_sheet_test.dart @@ -1,16 +1,52 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mithka/chat/chat_message_merge.dart'; +import 'package:mithka/chat/chat_view_model.dart'; import 'package:mithka/chat/message_action_menu.dart'; import 'package:mithka/chat/message_bubble.dart'; import 'package:mithka/chat/message_replies_sheet.dart'; import 'package:mithka/chat/rich_text_format.dart'; import 'package:mithka/components/app_icons.dart'; +import 'package:mithka/l10n/app_localizations.dart'; +import 'package:mithka/moments/moments_view.dart'; +import 'package:mithka/tdlib/td_client.dart'; import 'package:mithka/tdlib/td_models.dart'; +import 'package:mithka/theme/app_theme.dart'; import 'package:mithka/theme/theme_controller.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late StreamController> updates; + late Future> Function(Map request) + queryHandler; + + setUpAll(() { + updates = StreamController>.broadcast(); + TdClient.shared.configureProxy( + TdClientProxyTransport( + accountSlot: 4, + query: (request) => queryHandler(request), + send: (_) async {}, + updates: updates.stream, + ), + ); + }); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + queryHandler = (request) async => {'@type': 'ok'}; + }); + + tearDownAll(() async { + await updates.close(); + }); + test('reply sheet requests share channel and nested reply routing', () { const formatted = FormattedTextPayload('Nested reply', []); final forum = buildReplySheetTextRequest( @@ -79,7 +115,6 @@ void main() { testWidgets('reply sheet items render video and structured rich content', ( tester, ) async { - SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final theme = ThemeController(prefs); addTearDown(theme.dispose); @@ -189,4 +224,1974 @@ void main() { expect(played, same(video)); expect(tester.takeException(), isNull); }); + + test( + 'mergeChatMessages preserves resolved reply anchor fields on refresh', + () { + final existing = + ChatMessage( + id: 301, + isOutgoing: false, + text: 'Comment reply', + date: 10, + replyToMessageId: 201, + replyToDate: 5, + replyToEntities: const [ + MessageTextEntity( + offset: 0, + length: 4, + type: 'textEntityTypeBold', + ), + ], + ) + ..replyToSender = 'Bob' + ..replyToPreview = 'Bold parent comment'; + + final refreshed = ChatMessage( + id: 301, + isOutgoing: false, + text: 'Comment reply', + date: 10, + replyToMessageId: 201, + commentCount: 1, + commentThreadMetadataKnown: true, + ); + + final merged = mergeChatMessages([existing], [refreshed]); + expect(merged.single.replyToSender, 'Bob'); + expect(merged.single.replyToPreview, 'Bold parent comment'); + expect(merged.single.replyToDate, 5); + expect(merged.single.replyToEntities, hasLength(1)); + }, + ); + + test( + 'ChatViewModel resolves cross-chat reply targets using replyToChatId', + () async { + final getMessagesRequests = >[]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessages': + getMessagesRequests.add(Map.from(request)); + final chatId = request['chat_id'] as int; + if (chatId == -1001) { + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 42, + 'chat_id': -1001, + 'is_outgoing': false, + 'date': 10, + 'sender_id': { + '@type': 'messageSenderChat', + 'chat_id': -1001, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Channel post anchor', + 'entities': >[], + }, + }, + }, + ], + }; + } + return { + '@type': 'messages', + 'messages': >[], + }; + case 'getChat': + return { + '@type': 'chat', + 'id': request['chat_id'], + 'title': request['chat_id'] == -1001 + ? 'News Channel' + : 'Discussion Group', + }; + case 'getChatHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 501, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -1001, + 'message_id': 42, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Commenting on the channel post', + 'entities': >[], + }, + }, + }, + ], + }; + default: + return {'@type': 'ok'}; + } + }; + + final vm = ChatViewModel( + chatId: -2002, + title: 'Discussion Group', + markReadOnOpen: true, + ); + addTearDown(vm.dispose); + vm.onAppear(); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + + expect(getMessagesRequests, isNotEmpty); + expect(getMessagesRequests.first['chat_id'], -1001); + expect(getMessagesRequests.first['message_ids'], [42]); + expect(vm.messages.single.replyToPreview, 'Channel post anchor'); + expect(vm.messages.single.replyToSender, 'News Channel'); + }, + ); + + testWidgets( + 'reply sheet resolves uncached comment reply anchors and jumps to discussion targets', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + final getMessagesRequests = >[]; + + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': id == -2002 ? 'Discussion Group' : 'News Channel', + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': id == 11 ? 'Alice' : 'Bob', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 300, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 30, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -2002, + 'message_id': 200, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Reply to an older comment', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + getMessagesRequests.add(Map.from(request)); + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 200, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Older parent comment', + 'entities': >[], + }, + }, + }, + ], + }; + default: + return {'@type': 'ok'}; + } + }; + + MessageRepliesViewTarget? viewedInChat; + int? openedInSourceChat; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: 2, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onOpenReply: (id) => openedInSourceChat = id, + onViewInChat: (target) => viewedInChat = target, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + expect(getMessagesRequests, hasLength(1)); + expect(getMessagesRequests.single['chat_id'], -2002); + expect(getMessagesRequests.single['message_ids'], [200]); + expect(find.byKey(const ValueKey('messageReplyQuote')), findsOneWidget); + expect( + find.text('Older parent comment', findRichText: true), + findsOneWidget, + ); + expect(find.textContaining('Bob', findRichText: true), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('messageReplyOpenOriginal'))); + await tester.pumpAndSettle(); + + expect(openedInSourceChat, isNull); + expect(viewedInChat, isNotNull); + expect(viewedInChat!.chatId, -2002); + expect(viewedInChat!.messageId, 200); + expect(viewedInChat!.title, 'Discussion Group'); + }, + ); + + testWidgets( + 'ChannelPostDetailView resolves parent sender anchor when parent comment is outside the thread window', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + queryHandler = (request) async { + switch (request['@type']) { + case 'getMe': + return { + '@type': 'user', + 'id': 1, + 'first_name': 'Me', + 'last_name': '', + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': id == 11 ? 'Alice' : 'Bob', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 300, + 'chat_id': 99, + 'is_outgoing': false, + 'date': 30, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': 99, + 'message_id': 200, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Child comment body', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 200, + 'chat_id': 99, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Parent comment body', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessageAvailableReactions': + return { + '@type': 'availableReactions', + 'top_reactions': >[], + 'recent_reactions': >[], + 'popular_reactions': >[], + 'allow_custom_emoji': false, + 'are_tags': false, + }; + default: + return {'@type': 'ok'}; + } + }; + + final channel = ChatSummary( + id: 42, + title: 'Channel', + lastMessage: '', + lastMessageId: 7, + date: 1, + unreadCount: 0, + order: 1, + isMuted: false, + kind: ChatKind.channel, + ); + final post = ChannelPost( + channel: channel, + message: ChatMessage( + id: 7, + isOutgoing: false, + text: 'Moment post', + date: 1, + contentType: 'messageText', + hasCommentThread: true, + ), + accountSlot: 4, + threadTarget: const ChannelPostThreadTarget( + chatId: 99, + messageThreadId: 500, + ), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: ChannelPostDetailView(post: post, showBackButton: false), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Bob', findRichText: true), findsOneWidget); + expect( + find.text('Child comment body', findRichText: true), + findsOneWidget, + ); + }, + ); + + test( + 'mergeChatMessages drops stale reply metadata when reply target is cleared or changed', + () { + final existing = + ChatMessage( + id: 301, + isOutgoing: false, + text: 'Comment reply', + date: 10, + replyToMessageId: 201, + replyToChatId: -2002, + replyToDate: 5, + replyToEntities: const [ + MessageTextEntity( + offset: 0, + length: 3, + type: 'textEntityTypeBold', + ), + ], + ) + ..replyToSender = 'Bob' + ..replyToPreview = 'Old parent comment'; + + final cleared = ChatMessage( + id: 301, + isOutgoing: false, + text: 'Comment reply', + date: 10, + ); + final afterClear = mergeChatMessages([existing], [cleared]).single; + expect(afterClear.replyToSender, isNull); + expect(afterClear.replyToPreview, isNull); + expect(afterClear.replyToDate, isNull); + expect(afterClear.replyToEntities, isEmpty); + + final changedChat = ChatMessage( + id: 301, + isOutgoing: false, + text: 'Comment reply', + date: 10, + replyToMessageId: 201, + replyToChatId: -3003, + ); + final afterChangedChat = mergeChatMessages( + [existing], + [changedChat], + ).single; + expect(afterChangedChat.replyToSender, isNull); + expect(afterChangedChat.replyToPreview, isNull); + }, + ); + + test( + 'ChatViewModel bumps materialized message revision when async reply quote and sender resolve', + () async { + final getMessagesCompleter = Completer>(); + final getUserCompleter = Completer>(); + queryHandler = (request) async { + switch (request['@type']) { + case 'getChatHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 501, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -1001, + 'message_id': 42, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Commenting on the channel post', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + return getMessagesCompleter.future; + case 'getUser': + if (request['user_id'] == 99) return getUserCompleter.future; + return { + '@type': 'user', + 'id': request['user_id'], + 'first_name': 'Alice', + 'last_name': '', + }; + default: + return {'@type': 'ok'}; + } + }; + + final vm = ChatViewModel( + chatId: -2002, + title: 'Discussion Group', + markReadOnOpen: true, + ); + addTearDown(vm.dispose); + vm.onAppear(); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + + // Materialize the per-message revision listenable while the reply target + // lookup is still in flight (as ChatView's ValueListenableBuilder does). + final revision = vm.messageRevisionListenable(501); + final initialRevision = revision.value; + expect(vm.messages.single.replyToPreview, isNull); + + getMessagesCompleter.complete({ + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 42, + 'chat_id': -1001, + 'is_outgoing': false, + 'date': 10, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 99}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Channel post anchor', + 'entities': >[], + }, + }, + }, + ], + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + final afterQuoteRevision = revision.value; + expect(vm.messages.single.replyToPreview, 'Channel post anchor'); + expect(afterQuoteRevision, greaterThan(initialRevision)); + + getUserCompleter.complete({ + '@type': 'user', + 'id': 99, + 'first_name': 'QuotedAuthor', + 'last_name': '', + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(vm.messages.single.replyToSender, 'QuotedAuthor'); + expect(revision.value, greaterThan(afterQuoteRevision)); + }, + ); + + testWidgets( + 'reply sheet disambiguates cross-chat reply targets with colliding message IDs and opens the target chat', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + final getMessagesRequests = >[]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': switch (id) { + -2002 => 'Discussion Group', + -3003 => 'External Channel', + _ => 'News Channel', + }, + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': switch (id) { + 11 => 'Alice', + 22 => 'LocalBob', + 33 => 'ExternalCarol', + _ => 'User', + }, + 'last_name': '', + }; + case 'getMessageThreadHistory': + return { + '@type': 'messages', + 'messages': [ + // In-sheet comment with id 200 in the discussion chat (-2002) + { + '@type': 'message', + 'id': 200, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'In-sheet discussion comment 200', + 'entities': >[], + }, + }, + }, + // Comment 300 replying to message 200 in a DIFFERENT chat (-3003) + { + '@type': 'message', + 'id': 300, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 30, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -3003, + 'message_id': 200, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Reply to external message 200', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + getMessagesRequests.add(Map.from(request)); + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 200, + 'chat_id': -3003, + 'is_outgoing': false, + 'date': 15, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 33}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'External channel message 200', + 'entities': >[], + }, + }, + }, + ], + }; + default: + return {'@type': 'ok'}; + } + }; + + MessageRepliesViewTarget? viewedInChat; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: 2, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onViewInChat: (target) => viewedInChat = target, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + // Must fetch (-3003, 200) via getMessages rather than reusing (-2002, 200). + expect(getMessagesRequests, hasLength(1)); + expect(getMessagesRequests.single['chat_id'], -3003); + expect(getMessagesRequests.single['message_ids'], [200]); + expect( + find.text('External channel message 200', findRichText: true), + findsOneWidget, + ); + expect( + find.textContaining('ExternalCarol', findRichText: true), + findsOneWidget, + ); + + // Tapping the quote opens chat -3003 ('External Channel'), not the + // in-sheet comment or the discussion group -2002. + await tester.tap(find.byKey(const ValueKey('messageReplyOpenOriginal'))); + await tester.pumpAndSettle(); + + expect(viewedInChat, isNotNull); + expect(viewedInChat!.chatId, -3003); + expect(viewedInChat!.messageId, 200); + expect(viewedInChat!.title, 'External Channel'); + }, + ); + + testWidgets( + 'ChannelPostDetailView keys reply targets by (chatId, messageId) when IDs collide across chats', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + final getMessagesRequests = >[]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMe': + return { + '@type': 'user', + 'id': 1, + 'first_name': 'Me', + 'last_name': '', + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': switch (id) { + 11 => 'Alice', + 22 => 'LocalBob', + 33 => 'ExternalCarol', + _ => 'User', + }, + 'last_name': '', + }; + case 'getMessageThreadHistory': + return { + '@type': 'messages', + 'messages': [ + // In-thread comment 200 in discussion chat 99 + { + '@type': 'message', + 'id': 200, + 'chat_id': 99, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'In-thread comment 200', + 'entities': >[], + }, + }, + }, + // Comment 300 in discussion chat 99 replying to external message + // (chat 777, message 200) which shares id 200 + { + '@type': 'message', + 'id': 300, + 'chat_id': 99, + 'is_outgoing': false, + 'date': 30, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': 777, + 'message_id': 200, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Reply to external message 200', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + getMessagesRequests.add(Map.from(request)); + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 200, + 'chat_id': 777, + 'is_outgoing': false, + 'date': 15, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 33}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'External message 200', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessageAvailableReactions': + return { + '@type': 'availableReactions', + 'top_reactions': >[], + 'recent_reactions': >[], + 'popular_reactions': >[], + 'allow_custom_emoji': false, + 'are_tags': false, + }; + default: + return {'@type': 'ok'}; + } + }; + + final channel = ChatSummary( + id: 42, + title: 'Channel', + lastMessage: '', + lastMessageId: 7, + date: 1, + unreadCount: 0, + order: 1, + isMuted: false, + kind: ChatKind.channel, + ); + final post = ChannelPost( + channel: channel, + message: ChatMessage( + id: 7, + isOutgoing: false, + text: 'Moment post', + date: 1, + contentType: 'messageText', + hasCommentThread: true, + ), + accountSlot: 4, + threadTarget: const ChannelPostThreadTarget( + chatId: 99, + messageThreadId: 500, + ), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: ChannelPostDetailView(post: post, showBackButton: false), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(getMessagesRequests, hasLength(1)); + expect(getMessagesRequests.single['chat_id'], 777); + expect(getMessagesRequests.single['message_ids'], [200]); + expect( + find.textContaining('ExternalCarol', findRichText: true), + findsOneWidget, + ); + }, + ); + + test( + 'ChatViewModel drops a held reply lookup once the reply retargets to another chat with the same message id', + () async { + final heldResponses = >>{}; + final heldChatIds = []; + queryHandler = (request) async { + switch (request['@type']) { + case 'getChatHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 501, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -1001, + 'message_id': 42, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Commenting on the channel post', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + final chatId = request['chat_id'] as int; + heldChatIds.add(chatId); + return heldResponses[chatId]!; + default: + return {'@type': 'ok'}; + } + }; + + // Hold the lookup for chat A / message 42. + final heldForChannel = Completer>(); + heldResponses[-1001] = heldForChannel.future; + final vm = ChatViewModel( + chatId: -2002, + title: 'Discussion Group', + markReadOnOpen: true, + ); + addTearDown(vm.dispose); + vm.onAppear(); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + expect(heldChatIds, contains(-1001)); + expect(vm.messages.single.replyToPreview, isNull); + + // The reply refreshes pointing at chat B / message 42 — same numeric id, + // different chat. Its own lookup is held too, so both responses arrive + // out of order. + final refreshed = ChatMessage( + id: 501, + isOutgoing: false, + text: 'Commenting on the other channel post', + date: 21, + chatId: -2002, + replyToMessageId: 42, + replyToChatId: -3003, + ); + final heldForOther = Completer>(); + heldResponses[-3003] = heldForOther.future; + vm.mergeMessageForTesting(refreshed); + vm.applyLiveUpdateForTesting({ + '@type': 'updateNewMessage', + 'message': { + '@type': 'message', + 'id': 501, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 21, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -3003, + 'message_id': 42, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Commenting on the other channel post', + 'entities': >[], + }, + }, + }, + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(heldChatIds, contains(-3003)); + + // Now complete the STALE lookup for chat A. + heldForChannel.complete({ + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 42, + 'chat_id': -1001, + 'is_outgoing': false, + 'date': 10, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 99}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Channel A anchor', + 'entities': >[], + }, + }, + }, + ], + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(vm.messages.single.replyToPreview, isNull); + + heldForOther.complete({ + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 42, + 'chat_id': -3003, + 'is_outgoing': false, + 'date': 12, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 33}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Channel B anchor', + 'entities': >[], + }, + }, + }, + ], + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(vm.messages.single.replyToPreview, 'Channel B anchor'); + }, + ); + + test( + 'ChatViewModel never rehydrates a restriction-redacted reply from plaintext or a redacted quote', + () async { + final heldResponses = >>{}; + final heldChatIds = []; + queryHandler = (request) async { + switch (request['@type']) { + case 'getChatHistory': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 501, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 20, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -1001, + 'message_id': 42, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Commenting on the channel post', + 'entities': >[], + }, + }, + }, + ], + }; + case 'getMessages': + final chatId = request['chat_id'] as int; + heldChatIds.add(chatId); + return heldResponses[chatId]!; + default: + return {'@type': 'ok'}; + } + }; + + final heldForChannel = Completer>(); + heldResponses[-1001] = heldForChannel.future; + final vm = ChatViewModel( + chatId: -2002, + title: 'Discussion Group', + markReadOnOpen: true, + ); + addTearDown(vm.dispose); + vm.onAppear(); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + expect(heldChatIds, contains(-1001)); + + // A restriction-redacted refresh of the same reply arrives first: it + // withdraws the quote entirely. + vm.mergeMessageForTesting( + ChatMessage( + id: 501, + isOutgoing: false, + text: 'Commenting on the channel post', + date: 21, + chatId: -2002, + restrictionReason: 'pornography', + ), + ); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(vm.messages.single.isContentRestricted, isTrue); + expect(vm.messages.single.replyToMessageId, isNull); + + // The held response lands after the restriction: no cached plaintext may + // be rehydrated onto the redacted message. + heldForChannel.complete({ + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 42, + 'chat_id': -1001, + 'is_outgoing': false, + 'date': 10, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 99}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Channel post anchor', + 'entities': >[], + }, + }, + }, + ], + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(vm.messages.single.replyToPreview, isNull); + expect(vm.messages.single.replyToSender, isNull); + expect(vm.messages.single.replyToDate, isNull); + + // And a redacted RESPONSE is a withdrawal, not an anchor: a fresh reply + // waiting on the same (chat, message) must stay unquoted. + final heldForRedacted = Completer>(); + heldResponses[-4004] = heldForRedacted.future; + vm.historyReachesLatestForTesting(); + vm.applyLiveUpdateForTesting({ + '@type': 'updateNewMessage', + 'message': { + '@type': 'message', + 'id': 502, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 22, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 7}, + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -4004, + 'message_id': 9, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Reply to a redacted parent', + 'entities': >[], + }, + }, + }, + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(heldChatIds, contains(-4004)); + heldForRedacted.complete({ + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': 9, + 'chat_id': -4004, + 'is_outgoing': false, + 'date': 11, + 'restriction_info': {'restriction_reason': 'pornography'}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Redacted parent text', + 'entities': >[], + }, + }, + }, + ], + }); + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + final quoted = vm.messages.firstWhere((message) => message.id == 502); + expect(quoted.replyToPreview, isNull); + expect(quoted.replyToSender, isNull); + }, + ); + + testWidgets( + 'reply sheet keeps a discussion comment whose id collides with the channel post id', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + final comments = >[ + { + '@type': 'message', + 'id': 42, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 21, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Discussion comment sharing the post id', + 'entities': >[], + }, + }, + }, + { + '@type': 'message', + 'id': 300, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 30, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 12}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Second discussion comment', + 'entities': >[], + }, + }, + }, + ]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': id == -2002 ? 'Discussion Group' : 'News Channel', + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': 'User $id', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return {'@type': 'messages', 'messages': comments}; + default: + return {'@type': 'ok'}; + } + }; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: 2, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onViewInChat: (_) {}, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + // Both comments render: the (-2002, 42) comment is NOT the (-1001, 42) + // channel root, even though the ids match. + expect(find.byType(MessageBubble), findsNWidgets(2)); + expect( + find.text('Discussion comment sharing the post id', findRichText: true), + findsOneWidget, + ); + expect( + find.text('Second discussion comment', findRichText: true), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'reply sheet jump materializes a parent comment below long variable-height rows', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + // 100 discussion comments; comments 1..10 carry 100 lines each, so the + // target (50) sits far below ~1750px of text and above a comment 100 + // that replies to it. A proportional jump cannot land on it. + const tallRowMessageCount = 10; + const commentCount = 100; + const targetMessageId = 40050; + const bottomMessageId = 40100; + final longText = List.generate( + 100, + (i) => 'Line $i of a long comment', + ).join('\n'); + final comments = >[ + for (var i = 1; i <= commentCount; i++) + { + '@type': 'message', + 'id': 40000 + i, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': i, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + if (i == commentCount) + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -2002, + 'message_id': targetMessageId, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': i <= tallRowMessageCount ? longText : 'Comment $i', + 'entities': >[], + }, + }, + }, + ]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': id == -2002 ? 'Discussion Group' : 'News Channel', + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': 'User $id', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return {'@type': 'messages', 'messages': comments}; + case 'getMessages': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': targetMessageId, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 50, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Parent comment 50', + 'entities': >[], + }, + }, + }, + ], + }; + default: + return {'@type': 'ok'}; + } + }; + + MessageRepliesViewTarget? viewedInChat; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: commentCount, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onViewInChat: (target) => viewedInChat = target, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + // Rows lazily built: only the top of the sheet exists so far. + expect(find.byType(MessageBubble), findsWidgets); + expect( + find.byKey( + const ValueKey('messageRepliesSheetMessage-$targetMessageId'), + ), + findsNothing, + ); + + // The user scrolls to the bottom first; the bottom row quotes comment 50. + final sheetScrollable = find.byType(Scrollable).first; + final sheetPosition = tester + .state(sheetScrollable) + .position; + final bottomRow = find.byKey( + const ValueKey('messageRepliesSheetMessage-$bottomMessageId'), + ); + for (var i = 0; i < 60 && bottomRow.evaluate().isEmpty; i++) { + sheetPosition.jumpTo(sheetPosition.maxScrollExtent); + await tester.pump(); + } + expect(bottomRow, findsOneWidget); + + // Tap comment 100's quote → the in-sheet jump to comment 50. + await tester.tap(find.byKey(const ValueKey('messageReplyOpenOriginal'))); + await tester.pump(); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + // The jump only succeeded if the target row is materialized AND visible + // in the viewport. + final targetFinder = find.byKey( + const ValueKey('messageRepliesSheetMessage-$targetMessageId'), + ); + expect(targetFinder, findsOneWidget); + final sheetBox = tester.getRect(sheetScrollable); + final targetRect = tester.getRect(targetFinder); + expect( + targetRect.bottom > sheetBox.top && targetRect.top < sheetBox.bottom, + isTrue, + reason: 'parent comment 50 must be visible after the jump', + ); + expect(viewedInChat, isNull); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'the in-sheet jump reaches a parent under phone-width strongly non-uniform rows', + (tester) async { + // The reviewer's distribution at a real phone size: a 390x844 sheet, 100 + // discussion comments, and comments 1..10 each carrying 100 lines of + // long, wrapping text — roughly 4,000 logical pixels per tall row, so + // the parent of comment 100 sits ~45,000 pixels above it and no + // proportional offset can land on it. + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + const tallRowMessageCount = 10; + const commentCount = 100; + const targetMessageId = 40050; + const bottomMessageId = 40100; + final longText = List.generate( + 100, + (i) => + 'Line $i of a long comment that wraps over several visual ' + 'lines in a phone-width sheet, which is what makes the row ' + 'height impossible to guess.', + ).join('\n'); + final comments = >[ + for (var i = 1; i <= commentCount; i++) + { + '@type': 'message', + 'id': 40000 + i, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': i, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + if (i == commentCount) + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -2002, + 'message_id': targetMessageId, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': i <= tallRowMessageCount ? longText : 'Comment $i', + 'entities': >[], + }, + }, + }, + ]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': id == -2002 ? 'Discussion Group' : 'News Channel', + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': 'User $id', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return {'@type': 'messages', 'messages': comments}; + case 'getMessages': + return { + '@type': 'messages', + 'messages': [ + { + '@type': 'message', + 'id': targetMessageId, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': 50, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 22}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Parent comment 50', + 'entities': >[], + }, + }, + }, + ], + }; + default: + return {'@type': 'ok'}; + } + }; + + MessageRepliesViewTarget? viewedInChat; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: commentCount, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onViewInChat: (target) => viewedInChat = target, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + final sheetScrollable = find.byType(Scrollable).first; + final sheetPosition = tester + .state(sheetScrollable) + .position; + + // Scroll to the bottom the way a reader would, letting the sliver + // refine its extent estimate as the late rows materialize. + final bottomRow = find.byKey( + const ValueKey('messageRepliesSheetMessage-$bottomMessageId'), + ); + for (var i = 0; i < 200 && bottomRow.evaluate().isEmpty; i++) { + sheetPosition.jumpTo(sheetPosition.maxScrollExtent); + await tester.pump(); + } + expect(bottomRow, findsOneWidget); + final targetFinder = find.byKey( + const ValueKey('messageRepliesSheetMessage-$targetMessageId'), + ); + expect( + targetFinder, + findsNothing, + reason: 'comment 50 must start out unmaterialized', + ); + + // Tap comment 100's quote: the sheet must sweep to comment 50 and prove + // it visible. + await tester.tap(find.byKey(const ValueKey('messageReplyOpenOriginal'))); + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 16)); + + expect(targetFinder, findsOneWidget); + final sheetBox = tester.getRect(sheetScrollable); + final targetRect = tester.getRect(targetFinder); + expect( + targetRect.bottom > sheetBox.top && targetRect.top < sheetBox.bottom, + isTrue, + reason: 'parent comment 50 must be visible after the jump', + ); + expect(viewedInChat, isNull); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'a failed in-sheet jump falls back to verified in-chat navigation', + (tester) async { + debugForceSheetReplyScrollFailure = true; + addTearDown(() => debugForceSheetReplyScrollFailure = false); + + final prefs = await SharedPreferences.getInstance(); + final theme = ThemeController(prefs); + addTearDown(theme.dispose); + + const targetMessageId = 40002; + final comments = >[ + for (var i = 1; i <= 10; i++) + { + '@type': 'message', + 'id': 40000 + i, + 'chat_id': -2002, + 'is_outgoing': false, + 'date': i, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 11}, + if (i == 10) + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': -2002, + 'message_id': targetMessageId, + }, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Comment $i', + 'entities': >[], + }, + }, + }, + ]; + queryHandler = (request) async { + switch (request['@type']) { + case 'getMessageProperties': + return { + '@type': 'messageProperties', + 'can_get_message_thread': true, + 'can_be_replied': true, + }; + case 'getMessageThread': + return { + '@type': 'messageThreadInfo', + 'chat_id': -2002, + 'message_thread_id': 99, + }; + case 'getChat': + final id = request['chat_id'] as int; + return { + '@type': 'chat', + 'id': id, + 'title': id == -2002 ? 'Discussion Group' : 'News Channel', + 'permissions': {'can_send_basic_messages': true}, + }; + case 'getUser': + final id = request['user_id'] as int; + return { + '@type': 'user', + 'id': id, + 'first_name': 'User $id', + 'last_name': '', + }; + case 'getMessageThreadHistory': + return {'@type': 'messages', 'messages': comments}; + default: + return {'@type': 'ok'}; + } + }; + + final viewed = []; + final opened = []; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: theme, + child: MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: ThemeData(extensions: [AppColors.light]), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showMessageRepliesSheet( + context: context, + chatId: -1001, + message: ChatMessage( + id: 42, + isOutgoing: false, + text: 'Channel post', + date: 10, + chatId: -1001, + commentCount: 10, + hasCommentThread: true, + ), + peerTitle: 'News Channel', + onOpenReply: opened.add, + onViewInChat: viewed.add, + ); + }, + child: const Text('Open comments'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open comments')); + await tester.pumpAndSettle(); + + // Comment 10 quotes comment 2; the scroll cannot reach it, so the tap + // must not be swallowed: it opens the same (chat, message) pair through + // the verified route. + // The sheet is taller than the viewport: bring the quoting row into view + // the way a reader would before tapping it. + final sheetPosition = tester + .state(find.byType(Scrollable).first) + .position; + for ( + var i = 0; + i < 20 && + find + .byKey(const ValueKey('messageReplyOpenOriginal')) + .evaluate() + .isEmpty; + i++ + ) { + sheetPosition.jumpTo(sheetPosition.maxScrollExtent); + await tester.pump(); + } + await tester.tap(find.byKey(const ValueKey('messageReplyOpenOriginal'))); + await tester.pumpAndSettle(); + + expect(viewed, hasLength(1)); + expect(viewed.single.chatId, -2002); + expect(viewed.single.messageId, targetMessageId); + expect( + opened, + isEmpty, + reason: 'the addressed fallback wins over id-only', + ); + expect(tester.takeException(), isNull); + }, + ); }