From ddd4f6180c0584ac94f65703e5adc6004587677c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 19:28:37 -0400 Subject: [PATCH 1/3] feat(chat): scaffold group chat RPCs and roster-update handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add service-layer support for the four new group-chat RPCs added to the Chat proto service: GetGroupChatFeed, StartChat, JoinChat, and LeaveChat. Each follows ChatService's existing plain Int-rawValue error pattern (ErrorGetGroupChatFeed/ErrorStartChat/ErrorJoinChat/ ErrorLeaveChat), with async FlipClient+Chat wrappers and TransportClassificationTests coverage. ErrorStartChat can't carry the server's flaggedCategory detail on .titleModerated since it stays a plain Int-rawValue enum for consistency with its ChatService siblings — callers only learn the title was rejected, not why. StartChat's group parameters need a wire form for ConversationRules, so add the domain-to-proto direction (ConversationRules, ConversationListenerRule, ConversationSpeakerRule, MinimumBalanceRequirement) alongside the existing proto-to-domain init. Wire the new RosterUpdate/RosterUpdateBatch messages into the event stream: decode them into DecodedRosterUpdate/RosterChange, apply them in ConversationStore by RosterSummary.version (drop if not greater), and handle self-join/self-leave in ConversationController — a join with an embedded chat snapshot inserts it into the feed directly, a leave naming the signed-in user removes it and deletes the local row via the new Database+Conversations.deleteConversation. ChatUpdate.new_messages (field 2) is unused on this client already, so its removal (now reserved) needs no changes here. --- .../Controllers/ConversationController.swift | 40 ++++ .../Clients/Flip API/FlipClient+Chat.swift | 43 ++++ .../Flip API/Services/ChatService.swift | 214 ++++++++++++++++++ .../Conversation/ConversationRules.swift | 48 ++++ .../Conversation/ConversationStore.swift | 33 +++ .../ConversationStreamEvent.swift | 56 +++++ .../Database+Conversations.swift | 12 + .../ConversationStoreTests.swift | 117 +++++++++- .../ConversationStreamEventDecodeTests.swift | 95 ++++++++ .../TransportClassificationTests.swift | 4 + 10 files changed, 656 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 682e36d94..cca314eca 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -222,6 +222,7 @@ final class ConversationController { for await event in events { guard let self else { return } let gap = self.store.apply(event) + self.handleRosterUpdates(event) self.persist(event: event) self.hydrateIfUnknown(event) self.logCounterpartRead(event) @@ -453,6 +454,13 @@ final class ConversationController { switch event { case .chatEvents(let id, _), .lastActivityChanged(let id, _), .readPointersChanged(let id, _): conversationID = id + case .rosterChanged(let id, let updates): + // A self-leave already dropped this conversation on purpose (`handleRosterUpdates` runs + // first) — don't re-fetch a chat the server no longer considers us a member of. + guard !updates.contains(where: { if case .left(let userID) = $0.change { userID == selfUserID } else { false } }) else { + return + } + conversationID = id case .metadataRefresh: return case .typingChanged: @@ -481,6 +489,32 @@ final class ConversationController { } } + /// Applies the two roster effects that need the signed-in user's identity, which the pure + /// `ConversationStore` doesn't hold: a self-join inserts the chat from its embedded snapshot + /// (`metadata` is set only for the member who just joined), and a self-leave drops the chat from + /// the feed and the database. Every other member's join/leave is already folded into the + /// conversation's roster by `store.apply(event)`, persisted generically by `persist(event:)`. + private func handleRosterUpdates(_ event: ConversationStreamEvent) { + guard case .rosterChanged(let conversationID, let updates) = event else { return } + for update in updates { + switch update.change { + case .joined(_, let chat?): + // RosterUpdate.roster_summary is authoritative for versioning; the embedded metadata's + // own roster_summary is not compared separately, so it's overwritten here. + var chat = chat + chat.rosterSummary = update.rosterSummary + store.apply(.metadataRefresh(chat)) + case .joined: + break + case .left(let userID) where userID == selfUserID: + store.remove(conversationID) + persist(operation: "delete-conversation") { try database.deleteConversation(conversationID: conversationID) } + case .left: + break + } + } + } + // MARK: - Feed func loadFeed() async { @@ -643,6 +677,12 @@ final class ConversationController { case .lastActivityChanged(let conversationID, _), .readPointersChanged(let conversationID, _): persistConversation(conversationID) + case .rosterChanged(let conversationID, _): + // Persists whatever the store now holds: a generic in-place roster patch, or a fresh + // self-join `handleRosterUpdates` already inserted. No-ops for a self-leave — that + // conversation was already removed from both the store and the database. + persistConversation(conversationID) + refreshFeedPreview(for: conversationID) case .typingChanged: break } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 891b7cf49..f140af3e7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -34,6 +34,49 @@ extension FlipClient { } } + /// Page the group chat feed to exhaustion against a single pinned snapshot. Unlike a DM feed, the + /// caller's own membership can change mid-read (a join or a leave lands as a `rosterChanged` + /// stream event, not a feed mutation) — the same live-stream caveat as `getDmChatFeed` applies. + public func getGroupChatFeed(owner: KeyPair) async throws -> [Conversation] { + var all: [Conversation] = [] + var pagingToken: Data? + + while true { + let page = try await withCheckedThrowingContinuation { c in + chatService.getGroupChatFeed(owner: owner, pagingToken: pagingToken) { c.resume(with: $0) } + } + all.append(contentsOf: page.conversations) + if !page.hasMore { break } + pagingToken = page.pagingToken + } + + return all + } + + /// Starts a new group chat and returns its metadata on success. `rules` gates who may read/join + /// and who may send; `nil` leaves the chat unrestricted. + public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?) async throws -> Conversation { + try await withCheckedThrowingContinuation { c in + chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules) { c.resume(with: $0) } + } + } + + /// Joins an existing group chat. Fails with `.rulesNotSatisfied` when the caller doesn't meet the + /// chat's `ConversationRules`. + public func joinChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation { + try await withCheckedThrowingContinuation { c in + chatService.joinChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + } + + /// Leaves a group chat. The caller's own `rosterChanged` event (naming itself) is what actually + /// drops the chat from the local feed and database — this call just tells the server to emit it. + public func leaveChat(owner: KeyPair, conversationID: ConversationID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.leaveChat(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + } + public func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] { try await withCheckedThrowingContinuation { c in chatMessagingService.getMessages(owner: owner, conversationID: conversationID, pagingToken: before?.pagingToken) { c.resume(with: $0) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 9c92918e7..f530bec3b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -83,6 +83,130 @@ final class ChatService: Sendable { } } } + struct GroupFeedPage: Sendable { + let conversations: [Conversation] + let pagingToken: Data + let hasMore: Bool + } + + func getGroupChatFeed(owner: KeyPair, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetGroupChatFeedRequest.with { + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getGroupChatFeed(request, options: .unaryDefault) + let error = ErrorGetGroupChatFeed(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch group chat feed") + await MainActor.run { completion(.failure(error)) } + return + } + let page = GroupFeedPage( + conversations: response.chats.map(Conversation.init), + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Starts a new group chat. `pictureBlobID` must already be `READY` (uploaded via + /// `BlobService`); `rules` gate who may read/join and who may send — `nil` means no + /// restrictions. On `.titleModerated` the server also reports which category flagged the + /// title, but that detail isn't surfaced through this Int-rawValue error — callers only see + /// that the title was rejected. + func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_StartChatRequest.with { + $0.group = .with { + $0.title = title + if let pictureBlobID { + $0.picture = .with { $0.value = pictureBlobID.data } + } + if let rules { + $0.rules = rules.proto + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.startChat(request, options: .unaryDefault) + let error = ErrorStartChat(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasChat else { + logger.error("Failed to start chat") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(Conversation(response.chat))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func joinChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_JoinChatRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.joinChat(request, options: .unaryDefault) + let error = ErrorJoinChat(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasChat else { + logger.error("Failed to join chat") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(Conversation(response.chat))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func leaveChat(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_LeaveChatRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.leaveChat(request, options: .unaryDefault) + let error = ErrorLeaveChat(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to leave chat") + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } } // MARK: - Errors - @@ -107,6 +231,52 @@ public enum ErrorGetChat: Int, Error { case rejected = -4 } +public enum ErrorGetGroupChatFeed: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +/// The proto also reports a `flaggedCategory` on `.titleModerated`; this Int-rawValue enum can't +/// carry that payload, so callers only learn the title was rejected, not why. +public enum ErrorStartChat: Int, Error { + case ok + case denied + case titleModerated + case pictureBlobNotAccepted + case invalidRules + case rulesNotSatisfied + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorJoinChat: Int, Error { + case ok + case denied + case notFound + case rulesNotSatisfied + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorLeaveChat: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + extension ErrorGetDmChatFeed: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { @@ -128,3 +298,47 @@ extension ErrorGetChat: ServerError, TransportClassifiableError { } } } + +extension ErrorGetGroupChatFeed: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorStartChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorJoinChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .rulesNotSatisfied: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorLeaveChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 7e3dbfea4..9030e72a9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -68,6 +68,16 @@ extension ConversationRules { } } +extension ConversationRules { + /// Builds the wire form for `StartChatRequest.GroupChatParameters.rules`. + var proto: Flipcash_Chat_V1_Rules { + .with { + $0.listener = listener.map(\.proto) + $0.speaker = speaker.map(\.proto) + } + } +} + /// A single requirement gating reading and joining a chat. See /// `chat.v1.ListenerRules`. public enum ConversationListenerRule: Hashable, Sendable { @@ -92,6 +102,19 @@ extension ConversationListenerRule { } } +extension ConversationListenerRule { + var proto: Flipcash_Chat_V1_ListenerRules { + .with { + switch self { + case .minimumBalance(let requirement): + $0.minimumBalance = requirement.proto + case .staff: + $0.staff = .init() + } + } + } +} + /// A single requirement gating sending messages in a chat. See /// `chat.v1.SpeakerRules`. public enum ConversationSpeakerRule: Hashable, Sendable { @@ -116,6 +139,19 @@ extension ConversationSpeakerRule { } } +extension ConversationSpeakerRule { + var proto: Flipcash_Chat_V1_SpeakerRules { + .with { + switch self { + case .minimumBalance(let requirement): + $0.minimumBalance = requirement.proto + case .staff: + $0.staff = .init() + } + } + } +} + /// Requires holding a minimum balance, denominated in fiat, in an acceptable /// mint. See `chat.v1.MinimumBalanceRequirement`. public struct MinimumBalanceRequirement: Hashable, Sendable { @@ -148,3 +184,15 @@ extension MinimumBalanceRequirement { ) } } + +extension MinimumBalanceRequirement { + var proto: Flipcash_Chat_V1_MinimumBalanceRequirement { + .with { + $0.amount = .with { + $0.currency = amount.currency.rawValue + $0.nativeAmount = amount.doubleValue + } + $0.mints = mints.map { mint in .with { $0.value = mint.data } } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index c52d2918e..87d742b72 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -304,9 +304,42 @@ public struct ConversationStore: Sendable { case .typingChanged: // Typing is ephemeral UI state held by the controller, never the persisted message store. return .none + case .rosterChanged(let conversationID, let updates): + for update in updates { + applyRosterUpdate(update, in: conversationID) + } + return .none + } + } + + /// Apply one live roster change to an already-known conversation: drop it if + /// `rosterSummary.version` isn't greater than the version held (delivery order doesn't matter), + /// else advance the summary and patch the member list. No-ops for a conversation the store doesn't + /// hold yet — a fresh self-join arrives instead via its embedded chat snapshot + /// (`RosterChange.joined(chat:)`), inserted like any other ``ConversationStreamEvent/metadataRefresh(_:)`` + /// by the controller, which alone knows whether the signed-in user is the recipient. + private mutating func applyRosterUpdate(_ update: DecodedRosterUpdate, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + guard update.rosterSummary.version > conversations[index].rosterSummary.version else { return } + conversations[index].rosterSummary = update.rosterSummary + switch update.change { + case .joined(let member, _): + if let memberIndex = conversations[index].members.firstIndex(where: { $0.userID == member.userID }) { + conversations[index].members[memberIndex] = member + } else { + conversations[index].members.append(member) + } + case .left(let userID): + conversations[index].members.removeAll { $0.userID == userID } } } + /// Drops a conversation from the feed entirely — used when the signed-in user leaves (or is + /// removed from) a group chat. No-ops when the conversation isn't held. + public mutating func remove(_ conversationID: ConversationID) { + conversations.removeAll { $0.id == conversationID } + } + /// Advance the contiguous event-log frontier while events arrive gapless and flag a gap the moment /// one is skipped so the caller catches up via `GetDelta`; bump the feed activity to the newest /// `.sent` mutation (an edit/delete carries the message's original low id and must not move the row). diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index 5dc749284..8d2de5fc4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -31,6 +31,12 @@ public enum ConversationStreamEvent: Sendable { /// event log; the controller holds it as transient UI state and the server clears it with a /// stopped/timed-out notification. case typingChanged(conversationID: ConversationID, notifications: [TypingNotification]) + + /// One or more members joined or left the roster. Like `readPointersChanged`, this rides outside + /// the gap-detected event log as a convergent overlay — the store applies each update by + /// `RosterSummary.version` (a greater version wins, drop-if-not-greater), so delivery order + /// doesn't matter. + case rosterChanged(conversationID: ConversationID, updates: [DecodedRosterUpdate]) } /// One durable event in a chat's log: a contiguous run of mutations delivered atomically. `sequence` @@ -79,6 +85,31 @@ public struct MemberReadPointer: Sendable, Hashable { } } +/// One live roster change from a `RosterUpdate`: the chat's roster summary after the change (compared +/// by ``ConversationRosterSummary/version`` — apply a greater version, drop the rest) and what +/// changed. Delivered to every member of the chat, including — for a join — the joining member's +/// other devices and — for a leave — the leaving member themself. +public struct DecodedRosterUpdate: Sendable { + public let rosterSummary: ConversationRosterSummary + public let change: RosterChange + + public init(rosterSummary: ConversationRosterSummary, change: RosterChange) { + self.rosterSummary = rosterSummary + self.change = change + } +} + +/// What changed in a roster update. +public enum RosterChange: Sendable { + /// A member joined. `chat` is the full chat snapshot, set only when the signed-in user is the + /// member who joined — the recipient inserts it into their feed directly, without a refetch. + /// `nil` for every other member's join. + case joined(member: ConversationMember, chat: Conversation?) + /// A member left. Naming the signed-in user means the recipient is no longer a member and should + /// remove the chat from their feed. + case left(userID: UserID) +} + extension ConversationStreamEvent { /// Decodes a raw stream event into zero or more domain events. Pure and @@ -125,6 +156,11 @@ extension ConversationStreamEvent { events.append(.typingChanged(conversationID: conversationID, notifications: typing)) } + let rosterUpdates = update.rosterUpdates.rosterUpdates.compactMap(DecodedRosterUpdate.init) + if !rosterUpdates.isEmpty { + events.append(.rosterChanged(conversationID: conversationID, updates: rosterUpdates)) + } + return events } } @@ -162,3 +198,23 @@ extension DecodedMutation { } } } + +extension DecodedRosterUpdate { + /// Nil when the update carries no roster summary (nothing to version-compare against) or its kind + /// is neither joined nor left (a future oneof case this client doesn't know about yet). + init?(_ proto: Flipcash_Chat_V1_RosterUpdate) { + guard proto.hasRosterSummary else { return nil } + let rosterSummary = ConversationRosterSummary(proto.rosterSummary) + switch proto.kind { + case .memberJoined(let joined): + let member = ConversationMember(joined.member) + let chat = joined.hasMetadata ? Conversation(joined.metadata) : nil + self.init(rosterSummary: rosterSummary, change: .joined(member: member, chat: chat)) + case .memberLeft(let left): + guard let userID = try? UUID(data: left.userID.value) else { return nil } + self.init(rosterSummary: rosterSummary, change: .left(userID: userID)) + case nil: + return nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 07bcb4c23..13d0cdf88 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -307,6 +307,18 @@ nonisolated extension Database { try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) } + /// Removes a conversation the signed-in user has left (or been removed from): its row and member + /// rows. Messages are left in place, orphaned but unread — the same treatment + /// `replaceConversationFeed` gives a conversation that drops out of a feed snapshot. + public func deleteConversation(conversationID: ConversationID) throws { + let c = ConversationTable() + let m = ConversationMemberTable() + try writer.transaction { + try writer.run(c.table.filter(c.id == conversationID.data).delete()) + try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) + } + } + /// Must be called inside a `writer.transaction`. private func writeConversation(_ conversation: Conversation) throws { let c = ConversationTable() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift index e46ab4a9c..21e69ee33 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreTests.swift @@ -396,12 +396,117 @@ struct ConversationStoreTests { #expect(store.selfReadPointer(for: conversationID(1), selfUserID: me) == MessageID(value: 5)) // never backward } - @Test("hasPendingMessages reflects only the optimistic overlay") - func hasPending() { + + + // MARK: - Roster updates + + @Test("a roster update for an already-known member patches it in place and advances the summary") + func rosterUpdatePatchesExistingMember() { + let other = UUID() var store = ConversationStore() - #expect(!store.hasPendingMessages(for: conversationID(1))) - let clientID = UUID() - store.insertPending(pending(clientID, "c"), anchoredTo: 0, into: conversationID(1)) - #expect(store.hasPendingMessages(for: conversationID(1))) + store.setFeed([Conversation( + id: conversationID(1), + members: [ConversationMember(userID: other, displayName: "Old Name")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 1) + )]) + + store.apply(.rosterChanged(conversationID: conversationID(1), updates: [ + DecodedRosterUpdate( + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 2), + change: .joined(member: ConversationMember(userID: other, displayName: "New Name"), chat: nil) + ) + ])) + + let conversation = store.conversations.first { $0.id == conversationID(1) } + #expect(conversation?.members.first { $0.userID == other }?.displayName == "New Name") + #expect(conversation?.rosterSummary.version == 2) + } + + @Test("a roster update whose version doesn't advance past the held one is dropped") + func rosterUpdateDropsStaleVersion() { + let other = UUID() + var store = ConversationStore() + store.setFeed([Conversation( + id: conversationID(1), + members: [ConversationMember(userID: other, displayName: "Current")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 5) + )]) + + store.apply(.rosterChanged(conversationID: conversationID(1), updates: [ + DecodedRosterUpdate( + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 5), // not greater — dropped + change: .joined(member: ConversationMember(userID: other, displayName: "Stale"), chat: nil) + ) + ])) + + let conversation = store.conversations.first { $0.id == conversationID(1) } + #expect(conversation?.members.first?.displayName == "Current") + #expect(conversation?.rosterSummary.version == 5) + } + + @Test("a join for a brand-new member inserts them into the roster") + func rosterUpdateInsertsNewMember() { + let existing = UUID() + let joiner = UUID() + var store = ConversationStore() + store.setFeed([Conversation( + id: conversationID(1), + members: [ConversationMember(userID: existing, displayName: "Existing")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 1) + )]) + + store.apply(.rosterChanged(conversationID: conversationID(1), updates: [ + DecodedRosterUpdate( + rosterSummary: ConversationRosterSummary(memberCount: 2, version: 2), + change: .joined(member: ConversationMember(userID: joiner, displayName: "Joiner"), chat: nil) + ) + ])) + + let members = store.conversations.first { $0.id == conversationID(1) }?.members ?? [] + #expect(members.map(\.userID).contains(joiner)) + #expect(members.count == 2) + } + + @Test("a leave removes the named member from the roster") + func rosterUpdateRemovesMember() { + let leaving = UUID() + var store = ConversationStore() + store.setFeed([Conversation( + id: conversationID(1), + members: [ConversationMember(userID: leaving, displayName: "Leaving")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + rosterSummary: ConversationRosterSummary(memberCount: 1, version: 1) + )]) + + store.apply(.rosterChanged(conversationID: conversationID(1), updates: [ + DecodedRosterUpdate(rosterSummary: ConversationRosterSummary(memberCount: 0, version: 2), change: .left(userID: leaving)) + ])) + + let members = store.conversations.first { $0.id == conversationID(1) }?.members ?? [] + #expect(members.isEmpty) + } + + @Test("a roster update for a conversation the store doesn't hold is a no-op") + func rosterUpdateUnknownConversationNoOp() { + var store = ConversationStore() + store.apply(.rosterChanged(conversationID: conversationID(9), updates: [ + DecodedRosterUpdate(rosterSummary: ConversationRosterSummary(memberCount: 1, version: 1), change: .left(userID: UUID())) + ])) + #expect(store.conversations.isEmpty) + } + + @Test("remove(_:) drops a conversation from the feed") + func removeConversationDropsFromFeed() { + var store = ConversationStore() + store.setFeed([conversation(1, lastActivity: 0), conversation(2, lastActivity: 1)]) + store.remove(conversationID(1)) + #expect(store.conversations.map(\.id) == [conversationID(2)]) } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index a7c1b523c..8fa91ec3d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -230,4 +230,99 @@ struct ConversationStreamEventDecodeTests { #expect(events[0].sequence == 12) #expect(events[0].mutations.isEmpty) // reply content unrepresentable → dropped, event survives } + + // MARK: - Roster updates (ChatUpdate.rosterUpdates) + + private func rosterSummary(_ memberCount: UInt64, _ version: UInt64) -> Flipcash_Chat_V1_RosterSummary { + .with { $0.memberCount = memberCount; $0.version = version } + } + + @Test("a join naming the recipient (metadata set) decodes to .joined with the embedded chat snapshot") + func rosterJoinedAsRecipient() { + let memberBytes = Data((0..<16).map { UInt8($0) }) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.rosterUpdates = .with { + $0.rosterUpdates = [.with { + $0.rosterSummary = rosterSummary(2, 3) + $0.memberJoined = .with { + $0.member = .with { $0.userID = .with { $0.value = memberBytes } } + $0.metadata = .with { $0.chatID = .with { $0.value = conversationBytes } } + } + }] + } + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .rosterChanged(let conversationID, let updates) = decoded.first else { + Issue.record("expected .rosterChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(updates.count == 1) + #expect(updates[0].rosterSummary == ConversationRosterSummary(memberCount: 2, version: 3)) + guard case .joined(let member, let chat) = updates[0].change else { Issue.record("expected .joined"); return } + #expect(member.userID == (try? UUID(data: memberBytes))) + #expect(chat?.id == ConversationID(data: conversationBytes)) + } + + @Test("a join for another member (no metadata) decodes to .joined with a nil chat") + func rosterJoinedAsOtherMember() { + let memberBytes = Data((16..<32).map { UInt8($0) }) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.rosterUpdates = .with { + $0.rosterUpdates = [.with { + $0.rosterSummary = rosterSummary(3, 4) + $0.memberJoined = .with { + $0.member = .with { $0.userID = .with { $0.value = memberBytes } } + } + }] + } + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .rosterChanged(_, let updates) = decoded.first else { Issue.record("expected .rosterChanged"); return } + guard case .joined(_, let chat) = updates[0].change else { Issue.record("expected .joined"); return } + #expect(chat == nil) + } + + @Test("a leave naming a member decodes to .left with that member's userID") + func rosterLeft() { + let memberBytes = Data((0..<16).map { UInt8($0) }) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.rosterUpdates = .with { + $0.rosterUpdates = [.with { + $0.rosterSummary = rosterSummary(1, 5) + $0.memberLeft = .with { $0.userID = .with { $0.value = memberBytes } } + }] + } + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .rosterChanged(_, let updates) = decoded.first else { Issue.record("expected .rosterChanged"); return } + guard case .left(let userID) = updates[0].change else { Issue.record("expected .left"); return } + #expect(userID == (try? UUID(data: memberBytes))) + } + + @Test("an update with no roster summary is dropped rather than decoded without a version to compare") + func rosterUpdateWithoutSummaryDropped() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.rosterUpdates = .with { + $0.rosterUpdates = [.with { + $0.memberLeft = .with { $0.userID = .with { $0.value = Data((0..<16).map { UInt8($0) }) } } + }] + } + } + } + #expect(!ConversationStreamEvent.decode(event).contains { if case .rosterChanged = $0 { true } else { false } }) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 42b12dc45..6e09c1306 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -77,6 +77,10 @@ struct TransportClassificationTests { @Test func errorNotifyIsTyping() { assertClassifies(ErrorNotifyIsTyping.self) } @Test func errorGetDmChatFeed() { assertClassifies(ErrorGetDmChatFeed.self) } @Test func errorGetChat() { assertClassifies(ErrorGetChat.self) } + @Test func errorGetGroupChatFeed() { assertClassifies(ErrorGetGroupChatFeed.self) } + @Test func errorStartChat() { assertClassifies(ErrorStartChat.self) } + @Test func errorJoinChat() { assertClassifies(ErrorJoinChat.self) } + @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } // In-band outcomes fall outside the generic four-case contract. @Test("Explicit server outcomes never retry") From 06358e62572a506a0c194ca2022f313ee070e360 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 19:37:41 -0400 Subject: [PATCH 2/3] fix(chat): carry the flagged category on ErrorStartChat.titleModerated ErrorStartChat dropped the moderation category the server reports on TITLE_MODERATED, so a rejected title could only be reported as "rejected," not why. Android already surfaces this as StartChatError.TitleModerated(flaggedCategory); dropping it on iOS diverges from a contract both clients consume. Convert ErrorStartChat to an associated-value enum modelled on ErrorProfile, which already carries a FlaggedCategory the same way for ProfileService's moderation cases. .titleModerated(category) replaces the payload-less case; the plain-Int siblings (ErrorGetGroupChatFeed/ErrorJoinChat/ErrorLeaveChat) are unchanged since they have no payload to carry. ChatService.startChat now switches on the response result explicitly instead of rebuilding it via rawValue, through a new ErrorStartChat.init(_:flaggedCategory:) that maps StartChatResponse's proto Result to the domain error. Keeping this mapping as a pure, synchronous init (rather than inlining the switch in the async Task body) makes it unit-testable on its own, and means a case inserted upstream can no longer silently renumber this enum the way rawValue mapping could. --- .../Flip API/Services/ChatService.swift | 59 ++++++++++++++----- .../TransportClassificationTests.swift | 21 +++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index f530bec3b..48733a932 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -126,7 +126,7 @@ final class ChatService: Sendable { /// Starts a new group chat. `pictureBlobID` must already be `READY` (uploaded via /// `BlobService`); `rules` gate who may read/join and who may send — `nil` means no /// restrictions. On `.titleModerated` the server also reports which category flagged the - /// title, but that detail isn't surfaced through this Int-rawValue error — callers only see + /// title; `ErrorStartChat.titleModerated` carries it through so callers can say why, not just /// that the title was rejected. func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_StartChatRequest.with { @@ -145,10 +145,14 @@ final class ChatService: Sendable { Task { do { let response = try await service.startChat(request, options: .unaryDefault) - let error = ErrorStartChat(rawValue: response.result.rawValue) ?? .unknown - guard error == .ok, response.hasChat else { + guard response.result == .ok else { logger.error("Failed to start chat") - await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + await MainActor.run { completion(.failure(ErrorStartChat(response.result, flaggedCategory: response.flaggedCategory))) } + return + } + guard response.hasChat else { + logger.error("Failed to start chat") + await MainActor.run { completion(.failure(.unknown)) } return } await MainActor.run { completion(.success(Conversation(response.chat))) } @@ -241,19 +245,20 @@ public enum ErrorGetGroupChatFeed: Int, Error { case rejected = -4 } -/// The proto also reports a `flaggedCategory` on `.titleModerated`; this Int-rawValue enum can't -/// carry that payload, so callers only learn the title was rejected, not why. -public enum ErrorStartChat: Int, Error { - case ok +/// Associated-value error, modelled on `ErrorProfile`, so `.titleModerated` can carry the +/// `flaggedCategory` the server reports for it — the moderation category the plain-Int pattern +/// used by this file's other error enums has no room for. No `.ok` case: a success response +/// resolves to `Conversation` in `startChat`'s `Result`, it never reaches this type. +public enum ErrorStartChat: Error, Sendable, Equatable { case denied - case titleModerated + case titleModerated(Flipcash_Moderation_V1_FlaggedCategory) case pictureBlobNotAccepted case invalidRules case rulesNotSatisfied - case unknown = -1 - case transportFailure = -2 - case cancelled = -3 - case rejected = -4 + case unknown + case transportFailure + case cancelled + case rejected } public enum ErrorJoinChat: Int, Error { @@ -313,7 +318,7 @@ extension ErrorGetGroupChatFeed: ServerError, TransportClassifiableError { extension ErrorStartChat: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { - case .ok, .transportFailure: .suppressed + case .transportFailure: .suppressed case .cancelled: .info case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied: .info case .unknown, .rejected: .error @@ -321,6 +326,32 @@ extension ErrorStartChat: ServerError, TransportClassifiableError { } } +extension ErrorStartChat { + /// Maps a non-`.ok` `StartChatResponse.Result` to its domain error, carrying `flaggedCategory` + /// through on `.titleModerated` rather than flattening it. Pure and synchronous so the mapping is + /// unit-testable without a live RPC. Callers only reach this once they've confirmed `result != .ok`; + /// `.ok` itself resolves to `Conversation` in `ChatService.startChat`, not this type, but is handled + /// here too (as `.unknown`) so the mapping is total over every case of the proto enum. + init(_ result: Flipcash_Chat_V1_StartChatResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) { + switch result { + case .ok: + self = .unknown + case .denied: + self = .denied + case .titleModerated: + self = .titleModerated(flaggedCategory) + case .pictureBlobNotAccepted: + self = .pictureBlobNotAccepted + case .invalidRules: + self = .invalidRules + case .rulesNotSatisfied: + self = .rulesNotSatisfied + case .UNRECOGNIZED: + self = .unknown + } + } +} + extension ErrorJoinChat: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 6e09c1306..206e6e14f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -92,6 +92,27 @@ struct TransportClassificationTests { #expect(!ErrorFetchBalance.parseFailed.isRetryable) } + // `ErrorStartChat` carries a payload on `.titleModerated` (unlike its plain-Int siblings + // above), so its mapping from the wire response needs its own coverage: the flagged category + // must survive, not get flattened into a payload-less case. + @Test("ErrorStartChat.titleModerated carries the flagged category from a TITLE_MODERATED response") + func errorStartChatTitleModeratedCarriesCategory() { + let error = ErrorStartChat(.titleModerated, flaggedCategory: .nsfw) + guard case .titleModerated(let category) = error else { + Issue.record("Expected .titleModerated, got \(error)") + return + } + #expect(category == .nsfw) + #expect(error.reportingLevel == .info) + #expect(!error.isRetryable) + + // Every other result maps to its payload-less case, unaffected by the category argument. + #expect(ErrorStartChat(.denied, flaggedCategory: .nsfw) == .denied) + #expect(ErrorStartChat(.pictureBlobNotAccepted, flaggedCategory: .nsfw) == .pictureBlobNotAccepted) + #expect(ErrorStartChat(.invalidRules, flaggedCategory: .nsfw) == .invalidRules) + #expect(ErrorStartChat(.rulesNotSatisfied, flaggedCategory: .nsfw) == .rulesNotSatisfied) + } + // MARK: - Tier 2: associated-value errors that capture the transport error - // These don't conform to TransportClassifiableError (they carry the error in a // case rather than mapping to a dedicated one), so their `reportingLevel` is From ef9f3aa0371828671ef7f36485ea6623e8ce5dc3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 19:38:28 -0400 Subject: [PATCH 3/3] chore(deps): pin flipcash2-client-protocol to 0.7.0 Group chat RPCs and roster updates need the 0.7.0 contract. The tag does not exist yet, so the exact requirement cannot resolve until it publishes. --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index ad6b01c4b..a9f19a4e9 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.4.0" - case .flipcash2: return "0.6.0" + case .flipcash2: return "0.7.0" } }