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
30 changes: 26 additions & 4 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,11 @@ final class ConversationController {

case .deleted:
return nil

case .encrypted:
// No plaintext to preview -- the row still surfaces (it's still the newest activity),
// just with a blank subtitle, same as an empty text body above.
return nil
}
}

Expand Down Expand Up @@ -1556,10 +1561,27 @@ final class ConversationController {
/// so a double-tap (or a tap during a slow in-flight retry) can't fire concurrent sends.
func retry(clientMessageID: UUID, in conversationID: ConversationID) async {
guard let pending = store.pendingMessage(clientMessageID: clientMessageID, in: conversationID),
pending.status == .failed,
case .text(let text) = pending.content else { return }
store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID)
_ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID)
pending.status == .failed else { return }
// Only `.text` is ever sent by this client today -- `deliver(text:)` is the only send path,
// and `send(_:to:)` only ever creates a `.text` pending row -- so this is unreachable in
// practice. It's a `switch` rather than the narrow `guard case .text` it replaces so a future
// pending shape (e.g. an outbox that can hold `.encrypted`) fails loudly via the log below
// instead of silently never retrying, and so `Content.asProto()`'s own crash-free contract
// (no fatalError/force-unwrap for `.encrypted`/`.cash`/`.deleted`) is exercised here too.
switch pending.content {
case .text(let text):
store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID)
_ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID)
case .encrypted, .cash, .deleted:
if case .failure(let error) = Result(catching: { try pending.content.asProto() }) {
logger.error("Cannot retry a send this client has no path to re-send", metadata: [
"conversationID": "\(conversationID)",
"error": "\(error)",
])
}
// Nothing to resend over the existing text-only send RPC; leave it `.failed` rather than
// looping forever or crashing.
}
}

private func deliver(clientMessageID: UUID, text: String, repliedTo: MessageID?, to conversationID: ConversationID) async -> Bool {
Expand Down
19 changes: 17 additions & 2 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ extension ChatItem {
func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool {
switch message.content {
case .text(let text): EmojiOnlyDetector.isEmojiOnly(text)
case .cash, .deleted: false
case .cash, .deleted, .encrypted: false
}
}
func rendersBare(_ message: ConversationMessage) -> Bool {
Expand All @@ -131,7 +131,7 @@ extension ChatItem {
let layouts = messages.map { message in
switch message.content {
case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard))
case .cash, .deleted: [RowLayout(part: nil, text: nil, preview: nil)]
case .cash, .deleted, .encrypted: [RowLayout(part: nil, text: nil, preview: nil)]
}
}
// A card row breaks the bubble run the way bare emoji do, so what faces a neighbour is the
Expand Down Expand Up @@ -202,6 +202,11 @@ extension ChatItem {
? "You deleted this message"
: "This message was deleted"
)
case .encrypted:
// Decryption isn't implemented on this client -- a cross-platform parity hotspot --
// so an encrypted message renders as the same non-interactive placeholder bubble a
// tombstone does, with copy matching Android's unsupported-content bubble.
content = .deleted(ChatMessage.unsupportedContentCopy)
}

// The status line rides on the bubble itself (not a separate row, so a send is a clean
Expand Down Expand Up @@ -334,6 +339,16 @@ extension ChatItem {
kind: .unavailable,
authorID: original.senderID
)
case .encrypted:
// No plaintext to preview -- same unavailable treatment as a quote whose original the
// local database never saw.
return ChatQuote(
stableID: nil,
authorName: authorName,
snippet: ChatQuote.unavailableSnippet,
kind: .unavailable,
authorID: original.senderID
)
}
}

Expand Down
4 changes: 3 additions & 1 deletion Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,8 @@ struct ConversationScreen: View {
)
case .deleted:
(ChatQuote.deletedSnippet, .unavailable)
case .encrypted:
(ChatQuote.unavailableSnippet, .unavailable)
}
}

Expand Down Expand Up @@ -980,7 +982,7 @@ struct ConversationScreen: View {
case .cash(let fiat):
Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint)
router.push(.currencyInfo(fiat.mint))
case .text, .deleted:
case .text, .deleted, .encrypted:
break
}
}
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ nonisolated struct ChatSpotlightItem {
switch message?.content {
case .text(let text): text
case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())"
case .deleted, nil: nil
case .deleted, .encrypted, nil: nil
}
}

Expand Down
4 changes: 2 additions & 2 deletions FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ enum ContractPackage: String, CaseIterable {
/// The pinned version consumed when this package isn't building against a local checkout.
var version: Version {
switch self {
case .ocp: return "0.5.0"
case .flipcash2: return "0.11.0"
case .ocp: return "0.6.0"
case .flipcash2: return "0.12.0"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ final class ChatMessagingService: Sendable {
await MainActor.run {
completion(.success(MessageMutation(message: message, isConflict: error == .conflict)))
}
case .denied, .messageNotFound, .cannotEdit, .unknown, .transportFailure, .cancelled, .rejected:
case .denied, .messageNotFound, .cannotEdit, .encryptionNotAllowed, .unknown, .transportFailure, .cancelled, .rejected:
logger.error("Failed to edit message")
await MainActor.run { completion(.failure(error)) }
}
Expand Down Expand Up @@ -353,6 +353,8 @@ public enum ErrorGetDelta: Int, Error {
public enum ErrorSendMessage: Int, Error {
case ok
case denied
/// The content is EncryptedContent and the chat is not a DM.
case encryptionNotAllowed
case unknown = -1
case transportFailure = -2
case cancelled = -3
Expand All @@ -365,6 +367,8 @@ public enum ErrorEditMessage: Int, Error {
case messageNotFound
case cannotEdit
case conflict
/// The content is EncryptedContent and the chat is not a DM.
case encryptionNotAllowed
case unknown = -1
case transportFailure = -2
case cancelled = -3
Expand Down Expand Up @@ -441,7 +445,10 @@ extension ErrorSendMessage: ServerError, TransportClassifiableError {
switch self {
case .ok, .transportFailure: .suppressed
case .cancelled: .info
// Denied is an expected membership/business outcome; encryptionNotAllowed is a client-side
// contract violation (sending EncryptedContent outside a DM), not a server hiccup.
case .denied: .info
case .encryptionNotAllowed: .error
case .unknown, .rejected: .error
}
}
Expand All @@ -455,6 +462,8 @@ extension ErrorEditMessage: ServerError, TransportClassifiableError {
// `conflict` is the concurrency guard doing its job, and the rest are expected
// membership/business outcomes — none is a client defect.
case .denied, .messageNotFound, .cannotEdit, .conflict: .info
// A client-side contract violation (sending EncryptedContent outside a DM), not a server hiccup.
case .encryptionNotAllowed: .error
case .unknown, .rejected: .error
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable {
}
}

/// Placeholder copy for content this client has no way to render -- today, an encrypted
/// message it cannot decrypt. Reuses the tombstone's `.deleted` display case (same
/// non-interactive bubble style) with wording that says "unsupported" rather than "deleted",
/// matching Android's copy for the same content.
public static let unsupportedContentCopy = "This message isn't supported on this version"

/// Whether this row draws as the link card on its own, with no bubble behind it.
///
/// The card is already a surface with its own rounded shape, so a bubble behind it would draw
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ extension ChatItem {
iconURL: branding?.iconURL,
isTip: message.cashAction == .tipped
))
case .encrypted:
// Not filtered above (only tombstones are): an encrypted message stays a real,
// visible row, same as the in-app transcript, just with no plaintext to preview.
content = .deleted(ChatMessage.unsupportedContentCopy)
case .deleted:
continue // filtered out above; unreachable, kept for switch exhaustiveness
}
Expand All @@ -86,8 +90,8 @@ extension ChatItem {
// at most three rows and never groups them.
let isEmojiOnly: Bool
switch message.content {
case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text)
case .cash, .deleted: isEmojiOnly = false
case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text)
case .cash, .deleted, .encrypted: isEmojiOnly = false
}

items.append(.message(ChatMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ public struct Conversation: Identifiable, Hashable, Sendable {
/// server has reported one. See ``ConversationViewerState``.
public var viewerState: ConversationViewerState?

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) {
/// The chat's creator. Only ever set for group chats; `nil` for DMs.
public var creator: UserID?

/// Whether messages in this chat are end-to-end encrypted. DMs only, always `false` for group
/// chats. A transitional migration flag — see `Flipcash_Chat_V1_Metadata.useE2Ee` — that this
/// client does not yet act on: E2EE send/receive is a cross-platform parity hotspot with its
/// 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) {
self.id = id
self.members = members
self.lastMessage = lastMessage
Expand All @@ -62,6 +71,8 @@ public struct Conversation: Identifiable, Hashable, Sendable {
self.rosterSummary = rosterSummary
self.rules = rules
self.viewerState = viewerState
self.creator = creator
self.useE2Ee = useE2Ee
}
}

Expand Down Expand Up @@ -145,6 +156,8 @@ extension Conversation {
self.rosterSummary = ConversationRosterSummary(proto.rosterSummary)
self.rules = proto.hasRules ? ConversationRules(proto.rules) : nil
self.viewerState = proto.hasViewerState ? ConversationViewerState(proto.viewerState) : nil
self.creator = proto.hasCreator ? (try? UUID(data: proto.creator.value)) : nil
self.useE2Ee = proto.useE2Ee
}

/// The member that isn't the signed-in user, used to title the conversation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable {
case text(String)
case cash(ExchangedFiat)
case deleted(Deletion)
/// End-to-end-encrypted content this client cannot decrypt (decryption isn't implemented
/// yet -- a cross-platform parity hotspot). `scheme` is the wire `EncryptedContent.Scheme`
/// raw value, kept as `Int` so this model doesn't depend on the generated proto enum.
/// Stored verbatim -- nonce and ciphertext are never inspected -- so the message round-trips
/// byte for byte back to the wire on re-send/edit, and renders as an "unsupported" bubble.
case encrypted(scheme: Int, nonce: Data, ciphertext: Data)
}

public let id: MessageID
Expand Down Expand Up @@ -196,6 +202,19 @@ extension ConversationMessage {
self.content = .text(textContent.text)
self.cashAction = nil
repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil
case .encrypted(let encryptedContent):
// EncryptedContent is a cross-platform parity hotspot (X25519/HKDF/XChaCha20); decrypting
// it is not implemented here. Unlike `.media`/`.system`, the message is kept -- stored
// verbatim and rendered as an "unsupported" bubble -- so it doesn't silently vanish from
// the transcript the way Android's client no longer does either.
self.content = .encrypted(
scheme: encryptedContent.scheme.rawValue,
nonce: encryptedContent.nonce,
ciphertext: encryptedContent.ciphertext
)
self.cashAction = nil
repliedTo = nil
// `.media`/`.system` are dropped by design: the message is not stored and not shown.
case .media, .system, .none:
return nil
}
Expand All @@ -212,3 +231,34 @@ extension ConversationMessage {
self.redacted = proto.redacted
}
}

/// Content this client has no way to turn back into a proto `Content` to send. Thrown rather than
/// asserted: `.cash` and `.deleted` are never round-tripped this way (cash has its own send path;
/// a tombstone is a mutation result, never resent), but a caller that tries should get a normal
/// error, not a crash.
public enum ConversationMessageContentEncodingError: Error, Sendable {
case unsupported(ConversationMessage.Content)
}

extension ConversationMessage.Content {
/// Encodes this content back to the wire `Content` it would be sent as. For `.encrypted` this
/// is a byte-for-byte round trip of the stored scheme/nonce/ciphertext -- not encryption, since
/// this client never decrypted them in the first place -- so a retried/re-sent encrypted message
/// reaches the server unchanged rather than being dropped or crashing the sender.
public func asProto() throws -> Flipcash_Messaging_V1_Content {
switch self {
case .text(let text):
return .with { $0.type = .text(.with { $0.text = text }) }
case .encrypted(let scheme, let nonce, let ciphertext):
return .with {
$0.type = .encrypted(.with {
$0.scheme = Flipcash_Messaging_V1_EncryptedContent.Scheme(rawValue: scheme) ?? .unknown
$0.nonce = nonce
$0.ciphertext = ciphertext
})
}
case .cash, .deleted:
throw ConversationMessageContentEncodingError.unsupported(self)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ extension MessageCapability {
now: Date
) -> Set<MessageCapability> {
switch message.content {
case .deleted:
// Nothing is left to act on, and a tombstone must not be re-deleted.
case .deleted, .encrypted:
// Nothing is left to act on: a tombstone must not be re-deleted, and this client has no
// plaintext to copy, quote, or edit for an encrypted message it cannot decrypt.
return []
case .cash:
// Reply is a cash message's only capability: there is no text to copy, the server
Expand Down
20 changes: 16 additions & 4 deletions FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,30 @@ public enum NotificationPayload {
}

/// The message a CHAT push carries inline, or `nil` when the push isn't a chat message, carries
/// no chat metadata, predates the server embedding the message, or carries content this client
/// can't represent.
/// no chat metadata, predates the server embedding the message, carries content this client
/// can't represent, or only carries the message's id (a long message — see
/// `Flipcash_Push_V1_ChatMetadata.messageRef`).
///
/// The embedded message is the only part of a push that needs no network to become store rows.
/// It carries the same `eventSequence` the transcript fetch would return for it, so it merges
/// with a fetched message rather than competing with one.
///
/// TODO(push/v1/model.proto ChatMetadata.message_ref): when only `messageID` is present, this
/// returns nil rather than fetching the message via `Messaging.GetMessage`. The notification
/// service extension's transcript prefetch (`NotificationService.cachePreview`) already fetches
/// the chat's recent messages independently of this value, so the id-only case is not silently
/// dropped in practice — it just doesn't get the "needs no network" fast path this doc comment
/// describes. Wire up a `GetMessage` fetch here (or at the call site) if that gap matters.
public static func chatMessage(_ userInfo: [AnyHashable: Any]) -> ConversationMessage? {
guard let payload = decode(userInfo), payload.category == .chat, payload.hasChatMetadata else {
return nil
}
guard payload.chatMetadata.hasMessage else { return nil }
return ConversationMessage(payload.chatMetadata.message)
switch payload.chatMetadata.messageRef {
case .message(let message):
return ConversationMessage(message)
case .messageID, nil:
return nil
}
}

/// Whether the recipient had the chat muted when a CHAT push was sent. The push is still
Expand Down
Loading
Loading