Skip to content

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down
8 changes: 7 additions & 1 deletion Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
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.15.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 Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading