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

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

2 changes: 1 addition & 1 deletion CrossPlatformVectors/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = {
if let sharedCoreLocalRoot {
return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm")
}
return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.9.0"))
return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.10.0"))
}()

// Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs
Expand Down
229 changes: 229 additions & 0 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//
// EncryptedChatClient.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import os
import FlipcashCore

/// The chat surface `ConversationController` talks to, with DM end-to-end encryption applied at
/// the edge: every message read from the server comes back decrypted (or marked with why it
/// couldn't be), and every text sent into an encrypting chat goes out sealed.
///
/// The controller never sees ciphertext it could decrypt, so the store, the database and the UI
/// work on plaintext. A message whose peer key can't be fetched yet passes through undecrypted
/// and unmarked; the transcript hides it until a later read decrypts it.
final class EncryptedChatClient: Sendable {

private let client: FlipClient
private let keyring: ChatKeyring

/// Every chat seen on its way through, so a message can be opened without a `GetChat`.
private let conversations = OSAllocatedUnfairLock<[ConversationID: Conversation]>(initialState: [:])

init(client: FlipClient, keyring: ChatKeyring) {
self.client = client
self.keyring = keyring
}

private func remember(_ conversation: Conversation) {
conversations.withLock { $0[conversation.id] = conversation }
}

/// The chat `conversationID` names, fetched when it hasn't passed through yet.
private func conversation(_ conversationID: ConversationID, owner: KeyPair) async throws -> Conversation {
if let known = conversations.withLock({ $0[conversationID] }) { return known }
let fetched = try await client.getChat(owner: owner, conversationID: conversationID)
remember(fetched)
return fetched
}

/// How `conversationID`'s messages open; waits on the key when the chat can't be fetched.
private func opener(_ conversationID: ConversationID, owner: KeyPair) async -> ChatOpener {
guard let conversation = try? await conversation(conversationID, owner: owner) else { return .awaitingKey }
return await keyring.opener(for: conversation)
}

/// `conversation` remembered, with its preview message opened.
private func opened(_ conversation: Conversation) async -> Conversation {
remember(conversation)
guard let lastMessage = conversation.lastMessage, lastMessage.isAwaitingDecryption else { return conversation }
var conversation = conversation
conversation.lastMessage = await keyring.open(lastMessage, in: conversation)
return conversation
}

/// The seal for a send into `conversationID`, nil when it goes out in plaintext.
private func sealForSending(_ conversationID: ConversationID, owner: KeyPair) async throws -> ChatSeal? {
try await keyring.sealForSending(in: conversation(conversationID, owner: owner))
}

/// Runs a send, forgetting `conversationID` when the server refuses its encryption choice so a
/// retry decides again from a fresh copy of the chat.
private func refetchingOnRefusal<T>(_ conversationID: ConversationID, _ send: () async throws -> T) async throws -> T {
do {
return try await send()
} catch ErrorSendMessage.encryptionNotAllowed {
conversations.withLock { _ = $0.removeValue(forKey: conversationID) }
throw ErrorSendMessage.encryptionNotAllowed
} catch ErrorEditMessage.encryptionNotAllowed {
conversations.withLock { _ = $0.removeValue(forKey: conversationID) }
throw ErrorEditMessage.encryptionNotAllowed
}
}
}

// MARK: - ConversationFetching -

extension EncryptedChatClient: ConversationFetching {

func getDmChatFeed(owner: KeyPair, type: ConversationType) async throws -> [Conversation] {
var feed: [Conversation] = []
for conversation in try await client.getDmChatFeed(owner: owner, type: type) {
feed.append(await opened(conversation))
}
return feed
}

func getGroupChatFeed(owner: KeyPair) async throws -> [Conversation] {
let feed = try await client.getGroupChatFeed(owner: owner)
feed.forEach(remember)
return feed
}

func getChat(owner: KeyPair, conversationID: ConversationID) async throws -> Conversation {
await opened(try await client.getChat(owner: owner, conversationID: conversationID))
}
}

// MARK: - ConversationMessaging -

extension EncryptedChatClient: ConversationMessaging {

func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? {
guard let message = try await client.getMessage(owner: owner, conversationID: conversationID, messageID: messageID) else { return nil }
guard message.isAwaitingDecryption else { return message }
return await opener(conversationID, owner: owner).open(message)
}

func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] {
let messages = try await client.getMessages(owner: owner, conversationID: conversationID, before: before)
guard messages.contains(where: \.isAwaitingDecryption) else { return messages }
return await opener(conversationID, owner: owner).open(messages)
}

