Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
2 changes: 2 additions & 0 deletions Flipcash/Utilities/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand Down Expand Up @@ -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) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Conversation, ErrorStartChat>) -> Void) {
func startChat(owner: KeyPair, title: String, description: String? = nil, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID, completion: @Sendable @escaping (Result<Conversation, ErrorStartChat>) -> 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 }
}
Expand Down Expand Up @@ -357,21 +361,27 @@ 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`.
///
/// `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<Conversation, ErrorEditChat>) -> 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<Conversation, ErrorEditChat>) -> 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 } }
}
Expand Down Expand Up @@ -659,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
Expand Down Expand Up @@ -733,13 +744,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
Expand Down Expand Up @@ -860,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
}
}
Expand All @@ -886,6 +898,8 @@ extension ErrorStartChat {
self = .invalidRules
case .rulesNotSatisfied:
self = .rulesNotSatisfied
case .descriptionModerated:
self = .descriptionModerated(flaggedCategory)
case .UNRECOGNIZED:
self = .unknown
}
Expand Down Expand Up @@ -1076,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
}
}
Expand All @@ -1099,6 +1113,8 @@ extension ErrorEditChat {
self = .titleModerated(flaggedCategory)
case .pictureBlobNotAccepted:
self = .pictureBlobNotAccepted
case .descriptionModerated:
self = .descriptionModerated(flaggedCategory)
case .UNRECOGNIZED:
self = .unknown
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,14 +71,15 @@ 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
self.lastActivity = lastActivity
self.type = type
self.isHidden = isHidden
self.title = title
self.description = description
self.latestEventSequence = latestEventSequence
self.picture = picture
self.rosterSummary = rosterSummary
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading