diff --git a/.claude/plans/2026-10-14-link-preview-send.md b/.claude/plans/2026-10-14-link-preview-send.md new file mode 100644 index 000000000..c9c0cac50 --- /dev/null +++ b/.claude/plans/2026-10-14-link-preview-send.md @@ -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). diff --git a/Flipcash/Core/Controllers/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index 954715e47..425256fc2 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -180,7 +180,6 @@ extension BetaFlags { case vibrateOnScan case enableCoinbase - case webLinkPreviews var id: String { localizedTitle @@ -192,8 +191,6 @@ extension BetaFlags { return "Vibrate on scan" case .enableCoinbase: return "Enable Coinbase" - case .webLinkPreviews: - return "Web link previews" } } @@ -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" } } @@ -213,7 +208,6 @@ extension BetaFlags { switch self { case .vibrateOnScan: return .developer case .enableCoinbase: return .developer - case .webLinkPreviews: return .developer } } @@ -223,7 +217,6 @@ extension BetaFlags { switch self { case .vibrateOnScan: return false case .enableCoinbase: return false - case .webLinkPreviews: return true } } } diff --git a/Flipcash/Core/Controllers/ConversationController+MessageMutations.swift b/Flipcash/Core/Controllers/ConversationController+MessageMutations.swift index e0d02636d..27b84dd9a 100644 --- a/Flipcash/Core/Controllers/ConversationController+MessageMutations.swift +++ b/Flipcash/Core/Controllers/ConversationController+MessageMutations.swift @@ -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") diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index b221ff49d..f52fcfbe9 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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 } @@ -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, @@ -2154,6 +2155,7 @@ final class ConversationController { date: .now, unreadSeq: 0, repliedTo: repliedTo, + linkPreview: linkPreview, status: .sending, clientMessageID: clientMessageID ) @@ -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: @@ -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 @@ -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 @@ -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 { @@ -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 { @@ -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 } diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 841569d9b..3dfe5bb76 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -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) } } @@ -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) } } diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index 8946dd06a..1e62c1665 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -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 @@ -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. diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 95982699b..2eee4656b 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -39,7 +39,7 @@ nonisolated(unsafe) private let linkPreviewCache: NSCache 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] @@ -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 @@ -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)] } } diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 62c9d93a2..353251403 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -42,8 +42,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Where a link card looks its link up — see ``LinkCardFeed``. Container-scoped, so it outlives /// both this view and the rows that subscribe to it. let linkCardSource: any LinkCardSource - /// Whether web cards ask for their pages as they draw (DMs, members) or wait for the chip. - let webPreviewMode: WebLinkPreviewMode /// The "Update Flipcash" capsule an `.updateApp` bubble draws, or nil while no newer version is known. let updateAction: ChatBubbleAction? /// The app's toast, raised over the bar and narrowed to its width while the chat is open. Passed in: @@ -174,7 +172,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource - screen.webPreviewMode = webPreviewMode screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil @@ -234,7 +231,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource - screen.webPreviewMode = webPreviewMode screen.updateAction = updateAction screen.onMediaTap = onMediaTap screen.acceptsPastedImages = acceptsMedia && editingStableID == nil diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 470bacdeb..1601b20b8 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -134,17 +134,7 @@ private struct ComposerChipView: View { .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) .frame(width: size, height: size) .overlay(alignment: .topTrailing) { - Button(action: onRemove) { - Image(systemName: SystemSymbol.closeCircle.rawValue) - .font(.default(size: 18, weight: .semibold)) - .symbolRenderingMode(.palette) - .foregroundStyle(Color.white, Color.black.opacity(0.55)) - .padding(3) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Remove photo") - .accessibilityIdentifier("composer-chip-remove") + ComposerChipRemoveButton(label: "Remove photo", identifier: "composer-chip-remove", action: onRemove) } .accessibilityElement(children: .contain) .accessibilityIdentifier("composer-chip") @@ -183,3 +173,135 @@ private struct ComposerChipView: View { } } } + +/// The draft link's preview as the recipient will see it: a compact card with the page's image, +/// host, description and title, a spinner while it loads, and a remove button on its corner. +struct ComposerLinkChip: View { + + let preview: ComposerLinkPreview + let onRemove: () -> Void + + private static let cardCornerRadius: CGFloat = 14 + private static let imageCornerRadius: CGFloat = 8 + private static let imageSize: CGFloat = 44 + private static let maxCardWidth: CGFloat = 280 + + /// How far the remove button hangs past the card's top-trailing corner. + private static let removeOverhang: CGFloat = 9 + + var body: some View { + HStack(spacing: 10) { + Color.backgroundSecondary + .overlay { thumbnail } + .frame(width: Self.imageSize, height: Self.imageSize) + .clipShape(RoundedRectangle(cornerRadius: Self.imageCornerRadius)) + VStack(alignment: .leading, spacing: 2) { + text + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .frame(maxWidth: Self.maxCardWidth, alignment: .leading) + .background(Color.black.opacity(0.25), in: RoundedRectangle(cornerRadius: Self.cardCornerRadius)) + .overlay(alignment: .topTrailing) { + ComposerChipRemoveButton( + label: "Remove link preview", + identifier: "composer-link-chip-remove", + action: onRemove + ) + .offset(x: Self.removeOverhang, y: -Self.removeOverhang) + } + .padding(.top, Self.removeOverhang) + .padding(.trailing, Self.removeOverhang) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilityLabel) + .accessibilityIdentifier("composer-link-chip") + } + + @ViewBuilder + private var thumbnail: some View { + switch preview.state { + case .none: + EmptyView() + case .loading: + ProgressView() + case .ready(_, _, let page): + if let thumbnail = page.thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + .overlay { + if page.isUploadingImage { + Color.black.opacity(0.35) + ProgressView().tint(.white) + } + } + } else { + Image(systemName: "link") + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.6)) + } + } + } + + @ViewBuilder + private var text: some View { + switch preview.state { + case .none: + EmptyView() + case .loading(_, let host): + Text(host) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .lineLimit(1) + case .ready(_, let host, let page): + HStack(spacing: 6) { + Text(host) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .layoutPriority(1) + if let description = page.description, !description.isEmpty { + Text(description) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + .lineLimit(1) + Text(page.title) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + } + + private var accessibilityLabel: String { + switch preview.state { + case .none: "" + case .loading(_, let host): "Loading preview for \(host)" + case .ready(_, let host, let page): "Preview for \(host): \(page.title)" + } + } +} + +/// The remove button drawn on the top-trailing corner of a staged photo or link thumbnail. +private struct ComposerChipRemoveButton: View { + + let label: String + let identifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: SystemSymbol.closeCircle.rawValue) + .font(.default(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.black.opacity(0.55)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift new file mode 100644 index 000000000..950d56c58 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift @@ -0,0 +1,197 @@ +// +// ComposerLinkPreview.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import FlipcashCore + +/// The preview the composer builds for the first web link in its draft, which the next text send +/// carries for recipients to draw as sent. +/// +/// A send never waits on it: a preview still loading, or whose fetch found nothing, goes unsent. +@MainActor +@Observable +final class ComposerLinkPreview { + + /// What a finished fetch found for a link. + struct Page: Sendable { + let title: String + let description: String? + /// The page's image as uploaded, or `nil` until the upload lands or when there is none. + var image: MediaAttachment? + /// The page's image for the composer's card, or `nil` when it has none or its upload failed. + var thumbnail: UIImage? + + /// Whether the thumbnail is still uploading, so a send now would go without it. + var isUploadingImage: Bool { thumbnail != nil && image == nil } + } + + /// Fetches the page at `url` and downloads its image, or returns `nil` when there is no preview. + typealias Load = @MainActor (URL) async -> Page? + + /// Uploads a page's image as a chat photo, or returns `nil` when the upload fails. + typealias Upload = @MainActor (UIImage) async -> MediaAttachment? + + enum State { + /// No link in the draft has a preview to show. + case none + /// The page for `link` is being fetched, or its image uploaded. + case loading(link: String, host: String) + /// The preview for `link` is ready to send. + case ready(link: String, host: String, page: Page) + } + + private(set) var state: State = .none + + /// Builds the preview for a link; until it is set, nothing is fetched. + @ObservationIgnored var load: Load? + + /// Uploads a fetched page's image; until it is set, a preview goes without one. + @ObservationIgnored var upload: Upload? + + /// Links whose preview was removed, kept off while they stay in the draft. + @ObservationIgnored private var dismissed: Set = [] + @ObservationIgnored private var task: Task? + /// The draft link last looked at, including one whose fetch found nothing, so a failure is not + /// fetched again on every keystroke. + @ObservationIgnored private var current: String? + + private let detector = LinkDetector() + private let classifier = LinkCardClassifier() + + /// The link the preview belongs to, as it appears in the draft. + var link: String? { + switch state { + case .none: nil + case .loading(let link, _): link + case .ready(let link, _, _): link + } + } + + /// Follows the draft to `text`: keeps the preview while its link is still the draft's first + /// web card, and otherwise starts on the new one. + func update(for text: String) { + let next = Self.webLink(in: text, detector: detector, classifier: classifier) + // Editing a removed link out of the draft lets its preview come back when it is typed again. + dismissed = dismissed.filter { text.contains($0) } + guard next?.text != current else { return } + cancel() + current = next?.text + guard let next, !dismissed.contains(next.text), let load else { return } + let host = WebLinks.host(of: next.url) ?? next.url.host() ?? next.text + state = .loading(link: next.text, host: host) + let upload = upload + task = Task { [weak self] in + guard var page = await load(next.url) else { + guard let self, !Task.isCancelled, self.link == next.text else { return } + self.state = .none + return + } + guard let self, !Task.isCancelled, self.link == next.text else { return } + if upload == nil { + page.thumbnail = nil + } + self.state = .ready(link: next.text, host: host, page: page) + guard page.isUploadingImage, let thumbnail = page.thumbnail, let upload else { return } + let image = await upload(thumbnail) + guard !Task.isCancelled, self.link == next.text else { return } + page.image = image + if image == nil { + page.thumbnail = nil + } + self.state = .ready(link: next.text, host: host, page: page) + } + } + + /// Removes the preview, which stays off for this link until it is edited out of the draft. + func remove() { + if let link { + dismissed.insert(link) + } + cancel() + } + + /// Forgets the draft's preview and every removal, for a fresh draft. + func reset() { + dismissed = [] + cancel() + current = nil + } + + /// The preview to send with `text`, or `nil` when it is not ready, its link is no longer whole + /// in `text`, or its title cleans to nothing. + func attachment(for text: String) -> MessageLinkPreview? { + guard case .ready(let link, _, let page) = state, + LinkPreviewSendChecks.isWholeLink(url: link, in: text), + let title = LinkPreviewSendChecks.cleanTitle(page.title) else { return nil } + let description = page.description.map(LinkPreviewSendChecks.cleanDescription) + return MessageLinkPreview( + url: link, + title: title, + description: description?.isEmpty == false ? description : nil, + image: page.image + ) + } + + private func cancel() { + task?.cancel() + task = nil + if link != nil { + state = .none + } + } + + /// The first link in `text` the classifier would preview, with the text it was found in. + nonisolated static func webLink(in text: String, detector: LinkDetector, classifier: LinkCardClassifier) -> (text: String, url: URL)? { + guard let link = classifier.firstWebLink(in: detector.webLinks(in: text)) else { return nil } + return ((text as NSString).substring(with: link.range), link.url) + } +} + +extension ComposerLinkPreview { + + /// A loader that fetches pages through `metadata` and downloads their image through `images`. + static func loader(metadata: any LinkMetadataSource, images: WebImageSource) -> Load { + { url in + guard let page = (try? await metadata.metadata(for: url)) ?? nil else { return nil } + var thumbnail: UIImage? + if let imageURL = page.imageURL, let data = await images.data(for: imageURL) { + thumbnail = UIImage(data: data) + } + guard !Task.isCancelled else { return nil } + return Page(title: page.title, description: page.description, image: nil, thumbnail: thumbnail) + } + } + + /// An uploader that sends a page's image through `uploader` as `senderID`'s chat photo. + static func uploader(_ uploader: @escaping @MainActor () -> ChatMediaUploader, senderID: UserID) -> Upload { + { picture in + var size: (width: Int, height: Int)? + guard let uploaded = try? await uploader().upload(picture, onPrepared: { size = ($0, $1) }), + let size else { return nil } + return MediaAttachment(uploaded, width: size.width, height: size.height, senderID: senderID) + } + } +} + +extension MediaAttachment { + + /// The attachment for `photo` as uploaded by `senderID` at `width`×`height`. + init(_ photo: UploadedPhoto, width: Int, height: Int, senderID: UserID) { + switch photo { + case .plain(let blobID): + self.init(blobID: blobID, width: width, height: height, blurhash: nil) + case .sealed(let sealed): + self.init( + blobID: sealed.blobID, + width: sealed.width, + height: sealed.height, + blurhash: sealed.blurhash.isEmpty ? nil : sealed.blurhash, + sealed: SealedBlob(senderID: senderID, plaintextSize: sealed.sizeBytes) + ) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 98dfcd3df..c601f8f2a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -54,6 +54,15 @@ final class ComposerModel { /// Photos staged for the next send, in the order they were added. private(set) var chips: [ComposerChip] = [] + /// The preview of the draft's first web link, which a text send carries. + let linkPreview = ComposerLinkPreview() + + /// Whether the link preview's card is drawn: while it has a link, and not during an edit, which + /// sends no new one. + var showsLinkPreview: Bool { + linkPreview.link != nil && !isEditing + } + /// The unsent new-message draft, held while an edit occupies the field. @ObservationIgnored private var stashedDraft = "" /// The text the message had when the edit began, so an unchanged edit can be refused. @@ -168,6 +177,8 @@ final class ComposerModel { guard case .new = mode, draft.isEmpty else { return } draft = stored.text selection = nil + // The restore can land before the bar's draft observer is attached, so feed the preview here. + linkPreview.update(for: stored.text) if let target = stored.replyTarget { mode = .replying(to: ReplyTarget(target)) } @@ -247,6 +258,7 @@ final class ComposerModel { if !chips.isEmpty { chips = [] } + linkPreview.reset() } /// The `@word` at the cursor that the mention picker searches for, or `nil` when it is closed. diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 9184ae2a2..a5b7a3101 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -568,6 +568,12 @@ struct ConversationBottomBar: View { transaction.animation = ChatMotion.composerChip.animation } } + // The link card arriving and leaving on the same spring as a photo chip. + .transaction(value: composer.showsLinkPreview) { transaction in + if transaction.animation == nil, !transaction.disablesAnimations { + transaction.animation = ChatMotion.composerChip.animation + } + } .onChange(of: wantsCameraWarm) { _, wanted in model.attachWarmUp.update(wanted: wanted) } @@ -1302,6 +1308,13 @@ struct ConversationComposer: View { .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) } + if composer.showsLinkPreview { + ComposerLinkChip(preview: composer.linkPreview, onRemove: { composer.linkPreview.remove() }) + .padding(.leading, Self.leadingInset - BarMetrics.fieldPadding) + .padding(.trailing, Self.leadingInset - BarMetrics.fieldPadding) + .padding(.top, BarMetrics.fieldVerticalPadding / 2) + .transition(motion.stripTransition(isLanding: false)) + } row } @@ -1319,6 +1332,9 @@ struct ConversationComposer: View { } } .onChange(of: composer.draft) { _, text in + if !composer.isEditing { + composer.linkPreview.update(for: text) + } guard let conversationID else { return } conversationController.draftDidChange(text, in: conversationID) } @@ -1402,6 +1418,10 @@ struct ConversationComposer: View { // travels with the text, since restoring the words alone would downgrade it to a loose // message. let draft = composer.persistableDraft + let linkPreview: MessageLinkPreview? = switch outgoing { + case .text(let text): composer.linkPreview.attachment(for: text) + case .media(_, let caption): caption.flatMap(composer.linkPreview.attachment(for:)) + } composer.clear() isFocused = true switch outgoing { @@ -1411,14 +1431,27 @@ struct ConversationComposer: View { text, to: conversationID, repliedTo: repliedTo, + linkPreview: linkPreview, restoringOnFailure: draft ) } case .media(let chips, let caption): // A photo that fails stays in the transcript to be retried, so nothing goes back - // into the field. + // into the field. A caption carries no link preview, so text with one follows the + // photos as its own message. + let followingText: (text: String, linkPreview: MessageLinkPreview)? = if let caption, let linkPreview { + (caption, linkPreview) + } else { + nil + } Task { - await conversationController.sendMedia(chips, caption: caption, to: conversationID, repliedTo: repliedTo) + await conversationController.sendMedia( + chips, + caption: followingText == nil ? caption : nil, + followedBy: followingText, + to: conversationID, + repliedTo: repliedTo + ) } } case .editing(let messageID, _): diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 719d2af48..aab7c3cae 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -38,10 +38,6 @@ final class ConversationLoadCoordinator { let claimReplies: CashLinkClaimReplies private let controller: ConversationController - /// Starts lookups for web cards; called only while ``mayPrefetchWebCards(_:)`` holds. - private let prefetchWebCards: @MainActor ([LinkCard]) -> Void - /// Whether the app is in the foreground. A seam for tests. - private let isAppActive: @MainActor () -> Bool private let session: Session /// Names senders the chat's own roster leaves out, from what the device already knows about /// them. Observed like every other input, so a reload re-attributes the window in place. @@ -85,14 +81,10 @@ final class ConversationLoadCoordinator { controller: ConversationController, session: Session, knownAuthors: KnownAuthorDirectory, - prefetchWebCards: @escaping @MainActor ([LinkCard]) -> Void = { _ in }, - isAppActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active }, openingLoadLanded: Bool = false ) { self.conversationID = conversationID self.controller = controller - self.prefetchWebCards = prefetchWebCards - self.isAppActive = isAppActive self.session = session self.knownAuthors = knownAuthors self.loader = MessageLoader(conversationID: conversationID, controller: controller) @@ -167,35 +159,16 @@ final class ConversationLoadCoordinator { let authors = Self.authors(from: attribution.members) mapTask?.cancel() mapTask = Task { [weak self] in - let (mapped, webCards) = await Task.detached { - (Self.map(inputs, authors: authors), ChatItem.webCards(in: inputs.messages, webLinks: inputs.webLinks)) + let mapped = await Task.detached { + Self.map(inputs, authors: authors) }.value guard let self, !Task.isCancelled else { return } self.items = mapped - if !webCards.isEmpty, self.mayPrefetchWebCards(inputs) { - self.prefetchWebCards(webCards) - } self.attributedMembers = attribution.members self.unattributedSenders = attribution.unnamed } } - // A chat whose record has not landed yet does not prefetch; its cards still resolve when drawn. - private func mayPrefetchWebCards(_ inputs: Inputs) -> Bool { - guard let conversation = inputs.conversation else { return false } - return Self.mayPrefetchWebCards( - isMember: controller.isMember(of: conversation), - isVisible: controller.visibleConversationID == conversationID, - isAppActive: isAppActive() - ) - } - - /// Whether a page's web cards fetch ahead of being drawn: only for the chat on screen, in the - /// foreground, and in a group only for a member (a DM always counts as membership). - static func mayPrefetchWebCards(isMember: Bool, isVisible: Bool, isAppActive: Bool) -> Bool { - isMember && isVisible && isAppActive - } - // Maps on the main thread and lands the whole result at once, rather than through `refresh`'s // detached hop. First paint needs that: the hop would flash an empty transcript. private func paint(_ inputs: Inputs) { @@ -303,8 +276,7 @@ final class ConversationLoadCoordinator { // takes a dependency on the directory and never re-maps when it reloads. knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty, headsHistory: headsHistory, - unreadBoundary: unreadBoundary(in: window), - webLinks: BetaFlags.shared.hasEnabled(.webLinkPreviews) + unreadBoundary: unreadBoundary(in: window) ) } @@ -327,7 +299,7 @@ final class ConversationLoadCoordinator { } nonisolated private static func map(_ inputs: Inputs, authors: [UserID: ChatAuthor]) -> [ChatItem] { - let classifier = LinkCardClassifier(webLinks: inputs.webLinks) + let classifier = LinkCardClassifier() var items = ChatItem.from( inputs.messages, selfUserID: inputs.selfUserID, @@ -470,8 +442,6 @@ final class ConversationLoadCoordinator { var headsHistory: Bool /// Where the divider goes; `.none` draws none. var unreadBoundary: UnreadBoundary - /// Whether outside links become web cards; the `webLinkPreviews` flag, read live. - var webLinks: Bool = true struct Branding: Equatable, Sendable { var token: String diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 03b0609f1..1f6e31db0 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -55,9 +55,6 @@ struct ConversationScreen: View { @State private var didInitialRead = false /// The chat whose opening load has landed, for a coordinator built after it. @State private var loadedConversationID: ConversationID? - /// Set once the saved link previews have loaded or 300 ms have passed, so a chat opened right - /// after launch draws its cards from them instead of flashing placeholders (P22d). - @State private var waitedForLinkPreviews = false /// The chat whose stored draft has been put back, which is also what permits saving: an empty /// composer must not delete a stored draft in the frame before the restore runs. @State private var restoredDraftID: ConversationID? @@ -217,8 +214,6 @@ struct ConversationScreen: View { /// /// Recomputed on each observation tick rather than cached, so a balance that crosses the /// requirement — or a rate that finally loads — opens the chat without a reopen. - /// DMs and members fetch web previews as they draw; anyone else, or a chat whose record has not - /// loaded yet, waits for the chip. /// The bubble's "Update Flipcash" action, offered whenever the store has a newer version. private var updateAction: ChatBubbleAction? { let updates = container.updateAvailability @@ -226,14 +221,6 @@ struct ConversationScreen: View { return ChatBubbleAction(title: "Update Flipcash") { updates.openStore() } } - private var webPreviewMode: WebLinkPreviewMode { - guard let conversationID, - let conversation = conversationController.conversation(withID: conversationID), - conversationController.isMember(of: conversation) - else { return .tapToLoad } - return .automatic - } - private var gate: ConversationGatePresentation { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) @@ -399,7 +386,7 @@ struct ConversationScreen: View { pagesHistory: Bool ) -> ChatScreenRepresentable { ChatScreenRepresentable( - items: (waitedForLinkPreviews || sessionContainer.linkCardMemo.isLoaded) ? (coordinator?.items ?? []) : [], + items: coordinator?.items ?? [], // Paging history for a chat the server hasn't created yet fetches // against an id it doesn't know and error-reports. onReachTop: { if pagesHistory { coordinator?.reachedTop() } }, @@ -411,7 +398,6 @@ struct ConversationScreen: View { ownProfile: ownProfile, onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, - webPreviewMode: webPreviewMode, updateAction: updateAction, toasts: container.toasts, onEncryptionMarkerTap: { isShowingEncryptionInfo = true }, @@ -714,10 +700,6 @@ struct ConversationScreen: View { .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in isKeyboardUp = false } - .task { - await sessionContainer.linkCardMemo.awaitLoaded() - waitedForLinkPreviews = true - } .task { // The probe runs once per OS build; after that this is a cached read. guard let contents = try? await EmojiCatalog.shared.load() else { return } @@ -872,6 +854,14 @@ struct ConversationScreen: View { .onAppear { setVisibleConversation(conversationID, source: "onAppear") syncCoordinator(conversationID) + composer.linkPreview.load = ComposerLinkPreview.loader( + metadata: sessionContainer.linkMetadataSource, + images: WebImageSource() + ) + composer.linkPreview.upload = ComposerLinkPreview.uploader( + { mediaUploader }, + senderID: sessionContainer.session.userID + ) restoreDraft(conversationID) } // A matched contact's chat is created mid-screen on the first payment, @@ -1467,7 +1457,6 @@ struct ConversationScreen: View { controller: conversationController, session: session, knownAuthors: sessionContainer.knownAuthors, - prefetchWebCards: { [linkCardFeed = sessionContainer.linkCardFeed] in linkCardFeed.prefetch($0) }, openingLoadLanded: loadedConversationID == id ) } diff --git a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift index c7eb887ee..bb4b54220 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardClassifier.swift @@ -33,10 +33,6 @@ import FlipcashCore /// tap target in front of either is a phishing aid. nonisolated struct LinkCardClassifier { - /// Whether an outside link may become a web card. False while `webLinkPreviews` is off, so the - /// feature draws nothing at all: no card, no chip, no prefetch. - var webLinks: Bool = true - /// The first card-eligible link wins; at most one card per message. /// /// Takes the detected links rather than their URLs because the card carries the span it was @@ -44,24 +40,28 @@ nonisolated struct LinkCardClassifier { /// is the wrapper while its `url` is the target, so a later search for the URL would find /// nothing to remove. /// - /// A Flipcash card anywhere in the message beats an outside page earlier in it. Only when there is - /// none does the first outside `https` link become a web card. + /// Only Flipcash cards: a web card is drawn from the preview its message carries, never from + /// the text. func firstCard(in links: [DetectedLink]) -> LinkCard? { - if let card = links.lazy.compactMap({ classify($0) }).first { return card } - guard webLinks else { return nil } - return links.lazy.compactMap { web($0) }.first + links.lazy.compactMap { classify($0) }.first + } + + /// The link a sender's composer previews: the first outside `https` link, or a marketing page + /// on the apex or `www`, and none when a Flipcash card anywhere in the text would win. + func firstWebLink(in links: [DetectedLink]) -> DetectedLink? { + guard firstCard(in: links) == nil else { return nil } + return links.first(where: isWebLink) } - /// An outside `https` link, or a marketing page on the apex or `www`. Any other Flipcash host - /// never falls through to here, whatever its path, and that includes a jump wrapper around an - /// outside target. - private func web(_ link: DetectedLink) -> LinkCard? { + /// Any Flipcash host other than the marketing ones never passes, whatever its path, and that + /// includes a jump wrapper around an outside target. + private func isWebLink(_ link: DetectedLink) -> Bool { guard WebLinks.isFetchable(link.url), - let host = WebLinks.host(of: link.url) else { return nil } + let host = WebLinks.host(of: link.url) else { return false } if Route.flipcashHosts.contains(host) { - guard Self.marketingHosts.contains(host), Self.isMarketingPage(link.url) else { return nil } + return Self.marketingHosts.contains(host) && Self.isMarketingPage(link.url) } - return .web(LinkCard.Web(url: link.url, range: link.range)) + return true } /// Flipcash hosts whose unclassified pages are fetched like any outside site. diff --git a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift index 356fbfc1a..65c0e206b 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift @@ -33,8 +33,6 @@ final class LinkCardFeed: LinkCardSource { private let claims: CashLinkClaimLog private let groups: any GroupLinkPresenting private let users: any UserLinkPresenting - private let webImages: @Sendable (URL) async -> Data? - private let cachedWebImages: @Sendable (URL) -> Data? /// What a group or person link's lookup fetched. private enum Fetched { @@ -76,17 +74,13 @@ final class LinkCardFeed: LinkCardSource { memo: LinkCardMemo, claims: CashLinkClaimLog, groups: any GroupLinkPresenting, - users: any UserLinkPresenting, - webImages: @escaping @Sendable (URL) async -> Data? = { await WebImageSource.shared.data(for: $0) }, - cachedWebImages: @escaping @Sendable (URL) -> Data? = { WebImageSource.shared.cached(for: $0) } + users: any UserLinkPresenting ) { self.resolver = resolver self.memo = memo self.claims = claims self.groups = groups self.users = users - self.webImages = webImages - self.cachedWebImages = cachedWebImages observeSettledClaims() startClaimableRefresh() } @@ -112,18 +106,10 @@ final class LinkCardFeed: LinkCardSource { case .group, .user: fetched[card.resolutionKey].map(present) case .web: - memo.web(card.resolutionKey).map(LinkCard.State.web) + nil } } - func webImage(for url: URL) async -> Data? { - await webImages(url) - } - - func cachedWebImage(for url: URL) -> Data? { - cachedWebImages(url) - } - func states(for card: LinkCard) -> AsyncStream { let key = card.resolutionKey let id = UUID() @@ -172,29 +158,9 @@ final class LinkCardFeed: LinkCardSource { let facts = await resolver.user(user.identity) deliverFetched(facts.map(Fetched.user), missing: .user(.notFound), for: key) } - case .web(let web): - Task { [resolver, memo] in - await memo.awaitLoaded() - if let held = memo.web(key) { - deliver(.web(held), for: key, generation: generation) - return - } - // A failure is not remembered, but the card still hears it: a link-only message's - // placeholder has to give way to its text bubble. - guard let state = await resolver.web(web) else { - deliver(.web(.none), for: key, generation: generation) - return - } - memo.recordWeb(state, for: key) - deliver(.web(state), for: key, generation: generation) - } - } - } - - /// Starts lookups for web cards that are not held yet. Callers decide whether a fetch may happen now. - func prefetch(_ cards: [LinkCard]) { - for case .web(let web) in cards where known(.web(web)) == nil { - ask(.web(web)) + case .web: + // Its page came with the message; there is nothing to ask. + break } } @@ -205,9 +171,7 @@ final class LinkCardFeed: LinkCardSource { // offline. private func deliver(_ state: LinkCard.State, for key: String, generation: Int) { guard generation == generations[key, default: 0] else { return } - if case .web = state { - // Recorded with its TTL by the web branch of `ask`. - } else if state.isResolved { + if state.isResolved { memo.record(state, for: key) } listeners[key]?.values.forEach { $0.yield(state) } diff --git a/Flipcash/Core/Screens/Conversation/LinkCardMemo.swift b/Flipcash/Core/Screens/Conversation/LinkCardMemo.swift index a79b22d26..c36a3c84c 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardMemo.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardMemo.swift @@ -5,7 +5,6 @@ import Foundation import FlipcashCore -import FlipcashStore /// What the resolver has already answered about a link, readable without awaiting it. /// @@ -20,33 +19,13 @@ import FlipcashStore /// Container-scoped like the resolver, and grows with the links a session has looked at. A state is /// two enum cases and a handful of short strings, and it is bounded by the cash and token links the /// reader actually scrolled past, so there is nothing here worth evicting. -/// -/// Web answers are the exception to "nothing worth evicting": each carries the time it landed and -/// reads as nil once past its TTL, and they persist in the store's `linkPreview` table so a relaunch -/// does not fetch the page again. The rows have Android's shape and key, which is what keeps the two -/// apps' caches readable the same way. @MainActor final class LinkCardMemo { /// Keyed by ``LinkCard/resolutionKey``, which is what the resolver memoizes on. private(set) var states: [String: LinkCard.State] = [:] - private var webAnswers: [String: (state: LinkCard.Web.State, at: Date)] = [:] - private let store: (any LinkPreviewStoring)? - private let images: WebImageDiskCache? - private let now: @Sendable () -> Date - private var loaded: Bool - private var loadWaiters: [UUID: CheckedContinuation] = [:] - - /// A memo with no store keeps web answers for the session only. `images` is the disk store - /// whose files go when the rows pointing at them are replaced or expire (P22c). - init(store: (any LinkPreviewStoring)? = nil, images: WebImageDiskCache? = nil, now: @escaping @Sendable () -> Date = Date.init) { - self.store = store - self.images = images - self.now = now - self.loaded = store == nil - if let store { load(from: store) } - } + init() {} /// Remembers what the resolver answered for `key`. /// @@ -63,135 +42,4 @@ final class LinkCardMemo { func forget(_ key: String) { states[key] = nil } - - // MARK: - Web - - - /// The web answer held for `key`, or nil when there is none or it is past its TTL. - func web(_ key: String) -> LinkCard.Web.State? { - guard let held = webAnswers[key] else { return nil } - let ttl = switch held.state { - case .none: WebLinks.emptyTTL - case .resolved: WebLinks.resolvedTTL - } - return now().timeIntervalSince(held.at) <= ttl ? held.state : nil // D8: exactly at the TTL still counts - } - - /// Remembers a web answer and writes it through to the store. Failures never reach here. - func recordWeb(_ state: LinkCard.Web.State, for key: String) { - let at = now() - let images = images - if let replaced = Self.imageURL(of: webAnswers[key]?.state), replaced != Self.imageURL(of: state) { - Task.detached(priority: .utility) { images?.remove(replaced) } - } - if let kept = Self.imageURL(of: state) { - Task.detached(priority: .utility) { images?.touch(kept) } - } - webAnswers[key] = (state, at) - guard let store else { return } - let json = StoredWeb(state).encoded - Task.detached(priority: .utility) { - try? store.upsertLinkPreview(key: key, json: json, updatedAt: at) - } - } - - /// Whether the stored answers have loaded. Not observed; ``awaitLoaded()`` is how to wait for it. - var isLoaded: Bool { loaded } - - /// Returns once the stored answers are loaded, or after 300 ms, whichever comes first (P22d). - func awaitLoaded() async { - guard !loaded else { return } - let id = UUID() - await withCheckedContinuation { continuation in - loadWaiters[id] = continuation - Task { [weak self] in - try? await Task.sleep(for: .milliseconds(300)) - self?.loadWaiters.removeValue(forKey: id)?.resume() - } - } - } - - private func load(from store: any LinkPreviewStoring) { - let cutoff = now().addingTimeInterval(-WebLinks.resolvedTTL) - Task.detached(priority: .utility) { [weak self, images] in - let rows = (try? store.linkPreviews(since: cutoff)) ?? [] - try? store.deleteLinkPreviews(before: cutoff) - images?.removeAll(before: cutoff) - await self?.finishLoad(rows) - } - } - - private static func imageURL(of state: LinkCard.Web.State?) -> URL? { - guard case .resolved(let page) = state else { return nil } - return page.imageURL - } - - private func finishLoad(_ rows: [LinkPreviewRow]) { - for row in rows where webAnswers[row.key] == nil { - guard let state = StoredWeb.decode(row.json)?.state else { continue } - webAnswers[row.key] = (state, row.updatedAt) - } - loaded = true - loadWaiters.values.forEach { $0.resume() } - loadWaiters = [:] - } -} - -/// Where web answers persist. `Database` is the real one. -nonisolated extension LinkCardMemo: WebHomeAnswers { - func answer(forHome home: URL) async -> LinkCard.Web.State? { - await MainActor.run { web(LinkCard.webKey(home)) } - } - - func record(_ state: LinkCard.Web.State, forHome home: URL) async { - await MainActor.run { recordWeb(state, for: LinkCard.webKey(home)) } - } -} - -nonisolated protocol LinkPreviewStoring: Sendable { - func linkPreviews(since: Date) throws -> [LinkPreviewRow] - func upsertLinkPreview(key: String, json: Data, updatedAt: Date) throws - func deleteLinkPreviews(before: Date) throws -} - -extension Database: LinkPreviewStoring {} - -/// Android's persisted shape: every field optional, and a null `title` means the host had nothing. -nonisolated struct StoredWeb: Codable, Equatable { - var title: String? - var description: String? - var imageUrl: String? - var host: String? - - init(_ state: LinkCard.Web.State) { - switch state { - case .none: - break - case .resolved(let resolved): - title = resolved.title - description = resolved.description - imageUrl = resolved.imageURL?.absoluteString - host = resolved.host - } - } - - var state: LinkCard.Web.State? { - guard let title else { return LinkCard.Web.State.none } - guard let host else { return nil } - return .resolved(.init(title: title, description: description, imageURL: imageUrl.flatMap(URL.init(string:)), host: host)) - } - - /// D8: all four keys are written, nulls included, as Android writes them. - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(title, forKey: .title) - try container.encode(description, forKey: .description) - try container.encode(imageUrl, forKey: .imageUrl) - try container.encode(host, forKey: .host) - } - - var encoded: Data { (try? JSONEncoder().encode(self)) ?? Data() } - - static func decode(_ data: Data) -> StoredWeb? { - try? JSONDecoder().decode(StoredWeb.self, from: data) - } } diff --git a/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift b/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift index ea973aa4e..fd8a27eb6 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardResolver.swift @@ -32,7 +32,6 @@ 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 webLookup: @Sendable (URL) async throws -> LinkCard.Web.State /// The query per key, not the answer. /// @@ -49,21 +48,17 @@ actor LinkCardResolver { private var tokenQueries: [PublicKey: Task] = [:] private var groupQueries: [ConversationID: Task] = [:] private var userQueries: [LinkCard.User.Identity: Task, Never>] = [:] - /// In flight only: a web answer has a TTL, so ``LinkCardMemo`` holds it rather than this map. - private var webQueries: [String: Task] = [:] 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, - webLookup: @escaping @Sendable (URL) async throws -> LinkCard.Web.State + userLookup: @escaping @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts ) { self.cashLookup = cashLookup self.mintLookup = mintLookup self.groupLookup = groupLookup self.userLookup = userLookup - self.webLookup = webLookup } /// How far a cash or token card's lookup got: resolved, or unresolved if it failed. @@ -82,8 +77,7 @@ actor LinkCardResolver { assertionFailure("A person card resolves through user(_:), not resolve(_:)") return .user(.notFound) case .web: - assertionFailure("A web card resolves through web(_:), not resolve(_:)") - return .web(.none) + preconditionFailure("A web card draws the preview its message carries and is never resolved") } } @@ -124,23 +118,6 @@ actor LinkCardResolver { return result } - /// What a web page's head says, or nil on a failure, which is dropped so the next view asks again. - func web(_ card: LinkCard.Web) async -> LinkCard.Web.State? { - guard let key = WebLinks.cacheKey(card.url) else { return LinkCard.Web.State.none } - if let query = webQueries[key] { return await query.value } - - let lookup = webLookup - let url = card.url - let query = Task { - try? await lookup(url) - } - webQueries[key] = query - - let state = await query.value - if webQueries[key] == query { webQueries[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 @@ -203,14 +180,10 @@ nonisolated extension LinkCard { case .userID(let userID): "user:id:\(userID.uuidString.lowercased())" case .username(let username): "user:handle:\(username.value)" } - // The same key Android persists, so a page reads the same on both. - case .web(let web): Self.webKey(web.url) + case .web(let web): "web:\(web.url.absoluteString)" } } - /// The key a web page's answer is remembered under, for a card or a fallback's home page. - static func webKey(_ url: URL) -> String { "web:\(WebLinks.cacheKey(url) ?? url.absoluteString)" } - /// The same key from an entropy alone. A settled claim names the entropy and nothing else, so /// the one place that has to build a key without a card builds it here rather than by hand. static func cashKey(entropy: String) -> String { "cash:\(entropy)" } diff --git a/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift b/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift index 91b9ea0ea..9f08d91d9 100644 --- a/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift +++ b/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift @@ -8,31 +8,13 @@ import Foundation import FlipcashCore -/// The client implementation of the spec's LinkMetadataSource. A server RPC replaces it. +/// Where the sender's composer reads what a linked page says about itself. protocol LinkMetadataSource: Sendable { - /// The preview for `url`; throws when the outcome should not be remembered. A link that falls - /// back to its site's home page reads and records that page's answer through `homes` (P24d). - func metadata(for url: URL, homes: (any WebHomeAnswers)?) async throws -> LinkCard.Web.State + /// What the page at `url` says about itself, or nil when it offers nothing to preview. Throws + /// when the fetch failed in a way worth trying again later. + func metadata(for url: URL) async throws -> LinkCard.Web.Resolved? } -nonisolated extension LinkMetadataSource { - /// The preview for `url`, with no remembered home-page answers to read or record. - func metadata(for url: URL) async throws -> LinkCard.Web.State { - try await metadata(for: url, homes: nil) - } -} - -/// The remembered answers for site home pages, which a fallback reads before fetching (P24d). -nonisolated protocol WebHomeAnswers: Sendable { - /// The answer held for `home`, if it is still fresh. - func answer(forHome home: URL) async -> LinkCard.Web.State? - /// Remembers `state` as the answer for `home`. - func record(_ state: LinkCard.Web.State, forHome home: URL) async -} - -/// Thrown while the `webLinkPreviews` flag is off, so nothing is fetched or remembered. -nonisolated struct WebLinkPreviewsDisabled: Error {} - /// A server error worth asking again later, so it is never remembered. nonisolated struct WebLinkHTTPStatus: Error, Equatable { let code: Int @@ -42,45 +24,38 @@ nonisolated struct WebLinkHTTPStatus: Error, Equatable { nonisolated final class PinnedLinkMetadataSource: LinkMetadataSource { private let client: any PinnedFetching - private let isEnabled: @Sendable () async -> Bool private let limiter: FetchLimiter init(client: any PinnedFetching = PinnedHTTPClient(), - limiter: FetchLimiter = .shared, - isEnabled: @escaping @Sendable () async -> Bool = { await MainActor.run { BetaFlags.shared.hasEnabled(.webLinkPreviews) } }) { + limiter: FetchLimiter = .shared) { self.client = client self.limiter = limiter - self.isEnabled = isEnabled } - func metadata(for url: URL, homes: (any WebHomeAnswers)?) async throws -> LinkCard.Web.State { - guard await isEnabled() else { throw WebLinkPreviewsDisabled() } + func metadata(for url: URL) async throws -> LinkCard.Web.Resolved? { return try await limiter.run { // P24b: the link and its home-page fallback share one deadline and one redirect budget. try await WebRedirects.withDeadline(.seconds(2 * WebLinks.timeout)) { - try await self.lookup(url, homes: homes) + try await self.lookup(url) } } } - private func lookup(_ url: URL, homes: (any WebHomeAnswers)?) async throws -> LinkCard.Web.State { + private func lookup(_ url: URL) async throws -> LinkCard.Web.Resolved? { var redirects = WebLinks.maxRedirects - guard let (response, finalURL) = try await fetchPage(url, redirects: &redirects) else { return .none } + guard let (response, finalURL) = try await fetchPage(url, redirects: &redirects) else { return nil } switch try Self.read(response, from: finalURL) { - case .resolved(let resolved): return .resolved(resolved) - case .none: return .none + case .resolved(let resolved): return resolved + case .none: return nil case .fallBack: break } - guard let home = Self.home(of: finalURL) else { return .none } - if let known = await homes?.answer(forHome: home) { return known } - // P24d: a home page past the shared redirect budget is no answer for the home key. - guard let (homeResponse, homeURL) = try await fetchPage(home, redirects: &redirects) else { return .none } - let state: LinkCard.Web.State = switch try Self.read(homeResponse, from: homeURL) { - case .resolved(let resolved): .resolved(resolved) - case .none, .fallBack: .none + guard let home = Self.home(of: finalURL) else { return nil } + // P24d: a home page past the shared redirect budget is no answer. + guard let (homeResponse, homeURL) = try await fetchPage(home, redirects: &redirects) else { return nil } + return switch try Self.read(homeResponse, from: homeURL) { + case .resolved(let resolved): resolved + case .none, .fallBack: nil } - await homes?.record(state, forHome: home) - return state } private func fetchPage(_ url: URL, redirects: inout Int) async throws -> (PinnedResponse, URL)? { @@ -129,19 +104,14 @@ nonisolated final class PinnedLinkMetadataSource: LinkMetadataSource { /// remembered as missing. nonisolated final class WebImageSource: @unchecked Sendable { - static let shared = WebImageSource(disk: .shared) - private let client: any PinnedFetching private let limiter: FetchLimiter // NSCache is thread-safe; it is the only mutable state here. private let cache = NSCache() - private let disk: WebImageDiskCache? - /// A source with no `disk` keeps images in memory only. - init(client: any PinnedFetching = PinnedHTTPClient(), limiter: FetchLimiter = .shared, disk: WebImageDiskCache? = nil) { + init(client: any PinnedFetching = PinnedHTTPClient(), limiter: FetchLimiter = .shared) { self.client = client self.limiter = limiter - self.disk = disk } /// The image bytes at `url` if a previous fetch is still held in memory. @@ -152,10 +122,6 @@ nonisolated final class WebImageSource: @unchecked Sendable { /// The image bytes at `url`, or nil when it cannot be fetched under the rules. func data(for url: URL) async -> Data? { if let hit = cache.object(forKey: url as NSURL) { return hit as Data } - if let stored = disk?.data(for: url) { - cache.setObject(stored as NSData, forKey: url as NSURL) - return stored - } let data = try? await limiter.run { () -> Data? in guard let (response, _) = try await WebRedirects.follow( url, client: self.client, accept: "image/*", maxBytes: WebLinks.maxImageBytes @@ -169,7 +135,6 @@ nonisolated final class WebImageSource: @unchecked Sendable { } guard let data = data ?? nil else { return nil } cache.setObject(data as NSData, forKey: url as NSURL) - disk?.store(data, for: url) return data } } diff --git a/Flipcash/Core/Screens/Conversation/WebImageDiskCache.swift b/Flipcash/Core/Screens/Conversation/WebImageDiskCache.swift deleted file mode 100644 index 78df9b708..000000000 --- a/Flipcash/Core/Screens/Conversation/WebImageDiskCache.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// WebImageDiskCache.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import CryptoKit -import Foundation -import FlipcashCore - -/// Preview images kept on disk across launches, in a directory of their own (P22b, P22c). -/// -/// Only bytes that already passed the image rules are written. A file's time is its row's: it is -/// restamped whenever the row is recorded again, so a file older than `WebLinks.resolvedTTL` belongs -/// to an expired row, reads as nil and is deleted. The whole store is held under ``capacity`` by -/// dropping the oldest files first. -nonisolated final class WebImageDiskCache: @unchecked Sendable { - - static let shared = WebImageDiskCache( - directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("LinkPreviewImages", isDirectory: true) - ) - - /// The most the store holds, in bytes. - static let capacity = 20 * 1024 * 1024 - - private let directory: URL - private let capacity: Int - private let now: @Sendable () -> Date - // Serializes every file operation; the store has no other state. - private let lock = NSLock() - - init(directory: URL, capacity: Int = WebImageDiskCache.capacity, now: @escaping @Sendable () -> Date = Date.init) { - self.directory = directory - self.capacity = capacity - self.now = now - } - - /// The bytes stored for `url`, or nil when there are none or they have outlived their row. - func data(for url: URL) -> Data? { - lock.withLock { - let file = path(for: url) - guard let written = modified(file) else { return nil } - guard now().timeIntervalSince(written) <= WebLinks.resolvedTTL else { - try? FileManager.default.removeItem(at: file) - return nil - } - return try? Data(contentsOf: file) - } - } - - /// Stores `data` for `url`, then drops the oldest files until the store fits its capacity. - func store(_ data: Data, for url: URL) { - lock.withLock { - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = path(for: url) - guard (try? data.write(to: file, options: .atomic)) != nil else { return } - try? FileManager.default.setAttributes([.modificationDate: now()], ofItemAtPath: file.path) - trim() - } - } - - /// Restamps what is stored for `url` with the current time, as when the row pointing at it is - /// recorded again, so the file lives exactly as long as its row (P22c). - func touch(_ url: URL) { - lock.withLock { - let file = path(for: url) - guard modified(file) != nil else { return } - try? FileManager.default.setAttributes([.modificationDate: now()], ofItemAtPath: file.path) - } - } - - /// Deletes what is stored for `url`, as when the row that pointed at it is replaced. - func remove(_ url: URL) { - lock.withLock { try? FileManager.default.removeItem(at: path(for: url)) } - } - - /// Deletes every file older than `cutoff`, as when the rows written before it are dropped. - func removeAll(before cutoff: Date) { - lock.withLock { - for (file, written, _) in files() where written < cutoff { - try? FileManager.default.removeItem(at: file) - } - } - } - - private func trim() { - let all = files().sorted { $0.written < $1.written } - var total = all.reduce(0) { $0 + $1.size } - for (file, _, size) in all where total > capacity { - try? FileManager.default.removeItem(at: file) - total -= size - } - } - - private func files() -> [(file: URL, written: Date, size: Int)] { - let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey] - let urls = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? [] - return urls.compactMap { file in - guard let values = try? file.resourceValues(forKeys: Set(keys)), - let written = values.contentModificationDate else { return nil } - return (file, written, values.fileSize ?? 0) - } - } - - private func modified(_ file: URL) -> Date? { - try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - } - - // Keyed by the resolved image URL (P22c), hashed so any URL makes a safe file name. - private func path(for url: URL) -> URL { - let digest = CryptoKit.SHA256.hash(data: Data(url.absoluteString.utf8)) - return directory.appendingPathComponent(digest.map { String(format: "%02x", $0) }.joined()) - } -} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 8731cdad8..2358bd1df 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -542,7 +542,7 @@ final class SessionContainer { /// beside the resolver because it remembers the same answers; a transcript seeds its first /// paint from this so a chat opened again does not flash an unresolved card it already knows. let linkCardMemo: LinkCardMemo - /// Fetches web pages' heads for web link cards, through the pinned client. + /// Fetches a linked page's head for the preview a sent message carries, through the pinned client. let linkMetadataSource: any LinkMetadataSource = PinnedLinkMetadataSource() /// What a link card in a transcript asks for its contents — see ``LinkCardFeed``. Lazy so it /// can read the resolver and memo built alongside it; container-scoped for the same reason they @@ -628,14 +628,12 @@ final class SessionContainer { let coinbaseApiKey = (try? InfoPlist.value(for: "coinbase").value(for: "apiKey").string()) ?? "" let owner = session.ownerKeyPair - let linkCardMemo = LinkCardMemo(store: database, images: .shared) - self.linkCardMemo = linkCardMemo + self.linkCardMemo = LinkCardMemo() self.linkCardResolver = LinkCardResolver( cashLookup: LinkCardResolver.giftCardLookup(reader: client, viewer: owner), mintLookup: LinkCardResolver.mintLookup(reader: client), groupLookup: LinkCardResolver.groupLookup(chats: flipClient, mints: client, viewer: owner), - userLookup: LinkCardResolver.userLookup(profiles: flipClient, viewer: owner, viewerID: session.userID), - webLookup: { [linkMetadataSource, linkCardMemo] in try await linkMetadataSource.metadata(for: $0, homes: linkCardMemo) } + userLookup: LinkCardResolver.userLookup(profiles: flipClient, viewer: owner, viewerID: session.userID) ) let coinbase = Coinbase(configuration: .init(bearerTokenProvider: { [weak flipClient] method, path in guard let flipClient, !coinbaseApiKey.isEmpty else { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift index 7f5811fa1..3ae67d02f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Chat/ChatNotificationClient.swift @@ -207,6 +207,7 @@ public final class ChatNotificationClient: Sendable { // A notification quick-reply is a plain message: the extension has no transcript // to quote from. repliedTo: nil, + linkPreview: nil, seal: seal, clientMessageID: clientMessageID ) { continuation.resume(with: $0) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 41442dbb0..c39229828 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -220,11 +220,12 @@ extension FlipClient { } } - /// Sends `text`, encrypted with `seal` when set, and returns the server's copy (decrypted). + /// Sends `text` carrying `linkPreview`, encrypted with `seal` when set, and returns the server's + /// copy (decrypted). @discardableResult - public func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, seal: ChatSeal?, clientMessageID: UUID) async throws -> ConversationMessage { + public func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview? = nil, seal: ChatSeal?, clientMessageID: UUID) async throws -> ConversationMessage { try await withCheckedThrowingContinuation { c in - chatMessagingService.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } + chatMessagingService.sendMessage(owner: owner, conversationID: conversationID, text: text, repliedTo: repliedTo, linkPreview: linkPreview, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } } } @@ -251,6 +252,7 @@ extension FlipClient { messageID: MessageID, text: String, repliedTo: MessageID?, + linkPreview: MessageLinkPreview? = nil, seal: ChatSeal?, expectedEventSequence: UInt64 ) async throws -> MessageMutation { @@ -261,6 +263,7 @@ extension FlipClient { messageID: messageID, text: text, repliedTo: repliedTo, + linkPreview: linkPreview, seal: seal, expectedEventSequence: expectedEventSequence ) { c.resume(with: $0) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 9d4aa7f01..c8d921d22 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -157,12 +157,13 @@ final class ChatMessagingService: Sendable { } } - /// Sends `text`, as a reply to `repliedTo` when set. With a `seal` the text goes out as - /// `EncryptedContent` and the server's copy comes back decrypted. - func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + /// Sends `text` carrying `linkPreview`, as a reply to `repliedTo` when set. With a `seal` the + /// text goes out as `EncryptedContent` and the server's copy comes back decrypted. + func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview?, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { let content: Flipcash_Messaging_V1_Content do { - content = try seal?.seal(text: text, repliedTo: repliedTo) ?? .plaintext(text: text, repliedTo: repliedTo) + content = try seal?.seal(text: text, repliedTo: repliedTo, linkPreview: linkPreview) + ?? .plaintext(text: text, repliedTo: repliedTo, linkPreview: linkPreview) } catch { logger.error("Failed to encrypt message") completion(.failure(.encryptionFailed)) @@ -244,6 +245,7 @@ final class ChatMessagingService: Sendable { messageID: MessageID, text: String, repliedTo: MessageID?, + linkPreview: MessageLinkPreview?, seal: ChatSeal?, expectedEventSequence: UInt64, completion: @Sendable @escaping (Result) -> Void @@ -252,7 +254,8 @@ final class ChatMessagingService: Sendable { do { // The edit replaces the whole content, reply wrapper included, so a reply has to restate // its target or the server stores it as a plain message. - content = try seal?.seal(text: text, repliedTo: repliedTo) ?? .plaintext(text: text, repliedTo: repliedTo) + content = try seal?.seal(text: text, repliedTo: repliedTo, linkPreview: linkPreview) + ?? .plaintext(text: text, repliedTo: repliedTo, linkPreview: linkPreview) } catch { logger.error("Failed to encrypt edited message") completion(.failure(.encryptionFailed)) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift index 36105fd79..b7cfae307 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift @@ -56,16 +56,14 @@ public enum LinkCard: Hashable, Sendable, Codable { case token(Token.State) case group(Group.State) case user(User.State) - case web(Web.State) /// Whether the lookup came back with something. A failure of any kind is `unresolved` (or /// `unavailable` for a group, `notFound` for a person), which is the one answer not worth remembering — the next - /// look asks again. A web page's `none` is false here too, though it is remembered for a day: - /// a web failure never reaches a state at all. + /// look asks again. public var isResolved: Bool { switch self { - case .cash(.resolved), .token(.resolved), .group(.resolved), .user(.resolved), .web(.resolved): true - case .cash(.unresolved), .token(.unresolved), .group(.unavailable), .user(.notFound), .web(.none): false + case .cash(.resolved), .token(.resolved), .group(.resolved), .user(.resolved): true + case .cash(.unresolved), .token(.unresolved), .group(.unavailable), .user(.notFound): false } } } @@ -359,31 +357,29 @@ extension LinkCard { extension LinkCard { - /// An outside `https` page. Unlike the Flipcash cards it does not replace its link: the text - /// stays as written and the card draws under it, and only when the page had something to show. + /// An outside page the sender previewed. Unlike the Flipcash cards it does not replace its + /// link: the text stays as written and the card draws under it from `page`, which came with the + /// message, so the recipient never fetches anything. public struct Web: Hashable, Sendable, Codable { public let url: URL /// UTF-16 offsets into the message text — the same frame `DetectedLink` indexes in. public let location: Int public let length: Int + /// The preview the sender attached. + public let page: MessageLinkPreview public var range: NSRange { NSRange(location: location, length: length) } - public init(url: URL, range: NSRange) { + public init(url: URL, range: NSRange, page: MessageLinkPreview) { self.url = url self.location = range.location self.length = range.length + self.page = page } - /// No loading case: an absent state is loading, and loading draws nothing. - public enum State: Hashable, Sendable, Codable { - /// The host answered with nothing to show. Remembered for `WebLinks.emptyTTL`. - case none - case resolved(Resolved) - } - - /// What the page said about itself, cleaned for display. + /// What a fetched page said about itself, cleaned for display. The sender's fetch produces + /// it; the message carries a `MessageLinkPreview` built from it. public struct Resolved: Hashable, Sendable, Codable { public let title: String public let description: String? @@ -413,7 +409,7 @@ nonisolated extension LinkCard { case .token(let token): .token(Token(url: token.url, mint: token.mint, range: range)) case .group(let group): .group(Group(url: group.url, chatID: group.chatID, range: range)) case .user(let user): .user(User(url: user.url, identity: user.identity, range: range)) - case .web(let web): .web(Web(url: web.url, range: range)) + case .web(let web): .web(Web(url: web.url, range: range, page: web.page)) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift new file mode 100644 index 000000000..22fdb2de8 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift @@ -0,0 +1,164 @@ +// +// LinkPreviewSendChecks.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The checks a sender runs before attaching a `LinkPreview` to a message, pinned by +/// `link_preview_send.json`. All lengths and positions are Unicode code points (`unicodeScalars`), +/// as the contract counts them; neither `String.count` nor UTF-16 length does. +public enum LinkPreviewSendChecks { + + public static let maxURLLength = 2048 + public static let maxTitleLength = 256 + public static let maxDescriptionLength = 512 + + // MARK: - Whole link - + + /// Whether `url` is a link the contract accepts and appears in `text` as a whole link. + public static func isWholeLink(url: String, in text: String) -> Bool { + wholeLinkRange(url: url, in: text) != nil + } + + /// The UTF-16 range of the first place `url` appears in `text` as a whole link, or `nil` when + /// it never does or is not a link the contract accepts. + public static func wholeLinkRange(url: String, in text: String) -> NSRange? { + let link = Array(url.unicodeScalars) + guard isValidURL(link) else { return nil } + let haystack = Array(text.unicodeScalars) + guard haystack.count >= link.count else { return nil } + + var start = 0 + while start <= haystack.count - link.count { + if haystack[start..<(start + link.count)].elementsEqual(link) { + var i = start + while i > 0, openers.contains(haystack[i - 1]) { i -= 1 } + let before = i == 0 || whiteSpace.contains(haystack[i - 1]) + var j = start + link.count + while j < haystack.count, trailers.contains(haystack[j]) { j += 1 } + let after = j == haystack.count || whiteSpace.contains(haystack[j]) + if before && after { + let location = haystack[.. String? { + clean(raw, limit: maxTitleLength, keepLineFeeds: false) + } + + /// The description to send for a scraped `raw` description; empty for none. + public static func cleanDescription(_ raw: String) -> String { + clean(raw, limit: maxDescriptionLength, keepLineFeeds: true) ?? "" + } + + private static func clean(_ raw: String, limit: Int, keepLineFeeds: Bool) -> String? { + var out: [Unicode.Scalar] = [] + let scalars = Array(raw.unicodeScalars) + var i = 0 + while i < scalars.count { + let c = scalars[i] + i += 1 + switch c.value { + case 0x0D: + if i < scalars.count, scalars[i].value == 0x0A { i += 1 } + out.append(keepLineFeeds ? "\n" : " ") + case 0x0A, 0x2028, 0x2029: + out.append(keepLineFeeds ? "\n" : " ") + case 0x09, 0x0B, 0x0C: + out.append(" ") + default: + if c.properties.generalCategory == .control || bidiEmbeddings.contains(c) { continue } + out.append(c) + } + } + var s = trim(out[...]) + if s.count > limit { s = s.prefix(limit) } + s = trim(s) + guard s.contains(where: { !whiteSpace.contains($0) && $0.properties.generalCategory != .format }) else { + return nil + } + var view = String.UnicodeScalarView() + view.append(contentsOf: s) + return String(view) + } + + private static func trim(_ s: ArraySlice) -> ArraySlice { + var lo = s.startIndex, hi = s.endIndex + while lo < hi, whiteSpace.contains(s[lo]) { lo += 1 } + while hi > lo, whiteSpace.contains(s[hi - 1]) { hi -= 1 } + return s[lo.. Bool { + guard (1...maxURLLength).contains(url.count) else { return false } + let rest: ArraySlice + if url.starts(with: "https://".unicodeScalars) { + rest = url.dropFirst(8) + } else if url.starts(with: "http://".unicodeScalars) { + rest = url.dropFirst(7) + } else { + return false + } + if url.contains(where: { whiteSpace.contains($0) || bidiInURL.contains($0) }) { return false } + + let authority = rest.prefix { $0 != "/" && $0 != "?" && $0 != "#" } + if authority.contains("@") { return false } + + var host = authority + if let colon = authority.lastIndex(of: ":") { + host = authority[authority.startIndex..) -> Bool { + var h = host + if h.last == "." { h = h.dropLast() } + guard !h.isEmpty else { return false } + let labels = h.split(separator: ".", omittingEmptySubsequences: false) + for label in labels { + guard !label.isEmpty, label.allSatisfy({ labelScalars.contains($0) }) else { return false } + } + let last = labels[labels.count - 1] + if last.allSatisfy({ ("0"..."9").contains($0) }) { return false } + if last.count >= 2, last[last.startIndex] == "0", last[last.startIndex + 1] == "x" || last[last.startIndex + 1] == "X", + last.dropFirst(2).allSatisfy({ $0.properties.isASCIIHexDigit }) { + return false + } + return true + } + + // MARK: - Character sets - + + private static let whiteSpace: Set = Set( + ([0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000] + + Array(0x2000...0x200A)).compactMap(Unicode.Scalar.init) + ) + private static let openers: Set = Set("([{<\"'".unicodeScalars) + private static let trailers: Set = Set(".,;:!?)]}>\"'".unicodeScalars) + private static let bidiEmbeddings: Set = Set( + (Array(0x202A...0x202E) + Array(0x2066...0x2069)).compactMap(Unicode.Scalar.init) + ) + private static let bidiInURL: Set = bidiEmbeddings.union([0x061C, 0x200E, 0x200F].compactMap(Unicode.Scalar.init)) + private static let labelScalars: Set = Set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-".unicodeScalars + ) +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index 72afe1aa8..f6aff27f9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -84,9 +84,10 @@ public struct ChatSeal: @unchecked Sendable { self.conversationID = conversationID } - /// Encrypts `text`, as a reply to `repliedTo` when set, into the content to send in its place. - public func seal(text: String, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { - try seal(.plaintext(text: text, repliedTo: repliedTo)) + /// Encrypts `text` carrying `linkPreview`, as a reply to `repliedTo` when set, into the content + /// to send in its place. + public func seal(text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview? = nil) throws -> Flipcash_Messaging_V1_Content { + try seal(.plaintext(text: text, repliedTo: repliedTo, linkPreview: linkPreview, sealed: true)) } /// Encrypts a message carrying `photo`, with `caption` under it and as a reply to `repliedTo` @@ -205,7 +206,7 @@ public struct ChatSeal: @unchecked Sendable { let readable = content.readable(senderID: senderID) else { return message.opened(.failure(.unsupported)) } - return message.opened(.success(readable)) + return message.opened(.success((readable.content, readable.repliedTo)), linkPreview: readable.linkPreview) } /// The raw `UserId.value` bytes the group aads bind. @@ -271,9 +272,18 @@ public struct SealedPhoto: Hashable, Sendable, Codable { extension Flipcash_Messaging_V1_Content { - /// The plaintext content for `text`, wrapped as a reply to `repliedTo` when set. - static func plaintext(text: String, repliedTo: MessageID?) -> Self { - let body = Self.with { $0.text = .with { $0.text = text } } + /// The plaintext content for `text` carrying `linkPreview`, wrapped as a reply to `repliedTo` + /// when set. `sealed` says the content is about to be encrypted, which decides the image the + /// preview may reference. + static func plaintext(text: String, repliedTo: MessageID?, linkPreview: MessageLinkPreview? = nil, sealed: Bool = false) -> Self { + let body = Self.with { + $0.text = .with { + $0.text = text + if let linkPreview { + $0.linkPreviews = [linkPreview.proto(sealed: sealed)] + } + } + } guard let repliedTo else { return body } return .with { $0.reply = .with { @@ -303,22 +313,23 @@ extension Flipcash_Messaging_V1_Content { } } - /// The content and replied-to message of decrypted Text, Media, or a Reply of either sent by - /// `senderID`; nil for any other type, or for media whose metadata breaks the contract. - fileprivate func readable(senderID: UserID) -> (content: ConversationMessage.Content, repliedTo: MessageID?)? { + /// The content, replied-to message and link preview of decrypted Text, Media, or a Reply of + /// either sent by `senderID`; nil for any other type, or for media whose metadata breaks the + /// contract. + fileprivate func readable(senderID: UserID) -> (content: ConversationMessage.Content, repliedTo: MessageID?, linkPreview: MessageLinkPreview?)? { switch type { case .text(let text): - return (.text(text.text), nil) + return (.text(text.text), nil, .first(of: text, sealedBy: senderID)) case .media(let media): - return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, nil) } + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, nil, nil) } case .reply(let reply): guard reply.content.count == 1 else { return nil } let repliedTo = MessageID(reply.repliedMessageID) switch reply.content[0].type { case .text(let text): - return (.text(text.text), repliedTo) + return (.text(text.text), repliedTo, .first(of: text, sealedBy: senderID)) case .media(let media): - return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, repliedTo) } + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, repliedTo, nil) } case .cash, .system, .widget, .deleted, .encrypted, .reply, nil: return nil } @@ -331,29 +342,36 @@ extension Flipcash_Messaging_V1_Content { extension ConversationMessage.Content { /// The `.media` content of a decrypted `MediaContent` sent by `senderID`, or nil when it breaks - /// the encrypted-media contract: exactly one item with exactly one ORIGINAL rendition, a blob id, - /// and valid `BlobMetadata` describing an image. + /// the encrypted-media contract: exactly one item that passes `MediaAttachment(sealed:senderID:)`. /// /// The dimensions are the sender's claim; the decoded image's own are authoritative once it loads. init?(sealed proto: Flipcash_Messaging_V1_MediaContent, senderID: UserID) { guard proto.items.count == 1, - proto.items[0].renditions.count == 1 else { return nil } - let rendition = proto.items[0].renditions[0] + let attachment = MediaAttachment(sealed: proto.items[0], senderID: senderID) else { return nil } + let caption = proto.caption.text + self = .media([attachment], caption: caption.isEmpty ? nil : caption) + } +} + +extension MediaAttachment { + + /// The attachment a decrypted `Media` sent by `senderID` describes, or nil when it breaks the + /// encrypted-media contract: exactly one ORIGINAL rendition, a blob id, and valid `BlobMetadata` + /// describing an image. + init?(sealed media: Flipcash_Blob_V1_Media, senderID: UserID) { + guard media.renditions.count == 1 else { return nil } + let rendition = media.renditions[0] guard rendition.role == .original, rendition.hasBlobID, rendition.blobID.value.count == 16, rendition.hasBlob, let image = Self.validImage(rendition.blob) else { return nil } - let caption = proto.caption.text - self = .media( - [MediaAttachment( - blobID: BlobID(data: rendition.blobID.value), - width: Int(image.width), - height: Int(image.height), - blurhash: image.blurhash.isEmpty ? nil : image.blurhash, - sealed: SealedBlob(senderID: senderID, plaintextSize: Int(rendition.blob.sizeBytes)) - )], - caption: caption.isEmpty ? nil : caption + self.init( + blobID: BlobID(data: rendition.blobID.value), + width: Int(image.width), + height: Int(image.height), + blurhash: image.blurhash.isEmpty ? nil : image.blurhash, + sealed: SealedBlob(senderID: senderID, plaintextSize: Int(rendition.blob.sizeBytes)) ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 0acaac823..6ae1d6706 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -121,6 +121,9 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// path — link detection, the transcript mapper, the bubble, edit — sees the shape it /// always saw. public let repliedTo: MessageID? + /// The preview the sender built for a link in this text message, or `nil` when it carries none. + /// Like `repliedTo`, it decorates `.text` content rather than being a kind of its own. + public let linkPreview: MessageLinkPreview? /// Delivery state. `.sent` for every server/cache message; `.sending`/`.failed` only for an /// in-flight optimistic message. public var status: SendStatus @@ -154,6 +157,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { eventSequence: UInt64 = 0, lastEditedTs: Date? = nil, repliedTo: MessageID? = nil, + linkPreview: MessageLinkPreview? = nil, status: SendStatus = .sent, clientMessageID: UUID? = nil, redacted: Bool = false, @@ -171,6 +175,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.eventSequence = eventSequence self.lastEditedTs = lastEditedTs self.repliedTo = repliedTo + self.linkPreview = linkPreview self.status = status self.clientMessageID = clientMessageID self.redacted = redacted @@ -201,7 +206,7 @@ extension ConversationMessage { /// A copy carrying different content. Identity, ordering, and delivery status are preserved — /// this exists for the optimistic mutation overlay, which changes what a message says and /// nothing else about where it sits. - public func replacingContent(_ content: Content, lastEditedTs: Date?) -> ConversationMessage { + public func replacingContent(_ content: Content, lastEditedTs: Date?, linkPreview: MessageLinkPreview? = nil) -> ConversationMessage { ConversationMessage( id: id, senderID: senderID, @@ -213,6 +218,7 @@ extension ConversationMessage { eventSequence: eventSequence, lastEditedTs: lastEditedTs, repliedTo: repliedTo, + linkPreview: linkPreview, status: status, clientMessageID: clientMessageID, redacted: redacted, @@ -247,20 +253,24 @@ extension ConversationMessage { } } - /// A copy carrying the outcome of decrypting it: the decrypted content and the message it - /// replies to, or the reason it failed. Everything else is kept, including the ciphertext. - public func opened(_ outcome: Result<(content: Content, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { + /// A copy carrying the outcome of decrypting it: the decrypted content, the message it replies + /// to and its `linkPreview`, or the reason it failed. Everything else is kept, including the + /// ciphertext. + public func opened(_ outcome: Result<(content: Content, repliedTo: MessageID?), DecryptFailure>, linkPreview: MessageLinkPreview? = nil) -> ConversationMessage { let content: Content let repliedTo: MessageID? + let preview: MessageLinkPreview? let failure: DecryptFailure? switch outcome { case .success(let plaintext): content = plaintext.content repliedTo = plaintext.repliedTo + preview = linkPreview failure = nil case .failure(let reason): content = self.content repliedTo = self.repliedTo + preview = self.linkPreview failure = reason } return ConversationMessage( @@ -274,6 +284,7 @@ extension ConversationMessage { eventSequence: eventSequence, lastEditedTs: lastEditedTs, repliedTo: repliedTo, + linkPreview: preview, status: status, clientMessageID: clientMessageID, redacted: redacted, @@ -319,10 +330,12 @@ extension ConversationMessage { /// and can't leave the pre-delete content on screen. public init?(_ proto: Flipcash_Messaging_V1_Message) { let repliedTo: MessageID? + var linkPreview: MessageLinkPreview? var cashReceiverID: UserID? switch proto.content.first?.type { case .text(let textContent): self.content = .text(textContent.text) + linkPreview = .first(of: textContent) self.cashAction = nil repliedTo = nil case .cash(let cashContent): @@ -352,6 +365,7 @@ extension ConversationMessage { switch replyContent.content.first?.type { case .text(let textContent): self.content = .text(textContent.text) + linkPreview = .first(of: textContent) case .media(let mediaContent): guard let content = Content(mediaContent) else { return nil } self.content = content @@ -403,6 +417,7 @@ extension ConversationMessage { self.eventSequence = proto.eventSequence self.lastEditedTs = proto.hasLastEditedTs ? proto.lastEditedTs.date : nil self.repliedTo = repliedTo + self.linkPreview = linkPreview self.status = .sent self.clientMessageID = nil self.redacted = proto.redacted diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index de569791d..edd6f818b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -179,7 +179,11 @@ public struct ConversationStore: Sendable { case .edited(let text): // The stamp only drives the "Edited" marker, so the message's own date stands in for // the server's — using a live clock here would make the store's output time-dependent. - return message.replacingContent(.text(text), lastEditedTs: message.lastEditedTs ?? message.date) + return message.replacingContent( + .text(text), + lastEditedTs: message.lastEditedTs ?? message.date, + linkPreview: message.linkPreview?.kept(for: text) + ) case .deleted: // Delete is only ever offered on your own message, so its sender is its deleter. return message.replacingContent( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageLinkPreview.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageLinkPreview.swift new file mode 100644 index 000000000..541952441 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageLinkPreview.swift @@ -0,0 +1,104 @@ +// +// MessageLinkPreview.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// A preview of a link in a text message, built by the sending device from the page and carried +/// in the message (`messaging.v1.LinkPreview`). Recipients draw it as sent and never fetch the page. +public struct MessageLinkPreview: Hashable, Sendable, Codable { + + /// The link the preview describes, exactly as it appears in the message text. + public let url: String + + /// The page's title, as the sender cleaned it. + public let title: String + + /// The page's description, or `nil` when it has none. + public let description: String? + + /// The page's image, uploaded like a chat photo, or `nil` when it has none. + public let image: MediaAttachment? + + public init(url: String, title: String, description: String?, image: MediaAttachment?) { + self.url = url + self.title = title + self.description = description + self.image = image + } + + /// This preview when its url is still a whole link in `text`, otherwise `nil`. + public func kept(for text: String) -> MessageLinkPreview? { + LinkPreviewSendChecks.isWholeLink(url: url, in: text) ? self : nil + } +} + +// MARK: - Proto - + +extension MessageLinkPreview { + + /// The wire preview, for a message sent sealed when `sealed` is true. An image uploaded for the + /// other mode is left off: a sealed message references only an encrypted blob and a plaintext + /// message only one the server can moderate. + func proto(sealed: Bool) -> Flipcash_Messaging_V1_LinkPreview { + .with { + $0.url = url + $0.title = title + $0.description_p = description ?? "" + if let media = image.flatMap({ Self.media(for: $0, sealed: sealed) }) { + $0.image = media + } + } + } + + private static func media(for image: MediaAttachment, sealed: Bool) -> Flipcash_Blob_V1_Media? { + guard let blobID = image.blobID else { return nil } + switch (sealed, image.sealed) { + case (true, let blob?): + return SealedPhoto( + blobID: blobID, + mimeType: ChatMediaEncoder.mimeType, + sizeBytes: blob.plaintextSize, + width: image.width, + height: image.height, + blurhash: image.blurhash ?? "" + ).media + case (false, nil): + return image.proto + case (true, nil), (false, _?): + return nil + } + } + + /// The preview `proto` carries for `text`, or `nil` when it breaks the contract: its url is not + /// a whole link in `text`, or its title has nothing visible. A sealed preview passes `senderID`, + /// whose image metadata is the sender's and is checked like encrypted media; an image that fails + /// is dropped and the preview kept. + init?(_ proto: Flipcash_Messaging_V1_LinkPreview, text: String, sealedBy senderID: UserID? = nil) { + guard LinkPreviewSendChecks.isWholeLink(url: proto.url, in: text), + LinkPreviewSendChecks.cleanTitle(proto.title) != nil else { return nil } + let image: MediaAttachment? + if !proto.hasImage { + image = nil + } else if let senderID { + image = MediaAttachment(sealed: proto.image, senderID: senderID) + } else { + image = MediaAttachment(proto.image) + } + self.init( + url: proto.url, + title: proto.title, + description: proto.description_p.isEmpty ? nil : proto.description_p, + image: image + ) + } + + /// The first preview `text` carries, the one this client renders. + static func first(of text: Flipcash_Messaging_V1_TextContent, sealedBy senderID: UserID? = nil) -> MessageLinkPreview? { + text.linkPreviews.first.flatMap { MessageLinkPreview($0, text: text.text, sealedBy: senderID) } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 816bf31cf..2a74c4eb7 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -611,6 +611,7 @@ nonisolated extension Database { var encryptedNonce: Data? var encryptedCiphertext: Data? var mediaJson: Data? + var linkPreviewJson: Data? if let sealed = message.sealed { encryptedScheme = sealed.scheme encryptedNonce = sealed.nonce @@ -621,6 +622,7 @@ nonisolated extension Database { case .text(let value): kind = 0 text = value + linkPreviewJson = try message.linkPreview.map { try JSONEncoder().encode($0) } case .cash(let amount): kind = 1 quarks = amount.onChainAmount.quarks @@ -685,7 +687,8 @@ nonisolated extension Database { m.encryptedCiphertext <- encryptedCiphertext, m.decryptFailure <- message.decryptFailure?.rawValue, m.reactionsJson <- Self.encodeReactions(message.reactionState), - m.mediaJson <- mediaJson + m.mediaJson <- mediaJson, + m.linkPreviewJson <- linkPreviewJson ) ) } @@ -852,10 +855,12 @@ nonisolated extension Database { let content: ConversationMessage.Content var redacted = false + var linkPreview: MessageLinkPreview? switch row[m.kind] { case 0: guard let text = row[m.text] else { return nil } content = .text(text) + linkPreview = row[m.linkPreviewJson].flatMap { try? JSONDecoder().decode(MessageLinkPreview.self, from: $0) } case 1: guard let quarks = row[m.quarks], let nativeAmount = row[m.nativeAmount].flatMap({ Decimal(string: $0) }), @@ -923,6 +928,7 @@ nonisolated extension Database { eventSequence: row[m.eventSequence], lastEditedTs: row[m.lastEditedTs].map(Date.init(timeIntervalSinceReferenceDate:)), repliedTo: row[m.repliedToId].map(MessageID.init(value:)), + linkPreview: linkPreview, clientMessageID: row[m.clientMessageID], redacted: redacted, reactionState: Self.decodeReactions(row[m.reactionsJson]), diff --git a/FlipcashCore/Sources/FlipcashStore/Database+LinkPreviews.swift b/FlipcashCore/Sources/FlipcashStore/Database+LinkPreviews.swift deleted file mode 100644 index 392729a21..000000000 --- a/FlipcashCore/Sources/FlipcashStore/Database+LinkPreviews.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// Database+LinkPreviews.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Foundation -import FlipcashCore -import SQLite - -/// One cached web link preview answer as stored, before decoding. -public struct LinkPreviewRow: Sendable, Equatable { - public let key: String - public let json: Data - public let updatedAt: Date - - public init(key: String, json: Data, updatedAt: Date) { - self.key = key - self.json = json - self.updatedAt = updatedAt - } -} - -nonisolated extension Database { - - /// The cached link previews written at or after `since`. - public func linkPreviews(since: Date) throws -> [LinkPreviewRow] { - let t = LinkPreviewTable() - let query = t.table.filter(t.updatedAt >= since.timeIntervalSinceReferenceDate) - return try reader.prepareRowIterator(query).map { row in - LinkPreviewRow( - key: row[t.key], - json: row[t.json], - updatedAt: Date(timeIntervalSinceReferenceDate: row[t.updatedAt]) - ) - } - } - - /// Insert or replace the cached link preview for `key`. - public func upsertLinkPreview(key: String, json: Data, updatedAt: Date) throws { - try write { writer in - let t = LinkPreviewTable() - try writer.run(t.table.upsert( - t.key <- key, - t.json <- json, - t.updatedAt <- updatedAt.timeIntervalSinceReferenceDate, - onConflictOf: t.key - )) - } - } - - /// Remove cached link previews written before `before`. - public func deleteLinkPreviews(before: Date) throws { - try write { writer in - let t = LinkPreviewTable() - try writer.run(t.table.filter(t.updatedAt < before.timeIntervalSinceReferenceDate).delete()) - } - } -} diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 7a7e62f3c..c047d9771 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 51 + public static let schemaVersion = 52 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index fddff54ce..247415352 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -190,18 +190,6 @@ nonisolated public struct BlocklistTable: Sendable { public let avatarBlurhash = Expression ("avatarBlurhash") } -// Cached web link preview answers, keyed by the previewed URL. -nonisolated public struct LinkPreviewTable: Sendable { - public static let name = "linkPreview" - - public init() {} - - public let table = Table(Self.name) - public let key = Expression ("key") // PK - public let json = Expression ("json") - public let updatedAt = Expression ("updatedAt") // timeIntervalSinceReferenceDate -} - // Verified reserve-state proofs, one per mint. nonisolated public struct VerifiedReserveTable: Sendable { public static let name = "verified_reserve" @@ -394,6 +382,8 @@ nonisolated public struct ConversationMessageTable: Sendable { public let reactionsJson = Expression ("reactionsJson") // JSON-encoded `.media` payload (attachments, caption, redaction); nil for a non-media row. public let mediaJson = Expression ("mediaJson") + // JSON-encoded `MessageLinkPreview` of a `.text` row; nil when it carries none. + public let linkPreviewJson = Expression ("linkPreviewJson") } @@ -675,6 +665,7 @@ nonisolated extension Database { t.column(conversationMessageTable.decryptFailure) t.column(conversationMessageTable.reactionsJson) t.column(conversationMessageTable.mediaJson) + t.column(conversationMessageTable.linkPreviewJson) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } @@ -690,15 +681,6 @@ nonisolated extension Database { }) } - let linkPreviewTable = LinkPreviewTable() - - try writer.transaction(.immediate) { - try writer.run(linkPreviewTable.table.create(ifNotExists: true, withoutRowid: true) { t in - t.column(linkPreviewTable.key, primaryKey: true) - t.column(linkPreviewTable.json) - t.column(linkPreviewTable.updatedAt) - }) - } } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageLinkPreviewTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageLinkPreviewTests.swift new file mode 100644 index 000000000..225bedf28 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageLinkPreviewTests.swift @@ -0,0 +1,106 @@ +// +// MessageLinkPreviewTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI +import SharedCore +@testable import FlipcashCore + +/// SharedCore exports its own `KeyPair`; these tests mean the app's. +private typealias KeyPair = FlipcashCore.KeyPair + +@Suite("MessageLinkPreview") +struct MessageLinkPreviewTests { + + private let alice = KeyPair.generate()! + private let bob = KeyPair.generate()! + private let aliceID = UUID() + private let bobID = UUID() + private let chatID = ConversationID(data: Data(repeating: 7, count: 32)) + private let blobID = BlobID(data: Data(repeating: 0xA5, count: 16)) + + private let text = "look https://example.com/a" + + private func seal(owner: KeyPair, peer: KeyPair, selfID: UUID) throws -> ChatSeal { + try ChatSeal(cipher: DefaultChatCipher.shared, owner: owner, peerPublicKey: peer.publicKey, conversationID: chatID, selfUserID: selfID) + } + + private func message(_ content: Flipcash_Messaging_V1_Content, from sender: UUID) throws -> ConversationMessage { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = MessageID(value: 42).proto + $0.senderID = sender.proto + $0.content = [content] + } + return try #require(ConversationMessage(proto)) + } + + private func preview(url: String = "https://example.com/a", title: String = "Example", image: MediaAttachment? = nil) -> MessageLinkPreview { + MessageLinkPreview(url: url, title: title, description: "A page", image: image) + } + + @Test("A plaintext preview comes back from the wire as sent") + func plaintextRoundTrip() throws { + let image = MediaAttachment(blobID: blobID, width: 0, height: 0, blurhash: nil) + let sent = preview(image: image) + let received = try message(.plaintext(text: text, repliedTo: nil, linkPreview: sent), from: aliceID) + + #expect(received.content == .text(text)) + #expect(received.linkPreview == sent) + } + + @Test("A reply carries its preview") + func replyRoundTrip() throws { + let received = try message(.plaintext(text: text, repliedTo: MessageID(value: 9), linkPreview: preview()), from: aliceID) + + #expect(received.repliedTo == MessageID(value: 9)) + #expect(received.linkPreview == preview()) + } + + @Test("A sealed preview decrypts with its sealed image") + func sealedRoundTrip() throws { + let image = MediaAttachment( + blobID: blobID, width: 640, height: 480, blurhash: "LEHV6nWB2yk8", + sealed: SealedBlob(senderID: aliceID, plaintextSize: 1234) + ) + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: text, repliedTo: nil, linkPreview: preview(image: image)) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.content == .text(text)) + #expect(opened.linkPreview == preview(image: image)) + } + + @Test("A sealed message leaves off an image uploaded in plaintext") + func sealedDropsPlainImage() throws { + let plainImage = MediaAttachment(blobID: blobID, width: 0, height: 0, blurhash: nil) + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(text: text, repliedTo: nil, linkPreview: preview(image: plainImage)) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + #expect(opened.linkPreview == preview()) + } + + @Test("A preview whose url is not a whole link in the text is dropped on read") + func notWholeLink() throws { + let received = try message(.plaintext(text: text, repliedTo: nil, linkPreview: preview(url: "https://example.com")), from: aliceID) + + #expect(received.content == .text(text)) + #expect(received.linkPreview == nil) + } + + @Test("A preview with an empty title is dropped on read") + func emptyTitle() throws { + let received = try message(.plaintext(text: text, repliedTo: nil, linkPreview: preview(title: " ")), from: aliceID) + + #expect(received.linkPreview == nil) + } + + @Test("An edit keeps the preview only while its link is still whole in the text") + func keptForEdit() { + #expect(preview().kept(for: "now https://example.com/a!") == preview()) + #expect(preview().kept(for: "gone") == nil) + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/link_preview_send.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/link_preview_send.json new file mode 100644 index 000000000..216baa03c --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/link_preview_send.json @@ -0,0 +1,415 @@ +{ + "algorithm": "flipcash2 0.19.0 LinkPreview sender checks (whole link, title, description)", + "note": "Code points throughout. See gen_link_preview_send.py for the cleaning rules.", + "limits": { + "url": 2048, + "title": 256, + "description": 512 + }, + "wholeLink": [ + { + "name": "plain", + "text": "https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "in a sentence", + "text": "see https://a.example now", + "url": "https://a.example", + "accepted": true + }, + { + "name": "brackets and trailing punctuation", + "text": "see (https://a.example).", + "url": "https://a.example", + "accepted": true + }, + { + "name": "quotes", + "text": "\"https://a.example\"", + "url": "https://a.example", + "accepted": true + }, + { + "name": "run of openers and trailers", + "text": "<[(https://a.example)]>!?", + "url": "https://a.example", + "accepted": true + }, + { + "name": "newline is whitespace", + "text": "x\nhttps://a.example\ny", + "url": "https://a.example", + "accepted": true + }, + { + "name": "no-break space is whitespace", + "text": "x\u00a0https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "path, query, fragment", + "text": "go https://a.example/p/q?x=1#f", + "url": "https://a.example/p/q?x=1#f", + "accepted": true + }, + { + "name": "url ending in a paren it owns", + "text": "https://w.example/Foo_(bar)", + "url": "https://w.example/Foo_(bar)", + "accepted": true + }, + { + "name": "the shorter reading also holds", + "text": "https://w.example/Foo_(bar)", + "url": "https://w.example/Foo_(bar", + "accepted": true + }, + { + "name": "http", + "text": "http://a.example", + "url": "http://a.example", + "accepted": true + }, + { + "name": "port", + "text": "https://a.example:8443/x", + "url": "https://a.example:8443/x", + "accepted": true + }, + { + "name": "trailing dot host", + "text": "https://a.example./x", + "url": "https://a.example./x", + "accepted": true + }, + { + "name": "second occurrence is whole", + "text": "https://a.example.evil.example https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "at the url length limit", + "text": "https://a.example/pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "url": "https://a.example/pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "accepted": true + }, + { + "name": "suffix continues the host", + "text": "https://a.example.evil.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "inside another url", + "text": "https://evil.example/(https://a.example)", + "url": "https://a.example", + "accepted": false + }, + { + "name": "glued to a word before", + "text": "xhttps://a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "glued to a word after", + "text": "https://a.examplex", + "url": "https://a.example", + "accepted": false + }, + { + "name": "trailer then a word", + "text": "https://a.example.x", + "url": "https://a.example", + "accepted": false + }, + { + "name": "bare domain resolved by the detector", + "text": "see a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "uppercase scheme in text", + "text": "HTTPS://a.example", + "url": "HTTPS://a.example", + "accepted": false + }, + { + "name": "detector lowercased the scheme", + "text": "HTTPS://a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "case-folded host", + "text": "https://A.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "percent-decoded", + "text": "https://a.example/a%20b", + "url": "https://a.example/a b", + "accepted": false + }, + { + "name": "ftp", + "text": "ftp://a.example", + "url": "ftp://a.example", + "accepted": false + }, + { + "name": "userinfo", + "text": "https://u:p@a.example", + "url": "https://u:p@a.example", + "accepted": false + }, + { + "name": "ipv4", + "text": "https://10.0.0.1/", + "url": "https://10.0.0.1/", + "accepted": false + }, + { + "name": "numeric last label", + "text": "https://a.123", + "url": "https://a.123", + "accepted": false + }, + { + "name": "hex last label", + "text": "https://a.0x7f", + "url": "https://a.0x7f", + "accepted": false + }, + { + "name": "ipv6", + "text": "https://[::1]/", + "url": "https://[::1]/", + "accepted": false + }, + { + "name": "punycode is fine", + "text": "https://xn--bcher-kva.example", + "url": "https://xn--bcher-kva.example", + "accepted": true + }, + { + "name": "non-ascii host", + "text": "https://b\u00fccher.example", + "url": "https://b\u00fccher.example", + "accepted": false + }, + { + "name": "percent in host", + "text": "https://a%2e.example", + "url": "https://a%2e.example", + "accepted": false + }, + { + "name": "empty label", + "text": "https://a..example", + "url": "https://a..example", + "accepted": false + }, + { + "name": "port zero", + "text": "https://a.example:0", + "url": "https://a.example:0", + "accepted": false + }, + { + "name": "port too big", + "text": "https://a.example:65536", + "url": "https://a.example:65536", + "accepted": false + }, + { + "name": "bidi mark in url", + "text": "https://a.example/\u200ex", + "url": "https://a.example/\u200ex", + "accepted": false + }, + { + "name": "over the url length limit", + "text": "https://a.example/ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "url": "https://a.example/ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "accepted": false + }, + { + "name": "not in the text", + "text": "hello", + "url": "https://a.example", + "accepted": false + } + ], + "title": [ + { + "name": "plain", + "raw": "Hello", + "expected": "Hello" + }, + { + "name": "trimmed", + "raw": " Hello \t", + "expected": "Hello" + }, + { + "name": "line feed becomes a space", + "raw": "Hello\nWorld", + "expected": "Hello World" + }, + { + "name": "crlf becomes one space", + "raw": "Hello\r\nWorld", + "expected": "Hello World" + }, + { + "name": "line separator becomes a space", + "raw": "Hello\u2028World", + "expected": "Hello World" + }, + { + "name": "controls removed", + "raw": "He\u0000ll\u0007o\u007f\u0085", + "expected": "Hello" + }, + { + "name": "tab becomes a space", + "raw": "Hello\tWorld", + "expected": "Hello World" + }, + { + "name": "bidi override removed", + "raw": "\u202eHello\u202c", + "expected": "Hello" + }, + { + "name": "bidi isolate removed", + "raw": "\u2066Hello\u2069", + "expected": "Hello" + }, + { + "name": "bidi marks kept", + "raw": "\u200fHello\u200e", + "expected": "\u200fHello\u200e" + }, + { + "name": "zero-width space kept beside text", + "raw": "Hel\u200blo", + "expected": "Hel\u200blo" + }, + { + "name": "only whitespace drops", + "raw": " ", + "expected": null + }, + { + "name": "only zero-width drops", + "raw": "\u200b\u200d\ufeff", + "expected": null + }, + { + "name": "only bidi drops", + "raw": "\u202e\u202c", + "expected": null + }, + { + "name": "only marks drops", + "raw": "\u200e\u200f", + "expected": null + }, + { + "name": "empty drops", + "raw": "", + "expected": null + }, + { + "name": "cut at 256 code points", + "raw": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "name": "astral counts once", + "raw": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "expected": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00" + }, + { + "name": "flag is two code points", + "raw": "\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8", + "expected": "\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8" + }, + { + "name": "cut splits a flag", + "raw": "a\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8", + "expected": "a\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa" + }, + { + "name": "cut lands before a space", + "raw": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa b", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "description": [ + { + "name": "plain", + "raw": "A page.", + "expected": "A page." + }, + { + "name": "line feeds kept", + "raw": "One\nTwo", + "expected": "One\nTwo" + }, + { + "name": "crlf becomes a line feed", + "raw": "One\r\nTwo", + "expected": "One\nTwo" + }, + { + "name": "paragraph separator becomes a line feed", + "raw": "One\u2029Two", + "expected": "One\nTwo" + }, + { + "name": "trim drops edge newlines", + "raw": "\n\nOne\n", + "expected": "One" + }, + { + "name": "controls removed", + "raw": "On\u001be\u0000", + "expected": "One" + }, + { + "name": "bidi override removed", + "raw": "\u202dOne", + "expected": "One" + }, + { + "name": "only whitespace is empty", + "raw": " \n ", + "expected": "" + }, + { + "name": "only format is empty", + "raw": "\u200b", + "expected": "" + }, + { + "name": "cut at 512 code points", + "raw": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "expected": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "name": "astral counts once", + "raw": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "expected": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00" + } + ] +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/LinkPreviewSendVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/LinkPreviewSendVectorTests.swift new file mode 100644 index 000000000..d717a4b85 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/LinkPreviewSendVectorTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import FlipcashCore + +/// `test-vectors/link_preview_send.json`: the checks a sender runs before attaching a link preview. +/// The canonical copy lives in the orchestrator repo; this one is synced. Never edit it locally. +@Suite struct LinkPreviewSendVectorTests { + + struct WholeLink: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let text: String + let url: String + let accepted: Bool + var testDescription: String { name } + } + + struct Cleaning: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let raw: String + let expected: String? + var testDescription: String { name } + } + + struct Fixture: Decodable { + let wholeLink: [WholeLink] + let title: [Cleaning] + let description: [Cleaning] + } + + static let fixture: Fixture? = try? loadFixture() + static let wholeLinks = fixture?.wholeLink ?? [] + static let titles = fixture?.title ?? [] + static let descriptions = fixture?.description ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "link_preview_send", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + let fixture = try Self.loadFixture() + #expect(!fixture.wholeLink.isEmpty && !fixture.title.isEmpty && !fixture.description.isEmpty) + } + + @Test(arguments: wholeLinks) + func wholeLinkMatches(_ v: WholeLink) { + #expect(LinkPreviewSendChecks.isWholeLink(url: v.url, in: v.text) == v.accepted) + } + + @Test(arguments: titles) + func titleMatches(_ v: Cleaning) { + #expect(LinkPreviewSendChecks.cleanTitle(v.raw) == v.expected) + } + + @Test(arguments: descriptions) + func descriptionMatches(_ v: Cleaning) { + #expect(LinkPreviewSendChecks.cleanDescription(v.raw) == (v.expected ?? "")) + } +} diff --git a/FlipcashTests/Chat/ChatLinkCardSplitTests.swift b/FlipcashTests/Chat/ChatLinkCardSplitTests.swift index 7a058bebb..8dd716fd4 100644 --- a/FlipcashTests/Chat/ChatLinkCardSplitTests.swift +++ b/FlipcashTests/Chat/ChatLinkCardSplitTests.swift @@ -50,7 +50,7 @@ struct ChatLinkCardSplitTests { /// The cash card when there is one, else the first link as a web card, as the classifier picks. private func cashOrWebCard(_ links: [DetectedLink]) -> LinkCard? { - cashCard(links) ?? links.first.map { .web(LinkCard.Web(url: $0.url, range: $0.range)) } + cashCard(links) ?? links.first.map { .web(LinkCard.Web(url: $0.url, range: $0.range, page: MessageLinkPreview(url: $0.url.absoluteString, title: "Example", description: nil, image: nil))) } } private func rows( @@ -292,4 +292,52 @@ struct ChatLinkCardSplitTests { #expect(rows.map(\.joinsBubbleAbove) == [false, true, true]) #expect(rows.map(\.joinsBubbleBelow) == [true, true, false]) } + + // MARK: - Web cards come from the sent preview + + private func page(_ url: String, title: String = "Example") -> MessageLinkPreview { + MessageLinkPreview(url: url, title: title, description: nil, image: nil) + } + + private func withPreview(_ message: ConversationMessage, _ preview: MessageLinkPreview) -> ConversationMessage { + message.replacingContent(message.content, lastEditedTs: nil, linkPreview: preview) + } + + @Test("A sent preview draws a web card at its link, and the text stays whole") + func sentPreview_drawsAWebCard() throws { + let link = "https://example.com/a" + let rows = rows([withPreview(text(1, them, "look \(link)"), page(link))]) + #expect(rows.count == 1) + #expect(body(rows[0]) == "look \(link)") + guard case .web(let web) = rows[0].linkPreview?.card else { + Issue.record("expected a web card") + return + } + #expect(web.range == NSRange(location: 5, length: (link as NSString).length)) + #expect(web.page.title == "Example") + } + + @Test("A link without a sent preview draws no web card") + func noPreview_noWebCard() { + let rows = rows([text(1, them, "look https://example.com/a")]) + #expect(rows[0].linkPreview?.card == nil) + } + + @Test("A preview whose url is not a whole link in the text, or whose title is empty, draws nothing") + func invalidPreview_noWebCard() { + let mismatched = rows([withPreview(text(1, them, "look https://example.com/ab"), page("https://example.com/a"))]) + #expect(mismatched[0].linkPreview?.card == nil) + let untitled = rows([withPreview(text(2, them, "https://example.com/a"), page("https://example.com/a", title: ""))]) + #expect(untitled[0].linkPreview?.card == nil) + } + + @Test("A preview of a link the composer would never fetch still draws: the sender decided") + func foreignPreview_stillDraws() { + let link = "http://example.com/a" + let rows = rows([withPreview(text(1, them, link), page(link))]) + guard case .web = rows[0].linkPreview?.card else { + Issue.record("expected a web card") + return + } + } } diff --git a/FlipcashTests/Chat/ChatLinkMessageCellTests.swift b/FlipcashTests/Chat/ChatLinkMessageCellTests.swift index 95fd45e88..a10bb2d30 100644 --- a/FlipcashTests/Chat/ChatLinkMessageCellTests.swift +++ b/FlipcashTests/Chat/ChatLinkMessageCellTests.swift @@ -254,7 +254,7 @@ struct ChatLinkMessageCellTests { sender: .me, linkPreview: LinkPreview( links: [DetectedLink(range: range, url: url(link))], - card: .web(LinkCard.Web(url: url(link), range: range)) + card: .web(LinkCard.Web(url: url(link), range: range, page: MessageLinkPreview(url: link, title: "Example", description: nil, image: nil))) ) ) let cell = makeCell() @@ -277,7 +277,7 @@ struct ChatLinkMessageCellTests { sender: .me, linkPreview: LinkPreview( links: [DetectedLink(range: range, url: url(link))], - card: .web(LinkCard.Web(url: url(link), range: range)) + card: .web(LinkCard.Web(url: url(link), range: range, page: MessageLinkPreview(url: link, title: "Example", description: nil, image: nil))) ) ) let cell = makeCell() diff --git a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift new file mode 100644 index 000000000..2db04e05c --- /dev/null +++ b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift @@ -0,0 +1,229 @@ +// +// ComposerLinkPreviewTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import UIKit +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("ComposerLinkPreview") +struct ComposerLinkPreviewTests { + + private static let page = ComposerLinkPreview.Page(title: " Example\nTitle ", description: "A page.", image: nil, thumbnail: nil) + + /// A preview whose loader answers `page` and counts the URLs it was asked for. + private func preview(answering page: ComposerLinkPreview.Page? = Self.page) -> (ComposerLinkPreview, Box) { + let box = Box() + let preview = ComposerLinkPreview() + preview.load = { url in + box.asked.append(url.absoluteString) + return page + } + return (preview, box) + } + + private final class Box { var asked: [String] = [] } + + private func settle(_ preview: ComposerLinkPreview) async { + for _ in 0..<20 where { if case .loading = preview.state { true } else { false } }() { + await Task.yield() + } + } + + @Test + func aDraftLinkLoadsItsPreview() async { + let (preview, box) = preview() + preview.update(for: "look https://example.com/a") + #expect(preview.link == "https://example.com/a") + await settle(preview) + #expect(box.asked == ["https://example.com/a"]) + guard case .ready = preview.state else { Issue.record("not ready"); return } + } + + @Test + func aRestoredDraftLinkLoadsItsPreview() async { + let composer = ComposerModel() + var asked: [String] = [] + composer.linkPreview.load = { url in + asked.append(url.absoluteString) + return Self.page + } + composer.restore(ChatDraft(text: "https://example.com/a", replyTarget: nil)) + await settle(composer.linkPreview) + #expect(asked == ["https://example.com/a"]) + #expect(composer.showsLinkPreview) + } + + @Test + func typingAroundTheSameLinkFetchesOnce() async { + let (preview, box) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + preview.update(for: "https://example.com/a and more") + preview.update(for: "so https://example.com/a and more") + await settle(preview) + #expect(box.asked.count == 1) + } + + @Test + func aFailedFetchIsNotRetriedOnEveryKeystroke() async { + let (preview, box) = preview(answering: nil) + preview.update(for: "https://example.com/a") + await settle(preview) + preview.update(for: "https://example.com/a x") + await settle(preview) + #expect(preview.link == nil) + #expect(box.asked.count == 1) + } + + @Test + func aRemovedPreviewStaysOffWhileItsLinkStays() async { + let (preview, box) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + preview.remove() + #expect(preview.link == nil) + preview.update(for: "https://example.com/a and more") + await settle(preview) + #expect(preview.link == nil) + #expect(box.asked.count == 1) + } + + @Test + func aRemovedPreviewComesBackOnceItsLinkIsEditedOut() async { + let (preview, box) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + preview.remove() + preview.update(for: "https://example.com/") + preview.update(for: "https://example.com/a") + await settle(preview) + #expect(preview.link == "https://example.com/a") + #expect(box.asked.last == "https://example.com/a") + } + + @Test + func resetForgetsRemovals() async { + let (preview, box) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + preview.remove() + preview.reset() + preview.update(for: "https://example.com/a") + await settle(preview) + #expect(preview.link == "https://example.com/a") + #expect(box.asked.count == 2) + } + + @Test + func noLinkMeansNoFetch() async { + let (preview, box) = preview() + preview.update(for: "no links here") + await settle(preview) + #expect(preview.link == nil) + #expect(box.asked.isEmpty) + } + + @Test + func theAttachmentIsCleanedAtSend() async throws { + let (preview, _) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + let sent = try #require(preview.attachment(for: "see https://example.com/a")) + #expect(sent.url == "https://example.com/a") + #expect(sent.title == "Example Title") + #expect(sent.description == "A page.") + } + + @Test + func noAttachmentOnceTheLinkIsNoLongerWhole() async { + let (preview, _) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + #expect(preview.attachment(for: "https://example.com/abc") == nil) + #expect(preview.attachment(for: "nothing") == nil) + } + + @Test + func noAttachmentWhileLoading() { + let preview = ComposerLinkPreview() + preview.load = { _ in try? await Task.sleep(for: .seconds(60)); return Self.page } + preview.update(for: "https://example.com/a") + #expect(preview.attachment(for: "https://example.com/a") == nil) + preview.reset() + } + + @Test + func aTitleThatCleansToNothingSendsNoPreview() async { + let (preview, _) = preview(answering: .init(title: " \n\t ", description: nil, image: nil, thumbnail: nil)) + preview.update(for: "https://example.com/a") + await settle(preview) + #expect(preview.attachment(for: "https://example.com/a") == nil) + } + + // MARK: - Image upload + + private static let picture = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { _ in } + private static let uploaded = MediaAttachment(blobID: BlobID(data: Data(repeating: 7, count: 16)), width: 4, height: 4, blurhash: nil) + + /// A preview whose page has an image and whose upload waits until `release` answers it. + private func uploadingPreview() -> (ComposerLinkPreview, (MediaAttachment?) -> Void) { + let page = ComposerLinkPreview.Page(title: "Example", description: nil, image: nil, thumbnail: Self.picture) + let (preview, _) = preview(answering: page) + let (answers, answer) = AsyncStream.makeStream(of: MediaAttachment?.self) + preview.upload = { _ in + for await image in answers { return image } + return nil + } + return (preview, { answer.yield($0) }) + } + + private func settleUpload(_ preview: ComposerLinkPreview) async { + for _ in 0..<50 { + if case .ready(_, _, let page) = preview.state, !page.isUploadingImage { return } + await Task.yield() + } + } + + @Test + func thePageShowsBeforeItsImageUploads() async { + let (preview, release) = uploadingPreview() + preview.update(for: "https://example.com/a") + await settle(preview) + guard case .ready(_, _, let page) = preview.state else { Issue.record("not ready"); return } + #expect(page.thumbnail != nil) + #expect(page.isUploadingImage) + #expect(preview.attachment(for: "https://example.com/a")?.image == nil) + release(Self.uploaded) + await settleUpload(preview) + #expect(preview.attachment(for: "https://example.com/a")?.image == Self.uploaded) + } + + @Test + func aFailedUploadDropsTheThumbnail() async { + let (preview, release) = uploadingPreview() + preview.update(for: "https://example.com/a") + await settle(preview) + release(nil) + await settleUpload(preview) + guard case .ready(_, _, let page) = preview.state else { Issue.record("not ready"); return } + #expect(page.thumbnail == nil) + #expect(preview.attachment(for: "https://example.com/a")?.title == "Example") + } + + @Test + func withoutAnUploaderThePageGoesWithoutItsImage() async { + let page = ComposerLinkPreview.Page(title: "Example", description: nil, image: nil, thumbnail: Self.picture) + let (preview, _) = preview(answering: page) + preview.update(for: "https://example.com/a") + await settle(preview) + guard case .ready(_, _, let ready) = preview.state else { Issue.record("not ready"); return } + #expect(ready.thumbnail == nil) + } +} diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift index da3e3e42f..cbf0aac36 100644 --- a/FlipcashTests/Chat/ConversationMediaSendTests.swift +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -143,6 +143,36 @@ struct ConversationMediaSendTests { #expect(mock.sentMedia.map(\.blobID) == [blobID("slow"), blobID("fast")]) } + @Test("Text with a link preview posts after the photos as its own message, and no photo carries it as a caption") + func linkTextFollowsThePhotos() async { + let mock = MockConversations() + let controller = makeController(mock) + let gate = UploadGate() + let text = "look https://example.com/a" + let preview = MessageLinkPreview(url: "https://example.com/a", title: "Example", description: nil, image: nil) + + let send = Task { + await controller.sendMedia( + [gatedChip(gate)], + caption: nil, + followedBy: (text: text, linkPreview: preview), + to: conversationID, + repliedTo: MessageID(value: 7) + ) + } + await yield { pendingMedia(controller).count == 1 } + for _ in 0..<20 { await Task.yield() } + #expect(mock.sent.isEmpty, "the text waits for the photo") + + gate.open(.success(blobID("photo"))) + #expect(await send.value) + #expect(mock.sentMedia.map(\.caption) == [nil]) + #expect(mock.sentMedia.map(\.repliedTo) == [MessageID(value: 7)]) + #expect(mock.sent.map(\.text) == [text]) + #expect(mock.sent.map(\.linkPreview) == [preview]) + #expect(mock.sent.map(\.repliedTo) == [nil], "the reply goes on the first message only") + } + @Test("A photo that fails to upload fails only its own bubble") func failedUploadFailsOnlyItsRow() async throws { let mock = MockConversations() diff --git a/FlipcashTests/Chat/ConversationWebPrefetchTests.swift b/FlipcashTests/Chat/ConversationWebPrefetchTests.swift deleted file mode 100644 index c499c2d95..000000000 --- a/FlipcashTests/Chat/ConversationWebPrefetchTests.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// ConversationWebPrefetchTests.swift -// FlipcashTests -// - -import Testing -import Foundation -import FlipcashCore -import FlipcashStore -@testable import Flipcash - -/// The load coordinator prefetches web cards only for the chat on screen, in the foreground, and in -/// a group only for a member (parity decision D5). -@MainActor -@Suite("ConversationLoadCoordinator web prefetch") -struct ConversationWebPrefetchTests { - - private let me = UUID() - private let them = UUID() - - private final class Spy { - var batches: [[LinkCard]] = [] - } - - private func conversation(_ type: ConversationType) -> Conversation { - Conversation( - id: .test(1), - members: [ConversationMember(userID: me, displayName: "Me"), ConversationMember(userID: them, displayName: "Them")], - lastMessage: nil, - lastActivity: Date(timeIntervalSince1970: 100), - type: type, - title: type == .group ? "Group" : nil - ) - } - - private func message(_ id: UInt64, _ body: String) -> ConversationMessage { - ConversationMessage( - id: MessageID(value: id), - senderID: them, - content: .text(body), - date: Date(timeIntervalSince1970: 200 + Double(id)), - unreadSeq: id - ) - } - - private enum Membership { case dm, memberGroup } - - /// Lands a page with two web links and one cash-free text message, then reports what was prefetched. - private func prefetched( - _ membership: Membership, - visible: Bool = true, - active: Bool = true - ) async throws -> [[LinkCard]] { - let (database, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - let mock = MockConversations() - switch membership { - case .dm: mock.feed = [conversation(.contactDm)] - case .memberGroup: mock.groupFeed = [conversation(.group)] - } - mock.deltaBatches = [.init(messages: [ - message(1, "see https://example.com/a"), - message(2, "no link here"), - message(3, "and https://example.org/b#frag"), - ], checkpoint: 3)] - mock.deltaHead = 3 - - let controller = ConversationController( - fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, - contactNaming: MockDMContactNaming(), - database: database, - owner: .generate()!, selfUserID: me - ) - controller.start() - try await waitUntil { mock.streamOpened } - switch membership { - case .dm: - try await waitUntil { controller.conversation(withID: .test(1)) != nil } - case .memberGroup: - await controller.loadGroupFeed() - } - if visible { controller.visibleConversationID = .test(1) } - - let spy = Spy() - let coordinator = ConversationLoadCoordinator( - conversationID: .test(1), - controller: controller, - session: .mock, - knownAuthors: KnownAuthorDirectory(read: { [:] }, fetch: { _ in throw CancellationError() }, cache: { _, _ in }), - prefetchWebCards: { spy.batches.append($0) }, - isAppActive: { active } - ) - - await controller.catchUp(conversationID: .test(1)) - // The prefetch decision runs on the same main-actor turn that lands the mapped items. - try await waitUntil { coordinator.items.count >= 3 } - controller.stop() - return spy.batches - } - - @Test("A visible, active DM prefetches each web card in the page") - func visibleActiveDM_prefetchesWebCards() async throws { - let batches = try await prefetched(.dm) - let urls = try #require(batches.last).compactMap { card -> String? in - guard case .web(let web) = card else { return nil } - return web.url.absoluteString - } - #expect(urls == ["https://example.com/a", "https://example.org/b#frag"]) - } - - @Test("A visible, active member of a group prefetches") - func memberGroup_prefetches() async throws { - #expect(try await !prefetched(.memberGroup).isEmpty) - } - - // A non-member reads a transcript only when they satisfy a gated group's rule, which needs a - // balance this harness does not model, so the member rule is checked on the decision itself. - @Test("A non-member reading a group does not prefetch, even on screen and in the foreground") - func nonMember_doesNotPrefetch() { - #expect(!ConversationLoadCoordinator.mayPrefetchWebCards(isMember: false, isVisible: true, isAppActive: true)) - #expect(ConversationLoadCoordinator.mayPrefetchWebCards(isMember: true, isVisible: true, isAppActive: true)) - } - - @Test("A chat that is not on screen does not prefetch") - func notVisible_doesNotPrefetch() async throws { - #expect(try await prefetched(.dm, visible: false).isEmpty) - } - - @Test("A visible chat while the app is inactive does not prefetch") - func inactive_doesNotPrefetch() async throws { - #expect(try await prefetched(.dm, active: false).isEmpty) - } -} diff --git a/FlipcashTests/Chat/LinkCardClassifierTests.swift b/FlipcashTests/Chat/LinkCardClassifierTests.swift index 57eefe62e..afd34be34 100644 --- a/FlipcashTests/Chat/LinkCardClassifierTests.swift +++ b/FlipcashTests/Chat/LinkCardClassifierTests.swift @@ -38,7 +38,7 @@ import FlipcashCore DetectedLink(range: NSRange(location: span.start, length: span.end - span.start), url: $0) } } - let actual = classifier.firstCard(in: links) + let actual = classifier.cardOrWebLink(in: links) guard let expected = vector.card else { #expect(actual == nil, "vector `\(vector.name)`: \(vector.note)") @@ -77,7 +77,7 @@ import FlipcashCore let url = try #require(URL(string: wrapper)) let link = DetectedLink(range: NSRange(location: 0, length: (wrapper as NSString).length), url: url) - #expect(LinkCardClassifier().firstCard(in: [link]) == nil) + #expect(LinkCardClassifier().cardOrWebLink(in: [link]) == nil) } /// Not in the canonical fixture yet: Android refuses `/chat/` links until it ships the group @@ -87,7 +87,7 @@ import FlipcashCore let url = try #require(URL(string: text)) let link = DetectedLink(range: NSRange(location: 0, length: (text as NSString).length), url: url) - let card = try #require(LinkCardClassifier().firstCard(in: [link])) + let card = try #require(LinkCardClassifier().cardOrWebLink(in: [link])) guard case .group(let group) = card else { Issue.record("expected a group card, got \(card.kindName)") return @@ -103,7 +103,7 @@ import FlipcashCore let url = try #require(URL(string: text)) let link = DetectedLink(range: NSRange(location: 0, length: (text as NSString).length), url: url) - #expect(LinkCardClassifier().firstCard(in: [link]) == nil) + #expect(LinkCardClassifier().cardOrWebLink(in: [link]) == nil) } // MARK: - Person cards - @@ -111,7 +111,7 @@ import FlipcashCore private static func card(for text: String) throws -> LinkCard? { let url = try #require(URL(string: text)) let link = DetectedLink(range: NSRange(location: 0, length: (text as NSString).length), url: url) - return LinkCardClassifier().firstCard(in: [link]) + return LinkCardClassifier().cardOrWebLink(in: [link]) } private static let userID = "2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b" @@ -172,26 +172,15 @@ import FlipcashCore DetectedLink(range: NSRange(location: 0, length: 21), url: web), DetectedLink(range: NSRange(location: 22, length: 66), url: group), ] - #expect(LinkCardClassifier().firstCard(in: links)?.kindName == "group") + #expect(LinkCardClassifier().cardOrWebLink(in: links)?.kindName == "group") } - /// With `webLinkPreviews` off an outside link gets no card at all, so no chip and no prefetch. - @Test func webLinksOff_anOutsideLinkGetsNoCard() throws { + /// The text alone never makes a web card: that comes only from a sent preview. + @Test func anOutsideLinkIsNeverACardOnItsOwn() throws { let web = try #require(URL(string: "https://example.com/a")) let links = [DetectedLink(range: NSRange(location: 0, length: 21), url: web)] - #expect(LinkCardClassifier(webLinks: false).firstCard(in: links) == nil) - #expect(LinkCardClassifier(webLinks: true).firstCard(in: links)?.kindName == "web") - } - - /// Turning web links off leaves Flipcash cards alone. - @Test func webLinksOff_aFlipcashCardStillDraws() throws { - let web = try #require(URL(string: "https://example.com/a")) - let group = try #require(URL(string: "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162")) - let links = [ - DetectedLink(range: NSRange(location: 0, length: 21), url: web), - DetectedLink(range: NSRange(location: 22, length: 66), url: group), - ] - #expect(LinkCardClassifier(webLinks: false).firstCard(in: links)?.kindName == "group") + #expect(LinkCardClassifier().firstCard(in: links) == nil) + #expect(LinkCardClassifier().firstWebLink(in: links)?.url == web) } /// An outside link that fails the fetch rules is skipped for the next one that passes. @@ -202,7 +191,7 @@ import FlipcashCore DetectedLink(range: NSRange(location: 0, length: 25), url: port), DetectedLink(range: NSRange(location: 26, length: 21), url: next), ] - guard case .web(let web) = LinkCardClassifier().firstCard(in: links) else { + guard case .web(let web) = LinkCardClassifier().cardOrWebLink(in: links) else { Issue.record("expected a web card") return } @@ -238,6 +227,21 @@ import FlipcashCore } } +private extension LinkCardClassifier { + /// The Flipcash card, or else the link the composer would preview as a web card: the one + /// answer the shared fixture describes. + func cardOrWebLink(in links: [DetectedLink]) -> LinkCard? { + if let card = firstCard(in: links) { return card } + return firstWebLink(in: links).map { + .web(LinkCard.Web( + url: $0.url, + range: $0.range, + page: MessageLinkPreview(url: $0.url.absoluteString, title: "t", description: nil, image: nil) + )) + } + } +} + /// The fixture names a card's kind as a string; this is the one place that string meets the enum. private extension LinkCard { var kindName: String { diff --git a/FlipcashTests/Chat/LinkCardFeedTests.swift b/FlipcashTests/Chat/LinkCardFeedTests.swift index 2e772e1be..3e5510ce0 100644 --- a/FlipcashTests/Chat/LinkCardFeedTests.swift +++ b/FlipcashTests/Chat/LinkCardFeedTests.swift @@ -40,10 +40,6 @@ import FlipcashUI cash: @escaping @Sendable (String) async throws -> LinkCard.Cash.Resolved = { _ in Issue.record("the cash lookup was called") throw CancellationError() - }, - web: @escaping @Sendable (URL) async throws -> LinkCard.Web.State = { _ in - Issue.record("the web lookup was called") - throw CancellationError() } ) -> LinkCardFeed { LinkCardFeed( @@ -60,8 +56,7 @@ import FlipcashUI userLookup: { _ in Issue.record("the user lookup was called") throw CancellationError() - }, - webLookup: web + } ), memo: memo, claims: claims, @@ -299,109 +294,3 @@ import FlipcashUI } } - -/// A web answer the memo holds paints at once; one it does not is looked up and remembered. -@MainActor -extension LinkCardFeedTests { - - // MARK: - Web - - - private static func webCard(_ url: String) -> LinkCard { - .web(LinkCard.Web(url: URL(string: url)!, range: NSRange(location: 0, length: url.utf16.count))) - } - - nonisolated private static let page = LinkCard.Web.Resolved(title: "Title", description: nil, imageURL: nil, host: "example.com") - - @Test func aRememberedWebAnswerPaintsWithoutWaiting() async { - let memo = LinkCardMemo() - let card = Self.webCard("https://example.com/a") - memo.recordWeb(.resolved(Self.page), for: card.resolutionKey) - - let feed = Self.feed(memo: memo) - #expect(feed.known(card) == .web(.resolved(Self.page))) - } - - @Test func aWebAnswerPastItsTTLReadsAsNothingKnown() async { - let clock = Clock(Date(timeIntervalSince1970: 0)) - let memo = LinkCardMemo(now: { clock.now }) - let card = Self.webCard("https://example.com/a") - memo.recordWeb(.none, for: card.resolutionKey) - clock.now = Date(timeIntervalSince1970: WebLinks.emptyTTL + 1) - - #expect(Self.feed(memo: memo).known(card) == nil) - } - - @Test func prefetchingAHeldWebCardDoesNotLookItUp() async { - let memo = LinkCardMemo() - let card = Self.webCard("https://example.com/a") - memo.recordWeb(.resolved(Self.page), for: card.resolutionKey) - - let feed = Self.feed(memo: memo) // the default web lookup records an issue if called - feed.prefetch([card]) - try? await Task.sleep(for: .milliseconds(50)) - } - - @Test func prefetchingAnUnheldWebCardRemembersTheAnswer() async { - let memo = LinkCardMemo() - let card = Self.webCard("https://example.com/a") - let feed = Self.feed(memo: memo, web: { _ in .resolved(Self.page) }) - - feed.prefetch([card]) - #expect(await settle { feed.known(card) != nil }) - #expect(feed.known(card) == .web(.resolved(Self.page))) - } - - @Test func aFailedWebLookupIsNotRemembered() async { - struct Offline: Error {} - let memo = LinkCardMemo() - let card = Self.webCard("https://example.com/a") - let feed = Self.feed(memo: memo, web: { _ in throw Offline() }) - - feed.prefetch([card]) - try? await Task.sleep(for: .milliseconds(100)) - #expect(memo.web(card.resolutionKey) == nil) - } - - /// A link-only message's placeholder gives way to its text bubble only if the card hears the failure. - @Test func aFailedWebLookupTellsTheCardNothingAndForgetsIt() async { - struct Offline: Error {} - let memo = LinkCardMemo() - let card = Self.webCard("https://example.com/a") - let feed = Self.feed(memo: memo, web: { _ in throw Offline() }) - - var states = feed.states(for: card).makeAsyncIterator() - #expect(await states.next() == .web(.none)) - #expect(memo.web(card.resolutionKey) == nil) - #expect(feed.known(card) == nil) - } - - /// The protocol's default answers nil, so a feed that does not answer itself draws every web - /// card without its image. - @Test func aWebImageComesFromTheFeedsFetcher() async { - let url = URL(string: "https://example.com/a.png")! - let bytes = Data([1, 2, 3]) - let feed = LinkCardFeed( - resolver: LinkCardResolver( - cashLookup: { _ in throw CancellationError() }, - mintLookup: { _ in throw CancellationError() }, - groupLookup: { _ in throw CancellationError() }, - userLookup: { _ in throw CancellationError() }, - webLookup: { _ in throw CancellationError() } - ), - memo: LinkCardMemo(), - claims: CashLinkClaimLog(), - groups: UnusedGroups(), - users: UnusedUsers(), - webImages: { $0 == url ? bytes : nil }, - cachedWebImages: { $0 == url ? bytes : nil } - ) - let source: any LinkCardSource = feed - #expect(await source.webImage(for: url) == bytes) - #expect(source.cachedWebImage(for: url) == bytes) - } - - private final class Clock: @unchecked Sendable { - var now: Date - init(_ now: Date) { self.now = now } - } -} diff --git a/FlipcashTests/Chat/LinkCardMemoWebTests.swift b/FlipcashTests/Chat/LinkCardMemoWebTests.swift deleted file mode 100644 index 3aa1722eb..000000000 --- a/FlipcashTests/Chat/LinkCardMemoWebTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// LinkCardMemoWebTests.swift -// FlipcashTests -// - -import Testing -import Foundation -import FlipcashCore -import FlipcashStore -@testable import Flipcash - -/// Web answers persist across memos over one store, under the TTLs in parity decision D8. -@MainActor -@Suite("LinkCardMemo web persistence") -struct LinkCardMemoWebTests { - - private final class Clock: @unchecked Sendable { - var now: Date - init(_ now: Date) { self.now = now } - } - - private static let key = "web:https://example.com/a" - private static let page = LinkCard.Web.Resolved(title: "Title", description: "About", imageURL: URL(string: "https://example.com/i.png"), host: "example.com") - - /// Waits for `memo`'s write-through to land in `database`. - private func waitForRow(_ key: String, in database: Database) async throws { - try await waitUntil { (try? database.linkPreviews(since: .distantPast))?.contains { $0.key == key } == true } - } - - /// A store whose load never returns until the test releases it. - private final class StuckStore: LinkPreviewStoring, @unchecked Sendable { - let release = DispatchSemaphore(value: 0) - func linkPreviews(since: Date) throws -> [LinkPreviewRow] { release.wait(); return [] } - func upsertLinkPreview(key: String, json: Data, updatedAt: Date) throws {} - func deleteLinkPreviews(before: Date) throws {} - } - - @Test func aChatWaitsAtMost300MsForTheSavedRows() async { - let store = StuckStore() - defer { store.release.signal() } - let memo = LinkCardMemo(store: store) - #expect(!memo.isLoaded) - let start = ContinuousClock.now - await memo.awaitLoaded() - let waited = ContinuousClock.now - start - #expect(waited >= .milliseconds(290)) - #expect(waited < .milliseconds(1000)) - #expect(!memo.isLoaded) - } - - @Test func aMemoWithNoStoreIsLoadedFromTheStart() { - #expect(LinkCardMemo().isLoaded) - } - - @Test func aRecordedAnswerSurvivesANewMemoOverTheSameStore() async throws { - let (database, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - - LinkCardMemo(store: database).recordWeb(.resolved(Self.page), for: Self.key) - try await waitForRow(Self.key, in: database) - - let reopened = LinkCardMemo(store: database) - await reopened.awaitLoaded() - #expect(reopened.web(Self.key) == .resolved(Self.page)) - } - - @Test func aStoredNoneRowOlderThanItsTTLReadsAsNil() async throws { - let (database, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - let clock = Clock(Date()) - try database.upsertLinkPreview(key: Self.key, json: StoredWeb(.none).encoded, - updatedAt: clock.now.addingTimeInterval(-WebLinks.emptyTTL - 60)) - - let memo = LinkCardMemo(store: database, now: { clock.now }) - await memo.awaitLoaded() - #expect(memo.web(Self.key) == nil) - } - - @Test func aLoadDeletesRowsOlderThanTheResolvedTTL() async throws { - let (database, url) = try Database.makeTemp() - defer { Database.removeTemp(at: url) } - let now = Date() - try database.upsertLinkPreview(key: "web:https://old.example/", json: StoredWeb(.resolved(Self.page)).encoded, - updatedAt: now.addingTimeInterval(-WebLinks.resolvedTTL - 60)) - try database.upsertLinkPreview(key: Self.key, json: StoredWeb(.resolved(Self.page)).encoded, updatedAt: now) - - let memo = LinkCardMemo(store: database, now: { now }) - await memo.awaitLoaded() - #expect(try database.linkPreviews(since: .distantPast).map(\.key) == [Self.key]) - } - - @Test func theStoredShapeMatchesAndroid() throws { - let none = try #require(JSONSerialization.jsonObject(with: StoredWeb(.none).encoded) as? [String: Any]) - #expect(Set(none.keys) == ["title", "description", "imageUrl", "host"], "all four keys, nulls included") - #expect(none.values.allSatisfy { $0 is NSNull }, "a null title is how a None answer is stored") - let decoded = try #require(StoredWeb.decode(Data(#"{"title":"T","description":null,"imageUrl":"https://e.com/i","host":"e.com"}"#.utf8))) - #expect(decoded.state == .resolved(.init(title: "T", description: nil, imageURL: URL(string: "https://e.com/i"), host: "e.com"))) - } - - @Test func anAnswerExactlyAtItsTTLStillReads() { - let clock = Clock(Date(timeIntervalSince1970: 0)) - let memo = LinkCardMemo(now: { clock.now }) - memo.recordWeb(.none, for: Self.key) - clock.now = Date(timeIntervalSince1970: WebLinks.emptyTTL) - #expect(memo.web(Self.key) == LinkCard.Web.State.none) - clock.now = Date(timeIntervalSince1970: WebLinks.emptyTTL + 0.001) - #expect(memo.web(Self.key) == nil) - } -} diff --git a/FlipcashTests/Chat/LinkCardResolverTests.swift b/FlipcashTests/Chat/LinkCardResolverTests.swift index f5c5c797c..2bcc39bde 100644 --- a/FlipcashTests/Chat/LinkCardResolverTests.swift +++ b/FlipcashTests/Chat/LinkCardResolverTests.swift @@ -33,12 +33,9 @@ import FlipcashCore }, user: @escaping @Sendable (LinkCard.User.Identity) async throws -> UserLinkFacts = { _ in Issue.record("the user lookup was called"); throw Offline() - }, - web: @escaping @Sendable (URL) async throws -> LinkCard.Web.State = { _ in - Issue.record("the web lookup was called"); throw Offline() } ) -> LinkCardResolver { - LinkCardResolver(cashLookup: cash, mintLookup: mint, groupLookup: group, userLookup: user, webLookup: web) + LinkCardResolver(cashLookup: cash, mintLookup: mint, groupLookup: group, userLookup: user) } @Test func aFailedLookupStaysUnresolved() async throws { @@ -197,46 +194,6 @@ import FlipcashCore #expect(LinkCard.token(Self.tokenCard).resolutionKey.hasPrefix("token:")) } - // MARK: - Web - - - private static func webCard(_ url: String) -> LinkCard.Web { - LinkCard.Web(url: URL(string: url)!, range: NSRange(location: 0, length: url.utf16.count)) - } - - @Test func twoWebCardsThatDifferOnlyByFragmentShareOneLookup() async throws { - let calls = Counter() - let gate = Gate() - let resolver = Self.resolver(web: { _ in - await calls.increment() - await gate.wait() - return .none - }) - - async let first = resolver.web(Self.webCard("https://example.com/a#one")) - async let second = resolver.web(Self.webCard("https://example.com/a#two")) - try await Task.sleep(for: .milliseconds(50)) - await gate.open() - - #expect(await first == LinkCard.Web.State.none) - #expect(await second == LinkCard.Web.State.none) - #expect(await calls.value == 1) - } - - @Test func aFailedWebLookupAnswersNilAndAsksAgain() async { - let calls = Counter() - let resolver = Self.resolver(web: { _ in await calls.increment(); throw Offline() }) - let card = Self.webCard("https://example.com/") - - #expect(await resolver.web(card) == nil) - #expect(await resolver.web(card) == nil) - #expect(await calls.value == 2) - } - - @Test func aWebCardWithNoCacheKeyAnswersNoneWithoutALookup() async { - let resolver = Self.resolver() - #expect(await resolver.web(Self.webCard("https://ex%61mple.com/")) == LinkCard.Web.State.none) - } - private actor Counter { private(set) var value = 0 func increment() { value += 1 } @@ -283,8 +240,7 @@ import FlipcashCore cashLookup: LinkCardResolver.giftCardLookup(reader: reader, viewer: viewer), mintLookup: { _ in throw Reader.Stop() }, groupLookup: { _ in throw Reader.Stop() }, - userLookup: { _ in throw Reader.Stop() }, - webLookup: { _ in throw Reader.Stop() } + userLookup: { _ in throw Reader.Stop() } ) let card = LinkCard.cash( @@ -323,8 +279,7 @@ import FlipcashCore cashLookup: { _ in throw Reader.Stop() }, mintLookup: LinkCardResolver.mintLookup(reader: reader), groupLookup: { _ in throw Reader.Stop() }, - userLookup: { _ in throw Reader.Stop() }, - webLookup: { _ in throw Reader.Stop() } + userLookup: { _ in throw Reader.Stop() } ) let card = LinkCard.token( diff --git a/FlipcashTests/Chat/LinkCardViewTests.swift b/FlipcashTests/Chat/LinkCardViewTests.swift index 81baa2bb9..6afd8ce27 100644 --- a/FlipcashTests/Chat/LinkCardViewTests.swift +++ b/FlipcashTests/Chat/LinkCardViewTests.swift @@ -177,117 +177,84 @@ struct LinkCardViewTests { // MARK: - Web - private static func webCard(_ url: String = "https://example.com/a") -> LinkCard { - .web(LinkCard.Web(url: URL(string: url)!, range: NSRange(location: 0, length: (url as NSString).length))) - } - - private static let page = LinkCard.Web.Resolved( - title: "Example Title", description: "About it", imageURL: nil, host: "example.com" + private static let attachment = MediaAttachment( + blobID: BlobID(data: Data(repeating: 7, count: 16)), width: 1200, height: 630, blurhash: nil ) + private static func webCard( + _ url: String = "https://example.com/a", + description: String? = "About it", + image: MediaAttachment? = nil + ) -> LinkCard { + .web(LinkCard.Web( + url: URL(string: url)!, + range: NSRange(location: 0, length: (url as NSString).length), + page: MessageLinkPreview(url: url, title: "Example Title", description: description, image: image) + )) + } + private func web(_ view: LinkCardView) -> LinkWebCardView? { view.descendants(of: LinkWebCardView.self).first } - @Test("A resolved web card shows its title and host") - func web_resolved_showsTitleAndHost() async { - let source = Source() - let card = Self.webCard() - source.answers[card] = .web(.resolved(Self.page)) - let view = LinkCardView() - view.webPreviewMode = .automatic - view.configure(with: card, source: source) - - #expect(web(view)?.content == .preview(Self.page)) + @Test("A web card draws the page its message carries, with no source") + func web_drawsTheSentPage() { + let view = LinkCardView(frame: CGRect(x: 0, y: 0, width: 240, height: 0)) + view.configure(with: Self.webCard(), source: nil) #expect(web(view)?.titleLabel.text == "Example Title") #expect(web(view)?.hostLabel.text == "example.com") - #expect(view.drawsWebPreview) } - @Test("A web card with nothing to show draws nothing") - func web_none_drawsNothing() async { + @Test("A web card never asks the source") + func web_neverAsksTheSource() async { let source = Source() - let card = Self.webCard() - source.answers[card] = .web(.none) - let view = LinkCardView() - view.webPreviewMode = .automatic - view.configure(with: card, source: source) - - #expect(web(view)?.content == .nothing) - #expect(!view.drawsWebPreview) - } - - @Test("Tap to load asks for nothing until the chip is tapped, then asks once") - func web_tapToLoad_asksOnlyAfterChip() async { - let source = Source() - let card = Self.webCard() - let view = LinkCardView() - view.webPreviewMode = .tapToLoad - view.configure(with: card, source: source) - + let view = LinkCardView(frame: CGRect(x: 0, y: 0, width: 240, height: 0)) + view.configure(with: Self.webCard(), source: source) + for _ in 0..<10 { await Task.yield() } #expect(source.asked.isEmpty) - #expect(web(view)?.content == .chip(host: "example.com")) - - web(view)?.onShowPreview?() - #expect(await settle { source.asked.count == 1 }) - source.yield(.web(.resolved(Self.page)), for: card) - #expect(await settle { web(view)?.content == .preview(Self.page) }) - #expect(source.asked == [card]) } - @Test("A preview asked for through the chip stays asked for when the row is recycled") - func web_tapToLoad_rememberedAcrossReuse() async { - let source = Source() - let card = Self.webCard() - let requests = WebPreviewRequests() - let view = LinkCardView() - view.webPreviewMode = .tapToLoad - view.webPreviewRequests = requests - view.configure(with: card, source: source) - web(view)?.onShowPreview?() - - let recycled = LinkCardView() - recycled.webPreviewMode = .tapToLoad - recycled.webPreviewRequests = requests - recycled.configure(with: card, source: source) - #expect(web(recycled)?.content != .chip(host: "example.com")) - #expect(await settle { source.asked.count == 2 }) - } - - @Test("The chip strips a leading www. from the host") - func web_chip_stripsWWW() async { - let view = LinkCardView() - view.webPreviewMode = .tapToLoad - view.configure(with: Self.webCard("https://www.example.com/a"), source: Source()) - #expect(web(view)?.content == .chip(host: "example.com")) + @Test("The host strips a leading www.") + func web_host_stripsWWW() { + let view = LinkCardView(frame: CGRect(x: 0, y: 0, width: 240, height: 0)) + view.configure(with: Self.webCard("https://www.example.com/a"), source: nil) + #expect(web(view)?.hostLabel.text == "example.com") } // MARK: - Web image slot - private static let pageWithImage = LinkCard.Web.Resolved( - title: "Example Title", description: nil, - imageURL: URL(string: "https://example.com/og.png")!, host: "example.com" + private static let pageWithImage = LinkWebCardView.Page( + host: "example.com", title: "Example Title", description: nil, image: attachment ) - /// A card whose image load waits until the test hands it bytes, or nil for a failure. - private func imageCard() -> (LinkWebCardView, AsyncStream.Continuation) { - let (stream, continuation) = AsyncStream.makeStream() + private static let pageWithoutImage = LinkWebCardView.Page( + host: "example.com", title: "Example Title", description: nil, image: nil + ) + + /// A card whose image load waits until the test hands it an image, or nil for a failure. + private func imageCard() -> (LinkWebCardView, AsyncStream.Continuation) { + let (stream, continuation) = AsyncStream.makeStream() let view = LinkWebCardView(frame: CGRect(x: 0, y: 0, width: 240, height: 0)) view.loadImage = { _ in - for await data in stream { return data } + for await image in stream { return image } return nil } return (view, continuation) } - private static let pixel: Data = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 1)) - .pngData { $0.fill(CGRect(x: 0, y: 0, width: 2, height: 1)) } + private static let pixel = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 1)) + .image { $0.fill(CGRect(x: 0, y: 0, width: 2, height: 1)) } + + private func isLoading(_ view: LinkWebCardView) -> Bool { + if case .loading = view.imageSlot { return true } + return false + } @Test("A preview with an image holds the image's slot while it loads") func webImage_loading_holdsTheSlot() { let (view, _) = imageCard() view.configure(with: .preview(Self.pageWithImage)) - #expect(view.imageSlot == .loading) + #expect(isLoading(view)) } @Test("A loaded image fills the slot it held") @@ -295,7 +262,7 @@ struct LinkCardViewTests { let (view, continuation) = imageCard() view.configure(with: .preview(Self.pageWithImage)) continuation.yield(Self.pixel) - for _ in 0..<50 where view.imageSlot == .loading { await Task.yield() } + for _ in 0..<50 where isLoading(view) { await Task.yield() } guard case .loaded = view.imageSlot else { Issue.record("expected a loaded image, got \(view.imageSlot)") return @@ -309,15 +276,18 @@ struct LinkCardViewTests { view.onImageChange = { changes += 1 } view.configure(with: .preview(Self.pageWithImage)) continuation.yield(nil) - for _ in 0..<50 where view.imageSlot == .loading { await Task.yield() } - #expect(view.imageSlot == .none) + for _ in 0..<50 where isLoading(view) { await Task.yield() } + guard case .none = view.imageSlot else { + Issue.record("expected no slot, got \(view.imageSlot)") + return + } #expect(changes == 1) } @Test("An image already in memory fills the slot on the first frame (P22a)") func webImage_cached_drawsOnFirstFrame() { let (view, _) = imageCard() - view.cachedImage = { $0 == Self.pageWithImage.imageURL ? Self.pixel : nil } + view.cachedImage = { $0 == Self.attachment ? Self.pixel : nil } view.configure(with: .preview(Self.pageWithImage)) guard case .loaded = view.imageSlot else { Issue.record("expected the cached image on the first frame, got \(view.imageSlot)") @@ -330,14 +300,16 @@ struct LinkCardViewTests { let (view, _) = imageCard() view.cachedImage = { _ in nil } view.configure(with: .preview(Self.pageWithImage)) - #expect(view.imageSlot == .loading) + #expect(isLoading(view)) } @Test("A preview with no image takes no slot") func webImage_absent_takesNoSlot() { let (view, _) = imageCard() - view.configure(with: .preview(Self.page)) - #expect(view.imageSlot == .none) + view.configure(with: .preview(Self.pageWithoutImage)) + guard case .none = view.imageSlot else { + Issue.record("expected no slot, got \(view.imageSlot)") + return + } } } - diff --git a/FlipcashTests/Chat/LinkMetadataSourceTests.swift b/FlipcashTests/Chat/LinkMetadataSourceTests.swift index 56cd6c1ba..8f5dcd6c3 100644 --- a/FlipcashTests/Chat/LinkMetadataSourceTests.swift +++ b/FlipcashTests/Chat/LinkMetadataSourceTests.swift @@ -17,8 +17,7 @@ struct LinkMetadataSourceTests { @Test func parsesAnHTMLPage() async throws { let client = FakeClient(["https://example.com/": html(Self.page)]) - let state = try await source(client).metadata(for: URL(string: "https://example.com/")!) - guard case .resolved(let resolved) = state else { Issue.record("\(state)"); return } + let resolved = try #require(try await source(client).metadata(for: URL(string: "https://example.com/")!)) #expect(resolved.title == "Hello") #expect(resolved.host == "example.com") } @@ -29,46 +28,46 @@ struct LinkMetadataSourceTests { } chain["https://example.com/3"] = html(Self.page) let three = try await source(FakeClient(chain)).metadata(for: URL(string: "https://example.com/0")!) - #expect(three != .none) + #expect(three != nil) chain["https://example.com/3"] = redirect("/4") chain["https://example.com/4"] = html(Self.page) let client = FakeClient(chain) let four = try await source(client).metadata(for: URL(string: "https://example.com/0")!) - #expect(four == .none) + #expect(four == nil) #expect(client.requested == (0...3).map { "https://example.com/\($0)" }) } @Test func redirectToAnIneligibleHostIsNone() async throws { let client = FakeClient(["https://example.com/": redirect("https://localhost/")]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) #expect(client.requested == ["https://example.com/"]) } @Test func redirectToHTTPIsNone() async throws { let client = FakeClient(["https://example.com/": redirect("http://example.com/")]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) } @Test func percentEscapedHostIsNeverFetched() async throws { let client = FakeClient([:]) - #expect(try await source(client).metadata(for: URL(string: "https://ex%61mple.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://ex%61mple.com/")!) == nil) #expect(client.requested.isEmpty) } @Test func nonIdentityEncodingIsNone() async throws { let client = FakeClient(["https://example.com/": html(Self.page, extra: ["content-encoding": "gzip"])]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) } @Test func notHTMLIsNone() async throws { let client = FakeClient(["https://example.com/": PinnedResponse(status: 200, headers: ["content-type": "application/pdf"], body: Self.page, truncated: false)]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) } @Test func clientErrorIsNone() async throws { let client = FakeClient(["https://example.com/": PinnedResponse(status: 404, headers: [:], body: Data(), truncated: false)]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) } @Test func serverErrorThrows() async throws { @@ -78,37 +77,18 @@ struct LinkMetadataSourceTests { } } - @Test func flagOffThrowsWithoutFetching() async throws { - let client = FakeClient([:]) - await #expect(throws: WebLinkPreviewsDisabled.self) { - try await PinnedLinkMetadataSource(client: client, limiter: FetchLimiter(limit: 1), isEnabled: { false }) - .metadata(for: URL(string: "https://example.com/")!) - } - #expect(client.requested.isEmpty) - } - - @Test func anImageOnDiskIsReadWithoutFetching() async { - let disk = WebImageDiskCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) - let url = URL(string: "https://img.example.com/a.png")! - disk.store(Data([9]), for: url) - let client = FakeClient([:]) - #expect(await WebImageSource(client: client, limiter: FetchLimiter(limit: 1), disk: disk).data(for: url) == Data([9])) - #expect(client.requested.isEmpty) - } - - @Test func aFetchedImageIsWrittenToDiskAndAFailureIsNot() async { - let disk = WebImageDiskCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + @Test func aFetchedImageIsHeldAndAFailureIsNot() async { let good = URL(string: "https://img.example.com/a.png")! let html = URL(string: "https://img.example.com/b.png")! let client = FakeClient([ good.absoluteString: PinnedResponse(status: 200, headers: ["content-type": "image/png"], body: Data([1]), truncated: false), html.absoluteString: PinnedResponse(status: 200, headers: ["content-type": "text/html"], body: Data([2]), truncated: false), ]) - let source = WebImageSource(client: client, limiter: FetchLimiter(limit: 1), disk: disk) + let source = WebImageSource(client: client, limiter: FetchLimiter(limit: 1)) _ = await source.data(for: good) _ = await source.data(for: html) - #expect(disk.data(for: good) == Data([1])) - #expect(disk.data(for: html) == nil) + #expect(source.cached(for: good) == Data([1])) + #expect(source.cached(for: html) == nil) } @Test func imageNeedsAnImageContentTypeAndFullBody() async { @@ -147,9 +127,9 @@ struct LinkMetadataSourceTests { /// D12: a port other than 443 is never fetched, on the first URL or a hop. @Test func nonDefaultPortIsNone() async throws { let client = FakeClient(["https://example.com/": redirect("https://example.com:8443/")]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com:6379/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com:6379/")!) == nil) #expect(client.requested.isEmpty) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) #expect(client.requested == ["https://example.com/"]) } @@ -159,15 +139,13 @@ struct LinkMetadataSourceTests { "https://example.com/": redirect("/redir?to=https://a%20b"), "https://example.com/redir?to=https://a%20b": html(Self.page), ]) - guard case .resolved = try await source(client).metadata(for: URL(string: "https://example.com/")!) else { - Issue.record("expected a resolved card"); return - } + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) != nil) } @Test(arguments: ["//ex%61mple.org/x", "https:\\\\ex%61mple.org", "https://ex%61mple.org/"]) func escapedOrBackslashLocationIsNone(_ location: String) async throws { let client = FakeClient(["https://example.com/": redirect(location)]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) #expect(client.requested == ["https://example.com/"]) } @@ -175,7 +153,7 @@ struct LinkMetadataSourceTests { @Test(arguments: ["identity, gzip", "", "identity, "]) func anyNonIdentityOrEmptyEncodingTokenIsNone(_ encoding: String) async throws { let client = FakeClient(["https://example.com/": html(Self.page, extra: ["content-encoding": encoding])]) - #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == .none) + #expect(try await source(client).metadata(for: URL(string: "https://example.com/")!) == nil) } /// D18: one deadline covers every hop, and running out throws so nothing is remembered. @@ -193,7 +171,7 @@ struct LinkMetadataSourceTests { // MARK: - Helpers private func source(_ client: FakeClient) -> PinnedLinkMetadataSource { - PinnedLinkMetadataSource(client: client, limiter: FetchLimiter(limit: 4), isEnabled: { true }) + PinnedLinkMetadataSource(client: client, limiter: FetchLimiter(limit: 4)) } private func html(_ body: Data, extra: [String: String] = [:]) -> PinnedResponse { diff --git a/FlipcashTests/Chat/UserLinkCardTests.swift b/FlipcashTests/Chat/UserLinkCardTests.swift index 372c3d149..550ac24c3 100644 --- a/FlipcashTests/Chat/UserLinkCardTests.swift +++ b/FlipcashTests/Chat/UserLinkCardTests.swift @@ -95,8 +95,7 @@ import FlipcashCore cashLookup: { _ in throw CancellationError() }, mintLookup: { _ in throw CancellationError() }, groupLookup: { _ in throw CancellationError() }, - userLookup: lookup, - webLookup: { _ in throw CancellationError() } + userLookup: lookup ) #expect(await resolver.user(.username(Self.satoshi)) == nil) } @@ -110,8 +109,7 @@ import FlipcashCore cashLookup: { _ in throw CancellationError() }, mintLookup: { _ in throw CancellationError() }, groupLookup: { _ in throw CancellationError() }, - userLookup: { _ in await calls.increment(); throw Offline() }, - webLookup: { _ in throw CancellationError() } + userLookup: { _ in await calls.increment(); throw Offline() } ) #expect(await resolver.user(.username(Self.satoshi)) == nil) #expect(await resolver.user(.username(Self.satoshi)) == nil) diff --git a/FlipcashTests/Chat/WebImageDiskCacheTests.swift b/FlipcashTests/Chat/WebImageDiskCacheTests.swift deleted file mode 100644 index d77c2776e..000000000 --- a/FlipcashTests/Chat/WebImageDiskCacheTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// WebImageDiskCacheTests.swift -// FlipcashTests -// - -import Testing -import Foundation -import FlipcashCore -@testable import Flipcash - -/// Preview images on disk follow their rows' TTL and fit a fixed capacity (P22b, P22c). -@Suite("WebImageDiskCache") -struct WebImageDiskCacheTests { - - private final class Clock: @unchecked Sendable { - var now: Date - init(_ now: Date) { self.now = now } - } - - private static let a = URL(string: "https://img.example.com/a.png")! - private static let b = URL(string: "https://img.example.com/b.png")! - private static let c = URL(string: "https://img.example.com/c.png")! - - private static func directory() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - } - - @Test func storedBytesReadBack() { - let disk = WebImageDiskCache(directory: Self.directory()) - disk.store(Data([1, 2, 3]), for: Self.a) - #expect(disk.data(for: Self.a) == Data([1, 2, 3])) - #expect(disk.data(for: Self.b) == nil) - } - - @Test func anImageExactlyAtTheResolvedTTLStillReads() { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let disk = WebImageDiskCache(directory: Self.directory(), now: { clock.now }) - disk.store(Data([1]), for: Self.a) - clock.now += WebLinks.resolvedTTL - #expect(disk.data(for: Self.a) == Data([1])) - } - - @Test func anImagePastTheResolvedTTLReadsAsNilAndIsDeleted() { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let disk = WebImageDiskCache(directory: Self.directory(), now: { clock.now }) - disk.store(Data([1]), for: Self.a) - clock.now += WebLinks.resolvedTTL + 1 - #expect(disk.data(for: Self.a) == nil) - clock.now -= WebLinks.resolvedTTL - #expect(disk.data(for: Self.a) == nil) - } - - @Test func touchingAFileKeepsItAsLongAsItsRow() { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let disk = WebImageDiskCache(directory: Self.directory(), now: { clock.now }) - disk.store(Data([1]), for: Self.a) - clock.now += WebLinks.resolvedTTL - 10 - disk.touch(Self.a) - clock.now += 20 - #expect(disk.data(for: Self.a) == Data([1])) - } - - @MainActor - @Test func reRecordingARowRestampsItsImage() async throws { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let directory = Self.directory() - let disk = WebImageDiskCache(directory: directory, now: { clock.now }) - disk.store(Data([1]), for: Self.a) - let memo = LinkCardMemo(images: disk, now: { clock.now }) - clock.now += WebLinks.resolvedTTL - 10 - let restamped = clock.now - memo.recordWeb(.resolved(.init(title: "T", description: nil, imageURL: Self.a, host: "example.com")), for: "web:https://example.com/") - try await waitUntil { - let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey])) ?? [] - return files.first.flatMap { try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } == restamped - } - clock.now += 20 - #expect(disk.data(for: Self.a) == Data([1])) - } - - @Test func overCapacityDropsTheOldestFirst() { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let disk = WebImageDiskCache(directory: Self.directory(), capacity: 10, now: { clock.now }) - disk.store(Data(repeating: 1, count: 4), for: Self.a) - clock.now += 1 - disk.store(Data(repeating: 2, count: 4), for: Self.b) - clock.now += 1 - disk.store(Data(repeating: 3, count: 4), for: Self.c) - #expect(disk.data(for: Self.a) == nil) - #expect(disk.data(for: Self.b) != nil) - #expect(disk.data(for: Self.c) != nil) - } - - @Test func removeAllBeforeDropsOnlyOlderImages() { - let clock = Clock(Date(timeIntervalSince1970: 1_000_000)) - let disk = WebImageDiskCache(directory: Self.directory(), now: { clock.now }) - disk.store(Data([1]), for: Self.a) - clock.now += 10 - disk.store(Data([2]), for: Self.b) - disk.removeAll(before: clock.now - 5) - #expect(disk.data(for: Self.a) == nil) - #expect(disk.data(for: Self.b) == Data([2])) - } - - @MainActor - @Test func aRowReplacedWithADifferentImageDeletesTheOldImage() async throws { - let disk = WebImageDiskCache(directory: Self.directory()) - disk.store(Data([1]), for: Self.a) - let memo = LinkCardMemo(images: disk) - let key = "web:https://example.com/" - memo.recordWeb(.resolved(.init(title: "T", description: nil, imageURL: Self.a, host: "example.com")), for: key) - memo.recordWeb(.none, for: key) - try await waitUntil { disk.data(for: Self.a) == nil } - } - - @MainActor - @Test func aRowReRecordedWithTheSameImageKeepsIt() async throws { - let disk = WebImageDiskCache(directory: Self.directory()) - disk.store(Data([1]), for: Self.a) - let memo = LinkCardMemo(images: disk) - let key = "web:https://example.com/" - let page = LinkCard.Web.Resolved(title: "T", description: nil, imageURL: Self.a, host: "example.com") - memo.recordWeb(.resolved(page), for: key) - memo.recordWeb(.resolved(page), for: key) - try await Task.sleep(for: .milliseconds(50)) - #expect(disk.data(for: Self.a) == Data([1])) - } -} diff --git a/FlipcashTests/Chat/WebLinkBareCardTests.swift b/FlipcashTests/Chat/WebLinkBareCardTests.swift index f70eeee8a..9ed07b72b 100644 --- a/FlipcashTests/Chat/WebLinkBareCardTests.swift +++ b/FlipcashTests/Chat/WebLinkBareCardTests.swift @@ -4,37 +4,19 @@ import UIKit @testable import FlipcashCore @testable import FlipcashUI -/// A message that is only its web link draws its preview bare, in place of its bubble, once the -/// preview draws; until then, and whenever there is no preview, it keeps its text bubble. +/// A message that is only its web link draws the sender's preview bare, in place of its bubble, +/// from the first frame; text beside the link keeps the card inside the bubble. @MainActor @Suite("Web link bare card") struct WebLinkBareCardTests { - /// Answers from `answers` at paint time and lets the test push later states by hand. - private final class Source: LinkCardSource { - var answers: [LinkCard: LinkCard.State] = [:] - private var continuations: [LinkCard: AsyncStream.Continuation] = [:] - - func known(_ card: LinkCard) -> LinkCard.State? { answers[card] } - - func states(for card: LinkCard) -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream() - continuations[card] = continuation - return stream - } - - func yield(_ state: LinkCard.State, for card: LinkCard) { - continuations[card]?.yield(state) - } - } - private static let link = "https://example.com/a" - private static let page = LinkCard.Web.Resolved( + private static let page = MessageLinkPreview( + url: link, title: "Example Title", description: nil, - imageURL: nil, - host: "example.com" + image: nil ) /// A text message whose web card is the link at `prefix.count` in `prefix + link + suffix`. @@ -48,34 +30,15 @@ struct WebLinkBareCardTests { sender: .other, linkPreview: LinkPreview( links: [DetectedLink(range: range, url: url)], - card: .web(LinkCard.Web(url: url, range: range)) + card: .web(LinkCard.Web(url: url, range: range, page: page)) ) ) } - private static func card(of message: ChatMessage) -> LinkCard { - message.linkPreview!.card! - } - - private func bubble(_ source: Source, mode: WebLinkPreviewMode = .automatic) -> LinkableBubbleView { - let bubble = LinkableBubbleView() - bubble.linkCardSource = source - bubble.webPreviewMode = mode - return bubble - } - private func textView(_ bubble: LinkableBubbleView) -> UITextView? { bubble.descendants(of: UITextView.self).first } - private func settle(until condition: () -> Bool) async -> Bool { - for _ in 0..<200 { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(5)) - } - return condition() - } - // MARK: - Model @Test("Only the link, give or take whitespace and punctuation around it") @@ -106,166 +69,26 @@ struct WebLinkBareCardTests { // MARK: - Bubble - @Test("A remembered preview draws bare from the first frame") - func rememberedPreview_isBareAtOnce() { - let source = Source() - let message = Self.message() - source.answers[Self.card(of: message)] = .web(.resolved(Self.page)) - let bubble = bubble(source) - bubble.configure(with: message) - - #expect(bubble.isBare) - #expect(textView(bubble)?.isHidden == true) - } - - @Test("While loading a link-only row draws its placeholder bare, then fills it in place") - func loadingThenResolved_fillsThePlaceholder() async { - let source = Source() - let message = Self.message() - let bubble = bubble(source) - var changes = 0 - bubble.onBareChange = { changes += 1 } - bubble.configure(with: message) + @Test("A sent preview draws bare from the first frame") + func sentPreview_isBareAtOnce() { + let bubble = LinkableBubbleView() + bubble.configure(with: Self.message()) #expect(bubble.isBare) #expect(textView(bubble)?.isHidden == true) - - source.yield(.web(.resolved(Self.page)), for: Self.card(of: message)) - #expect(await settle { if case .preview = bubble.cardView.webView.content { true } else { false } }) - #expect(bubble.isBare) - #expect(changes == 0) - } - - @Test("A placeholder whose page has nothing puts the text bubble back") - func loadingThenNone_restoresTheBubble() async { - let source = Source() - let message = Self.message() - let bubble = bubble(source) - bubble.configure(with: message) - #expect(bubble.isBare) - - source.yield(.web(.none), for: Self.card(of: message)) - #expect(await settle { !bubble.isBare }) - #expect(textView(bubble)?.attributedText.string == Self.link) - } - - @Test("A remembered nothing draws the text bubble from the first frame, no placeholder") - func rememberedNone_skipsThePlaceholder() { - let source = Source() - let message = Self.message() - source.answers[Self.card(of: message)] = .web(.none) - let bubble = bubble(source) - bubble.configure(with: message) - - #expect(!bubble.isBare) - #expect(bubble.cardView.webView.content == .nothing) - } - - @Test("A preview that goes away puts the text bubble back, never an empty row") - func previewGone_restoresTheBubble() async { - let source = Source() - let message = Self.message() - source.answers[Self.card(of: message)] = .web(.resolved(Self.page)) - let bubble = bubble(source) - bubble.configure(with: message) - #expect(bubble.isBare) - - source.yield(.web(.none), for: Self.card(of: message)) - #expect(await settle { !bubble.isBare }) - #expect(textView(bubble)?.isHidden == false) - #expect(textView(bubble)?.attributedText.string == Self.link) } @Test("Text beside the link keeps the card inside the bubble") func textAndLink_staysInTheBubble() { - let source = Source() - let message = Self.message(prefix: "look ") - source.answers[Self.card(of: message)] = .web(.resolved(Self.page)) - let bubble = bubble(source) - bubble.configure(with: message) + let bubble = LinkableBubbleView() + bubble.configure(with: Self.message(prefix: "look ")) #expect(!bubble.isBare) #expect(textView(bubble)?.isHidden == false) } - @Test("Text beside a loading link draws nothing under it") - func textAndLoadingLink_drawsNoPlaceholder() { - let source = Source() - let message = Self.message(prefix: "look ") - let bubble = bubble(source) - bubble.configure(with: message) - - #expect(!bubble.isBare) - #expect(bubble.cardView.webView.content == .nothing) - } - - @Test("A link-only row at its chip keeps its bubble") - func chip_keepsTheBubble() { - let source = Source() - let message = Self.message() - let bubble = bubble(source, mode: .tapToLoad) - bubble.configure(with: message) - - #expect(!bubble.isBare) - } - - @Test("Tapping a link-only row's chip draws the placeholder while it loads") - func chipTapped_drawsThePlaceholder() { - let source = Source() - let message = Self.message() - let bubble = bubble(source, mode: .tapToLoad) - bubble.configure(with: message) - #expect(!bubble.isBare) - - bubble.cardView.webView.chip.sendActions(for: .touchUpInside) - - #expect(bubble.isBare) - #expect(bubble.cardView.webView.content == .placeholder(host: "example.com", url: URL(string: Self.link)!)) - } - - @Test("The placeholder shows the link's host without www, holds the image slot, and reads as the link") - func placeholder_content() throws { - let url = try #require(URL(string: "https://www.example.com/a")) - let view = LinkWebCardView(frame: CGRect(x: 0, y: 0, width: 250, height: 300)) - view.configure(with: .placeholder(host: "example.com", url: url)) - - #expect(view.hostLabel.text == "example.com") - #expect(view.imageSlot == .loading) - #expect(view.titleLabel.isHidden) - #expect(view.descriptionLabel.isHidden) - let panel = try #require(view.hostLabel.superview?.superview) - #expect(panel.isAccessibilityElement) - #expect(panel.accessibilityLabel == url.absoluteString) - - view.configure(with: .preview(Self.page)) - #expect(!panel.isAccessibilityElement) - #expect(!view.titleLabel.isHidden) - #expect(view.imageSlot == .none) - } - // MARK: - Buttons keep their single tap - private func laidOut(_ card: LinkCard, source: Source, mode: WebLinkPreviewMode = .automatic) -> LinkCardView { - let view = LinkCardView(frame: CGRect(x: 0, y: 0, width: 250, height: 300)) - view.webPreviewMode = mode - view.configure(with: card, source: source) - view.setNeedsLayout() - view.layoutIfNeeded() - return view - } - - @Test("The web chip is a button; the rest of the card is not") - func webChip_isAButton() throws { - let message = Self.message() - let view = laidOut(Self.card(of: message), source: Source(), mode: .tapToLoad) - let chip = try #require(view.descendants(of: LinkWebCardView.self).first?.chip) - #expect(!chip.isHidden) - - let center = view.convert(CGPoint(x: chip.bounds.midX, y: chip.bounds.midY), from: chip) - #expect(view.hasButton(at: center)) - #expect(!view.hasButton(at: CGPoint(x: view.bounds.maxX - 1, y: view.bounds.maxY - 1))) - } - @Test("The cash card's claim pill is a button; its body is not") func cashPill_isAButton() { let link = "https://send.flipcash.com/c/#/e=abc" @@ -274,7 +97,10 @@ struct WebLinkBareCardTests { entropy: "abc", range: NSRange(location: 0, length: (link as NSString).length) )) - let view = laidOut(card, source: Source()) + let view = LinkCardView(frame: CGRect(x: 0, y: 0, width: 250, height: 300)) + view.configure(with: card, source: nil) + view.setNeedsLayout() + view.layoutIfNeeded() let points = stride(from: 0.0, to: view.bounds.width, by: 4).flatMap { x in stride(from: 0.0, to: view.bounds.height, by: 4).map { CGPoint(x: x, y: $0) } @@ -288,13 +114,8 @@ struct WebLinkBareCardTests { @Test("A bare web card runs to the bubble's full width") func bareCell_fillsTheWidth() { - let source = Source() - let message = Self.message() - source.answers[Self.card(of: message)] = .web(.resolved(Self.page)) let cell = ChatLinkMessageCell(frame: CGRect(x: 0, y: 0, width: 390, height: 400)) - cell.linkCardSource = source - cell.webPreviewMode = .automatic - cell.configure(with: message, maxWidth: 250) + cell.configure(with: Self.message(), maxWidth: 250) cell.contentView.setNeedsLayout() cell.contentView.layoutIfNeeded() diff --git a/FlipcashTests/Chat/WebLookupVectorTests.swift b/FlipcashTests/Chat/WebLookupVectorTests.swift index 13b175b41..4451cb779 100644 --- a/FlipcashTests/Chat/WebLookupVectorTests.swift +++ b/FlipcashTests/Chat/WebLookupVectorTests.swift @@ -10,7 +10,7 @@ import Testing import FlipcashCore @testable import Flipcash -/// Runs every `lookups` vector in link_metadata.json through the real source and response reader. +/// Runs the `lookups` vectors in link_metadata.json through the real source and response reader. @Suite("WebLookupVectors") struct WebLookupVectorTests { @@ -53,33 +53,33 @@ struct WebLookupVectorTests { private struct Fixture: Decodable { let lookups: [Lookup] } + /// Every lookup except those that start from a remembered home answer: the sender fetches + /// fresh each time, so it holds none. static let lookups: [Lookup] = { let bundle = Bundle(for: BundleToken.self) guard let url = bundle.url(forResource: "link_metadata", withExtension: "json"), let data = try? Data(contentsOf: url), let fixture = try? JSONDecoder().decode(Fixture.self, from: data) else { return [] } - return fixture.lookups + return fixture.lookups.filter { $0.cachedBefore.isEmpty } }() @Test func fixtureIsLoaded() { - #expect(Self.lookups.count == 20) + #expect(Self.lookups.count == 18) } @Test(arguments: lookups) func looksUpLikeTheFixture(_ lookup: Lookup) async throws { let client = ScriptedClient(lookup.responses) - let homes = HomeAnswers(lookup.cachedBefore) - let source = PinnedLinkMetadataSource(client: client, limiter: FetchLimiter(limit: 4), isEnabled: { true }) + let source = PinnedLinkMetadataSource(client: client, limiter: FetchLimiter(limit: 4)) let url = try #require(URL(string: lookup.url)) - // The feed's part: a returned answer is remembered under the link's key, a failure is not. - let state: LinkCard.Web.State? + let page: LinkCard.Web.Resolved? + var failed = false do { - let answer = try await source.metadata(for: url, homes: homes) - await homes.write(answer, key: Self.key(url)) - state = answer + page = try await source.metadata(for: url) } catch { - state = nil + page = nil + failed = true } #expect(client.requested == lookup.fetches) @@ -87,62 +87,18 @@ struct WebLookupVectorTests { switch lookup.result { case "failure": - #expect(state == nil) + #expect(failed) case "none": - #expect(state == LinkCard.Web.State.none) + #expect(!failed) + #expect(page == nil) default: - #expect(Self.card(state) == lookup.card) + #expect(Self.card(page) == lookup.card) } - - let written = Dictionary(await homes.written.map { ($0.key, $0.state) }, uniquingKeysWith: { $1 }) - let expected = Dictionary(lookup.cached.map { (Self.key(URL(string: $0.key)!), $0) }, uniquingKeysWith: { $1 }) - #expect(Set(written.keys) == Set(expected.keys)) - for (key, entry) in expected { - guard let state = written[key] else { continue } - #expect(Self.card(state) == entry.card, "\(key)") - #expect((state == LinkCard.Web.State.none) == (entry.state == "none"), "\(key)") - } - } - - static func key(_ url: URL) -> String { LinkCard.webKey(url) } - - static func card(_ state: LinkCard.Web.State?) -> Card? { - guard case .resolved(let resolved) = state else { return nil } - return Card(title: resolved.title, description: resolved.description, - imageUrl: resolved.imageURL?.absoluteString, host: resolved.host) - } -} - -/// The remembered home answers before a lookup, and every answer written during it, in order. -private actor HomeAnswers: WebHomeAnswers { - private var held: [String: LinkCard.Web.State] = [:] - private(set) var written: [(key: String, state: LinkCard.Web.State)] = [] - - init(_ entries: [WebLookupVectorTests.Entry]) { - for entry in entries { - let state: LinkCard.Web.State = switch entry.card { - case .some(let card): - .resolved(LinkCard.Web.Resolved( - title: card.title, description: card.description, - imageURL: card.imageUrl.flatMap(URL.init(string:)), host: card.host - )) - case .none: .none - } - held[WebLookupVectorTests.key(URL(string: entry.key)!)] = state - } - } - - func answer(forHome home: URL) -> LinkCard.Web.State? { - held[WebLookupVectorTests.key(home)] - } - - func record(_ state: LinkCard.Web.State, forHome home: URL) { - write(state, key: WebLookupVectorTests.key(home)) } - func write(_ state: LinkCard.Web.State, key: String) { - held[key] = state - written.append((key, state)) + static func card(_ page: LinkCard.Web.Resolved?) -> Card? { + page.map { Card(title: $0.title, description: $0.description, + imageUrl: $0.imageURL?.absoluteString, host: $0.host) } } } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 93b56ba84..8a606db6f 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -544,6 +544,30 @@ struct DatabaseConversationsTests { #expect(try database.getConversationMessages(conversationID: id) == [message]) } + @Test("A text message reloads with the link preview its sender built, image included") + func linkPreviewRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let preview = MessageLinkPreview( + url: "https://example.com/a", title: "Example", description: nil, + image: MediaAttachment( + blobID: BlobID(data: Data(repeating: 9, count: 16)), width: 300, height: 400, blurhash: nil, + sealed: SealedBlob(senderID: otherID, plaintextSize: 4321) + ) + ) + let message = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .text("see https://example.com/a"), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + linkPreview: preview + ) + + try database.upsertConversationMessages([message], conversationID: id) + + #expect(try database.getConversationMessages(conversationID: id).first?.linkPreview == preview) + } + @Test("A redacted media message reloads still redacted, so its bytes stay unfetched after a relaunch") func redactedMediaRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/LinkPreviewStoreTests.swift b/FlipcashTests/LinkPreviewStoreTests.swift deleted file mode 100644 index 0cae43e56..000000000 --- a/FlipcashTests/LinkPreviewStoreTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// LinkPreviewStoreTests.swift -// FlipcashTests -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Foundation -import Testing -import FlipcashStore -@testable import Flipcash - -@Suite("LinkPreview store") -struct LinkPreviewStoreTests { - - private let epoch = Date(timeIntervalSinceReferenceDate: 1_000_000) - - @Test("Upsert then read back returns the stored row") - func upsertAndRead() throws { - let database = try Database.mock - let json = Data("a".utf8) - try database.upsertLinkPreview(key: "k", json: json, updatedAt: epoch) - - let rows = try database.linkPreviews(since: .distantPast) - #expect(rows == [LinkPreviewRow(key: "k", json: json, updatedAt: epoch)]) - } - - @Test("Upserting the same key replaces the row") - func upsertReplaces() throws { - let database = try Database.mock - try database.upsertLinkPreview(key: "k", json: Data("a".utf8), updatedAt: epoch) - try database.upsertLinkPreview(key: "k", json: Data("b".utf8), updatedAt: epoch.addingTimeInterval(10)) - - let rows = try database.linkPreviews(since: .distantPast) - #expect(rows.count == 1) - #expect(rows[0].json == Data("b".utf8)) - #expect(rows[0].updatedAt == epoch.addingTimeInterval(10)) - } - - @Test("linkPreviews(since:) excludes older rows") - func sinceExcludesOlder() throws { - let database = try Database.mock - try database.upsertLinkPreview(key: "old", json: Data(), updatedAt: epoch) - try database.upsertLinkPreview(key: "edge", json: Data(), updatedAt: epoch.addingTimeInterval(100)) - try database.upsertLinkPreview(key: "new", json: Data(), updatedAt: epoch.addingTimeInterval(200)) - - let keys = try database.linkPreviews(since: epoch.addingTimeInterval(100)).map(\.key) - #expect(Set(keys) == ["edge", "new"]) - } - - @Test("deleteLinkPreviews(before:) removes only older rows") - func deleteBefore() throws { - let database = try Database.mock - try database.upsertLinkPreview(key: "old", json: Data(), updatedAt: epoch) - try database.upsertLinkPreview(key: "edge", json: Data(), updatedAt: epoch.addingTimeInterval(100)) - try database.upsertLinkPreview(key: "new", json: Data(), updatedAt: epoch.addingTimeInterval(200)) - - try database.deleteLinkPreviews(before: epoch.addingTimeInterval(100)) - - let keys = try database.linkPreviews(since: .distantPast).map(\.key) - #expect(Set(keys) == ["edge", "new"]) - } -} diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index c95aa2e2f..5130e1baf 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -15,6 +15,7 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con let conversationID: ConversationID let text: String let repliedTo: MessageID? + var linkPreview: MessageLinkPreview? = nil } /// One `sendMediaMessage` call. struct SentMedia: Sendable, Equatable { @@ -42,6 +43,7 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con let messageID: MessageID let text: String let repliedTo: MessageID? + var linkPreview: MessageLinkPreview? = nil let expectedEventSequence: UInt64 } @@ -408,15 +410,15 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con return olderMessages } - 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 { lock.withLock { - _sent.append(Sent(conversationID: conversationID, text: text, repliedTo: repliedTo)) + _sent.append(Sent(conversationID: conversationID, text: text, repliedTo: repliedTo, linkPreview: linkPreview)) _sentClientIDs.append(clientMessageID) } if let error = sendError { throw error } return sendResult ?? ConversationMessage( id: MessageID(value: 1), senderID: nil, content: .text(text), - date: Date(timeIntervalSince1970: 0), unreadSeq: 0, repliedTo: repliedTo + date: Date(timeIntervalSince1970: 0), unreadSeq: 0, repliedTo: repliedTo, linkPreview: linkPreview ) } @@ -447,9 +449,9 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con ) } - 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 { lock.withLock { - _edited.append(Edited(conversationID: conversationID, messageID: messageID, text: text, repliedTo: repliedTo, expectedEventSequence: expectedEventSequence)) + _edited.append(Edited(conversationID: conversationID, messageID: messageID, text: text, repliedTo: repliedTo, linkPreview: linkPreview, expectedEventSequence: expectedEventSequence)) } if let editError { throw editError } guard let editResult else { throw ErrorEditMessage.unknown } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift index c2a5872b7..e3466f69f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift @@ -46,17 +46,11 @@ public final class ChatLinkMessageCell: ChatColumnCell { didSet { bubble.linkCardSource = linkCardSource } } - /// Whether the bubble's web card asks for its page as it draws or waits for its chip. Set - /// before ``configure(with:maxWidth:authorImageData:quoteThumbnail:)``. - var webPreviewMode: WebLinkPreviewMode { - get { bubble.webPreviewMode } - set { bubble.webPreviewMode = newValue } - } - - /// The transcript's record of the web previews its viewer asked for, shared by every row. - var webPreviewRequests: WebPreviewRequests { - get { bubble.webPreviewRequests } - set { bubble.webPreviewRequests = newValue } + /// Where the bubble's web card gets its image. Set before + /// ``configure(with:maxWidth:authorImageData:quoteThumbnail:)``. + var webImages: WebCardImages? { + get { bubble.webImages } + set { bubble.webImages = newValue } } var bubbleView: LinkableBubbleView { bubble } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift index 32dfa42ce..d4968f188 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift @@ -30,13 +30,33 @@ public enum ChatMediaImageSource { /// minted per fetch, so keying on it would re-download the same photo every session. An /// encrypted blob is decrypted before Kingfisher decodes it. public static func source(blobID: BlobID?, location: ChatMediaLocation) -> Source { - let cacheKey = blobID.map { "chat-media-\($0.description)" } ?? location.url.absoluteString + let cacheKey = blobID.map(Self.cacheKey(for:)) ?? location.url.absoluteString guard let decrypt = location.decrypt else { return .network(KF.ImageResource(downloadURL: location.url, cacheKey: cacheKey)) } return .provider(DecryptingProvider(cacheKey: cacheKey, url: location.url, decrypt: decrypt)) } + /// The photo at `location`, from the cache when held, or nil when it fails to load or decrypt. + @MainActor + public static func image(blobID: BlobID?, location: ChatMediaLocation) async -> UIImage? { + let source = source(blobID: blobID, location: location) + guard let result = try? await KingfisherManager.shared.retrieveImage(with: source, options: options()) else { + return nil + } + persist(.success(result)) + return result.image + } + + /// The photo for `blobID` if already held in memory, without loading anything. + public static func cachedImage(blobID: BlobID) -> UIImage? { + cache.retrieveImageInMemoryCache(forKey: cacheKey(for: blobID)) + } + + private static func cacheKey(for blobID: BlobID) -> String { + "chat-media-\(blobID.description)" + } + /// Whether a failed load was an encrypted blob that will never decrypt, rather than a fetch /// that may succeed later. public static func isUndecryptable(_ error: KingfisherError) -> Bool { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift index 09d93989b..3639bb177 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift @@ -72,7 +72,18 @@ public final class ChatMediaURLResolver { /// to draw or the fetch failed. Follows the same rules as the callback form. public func thumbnailLocation(for kind: ChatQuote.Kind, canReact: Bool) async -> ChatMediaLocation? { guard let (blobID, sealed) = Self.thumbnailBlob(for: kind, canReact: canReact) else { return nil } - return await withCheckedContinuation { continuation in + return await location(of: blobID, sealed: sealed) + } + + /// Where to draw `attachment` from, waiting for the fetch; nil when it has no blob or the fetch + /// failed. A link preview's image goes through here, sharing the photo rows' cache. + public func location(for attachment: MediaAttachment) async -> ChatMediaLocation? { + guard let blobID = attachment.blobID else { return nil } + return await location(of: blobID, sealed: attachment.sealed) + } + + private func location(of blobID: BlobID, sealed: SealedBlob?) async -> ChatMediaLocation? { + await withCheckedContinuation { continuation in var resumed = false let immediate = resolve(blobID, sealed: sealed) { location in guard !resumed else { return } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 676d9097b..b19274d51 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -236,12 +236,6 @@ public final class ChatScreenViewController: UIViewController { set { transcript.linkCardSource = newValue } } - /// ``ChatViewController/webPreviewMode``. - public var webPreviewMode: WebLinkPreviewMode { - get { transcript.webPreviewMode } - set { transcript.webPreviewMode = newValue } - } - /// ``ChatViewController/updateAction``. public var updateAction: ChatBubbleAction? { get { transcript.updateAction } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift index d648f3963..7fd3f2dfb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -164,7 +164,7 @@ public final class ChatShareProfileCell: ChatColumnCell { private static func userState(_ state: LinkCard.State?) -> LinkCard.User.State? { switch state { case .user(let user): user - case .cash, .token, .group, .web, nil: nil + case .cash, .token, .group, nil: nil } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 511ed0aed..e7504d275 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -82,20 +82,6 @@ public final class ChatViewController: UICollectionViewController { /// carries no resolution state and a lookup landing cannot change a row's diff. public weak var linkCardSource: (any LinkCardSource)? - /// Whether this transcript's web cards ask for their pages as they draw or wait for the chip. - /// A change, such as the viewer joining the group, redraws the rows on screen. - public var webPreviewMode: WebLinkPreviewMode = .tapToLoad { - didSet { - guard webPreviewMode != oldValue, isViewLoaded else { return } - for cell in collectionView.visibleCells where cell is ChatLinkMessageCell { - guard let indexPath = collectionView.indexPath(for: cell), - items.indices.contains(indexPath.item), - case .message(let message) = items[indexPath.item] else { continue } - configure(cell, with: message) - } - } - } - /// The capsule an `.updateApp` bubble draws, or nil while no newer version is known. A change /// redraws the rows on screen. public var updateAction: ChatBubbleAction? { @@ -111,9 +97,6 @@ public final class ChatViewController: UICollectionViewController { } } - /// The web previews this transcript's viewer asked for through the chip, shared by every row. - private let webPreviewRequests = WebPreviewRequests() - /// Called when the "Encrypted" marker is tapped; nil leaves it inert. public var onEncryptionMarkerTap: (() -> Void)? @@ -684,8 +667,7 @@ public final class ChatViewController: UICollectionViewController { case let cell as ChatLinkMessageCell: // Before `configure`, which is where the card subscribes. cell.linkCardSource = linkCardSource - cell.webPreviewMode = webPreviewMode - cell.webPreviewRequests = webPreviewRequests + cell.webImages = mediaURLResolver.map(WebCardImages.init(resolver:)) cell.configure( with: message, maxWidth: maxWidth, diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardSource.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardSource.swift index b17707d5d..d55c8df48 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardSource.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardSource.swift @@ -40,46 +40,5 @@ public protocol LinkCardSource: AnyObject { /// next row to show that link is about to want. func states(for card: LinkCard) -> AsyncStream - /// The bytes of a web card's preview image, or nil when it cannot be fetched. - func webImage(for url: URL) async -> Data? - - /// The bytes of a web card's preview image if already held in memory, without suspending. - /// - /// Lets a card draw an image it has shown before on its first frame instead of the loading slot. - func cachedWebImage(for url: URL) -> Data? -} - -extension LinkCardSource { - - /// The bytes of a web card's preview image, or nil when it cannot be fetched. - /// - /// Nil by default, so a source with no web lookup draws every web card without its image. - public func webImage(for url: URL) async -> Data? { nil } - - /// Nil by default: nothing is held, so every image goes through ``webImage(for:)``. - public func cachedWebImage(for url: URL) -> Data? { nil } } -/// Whether a web card in this transcript looks its page up on its own or waits to be asked. -/// -/// Never stored on the card or in the memo: a cached answer must not say who asked for it. -public enum WebLinkPreviewMode: Sendable, Equatable { - /// Members of the chat, and anyone in a DM: the card asks as soon as it draws. - case automatic - /// A viewer outside the group: the card shows a chip and asks only once it is tapped. - case tapToLoad -} - -/// The web links a transcript's viewer has asked to preview through the chip. -/// -/// Held by the transcript and shared by its rows, so a row recycled after the tap does not show the -/// chip again. Never persisted, and never part of the card or the memo. -@MainActor -public final class WebPreviewRequests { - private var urls: Set = [] - - public init() {} - - public func insert(_ url: URL) { urls.insert(url) } - public func contains(_ url: URL) -> Bool { urls.contains(url) } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardView.swift index 74872feac..38b07056d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkCardView.swift @@ -56,25 +56,20 @@ final class LinkCardView: UIView { /// The same, for the web card, which takes no height while it draws nothing. private var webBottom: NSLayoutConstraint! - /// Whether a web card asks for its page as it draws or waits for its chip. Set before - /// ``configure(with:source:)``. - var webPreviewMode: WebLinkPreviewMode = .tapToLoad - /// The web links this transcript's viewer has already asked to preview, so a recycled row does - /// not put the chip back up in front of an answer they asked for. - var webPreviewRequests = WebPreviewRequests() - /// Where the web card's image comes from: the source the card was last configured with. - private weak var imageSource: (any LinkCardSource)? - - /// Whether a web card that is still waiting on its page draws the placeholder: set for a message - /// that is only its link, before ``configure(with:source:)``. - var webShowsPlaceholder = false - - /// Whether a web card's page preview, or its placeholder, is drawn: what widens the bubble around - /// it, and what a link-only message is drawn bare for. + /// Where a web card's image comes from. Set before ``configure(with:source:)``. + var webImages: WebCardImages? { + didSet { + webView.loadImage = webImages?.load + webView.cachedImage = webImages?.cached + } + } + + /// Whether a web card's page preview is drawn: what widens the bubble around it, and what a + /// link-only message is drawn bare for. var drawsWebPreview: Bool { switch webView.content { - case .preview, .placeholder: true - case .nothing, .chip: false + case .preview: true + case .nothing: false } } @@ -87,12 +82,11 @@ final class LinkCardView: UIView { var onCardButton: (() -> Void)? /// Whether `point`, in this view's coordinates, is on one of the card's own buttons: the cash - /// card's "Claim" pill, the group card's "View" or the web card's "Show preview" chip. These - /// act on the first tap; the rest of a card takes the transcript's double tap. + /// card's "Claim" pill or the group card's "View". These act on the first tap; the rest of a + /// card takes the transcript's double tap. func hasButton(at point: CGPoint) -> Bool { if !cashView.isHidden, cashView.hasButton(at: convert(point, to: cashView)) { return true } if !groupView.isHidden, groupView.hasButton(at: convert(point, to: groupView)) { return true } - if !webView.isHidden, webView.hasButton(at: convert(point, to: webView)) { return true } return false } @@ -217,38 +211,23 @@ final class LinkCardView: UIView { /// /// With no source the card paints unresolved and asks nothing, which is what a preview or a /// test that does not care about resolution gets. + /// + /// A web card asks nothing either way: its page came with the message. func configure(with card: LinkCard, source: (any LinkCardSource)?) { subscription?.cancel() subscription = nil - imageSource = source - webView.onShowPreview = nil + if case .web = card { + show(card, state: nil, loading: false) + return + } let known = source?.known(card) show(card, state: known, loading: known == nil && source != nil) guard let source else { return } - // A held answer costs no request, so it paints in either mode; the chip stands in for - // the lookup until the viewer asks. - if case .web(let web) = card, known == nil, awaitsChip(web) { - webView.onShowPreview = { [weak self, weak source] in - guard let self, let source else { return } - webPreviewRequests.insert(web.url) - configure(with: card, source: source) - onHeightChange?() - } - return - } subscribe(to: card, source: source) } - /// Whether `web` waits behind the chip: a viewer outside the group who has not asked for it. - private func awaitsChip(_ web: LinkCard.Web) -> Bool { - switch webPreviewMode { - case .automatic: false - case .tapToLoad: !webPreviewRequests.contains(web.url) - } - } - private func subscribe(to card: LinkCard, source: any LinkCardSource) { // Subscribed here rather than inside the task: a task body does not run until the caller // suspends, and an answer that lands in that gap would be yielded to nobody. @@ -298,57 +277,38 @@ final class LinkCardView: UIView { cashView.isHidden = true tokenView.isHidden = true setSized(.web) - webView.loadImage = { [weak self] url in await self?.imageSource?.webImage(for: url) } - webView.cachedImage = { [weak self] url in self?.imageSource?.cachedWebImage(for: url) } - let placeholder = webShowsPlaceholder && loading ? Self.displayHost(of: web) : nil - return webView.configure(with: Self.webContent( - state, chip: chipHost(web), placeholder: placeholder.map { ($0, web.url) } - )) + return webView.configure(with: .preview(Self.page(of: web))) } } - /// The chip's host when `web` waits behind the chip, else nil. - private func chipHost(_ web: LinkCard.Web) -> String? { - guard awaitsChip(web) else { return nil } - return Self.displayHost(of: web) + /// The page as the web card draws it. Title and description are clamped by the labels, not + /// cleaned again: the sender cleaned them, and the recipient draws what was sent. + static func page(of web: LinkCard.Web) -> LinkWebCardView.Page { + LinkWebCardView.Page( + host: displayHost(of: web.url), + title: web.page.title, + description: web.page.description, + image: web.page.image + ) } - /// The link's own host as the chip and placeholder show it, without its "www.". - private static func displayHost(of web: LinkCard.Web) -> String { - let host = WebLinks.host(of: web.url) ?? web.url.host() ?? "" + /// The link's own host as the card shows it, without its "www.". + private static func displayHost(of url: URL) -> String { + let host = url.host() ?? "" return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host } - /// `.none` draws nothing. Loading draws the chip while it waits to be tapped, the placeholder for - /// a link-only message, and otherwise nothing. - private static func webContent( - _ state: LinkCard.State?, - chip host: String?, - placeholder: (host: String, url: URL)? - ) -> LinkWebCardView.Content { - switch state { - case .web(.resolved(let page)): - return .preview(page) - case .web(.none): - return .nothing - case .cash, .token, .group, .user, nil: - if let host { return .chip(host: host) } - if let placeholder { return .placeholder(host: placeholder.host, url: placeholder.url) } - return .nothing - } - } - private static func cashState(_ state: LinkCard.State?) -> LinkCard.Cash.State { switch state { case .cash(let cash): cash - case .token, .group, .user, .web, nil: .unresolved + case .token, .group, .user, nil: .unresolved } } private static func tokenState(_ state: LinkCard.State?) -> LinkCard.Token.State { switch state { case .token(let token): token - case .cash, .group, .user, .web, nil: .unresolved + case .cash, .group, .user, nil: .unresolved } } @@ -356,7 +316,7 @@ final class LinkCardView: UIView { private static func groupState(_ state: LinkCard.State?) -> LinkCard.Group.State? { switch state { case .group(let group): group - case .cash, .token, .user, .web, nil: nil + case .cash, .token, .user, nil: nil } } @@ -364,7 +324,7 @@ final class LinkCardView: UIView { private static func userState(_ state: LinkCard.State?) -> LinkCard.User.State? { switch state { case .user(let user): user - case .cash, .token, .group, .web, nil: nil + case .cash, .token, .group, nil: nil } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkWebCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkWebCardView.swift index c40dc2bca..2529dcf56 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkWebCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkWebCardView.swift @@ -10,31 +10,41 @@ import UIKit import FlipcashCore /// The web card: an outside page's image, host, title and description, drawn under the text inside -/// its bubble. Or, for a viewer outside the group, the "Show preview" chip that asks for it. +/// its bubble, from the preview its sender attached. /// -/// It draws nothing at all while the page is loading or had nothing to show, and then takes no -/// height, so the bubble stays the text bubble it would have been. The exception is a message that -/// is only its link: there the card stands in for the hidden text while it loads, as a placeholder. +/// It draws nothing at all for a message without a preview, and then takes no height, so the bubble +/// stays the text bubble it would have been. final class LinkWebCardView: UIView { /// What the card is drawing. enum Content: Equatable { case nothing - case chip(host: String) - case preview(LinkCard.Web.Resolved) - /// A link-only message's card while its page loads: the link's own host over shimmering - /// stand-ins for the image and title. - case placeholder(host: String, url: URL) + case preview(Page) + } + + /// The page as the card draws it. + struct Page: Equatable { + /// The link's own host, without its "www.". + let host: String + let title: String + let description: String? + /// The page's image, a chat blob like any photo. + let image: MediaAttachment? + + init(host: String, title: String, description: String?, image: MediaAttachment?) { + self.host = host + self.title = title + self.description = description + self.image = image + } } - /// Called when the chip is tapped. - var onShowPreview: (() -> Void)? /// Called when the drawn preview is tapped. var onTap: (() -> Void)? - /// Fetches the preview image's bytes; nil draws the preview without its image. - var loadImage: ((URL) async -> Data?)? - /// The preview image's bytes if already in memory; a hit draws on this frame, with no loading slot. - var cachedImage: ((URL) -> Data?)? + /// Loads the preview image; nil draws the preview without its image. + var loadImage: ((MediaAttachment) async -> UIImage?)? + /// The preview image if already in memory; a hit draws on this frame, with no loading slot. + var cachedImage: ((MediaAttachment) -> UIImage?)? private(set) var content: Content = .nothing @@ -50,18 +60,7 @@ final class LinkWebCardView: UIView { let hostLabel = UILabel() let titleLabel = UILabel() let descriptionLabel = UILabel() - let chip = UIButton(type: .system) - private let imageShimmer = LinkCardShimmerView(ground: nil, highlight: LinkWebCardView.shimmerHighlight) - private let titleBars = UIStackView() - private let titleBarShimmers = [ - LinkCardShimmerView(ground: LinkWebCardView.barGround, highlight: LinkWebCardView.shimmerHighlight), - LinkCardShimmerView(ground: LinkWebCardView.barGround, highlight: LinkWebCardView.shimmerHighlight), - ] - - /// Whether `point`, in this view's coordinates, is on the "Show preview" chip. - func hasButton(at point: CGPoint) -> Bool { - !chip.isHidden && chip.convert(chip.bounds, to: self).contains(point) - } + private var collapse: NSLayoutConstraint! private var imageTask: Task? @@ -100,45 +99,15 @@ final class LinkWebCardView: UIView { descriptionLabel.textColor = UIColor.white.withAlphaComponent(0.75) descriptionLabel.numberOfLines = 2 - imageShimmer.translatesAutoresizingMaskIntoConstraints = false - panel.addSubview(imageShimmer) - - titleBars.axis = .vertical - titleBars.alignment = .leading - titleBars.spacing = 6 - titleBars.isHidden = true - for bar in titleBarShimmers { - bar.roundCorners(to: 4) - bar.translatesAutoresizingMaskIntoConstraints = false - titleBars.addArrangedSubview(bar) - bar.heightAnchor.constraint(equalToConstant: 12).isActive = true - } - titleBarShimmers[0].widthAnchor.constraint(equalTo: titleBars.widthAnchor).isActive = true - titleBarShimmers[1].widthAnchor.constraint(equalTo: titleBars.widthAnchor, multiplier: 0.6).isActive = true - stack.axis = .vertical stack.spacing = 2 stack.translatesAutoresizingMaskIntoConstraints = false - for view in [hostLabel, titleBars, titleLabel, descriptionLabel] { + for view in [hostLabel, titleLabel, descriptionLabel] { stack.addArrangedSubview(view) } stack.setCustomSpacing(6, after: hostLabel) panel.addSubview(stack) - chip.titleLabel?.font = .default(size: 13, weight: .bold) - chip.setTitleColor(.white, for: .normal) - chip.contentHorizontalAlignment = .leading - chip.backgroundColor = UIColor.white.withAlphaComponent(0.12) - chip.layer.cornerRadius = 14 - chip.layer.cornerCurve = .continuous - chip.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) - chip.titleLabel?.lineBreakMode = .byTruncatingMiddle - // A long host truncates rather than widening the bubble around it. - chip.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - chip.addTarget(self, action: #selector(chipTapped), for: .touchUpInside) - chip.translatesAutoresizingMaskIntoConstraints = false - addSubview(chip) - // Only one of the image's two heights is ever live: a zero height competing with the aspect // would be settled by shrinking the width, and the card with it. imageHeight = imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 1 / Self.imageAspect) @@ -147,7 +116,6 @@ final class LinkWebCardView: UIView { collapse = heightAnchor.constraint(equalToConstant: 0) panelTop = panel.topAnchor.constraint(equalTo: topAnchor, constant: Self.topGap) - chipTop = chip.topAnchor.constraint(equalTo: topAnchor, constant: Self.topGap) NSLayoutConstraint.activate([ panelTop, panel.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -157,25 +125,13 @@ final class LinkWebCardView: UIView { imageView.leadingAnchor.constraint(equalTo: panel.leadingAnchor), imageView.trailingAnchor.constraint(equalTo: panel.trailingAnchor), - imageShimmer.topAnchor.constraint(equalTo: imageView.topAnchor), - imageShimmer.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - imageShimmer.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - imageShimmer.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), - - titleBars.widthAnchor.constraint(equalTo: stack.widthAnchor), - stackBelowImage, stack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 10), stack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -10), stack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -10), - - chipTop, - chip.leadingAnchor.constraint(equalTo: leadingAnchor), - chip.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), ]) - chipBottom = chip.bottomAnchor.constraint(equalTo: bottomAnchor) - // The panel and the chip each close the card only while they draw, so the zero height of a - // card that draws nothing never fights their gap above. + // The panel closes the card only while it draws, so the zero height of a card that draws + // nothing never fights its gap above. panelBottom = panel.bottomAnchor.constraint(equalTo: bottomAnchor) imageCollapsed.isActive = true apply(.nothing) @@ -183,7 +139,6 @@ final class LinkWebCardView: UIView { private var imageHeight: NSLayoutConstraint! private var panelTop: NSLayoutConstraint! - private var chipTop: NSLayoutConstraint! /// Whether the card stands on its own in place of its bubble: no gap above it, and the panel cut /// to `cornerRadii` rather than rounded inside a bubble. @@ -191,7 +146,6 @@ final class LinkWebCardView: UIView { didSet { guard isBare != oldValue else { return } panelTop.constant = isBare ? 0 : Self.topGap - chipTop.constant = isBare ? 0 : Self.topGap setNeedsLayout() } } @@ -229,7 +183,6 @@ final class LinkWebCardView: UIView { } } private var imageCollapsed: NSLayoutConstraint! - private var chipBottom: NSLayoutConstraint! private var panelBottom: NSLayoutConstraint! func prepareForReuse() { @@ -248,40 +201,16 @@ final class LinkWebCardView: UIView { } private func apply(_ content: Content) { - let previousImageURL = Self.imageURL(of: self.content) - let wasPlaceholder = Self.isPlaceholder(self.content) + let previousImage = Self.image(of: self.content) self.content = content - let isPlaceholder = Self.isPlaceholder(content) - titleBars.isHidden = !isPlaceholder - titleLabel.isHidden = isPlaceholder - imageShimmer.isHidden = !isPlaceholder - imageShimmer.setShimmering(isPlaceholder) - titleBarShimmers.forEach { $0.setShimmering(isPlaceholder) } - // The placeholder hides the link's text, so it reads out as the link. - panel.isAccessibilityElement = isPlaceholder - panel.accessibilityLabel = nil - panel.accessibilityTraits = isPlaceholder ? .link : [] switch content { case .nothing: panel.isHidden = true - chip.isHidden = true - chip.setTitle(nil, for: .normal) - chipBottom.isActive = false panelBottom.isActive = false collapse.isActive = true - case .chip(let host): - panel.isHidden = true - chip.isHidden = false - chip.setTitle(Self.chipTitle(host: host), for: .normal) - panelBottom.isActive = false - collapse.isActive = false - chipBottom.isActive = true - case .preview(let page): - chip.isHidden = true - chipBottom.isActive = false panel.isHidden = false collapse.isActive = false panelBottom.isActive = true @@ -289,47 +218,29 @@ final class LinkWebCardView: UIView { titleLabel.text = page.title descriptionLabel.text = page.description descriptionLabel.isHidden = page.description == nil - - case .placeholder(let host, let url): - chip.isHidden = true - chipBottom.isActive = false - panel.isHidden = false - collapse.isActive = false - panelBottom.isActive = true - hostLabel.text = host - titleLabel.text = nil - descriptionLabel.text = nil - descriptionLabel.isHidden = true - panel.accessibilityLabel = url.absoluteString } - let imageURL = Self.imageURL(of: content) - if isPlaceholder { - // Held at the image's shape, as most pages answer with one. - imageTask?.cancel() - imageTask = nil - setImageSlot(.loading) - } else if imageURL != previousImageURL || wasPlaceholder { - imageTask?.cancel() - imageTask = nil - if let imageURL, let data = cachedImage?(imageURL), let image = UIImage(data: data) { - setImageSlot(.loaded(image)) - } else if let imageURL, let loadImage { - setImageSlot(.loading) - load(imageURL, with: loadImage) - } else { - setImageSlot(.none) - } + let image = Self.image(of: content) + guard image != previousImage else { return } + imageTask?.cancel() + imageTask = nil + if let image, let cached = cachedImage?(image) { + setImageSlot(.loaded(cached)) + } else if let image, let loadImage { + setImageSlot(.loading(BlurHashCache.shared.image(for: image.blurhash))) + load(image, with: loadImage) + } else { + setImageSlot(.none) } } - /// Fetches the image and shows it only if the card still draws that same image. - private func load(_ url: URL, with loadImage: @escaping (URL) async -> Data?) { + /// Loads the image and shows it only if the card still draws that same image. + private func load(_ image: MediaAttachment, with loadImage: @escaping (MediaAttachment) async -> UIImage?) { imageTask = Task { [weak self] in - let data = await loadImage(url) - guard !Task.isCancelled, let self, Self.imageURL(of: content) == url else { return } - if let data, let image = UIImage(data: data) { - setImageSlot(.loaded(image)) + let loaded = await loadImage(image) + guard !Task.isCancelled, let self, Self.image(of: content) == image else { return } + if let loaded { + setImageSlot(.loaded(loaded)) } else { setImageSlot(.none) onImageChange?() @@ -340,11 +251,12 @@ final class LinkWebCardView: UIView { /// Called when the image fails and the card drops the slot it held for it. var onImageChange: (() -> Void)? - /// What the image slot shows. The slot is held at the image's shape while it loads, so a - /// successful image doesn't move the text, and dropped only when the image fails. + /// What the image slot shows. The slot is held at the image's shape while it loads, under the + /// image's BlurHash when it has one, so a successful image doesn't move the text, and dropped + /// only when the image fails. enum ImageSlot: Equatable { case none - case loading + case loading(UIImage?) case loaded(UIImage) } @@ -356,8 +268,8 @@ final class LinkWebCardView: UIView { case .none: imageView.image = nil imageView.isHidden = true - case .loading: - imageView.image = nil + case .loading(let blurhash): + imageView.image = blurhash imageView.isHidden = false imageView.backgroundColor = Self.loadingTint case .loaded(let image): @@ -376,23 +288,40 @@ final class LinkWebCardView: UIView { } private static let loadingTint = UIColor.white.withAlphaComponent(0.15) - private static let barGround = UIColor.white.withAlphaComponent(0.12) - private static let shimmerHighlight = UIColor.white.withAlphaComponent(0.06) - private static func isPlaceholder(_ content: Content) -> Bool { - if case .placeholder = content { true } else { false } + private static func image(of content: Content) -> MediaAttachment? { + switch content { + case .preview(let page): page.image + case .nothing: nil + } } - private static func imageURL(of content: Content) -> URL? { - if case .preview(let page) = content { page.imageURL } else { nil } - } + @objc private func panelTapped() { onTap?() } +} - /// The chip's label; Android draws the same string. - static func chipTitle(host: String) -> String { - "Show preview · \(host)" +/// Where a transcript's web cards get their images: the chat photo pipeline, so a sealed preview +/// image decrypts and caches exactly as a sealed photo does. +@MainActor +struct WebCardImages { + /// Loads the image, or nil when it fails to load or decrypt. + let load: (MediaAttachment) async -> UIImage? + /// The image if already held in memory. + let cached: (MediaAttachment) -> UIImage? + + /// Images resolved through `resolver`. + init(resolver: ChatMediaURLResolver) { + load = { [weak resolver] attachment in + guard let location = await resolver?.location(for: attachment) else { return nil } + return await ChatMediaImageSource.image(blobID: attachment.blobID, location: location) + } + cached = { attachment in + attachment.blobID.flatMap(ChatMediaImageSource.cachedImage(blobID:)) + } } - @objc private func chipTapped() { onShowPreview?() } - @objc private func panelTapped() { onTap?() } + init(load: @escaping (MediaAttachment) async -> UIImage?, cached: @escaping (MediaAttachment) -> UIImage?) { + self.load = load + self.cached = cached + } } #endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index c0960c196..3d868eb8f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -40,15 +40,10 @@ public final class LinkableBubbleView: UIView { /// Called when the card's height changes after the bubble was configured, as a group card's /// does when its lookup lands. var onCardHeightChange: (() -> Void)? - /// Whether a web card asks for its page as it draws or waits for its chip. - var webPreviewMode: WebLinkPreviewMode { - get { cardView.webPreviewMode } - set { cardView.webPreviewMode = newValue } - } - /// The transcript's record of the web previews its viewer asked for through the chip. - var webPreviewRequests: WebPreviewRequests { - get { cardView.webPreviewRequests } - set { cardView.webPreviewRequests = newValue } + /// Where the bubble's web card gets its image. Set before ``configure(with:)``. + var webImages: WebCardImages? { + get { cardView.webImages } + set { cardView.webImages = newValue } } /// The bubble's widest, which it takes while a web preview draws so the image is not cut to /// the text's width. @@ -309,19 +304,18 @@ public final class LinkableBubbleView: UIView { self.message = message self.quoteThumbnail = quoteThumbnail cardView.cornerRadii = Self.radii(for: message) - // Painted first, so a preview already remembered draws a link-only row bare from its first - // frame rather than swapping out of a bubble. + // Painted first, so a link-only row draws bare from its first frame rather than swapping + // out of a bubble. if case .web = message.linkPreview?.card, let card = message.linkPreview?.card { self.card = card - cardView.webShowsPlaceholder = message.isOnlyItsWebLink cardView.configure(with: card, source: linkCardSource) } lay(message, quoteThumbnail: quoteThumbnail) } /// Whether `message` draws as its card alone. The transcript gives a Flipcash card a row of its - /// own, so that row always does; a web link with nothing else beside it does only while its - /// preview draws, and keeps its text bubble while loading, with nothing to show, or at its chip. + /// own, so that row always does; a web link with nothing else beside it does whenever its + /// preview draws. private func drawsBare(_ message: ChatMessage) -> Bool { message.rendersAsBareLinkCard || (message.isOnlyItsWebLink && cardView.drawsWebPreview) }