From cf6a824a3af93f987a66304f22e235f33ce3809b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 16:57:15 -0400 Subject: [PATCH 01/11] feat(chat): add the sender-side link preview checks and their shared vectors --- .../Models/Chat/LinkPreviewSendChecks.swift | 155 +++++++ .../Fixtures/link_preview_send.json | 415 ++++++++++++++++++ .../LinkPreviewSendVectorTests.swift | 61 +++ 3 files changed, 631 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/link_preview_send.json create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/LinkPreviewSendVectorTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift new file mode 100644 index 000000000..36950b6b4 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift @@ -0,0 +1,155 @@ +// +// 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 { + let link = Array(url.unicodeScalars) + guard isValidURL(link) else { return false } + let haystack = Array(text.unicodeScalars) + guard haystack.count >= link.count else { return false } + + 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 { return true } + } + start += 1 + } + return false + } + + // MARK: - Cleaning - + + /// The title to send for a scraped `raw` title, or `nil` when nothing visible is left and the + /// preview must be dropped. + public static func cleanTitle(_ raw: String) -> 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/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 ?? "")) + } +} From ba868fb543df010e1b2507af356c80e3c224b034 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:46:41 -0400 Subject: [PATCH 02/11] feat(chat): carry a text message's link preview on the wire and in the store A text message now holds the sender's `MessageLinkPreview` (url, title, description, image) next to its text. It goes out in `TextContent.link_previews`, plaintext and sealed, and is read back from both, dropping a preview whose url is not a whole link in the text or whose title is empty. A sealed preview's image is checked like encrypted media; one uploaded for the other encryption mode is left off. An edit sends no new fetch: it keeps the stored preview while its link is still whole in the edited text, and the local rewrite does the same. The store keeps the preview in a new `conversation_message.linkPreviewJson` column, so `Database.schemaVersion` goes to 52. --- .claude/plans/2026-10-14-link-preview-send.md | 48 ++++++++ ...versationController+MessageMutations.swift | 3 + .../Controllers/ConversationController.swift | 17 +-- .../Controllers/EncryptedChatClient.swift | 8 +- .../Controllers/FlipClient+Protocols.swift | 7 +- .../Clients/Chat/ChatNotificationClient.swift | 1 + .../Clients/Flip API/FlipClient+Chat.swift | 9 +- .../Services/ChatMessagingService.swift | 13 ++- .../Models/Conversation/ChatSeal.swift | 74 +++++++----- .../Conversation/ConversationMessage.swift | 23 +++- .../Conversation/ConversationStore.swift | 6 +- .../Conversation/MessageLinkPreview.swift | 104 +++++++++++++++++ .../Database+Conversations.swift | 8 +- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 3 + .../MessageLinkPreviewTests.swift | 106 ++++++++++++++++++ .../Database+ConversationsTests.swift | 24 ++++ .../TestSupport/MockConversations.swift | 12 +- 18 files changed, 406 insertions(+), 62 deletions(-) create mode 100644 .claude/plans/2026-10-14-link-preview-send.md create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageLinkPreview.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/MessageLinkPreviewTests.swift 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/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..664506050 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 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/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/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.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..250b9b87a 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -394,6 +394,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 +677,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) }) } 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/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/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 } From 33dfdcabb4ea13ccdc7852090a3e51980fe2d71a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:54:14 -0400 Subject: [PATCH 03/11] feat(chat): build a link preview in the composer and send it with the text The composer fetches the draft's first web link, uploads the page image through the chat photo uploader (sealed for an encrypted chat), and shows a 56pt thumbnail with a remove control beside the field. A text send attaches the preview only if it is ready, its URL is still a whole link in the text, and its title cleans to something; a send never waits on it. No preview is built while photos are staged, since a caption carries none, or during an edit. The fetcher no longer reads the webLinkPreviews flag. --- .../Conversation/ComposerChipStrip.swift | 63 +++++++ .../Conversation/ComposerLinkPreview.swift | 178 ++++++++++++++++++ .../Screens/Conversation/ComposerModel.swift | 10 + .../Conversation/ConversationBottomBar.swift | 13 ++ .../Conversation/ConversationScreen.swift | 6 + .../Conversation/LinkMetadataSource.swift | 9 +- .../Chat/ComposerLinkPreviewTests.swift | 142 ++++++++++++++ .../Chat/LinkMetadataSourceTests.swift | 11 +- FlipcashTests/Chat/WebLookupVectorTests.swift | 2 +- 9 files changed, 415 insertions(+), 19 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift create mode 100644 FlipcashTests/Chat/ComposerLinkPreviewTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 470bacdeb..a60030566 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -183,3 +183,66 @@ private struct ComposerChipView: View { } } } + +/// The draft link's preview, drawn like a staged photo: the page's image, or a link glyph when it +/// has none, a spinner while it loads, and a remove button. +struct ComposerLinkChip: View { + + let preview: ComposerLinkPreview + let onRemove: () -> Void + + private static let cornerRadius: CGFloat = 10 + + var body: some View { + let size = ComposerChipStrip.chipSize + Color.backgroundSecondary + .overlay { content } + .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 link preview") + .accessibilityIdentifier("composer-link-chip-remove") + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilityLabel) + .accessibilityIdentifier("composer-link-chip") + } + + @ViewBuilder + private var content: 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() + } else { + Image(systemName: "link") + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.6)) + } + } + } + + 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)" + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift new file mode 100644 index 000000000..79d998e7a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift @@ -0,0 +1,178 @@ +// +// 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` when it had none or the upload failed. + let image: MediaAttachment? + /// The page's image for the composer's thumbnail. + let thumbnail: UIImage? + } + + /// Fetches the page at `url` and uploads its image, or returns `nil` when there is no preview. + typealias Load = @MainActor (URL) async -> Page? + + 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? + + /// Links whose preview was removed, kept off for the rest of 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) + 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) + task = Task { [weak self] in + let page = await load(next.url) + guard let self, !Task.isCancelled, self.link == next.text else { return } + if let page { + self.state = .ready(link: next.text, host: host, page: page) + } else { + self.state = .none + } + } + } + + /// Removes the preview, which stays off for this link until the draft is cleared. + 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 makes a web card, with the text it was found in. + nonisolated static func webLink(in text: String, detector: LinkDetector, classifier: LinkCardClassifier) -> (text: String, url: URL)? { + guard case .web(let web)? = classifier.firstCard(in: detector.webLinks(in: text)) else { return nil } + return ((text as NSString).substring(with: web.range), web.url) + } +} + +extension ComposerLinkPreview { + + /// A loader that fetches pages through `metadata`, downloads their image through `images`, and + /// uploads it through `uploader` as `senderID`'s chat photo. + static func loader( + metadata: any LinkMetadataSource, + images: WebImageSource, + uploader: @escaping @MainActor () -> ChatMediaUploader, + senderID: UserID + ) -> Load { + { url in + guard case .resolved(let page)? = try? await metadata.metadata(for: url) else { return nil } + var image: MediaAttachment? + var thumbnail: UIImage? + if let imageURL = page.imageURL, + let data = await images.data(for: imageURL), + let picture = UIImage(data: data) { + var size: (width: Int, height: Int)? + if let uploaded = try? await uploader().upload(picture, onPrepared: { size = ($0, $1) }), + let size { + image = MediaAttachment(uploaded, width: size.width, height: size.height, senderID: senderID) + thumbnail = picture + } + } + guard !Task.isCancelled else { return nil } + return Page(title: page.title, description: page.description, image: image, thumbnail: thumbnail) + } + } +} + +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..2475605b6 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 chip is drawn: while it has a link and no photo is staged, since a + /// caption carries no preview, and not during an edit, which sends no new one. + var showsLinkPreview: Bool { + linkPreview.link != nil && chips.isEmpty && !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. @@ -247,6 +256,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..a84372cac 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -1301,6 +1301,11 @@ struct ConversationComposer: View { .padding(.trailing, -BarMetrics.fieldPadding) .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) + } else if composer.showsLinkPreview { + ComposerLinkChip(preview: composer.linkPreview, onRemove: { composer.linkPreview.remove() }) + .padding(.leading, Self.leadingInset - BarMetrics.fieldPadding) + .padding(.top, BarMetrics.fieldVerticalPadding / 2) + .transition(motion.stripTransition(isLanding: false)) } row } @@ -1319,6 +1324,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 +1410,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: nil + } composer.clear() isFocused = true switch outgoing { @@ -1411,6 +1423,7 @@ struct ConversationComposer: View { text, to: conversationID, repliedTo: repliedTo, + linkPreview: linkPreview, restoringOnFailure: draft ) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 03b0609f1..b82893ca1 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -872,6 +872,12 @@ struct ConversationScreen: View { .onAppear { setVisibleConversation(conversationID, source: "onAppear") syncCoordinator(conversationID) + composer.linkPreview.load = ComposerLinkPreview.loader( + metadata: sessionContainer.linkMetadataSource, + images: WebImageSource(), + uploader: { mediaUploader }, + senderID: sessionContainer.session.userID + ) restoreDraft(conversationID) } // A matched contact's chat is created mid-screen on the first payment, diff --git a/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift b/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift index 91b9ea0ea..bb75d1830 100644 --- a/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift +++ b/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift @@ -30,9 +30,6 @@ nonisolated protocol WebHomeAnswers: Sendable { 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,19 +39,15 @@ 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() } 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)) { diff --git a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift new file mode 100644 index 000000000..d59b567e6 --- /dev/null +++ b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift @@ -0,0 +1,142 @@ +// +// ComposerLinkPreviewTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +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 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 aRemovedPreviewStaysOffForTheDraft() async { + let (preview, box) = preview() + preview.update(for: "https://example.com/a") + await settle(preview) + preview.remove() + #expect(preview.link == nil) + preview.update(for: "") + preview.update(for: "https://example.com/a") + await settle(preview) + #expect(preview.link == nil) + #expect(box.asked.count == 1) + } + + @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) + } +} diff --git a/FlipcashTests/Chat/LinkMetadataSourceTests.swift b/FlipcashTests/Chat/LinkMetadataSourceTests.swift index 56cd6c1ba..aa1061b2a 100644 --- a/FlipcashTests/Chat/LinkMetadataSourceTests.swift +++ b/FlipcashTests/Chat/LinkMetadataSourceTests.swift @@ -78,15 +78,6 @@ 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")! @@ -193,7 +184,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/WebLookupVectorTests.swift b/FlipcashTests/Chat/WebLookupVectorTests.swift index 13b175b41..40c381646 100644 --- a/FlipcashTests/Chat/WebLookupVectorTests.swift +++ b/FlipcashTests/Chat/WebLookupVectorTests.swift @@ -69,7 +69,7 @@ struct WebLookupVectorTests { 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. From 35d7b8634fef2e8e9227b005e35b05ed434cbc61 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 23:30:30 -0400 Subject: [PATCH 04/11] feat(chat): draw a web card from the sender's preview and drop the receive-side fetch A recipient now draws a web card only from the preview its sender attached: the card sits at the first place the preview's url appears in the text as a whole link, and a preview whose url is not there, or whose title is empty, draws nothing. No fetch rule is applied on read, so a link the composer would not fetch still draws if the sender's platform previewed it. The image loads through the chat photo resolver, so a sealed preview image decrypts the same way a sealed photo does. A Flipcash card in the same text still wins. Removed with no fallback, since the receive-side fetch never shipped: the web paths in LinkCardFeed, LinkCardResolver and LinkCardMemo, the transcript prefetch and the first-paint wait on the memo, the tap-to-load mode and chip, WebImageDiskCache, the linkPreview table, and the webLinkPreviews beta flag with its settings row. The page fetcher stays for the composer. --- Flipcash/Core/Controllers/BetaFlags.swift | 7 - .../Conversation/ChatItem+Conversation.swift | 32 ++- .../ChatScreenRepresentable.swift | 4 - .../Conversation/ComposerLinkPreview.swift | 8 +- .../ConversationLoadCoordinator.swift | 38 +-- .../Conversation/ConversationScreen.swift | 21 +- .../Conversation/LinkCardClassifier.swift | 32 +-- .../Screens/Conversation/LinkCardFeed.swift | 48 +--- .../Screens/Conversation/LinkCardMemo.swift | 154 +----------- .../Conversation/LinkCardResolver.swift | 33 +-- .../Conversation/LinkMetadataSource.swift | 62 ++--- .../Conversation/WebImageDiskCache.swift | 116 --------- .../Core/Session/SessionAuthenticator.swift | 8 +- .../FlipcashCore/Models/Chat/LinkCard.swift | 30 +-- .../Models/Chat/LinkPreviewSendChecks.swift | 17 +- .../FlipcashStore/Database+LinkPreviews.swift | 60 ----- .../Sources/FlipcashStore/Schema.swift | 21 -- .../Chat/ChatLinkCardSplitTests.swift | 50 +++- .../Chat/ChatLinkMessageCellTests.swift | 4 +- .../Chat/ConversationWebPrefetchTests.swift | 133 ---------- .../Chat/LinkCardClassifierTests.swift | 48 ++-- FlipcashTests/Chat/LinkCardFeedTests.swift | 113 +-------- FlipcashTests/Chat/LinkCardMemoWebTests.swift | 109 -------- .../Chat/LinkCardResolverTests.swift | 51 +--- FlipcashTests/Chat/LinkCardViewTests.swift | 146 +++++------ .../Chat/LinkMetadataSourceTests.swift | 49 ++-- FlipcashTests/Chat/UserLinkCardTests.swift | 6 +- .../Chat/WebImageDiskCacheTests.swift | 128 ---------- FlipcashTests/Chat/WebLinkBareCardTests.swift | 213 ++-------------- FlipcashTests/Chat/WebLookupVectorTests.swift | 78 ++---- FlipcashTests/LinkPreviewStoreTests.swift | 63 ----- .../FlipcashUI/Chat/ChatLinkMessageCell.swift | 16 +- .../Chat/ChatMediaImageSource.swift | 22 +- .../Chat/ChatMediaURLResolver.swift | 13 +- .../Chat/ChatScreenViewController.swift | 6 - .../Chat/ChatShareProfileCell.swift | 2 +- .../FlipcashUI/Chat/ChatViewController.swift | 20 +- .../FlipcashUI/Chat/LinkCardSource.swift | 41 --- .../FlipcashUI/Chat/LinkCardView.swift | 114 +++------ .../FlipcashUI/Chat/LinkWebCardView.swift | 235 ++++++------------ .../FlipcashUI/Chat/LinkableBubbleView.swift | 22 +- 41 files changed, 457 insertions(+), 1916 deletions(-) delete mode 100644 Flipcash/Core/Screens/Conversation/WebImageDiskCache.swift delete mode 100644 FlipcashCore/Sources/FlipcashStore/Database+LinkPreviews.swift delete mode 100644 FlipcashTests/Chat/ConversationWebPrefetchTests.swift delete mode 100644 FlipcashTests/Chat/LinkCardMemoWebTests.swift delete mode 100644 FlipcashTests/Chat/WebImageDiskCacheTests.swift delete mode 100644 FlipcashTests/LinkPreviewStoreTests.swift 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/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/ComposerLinkPreview.swift b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift index 79d998e7a..c40710145 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift @@ -121,10 +121,10 @@ final class ComposerLinkPreview { } } - /// The first link in `text` the classifier makes a web card, with the text it was found in. + /// 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 case .web(let web)? = classifier.firstCard(in: detector.webLinks(in: text)) else { return nil } - return ((text as NSString).substring(with: web.range), web.url) + guard let link = classifier.firstWebLink(in: detector.webLinks(in: text)) else { return nil } + return ((text as NSString).substring(with: link.range), link.url) } } @@ -139,7 +139,7 @@ extension ComposerLinkPreview { senderID: UserID ) -> Load { { url in - guard case .resolved(let page)? = try? await metadata.metadata(for: url) else { return nil } + guard let page = (try? await metadata.metadata(for: url)) ?? nil else { return nil } var image: MediaAttachment? var thumbnail: UIImage? if let imageURL = page.imageURL, 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 b82893ca1..c2c530608 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 } @@ -1473,7 +1455,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 bb75d1830..9f08d91d9 100644 --- a/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift +++ b/Flipcash/Core/Screens/Conversation/LinkMetadataSource.swift @@ -8,26 +8,11 @@ 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 -} - -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 + /// 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? } /// A server error worth asking again later, so it is never remembered. @@ -47,33 +32,30 @@ nonisolated final class PinnedLinkMetadataSource: LinkMetadataSource { self.limiter = limiter } - func metadata(for url: URL, homes: (any WebHomeAnswers)?) async throws -> LinkCard.Web.State { + 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)? { @@ -122,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. @@ -145,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 @@ -162,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/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 index 36950b6b4..22fdb2de8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkPreviewSendChecks.swift @@ -20,10 +20,16 @@ public enum LinkPreviewSendChecks { /// 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 false } + guard isValidURL(link) else { return nil } let haystack = Array(text.unicodeScalars) - guard haystack.count >= link.count else { return false } + guard haystack.count >= link.count else { return nil } var start = 0 while start <= haystack.count - link.count { @@ -34,11 +40,14 @@ public enum LinkPreviewSendChecks { 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 { return true } + if before && after { + let location = haystack[.. [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/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 250b9b87a..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" @@ -693,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/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/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 aa1061b2a..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,28 +77,18 @@ struct LinkMetadataSourceTests { } } - @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 { @@ -138,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/"]) } @@ -150,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/"]) } @@ -166,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. 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 40c381646..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)) 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/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/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) } From 6c3c16061862ae429196d99da14b3b28e5b83764 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 17:50:14 -0400 Subject: [PATCH 05/11] feat(chat): show the link preview's page in the composer before its image uploads The composer chip only drew the page's image once the upload finished, and a failed upload left the link glyph, so the sender rarely saw what would be sent. The fetch and the upload are now two steps: the card appears as soon as the page is fetched, with the image under a spinner while it uploads. The chip widens to the field and shows the title, description and host next to the image. A send while the image is still uploading goes with the title and description only, as before. A failed upload drops the thumbnail so the card matches what will be sent. --- .../Conversation/ComposerChipStrip.swift | 82 ++++++++++++++----- .../Conversation/ComposerLinkPreview.swift | 75 ++++++++++------- .../Conversation/ConversationBottomBar.swift | 1 + .../Conversation/ConversationScreen.swift | 6 +- .../Chat/ComposerLinkPreviewTests.swift | 61 ++++++++++++++ 5 files changed, 172 insertions(+), 53 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index a60030566..a8ac50b2a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -184,8 +184,8 @@ private struct ComposerChipView: View { } } -/// The draft link's preview, drawn like a staged photo: the page's image, or a link glyph when it -/// has none, a spinner while it loads, and a remove button. +/// The draft link's preview as the recipient will see it: the page's image, title, description and +/// host across the field's width, with a spinner while it loads and a remove button. struct ComposerLinkChip: View { let preview: ComposerLinkPreview @@ -195,31 +195,35 @@ struct ComposerLinkChip: View { var body: some View { let size = ComposerChipStrip.chipSize - Color.backgroundSecondary - .overlay { content } - .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 link preview") - .accessibilityIdentifier("composer-link-chip-remove") + HStack(spacing: 10) { + Color.backgroundSecondary + .overlay { thumbnail } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) + VStack(alignment: .leading, spacing: 2) { + text } .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .contain) - .accessibilityLabel(accessibilityLabel) - .accessibilityIdentifier("composer-link-chip") + 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 link preview") + .accessibilityIdentifier("composer-link-chip-remove") + } + .frame(height: size) + .accessibilityElement(children: .contain) + .accessibilityLabel(accessibilityLabel) + .accessibilityIdentifier("composer-link-chip") } @ViewBuilder - private var content: some View { + private var thumbnail: some View { switch preview.state { case .none: EmptyView() @@ -230,6 +234,12 @@ struct ComposerLinkChip: View { 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)) @@ -238,6 +248,34 @@ struct ComposerLinkChip: View { } } + @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): + Text(page.title) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .lineLimit(1) + if let description = page.description, !description.isEmpty { + Text(description) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + Text(host) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + } + private var accessibilityLabel: String { switch preview.state { case .none: "" diff --git a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift index c40710145..a42e55e83 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift @@ -20,15 +20,21 @@ final class ComposerLinkPreview { struct Page: Sendable { let title: String let description: String? - /// The page's image as uploaded, or `nil` when it had none or the upload failed. - let image: MediaAttachment? - /// The page's image for the composer's thumbnail. - let thumbnail: UIImage? + /// 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 uploads its image, or returns `nil` when there is no preview. + /// 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 @@ -43,6 +49,9 @@ final class ComposerLinkPreview { /// 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 for the rest of the draft. @ObservationIgnored private var dismissed: Set = [] @ObservationIgnored private var task: Task? @@ -72,14 +81,26 @@ final class ComposerLinkPreview { 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 - let page = await load(next.url) - guard let self, !Task.isCancelled, self.link == next.text else { return } - if let page { - self.state = .ready(link: next.text, host: host, page: page) - } else { + 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) } } @@ -130,30 +151,26 @@ final class ComposerLinkPreview { extension ComposerLinkPreview { - /// A loader that fetches pages through `metadata`, downloads their image through `images`, and - /// uploads it through `uploader` as `senderID`'s chat photo. - static func loader( - metadata: any LinkMetadataSource, - images: WebImageSource, - uploader: @escaping @MainActor () -> ChatMediaUploader, - senderID: UserID - ) -> Load { + /// 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 image: MediaAttachment? var thumbnail: UIImage? - if let imageURL = page.imageURL, - let data = await images.data(for: imageURL), - let picture = UIImage(data: data) { - var size: (width: Int, height: Int)? - if let uploaded = try? await uploader().upload(picture, onPrepared: { size = ($0, $1) }), - let size { - image = MediaAttachment(uploaded, width: size.width, height: size.height, senderID: senderID) - thumbnail = picture - } + 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: image, thumbnail: thumbnail) + 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) } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index a84372cac..12710e35a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -1304,6 +1304,7 @@ struct ConversationComposer: View { } else 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)) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c2c530608..1f6e31db0 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -856,8 +856,10 @@ struct ConversationScreen: View { syncCoordinator(conversationID) composer.linkPreview.load = ComposerLinkPreview.loader( metadata: sessionContainer.linkMetadataSource, - images: WebImageSource(), - uploader: { mediaUploader }, + images: WebImageSource() + ) + composer.linkPreview.upload = ComposerLinkPreview.uploader( + { mediaUploader }, senderID: sessionContainer.session.userID ) restoreDraft(conversationID) diff --git a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift index d59b567e6..4f8fb3cc5 100644 --- a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift +++ b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit import Testing import FlipcashCore @testable import Flipcash @@ -139,4 +140,64 @@ struct ComposerLinkPreviewTests { 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) + } } From 161c9a081614f16efbf54482e0312d9830e147af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 18:09:34 -0400 Subject: [PATCH 06/11] feat(chat): send a link preview alongside staged photos A caption carries no link preview, so staging a photo hid the preview card and dropped it from the send. The card now stays up with photos staged, and when it has a preview the text goes out as its own message after the photos instead of as the last photo's caption. A reply still goes on the first photo only. --- .../Controllers/ConversationController.swift | 15 ++++++++++ .../Screens/Conversation/ComposerModel.swift | 6 ++-- .../Conversation/ConversationBottomBar.swift | 23 ++++++++++---- .../Chat/ConversationMediaSendTests.swift | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 664506050..f52fcfbe9 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -2282,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 { @@ -2299,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 { @@ -2311,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/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 2475605b6..a29ff3ef6 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -57,10 +57,10 @@ final class ComposerModel { /// The preview of the draft's first web link, which a text send carries. let linkPreview = ComposerLinkPreview() - /// Whether the link preview's chip is drawn: while it has a link and no photo is staged, since a - /// caption carries no preview, and not during an edit, which sends no new one. + /// 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 && chips.isEmpty && !isEditing + linkPreview.link != nil && !isEditing } /// The unsent new-message draft, held while an edit occupies the field. diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 12710e35a..7fe9312ed 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -1301,7 +1301,8 @@ struct ConversationComposer: View { .padding(.trailing, -BarMetrics.fieldPadding) .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) - } else if composer.showsLinkPreview { + } + if composer.showsLinkPreview { ComposerLinkChip(preview: composer.linkPreview, onRemove: { composer.linkPreview.remove() }) .padding(.leading, Self.leadingInset - BarMetrics.fieldPadding) .padding(.trailing, Self.leadingInset - BarMetrics.fieldPadding) @@ -1412,8 +1413,8 @@ struct ConversationComposer: View { // message. let draft = composer.persistableDraft let linkPreview: MessageLinkPreview? = switch outgoing { - case .text(let text): composer.linkPreview.attachment(for: text) - case .media: nil + case .text(let text): composer.linkPreview.attachment(for: text) + case .media(_, let caption): caption.flatMap(composer.linkPreview.attachment(for:)) } composer.clear() isFocused = true @@ -1430,9 +1431,21 @@ struct ConversationComposer: View { } 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/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() From 85d4256f11214969754870e5d48208966eadc3fa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 18:14:56 -0400 Subject: [PATCH 07/11] fix(chat): enlarge the composer link image and pin its remove button to it The link card's remove button floated alone at the card's trailing edge, unlike a staged photo's. It now sits on the image's top-trailing corner through the same button as the photo chip. The image grows to 80pt and the description wraps to two lines to fill the height. --- .../Conversation/ComposerChipStrip.swift | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index a8ac50b2a..590a501b6 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") @@ -193,28 +183,27 @@ struct ComposerLinkChip: View { private static let cornerRadius: CGFloat = 10 + /// Larger than a photo chip, so the page's image reads at a glance beside its text. + static let imageSize: CGFloat = 80 + var body: some View { - let size = ComposerChipStrip.chipSize + let size = Self.imageSize HStack(spacing: 10) { Color.backgroundSecondary .overlay { thumbnail } .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) + .overlay(alignment: .topTrailing) { + ComposerChipRemoveButton( + label: "Remove link preview", + identifier: "composer-link-chip-remove", + action: onRemove + ) + } VStack(alignment: .leading, spacing: 2) { text } .frame(maxWidth: .infinity, alignment: .leading) - 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 link preview") - .accessibilityIdentifier("composer-link-chip-remove") } .frame(height: size) .accessibilityElement(children: .contain) @@ -267,7 +256,7 @@ struct ComposerLinkChip: View { Text(description) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) - .lineLimit(1) + .lineLimit(2) } Text(host) .font(.appTextCaption) @@ -284,3 +273,25 @@ struct ComposerLinkChip: View { } } } + +/// 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) + } +} From 257a97d30b30e92dfd240eeac6f45ef170d74668 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 18:19:06 -0400 Subject: [PATCH 08/11] fix(chat): draw the composer link preview as a compact card with its remove button on the corner --- .../Conversation/ComposerChipStrip.swift | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 590a501b6..1601b20b8 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -174,38 +174,46 @@ private struct ComposerChipView: View { } } -/// The draft link's preview as the recipient will see it: the page's image, title, description and -/// host across the field's width, with a spinner while it loads and a remove button. +/// 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 cornerRadius: CGFloat = 10 + private static let cardCornerRadius: CGFloat = 14 + private static let imageCornerRadius: CGFloat = 8 + private static let imageSize: CGFloat = 44 + private static let maxCardWidth: CGFloat = 280 - /// Larger than a photo chip, so the page's image reads at a glance beside its text. - static let imageSize: CGFloat = 80 + /// How far the remove button hangs past the card's top-trailing corner. + private static let removeOverhang: CGFloat = 9 var body: some View { - let size = Self.imageSize HStack(spacing: 10) { Color.backgroundSecondary .overlay { thumbnail } - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) - .overlay(alignment: .topTrailing) { - ComposerChipRemoveButton( - label: "Remove link preview", - identifier: "composer-link-chip-remove", - action: onRemove - ) - } + .frame(width: Self.imageSize, height: Self.imageSize) + .clipShape(RoundedRectangle(cornerRadius: Self.imageCornerRadius)) VStack(alignment: .leading, spacing: 2) { text } .frame(maxWidth: .infinity, alignment: .leading) } - .frame(height: size) + .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") @@ -248,17 +256,19 @@ struct ComposerLinkChip: View { .foregroundStyle(Color.textMain) .lineLimit(1) case .ready(_, let host, let page): - Text(page.title) - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .lineLimit(1) - if let description = page.description, !description.isEmpty { - Text(description) - .font(.appTextCaption) - .foregroundStyle(Color.textSecondary) - .lineLimit(2) + 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) + } } - Text(host) + .lineLimit(1) + Text(page.title) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) .lineLimit(1) From c556690a5461cbd95645b0084bec845fc05c75ec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 18:30:51 -0400 Subject: [PATCH 09/11] fix(chat): animate the composer link card in and out on the chip spring --- .../Core/Screens/Conversation/ConversationBottomBar.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 7fe9312ed..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) } From 529fe9d2c1ff10555e3aab8ea4d9ace7ba82616b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 18:49:52 -0400 Subject: [PATCH 10/11] fix(chat): bring a removed link preview back once its link is edited out of the draft --- .../Conversation/ComposerLinkPreview.swift | 6 ++++-- .../Chat/ComposerLinkPreviewTests.swift | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift index a42e55e83..950d56c58 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerLinkPreview.swift @@ -52,7 +52,7 @@ final class ComposerLinkPreview { /// 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 for the rest of the draft. + /// 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 @@ -75,6 +75,8 @@ final class ComposerLinkPreview { /// 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 @@ -104,7 +106,7 @@ final class ComposerLinkPreview { } } - /// Removes the preview, which stays off for this link until the draft is cleared. + /// 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) diff --git a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift index 4f8fb3cc5..88473b6f9 100644 --- a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift +++ b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift @@ -69,19 +69,31 @@ struct ComposerLinkPreviewTests { } @Test - func aRemovedPreviewStaysOffForTheDraft() async { + 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: "") - preview.update(for: "https://example.com/a") + 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() From 38dff336d01ced31b9edc6b8fe1b423d538d6985 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 20:23:09 -0400 Subject: [PATCH 11/11] fix(chat): load the link preview for a draft restored on opening a chat The draft is restored before the bottom bar starts watching it, so a link already in a saved draft never reached the preview. --- .../Core/Screens/Conversation/ComposerModel.swift | 2 ++ FlipcashTests/Chat/ComposerLinkPreviewTests.swift | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index a29ff3ef6..c601f8f2a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -177,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)) } diff --git a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift index 88473b6f9..2db04e05c 100644 --- a/FlipcashTests/Chat/ComposerLinkPreviewTests.swift +++ b/FlipcashTests/Chat/ComposerLinkPreviewTests.swift @@ -46,6 +46,20 @@ struct ComposerLinkPreviewTests { 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()