diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c70cd1a61..abe77f20a 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "17b0facafe1c56af7d903db91424043f8ffefe34d9f2b46ad2c995ed5e6d8172", + "originHash" : "35187cabf7e6599e3b44411595a9f495c04642de9731218289e5f1d9394061ed", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "f44148935e4492047ef2b5dc7c099340346b980a", - "version" : "0.17.0" + "revision" : "4c1315854e44e3f2c6e15f100e6fa819e01eeb9e", + "version" : "0.18.0" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3b475809f..366400250 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -875,7 +875,7 @@ final class ConversationController { // unknown chat here means the feed hasn't landed, and the feed will bring the state // with it. Nothing to fetch. return - case .titleChanged, .pictureChanged, .descriptionChanged: + case .titleChanged, .pictureChanged, .coverPictureChanged, .descriptionChanged: // Only delivered to a chat's members, same reasoning as `.viewerStateChanged`: an unknown // chat here means the feed hasn't landed yet, and it will bring the current title/picture // with it. Nothing to fetch. @@ -1171,6 +1171,9 @@ final class ConversationController { if let picture = conversation.picture { store.applyPictureChanged(picture, in: conversation.id) } + if let coverPicture = conversation.coverPicture { + store.applyCoverPictureChanged(coverPicture, in: conversation.id) + } persistConversation(conversation.id) } @@ -1223,6 +1226,10 @@ final class ConversationController { guard userID == selfUserID else { continue } store.setMembership(false, in: conversationID) persistMembership(false, in: conversationID) + case .membershipChanged: + // Names no user, so there is no own-membership change to record; the store has + // already applied the summary. + continue } } } @@ -1382,7 +1389,8 @@ final class ConversationController { // until the next metadata fetch — the same reason the roster summary is cached. persistConversation(conversationID) case .titleChanged(let conversationID, _), - .pictureChanged(let conversationID, _): + .pictureChanged(let conversationID, _), + .coverPictureChanged(let conversationID, _): // Cached like the roster summary/mute above, so a cold restore shows the edited // title/picture rather than the stale one until the next full metadata fetch. persistConversation(conversationID) diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index f798d1bd7..58eb36f73 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -283,7 +283,7 @@ extension EncryptedChatClient: ConversationEventStreaming { case .metadataRefresh(let conversation): return .metadataRefresh(await opened(conversation)) case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged, - .viewerStateChanged, .titleChanged, .pictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged: + .viewerStateChanged, .titleChanged, .pictureChanged, .coverPictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged: return event } } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 8588d7d0e..b6c51add5 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -542,6 +542,7 @@ extension Analytics { case .denied: "Denied" case .titleModerated: "TitleModerated" case .pictureBlobNotAccepted: "PictureBlobNotAccepted" + case .coverPictureBlobNotAccepted: "CoverPictureBlobNotAccepted" case .invalidRules: "InvalidRules" case .rulesNotSatisfied: "RulesNotSatisfied" case .descriptionModerated: "DescriptionModerated" @@ -553,6 +554,7 @@ extension Analytics { case .notFound: "NotFound" case .titleModerated: "TitleModerated" case .pictureBlobNotAccepted: "PictureBlobNotAccepted" + case .coverPictureBlobNotAccepted: "CoverPictureBlobNotAccepted" case .descriptionModerated: "DescriptionModerated" case .unknown, .transportFailure, .cancelled, .rejected: "Network" } diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 43f4d9ca7..6739d163b 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.6.0" - case .flipcash2: return "0.17.0" + case .flipcash2: return "0.18.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index c3bed0792..d67be5077 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -132,6 +132,33 @@ extension FlipClient { return RosterPage(members: members, rosterSummary: rosterSummary) } + /// A sample of up to 100 recent chatters in a public group, with whether more exist. Unlike + /// ``getRoster(owner:conversationID:)`` it needs no membership, so it is the call for a preview or + /// lobby view. `owner` is optional because a public group needs no auth; a private group or a DM + /// is `.denied`. + public func sampleChatters(owner: KeyPair?, conversationID: ConversationID) async throws -> (chatters: [SampledChatter], hasMore: Bool) { + let page = try await withCheckedThrowingContinuation { c in + chatService.sampleChatters(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + return (page.chatters, page.hasMore) + } + + /// Replaces the signed-in user's featured groups (at most 10, in order) and returns the + /// resulting list. A private group is `.denied`. + public func setFeaturedGroups(owner: KeyPair, conversationIDs: [ConversationID]) async throws -> [Conversation] { + try await withCheckedThrowingContinuation { c in + chatService.setFeaturedGroups(owner: owner, conversationIDs: conversationIDs) { c.resume(with: $0) } + } + } + + /// The groups `username` features, list-view shaped: no members, viewer state, last message or + /// cover picture. `owner` is optional. + public func getFeaturedGroups(owner: KeyPair?, username: Username) async throws -> [Conversation] { + try await withCheckedThrowingContinuation { c in + chatService.getFeaturedGroups(owner: owner, username: username) { c.resume(with: $0) } + } + } + /// The people to suggest after an `@` in a group chat, most relevant first; see /// ``MentionSuggestion``. Fetch it once per composing session and filter it locally. public func getMentionSuggestions(owner: KeyPair, conversationID: ConversationID) async throws -> [MentionSuggestion] { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index e49679512..d92b06461 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -144,7 +144,7 @@ final class ChatService: Sendable { $0.description_p = description } if let pictureBlobID { - $0.picture = .with { $0.value = pictureBlobID.data } + $0.profilePicture = .with { $0.value = pictureBlobID.data } } if let rules { $0.rules = rules.proto @@ -383,7 +383,7 @@ final class ChatService: Sendable { $0.description_p = description } if let pictureBlobID { - $0.picture = .with { $0.blobID = .with { $0.value = pictureBlobID.data } } + $0.profilePicture = .with { $0.blobID = .with { $0.value = pictureBlobID.data } } } $0.auth = owner.authFor(message: $0) } @@ -410,6 +410,100 @@ final class ChatService: Sendable { } } + struct SampledChattersPage: Sendable { + let chatters: [SampledChatter] + let hasMore: Bool + } + + /// Samples up to 100 recent chatters of a public group, the roster alternative for a viewer who + /// is not a member. A private group or a DM is `.denied`. A public group needs no auth, so + /// `owner` is optional. + func sampleChatters(owner: KeyPair?, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_SampleChattersRequest.with { + $0.chatID = conversationID.proto + if let owner { + $0.auth = owner.authFor(message: $0) + } + } + + Task { + do { + let response = try await service.sampleChatters(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to sample chatters", metadata: ["result": "\(response.result)"]) + await MainActor.run { completion(.failure(ErrorSampleChatters(response.result))) } + return + } + let page = SampledChattersPage( + chatters: response.chatters.compactMap(SampledChatter.init), + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + logger.error("Failed to sample chatters at the transport", metadata: ["code": "\(error.code)"]) + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Replaces the signed-in user's featured groups with `conversationIDs` (at most 10, in order) + /// and returns the resulting list. A private group is `.denied`. + func setFeaturedGroups(owner: KeyPair, conversationIDs: [ConversationID], completion: @Sendable @escaping (Result<[Conversation], ErrorSetFeaturedGroups>) -> Void) { + let request = Flipcash_Chat_V1_SetFeaturedGroupsRequest.with { + $0.chatIds = conversationIDs.map(\.proto) + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.setFeaturedGroups(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to set featured groups", metadata: ["result": "\(response.result)"]) + await MainActor.run { completion(.failure(ErrorSetFeaturedGroups(response.result))) } + return + } + let groups = response.featuredGroups.map(Conversation.init) + await MainActor.run { completion(.success(groups)) } + } catch let error as RPCError { + logger.error("Failed to set featured groups at the transport", metadata: ["code": "\(error.code)"]) + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Fetches the groups `username` features. The groups are list-view shaped: no members, viewer + /// state, last message or cover picture. Auth is optional, so `owner` may be `nil`. + func getFeaturedGroups(owner: KeyPair?, username: Username, completion: @Sendable @escaping (Result<[Conversation], ErrorGetFeaturedGroups>) -> Void) { + let request = Flipcash_Chat_V1_GetFeaturedGroupsRequest.with { + $0.username = username.proto + if let owner { + $0.auth = owner.authFor(message: $0) + } + } + + Task { + do { + let response = try await service.getFeaturedGroups(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to fetch featured groups", metadata: ["result": "\(response.result)"]) + await MainActor.run { completion(.failure(ErrorGetFeaturedGroups(response.result))) } + return + } + let groups = response.featuredGroups.map(Conversation.init) + await MainActor.run { completion(.success(groups)) } + } catch let error as RPCError { + logger.error("Failed to fetch featured groups at the transport", metadata: ["code": "\(error.code)"]) + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + // MARK: - Private chats - // The lobby and key-envelope RPCs of a private group. The envelope is passed through as opaque // bytes; this layer never wraps or unwraps a chat key. @@ -670,6 +764,7 @@ public enum ErrorStartChat: Error, Sendable, Equatable { case invalidRules case rulesNotSatisfied case descriptionModerated(Flipcash_Moderation_V1_FlaggedCategory) + case coverPictureBlobNotAccepted case unknown case transportFailure case cancelled @@ -751,6 +846,7 @@ public enum ErrorEditChat: Error, Sendable, Equatable { case notFound case titleModerated(Flipcash_Moderation_V1_FlaggedCategory) case pictureBlobNotAccepted + case coverPictureBlobNotAccepted case descriptionModerated(Flipcash_Moderation_V1_FlaggedCategory) case unknown case transportFailure @@ -758,6 +854,35 @@ public enum ErrorEditChat: Error, Sendable, Equatable { case rejected } +/// Mapped explicitly from `SampleChattersResponse.Result`; no `.ok` case, like ``ErrorGetRoster``. +public enum ErrorSampleChatters: Error, Sendable, Equatable { + case denied + case notFound + case unknown + case transportFailure + case cancelled + case rejected +} + +/// Mapped explicitly from `SetFeaturedGroupsResponse.Result`; no `.ok` case, like ``ErrorGetRoster``. +public enum ErrorSetFeaturedGroups: Error, Sendable, Equatable { + case denied + case notFound + case unknown + case transportFailure + case cancelled + case rejected +} + +/// Mapped explicitly from `GetFeaturedGroupsResponse.Result`; no `.ok` case, like ``ErrorGetRoster``. +public enum ErrorGetFeaturedGroups: Error, Sendable, Equatable { + case notFound + case unknown + case transportFailure + case cancelled + case rejected +} + public enum ErrorEnterLobby: Int, Error { case ok case denied @@ -872,7 +997,7 @@ extension ErrorStartChat: ServerError, TransportClassifiableError { switch self { case .transportFailure: .suppressed case .cancelled: .info - case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied, .descriptionModerated: .info + case .denied, .titleModerated, .pictureBlobNotAccepted, .coverPictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied, .descriptionModerated: .info case .unknown, .rejected: .error } } @@ -892,7 +1017,7 @@ extension ErrorStartChat { self = .denied case .titleModerated: self = .titleModerated(flaggedCategory) - case .pictureBlobNotAccepted: + case .profilePictureBlobNotAccepted: self = .pictureBlobNotAccepted case .invalidRules: self = .invalidRules @@ -900,6 +1025,8 @@ extension ErrorStartChat { self = .rulesNotSatisfied case .descriptionModerated: self = .descriptionModerated(flaggedCategory) + case .coverPictureBlobNotAccepted: + self = .coverPictureBlobNotAccepted case .UNRECOGNIZED: self = .unknown } @@ -1090,7 +1217,7 @@ extension ErrorEditChat: ServerError, TransportClassifiableError { switch self { case .transportFailure: .suppressed case .cancelled: .info - case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted, .descriptionModerated: .info + case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted, .coverPictureBlobNotAccepted, .descriptionModerated: .info case .unknown, .rejected: .error } } @@ -1111,8 +1238,10 @@ extension ErrorEditChat { self = .notFound case .titleModerated: self = .titleModerated(flaggedCategory) - case .pictureBlobNotAccepted: + case .profilePictureBlobNotAccepted: self = .pictureBlobNotAccepted + case .coverPictureBlobNotAccepted: + self = .coverPictureBlobNotAccepted case .descriptionModerated: self = .descriptionModerated(flaggedCategory) case .UNRECOGNIZED: @@ -1120,3 +1249,74 @@ extension ErrorEditChat { } } } + +extension ErrorSampleChatters: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorSampleChatters { + /// Maps a non-`.ok` `SampleChattersResponse.Result` to its domain error; total over the proto + /// enum, with `.ok` and `.UNRECOGNIZED` folding to `.unknown`. + init(_ result: Flipcash_Chat_V1_SampleChattersResponse.Result) { + switch result { + case .ok: self = .unknown + case .denied: self = .denied + case .notFound: self = .notFound + case .UNRECOGNIZED: self = .unknown + } + } +} + +extension ErrorSetFeaturedGroups: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorSetFeaturedGroups { + /// Maps a non-`.ok` `SetFeaturedGroupsResponse.Result` to its domain error; total over the proto + /// enum, with `.ok` and `.UNRECOGNIZED` folding to `.unknown`. + init(_ result: Flipcash_Chat_V1_SetFeaturedGroupsResponse.Result) { + switch result { + case .ok: self = .unknown + case .denied: self = .denied + case .notFound: self = .notFound + case .UNRECOGNIZED: self = .unknown + } + } +} + +extension ErrorGetFeaturedGroups: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetFeaturedGroups { + /// Maps a non-`.ok` `GetFeaturedGroupsResponse.Result` to its domain error; total over the proto + /// enum, with `.ok` and `.UNRECOGNIZED` folding to `.unknown`. + init(_ result: Flipcash_Chat_V1_GetFeaturedGroupsResponse.Result) { + switch result { + case .ok: self = .unknown + case .notFound: self = .notFound + case .UNRECOGNIZED: self = .unknown + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 7a7d97b82..3a3dacb42 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -40,6 +40,12 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// The chat's picture. Only ever set for group chats. public var picture: ProfilePicture? + /// The group's cover picture. Only ever set for group chats. The feed RPCs may leave it unset + /// even when the group has one, so the store, and the database's upsert, keep a held cover + /// when a copy carries none — `GetChat` is what fetches it. Persisted, so a cold start shows it + /// before any `GetChat`. + public var coverPicture: ProfilePicture? + /// Summary of the chat's roster. Tells whether ``members`` — a subset for /// a large group chat — is stale without needing to hold the full list. /// See ``ConversationRosterSummary``. @@ -71,7 +77,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// persisted, like ``isPrivate``. public var inLobby: Bool - public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil) { + public init(id: ConversationID, members: [ConversationMember], lastMessage: ConversationMessage?, lastActivity: Date, type: ConversationType = .contactDm, isHidden: Bool = false, title: String? = nil, latestEventSequence: UInt64 = 0, picture: ProfilePicture? = nil, rosterSummary: ConversationRosterSummary = ConversationRosterSummary(memberCount: 0, version: 0), rules: ConversationRules? = nil, viewerState: ConversationViewerState? = nil, creator: UserID? = nil, useE2Ee: Bool = false, isPrivate: Bool = false, inLobby: Bool = false, description: String? = nil, coverPicture: ProfilePicture? = nil) { self.id = id self.members = members self.lastMessage = lastMessage @@ -82,6 +88,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.description = description self.latestEventSequence = latestEventSequence self.picture = picture + self.coverPicture = coverPicture self.rosterSummary = rosterSummary self.rules = rules self.viewerState = viewerState @@ -169,7 +176,8 @@ extension Conversation { self.title = proto.title.isEmpty ? nil : proto.title self.description = proto.description_p.isEmpty ? nil : proto.description_p self.latestEventSequence = proto.latestEventSequence - self.picture = proto.hasPicture ? ProfilePicture(proto.picture) : nil + self.picture = proto.hasProfilePicture ? ProfilePicture(proto.profilePicture) : nil + self.coverPicture = proto.hasCoverPicture ? ProfilePicture(proto.coverPicture) : nil self.rosterSummary = ConversationRosterSummary(proto.rosterSummary) self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index fd8dc231b..097db9bb9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -59,7 +59,7 @@ public struct ConversationStore: Sendable { /// Replace the feed from a paged load, sorted most-recent-activity first. public mutating func setFeed(_ conversations: [Conversation]) { - let merged = conversations.map { keepingNewerActivity(keepingSelfReadPointer(seated($0))) } + let merged = conversations.map { keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0)))) } self.conversations = merged.sorted { $0.lastActivity > $1.lastActivity } } @@ -69,7 +69,7 @@ public struct ConversationStore: Sendable { public mutating func setFeed(_ conversations: [Conversation], type: ConversationType) { // Only the incoming rows are merged: re-merging the other types against themselves would // read the store's own copy as the server acknowledging an unsynced READ pointer. - let incoming = conversations.filter { $0.type == type }.map { keepingNewerActivity(keepingSelfReadPointer(seated($0))) } + let incoming = conversations.filter { $0.type == type }.map { keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated($0)))) } self.conversations = (self.conversations.filter { $0.type != type } + incoming) .sorted { $0.lastActivity > $1.lastActivity } } @@ -382,6 +382,9 @@ public struct ConversationStore: Sendable { case .pictureChanged(let conversationID, let picture): applyPictureChanged(picture, in: conversationID) return .none + case .coverPictureChanged(let conversationID, let coverPicture): + applyCoverPictureChanged(coverPicture, in: conversationID) + return .none case .lobbyChanged: // Lobby membership is not part of the feed; no consumer holds it yet. return .none @@ -524,6 +527,9 @@ public struct ConversationStore: Sendable { } case .left(let userID): conversations[index].members.removeAll { $0.userID == userID } + case .membershipChanged: + // The summary below is the whole change: the member list stays as cached. + break } conversations[index].rosterSummary = update.rosterSummary } @@ -557,13 +563,21 @@ public struct ConversationStore: Sendable { conversations[index].description = description.isEmpty ? nil : description } - /// Apply a picture change delivered via `MetadataUpdate.PictureChanged`. Same best-effort, + /// Apply a picture change delivered via `MetadataUpdate.ProfilePictureChanged`. Same best-effort, /// no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the store doesn't hold. public mutating func applyPictureChanged(_ picture: ProfilePicture, in conversationID: ConversationID) { guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } conversations[index].picture = picture } + /// Apply a cover picture change delivered via `MetadataUpdate.CoverPictureChanged`. Same + /// best-effort, no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the store + /// doesn't hold. + public mutating func applyCoverPictureChanged(_ coverPicture: ProfilePicture, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].coverPicture = coverPicture + } + /// Drops the cached viewer state for a chat the signed-in user just left. /// /// The server clears mute on leave and restarts the version with it, so this discards the @@ -575,7 +589,7 @@ public struct ConversationStore: Sendable { } private mutating func upsert(_ conversation: Conversation) { - var conversation = keepingNewerActivity(keepingSelfReadPointer(seated(conversation))) + var conversation = keepingCoverPicture(keepingNewerActivity(keepingSelfReadPointer(seated(conversation)))) if let index = conversations.firstIndex(where: { $0.id == conversation.id }) { if conversation.type == .group { conversation.members = mergedMembers(conversation.members, over: conversations[index].members) @@ -638,6 +652,19 @@ public struct ConversationStore: Sendable { return conversation } + /// `conversation` with the stored row's cover picture kept when the incoming copy carries none. + /// The feed RPCs may omit the cover even when one is set, so an unset cover there is "not + /// reported", not "cleared"; `GetChat` and `coverPictureChanged` are what change it. + private func keepingCoverPicture(_ conversation: Conversation) -> Conversation { + guard conversation.coverPicture == nil, + let held = conversations.first(where: { $0.id == conversation.id })?.coverPicture else { + return conversation + } + var conversation = conversation + conversation.coverPicture = held + return conversation + } + /// `conversation` with the self READ pointer raised to the one the store already holds, when the /// server's copy is behind it. The server never lowers a pointer, so a lower copy means an advance /// it hasn't taken yet; that advance is kept and recorded as unsynced for the caller to re-send. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index 4acd8f6cf..b66c12084 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -57,6 +57,10 @@ public enum ConversationStreamEvent: Sendable { /// devices. Best-effort and applied as received, like ``titleChanged``. case pictureChanged(conversationID: ConversationID, picture: ProfilePicture) + /// A group chat's cover picture changed (via `Chat.EditChat`), including on the editor's other + /// devices. Best-effort and applied as received, like ``titleChanged``. + case coverPictureChanged(conversationID: ConversationID, coverPicture: ProfilePicture) + /// Reactions on messages in the chat changed. Best-effort and outside the gap-detected event /// log: each update is applied by its per-emoji version, and a missed one is reconciled by the /// next reaction summary. @@ -181,6 +185,10 @@ public enum RosterChange: Sendable { /// 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) + /// Membership changed in a way the update doesn't itemize (`RosterUpdate.MembershipChanged`). + /// Only the summary applies: the cached member list stays as it is, and the update is not a + /// version gap, so it never triggers a roster refetch. + case membershipChanged } extension ConversationStreamEvent { @@ -213,11 +221,14 @@ extension ConversationStreamEvent { events.append(.titleChanged(conversationID: conversationID, title: changed.newTitle)) case .descriptionChanged(let changed): events.append(.descriptionChanged(conversationID: conversationID, description: changed.newDescription)) - case .pictureChanged(let changed): + case .profilePictureChanged(let changed): // `ProfilePicture.init?` fails without an original rendition — required on the // wire, but treated the same as an absent picture rather than force-unwrapped. - guard changed.hasNewPicture, let picture = ProfilePicture(changed.newPicture) else { break } + guard changed.hasNewProfilePicture, let picture = ProfilePicture(changed.newProfilePicture) else { break } events.append(.pictureChanged(conversationID: conversationID, picture: picture)) + case .coverPictureChanged(let changed): + guard changed.hasNewCoverPicture, let coverPicture = ProfilePicture(changed.newCoverPicture) else { break } + events.append(.coverPictureChanged(conversationID: conversationID, coverPicture: coverPicture)) case nil: break } @@ -296,7 +307,7 @@ 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). + /// is not one this client knows about yet. init?(_ proto: Flipcash_Chat_V1_RosterUpdate) { guard proto.hasRosterSummary else { return nil } let rosterSummary = ConversationRosterSummary(proto.rosterSummary) @@ -308,6 +319,8 @@ extension DecodedRosterUpdate { 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 .membershipChanged: + self.init(rosterSummary: rosterSummary, change: .membershipChanged) case nil: return nil } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SampledChatter.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SampledChatter.swift new file mode 100644 index 000000000..0dda7200b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/SampledChatter.swift @@ -0,0 +1,46 @@ +// +// SampledChatter.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// A recent chatter in a public group, as `Chat.SampleChatters` reports it: the public alternative +/// to the members-only roster for a view whose viewer is not a member (preview, lobby). +public struct SampledChatter: Sendable, Equatable { + public let userID: UserID + /// Their public profile. + public let profile: Profile + /// When they last sent a message in the chat, or `nil` when the server reports none. + public let lastSentAt: Date? + /// Whether they created the group. + public let isCreator: Bool + + public init(userID: UserID, profile: Profile, lastSentAt: Date?, isCreator: Bool) { + self.userID = userID + self.profile = profile + self.lastSentAt = lastSentAt + self.isCreator = isCreator + } +} + +extension SampledChatter { + /// `nil` for a chatter without a readable profile or user id, which the contract says the + /// server always sets. + init?(_ proto: Flipcash_Chat_V1_SampledChatter) { + guard + proto.hasUserProfile, + let profile = try? Profile(proto.userProfile), + let userID = profile.userID + else { return nil } + self.init( + userID: userID, + profile: profile, + lastSentAt: proto.hasLastSentAt ? proto.lastSentAt.date : nil, + isCreator: proto.isCreator + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 9b2b47e14..36c6cb0bf 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -107,7 +107,8 @@ nonisolated extension Database { rules: conversationRules(from: row), viewerState: conversationViewerState(from: row), creator: row[c.creator], - useE2Ee: row[c.useE2Ee] + useE2Ee: row[c.useE2Ee], + coverPicture: conversationCoverPicture(from: row) ) } } @@ -470,6 +471,18 @@ nonisolated extension Database { ) ) + // COALESCE(new, existing): the cover columns stay out of the upsert above, which would + // overwrite them with nil, and are written only when this copy carries a cover. + if let cover = conversation.coverPicture { + try writer.run( + c.table.filter(c.id == conversation.id.data).update( + c.coverPictureBlobID <- cover.blobID.data, + c.coverPictureThumbnailBlobID <- cover.thumbnailBlobID.data, + c.coverPictureThumbnailBlurhash <- cover.thumbnailBlurhash + ) + ) + } + try writer.run(m.table.filter(m.conversationId == conversation.id.data).delete()) for member in conversation.members { try writer.run( @@ -724,6 +737,20 @@ nonisolated extension Database { ) } + /// Same pair rule as ``conversationPicture(from:)``, for the cover columns. + private func conversationCoverPicture(from row: RowIterator.Element) -> ProfilePicture? { + let c = ConversationTable() + guard let blobID = row[c.coverPictureBlobID], + let thumbnailBlobID = row[c.coverPictureThumbnailBlobID] else { + return nil + } + return ProfilePicture( + blobID: BlobID(data: blobID), + thumbnailBlobID: BlobID(data: thumbnailBlobID), + thumbnailBlurhash: row[c.coverPictureThumbnailBlurhash] + ) + } + /// Returns nil when the chat has no rules, and also when the stored JSON /// names a rule this build doesn't know. An unreadable rule set degrades to /// "no rules" rather than failing the whole conversation row; the next diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index d194b4d9e..95e8ba00f 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 47 + public static let schemaVersion = 48 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index deb3eadc0..2b24e60a1 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -265,6 +265,11 @@ nonisolated public struct ConversationTable: Sendable { public let pictureBlobID = Expression ("pictureBlobID") public let pictureThumbnailBlobID = Expression ("pictureThumbnailBlobID") public let pictureThumbnailBlurhash = Expression ("pictureThumbnailBlurhash") + // The group's cover picture, same three-column layout. Written only when a conversation + // carries a cover — the feed RPCs omit it — so an unset cover never clears a stored one. + public let coverPictureBlobID = Expression ("coverPictureBlobID") + public let coverPictureThumbnailBlobID = Expression ("coverPictureThumbnailBlobID") + public let coverPictureThumbnailBlurhash = Expression ("coverPictureThumbnailBlurhash") // Roster summary. Cached so a group restored cold reports its real member // count instead of zero while the metadata refetch is in flight. public let rosterMemberCount = Expression ("rosterMemberCount") @@ -586,6 +591,9 @@ nonisolated extension Database { t.column(conversationTable.pictureBlobID) t.column(conversationTable.pictureThumbnailBlobID) t.column(conversationTable.pictureThumbnailBlurhash) + t.column(conversationTable.coverPictureBlobID) + t.column(conversationTable.coverPictureThumbnailBlobID) + t.column(conversationTable.coverPictureThumbnailBlurhash) t.column(conversationTable.rosterMemberCount, defaultValue: 0) t.column(conversationTable.rosterVersion, defaultValue: 0) t.column(conversationTable.rules) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift index b5c7ce4fb..190650c38 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift @@ -107,6 +107,48 @@ struct ConversationMetadataEditTests { #expect(store.conversations.isEmpty) } + + @Test("A cover picture change is applied to the matching chat, leaving the profile picture alone") + func coverPictureChangeApplies() { + var store = ConversationStore() + store.apply(.metadataRefresh(group(1))) + + let cover = picture(0x03) + store.applyCoverPictureChanged(cover, in: conversationID(1)) + + #expect(store.conversations[0].coverPicture == cover) + #expect(store.conversations[0].picture == nil) + } + + @Test("A cover picture change for a chat the store doesn't hold is a no-op") + func coverPictureChangeForUnknownChatNoOps() { + var store = ConversationStore() + store.applyCoverPictureChanged(picture(0x03), in: conversationID(1)) + #expect(store.conversations.isEmpty) + } + + @Test("A feed result without a cover does not clear a cached cover; one with a cover replaces it") + func feedWithoutCoverKeepsCachedCover() { + let cover = picture(0x03) + var store = ConversationStore() + store.apply(.metadataRefresh(group(1))) + store.applyCoverPictureChanged(cover, in: conversationID(1)) + + store.setGroupFeed([group(1, title: "from feed")]) + #expect(store.conversations[0].coverPicture == cover) + #expect(store.conversations[0].title == "from feed") + + store.setFeed([group(1)], type: .group) + #expect(store.conversations[0].coverPicture == cover) + + store.setFeed([group(1)]) + #expect(store.conversations[0].coverPicture == cover) + + var fresh = group(1) + fresh.coverPicture = picture(0x04) + store.apply(.metadataRefresh(fresh)) + #expect(store.conversations[0].coverPicture == picture(0x04)) + } } @Suite("Conversation description edit") diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index b154b3341..9a16b6aea 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -209,7 +209,7 @@ struct ConversationModelMappingTests { $0.memberCount = 12 $0.version = 3 } - $0.picture = .with { + $0.profilePicture = .with { $0.renditions = [.with { $0.role = .original $0.blobID = .with { $0.value = Data(repeating: 0x01, count: 16) } @@ -231,10 +231,29 @@ struct ConversationModelMappingTests { let conversation = Conversation(proto) #expect(conversation.rosterSummary == ConversationRosterSummary(memberCount: 12, version: 3)) #expect(conversation.picture != nil) + #expect(conversation.coverPicture == nil) #expect(conversation.rules?.listener == [.staff]) #expect(conversation.rules?.speaker == [.minimumBalance(MinimumBalanceRequirement(amount: .usd(5.0)))]) } + @Test("Metadata cover_picture maps to coverPicture, apart from the profile picture") + func coverPictureMaps() { + let proto = Flipcash_Chat_V1_Metadata.with { + $0.chatID = .with { $0.value = Data(repeating: 0xAB, count: 32) } + $0.type = .group + $0.coverPicture = .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = Data(repeating: 0x07, count: 16) } + }] + } + } + + let conversation = Conversation(proto) + #expect(conversation.coverPicture?.blobID == BlobID(data: Data(repeating: 0x07, count: 16))) + #expect(conversation.picture == nil) + } + @Test("Metadata without roster summary or rules maps to defaults") func dmMetadataWithoutRosterSummaryOrRulesMapsToDefaults() { let proto = Flipcash_Chat_V1_Metadata.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift index aee7d1d1b..e89d62fb8 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRosterTests.swift @@ -61,6 +61,36 @@ struct ConversationRosterTests { // MARK: - Roster updates + @Test("A membership change advances the summary and keeps the cached member list") + func membershipChangedKeepsMembers() { + var store = ConversationStore() + store.setFeed([group(1, members: [member(1), member(2)], memberCount: 2, version: 4)]) + + let update = DecodedRosterUpdate( + rosterSummary: ConversationRosterSummary(memberCount: 40, version: 5), + change: .membershipChanged + ) + store.applyRosterUpdates([update], in: conversationID(1)) + + let conversation = store.conversations[0] + #expect(conversation.members.map(\.userID) == [userID(1), userID(2)]) + #expect(conversation.rosterSummary == ConversationRosterSummary(memberCount: 40, version: 5)) + } + + @Test("A membership change at or below the cached version is dropped") + func membershipChangedStaleVersionDropped() { + var store = ConversationStore() + store.setFeed([group(1, members: [member(1)], memberCount: 1, version: 5)]) + + let update = DecodedRosterUpdate( + rosterSummary: ConversationRosterSummary(memberCount: 99, version: 5), + change: .membershipChanged + ) + store.applyRosterUpdates([update], in: conversationID(1)) + + #expect(store.conversations[0].rosterSummary == ConversationRosterSummary(memberCount: 1, version: 5)) + } + @Test("A join appends the member and advances the summary") func joinAppendsMember() { var store = ConversationStore() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index f2013782a..0789e827b 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -115,8 +115,8 @@ struct ConversationStreamEventDecodeTests { $0.chatUpdate = .with { $0.chat = .with { $0.value = conversationBytes } $0.metadataUpdates = [.with { - $0.pictureChanged = .with { - $0.newPicture = .with { + $0.profilePictureChanged = .with { + $0.newProfilePicture = .with { $0.renditions = [.with { $0.role = .original $0.blobID = .with { $0.value = blobBytes } @@ -141,7 +141,7 @@ struct ConversationStreamEventDecodeTests { $0.chatUpdate = .with { $0.chat = .with { $0.value = conversationBytes } $0.metadataUpdates = [.with { - $0.pictureChanged = .init() + $0.profilePictureChanged = .init() }] } } @@ -149,6 +149,67 @@ struct ConversationStreamEventDecodeTests { #expect(ConversationStreamEvent.decode(event).isEmpty) } + @Test("CoverPictureChanged decodes to a coverPictureChanged event") + func coverPictureChanged() { + let blobBytes = Data(repeating: 0x05, count: 16) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.coverPictureChanged = .with { + $0.newCoverPicture = .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobBytes } + }] + } + } + }] + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .coverPictureChanged(let conversationID, let cover) = decoded.first else { + Issue.record("expected .coverPictureChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(cover.blobID == BlobID(data: blobBytes)) + } + + @Test("CoverPictureChanged with no picture set decodes to nothing") + func coverPictureChangedWithoutPicture() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { $0.coverPictureChanged = .init() }] + } + } + + #expect(ConversationStreamEvent.decode(event).isEmpty) + } + + @Test("MembershipChanged decodes to a roster update carrying only the summary") + func rosterMembershipChanged() { + 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(9, 4) + $0.membershipChanged = .init() + }] + } + } + } + + guard case .rosterChanged(_, let updates) = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .rosterChanged"); return + } + #expect(updates.count == 1) + #expect(updates[0].rosterSummary == ConversationRosterSummary(memberCount: 9, version: 4)) + guard case .membershipChanged = updates[0].change else { Issue.record("expected .membershipChanged"); return } + } + @Test("An event batch and a metadata update decode to both events in order") func combined() { let event = Flipcash_Event_V1_Event.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 87b375ab5..5f642c8a8 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -99,6 +99,9 @@ struct TransportClassificationTests { @Test func errorGetRoster() { assertClassifies(ErrorGetRoster.self) } @Test func errorGetMentionSuggestions() { assertClassifies(ErrorGetMentionSuggestions.self) } @Test func errorEditChat() { assertClassifies(ErrorEditChat.self) } + @Test func errorSampleChatters() { assertClassifies(ErrorSampleChatters.self) } + @Test func errorSetFeaturedGroups() { assertClassifies(ErrorSetFeaturedGroups.self) } + @Test func errorGetFeaturedGroups() { assertClassifies(ErrorGetFeaturedGroups.self) } @Test func errorReport() { assertClassifies(ErrorReport.self) } // In-band outcomes fall outside the generic four-case contract. @@ -127,7 +130,7 @@ struct TransportClassificationTests { // 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(.profilePictureBlobNotAccepted, flaggedCategory: .nsfw) == .pictureBlobNotAccepted) #expect(ErrorStartChat(.invalidRules, flaggedCategory: .nsfw) == .invalidRules) #expect(ErrorStartChat(.rulesNotSatisfied, flaggedCategory: .nsfw) == .rulesNotSatisfied) } @@ -150,7 +153,49 @@ struct TransportClassificationTests { // Every other result maps to its payload-less case, unaffected by the category argument. #expect(ErrorEditChat(.denied, flaggedCategory: .nsfw) == .denied) #expect(ErrorEditChat(.notFound, flaggedCategory: .nsfw) == .notFound) - #expect(ErrorEditChat(.pictureBlobNotAccepted, flaggedCategory: .nsfw) == .pictureBlobNotAccepted) + #expect(ErrorEditChat(.profilePictureBlobNotAccepted, flaggedCategory: .nsfw) == .pictureBlobNotAccepted) + #expect(ErrorEditChat(.coverPictureBlobNotAccepted, flaggedCategory: .nsfw) == .coverPictureBlobNotAccepted) + } + + // `EditChatResponse.Result` was renumbered upstream (COVER_PICTURE_BLOB_NOT_ACCEPTED = 5 inserted, + // DESCRIPTION_MODERATED 5 -> 6). The mapping is by case name, so this pins it to the wire numbers + // the 0.18.0 contract defines rather than to any positional order. + @Test("ErrorEditChat maps every EditChatResponse.Result by wire number, including the renumbered ones") + func errorEditChatRenumberedWireValues() { + let wire: [(Int, ErrorEditChat)] = [ + (1, .denied), + (2, .notFound), + (3, .titleModerated(.nsfw)), + (4, .pictureBlobNotAccepted), + (5, .coverPictureBlobNotAccepted), + (6, .descriptionModerated(.nsfw)), + ] + for (raw, expected) in wire { + let result = Flipcash_Chat_V1_EditChatResponse.Result(rawValue: raw) + #expect(result != nil) + #expect(ErrorEditChat(result ?? .ok, flaggedCategory: .nsfw) == expected, "raw \(raw)") + } + #expect(ErrorEditChat(Flipcash_Chat_V1_EditChatResponse.Result(rawValue: 99) ?? .ok, flaggedCategory: .nsfw) == .unknown) + } + + @Test("ErrorStartChat maps the appended COVER_PICTURE_BLOB_NOT_ACCEPTED = 7") + func errorStartChatCoverPicture() { + let result = Flipcash_Chat_V1_StartChatResponse.Result(rawValue: 7) + #expect(result == .coverPictureBlobNotAccepted) + #expect(ErrorStartChat(result ?? .ok, flaggedCategory: .nsfw) == .coverPictureBlobNotAccepted) + #expect(ErrorStartChat(.coverPictureBlobNotAccepted, flaggedCategory: .nsfw).reportingLevel == .info) + #expect(ErrorEditChat.coverPictureBlobNotAccepted.reportingLevel == .info) + } + + @Test("Featured-group and sampled-chatter errors map their results explicitly") + func featuredAndSampledMapResults() { + #expect(ErrorSampleChatters(.denied) == .denied) + #expect(ErrorSampleChatters(.notFound) == .notFound) + #expect(ErrorSampleChatters(.ok) == .unknown) + #expect(ErrorSetFeaturedGroups(.denied) == .denied) + #expect(ErrorSetFeaturedGroups(.notFound) == .notFound) + #expect(ErrorGetFeaturedGroups(.notFound) == .notFound) + #expect(ErrorGetFeaturedGroups(.ok) == .unknown) } // `ErrorGetRoster` has no payload-carrying case, but it's still mapped explicitly from diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 76593f653..1c6418833 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -809,6 +809,43 @@ struct DatabaseConversationsTests { #expect(restored.members.first?.profilePicture == picture) } + @Test("Cover picture round-trips, survives a write without one, and is replaced by a new one") + func coverPictureRoundTripAndCoalesce() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + func cover(_ byte: UInt8) -> ProfilePicture { + ProfilePicture( + blobID: BlobID(data: Data(repeating: byte, count: 16)), + thumbnailBlobID: BlobID(data: Data(repeating: byte &+ 1, count: 16)), + thumbnailBlurhash: "LEHV6nWB2yk8" + ) + } + func group(cover: ProfilePicture?) -> Conversation { + Conversation( + id: ConversationID.test(7), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 100), + type: .group, + coverPicture: cover + ) + } + + try database.upsertConversation(group(cover: nil)) + #expect(try database.getConversations().first?.coverPicture == nil) + + try database.upsertConversation(group(cover: cover(0x10))) + #expect(try database.getConversations().first?.coverPicture == cover(0x10)) + + // A feed copy omits the cover: the stored one stays. + try database.upsertConversation(group(cover: nil)) + #expect(try database.getConversations().first?.coverPicture == cover(0x10)) + + // A copy that carries one replaces it. + try database.upsertConversation(group(cover: cover(0x20))) + #expect(try database.getConversations().first?.coverPicture == cover(0x20)) + } + @Test("A typed feed replace keeps the other type's conversations and members") func typedFeedReplaceKeepsOtherType() throws { let (database, url) = try Database.makeTemp()