func getDelta(
owner: KeyPair,
conversationID: ConversationID,
afterSequence: UInt64,
onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void
) async throws -> UInt64 {
// Batches arrive on a synchronous callback, so the chat's keys are fetched before it starts.
let opener = await opener(conversationID, owner: owner)
return try await client.getDelta(owner: owner, conversationID: conversationID, afterSequence: afterSequence) { messages, checkpoint in
onBatch(opener.open(messages), checkpoint)
}
}

func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage {
try await refetchingOnRefusal(conversationID) {
let seal = try await sealForSending(conversationID, owner: owner)
return try await client.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID)
}
}

func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, expectedEventSequence: UInt64) async throws -> MessageMutation {
try await refetchingOnRefusal(conversationID) {
let seal = try await sealForSending(conversationID, owner: owner)
return try await client.editMessage(owner: owner, conversationID: conversationID, messageID: messageID, text: text, seal: seal, expectedEventSequence: expectedEventSequence)
}
}

func deleteMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, expectedEventSequence: UInt64) async throws -> MessageMutation {
try await client.deleteMessage(owner: owner, conversationID: conversationID, messageID: messageID, expectedEventSequence: expectedEventSequence)
}

func addReaction(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String) async throws -> EmojiReaction {
try await client.addReaction(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji)
}

func removeReaction(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String) async throws -> EmojiReaction {
try await client.removeReaction(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji)
}

func getReactors(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, emoji: String, pageSize: Int, pagingToken: Data?) async throws -> ReactorPage {
try await client.getReactors(owner: owner, conversationID: conversationID, messageID: messageID, emoji: emoji, pageSize: pageSize, pagingToken: pagingToken)
}

func getReactionSummaries(owner: KeyPair, conversationID: ConversationID, messageIDs: [MessageID]) async throws -> [MessageID: ReactionState] {
try await client.getReactionSummaries(owner: owner, conversationID: conversationID, messageIDs: messageIDs)
}

func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws {
try await client.markRead(owner: owner, conversationID: conversationID, messageID: messageID)
}

func notifyIsTyping(owner: KeyPair, conversationID: ConversationID, state: TypingState) async throws {
try await client.notifyIsTyping(owner: owner, conversationID: conversationID, state: state)
}
}

// MARK: - ConversationEventStreaming -

extension EncryptedChatClient: ConversationEventStreaming {

func subscribeConversationStream(owner: KeyPair) async -> (events: AsyncStream<ConversationStreamEvent>, connectionState: AsyncStream<EventStreamConnectionState>) {
let (events, connectionState) = await client.subscribeConversationStream(owner: owner)
let (opened, continuation) = AsyncStream.makeStream(of: ConversationStreamEvent.self)
// One event at a time, so the stream's order survives a key fetch.
let task = Task { [self] in
for await event in events {
continuation.yield(await open(event, owner: owner))
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
return (opened, connectionState)
}

func ensureConversationStreamConnected() {
client.ensureConversationStreamConnected()
}

func closeConversationStream() {
client.closeConversationStream()
}

private func open(_ event: ConversationStreamEvent, owner: KeyPair) async -> ConversationStreamEvent {
switch event {
case .chatEvents(let conversationID, let events):
guard events.contains(where: { $0.mutations.contains { $0.message.isAwaitingDecryption } }) else { return event }
let opener = await opener(conversationID, owner: owner)
return .chatEvents(conversationID: conversationID, events: events.map { chatEvent in
DecodedChatEvent(
sequence: chatEvent.sequence,
count: chatEvent.count,
mutations: chatEvent.mutations.map { $0.opened(by: opener) }
)
})
case .metadataRefresh(let conversation):
return .metadataRefresh(await opened(conversation))
case .lastActivityChanged, .readPointersChanged, .typingChanged, .rosterChanged,
.viewerStateChanged, .titleChanged, .pictureChanged, .reactionsChanged:
return event
}
}
}

private extension DecodedMutation {
func opened(by opener: ChatOpener) -> DecodedMutation {
switch self {
case .sent(let message): .sent(opener.open(message))
case .edited(let message): .edited(opener.open(message))
case .deleted(let message): .deleted(message)
}
}
}
2 changes: 1 addition & 1 deletion Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,5 @@ extension FlipClient {
}

extension FlipClient: ContactVerifying, OnrampAuthorizing, ContactSyncing,
ConversationFetching, ConversationMembership, ConversationMessaging,
ConversationFetching, ConversationMembership,
ConversationViewerSettings, ConversationEventStreaming {}
32 changes: 25 additions & 7 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,24 @@ extension ChatItem {
linkCard: ([DetectedLink]) -> LinkCard? = { _ in nil },
/// Where the viewer's unread messages began at open; the divider heads the first message
/// after it that someone else sent.
unreadBoundary: UnreadBoundary = .none
unreadBoundary: UnreadBoundary = .none,
/// Whether `messages` starts at the chat's first message. The encryption marker heads an
/// all-encrypted transcript only then, since older history may still be plaintext.
headsHistory: Bool = true
) -> [ChatItem] {
// Tombstoned (deleted) messages are retained in the store for gapless ordering. Under
// `.hidden` they are dropped up front so they never skew a date separator, group an adjacent
// bubble to an invisible row, or steal the "Delivered"/"Read" receipt anchor below.
// A message still waiting on the peer's key is left out until it decrypts.
let messages: [ConversationMessage] = switch deletedPresentation {
case .hidden: messages.filter { !$0.isDeleted }
case .placeholder: messages
case .hidden: messages.filter { !$0.isDeleted && !$0.isAwaitingDecryption }
case .placeholder: messages.filter { !$0.isAwaitingDecryption }
}

// The marker goes where the ciphertext starts: above the first encrypted message with
// plaintext before it, or at the head of a history that is encrypted from the start.
let markerIndex = messages.firstIndex(where: \.isEncrypted).flatMap { index in
index > 0 || headsHistory ? index : nil
}

// "Delivered"/"Read" rides the latest *confirmed* self message, so an in-flight or failed send
Expand Down Expand Up @@ -181,6 +191,10 @@ extension ChatItem {

// A separator opens the transcript and breaks any run longer than the gap.
let showsSeparator = previous.map { separates(message, from: $0) } ?? true
let showsMarker = index == markerIndex
if showsMarker {
items.append(.encryptionMarker)
}
if showsSeparator {
items.append(.dateSeparator(id: "sep-\(message.stableID)", text: message.date.formattedChatSeparator()))
}
Expand All @@ -200,10 +214,11 @@ extension ChatItem {
// separator is already the heading of what follows it, so a second, shorter threshold
// would flatten runs the transcript still draws as continuous.
let groupedAbove = previous.map {
$0.senderID == message.senderID && !showsSeparator && !showsDivider
$0.senderID == message.senderID && !showsSeparator && !showsDivider && !showsMarker
} ?? false
let groupedBelow = next.map {
$0.senderID == message.senderID && !separates($0, from: message) && !divides($0, from: message)
&& index + 1 != markerIndex
} ?? false

// The bubble run, which is not the author run. A bubble stacked above a bare emoji would
Expand Down Expand Up @@ -237,9 +252,12 @@ extension ChatItem {
: "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 unavailable bubble, asking for an update.
content = .unavailable(.updateApp)
// Still encrypted here means decryption failed; the awaiting ones were dropped above.
content = .unavailable(.decryptFailure(
message.decryptFailure,
isFromSelf: isFromSelf,
senderName: counterpartName
))
}

// The status line rides on the bubble itself (not a separate row, so a send is a clean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
/// Fired when the user taps the group head card's "Invite People". The card draws the
/// button only when it both asks for it and this is set.
let onGroupInvite: (() -> Void)?
/// Fired when the user taps the "Encrypted" marker above a DM's first encrypted message.
let onEncryptionMarkerTap: () -> Void
/// Fired when the user taps an author's face in a group's gutter, with that author's user id.
let onAuthorTap: (UserID) -> Void
/// Fired when a context-menu action is chosen on a row, with the row's stable id. Copy never
Expand Down Expand Up @@ -137,6 +139,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onGroupInvite = onGroupInvite
screen.onEncryptionMarkerTap = onEncryptionMarkerTap
screen.onAuthorTap = onAuthorTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onQuoteTap = { [weak screen] stableID in
Expand Down Expand Up @@ -179,6 +182,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen.onContactAction = onContactAction
screen.onProfileTap = onProfileTap
screen.onGroupInvite = onGroupInvite
screen.onEncryptionMarkerTap = onEncryptionMarkerTap
screen.onAuthorTap = onAuthorTap
screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen)
screen.onQuoteTap = { [weak screen] stableID in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@ final class ConversationLoadCoordinator {
// card is the link's identity, and the card view looks it up for itself. So nothing
// here touches the network, and an answer landing cannot re-diff this window.
linkCard: { links in classifier.firstCard(in: links) },
unreadBoundary: inputs.unreadBoundary
unreadBoundary: inputs.unreadBoundary,
headsHistory: inputs.headsHistory
)
if !inputs.typists.isEmpty {
// Only a group draws faces ahead of the dots; a DM's bubble stays as it was. A typist no
Expand Down
Loading
Loading