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
48 changes: 48 additions & 0 deletions .claude/plans/2026-10-14-link-preview-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Sender-built link previews (flipcash2 0.19.0)

Spec: orchestrator docs/specs/link-preview-send.md; contract comments on TextContent.link_previews,
LinkPreview, EncryptedContent in flipcash2 model.proto.

## Model
- FlipcashCore `SentLinkPreview { url: String, title: String, description: String?, image: MediaAttachment? }`
(Codable, Hashable, Sendable). `ConversationMessage.linkPreview: SentLinkPreview?` stored property,
like `repliedTo` (init, replacingContent). Only on `.text` content.
- Proto out: `ChatSeal.plaintext(text:repliedTo:linkPreview:)` builds `linkPreviews` — one builder for
plaintext and sealed. Image: plaintext = ORIGINAL rendition + blobID only; sealed = ORIGINAL with
sender-set metadata (reuse SealedPhoto.media / MediaAttachment.proto).
- Proto in: `ConversationMessage.init(proto)` text + reply-text, and `ChatSeal.readable` for sealed.
Image decoded like MediaContent items (plain via MediaAttachment(proto), sealed via the sealed
validator). A preview whose url fails the whole-link check against its text, or whose title cleans
to nothing, is dropped on read (defensive: the sealed path is not server-checked).
- Storage: `conversation_message.linkPreviewJson: Data?`; schemaVersion 51 -> 52; drop LinkPreviewTable
creation (store is rebuilt, so dropping the create is enough).

## Sender
- Composer: detect via LinkCardClassifier first web card of the draft. Per conversation a
`ComposerLinkPreview` @Observable: state loading / ready(SentLinkPreview, thumbnail) / failed,
`dismissedURLs`. Fetch = PinnedLinkMetadataSource (flag gate removed) -> WebImageSource image bytes
(memory only) -> ChatMediaUploader.upload(UIImage) -> MediaAttachment. Loading covers the fetch and
the image upload; image failure -> ready without image.
- Chip strip shows the thumbnail (image or a link glyph + host) with the remove control.
- Send: only `ready` attaches; run isWholeLink(url, in: sentText) + cleanTitle/cleanDescription;
never on media captions. After send the draft's preview resets.
- Edit: no refetch; re-attach the message's stored preview iff isWholeLink(url, in: newText) still
passes, else send none. Local mutation rewrite keeps/clears linkPreview to match. Retry: resends the
pending message's preview as it was checked at send.
- Photo + link draft: the preview chip is hidden while photo chips exist and nothing attaches
(captions carry no preview).
- Chip shows a loading state during fetch+upload; fetch/upload task cancelled on remove and on send.
- Plaintext upload has no dims: capture width/height from onPrepared for the pending row.

## Recipient
- Mapper builds `.web` card only from `message.linkPreview` (range of url found as a whole link in the
text; no classifier/isFetchable/marketing rules on read). Detected web links without a preview stay
plain linkified text. `Resolved` stays the parser's output; the card draws a separate received type
(title, description?, host, image: ChatMediaContent?). Read drops only per contract (url not a whole
link, empty title); no cleaning on read, clamp in the view. Card image loads through the chat media pipeline (ChatMediaURLResolver +
ChatMediaImageSource), passed down from ChatViewController.
- Delete: receive fetch/prefetch/memo web paths, WebImageDiskCache, LinkPreviewStoring, Database+LinkPreviews,
WebLinkPreviewMode/WebPreviewRequests/tap-to-load chip, ConversationScreen first-paint gate,
`.webLinkPreviews` beta flag and `webLinks:` classifier parameter.
- Keep: PinnedHTTPClient, HTTP1ResponseReader, WebAddress, WebPageParser, WebLinks, PinnedLinkMetadataSource,
WebImageSource (sender).
7 changes: 0 additions & 7 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ extension BetaFlags {

case vibrateOnScan
case enableCoinbase
case webLinkPreviews

var id: String {
localizedTitle
Expand All @@ -192,8 +191,6 @@ extension BetaFlags {
return "Vibrate on scan"
case .enableCoinbase:
return "Enable Coinbase"
case .webLinkPreviews:
return "Web link previews"
}
}

Expand All @@ -203,8 +200,6 @@ extension BetaFlags {
return "If enabled, the device will vibrate to indicate that the camera has registered the code on the bill"
case .enableCoinbase:
return "If enabled, Coinbase onramp will be available regardless of region"
case .webLinkPreviews:
return "If enabled, links to other websites show a preview card"
}
}

Expand All @@ -213,7 +208,6 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return .developer
case .enableCoinbase: return .developer
case .webLinkPreviews: return .developer
}
}

Expand All @@ -223,7 +217,6 @@ extension BetaFlags {
switch self {
case .vibrateOnScan: return false
case .enableCoinbase: return false
case .webLinkPreviews: return true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ extension ConversationController {
messageID: messageID,
text: text,
repliedTo: current.repliedTo,
// An edit sends no fresh fetch: the preview built at send time stays only while
// its link is still whole in the new text.
linkPreview: current.linkPreview?.kept(for: text),
expectedEventSequence: current.eventSequence
)
settle(outcome, messageID: messageID, in: conversationID, operation: "edit-message")
Expand Down
32 changes: 25 additions & 7 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2114,16 +2114,17 @@ final class ConversationController {
_ text: String,
to conversationID: ConversationID,
repliedTo: MessageID? = nil,
linkPreview: MessageLinkPreview? = nil,
restoringOnFailure draft: ChatDraft? = nil
) async -> Bool {
let clientMessageID = insertPending(text, repliedTo: repliedTo, into: conversationID)
let clientMessageID = insertPending(text, repliedTo: repliedTo, linkPreview: linkPreview, into: conversationID)
// The composer's own snapshot, carried down rather than re-derived: only the bar holds the
// untrimmed text and the reply strip's author and snippet, and it has already cleared both
// by the time a failure comes back.
if let draft {
failedSends?.willSend(draft, clientMessageID: clientMessageID, in: conversationID)
}
let delivered = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: repliedTo, to: conversationID)
let delivered = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: repliedTo, linkPreview: linkPreview, to: conversationID)
if delivered { donateShareSuggestion(for: conversationID) }
return delivered
}
Expand All @@ -2137,15 +2138,15 @@ final class ConversationController {
/// Posts `text` the way ``send(_:to:repliedTo:restoringOnFailure:)`` does, except that a
/// refused message leaves the transcript rather than staying there to be retried.
func postOnce(_ text: String, to conversationID: ConversationID) async -> Bool {
let clientMessageID = insertPending(text, repliedTo: nil, into: conversationID)
let delivered = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: nil, to: conversationID)
let clientMessageID = insertPending(text, repliedTo: nil, linkPreview: nil, into: conversationID)
let delivered = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: nil, linkPreview: nil, to: conversationID)
if !delivered {
store.discardPending(clientMessageID: clientMessageID, in: conversationID)
}
return delivered
}

private func insertPending(_ text: String, repliedTo: MessageID?, into conversationID: ConversationID) -> UUID {
private func insertPending(_ text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, into conversationID: ConversationID) -> UUID {
let clientMessageID = UUID()
let pending = ConversationMessage(
id: .unassigned,
Expand All @@ -2154,6 +2155,7 @@ final class ConversationController {
date: .now,
unreadSeq: 0,
repliedTo: repliedTo,
linkPreview: linkPreview,
status: .sending,
clientMessageID: clientMessageID
)
Expand All @@ -2176,7 +2178,7 @@ final class ConversationController {
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)
_ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, linkPreview: pending.linkPreview, to: conversationID)
case .media(_, let caption):
await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID)
case .encrypted, .cash, .deleted, .widget, .system:
Expand All @@ -2191,7 +2193,7 @@ final class ConversationController {
}
}

private func deliver(clientMessageID: UUID, text: String, repliedTo: MessageID?, to conversationID: ConversationID) async -> Bool {
private func deliver(clientMessageID: UUID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, to conversationID: ConversationID) async -> Bool {
// Captured before the send so success and failure report the same
// conversation kind; an unresolved conversation reports as Unknown.
let chatType = conversation(withID: conversationID)?.type
Expand All @@ -2201,6 +2203,7 @@ final class ConversationController {
conversationID: conversationID,
text: text,
repliedTo: repliedTo,
linkPreview: linkPreview,
clientMessageID: clientMessageID
)
// Persist the row carrying its client id (the server echoes none) so the DB keeps the send's
Expand Down Expand Up @@ -2279,6 +2282,7 @@ final class ConversationController {
func sendMedia(
_ chips: [ComposerChip],
caption: String?,
followedBy followingText: (text: String, linkPreview: MessageLinkPreview)? = nil,
to conversationID: ConversationID,
repliedTo: MessageID? = nil
) async -> Bool {
Expand All @@ -2296,6 +2300,10 @@ final class ConversationController {
let sends = ChatMediaSendPlan.mediaMessages(chips: chips, caption: caption, replyTo: repliedTo).map { message in
(message: message, clientMessageID: insertPendingMedia(message, into: conversationID))
}
// Inserted with the photos so it sits after them, and delivered after them so it lands there.
let textMessageID = followingText.map {
insertPending($0.text, repliedTo: nil, linkPreview: $0.linkPreview, into: conversationID)
}

var deliveredAll = true
for send in sends {
Expand All @@ -2308,6 +2316,16 @@ final class ConversationController {
)
deliveredAll = deliveredAll && delivered
}
if let followingText, let textMessageID {
let delivered = await deliver(
clientMessageID: textMessageID,
text: followingText.text,
repliedTo: nil,
linkPreview: followingText.linkPreview,
to: conversationID
)
deliveredAll = deliveredAll && delivered
}
if deliveredAll { donateShareSuggestion(for: conversationID) }
return deliveredAll
}
Expand Down
8 changes: 4 additions & 4 deletions Flipcash/Core/Controllers/EncryptedChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ extension EncryptedChatClient: ConversationMessaging {
}
}

func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage {
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, 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)
return try await client.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, linkPreview: linkPreview, seal: seal, clientMessageID: clientMessageID)
}
}

