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/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" } } 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..48733a932 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -83,6 +83,134 @@ 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; `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 { + $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) + guard response.result == .ok else { + logger.error("Failed to start chat") + 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))) } + } 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 +235,53 @@ 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 +} + +/// 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(Flipcash_Moderation_V1_FlaggedCategory) + case pictureBlobNotAccepted + case invalidRules + case rulesNotSatisfied + case unknown + case transportFailure + case cancelled + case rejected +} + +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 +303,73 @@ 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 .transportFailure: .suppressed + case .cancelled: .info + case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied: .info + case .unknown, .rejected: .error + } + } +} + +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 { + 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..206e6e14f 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") @@ -88,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