fix(chat): rebuild chat history after logout and gate the tip milestone on it - #1331
Merged
Conversation
…urvive An account that had already sent a tip was shown the new-user tutorial again after signing back in, with "Scan a Tip Card" unticked. The milestone is a read of chat history — ChatMessageDao.hasEverTipped looks for an outgoing message with verb TIPPED — and two things conspired to leave that history empty. Logout wiped the cache. RealSessionController routed AuthState.LoggedOut into ChatCoordinator.reset, which deleted every metadata, message, and member row. Nothing needed it to: FlipcashDatabase names the file from the account entropy, so accounts are already separated by sitting in different databases, and the activity feed has never wiped itself on logout for exactly that reason. Split the coordinator into teardown (connections, jobs, in-memory state) and clearCache (the rows), point logout at teardown, and give deleteAndLogout the explicit wipe — deletion is the one caller that wants the data gone. Account switching goes through logout too, so it stops wiping as a side effect. Login could not rebuild what logout removed. performFeedSync decides which chats need catching up by asking whether a chat has cached messages and whether its stored sequence trails the server's — but it asked after upserting the server's sequence and each chat's lastMessage, so the first read was always true and the second always equal. Both branches were unreachable, leaving each chat holding the single message the feed carried. In the reported case that message was the tip received back, not the tip sent, so hasEverTipped stayed false for good. Snapshot both values before the writes. The delta path had the same shape one level down: performDeltaSync re-read chat_metadata for its starting sequence, which by then held the value the sync had just written, so it asked the server for everything after the server's own latest event. DeltaSyncNeeded now carries the pre-sync sequence. The live gap-fill caller has no write in front of it and keeps reading the row. One gap remains: WalletViewModel.isAwaitingActivity gates on the activity feed's sync state, not the chat cache's, so a cold start can still flash the milestone as incomplete before chat history lands. Closing that needs a "chat history hydrated at least once" signal that doesn't exist yet.
The wallet tab drew "Scan a Tip Card" as outstanding before the chat cache had been reconciled, so an account that had already tipped saw the new-user tutorial for as long as its history took to arrive. `isAwaitingActivity` covered the activity feed's sync state; nothing covered the chat cache. The feed sync cannot serve as that signal. It reports itself `Synced` once the conversation list is written, which is before the per-chat backfill it schedules has run — and that backfill is what surfaces a tip older than a chat's last message. `FeedSyncDelegate` now sends a terminal `Event.CatchUpComplete` after the catch-up loop; because the delegate's events are a FIFO channel drained by one sequential collector in `RealChatCoordinator`, every catch-up item ahead of the marker has finished its suspend call by the time it is routed. Routing it sets `ChatState.historyHydration` to `Hydrated`. A failed sync moves `Unknown` to `Unavailable` instead, so an unreachable server ends the wait rather than extending it; it never downgrades a hydration that already succeeded. `hasEverTipped()` returns `Flow<Boolean?>`, withholding an answer while hydration is `Unknown`. A cached tip still answers immediately — a TIPPED message in the cache is proof whatever the hydration state — so the warm cache path, now the normal one, does not wait on a round-trip. The flow re-subscribes to the Room query on the hydration change rather than combining the two: a combine would emit the pre-backfill answer alongside the "hydrated" flip and reintroduce the flash it is meant to remove. `WalletViewModel` emits nothing while the milestone is null, leaving `onboardingItems` null and the tab on its loading state. A held balance deliberately does not short-circuit this one, unlike the activity-feed clause: it is evidence about money, not about tipping. The cost is that a funded, never-tipped user with a cold cache now waits one chat round-trip before the tab draws.
bmc08gt
added a commit
that referenced
this pull request
Aug 26, 2026
…e presence A feed sync never backfilled a transcript. It asked `hasMessages` to decide whether a chat had ever been fetched, but the same sync writes each chat's last-message preview as a message row moments earlier — so the answer was always yes, and the newest page was never pulled. Chats opened to a single preview message until the user scrolled. Underneath that sat a worse problem: `latest_event_sequence` was written by two parties meaning two different things. The event stream advanced it to the sequence the client had actually applied; the feed sync overwrote it with the server's reported head via `@Insert(REPLACE)`. Once a sync landed, the next `GetDelta(after:)` resumed from head and silently skipped every event in between. The same whole-row replace also zeroed `analytics_counted_through`, re-counting received messages already counted. The column now has one meaning — the cursor the client has applied: - `ChatMetadataDao.upsert` inserts, or updates only the server-owned columns in place. The two client-owned watermarks are never touched by a sync. - `ChatEntityMapper` stops round-tripping the server head. A row starts at 0, meaning "this transcript has never been fetched"; a chat rebuilt from the database reports 0 for the head — unknown, not "no events". - `MessagingDelegate.loadMessages` seats the cursor at the newest page's frontier, and only ever forward. - The sync's decision reads the cursor: unseated means load the newest page (never a delta, which would re-pull the whole history from sequence 0), behind the server head means delta-sync the missed window. The pre-sync snapshot #1331 added comes out with it. That workaround read the cursor and `hasMessages` *before* the sync wrote, and carried the old cursor on `DeltaSyncNeeded`, because the write clobbered the row it was about to read. Nothing clobbers it now: the sync reads the cursor after its own write, and `performDeltaSync` reads it for itself again. This matches the iOS fix in code-ios-app#628.
bmc08gt
added a commit
that referenced
this pull request
Aug 26, 2026
…e presence (#1308) A feed sync never backfilled a transcript. It asked `hasMessages` to decide whether a chat had ever been fetched, but the same sync writes each chat's last-message preview as a message row moments earlier — so the answer was always yes, and the newest page was never pulled. Chats opened to a single preview message until the user scrolled. Underneath that sat a worse problem: `latest_event_sequence` was written by two parties meaning two different things. The event stream advanced it to the sequence the client had actually applied; the feed sync overwrote it with the server's reported head via `@Insert(REPLACE)`. Once a sync landed, the next `GetDelta(after:)` resumed from head and silently skipped every event in between. The same whole-row replace also zeroed `analytics_counted_through`, re-counting received messages already counted. The column now has one meaning — the cursor the client has applied: - `ChatMetadataDao.upsert` inserts, or updates only the server-owned columns in place. The two client-owned watermarks are never touched by a sync. - `ChatEntityMapper` stops round-tripping the server head. A row starts at 0, meaning "this transcript has never been fetched"; a chat rebuilt from the database reports 0 for the head — unknown, not "no events". - `MessagingDelegate.loadMessages` seats the cursor at the newest page's frontier, and only ever forward. - The sync's decision reads the cursor: unseated means load the newest page (never a delta, which would re-pull the whole history from sequence 0), behind the server head means delta-sync the missed window. The pre-sync snapshot #1331 added comes out with it. That workaround read the cursor and `hasMessages` *before* the sync wrote, and carried the old cursor on `DeltaSyncNeeded`, because the write clobbered the row it was about to read. Nothing clobbers it now: the sync reads the cursor after its own write, and `performDeltaSync` reads it for itself again. This matches the iOS fix in code-ios-app#628.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An account that had already sent a tip was shown the new-user tutorial again after signing back in, with "Scan a Tip Card" unticked while the activity feed right below it listed the tip. The milestone is a read of chat history —
ChatMessageDao.hasEverTippedlooks for an outgoing message with verbTIPPED— and two things conspired to leave that history empty.Logout wiped the cache
RealSessionControllerroutedAuthState.LoggedOutintoChatCoordinator.reset(), which deleted every chat metadata, message, and member row. Nothing needed it to:FlipcashDatabase.initnames the database file from the account entropy, so two accounts are already separated by sitting in different files —PersistenceProvidercalls that file swap the protection, which is why it must not short-circuit onisOpen(). The activity feed has never wiped itself on logout for the same reason.The wipe was load-bearing in one place: account deletion reaches
logout()throughdeleteAndLogout. So rather than dropping it,ChatCoordinatornow splits in two:teardown()— connections, jobs, in-memory state. What logout wants.clearCache()— the persisted rows. Called only fromdeleteAndLogout.logoutAndSwitchAccountalso routes throughlogout(), so account switching stops wiping chat history as a side effect.One consequence worth a decision: chat text now survives logout on disk, and the Room database is not SQLCipher-encrypted. That is the same exposure the activity feed already carries, but it is a product call rather than mine.
Login could not rebuild what logout removed
performFeedSyncdecides which chats need catching up by asking two questions — does this chat have cached messages, and does its storedlatestEventSequencetrail the server's. It asked both after upserting the server's sequence onto every metadata row and each chat'slastMessageinto the message table, so the first read was always true and the second always equal. Both branches were unreachable.That left each chat holding the single message the feed carried. In the reported case the newest message in the Jeffy DM was the tip received back at 2:39, not the tip sent at 2:38, so the rehydrated message was inbound and
hasEverTippedstayed false permanently. The fix snapshots both values before the writes.The delta path had the same shape one level down:
performDeltaSyncre-readchat_metadatafor its starting sequence, which by then held the value the sync had just written — asking the server for everything after its own latest event, which is always nothing.Event.DeltaSyncNeedednow carries the pre-sync sequence. The live gap-fill caller has no write in front of it and keeps reading the row.The milestone read the cache before it was complete
Even with the two fixes above, the wallet drew its verdict on a cold cache.
WalletViewModel.isAwaitingActivitycovered the activity feed's sync state; nothing covered the chat cache, so a cold start still flashed "Scan a Tip Card" as outstanding until the history landed.The feed sync cannot be that signal — it reports
Syncedonce the conversation list is written, before the backfill it schedules has run, and that backfill is exactly what surfaces a tip older than a chat's last message.FeedSyncDelegatenow sends a terminalEvent.CatchUpCompleteafter the catch-up loop. Its events are a FIFO channel drained by one sequential collector inRealChatCoordinator, so every catch-up item ahead of the marker has completed its suspend call by the time it is routed; routing it setsChatState.historyHydrationtoHydrated. A failed sync movesUnknowntoUnavailableinstead — an unreachable server ends the wait rather than extending it — and never downgrades a hydration that already succeeded.hasEverTipped()returnsFlow<Boolean?>and withholds an answer while hydration isUnknown. A cached tip still answers immediately, since a TIPPED message in the cache is proof whatever the hydration state, which keeps the warm-cache path — now the normal one — off the round-trip. It re-subscribes to the Room query on the hydration change rather than combining the two flows: a combine emits the pre-backfill answer alongside the "hydrated" flip, which is the same flash in a narrower window.WalletViewModelemits nothing while the milestone is null, soonboardingItemsstays null and the tab holds its loading state. A held balance deliberately does not short-circuit this the way it does the activity-feed clause — it is evidence about money, not about tipping. The cost is a funded, never-tipped user with a cold cache waiting one chat round-trip before the tab draws.Tests
New
FeedSyncCatchUpTest(6),ChatCoordinatorTeardownTest(2),ChatHistoryHydrationTest(4), andWalletMilestoneGatingTest(2), plus two cases inAuthManagerTestpinning that logout leaves the cache anddeleteAndLogoutclears it. Both new suites are written against the specific way this bug hides.ChatHistoryHydrationTestasserts the whole emitted sequence, since the defect is a transientfalseon the way totrueand the settled value is correct either way. The feed-sync harness models Room's write-then-read behaviour instead of using relaxed mocks, which answerfalse/0regardless of what the sync wrote — the exact coupling under test.