Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConversationID> = []
/// 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.
Expand Down Expand Up @@ -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<ConversationID> {
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)
}
Expand Down Expand Up @@ -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<ConversationID> {
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading