diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 48c79371c..c718b3d66 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9cf1f63bab7508c5285a752950d9d0615460c403bbd3882506965c3d40b1dfc3", + "originHash" : "ba845270d60171c0b080bca8e22a7e5a4d04e8499e9e330873701e180512c65e", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "a86e91bd9e42ab7cded49ad647987332ddb23aa3", - "version" : "0.15.0" + "revision" : "a5cc6f8a39b025b2bc2848c462c3e3d7a0fb8cd3", + "version" : "0.16.0" } }, { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 53ee0c450..581db537f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -854,11 +854,14 @@ 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: + case .titleChanged, .pictureChanged, .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. return + case .lobbyChanged: + // Only delivered to a private chat's admitting members, who already hold the chat. + return } guard !store.conversations.contains(where: { $0.id == conversationID }), !hydratingConversationIDs.contains(conversationID) else { @@ -1362,11 +1365,18 @@ final class ConversationController { // 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) + case .descriptionChanged: + // Description isn't persisted, so there is nothing to write. Fold this into the + // title/picture case above if it gains a column. + break case .typingChanged: break case .reactionsChanged: // Written by `reactions`, which also holds the taps the update has to merge with. break + case .lobbyChanged: + // Lobby membership is not cached; no consumer holds it yet. + break } } diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index c60ea7db5..de1bf4c1c 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -69,9 +69,15 @@ final class EncryptedChatClient: Sendable { } catch ErrorSendMessage.encryptionNotAllowed { conversations.withLock { _ = $0.removeValue(forKey: conversationID) } throw ErrorSendMessage.encryptionNotAllowed + } catch ErrorSendMessage.encryptionRequired { + conversations.withLock { _ = $0.removeValue(forKey: conversationID) } + throw ErrorSendMessage.encryptionRequired } catch ErrorEditMessage.encryptionNotAllowed { conversations.withLock { _ = $0.removeValue(forKey: conversationID) } throw ErrorEditMessage.encryptionNotAllowed + } catch ErrorEditMessage.encryptionRequired { + conversations.withLock { _ = $0.removeValue(forKey: conversationID) } + throw ErrorEditMessage.encryptionRequired } } } @@ -212,7 +218,7 @@ extension EncryptedChatClient: ConversationEventStreaming { case .metadataRefresh(let conversation): return .metadataRefresh(await opened(conversation)) case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged, - .viewerStateChanged, .titleChanged, .pictureChanged, .reactionsChanged: + .viewerStateChanged, .titleChanged, .pictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged: return event } } diff --git a/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift b/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift index 108a1fd8b..038566e64 100644 --- a/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift +++ b/Flipcash/Core/Screens/Main/Username/DialogItem+Username.swift @@ -105,7 +105,7 @@ extension DialogItem { // instead of silently inheriting "try again" — `.denied` in particular // is a real `SetUsername` rejection that is only here because it has no // signed-off copy yet. - case .denied, .invalidDisplayName, .blobNotFound, .blobNotReady, .blobRejected, .invalidBlob, .network, .unknown: + case .denied, .invalidDisplayName, .invalidBio, .blobNotFound, .blobNotReady, .blobRejected, .invalidBlob, .network, .unknown: return generic } } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index bb92cede9..8588d7d0e 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -544,6 +544,7 @@ extension Analytics { case .pictureBlobNotAccepted: "PictureBlobNotAccepted" case .invalidRules: "InvalidRules" case .rulesNotSatisfied: "RulesNotSatisfied" + case .descriptionModerated: "DescriptionModerated" case .unknown, .transportFailure, .cancelled, .rejected: "Network" } case let error as ErrorEditChat: @@ -552,6 +553,7 @@ extension Analytics { case .notFound: "NotFound" case .titleModerated: "TitleModerated" case .pictureBlobNotAccepted: "PictureBlobNotAccepted" + case .descriptionModerated: "DescriptionModerated" case .unknown, .transportFailure, .cancelled, .rejected: "Network" } case let error as ErrorJoinChat: diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 24b21358a..43f4d9ca7 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.15.0" + case .flipcash2: return "0.17.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index ad8e5ee08..388bdfbd8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -70,14 +70,14 @@ extension FlipClient { } /// 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. + /// and who may send; `nil` leaves the chat unrestricted. A `nil` or empty `description` sets none. /// /// `idempotencyKey` must be minted by the caller where the user's intent to create the chat /// originates (not here) and reused for every retry of that same attempt — see /// `ChatService.startChat`. A retry with the same key returns the original chat. - public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID) async throws -> Conversation { + public func startChat(owner: KeyPair, title: String, description: String? = nil, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules, idempotencyKey: idempotencyKey) { c.resume(with: $0) } + chatService.startChat(owner: owner, title: title, description: description, pictureBlobID: pictureBlobID, rules: rules, idempotencyKey: idempotencyKey) { c.resume(with: $0) } } } @@ -140,17 +140,18 @@ extension FlipClient { } } - /// Edits a group chat's title and/or picture; every parameter left `nil` leaves that field - /// unchanged, and passing both `nil` is a no-op that still returns the current metadata. Only a + /// Edits a group chat's title, description, and/or picture; a `nil` title or picture and an + /// `.unchanged` description leave that field as it is, and changing nothing is a no-op that still + /// returns the current metadata. Pass `.clear` to remove the description. Only a /// member with ``ConversationViewerState/canEdit`` may call this. /// /// `pictureBlobID` must already be `READY` (uploaded via `BlobService`) — this call does not /// upload it. Real changes also arrive on the event stream as `MetadataUpdate.titleChanged`/ - /// `.pictureChanged` for the chat's other members (and the caller's other devices); this call's + /// `.descriptionChanged`/`.pictureChanged` for the chat's other members (and the caller's other devices); this call's /// return value is only this device's confirmation. - public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, pictureBlobID: BlobID?) async throws -> Conversation { + public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.editChat(owner: owner, conversationID: conversationID, title: title, pictureBlobID: pictureBlobID) { c.resume(with: $0) } + chatService.editChat(owner: owner, conversationID: conversationID, title: title, description: description, pictureBlobID: pictureBlobID) { c.resume(with: $0) } } } @@ -284,4 +285,71 @@ extension FlipClient { public nonisolated func closeConversationStream() { Task { await eventStreamer.stop() } } + + // MARK: - Private chats - + + /// Enters the lobby of the private chat `conversationID` to wait for an admin to admit the + /// signed-in user. + public func enterLobby(owner: KeyPair, conversationID: ConversationID) async throws -> Lobby { + try await withCheckedThrowingContinuation { c in + chatService.enterLobby(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + } + + /// Leaves the lobby of `conversationID`, withdrawing the request to join. + public func leaveLobby(owner: KeyPair, conversationID: ConversationID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.leaveLobby(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + } + + /// Pages `conversationID`'s lobby to exhaustion. Members entering or leaving mid-read arrive + /// as `ConversationStreamEvent.lobbyChanged`, so the caller should already be consuming the + /// event stream. + public func getLobbyMembers(owner: KeyPair, conversationID: ConversationID) async throws -> [LobbyMember] { + var all: [LobbyMember] = [] + var pagingToken: Data? + + while true { + let page = try await withCheckedThrowingContinuation { c in + chatService.getLobbyMembers(owner: owner, conversationID: conversationID, pagingToken: pagingToken) { c.resume(with: $0) } + } + all.append(contentsOf: page.members) + if !page.hasMore { break } + pagingToken = page.pagingToken + } + + return all + } + + /// Admits `userID` from `conversationID`'s lobby, handing them the chat key wrapped in + /// `keyEnvelope`. The envelope is opaque to this layer. + public func admitLobbyMember(owner: KeyPair, conversationID: ConversationID, userID: UserID, keyEnvelope: ConversationKeyEnvelope) async throws { + try await withCheckedThrowingContinuation { c in + chatService.admitLobbyMember(owner: owner, conversationID: conversationID, userID: userID, keyEnvelope: keyEnvelope) { c.resume(with: $0) } + } + } + + /// Turns `userID` away from `conversationID`'s lobby. + public func denyLobbyMember(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.denyLobbyMember(owner: owner, conversationID: conversationID, userID: userID) { c.resume(with: $0) } + } + } + + /// Stores the signed-in user's wrapped copy of `conversationID`'s chat key. Throws + /// `ErrorSetKeyEnvelope.alreadySet` once one is stored. + public func setKeyEnvelope(owner: KeyPair, conversationID: ConversationID, keyEnvelope: ConversationKeyEnvelope) async throws { + try await withCheckedThrowingContinuation { c in + chatService.setKeyEnvelope(owner: owner, conversationID: conversationID, keyEnvelope: keyEnvelope) { c.resume(with: $0) } + } + } + + /// Fetches the chat key `conversationID` wrapped for the signed-in user and who wrapped it. + public func getKeyEnvelope(owner: KeyPair, conversationID: ConversationID) async throws -> (envelope: ConversationKeyEnvelope, wrappedBy: UserID?) { + let result = try await withCheckedThrowingContinuation { c in + chatService.getKeyEnvelope(owner: owner, conversationID: conversationID) { c.resume(with: $0) } + } + return (result.envelope, result.wrappedBy) + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift index 76097d5ec..5001477e0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Profile.swift @@ -49,6 +49,19 @@ extension FlipClient { try await profileService.setProfilePicture(blobID: blobID, owner: owner) } + /// Attaches an already-finalized blob as the caller's cover picture. Returns the picture the + /// server derived from it, or nil when the response carried none. + @discardableResult + public func setCoverPicture(blobID: BlobID, owner: KeyPair) async throws -> ProfilePicture? { + try await profileService.setCoverPicture(blobID: blobID, owner: owner) + } + + /// Sets the caller's bio, which the server moderates before it persists. An empty string + /// clears it. + public func setBio(_ bio: String, owner: KeyPair) async throws { + try await profileService.setBio(bio, owner: owner) + } + /// Updates the caller's Tip Card customization. The server validates the /// colour and falls back to the default for anything left unset. public func updateFlipcard(_ customization: TipCardCustomization, owner: KeyPair) async throws { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 0436a3914..9c50fb9a9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -239,7 +239,7 @@ final class ChatMessagingService: Sendable { await MainActor.run { completion(.success(MessageMutation(message: opened, isConflict: error == .conflict))) } - case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .encryptionFailed, .unknown, .transportFailure, .cancelled, .rejected: + case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .encryptionRequired, .encryptionFailed, .unknown, .transportFailure, .cancelled, .rejected: logger.error("Failed to edit message") await MainActor.run { completion(.failure(error)) } } @@ -526,6 +526,8 @@ public enum ErrorSendMessage: Int, Error { case denied /// The content is EncryptedContent and the chat is not a DM. case encryptionNotAllowed + /// The chat requires encrypted content and the message was not. + case encryptionRequired case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -542,6 +544,8 @@ public enum ErrorEditMessage: Int, Error { case conflict /// The content is EncryptedContent and the chat is not a DM. case encryptionNotAllowed + /// The chat requires encrypted content and the edit was not. + case encryptionRequired case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -624,6 +628,9 @@ extension ErrorSendMessage: ServerError, TransportClassifiableError { // contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .denied: .info case .encryptionNotAllowed: .error + // Plaintext sent to a chat that requires encryption; this client cannot yet encrypt for + // private chats, so reaching it is a client gap. + case .encryptionRequired: .error // Shared-core refused to encrypt, which only malformed keys cause. case .encryptionFailed: .error case .unknown, .rejected: .error @@ -641,6 +648,9 @@ extension ErrorEditMessage: ServerError, TransportClassifiableError { case .denied, .messageNotFound, .cannotEdit, .conflict: .info // A client-side contract violation (sending EncryptedContent outside a DM), not a server hiccup. case .encryptionNotAllowed: .error + // Plaintext sent to a chat that requires encryption; this client cannot yet encrypt for + // private chats, so reaching it is a client gap. + case .encryptionRequired: .error // Shared-core refused to encrypt, which only malformed keys cause. case .encryptionFailed: .error case .unknown, .rejected: .error diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 1f52f9f5b..e49679512 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -128,17 +128,21 @@ final class ChatService: Sendable { /// `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. + /// that the title was rejected. `description`, when non-empty, is moderated the same way and + /// reports `.descriptionModerated`; `nil` or empty sets none. /// /// `idempotencyKey` is required by the server: caller and key together identify the chat being /// created, so a retry with the same key returns the original chat (result `.ok`) rather than /// creating a duplicate, even if `title`/`pictureBlobID`/`rules` differ on the retry. Mint it once /// where the user's intent to create the chat originates and reuse it for every retry of that same /// attempt — never generate a fresh key per call, or retries lose their idempotency. - func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID, completion: @Sendable @escaping (Result) -> Void) { + func startChat(owner: KeyPair, title: String, description: String? = nil, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_StartChatRequest.with { - $0.group = .with { + $0.publicGroup = .with { $0.title = title + if let description, !description.isEmpty { + $0.description_p = description + } if let pictureBlobID { $0.picture = .with { $0.value = pictureBlobID.data } } @@ -357,7 +361,7 @@ final class ChatService: Sendable { } } - /// Edits a group chat's title and/or picture. Every field is optional — only fields set on the + /// Edits a group chat's title, description, and/or picture. Every field is optional — only fields set on the /// request change, atomically; a request that sets nothing is a no-op returning `.ok`. Only a /// member the server permits to edit (``ConversationViewerState/canEdit``) may call this; anyone /// else is `.denied`. @@ -365,13 +369,19 @@ final class ChatService: Sendable { /// `pictureBlobID`, when supplied, must already be `READY` (uploaded via `BlobService`) — this /// call does not upload it, mirroring `startChat`'s `pictureBlobID` contract. On `.titleModerated` /// the server also reports which category flagged the title, carried the same way - /// `ErrorStartChat.titleModerated` carries it. - func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, pictureBlobID: BlobID?, completion: @Sendable @escaping (Result) -> Void) { + /// `ErrorStartChat.titleModerated` carries it; `.descriptionModerated` works the same way. + /// + /// `description` is ``ConversationDescriptionEdit/unchanged`` by default; use `.clear` to remove + /// an existing description. + func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_EditChatRequest.with { $0.chatID = conversationID.proto if let title { $0.title = .with { $0.value = title } } + if let description = description.proto { + $0.description_p = description + } if let pictureBlobID { $0.picture = .with { $0.blobID = .with { $0.value = pictureBlobID.data } } } @@ -399,6 +409,222 @@ final class ChatService: Sendable { } } } + + // 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. + + /// Enters the lobby of the private chat `conversationID`, to wait for an admin to admit the + /// signed-in user. Returns the lobby entry on success. + func enterLobby(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_EnterLobbyRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.enterLobby(request, options: .unaryDefault) + let error = ErrorEnterLobby(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, let lobby = Lobby(response.lobby) else { + logger.error("Failed to enter lobby") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(lobby)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Leaves the lobby of `conversationID`, withdrawing the request to join. + func leaveLobby(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_LeaveLobbyRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.leaveLobby(request, options: .unaryDefault) + let error = ErrorLeaveLobby(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to leave lobby") + 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)) } + } + } + } + + struct LobbyMembersPage: Sendable { + let members: [LobbyMember] + let pagingToken: Data + let hasMore: Bool + } + + /// Pages the users waiting in `conversationID`'s lobby. Leave `pagingToken` `nil` on the first + /// call; on every later call pass back the previous page's `pagingToken`. `pageSize` is capped + /// at 100 server-side. A member whose public key does not parse is dropped from the page. + func getLobbyMembers(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetLobbyMembersRequest.with { + $0.chatID = conversationID.proto + $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.getLobbyMembers(request, options: .unaryDefault) + let error = ErrorGetLobbyMembers(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch lobby members") + await MainActor.run { completion(.failure(error)) } + return + } + let page = LobbyMembersPage( + members: response.members.compactMap(LobbyMember.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)) } + } + } + } + + /// Admits `userID` from `conversationID`'s lobby, handing them the chat key wrapped in + /// `keyEnvelope`. + func admitLobbyMember(owner: KeyPair, conversationID: ConversationID, userID: UserID, keyEnvelope: ConversationKeyEnvelope, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_AdmitLobbyMemberRequest.with { + $0.chatID = conversationID.proto + $0.userID = .with { $0.value = userID.data } + $0.keyEnvelope = keyEnvelope.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.admitLobbyMember(request, options: .unaryDefault) + let error = ErrorAdmitLobbyMember(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to admit lobby member") + 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)) } + } + } + } + + /// Turns `userID` away from `conversationID`'s lobby. + func denyLobbyMember(owner: KeyPair, conversationID: ConversationID, userID: UserID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_DenyLobbyMemberRequest.with { + $0.chatID = conversationID.proto + $0.userID = .with { $0.value = userID.data } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.denyLobbyMember(request, options: .unaryDefault) + let error = ErrorDenyLobbyMember(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to deny lobby member") + 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)) } + } + } + } + + /// Stores the signed-in user's wrapped copy of `conversationID`'s chat key. Rejected with + /// `.alreadySet` once one is stored. + func setKeyEnvelope(owner: KeyPair, conversationID: ConversationID, keyEnvelope: ConversationKeyEnvelope, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_SetKeyEnvelopeRequest.with { + $0.chatID = conversationID.proto + $0.keyEnvelope = keyEnvelope.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.setKeyEnvelope(request, options: .unaryDefault) + let error = ErrorSetKeyEnvelope(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to set key envelope") + 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)) } + } + } + } + + struct KeyEnvelopeResult: Sendable { + let envelope: ConversationKeyEnvelope + /// The member who wrapped the chat key for the signed-in user. + let wrappedBy: UserID? + } + + /// Fetches the chat key `conversationID` wrapped for the signed-in user, along with who + /// wrapped it. + func getKeyEnvelope(owner: KeyPair, conversationID: ConversationID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetKeyEnvelopeRequest.with { + $0.chatID = conversationID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getKeyEnvelope(request, options: .unaryDefault) + let error = ErrorGetKeyEnvelope(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, response.hasKeyEnvelope else { + logger.error("Failed to fetch key envelope") + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + let result = KeyEnvelopeResult( + envelope: ConversationKeyEnvelope(response.keyEnvelope), + wrappedBy: response.hasWrappedBy ? try? UUID(data: response.wrappedBy.value) : nil + ) + await MainActor.run { completion(.success(result)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } } // MARK: - Errors - @@ -443,6 +669,7 @@ public enum ErrorStartChat: Error, Sendable, Equatable { case pictureBlobNotAccepted case invalidRules case rulesNotSatisfied + case descriptionModerated(Flipcash_Moderation_V1_FlaggedCategory) case unknown case transportFailure case cancelled @@ -517,19 +744,96 @@ public enum ErrorGetMentionSuggestions: Error, Sendable, Equatable { /// No `.ok` case, and modelled on `ErrorStartChat` for the same reason: `.titleModerated` carries the /// `flaggedCategory` the server reports for it. Mapped explicitly from `EditChatResponse.Result` — see /// `ErrorEditChat.init(_:flaggedCategory:)` — never via positional `rawValue:`, since -/// `EditChatResponse.Result` has five cases against this file's usual three and a coincidental +/// `EditChatResponse.Result` has six cases against this file's usual three and a coincidental /// positional match would silently break the day a case is inserted upstream. public enum ErrorEditChat: Error, Sendable, Equatable { case denied case notFound case titleModerated(Flipcash_Moderation_V1_FlaggedCategory) case pictureBlobNotAccepted + case descriptionModerated(Flipcash_Moderation_V1_FlaggedCategory) case unknown case transportFailure case cancelled case rejected } +public enum ErrorEnterLobby: Int, Error { + case ok + case denied + case notFound + case alreadyMember + case lobbyFull + case tooManyLobbies + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorLeaveLobby: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetLobbyMembers: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorAdmitLobbyMember: Int, Error { + case ok + case denied + case notFound + case notInLobby + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorDenyLobbyMember: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorSetKeyEnvelope: Int, Error { + case ok + case denied + case notFound + case alreadySet + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetKeyEnvelope: Int, Error { + case ok + case denied + case notFound + case noEnvelope + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + extension ErrorGetDmChatFeed: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { @@ -568,7 +872,7 @@ extension ErrorStartChat: ServerError, TransportClassifiableError { switch self { case .transportFailure: .suppressed case .cancelled: .info - case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied: .info + case .denied, .titleModerated, .pictureBlobNotAccepted, .invalidRules, .rulesNotSatisfied, .descriptionModerated: .info case .unknown, .rejected: .error } } @@ -594,6 +898,8 @@ extension ErrorStartChat { self = .invalidRules case .rulesNotSatisfied: self = .rulesNotSatisfied + case .descriptionModerated: + self = .descriptionModerated(flaggedCategory) case .UNRECOGNIZED: self = .unknown } @@ -644,6 +950,83 @@ extension ErrorUnmuteChat: ServerError, TransportClassifiableError { } } +extension ErrorEnterLobby: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .alreadyMember, .lobbyFull, .tooManyLobbies: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorLeaveLobby: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetLobbyMembers: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorAdmitLobbyMember: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .notInLobby: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorDenyLobbyMember: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorSetKeyEnvelope: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .alreadySet: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetKeyEnvelope: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .noEnvelope: .info + case .unknown, .rejected: .error + } + } +} + extension ErrorGetRoster: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { @@ -707,7 +1090,7 @@ extension ErrorEditChat: ServerError, TransportClassifiableError { switch self { case .transportFailure: .suppressed case .cancelled: .info - case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted: .info + case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted, .descriptionModerated: .info case .unknown, .rejected: .error } } @@ -730,6 +1113,8 @@ extension ErrorEditChat { self = .titleModerated(flaggedCategory) case .pictureBlobNotAccepted: self = .pictureBlobNotAccepted + case .descriptionModerated: + self = .descriptionModerated(flaggedCategory) case .UNRECOGNIZED: self = .unknown } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift index 3d3dba40c..3da217efb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift @@ -158,6 +158,71 @@ final class ProfileService: Sendable { } } + /// Attaches an already-finalized blob as the caller's cover picture and returns the picture + /// the server derived from it, or `nil` when it carries no original rendition. + @discardableResult + func setCoverPicture(blobID: BlobID, owner: KeyPair) async throws -> ProfilePicture? { + var request = Flipcash_Profile_V1_SetCoverPictureRequest() + request.blobID = .with { $0.value = blobID.data } + request.auth = owner.authFor(message: request) + + do { + let response = try await service.setCoverPicture(request, options: .unaryDefault) + + switch response.result { + case .ok: + logger.info("Cover picture set", metadata: ["blobId": "\(blobID)"]) + return response.hasCoverPicture ? ProfilePicture(response.coverPicture) : nil + + case .denied: + throw ErrorProfile.denied + case .blobNotFound: + throw ErrorProfile.blobNotFound + case .blobNotReady: + throw ErrorProfile.blobNotReady + case .blobRejected: + throw ErrorProfile.blobRejected + case .invalidBlob: + throw ErrorProfile.invalidBlob + case .UNRECOGNIZED: + throw ErrorProfile.unknown + } + } catch let error as ErrorProfile { + throw error + } catch { + throw ErrorProfile.network(error) + } + } + + /// Sets the caller's bio, which the server moderates before it persists. An empty string + /// clears it. + func setBio(_ bio: String, owner: KeyPair) async throws { + var request = Flipcash_Profile_V1_SetBioRequest() + request.bio = bio + request.auth = owner.authFor(message: request) + + do { + let response = try await service.setBio(request, options: .unaryDefault) + + switch response.result { + case .ok: + logger.info("Bio set") + case .invalidBio: + throw ErrorProfile.invalidBio + case .denied: + throw ErrorProfile.denied + case .failedModerated: + throw ErrorProfile.moderated(response.flaggedCategory) + case .UNRECOGNIZED: + throw ErrorProfile.unknown + } + } catch let error as ErrorProfile { + throw error + } catch { + throw ErrorProfile.network(error) + } + } + func updateFlipcard(color: Flipcash_Common_V1_Color, owner: KeyPair) async throws { logger.info("Updating flipcard") @@ -285,6 +350,7 @@ extension ErrorSetMinDmChatInitFee: ServerError, TransportClassifiableError { public enum ErrorProfile: Error, Sendable { case denied case invalidDisplayName + case invalidBio case invalidUsername case usernameTaken case reservedWord @@ -301,7 +367,7 @@ public enum ErrorProfile: Error, Sendable { extension ErrorProfile: ServerError { public var reportingLevel: ErrorReportingLevel { switch self { - case .denied, .invalidDisplayName, .moderated, + case .denied, .invalidDisplayName, .invalidBio, .moderated, .invalidUsername, .usernameTaken, .reservedWord, .insufficientBalance, .blobNotFound, .blobNotReady, .blobRejected, .invalidBlob: .info diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index 35813f5d5..7a7d97b82 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -23,6 +23,10 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// The server-set title. Only populated for group chats; `nil` for DMs, /// where the counterpart's name is used instead. public var title: String? + /// The server-set description (at most 160 characters). Only populated for group chats; `nil` + /// when none is set. Not persisted: a conversation restored from the local database reports + /// `nil` until the server's copy refreshes it. + public var description: String? /// The newest event-log sequence the server holds for this chat, as reported /// by the feed. Compared against the locally-applied catch-up cursor to tell @@ -58,7 +62,16 @@ public struct Conversation: Identifiable, Hashable, Sendable { /// own implementation decision still pending. public var useE2Ee: 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) { + /// Whether the chat is a private group, whose members are admitted from a lobby and whose + /// messages are end-to-end encrypted. Not persisted: a conversation restored from the local + /// database reports `false` until the server's copy refreshes it. + public var isPrivate: Bool + + /// Whether the signed-in user is waiting in this chat's lobby rather than a member of it. Not + /// 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) { self.id = id self.members = members self.lastMessage = lastMessage @@ -66,6 +79,7 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.type = type self.isHidden = isHidden self.title = title + self.description = description self.latestEventSequence = latestEventSequence self.picture = picture self.rosterSummary = rosterSummary @@ -73,6 +87,8 @@ public struct Conversation: Identifiable, Hashable, Sendable { self.viewerState = viewerState self.creator = creator self.useE2Ee = useE2Ee + self.isPrivate = isPrivate + self.inLobby = inLobby } } @@ -151,6 +167,7 @@ extension Conversation { // Proto represents an unset title as an empty string; normalize to nil so // DMs (which never carry a title) and untitled groups behave the same. 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.rosterSummary = ConversationRosterSummary(proto.rosterSummary) @@ -158,6 +175,8 @@ extension Conversation { self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil self.creator = proto.hasCreator ? (try? UUID(data: proto.creator.value)) : nil self.useE2Ee = proto.useE2Ee + self.isPrivate = proto.isPrivate + self.inLobby = proto.inLobby } /// The member that isn't the signed-in user, used to title the conversation. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationDescriptionEdit.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationDescriptionEdit.swift new file mode 100644 index 000000000..44fac149b --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationDescriptionEdit.swift @@ -0,0 +1,35 @@ +// +// ConversationDescriptionEdit.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashAPI + +/// What an `EditChat` call does to a group's description. +/// +/// The wire distinguishes "leave it" (wrapper unset) from "clear it" (wrapper set with an empty +/// value); a bare `String?` would conflate the two with `""`, so the intent is spelled out. +public enum ConversationDescriptionEdit: Sendable, Equatable { + /// Leave the description as it is. + case unchanged + /// Replace the description. An empty string is treated as ``clear``. + case set(String) + /// Remove the description. + case clear +} + +extension ConversationDescriptionEdit { + /// The `EditChatRequest.description` wrapper, or `nil` to leave the field unset. + var proto: Flipcash_Chat_V1_EditChatRequest.Description? { + switch self { + case .unchanged: + nil + case .set(let value): + .with { $0.value = value } + case .clear: + .with { $0.value = "" } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationLobby.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationLobby.swift new file mode 100644 index 000000000..c3530c361 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationLobby.swift @@ -0,0 +1,113 @@ +// +// ConversationLobby.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// A wrapped chat key, carried as the opaque bytes the server stores. This client neither wraps +/// nor unwraps it: key wrapping is a cross-platform shared-logic hotspot that is not implemented +/// yet, so the scheme stays a raw value (like ``ConversationMessageContent/encrypted``) rather +/// than an enum this client would have to act on. +public struct ConversationKeyEnvelope: Hashable, Sendable { + /// The proto `KeyEnvelope.Scheme` raw value. + public let scheme: Int + public let nonce: Data + public let ciphertext: Data + + public init(scheme: Int, nonce: Data, ciphertext: Data) { + self.scheme = scheme + self.nonce = nonce + self.ciphertext = ciphertext + } +} + +extension ConversationKeyEnvelope { + init(_ proto: Flipcash_Chat_V1_KeyEnvelope) { + self.init(scheme: proto.scheme.rawValue, nonce: proto.nonce, ciphertext: proto.ciphertext) + } + + var proto: Flipcash_Chat_V1_KeyEnvelope { + .with { + $0.scheme = Flipcash_Chat_V1_KeyEnvelope.Scheme(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + } + } +} + +/// A user waiting in a private chat's lobby for an admin to admit them. +public struct LobbyMember: Sendable { + /// The waiting user's profile. `nil` when it does not parse. + public let profile: Profile? + /// The key an admitting member wraps the chat key to. + public let publicKey: PublicKey + public let enteredAt: Date? + + public var userID: UserID? { profile?.userID } + + public init(profile: Profile?, publicKey: PublicKey, enteredAt: Date?) { + self.profile = profile + self.publicKey = publicKey + self.enteredAt = enteredAt + } +} + +extension LobbyMember { + /// Nil when the public key does not parse: without it the member cannot be admitted. + init?(_ proto: Flipcash_Chat_V1_LobbyMember) { + guard let publicKey = try? PublicKey(proto.publicKey.value) else { return nil } + self.init( + profile: try? Profile(proto.userProfile), + publicKey: publicKey, + enteredAt: proto.hasEnteredAt ? proto.enteredAt.date : nil + ) + } +} + +/// A private chat the signed-in user has entered the lobby of and not yet been admitted to. +public struct Lobby: Sendable { + public let conversation: Conversation + public let enteredAt: Date? + + public init(conversation: Conversation, enteredAt: Date?) { + self.conversation = conversation + self.enteredAt = enteredAt + } +} + +extension Lobby { + /// Nil when the response carries no chat metadata. + init?(_ proto: Flipcash_Chat_V1_Lobby) { + guard proto.hasChat else { return nil } + self.init( + conversation: Conversation(proto.chat), + enteredAt: proto.hasEnteredAt ? proto.enteredAt.date : nil + ) + } +} + +/// One change to a private chat's lobby, delivered to its admitting members. +public enum LobbyUpdate: Sendable { + case memberEntered(LobbyMember) + case memberLeft(userID: UserID) +} + +extension LobbyUpdate { + /// Nil for a kind this client does not know, or a member or user id that does not parse. + init?(_ proto: Flipcash_Chat_V1_LobbyUpdate) { + switch proto.kind { + case .memberEntered(let entered): + guard entered.hasMember, let member = LobbyMember(entered.member) else { return nil } + self = .memberEntered(member) + case .memberLeft(let left): + guard let userID = try? UUID(data: left.userID.value) else { return nil } + self = .memberLeft(userID: userID) + case nil: + return nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 864630cff..fd8dc231b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -376,9 +376,15 @@ public struct ConversationStore: Sendable { case .titleChanged(let conversationID, let title): applyTitleChanged(title, in: conversationID) return .none + case .descriptionChanged(let conversationID, let description): + applyDescriptionChanged(description, in: conversationID) + return .none case .pictureChanged(let conversationID, let picture): applyPictureChanged(picture, in: conversationID) return .none + case .lobbyChanged: + // Lobby membership is not part of the feed; no consumer holds it yet. + return .none } } @@ -542,6 +548,15 @@ public struct ConversationStore: Sendable { conversations[index].title = title } + /// Apply a description change delivered via `MetadataUpdate.DescriptionChanged`. An empty string + /// clears it, matching how ``Conversation/init(_:)`` normalizes the wire's empty string to `nil`. + /// Same best-effort, no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the + /// store doesn't hold. + public mutating func applyDescriptionChanged(_ description: String, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].description = description.isEmpty ? nil : description + } + /// Apply a picture change delivered via `MetadataUpdate.PictureChanged`. 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) { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index a63a60aa0..4acd8f6cf 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -48,6 +48,11 @@ public enum ConversationStreamEvent: Sendable { /// miss refetches the chat via `Chat.GetChat`. case titleChanged(conversationID: ConversationID, title: String) + /// A group chat's description changed (via `Chat.EditChat`), including on the editor's other + /// devices. An empty `description` means it was cleared. Best-effort and applied as received, + /// like ``titleChanged``. + case descriptionChanged(conversationID: ConversationID, description: String) + /// A group chat's picture changed (via `Chat.EditChat`), including on the editor's other /// devices. Best-effort and applied as received, like ``titleChanged``. case pictureChanged(conversationID: ConversationID, picture: ProfilePicture) @@ -56,6 +61,10 @@ public enum ConversationStreamEvent: Sendable { /// log: each update is applied by its per-emoji version, and a missed one is reconciled by the /// next reaction summary. case reactionsChanged(conversationID: ConversationID, updates: [DecodedReactionUpdate]) + + /// Members entered or left a private chat's lobby. Delivered to the chat's admitting members; + /// surfaced as received, with no version to compare. + case lobbyChanged(conversationID: ConversationID, updates: [LobbyUpdate]) } /// One reaction added or removed on one message. @@ -202,6 +211,8 @@ extension ConversationStreamEvent { events.append(.viewerStateChanged(conversationID: conversationID, viewerState: ConversationViewerState(changed.viewerState))) case .titleChanged(let changed): events.append(.titleChanged(conversationID: conversationID, title: changed.newTitle)) + case .descriptionChanged(let changed): + events.append(.descriptionChanged(conversationID: conversationID, description: changed.newDescription)) case .pictureChanged(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. @@ -240,6 +251,11 @@ extension ConversationStreamEvent { events.append(.reactionsChanged(conversationID: conversationID, updates: reactionUpdates)) } + let lobbyUpdates = update.lobbyUpdates.lobbyUpdates.compactMap(LobbyUpdate.init) + if !lobbyUpdates.isEmpty { + events.append(.lobbyChanged(conversationID: conversationID, updates: lobbyUpdates)) + } + return events } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift index 01ab69af4..e407a8468 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Profile.swift @@ -50,6 +50,14 @@ public struct Profile: Codable, Equatable, Sendable { /// when there is no username. public let isUsernameAutoAssigned: Bool + /// The user's bio, or `nil` when they haven't set one. Public, but absent from a profile + /// that rides on a chat member row or mention suggestion. + public let bio: String? + + /// The user's cover picture, or `nil` when they haven't set one. Shares ``ProfilePicture``'s + /// shape: an original rendition plus a thumbnail. + public let coverPicture: ProfilePicture? + public var isPhoneVerified: Bool { phone != nil } @@ -67,7 +75,7 @@ public struct Profile: Codable, Equatable, Sendable { phone != nil && phone?.e164 != previous?.phone?.e164 } - public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil, minDmChatInitFee: FiatAmount? = nil, isUsernameAutoAssigned: Bool = false) throws { + public init(displayName: String?, phone: String?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil, minDmChatInitFee: FiatAmount? = nil, isUsernameAutoAssigned: Bool = false, bio: String? = nil, coverPicture: ProfilePicture? = nil) throws { // Only parse phone if it's not empty var parsedPhone: Phone? @@ -93,11 +101,13 @@ public struct Profile: Codable, Equatable, Sendable { userID: userID, username: username, minDmChatInitFee: minDmChatInitFee, - isUsernameAutoAssigned: isUsernameAutoAssigned + isUsernameAutoAssigned: isUsernameAutoAssigned, + bio: bio, + coverPicture: coverPicture ) } - public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil, minDmChatInitFee: FiatAmount? = nil, isUsernameAutoAssigned: Bool = false) { + public init(displayName: String?, phone: Phone?, email: String?, profilePicture: ProfilePicture? = nil, joinedAt: Date? = nil, tipCardCustomization: TipCardCustomization? = nil, userID: UserID? = nil, username: Username? = nil, minDmChatInitFee: FiatAmount? = nil, isUsernameAutoAssigned: Bool = false, bio: String? = nil, coverPicture: ProfilePicture? = nil) { self.displayName = displayName self.phone = phone self.email = email @@ -108,10 +118,12 @@ public struct Profile: Codable, Equatable, Sendable { self.username = username self.minDmChatInitFee = minDmChatInitFee self.isUsernameAutoAssigned = isUsernameAutoAssigned + self.bio = bio + self.coverPicture = coverPicture } - /// `isUsernameAutoAssigned` is decoded with a default so rows persisted - /// before it existed still decode, without a `schemaVersion` bump. + /// `isUsernameAutoAssigned`, `bio` and `coverPicture` are decoded with a default so rows + /// persisted before they existed still decode, without a `schemaVersion` bump. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.displayName = try c.decodeIfPresent(String.self, forKey: .displayName) @@ -124,6 +136,8 @@ public struct Profile: Codable, Equatable, Sendable { self.username = try c.decodeIfPresent(Username.self, forKey: .username) self.minDmChatInitFee = try c.decodeIfPresent(FiatAmount.self, forKey: .minDmChatInitFee) self.isUsernameAutoAssigned = try c.decodeIfPresent(Bool.self, forKey: .isUsernameAutoAssigned) ?? false + self.bio = try c.decodeIfPresent(String.self, forKey: .bio) + self.coverPicture = try c.decodeIfPresent(ProfilePicture.self, forKey: .coverPicture) } } @@ -162,7 +176,10 @@ extension Profile { value: Decimal(proto.minDmChatInitFee.nativeAmount), currency: try CurrencyCode(currencyCode: proto.minDmChatInitFee.currency) ) : nil, - isUsernameAutoAssigned: proto.isUsernameAutoAssigned + isUsernameAutoAssigned: proto.isUsernameAutoAssigned, + // The proto represents an unset bio as an empty string. + bio: proto.bio.isEmpty ? nil : proto.bio, + coverPicture: proto.hasCoverPicture ? ProfilePicture(proto.coverPicture) : nil ) } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift index ff2edd0ec..b5c7ce4fb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import FlipcashAPI @testable import FlipcashCore /// `Chat.EditChat`'s title/picture updates: unlike the roster summary and viewer state, these @@ -52,6 +53,41 @@ struct ConversationMetadataEditTests { #expect(store.conversations.isEmpty) } + @Test("A description change is applied, and an empty one clears it") + func descriptionChangeApplies() { + var store = ConversationStore() + store.apply(.metadataRefresh(group(1))) + + store.applyDescriptionChanged("About us", in: conversationID(1)) + #expect(store.conversations[0].description == "About us") + + store.applyDescriptionChanged("", in: conversationID(1)) + #expect(store.conversations[0].description == nil) + } + + @Test("Metadata maps the wire description, normalizing empty to nil") + func metadataMapsDescription() { + func conversation(_ description: String) -> Conversation { + Conversation(Flipcash_Chat_V1_Metadata.with { + $0.chatID = conversationID(1).proto + $0.type = .group + $0.description_p = description + }) + } + + #expect(conversation("About us").description == "About us") + #expect(conversation("").description == nil) + } + + @Test("A description change for a chat the store doesn't hold is a no-op") + func descriptionChangeForUnknownChatNoOps() { + var store = ConversationStore() + + store.applyDescriptionChanged("About us", in: conversationID(1)) + + #expect(store.conversations.isEmpty) + } + @Test("A picture change is applied to the matching chat") func pictureChangeApplies() { var store = ConversationStore() @@ -72,3 +108,17 @@ struct ConversationMetadataEditTests { #expect(store.conversations.isEmpty) } } + +@Suite("Conversation description edit") +struct ConversationDescriptionEditTests { + + @Test("Unchanged leaves the wrapper unset; set and clear send a value") + func wireShape() { + #expect(ConversationDescriptionEdit.unchanged.proto == nil) + #expect(ConversationDescriptionEdit.set("About us").proto?.value == "About us") + + let cleared = ConversationDescriptionEdit.clear.proto + #expect(cleared != nil) + #expect(cleared?.value == "") + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index c3108be08..f2013782a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -83,6 +83,31 @@ struct ConversationStreamEventDecodeTests { #expect(title == "New title") } + @Test("DescriptionChanged decodes to a descriptionChanged event, empty meaning cleared") + func descriptionChanged() { + func decode(_ value: String) -> ConversationStreamEvent? { + ConversationStreamEvent.decode(Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.descriptionChanged = .with { $0.newDescription = value } + }] + } + }).first + } + + guard case .descriptionChanged(let conversationID, let description) = decode("About us") else { + Issue.record("expected .descriptionChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(description == "About us") + + guard case .descriptionChanged(_, let cleared) = decode("") else { + Issue.record("expected .descriptionChanged for a cleared description"); return + } + #expect(cleared == "") + } + @Test("PictureChanged decodes to a pictureChanged event") func pictureChanged() { let blobBytes = Data(repeating: 0x01, count: 16) @@ -417,4 +442,45 @@ struct ConversationStreamEventDecodeTests { reactedAt: Date(timeIntervalSince1970: 50) )) } + + @Test("lobby updates decode, dropping a member whose public key does not parse") + func lobbyUpdates() throws { + let leaver = UUID() + let entering = UUID() + let entered: (Data) -> Flipcash_Chat_V1_LobbyUpdate = { key in + .with { + $0.memberEntered = .with { + $0.member = .with { + $0.userProfile = .with { $0.userID = .with { $0.value = entering.data } } + $0.publicKey = .with { $0.value = key } + $0.enteredAt = .init(date: Date(timeIntervalSince1970: 70)) + } + } + } + } + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.lobbyUpdates = .with { + $0.lobbyUpdates = [ + entered(Data(repeating: 0x07, count: 32)), + entered(Data(repeating: 0x07, count: 3)), + .with { $0.memberLeft = .with { $0.userID = .with { $0.value = leaver.data } } }, + ] + } + } + } + + guard case .lobbyChanged(let conversationID, let updates) = ConversationStreamEvent.decode(event).first else { + Issue.record("expected .lobbyChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(updates.count == 2) + guard case .memberEntered(let member) = updates[0], case .memberLeft(let left) = updates[1] else { + Issue.record("expected an entry then a leave"); return + } + #expect(member.userID == entering) + #expect(member.enteredAt == Date(timeIntervalSince1970: 70)) + #expect(left == leaver) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index adca719bc..87b375ab5 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -75,9 +75,22 @@ struct TransportClassificationTests { @Test func errorGetDelta() { assertClassifies(ErrorGetDelta.self) } @Test func errorAdvancePointer() { assertClassifies(ErrorAdvancePointer.self) } @Test func errorNotifyIsTyping() { assertClassifies(ErrorNotifyIsTyping.self) } + @Test("ENCRYPTION_REQUIRED maps positionally onto encryptionRequired for send and edit") + func encryptionRequiredMapping() { + #expect(ErrorSendMessage(rawValue: Flipcash_Messaging_V1_SendMessageResponse.Result.encryptionRequired.rawValue) == .encryptionRequired) + #expect(ErrorEditMessage(rawValue: Flipcash_Messaging_V1_EditMessageResponse.Result.encryptionRequired.rawValue) == .encryptionRequired) + } + @Test func errorGetDmChatFeed() { assertClassifies(ErrorGetDmChatFeed.self) } @Test func errorGetChat() { assertClassifies(ErrorGetChat.self) } @Test func errorGetGroupChatFeed() { assertClassifies(ErrorGetGroupChatFeed.self) } + @Test func errorEnterLobby() { assertClassifies(ErrorEnterLobby.self) } + @Test func errorLeaveLobby() { assertClassifies(ErrorLeaveLobby.self) } + @Test func errorGetLobbyMembers() { assertClassifies(ErrorGetLobbyMembers.self) } + @Test func errorAdmitLobbyMember() { assertClassifies(ErrorAdmitLobbyMember.self) } + @Test func errorDenyLobbyMember() { assertClassifies(ErrorDenyLobbyMember.self) } + @Test func errorSetKeyEnvelope() { assertClassifies(ErrorSetKeyEnvelope.self) } + @Test func errorGetKeyEnvelope() { assertClassifies(ErrorGetKeyEnvelope.self) } @Test func errorStartChat() { assertClassifies(ErrorStartChat.self) } @Test func errorJoinChat() { assertClassifies(ErrorJoinChat.self) } @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) }