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/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 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()