From 4d5d5d1346a97048327817cb9b5412ac1011a1bd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 14:26:34 -0500 Subject: [PATCH 1/2] fix(chat): keep a just-joined group when an older group feed lands After tapping Join, the user could land on an empty "Flipcash User" conversation. A group feed requested before the join can be applied after it; `setGroupFeed` read the group's absence as a departure, removed it from the store and deleted it from the DB, so the open screen could not find its conversation. The store now stamps each membership it seats (join, create, roster join) with a generation. The controller reads `groupFeedMark` before a group feed is requested and passes it to `setGroupFeed(_:requestedAt:)`, which keeps groups seated after that mark, still joined and out of `departed`. A group missing from a feed requested after it was seated is still dropped. --- .../Controllers/ConversationController.swift | 18 ++++++---- .../Conversation/ConversationStore.swift | 25 ++++++++++++-- .../ConversationRosterTests.swift | 33 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 0b31914b4..b1eec1d0f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1247,6 +1247,9 @@ final class ConversationController { private func reconcileFeeds() async -> [Conversation] { let sources: [FeedArrivals.Source] = [.dm(.contactDm), .dm(.tipDm), .groups] let arrivals = FeedArrivals(expecting: sources.count) + // Before any fetch starts: a join that lands while the feeds are in flight is newer than + // the group snapshot, and the apply below must not read its absence as a departure. + let groupFeedMark = store.groupFeedMark let fetches = sources.map { source in Task { [self] in let feed = await fetchFeed(source) @@ -1256,7 +1259,7 @@ final class ConversationController { } if arrivals.isClosed { guard let feed else { return } - withTransaction(Self.unanimated) { applyFeed(feed, from: source) } + withTransaction(Self.unanimated) { applyFeed(feed, from: source, groupFeedMark: groupFeedMark) } await backfillMessages(for: feed) } else { arrivals.arrive(source, feed) @@ -1274,7 +1277,7 @@ final class ConversationController { withTransaction(Self.unanimated) { for (source, feed) in landed { - applyFeed(feed, from: source) + applyFeed(feed, from: source, groupFeedMark: groupFeedMark) } watermarkStamps.release() } @@ -1318,10 +1321,10 @@ final class ConversationController { } } - private func applyFeed(_ feed: [Conversation], from source: FeedArrivals.Source) { + private func applyFeed(_ feed: [Conversation], from source: FeedArrivals.Source, groupFeedMark: UInt64) { switch source { case .dm(let type): applyFeed(feed, type: type) - case .groups: applyGroupFeed(feed) + case .groups: applyGroupFeed(feed, requestedAt: groupFeedMark) } } @@ -1334,8 +1337,9 @@ final class ConversationController { /// this feed, so a type-scoped replace would delete it out from under its own open screen. @discardableResult func loadGroupFeed() async -> [Conversation] { + let mark = store.groupFeedMark guard let groups = await fetchGroupFeed() else { return [] } - applyGroupFeed(groups) + applyGroupFeed(groups, requestedAt: mark) return groups } @@ -1349,8 +1353,8 @@ final class ConversationController { } } - private func applyGroupFeed(_ groups: [Conversation]) { - let departed = store.setGroupFeed(groups) + private func applyGroupFeed(_ groups: [Conversation], requestedAt mark: UInt64) { + let departed = store.setGroupFeed(groups, requestedAt: mark) reconcileHidden() paymentRequests?.forget(conversations: departed) persistChanges(operation: "replace-group-feed") { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 880581330..81499648a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -39,6 +39,11 @@ public struct ConversationStore: Sendable { /// naming the user. Held here rather than on the conversation so a metadata refresh — which /// replaces the whole row — cannot clobber it. private var joinedGroupIDs: Set = [] + /// Counts the memberships the client has seated itself, and stamps each seated group with the + /// count it reached. A group feed requested before a seat cannot have seen it, so + /// ``setGroupFeed(_:requestedAt:)`` compares against these stamps rather than trusting the feed. + private var seatGeneration: UInt64 = 0 + private var seatedAtGeneration: [ConversationID: UInt64] = [:] /// An optimistic send plus the keys that place it in the transcript: `anchor` is the newest /// confirmed server id at send time (the row this send sits after), `sequence` is its send order. @@ -89,13 +94,20 @@ public struct ConversationStore: Sendable { /// upserted and marked joined, and only a group that was previously joined and is absent from this /// feed is dropped: the user left it somewhere else, and the contract says to remove it from the /// chat list. The returned ids are those drops, for the caller to mirror in the cache. + /// + /// `requestedAt` is the ``groupFeedMark`` read before the feed was requested. A group the client + /// joined or created after that point is newer than the feed's snapshot, so its absence is not a + /// departure: it stays in the store, stays a member, and is left out of the returned drops. + /// Omitting it treats the feed as requested now. @discardableResult - public mutating func setGroupFeed(_ groups: [Conversation]) -> Set { + public mutating func setGroupFeed(_ groups: [Conversation], requestedAt mark: UInt64? = nil) -> Set { + let mark = mark ?? seatGeneration + let seatedSince = Set(seatedAtGeneration.filter { $0.value > mark }.keys) let joined = Set(groups.map(\.id)) - let departed = joinedGroupIDs.subtracting(joined) + let departed = joinedGroupIDs.subtracting(joined).subtracting(seatedSince) conversations.removeAll { $0.type == .group && departed.contains($0.id) } reindex() - joinedGroupIDs = joined + joinedGroupIDs = joined.union(seatedSince) for group in groups { upsert(group) } @@ -507,6 +519,10 @@ public struct ConversationStore: Sendable { } } + /// Read this before requesting a group feed and pass it to ``setGroupFeed(_:requestedAt:)`` when + /// the response is applied. + public var groupFeedMark: UInt64 { seatGeneration } + /// The groups the user has joined, for the caller to persist. public var joinedGroups: Set { joinedGroupIDs @@ -517,8 +533,11 @@ public struct ConversationStore: Sendable { public mutating func setMembership(_ isMember: Bool, in conversationID: ConversationID) { if isMember { joinedGroupIDs.insert(conversationID) + seatGeneration += 1 + seatedAtGeneration[conversationID] = seatGeneration } else { joinedGroupIDs.remove(conversationID) + seatedAtGeneration.removeValue(forKey: conversationID) } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift index e89d62fb8..b74c1b5f1 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift @@ -204,6 +204,39 @@ struct ConversationRosterTests { #expect(!store.isMember(of: group(2))) } + @Test("A group seated after a feed was requested survives that feed landing without it") + func seatedGroupSurvivesStaleGroupFeed() { + var store = ConversationStore() + store.setGroupFeed([group(1, lastActivity: 100)]) + + let mark = store.groupFeedMark + // Joined, and created, while the request is in flight. + store.apply(.metadataRefresh(group(2, lastActivity: 200))) + store.setMembership(true, in: conversationID(2)) + store.apply(.metadataRefresh(group(3, lastActivity: 300))) + store.setMembership(true, in: conversationID(3)) + + let departed = store.setGroupFeed([group(1, lastActivity: 100)], requestedAt: mark) + + #expect(departed.isEmpty) + #expect(store.contains(conversationID(2))) + #expect(store.contains(conversationID(3))) + #expect(store.joinedGroups == [conversationID(1), conversationID(2), conversationID(3)]) + } + + @Test("A group missing from a feed requested after it was seated is still dropped") + func seatedGroupDroppedByFreshGroupFeed() { + var store = ConversationStore() + store.apply(.metadataRefresh(group(2, lastActivity: 200))) + store.setMembership(true, in: conversationID(2)) + + let departed = store.setGroupFeed([], requestedAt: store.groupFeedMark) + + #expect(departed == [conversationID(2)]) + #expect(!store.contains(conversationID(2))) + #expect(store.joinedGroups.isEmpty) + } + @Test("The group feed seats membership for the groups it returns") func groupFeedSeatsMembership() { var store = ConversationStore() From 448118b2fd910d547823e7a5cfecb1d140b3f06b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 9 Nov 2026 14:28:43 -0500 Subject: [PATCH 2/2] docs(chat): point the group-feed doc link at setGroupFeed(_:requestedAt:) --- FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 569b36c99..f39b151ec 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -409,7 +409,7 @@ nonisolated extension Database { } /// Mirror a group feed load: upsert the feed's groups, record them as joined, and delete the - /// groups the user left elsewhere (`departed`, as reported by ``ConversationStore/setGroupFeed(_:)``). + /// groups the user left elsewhere (`departed`, as reported by ``ConversationStore/setGroupFeed(_:requestedAt:)``). /// Returns whether any group's last-message row changed. /// /// Deliberately not ``replaceConversationFeed(_:type:)``: the group feed carries only joined