fix: keep paging chat history until a visible message is found - #6714
Conversation
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/35254688511/artifacts/10511429758 |
A page fetched from the server (or a single loadMore round) can be made up entirely of messages that are never rendered as their own chat bubble, most notably a burst of REACTION/REACTION_REVOKED/ REACTION_DELETED system messages from repeatedly adding and removing a reaction. Accepting such an all-hidden page as "there is nothing (more) to show" left the chat looking empty or stuck on older history, even though real messages existed just beyond that page. ChatMessageSyncer.pullUntilVisibleMessage() now repeats the fetch, advancing the anchor deeper into history each round, until a round persists a message that will actually be shown, history is exhausted, or a bounded number of rounds is spent. Since the first round re-fetches the anchor message itself (includeLastKnown), a page is only accepted once it contains a visible message other than that already-known anchor - otherwise a heavily-reacted anchor message would immediately satisfy "found a visible message" and the wall of reaction messages right below it would never be paged through. This mirrors how the iOS client handles the same scenario (see https://github.com/nextcloud/talk-ios/blob/2131b55b0b79247121f4ca1eaed8d09bb246bdbc/NextcloudTalk/Chat/NCChatController.m#L148-L168). Fixes #5775 Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ConversationListUpdater.LAST_MESSAGE_HIDDEN_SYSTEM_TYPES answers "can this be a conversation's last-message preview text", which is not the same question as "does this render as its own bubble in an open chat". It is missing THREAD_CREATED and MESSAGE_UNPINNED: both are valid preview texts but ChatViewModel.shouldRemoveMessage() still hides them from the chat itself. Reusing the preview set in pullUntilVisibleMessage() meant a page made up entirely of e.g. THREAD_CREATED messages could fool the "found a visible message" check the same way a page of reaction messages did. Add CHAT_HIDDEN_SYSTEM_MESSAGE_TYPES, a set dedicated to that question and mirroring ChatViewModel.shouldRemoveMessage()'s per-type checks (kept unconditional, so it stays a safe superset of what is truly hidden rather than risking a subset). SyncOutcome now derives newestPersistedMessage (conversation preview, unchanged) and visibleMessageIds (chat visibility) from two separate filters instead of one. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ChatViewModel.handleThreadMessages() hides thread replies (isThread && threadId != its own id) from the main channel view when not viewing that specific thread. That filter needs isThread/threadId, not systemMessageType, so it could not live in CHAT_HIDDEN_SYSTEM_MESSAGE_TYPES - a very active thread could crowd a fetched page with replies and fool pullUntilVisibleMessage the same way reaction or THREAD_CREATED spam did. Add isChatVisibleMessage(), combining the existing hidden-type check with a thread-child check gated on the synced SyncTarget.threadId being null (main channel) vs. set (that thread's own replies are the visible content there, matching ChatViewModel's conversationThreadId == null guard). Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
4b661ab to
99a999b
Compare
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/35356290357/artifacts/10551779899 |
5 rounds (500 messages at the default limit) was not enough to reproduce a real-world reactions-spam scenario during testing. Raising it to 25 is safe: pullUntilVisibleMessage() exits after round 1 for virtually every page, so a higher cap costs nothing in the common case - the extra budget is only ever spent in the exact pathological case it exists to cover, where the previous alternative was getting stuck forever (#5775). Documented the reasoning with example calculations directly on the constant: worst case is MAX_VISIBLE_MESSAGE_ROUNDS * limit messages fetched and persisted before giving up (2,500 at the default limit of 100), roughly a few seconds up to ~10-12s of loading at a rough 300-500ms per round, paid once by the one chat that actually needs it. Also noted, as a known-gap comment in ChatViewModel.loadMoreMessages(), that this is a bounded guess and not an unbounded guarantee: if some room's gap ever exceeds the cap, what happens next is governed by how the caller advances its anchor for the next attempt (currently derived from the UI's filtered message list, not the raw persisted range that loadMoreMessages() already returns and discards) rather than by this constant. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/35360083926/artifacts/10553744462 |
Fix Messages not shown in chat with many users #5775 -> Chat history could stop loading — a chat could look empty, or scrolling up for older messages could get permanently stuck.
Root cause
The server returns chat history as a flat sequence of entries, and not every
entry is something the app actually draws as its own message bubble: system
messages (reactions, thread-created markers, unpin notices, ...) and, outside
of a thread view, other threads' replies are fetched and persisted like any
other message, but the chat UI filters them back out before rendering. A
single fetched page (default limit: 100 entries) has no guaranteed floor on
how much of it is "real" content versus entries the UI will filter away —
it's entirely possible for a page to be 100% filtered-out entries, with the
next actually-visible message sitting just beyond it. The app treated such a
page as "there is nothing (more) to show" and stopped, even though real
messages existed right past the edge of that page.
Concretely, this can happen several ways:
reaction generates its own REACTION/REACTION_REVOKED/REACTION_DELETED
entry. A message that collects many reactions — or has reactions
toggled on/off repeatedly — can push 100+ non-visible entries in a row
into the history, right after it.
that are only shown while viewing that thread; from the main channel's
perspective, a page can be 100% replies belonging to some other thread.
unpin/pin notices can have the same effect, even though none of them
are reactions.
Confirmed on a real device for the reactions case: the same anchor
(
161577) and range (161459..161577) were re-fetched on every scrollgesture with no progress, because the sync had no way to tell "this page has
nothing to show" apart from "there's nothing left". This mirrors a problem
iOS already solved (see the fix's inline reference to
NCChatController.m):keep paging until a page actually contains something visible, instead of
stopping at a fixed message count.
Fix
ChatMessageSyncer.pullUntilVisibleMessage(): repeats the fetch, advancingthe anchor deeper into history each round, until a round persists a message
that will actually render, history is exhausted, or a bounded number of
rounds (5) is spent. Wired into the initial "newest messages" fetch and into
loadMoreMessages(scroll-up-for-older-history).includeLastKnown),so a page is only accepted once it contains a newly visible message —
otherwise a heavily-reacted anchor message satisfies "found something
visible" immediately and the reaction spam right below it never gets paged
through. Found via device testing, not just by inspection.
CHAT_HIDDEN_SYSTEM_MESSAGE_TYPES: a set dedicated to "does this render asits own chat bubble", separate from the pre-existing
ConversationListUpdater.LAST_MESSAGE_HIDDEN_SYSTEM_TYPES(which answers adifferent question — "is this a valid conversation-preview text"). Reusing
the preview set left a gap:
THREAD_CREATEDandMESSAGE_UNPINNEDarevalid preview texts but never get their own bubble, so a page made up
entirely of those could reproduce the same stuck behavior.
isChatVisibleMessage(): also treats a real message as hidden when it's areply belonging to some other thread than the one being synced (mirrors
ChatViewModel.handleThreadMessages()'s main-channel filtering).Known follow-up (not addressed here)
ChatViewModelalso hides all system messages when a room is anannouncement-preset "channel" (
ConversationUtils.isChannel), not just thetypes in
CHAT_HIDDEN_SYSTEM_MESSAGE_TYPES.ChatMessageSyncerhas no way toevaluate that today — it only carries a bare
SyncTarget(user/roomToken/threadId/credentials/url), not the
ConversationModelorSpreedCapabilitythat decision depends on, and it's intentionally usablewithout an open chat (background sync, catch-up jobs). Closing this would mean
looking up the conversation + capabilities per visibility check, which is a
real plumbing change for a narrow slice of rooms — left for a follow-up PR
rather than folded in here.
Testing
ChatMessageSyncerTest: paging through reaction-onlypages, non-reaction hidden types, thread-reply-only pages (main channel vs.
the thread itself), the anchor re-fetch edge case, exhausted history, and
the round-budget give-up path.
same range with no new messages; after the fix, a single scroll paged
through the reaction wall and
revealed the older messages.
🖼️ Screenshots
🏁 Checklist
/backport to stable-xx.x🤖 AI (if applicable)