Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f238069
feat(chat): open message links on the linked message
bmc08gt Oct 14, 2026
7f6d0c7
test(chat): cover message-link landing with a UI test
bmc08gt Oct 14, 2026
47b9c83
Merge remote-tracking branch 'origin/main' into feat/message-link-route
bmc08gt Oct 14, 2026
2d7ae6e
fix(chat): draw the message link back chip in glass
bmc08gt Oct 16, 2026
ee69b47
fix(chat): keep the message-link back chip until it is tapped or the …
bmc08gt Oct 16, 2026
7aedfca
fix(chat): return from a message link to the chat as the reader left it
bmc08gt Oct 16, 2026
75ed2da
fix(chat): label the message link back chip with where it goes
bmc08gt Oct 16, 2026
a29a3a5
feat(chat): draw message links as a message card
bmc08gt Oct 14, 2026
411219b
fix(chat): match the message card faces to the design
bmc08gt Oct 14, 2026
8359957
fix(chat): size the message card at the cash card width
bmc08gt Oct 14, 2026
73b885e
fix(chat): show a link to a system notice as the plain link card
bmc08gt Oct 14, 2026
344befc
fix(chat): name a message card's author the way the chat rows do
bmc08gt Oct 16, 2026
5640e8a
fix(chat): size message card text to match Android
bmc08gt Oct 16, 2026
3e23722
fix(chat): lighten the message card's byline and footer
bmc08gt Oct 16, 2026
49a7c92
feat(chat): draw photos and cash links on message cards
bmc08gt Oct 16, 2026
1b340f9
feat(chat): draw the chat's avatar on message cards
bmc08gt Oct 16, 2026
a9fbab9
fix(chat): draw the message card as one identity block over a quieter…
bmc08gt Oct 16, 2026
7badc1f
fix(chat): date message cards relative to now
bmc08gt Oct 16, 2026
e3c5708
fix(chat): set message card body text in medium
bmc08gt Oct 16, 2026
89c99f3
Merge remote-tracking branch 'origin/main' into feat/message-link-card
bmc08gt Oct 16, 2026
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
5 changes: 3 additions & 2 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ extension EncryptedChatClient: ConversationFetching {

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 }
/// One message, decrypted when it came back sealed, read under `viewMode`.
func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, viewMode: ConversationViewMode) async throws -> ConversationMessage? {
guard let message = try await client.getMessage(owner: owner, conversationID: conversationID, messageID: messageID, viewMode: viewMode) else { return nil }
guard message.isAwaitingDecryption else { return message }
return await opener(conversationID, owner: owner).open(message)
}
Expand Down
16 changes: 10 additions & 6 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,19 @@ protocol ConversationViewerSettings: AnyObject, Sendable {
func unmuteChat(owner: KeyPair, conversationID: ConversationID) async throws -> ConversationViewerState
}

extension ConversationMessaging {
/// One message in full, the read every transcript path makes.
func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? {
try await getMessage(owner: owner, conversationID: conversationID, messageID: messageID, viewMode: .full)
}
}

/// DM message send/read surface used by `ConversationController`. Maps to the
/// `flipcash.messaging.v1.Messaging` RPCs.
protocol ConversationMessaging: AnyObject, Sendable {
/// Fetches one message by id, or nil when the chat has no such message.
func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage?
/// Fetches one message by id, or nil when the chat has no such message. `viewMode` asks the
/// server for the message in full or, where the viewer may not read it, redacted.
func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, viewMode: ConversationViewMode) async throws -> ConversationMessage?
/// Fetches a page of messages. `before == nil` returns the newest page;
/// pass the oldest currently-loaded id to page strictly older (history).
func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage]
Expand Down Expand Up @@ -164,10 +172,6 @@ extension FlipClient {
try await getChat(owner: owner, conversationID: conversationID, viewMode: .full)
}

func getMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws -> ConversationMessage? {
try await getMessage(owner: owner, conversationID: conversationID, messageID: messageID, viewMode: .full)
}

func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?) async throws -> [ConversationMessage] {
try await getMessages(owner: owner, conversationID: conversationID, before: before, viewMode: .full)
}
Expand Down
12 changes: 12 additions & 0 deletions Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,15 @@ final class ProfileAvatarStore {
blobBySubject[subject] = blobID
}
}

extension Conversation {

/// What a chat's avatar is of, as the Chats tab draws it: the chat itself for a group, whose
/// roster has no single face to stand for it, and the other person for a DM.
func avatar(excluding selfUserID: UserID) -> (subject: ProfileAvatarStore.AvatarSubject, picture: ProfilePicture?) {
guard type != .group, let other = counterpart(excluding: selfUserID), let userID = other.userID else {
return (.chat(id), picture)
}
return (.user(userID), other.profilePicture)
}
}
5 changes: 5 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,11 @@ struct ConversationScreen: View {
case .web:
// Through the same "You're Leaving Flipcash" path as a tap on the link text.
openLink(card.url)
case .message:
// The card and its Join, Buy and Request buttons all lead to the message: in place in
// this chat, else its landing, where the chat gates itself and offers the join, the buy
// or the request. The card never joins, buys or requests from here.
openLink(card.url)
case .group(let group):
// A link to the chat already on screen has nowhere to go.
guard group.chatID != conversationID else { return }
Expand Down
5 changes: 4 additions & 1 deletion Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ nonisolated struct LinkCardClassifier {
// anything is a question only the lookup can answer.
return .token(LinkCard.Token(url: target, mint: mint, range: link.range))

case .chat(let chatID), .chatMessage(let chatID, _):
case .chatMessage(let chatID, let messageID):
return .message(LinkCard.Message(url: target, chatID: chatID, messageID: messageID, range: link.range))

case .chat(let chatID):
// A group invite. Whether the id names a group this viewer can see is, as with a mint,
// a question for the lookup; a chat that turns out not to exist renders unavailable.
return .group(LinkCard.Group(url: target, chatID: chatID, range: link.range))
Expand Down
37 changes: 36 additions & 1 deletion Flipcash/Core/Screens/Conversation/LinkCardFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ final class LinkCardFeed: LinkCardSource {
private let claims: CashLinkClaimLog
private let groups: any GroupLinkPresenting
private let users: any UserLinkPresenting
/// Mints a message card photo's download URL, and its decryption for an encrypted chat.
private let messagePhotos: @MainActor (LinkCard.Message.Photo, ConversationID) async -> ChatMediaLocation?
private let chatAvatars: @MainActor (ConversationID) async -> Data?
private let cachedChatAvatars: @MainActor (ConversationID) -> Data?

/// What a group or person link's lookup fetched.
private enum Fetched {
Expand All @@ -50,6 +54,10 @@ final class LinkCardFeed: LinkCardSource {
/// many rows show it.
private var observedPictures: Set<String> = []

/// What each message link last answered, in memory only. Never the memo: that persists to the
/// database, and a card can hold a decrypted excerpt of an end-to-end encrypted chat.
private var messageStates: [String: LinkCard.State] = [:]

/// Who is listening to each key. A link quoted by two rows has two continuations and one query.
private var listeners: [String: [UUID: AsyncStream<LinkCard.State>.Continuation]] = [:]

Expand All @@ -74,13 +82,19 @@ final class LinkCardFeed: LinkCardSource {
memo: LinkCardMemo,
claims: CashLinkClaimLog,
groups: any GroupLinkPresenting,
users: any UserLinkPresenting
users: any UserLinkPresenting,
messagePhotos: @escaping @MainActor (LinkCard.Message.Photo, ConversationID) async -> ChatMediaLocation? = { _, _ in nil },
chatAvatars: @escaping @MainActor (ConversationID) async -> Data? = { _ in nil },
cachedChatAvatars: @escaping @MainActor (ConversationID) -> Data? = { _ in nil }
) {
self.resolver = resolver
self.memo = memo
self.claims = claims
self.groups = groups
self.users = users
self.messagePhotos = messagePhotos
self.chatAvatars = chatAvatars
self.cachedChatAvatars = cachedChatAvatars
observeSettledClaims()
startClaimableRefresh()
}
Expand All @@ -107,9 +121,23 @@ final class LinkCardFeed: LinkCardSource {
fetched[card.resolutionKey].map(present)
case .web:
nil
case .message:
messageStates[card.resolutionKey]
}
}

func messagePhoto(_ photo: LinkCard.Message.Photo, in chatID: ConversationID) async -> ChatMediaLocation? {
await messagePhotos(photo, chatID)
}

func chatAvatar(for chatID: ConversationID) async -> Data? {
await chatAvatars(chatID)
}

func cachedChatAvatar(for chatID: ConversationID) -> Data? {
cachedChatAvatars(chatID)
}

func states(for card: LinkCard) -> AsyncStream<LinkCard.State> {
let key = card.resolutionKey
let id = UUID()
Expand Down Expand Up @@ -158,6 +186,13 @@ final class LinkCardFeed: LinkCardSource {
let facts = await resolver.user(user.identity)
deliverFetched(facts.map(Fetched.user), missing: .user(.notFound), for: key)
}
case .message(let message):
Task { [resolver] in
let state = LinkCard.State.message(await resolver.message(message))
// A lookup that came back with nothing is forgotten, so the next appearance asks again.
if state.isResolved { messageStates[key] = state }
listeners[key]?.values.forEach { $0.yield(state) }
}
case .web:
// Its page came with the message; there is nothing to ask.
break
Expand Down
30 changes: 29 additions & 1 deletion Flipcash/Core/Screens/Conversation/LinkCardResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ actor LinkCardResolver {
private let mintLookup: @Sendable (PublicKey) async throws -> LinkCard.Token.Resolved
private let groupLookup: @Sendable (ConversationID) async throws -> GroupLinkFacts
private let userLookup: @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts
private let messageLookup: @Sendable (LinkCard.Message) async throws -> LinkCard.Message.State

/// The query per key, not the answer.
///
Expand All @@ -48,17 +49,23 @@ actor LinkCardResolver {
private var tokenQueries: [PublicKey: Task<LinkCard.Token.State, Never>] = [:]
private var groupQueries: [ConversationID: Task<GroupLinkFacts?, Never>] = [:]
private var userQueries: [LinkCard.User.Identity: Task<Result<UserLinkFacts, any Error>, Never>] = [:]
/// A message card's answer depends on the viewer's standing in the chat, which moves under it
/// (a join turns Join into the message), so only the query in flight is held and a finished
/// answer is dropped; the feed holds it in memory for the visit.
private var messageQueries: [String: Task<LinkCard.Message.State, Never>] = [:]

init(
cashLookup: @escaping @Sendable (String) async throws -> LinkCard.Cash.Resolved,
mintLookup: @escaping @Sendable (PublicKey) async throws -> LinkCard.Token.Resolved,
groupLookup: @escaping @Sendable (ConversationID) async throws -> GroupLinkFacts,
userLookup: @escaping @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts
userLookup: @escaping @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts,
messageLookup: @escaping @Sendable (LinkCard.Message) async throws -> LinkCard.Message.State = { _ in .unavailable }
) {
self.cashLookup = cashLookup
self.mintLookup = mintLookup
self.groupLookup = groupLookup
self.userLookup = userLookup
self.messageLookup = messageLookup
}

/// How far a cash or token card's lookup got: resolved, or unresolved if it failed.
Expand All @@ -78,6 +85,9 @@ actor LinkCardResolver {
return .user(.notFound)
case .web:
preconditionFailure("A web card draws the preview its message carries and is never resolved")
case .message:
assertionFailure("A message card resolves through message(_:), not resolve(_:)")
return .message(.unavailable)
}
}

Expand Down Expand Up @@ -118,6 +128,23 @@ actor LinkCardResolver {
return result
}

/// What a message link's card shows for this viewer. A failure of any kind is `unavailable`,
/// which draws as the URL-only card: the link underneath still opens.
func message(_ card: LinkCard.Message) async -> LinkCard.Message.State {
let key = LinkCard.message(card).resolutionKey
if let query = messageQueries[key] { return await query.value }

let lookup = messageLookup
let query = Task<LinkCard.Message.State, Never> {
(try? await lookup(card)) ?? .unavailable
}
messageQueries[key] = query

let state = await query.value
if messageQueries[key] == query { messageQueries[key] = nil }
return state
}

/// Forgets what a cash link's lookup answered, so the next ask goes back to the server.
///
/// A claim state is the one part of a card that changes under it: a link someone else collects
Expand Down Expand Up @@ -174,6 +201,7 @@ nonisolated extension LinkCard {
case .cash(let cash): Self.cashKey(entropy: cash.entropy)
case .token(let token): "token:\(token.mint.base58)"
case .group(let group): "group:\(group.chatID.description)"
case .message(let message): "message:\(message.chatID.description):\(message.sequence)"
case .user(let user):
switch user.identity {
// Lowercased because the no-handle link is, and a handle already is.
Expand Down
Loading
Loading