Expand All @@ -165,10 +165,10 @@ extension EncryptedChatClient: ConversationMessaging {
}
}

func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation {
func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, 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, repliedTo: repliedTo, seal: seal, expectedEventSequence: expectedEventSequence)
return try await client.editMessage(owner: owner, conversationID: conversationID, messageID: messageID, text: text, repliedTo: repliedTo, linkPreview: linkPreview, seal: seal, expectedEventSequence: expectedEventSequence)
}
}

Expand Down
7 changes: 4 additions & 3 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ protocol ConversationMessaging: AnyObject, Sendable {
afterSequence: UInt64,
onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void
) async throws -> UInt64
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
/// Sends `text` carrying `linkPreview`, as a reply to `repliedTo` when set.
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, clientMessageID: UUID) async throws -> ConversationMessage
/// Sends the finalized `photo` as one media message, with `caption` under it; a sealed photo
/// goes out inside `EncryptedContent`.
func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage
Expand All @@ -121,10 +122,10 @@ protocol ConversationMessaging: AnyObject, Sendable {
/// The seal `conversationID`'s messages open with, nil when it can't be built yet or the chat
/// doesn't encrypt.
func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal?
/// Replaces a message's text, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the
/// Replaces a message's text and its `linkPreview`, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the
/// server applies the edit only if the message still carries that sequence, and reports a
/// conflict with the winning state otherwise.
func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation
func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, expectedEventSequence: UInt64) async throws -> MessageMutation
/// Tombstones a message, guarded by `expectedEventSequence` the same way as `editMessage`.
func deleteMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, expectedEventSequence: UInt64) async throws -> MessageMutation
/// Adds the owner's reaction with `emoji`, returning the emoji's new aggregate.
Expand Down
32 changes: 15 additions & 17 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ nonisolated(unsafe) private let linkPreviewCache: NSCache<NSString, DetectedLink
return cache
}()

nonisolated private func detectedLink(in text: String, card: ([DetectedLink]) -> LinkCard?) -> LinkPreview? {
nonisolated private func detectedLink(in text: String, card: ([DetectedLink]) -> LinkCard?, page: MessageLinkPreview? = nil) -> LinkPreview? {
let key = text as NSString
let links: [DetectedLink]
let mentions: [DetectedMention]
Expand All @@ -51,24 +51,22 @@ nonisolated private func detectedLink(in text: String, card: ([DetectedLink]) ->
mentions = MentionDetector.mentions(in: text, excluding: links)
linkPreviewCache.setObject(DetectedLinkBox(links, mentions), forKey: key)
}
guard !links.isEmpty || !mentions.isEmpty else { return nil }
return LinkPreview(links: links, card: card(links), mentions: mentions)
let card = card(links) ?? page.flatMap { webCard(for: $0, in: text) }
guard !links.isEmpty || !mentions.isEmpty || card != nil else { return nil }
return LinkPreview(links: links, card: card, mentions: mentions)
}

extension ChatItem {
/// The web card for the preview a message's sender attached, placed at the first whole-link
/// occurrence of its url, or nil when the text never holds it as one. No fetch rule applies here:
/// whatever the sender's platform previewed, the recipient draws.
nonisolated private func webCard(for page: MessageLinkPreview, in text: String) -> LinkCard? {
guard !page.title.isEmpty,
let range = LinkPreviewSendChecks.wholeLinkRange(url: page.url, in: text),
let url = URL(string: page.url) else { return nil }
return .web(LinkCard.Web(url: url, range: range, page: page))
}

/// The web cards the text messages in `messages` would draw, one per message at most.
nonisolated static func webCards(in messages: [ConversationMessage], webLinks: Bool) -> [LinkCard] {
guard webLinks else { return [] }
let classifier = LinkCardClassifier(webLinks: webLinks)
return messages.compactMap { message in
guard case .text(let text) = message.content,
let card = detectedLink(in: text, card: { classifier.firstCard(in: $0) })?.card,
case .web = card
else { return nil }
return card
}
}
extension ChatItem {

/// Maps a conversation's messages to display-ready transcript items: resolves sender side,
/// formats cash amounts, derives the currency flag, inserts a date separator before the first
Expand Down Expand Up @@ -195,7 +193,7 @@ extension ChatItem {
// split around it.
let layouts = messages.map { message in
switch message.content {
case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard))
case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard, page: message.linkPreview))
case .cash, .deleted, .encrypted, .widget, .media, .system: [RowLayout(part: nil, text: nil, preview: nil)]
}
}
Expand Down
Loading
Loading