| 1 | import Foundation |
| 2 | |
| 3 | // π€ Media, Links & Docs β the native twin of the web's pure extractor |
| 4 | // (web/static/threadmedia.js; spec docs/superpowers/specs/ |
| 5 | // 2026-08-05-media-links-docs-design.md). Classifies a thread's stored, |
| 6 | // already-decrypted payloads into the three tabs. Pure functions in the |
| 7 | // shared core so iOS and Mac can't drift, pinned to the web extractor's |
| 8 | // rules by ThreadMediaTests (same fixtures as test/threadmedia.test.mjs). |
| 9 | // |
| 10 | // One deliberate divergence from the web's "skip typed app events" rule: |
| 11 | // MessageStore rows have already been through ChatFeedModel.renderable, which |
| 12 | // rewrites app events into `type: "chat"` projections with `action: true` β |
| 13 | // erasing the `type` discriminator the web filters on. So the native guard is |
| 14 | // on `action`, and it knowingly also skips a member's own /me lines (which |
| 15 | // the web's Links tab keeps when one carries a URL): the two shapes are |
| 16 | // indistinguishable in the store, and letting them through would fill the |
| 17 | // Links tab with app-event noise. The cost is a /me line's rare URL missing |
| 18 | // from Links on native β accepted until stored rows carry a discriminator. |
| 19 | public enum ThreadMedia { |
| 20 | /// One tab entry: the source message's id + sender, plus the attachment |
| 21 | /// (media/docs) or URL (links) needed to render without re-reading rows. |
| 22 | public struct Entry: Identifiable, Sendable { |
| 23 | public let sid: Int64 |
| 24 | public let ts: Int64 // ms β sender clock, falling back to server time |
| 25 | public let uid: String |
| 26 | public let name: String |
| 27 | public let att: Attachment? |
| 28 | public let url: URL? |
| 29 | // An attachment id is unique per upload; a repeated URL in one message |
| 30 | // is two rows (the web extractor's rule), so links key on position. |
| 31 | public var id: String { att.map { "a\(sid):\($0.id)" } ?? "l\(sid):\(url?.absoluteString ?? "")" } |
| 32 | |
| 33 | /// The still to show/index for a media entry: a photo is itself, a |
| 34 | /// video is its poster frame (whole videos never decrypt for a |
| 35 | /// thumbnail or a label β the app's standing rule). |
| 36 | public var stillAttachment: Attachment? { |
| 37 | guard let att, att.isImage || att.isVideo else { return nil } |
| 38 | if att.isVideo { |
| 39 | guard let poster = att.poster, !poster.isEmpty else { return nil } |
| 40 | var still = att |
| 41 | still.id = poster |
| 42 | still.type = "image/jpeg" |
| 43 | still.poster = nil |
| 44 | return still |
| 45 | } |
| 46 | return att |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | public struct Tabs: Sendable { |
| 51 | public var media: [Entry] = [] |
| 52 | public var links: [Entry] = [] |
| 53 | public var docs: [Entry] = [] |
| 54 | public var isEmpty: Bool { media.isEmpty && links.isEmpty && docs.isEmpty } |
| 55 | public init() {} |
| 56 | } |
| 57 | |
| 58 | /// One decrypted payload β its entries, or nil when the message |
| 59 | /// contributes nothing. `at` is the server row's created_at (seconds). |
| 60 | public static func extractEntries(_ p: MessagePayload, sid: Int64, at: Int64) -> Tabs? { |
| 61 | // Renderable chat only (web: `if (data.type) return null` β reactions |
| 62 | // and unknown kinds never reach the store, but guard anyway); |
| 63 | // projections and /me action lines are invisible (see header note). |
| 64 | guard p.isRenderableChat, p.action != true else { return nil } |
| 65 | let ts = p.ts ?? at * 1000 |
| 66 | var out = Tabs() |
| 67 | for a in p.attachments ?? [] { |
| 68 | guard !a.id.isEmpty else { continue } |
| 69 | let entry = Entry(sid: sid, ts: ts, uid: p.uid ?? "", name: p.name ?? "", att: a, url: nil) |
| 70 | if a.isImage || a.isVideo { out.media.append(entry) } else { out.docs.append(entry) } |
| 71 | } |
| 72 | for url in allLinks(p.text ?? "") { |
| 73 | out.links.append(Entry(sid: sid, ts: ts, uid: p.uid ?? "", name: p.name ?? "", att: nil, url: url)) |
| 74 | } |
| 75 | return out.isEmpty ? nil : out |
| 76 | } |
| 77 | |
| 78 | /// Fold a thread's stored rows (any order) into sorted tabs, newest |
| 79 | /// message first β the whole per-open index. MessageStore rows are |
| 80 | /// tombstone-free and unique by id, so no seen-set is needed here. |
| 81 | public static func extractAll(_ rows: [MessageStore.StoredMessage]) -> Tabs { |
| 82 | var out = Tabs() |
| 83 | for row in rows { |
| 84 | guard let ex = extractEntries(row.payload, sid: row.id, at: row.at) else { continue } |
| 85 | out.media.append(contentsOf: ex.media) |
| 86 | out.links.append(contentsOf: ex.links) |
| 87 | out.docs.append(contentsOf: ex.docs) |
| 88 | } |
| 89 | out.media.sort { $0.sid > $1.sid } |
| 90 | out.links.sort { $0.sid > $1.sid } |
| 91 | out.docs.sort { $0.sid > $1.sid } |
| 92 | return out |
| 93 | } |
| 94 | |
| 95 | /// Hide muted/blocked senders at query time (the web filters its index |
| 96 | /// the same way), so un-muting needs no re-extraction. |
| 97 | public static func filtered(_ entries: [Entry], hiddenUids: Set<String>) -> [Entry] { |
| 98 | hiddenUids.isEmpty ? entries : entries.filter { !hiddenUids.contains($0.uid) } |
| 99 | } |
| 100 | |
| 101 | /// Search within a tab (web threadmedia filterEntries): a case-insensitive |
| 102 | /// substring over what each entry actually shows β the sender's name |
| 103 | /// (every tab), the URL and its unfurl title (links; `titleFor` returns |
| 104 | /// the title if it's been fetched, else nil), the filename (docs, and |
| 105 | /// media that carry one), and β native only β what's IN a photo: |
| 106 | /// `imageTextFor(stillBlobID)` returns the indexed labels + recognised |
| 107 | /// text (ImageSearchIndex), already lowercased. Blank query = everything. |
| 108 | public static func filtered(_ entries: [Entry], query: String, |
| 109 | titleFor: (URL) -> String? = { _ in nil }, |
| 110 | imageTextFor: (String) -> String? = { _ in nil }) -> [Entry] { |
| 111 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() |
| 112 | guard !q.isEmpty else { return entries } |
| 113 | return entries.filter { e in |
| 114 | if e.name.lowercased().contains(q) { return true } |
| 115 | if let url = e.url { |
| 116 | if url.absoluteString.lowercased().contains(q) { return true } |
| 117 | if let t = titleFor(url), t.lowercased().contains(q) { return true } |
| 118 | } |
| 119 | if let n = e.att?.name, n.lowercased().contains(q) { return true } |
| 120 | if let still = e.stillAttachment, let t = imageTextFor(still.id), t.contains(q) { return true } |
| 121 | return false |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | /// "August 2026" β the month section header (web threadmedia monthLabel). |
| 126 | public static func monthLabel(_ tsMs: Int64) -> String { |
| 127 | Date(timeIntervalSince1970: TimeInterval(tsMs) / 1000) |
| 128 | .formatted(.dateTime.month(.wide).year()) |
| 129 | } |
| 130 | |
| 131 | public struct MonthBucket: Identifiable, Sendable { |
| 132 | public let key: String |
| 133 | public let label: String |
| 134 | public var items: [Entry] |
| 135 | public var id: String { key } |
| 136 | } |
| 137 | |
| 138 | /// Group an already-sorted (newest-first) entry list into month sections. |
| 139 | /// Consecutive grouping is enough BECAUSE the input is sorted. |
| 140 | public static func bucketByMonth(_ entries: [Entry]) -> [MonthBucket] { |
| 141 | var out: [MonthBucket] = [] |
| 142 | let cal = Calendar.current |
| 143 | for e in entries { |
| 144 | let d = Date(timeIntervalSince1970: TimeInterval(e.ts) / 1000) |
| 145 | let c = cal.dateComponents([.year, .month], from: d) |
| 146 | let key = "\(c.year ?? 0)-\(c.month ?? 0)" |
| 147 | if out.last?.key != key { |
| 148 | out.append(MonthBucket(key: key, label: monthLabel(e.ts), items: [])) |
| 149 | } |
| 150 | out[out.count - 1].items.append(e) |
| 151 | } |
| 152 | return out |
| 153 | } |
| 154 | } |
| 155 | |