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
18 changes: 11 additions & 7 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}

Expand All @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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] = [:]

/// 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 @@ -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<ConversationID> {
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 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)
}
Expand Down Expand Up @@ -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<ConversationID> {
joinedGroupIDs
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading