eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import 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.
19public 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