diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b1eec1d0f..fa19b04e3 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1358,7 +1358,9 @@ final class ConversationController { reconcileHidden() paymentRequests?.forget(conversations: departed) persistChanges(operation: "replace-group-feed") { - try database.replaceGroupFeed(groups.map(withStoreMembers), departed: departed) + // Only the groups the store still counts as joined are recorded as joined: a group + // the user left after this feed was requested is listed by it, but is not a member. + try database.replaceGroupFeed(groups.filter { store.isMember(of: $0) }.map(withStoreMembers), departed: departed) } resendUnsyncedReadPointers() // Same repair the DM feeds need: the store refuses a tombstone as a preview, so a chat whose diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 81499648a..ac65900da 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -39,11 +39,12 @@ 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] = [:] + /// Counts the membership changes the client has recorded itself (joins and leaves), and stamps + /// each group with the count its latest change reached. A group feed requested before a change + /// cannot have seen it, so ``setGroupFeed(_:requestedAt:)`` compares against these stamps rather + /// than trusting the feed. + private var membershipGeneration: UInt64 = 0 + private var membershipChangedAt: [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. @@ -95,19 +96,23 @@ public struct ConversationStore: Sendable { /// 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. + /// `requestedAt` is the ``groupFeedMark`` read before the feed was requested. A group whose + /// membership the client changed after that point is newer than the feed's snapshot, so local + /// membership wins. One joined since stays a member and is left out of the returned drops even if + /// the feed omits it. One left since stays un-joined even if the feed lists it; its row is kept, + /// because the screen the user left from still renders its gate from it. Omitting `requestedAt` + /// treats the feed as requested now. @discardableResult 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 mark = mark ?? membershipGeneration + let changedSince = Set(membershipChangedAt.filter { $0.value > mark }.keys) + let joinedSince = changedSince.intersection(joinedGroupIDs) + let leftSince = changedSince.subtracting(joinedGroupIDs) let joined = Set(groups.map(\.id)) - let departed = joinedGroupIDs.subtracting(joined).subtracting(seatedSince) + let departed = joinedGroupIDs.subtracting(joined).subtracting(joinedSince) conversations.removeAll { $0.type == .group && departed.contains($0.id) } reindex() - joinedGroupIDs = joined.union(seatedSince) + joinedGroupIDs = joined.subtracting(leftSince).union(joinedSince) for group in groups { upsert(group) } @@ -521,7 +526,7 @@ 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 } + public var groupFeedMark: UInt64 { membershipGeneration } /// The groups the user has joined, for the caller to persist. public var joinedGroups: Set { @@ -533,12 +538,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) } + membershipGeneration += 1 + membershipChangedAt[conversationID] = membershipGeneration } /// Seed memberships from the persisted cache at hydrate time, so a member isn't shown their own diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift index b74c1b5f1..5b0ecf993 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift @@ -224,6 +224,34 @@ struct ConversationRosterTests { #expect(store.joinedGroups == [conversationID(1), conversationID(2), conversationID(3)]) } + @Test("A group left after a feed was requested stays left when that feed still lists it") + func leftGroupStaysLeftOnStaleGroupFeed() { + var store = ConversationStore() + store.setGroupFeed([group(1, lastActivity: 100), group(2, lastActivity: 200)]) + + let mark = store.groupFeedMark + store.setMembership(false, in: conversationID(2)) + + let departed = store.setGroupFeed([group(1, lastActivity: 100), group(2, lastActivity: 200)], requestedAt: mark) + + #expect(departed.isEmpty) + #expect(store.joinedGroups == [conversationID(1)]) + #expect(store.contains(conversationID(2))) + #expect(!store.isMember(of: group(2))) + } + + @Test("A group left, then listed by a feed requested after the leave, is joined again") + func leftGroupRejoinedByFreshGroupFeed() { + var store = ConversationStore() + store.setGroupFeed([group(2, lastActivity: 200)]) + store.setMembership(false, in: conversationID(2)) + + store.setGroupFeed([group(2, lastActivity: 200)], requestedAt: store.groupFeedMark) + + #expect(store.joinedGroups == [conversationID(2)]) + #expect(store.isMember(of: group(2))) + } + @Test("A group missing from a feed requested after it was seated is still dropped") func seatedGroupDroppedByFreshGroupFeed() { var store = ConversationStore()