From 0d2537044eeb3c8dbc154f2b6aa98bf88bcce66e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 17:27:46 -0400 Subject: [PATCH 1/3] feat(chat): decode group descriptions and map DESCRIPTION_MODERATED Add Conversation.description (in-memory), decode MetadataUpdate.DescriptionChanged into the store, and map DESCRIPTION_MODERATED on StartChat and EditChat results. --- .../Controllers/ConversationController.swift | 6 +++- .../Controllers/EncryptedChatClient.swift | 2 +- Flipcash/Utilities/Events.swift | 2 ++ .../Flip API/Services/ChatService.swift | 12 +++++-- .../Models/Conversation/Conversation.swift | 8 ++++- .../Conversation/ConversationStore.swift | 12 +++++++ .../ConversationStreamEvent.swift | 7 ++++ .../ConversationMetadataEditTests.swift | 36 +++++++++++++++++++ .../ConversationStreamEventDecodeTests.swift | 25 +++++++++++++ 9 files changed, 104 insertions(+), 6 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index c8b2a1647..581db537f 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -854,7 +854,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: + 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. @@ -1365,6 +1365,10 @@ 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: diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index b04b8454a..de1bf4c1c 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -218,7 +218,7 @@ extension EncryptedChatClient: ConversationEventStreaming { case .metadataRefresh(let conversation): return .metadataRefresh(await opened(conversation)) case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged, - .viewerStateChanged, .titleChanged, .pictureChanged, .reactionsChanged, .lobbyChanged: + .viewerStateChanged, .titleChanged, .pictureChanged, .descriptionChanged, .reactionsChanged, .lobbyChanged: return event } } 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/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index b71fd274e..c15321d4f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -659,6 +659,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 @@ -733,13 +734,14 @@ 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 @@ -860,7 +862,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 } } @@ -886,6 +888,8 @@ extension ErrorStartChat { self = .invalidRules case .rulesNotSatisfied: self = .rulesNotSatisfied + case .descriptionModerated: + self = .descriptionModerated(flaggedCategory) case .UNRECOGNIZED: self = .unknown } @@ -1076,7 +1080,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 } } @@ -1099,6 +1103,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/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index b635596e0..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 @@ -67,7 +71,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) { + 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 @@ -75,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 @@ -162,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) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index d53647db0..fd8dc231b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -376,6 +376,9 @@ 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 @@ -545,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 4aed10a26..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) @@ -206,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. diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift index ff2edd0ec..6e2874b67 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() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index f1201463b..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) From bc346dcee8a1b7bc95dfbefa88963535ffc2948f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 17:30:19 -0400 Subject: [PATCH 2/3] feat(chat): pass group description through StartChat and EditChat EditChat models unchanged, set, and clear explicitly via ConversationDescriptionEdit, since the wire tells them apart by whether the wrapper is set. --- .../Clients/Flip API/FlipClient+Chat.swift | 17 ++++----- .../Flip API/Services/ChatService.swift | 20 ++++++++--- .../ConversationDescriptionEdit.swift | 35 +++++++++++++++++++ .../ConversationMetadataEditTests.swift | 14 ++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationDescriptionEdit.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 566f6d90c..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) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index c15321d4f..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.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 } } } 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/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift index 6e2874b67..b5c7ce4fb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift @@ -108,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 == "") + } +} From 0dc03d2218ce494a1864df06be27d057d3be4990 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 17:30:51 -0400 Subject: [PATCH 3/3] chore(deps): bump flipcash2-client-protocol to 0.17.0 --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 63f02aa2c..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.16.0" + case .flipcash2: return "0.17.0" } }