eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import QuickLook
3import SwiftUI
4
5// 🤖 "Media, Links & Docs" — the per-thread inventory of everything ever
6// shared in a chat (spec: docs/superpowers/specs/
7// 2026-08-05-media-links-docs-design.md § Native; web twin mediaview.js).
8// Three tabs over ThreadMedia's extraction of the local store's decrypted
9// rows; opening auto-runs the per-thread history walk (HistoryBackfill —
10// marks shared with search, so either finishing the walk finishes both).
11// Zero server involvement: the store holds what the feed already decrypted.
12struct MediaLinksDocsSheet: View {
13 let instance: SavedInstance
14 let thread: ChatThread
15 @ObservedObject var store: ThreadStore
16 let model: ChatFeedModel
17 // "Show in chat": the parent dismisses this sheet and lands the feed on
18 // the message (NativeChatScreen's pendingJump walk).
19 let onJump: (Int64) -> Void
20
21 @Environment(\.dismiss) private var dismiss
22 @State private var tabs = ThreadMedia.Tabs()
23 @State private var loaded = false
24 @State private var tab = "media"
25 // The sheet's own walker — deliberately not shared with the search UI's
26 // instance-wide runner; the store marks are the shared state.
27 @StateObject private var backfill = HistoryBackfill()
28 // What's IN each photo (Vision, on device) — search matches "dog" or the
29 // text on a whiteboard. Indexes the thread's stills on open, resumably.
30 @StateObject private var imageIndex = ImageSearchIndex()
31 @State private var query = ""
32 // Unfurl titles the Links tab has fetched so far, so search matches a
33 // link by title too (a title only exists once its row was on screen —
34 // search never triggers fetches of its own).
35 @State private var linkTitles: [URL: String] = [:]
36 // A tapped tile's collection viewer (Quick Look, staged lazily).
37 @State private var viewer: MediaCollectionItem?
38 struct MediaCollectionItem: Identifiable {
39 let id = UUID()
40 let entries: [ThreadMedia.Entry]
41 let index: Int
42 }
43 // A tapped doc, staged for the single-file Quick Look (FilePreview's).
44 @State private var docPreview: DocPreviewItem?
45 @State private var docCleanupDir: URL?
46 @State private var docBusy: String? // att id mid-decrypt — one at a time
47 struct DocPreviewItem: Identifiable {
48 let id = UUID()
49 let url: URL
50 }
51 // A "Save…" from the grid/rows: the staged file behind the system share
52 // sheet (Save to Photos / Files / AirDrop).
53 @State private var shareItem: ShareItem?
54 @State private var shareCleanupDir: URL?
55 @State private var sharingID: String?
56 struct ShareItem: Identifiable {
57 let id = UUID()
58 let url: URL
59 }
60
61 // Muted/blocked senders hide at query time (web hiddenUids) — un-muting
62 // needs no re-extraction.
63 private var hiddenUids: Set<String> { store.mutedPeople.union(store.blockedPeople) }
64
65 // Moderation applies always; the query narrows the open tab (and the
66 // viewer's collection with it, so swipes walk the search results).
67 private func visible(_ entries: [ThreadMedia.Entry]) -> [ThreadMedia.Entry] {
68 ThreadMedia.filtered(ThreadMedia.filtered(entries, hiddenUids: hiddenUids),
69 query: query, titleFor: { linkTitles[$0] },
70 imageTextFor: { imageIndex.textFor($0) })
71 }
72
73 private var counts: (media: Int, links: Int, docs: Int) {
74 (visible(tabs.media).count, visible(tabs.links).count, visible(tabs.docs).count)
75 }
76
77 var body: some View {
78 NavigationStack {
79 VStack(spacing: 0) {
80 Picker("Kind", selection: $tab) {
81 Text(segLabel("Media", counts.media)).tag("media")
82 Text(segLabel("Links", counts.links)).tag("links")
83 Text(segLabel("Docs", counts.docs)).tag("docs")
84 }
85 .pickerStyle(.segmented)
86 .padding(.horizontal, 16)
87 .padding(.vertical, 8)
88 if backfill.running {
89 progressRow(backfill.progress ?? "Loading earlier messages…") { backfill.cancel() }
90 }
91 if imageIndex.running {
92 progressRow(imageIndex.progress ?? "Indexing photos…") { imageIndex.cancel() }
93 }
94 content
95 }
96 .navigationTitle("Media, links and docs")
97 .navigationBarTitleDisplayMode(.inline)
98 // Search within the open tab — sender, URL/title, filename
99 // (ThreadMedia.filtered(query:)), the system bar-drawer field
100 // the people picker uses.
101 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
102 prompt: "Search")
103 .toolbar {
104 ToolbarItem(placement: .confirmationAction) {
105 Button("Done") { dismiss() }
106 }
107 }
108 }
109 .task(id: backfill.pageTick) { await reload() }
110 .onAppear { startWalkIfIncomplete() }
111 .onDisappear {
112 backfill.cancel()
113 imageIndex.cancel()
114 }
115 .fullScreenCover(item: $viewer) { item in
116 MediaCollectionPreview(entries: item.entries, index: item.index,
117 load: { await model.blobData($0) },
118 onClose: { viewer = nil })
119 .ignoresSafeArea()
120 }
121 .sheet(item: $docPreview, onDismiss: cleanupDoc) { item in
122 QuickLookPreview(url: item.url) { docPreview = nil }
123 .ignoresSafeArea()
124 }
125 .sheet(item: $shareItem, onDismiss: cleanupShare) { item in
126 ActivitySheet(url: item.url)
127 }
128 }
129
130 // "Media · 12" — counts pluralise implicitly (the label is the kind) and
131 // localise (formatted() delimits digits per locale).
132 private func segLabel(_ label: String, _ n: Int) -> String {
133 n > 0 ? "\(label) · \(n.formatted())" : label
134 }
135
136 // Inline, occupying layout (never a floating toast): a walk's narration
137 // plus its Stop — the history walk and the photo indexer share the shape.
138 private func progressRow(_ text: String, stop: @escaping () -> Void) -> some View {
139 HStack(spacing: 8) {
140 // The spinner is the "still working" signal: a count that sits on
141 // "0 of 5" while a slow photo decrypts otherwise reads as stuck.
142 ProgressView()
143 .controlSize(.mini)
144 Text(text)
145 .font(.footnote)
146 .foregroundStyle(.secondary)
147 Spacer(minLength: 0)
148 Button("Stop", action: stop)
149 .font(.footnote.weight(.semibold))
150 }
151 .padding(.horizontal, 16)
152 .padding(.bottom, 6)
153 .accessibilityElement(children: .combine)
154 }
155
156 // Hand a photo/video/file to the system share sheet — Save to Photos,
157 // Save to Files, AirDrop. (The tap path's Quick Look carries the same
158 // button; this is the shortcut from the grid.) Staged like every other
159 // preview: a temp dir burned when the sheet closes.
160 private func share(_ att: Attachment) {
161 guard shareItem == nil else { return }
162 sharingID = att.id
163 Task {
164 defer { sharingID = nil }
165 guard let data = await model.blobData(att),
166 let url = stageShareFile(
167 data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
168 else { return }
169 shareItem = ShareItem(url: url)
170 }
171 }
172
173 private func stageShareFile(_ data: Data, name: String) -> URL? {
174 let dir = FileManager.default.temporaryDirectory
175 .appendingPathComponent("media-share-\(UUID().uuidString)", isDirectory: true)
176 do {
177 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
178 let url = dir.appendingPathComponent(name)
179 try data.write(to: url, options: .completeFileProtection)
180 shareCleanupDir = dir
181 return url
182 } catch {
183 return nil
184 }
185 }
186
187 private func cleanupShare() {
188 if let dir = shareCleanupDir { try? FileManager.default.removeItem(at: dir) }
189 shareCleanupDir = nil
190 }
191
192 @ViewBuilder private var content: some View {
193 let all = tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs
194 let entries = visible(all)
195 if entries.isEmpty {
196 Spacer()
197 if !loaded {
198 ProgressView()
199 } else if !ThreadMedia.filtered(all, hiddenUids: hiddenUids).isEmpty {
200 Text("Nothing matches.")
201 .foregroundStyle(.secondary)
202 } else {
203 Text(tab == "media" ? "No photos or videos shared yet."
204 : tab == "links" ? "No links shared yet." : "No files shared yet.")
205 .foregroundStyle(.secondary)
206 }
207 Spacer()
208 } else {
209 ScrollView {
210 LazyVStack(alignment: .leading, spacing: 6, pinnedViews: []) {
211 ForEach(ThreadMedia.bucketByMonth(entries)) { bucket in
212 Text(bucket.label)
213 .font(.footnote)
214 .foregroundStyle(.secondary)
215 .padding(.horizontal, 16)
216 .padding(.top, 10)
217 if tab == "media" {
218 mediaGrid(bucket.items, all: entries)
219 } else if tab == "links" {
220 ForEach(bucket.items) { e in linkRow(e) }
221 } else {
222 ForEach(bucket.items) { e in docRow(e) }
223 }
224 }
225 }
226 .padding(.bottom, 16)
227 }
228 }
229 }
230
231 private func mediaGrid(_ items: [ThreadMedia.Entry], all: [ThreadMedia.Entry]) -> some View {
232 LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 2)], spacing: 2) {
233 ForEach(items) { e in
234 if let att = e.att {
235 MediaGridTile(att: att, source: tileSource(att))
236 .onTapGesture {
237 viewer = MediaCollectionItem(entries: all,
238 index: all.firstIndex { $0.id == e.id } ?? 0)
239 }
240 .accessibilityLabel("\(att.isVideo ? "Video" : "Photo") from \(e.name.isEmpty ? "someone" : e.name), \(relTime(e.ts / 1000))")
241 .accessibilityAddTraits(.isButton)
242 .contextMenu {
243 Button { share(att) } label: {
244 Label("Save…", systemImage: "square.and.arrow.down")
245 }
246 Button { onJump(e.sid) } label: {
247 Label("Show in chat", systemImage: "arrow.uturn.left")
248 }
249 }
250 }
251 }
252 }
253 .padding(.horizontal, 2)
254 }
255
256 // The tile's thumbnail source — a video tile shows its (much smaller)
257 // poster frame, so the grid never decrypts whole videos.
258 private func tileSource(_ att: Attachment) -> AvatarSource? {
259 guard var source = model.blobSource(att) else { return nil }
260 if att.isVideo, let poster = att.poster { source.blobID = poster }
261 return source
262 }
263
264 private func linkRow(_ e: ThreadMedia.Entry) -> some View {
265 LinkUnfurlRow(e: e, unfurl: { await model.unfurl($0) },
266 peek: { model.cachedUnfurl($0) },
267 onTitle: { url, title in linkTitles[url] = title })
268 .contextMenu {
269 if let url = e.url {
270 Button { UIPasteboard.general.string = url.absoluteString } label: {
271 Label("Copy Link", systemImage: "doc.on.doc")
272 }
273 ShareLink(item: url) { Label("Share…", systemImage: "square.and.arrow.up") }
274 }
275 Button { onJump(e.sid) } label: {
276 Label("Show in chat", systemImage: "arrow.uturn.left")
277 }
278 }
279 }
280
281 private func docRow(_ e: ThreadMedia.Entry) -> some View {
282 Button { if let att = e.att { openDoc(att) } } label: {
283 HStack(spacing: 10) {
284 Text(fileIcon(e.att?.name))
285 .font(.title3)
286 VStack(alignment: .leading, spacing: 2) {
287 Text(e.att?.name ?? "file")
288 .font(.callout.weight(.medium))
289 .lineLimit(1)
290 Text(docSubline(e))
291 .font(.footnote)
292 .foregroundStyle(.secondary)
293 .lineLimit(1)
294 }
295 Spacer(minLength: 0)
296 if docBusy == e.att?.id || sharingID == e.att?.id {
297 ProgressView().controlSize(.small)
298 }
299 }
300 .padding(.horizontal, 16)
301 .padding(.vertical, 6)
302 .contentShape(Rectangle())
303 }
304 .buttonStyle(.plain)
305 .contextMenu {
306 if let att = e.att {
307 Button { share(att) } label: {
308 Label("Save…", systemImage: "square.and.arrow.down")
309 }
310 }
311 Button { onJump(e.sid) } label: {
312 Label("Show in chat", systemImage: "arrow.uturn.left")
313 }
314 }
315 }
316
317 private func docSubline(_ e: ThreadMedia.Entry) -> String {
318 var parts: [String] = []
319 if let size = e.att?.size, size > 0 { parts.append(formatBytes(size)) }
320 parts.append("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))")
321 return parts.joined(separator: " · ")
322 }
323
324 // A doc tap decrypts to a temp file and opens Quick Look — the feed's
325 // file-chip recipe (deliberate act; the dir is burned on dismiss).
326 private func openDoc(_ att: Attachment) {
327 guard docBusy == nil else { return }
328 docBusy = att.id
329 Task {
330 defer { docBusy = nil }
331 guard let data = await model.blobData(att) else { return }
332 let dir = FileManager.default.temporaryDirectory
333 .appendingPathComponent("media-doc-\(UUID().uuidString)", isDirectory: true)
334 let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type)
335 do {
336 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
337 let url = dir.appendingPathComponent(name)
338 try data.write(to: url, options: .completeFileProtection)
339 docCleanupDir = dir
340 docPreview = DocPreviewItem(url: url)
341 } catch {}
342 }
343 }
344
345 private func cleanupDoc() {
346 if let dir = docCleanupDir { try? FileManager.default.removeItem(at: dir) }
347 docCleanupDir = nil
348 }
349
350 // First load sweeps the whole store; each backfill page then folds ONLY
351 // the newly-filed (strictly older) rows — the web overlay's incremental
352 // onPage fold, not a full re-extract per tick (a 10k-message walk would
353 // otherwise re-decode every stored row on every page).
354 @State private var oldestSID: Int64 = 0
355 private func reload() async {
356 if oldestSID == 0 {
357 let rows = await MessageStore.shared.allMessages(instance: instance.id,
358 thread: thread.id)
359 tabs = ThreadMedia.extractAll(rows)
360 oldestSID = rows.last?.id ?? 0 // newest-first, so last = oldest
361 } else {
362 let older = await MessageStore.shared.page(instance: instance.id,
363 thread: thread.id,
364 before: oldestSID, limit: 50_000)
365 guard let first = older.first else { loaded = true; return }
366 // Strictly older rows: appending keeps each tab's newest-first order.
367 let ex = ThreadMedia.extractAll(older)
368 tabs.media.append(contentsOf: ex.media)
369 tabs.links.append(contentsOf: ex.links)
370 tabs.docs.append(contentsOf: ex.docs)
371 oldestSID = first.id // page() returns ascending, so first = oldest
372 }
373 loaded = true
374 // Every photo the thread now holds gets looked at once (on device).
375 imageIndex.ensure(instance: instance.id, entries: tabs.media,
376 load: { await model.blobData($0) })
377 }
378
379 // Auto-complete on open (spec § Completeness): quiet when the walk has
380 // already finished — from here or from search, whichever got there first.
381 private func startWalkIfIncomplete() {
382 Task {
383 let mark = await MessageStore.shared.backfillMark(instance: instance.id,
384 thread: thread.id)
385 guard !mark.complete else { return }
386 backfill.start(instance: instance, threadID: thread.id,
387 threadKind: thread.kind, threadKeyB64: thread.threadKeyB64)
388 }
389 }
390}
391
392// 🤖 The system share sheet over a staged file — Save to Photos, Save to
393// Files, AirDrop. SwiftUI's ShareLink needs its URL up front, and ours only
394// exists after an async decrypt, so the item drives a sheet instead.
395private struct ActivitySheet: UIViewControllerRepresentable {
396 let url: URL
397
398 func makeUIViewController(context: Context) -> UIActivityViewController {
399 UIActivityViewController(activityItems: [url], applicationActivities: nil)
400 }
401
402 func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
403}
404
405// One grid tile: BlurHash ghost immediately (the payload carries it), the
406// decrypted thumbnail when loaded — MessageBubble.AttachmentImage's recipe at
407// grid size. LazyVGrid only materialises visible tiles, so decryption stays
408// proportional to what's viewed (the web's IntersectionObserver, for free).
409private struct MediaGridTile: View {
410 let att: Attachment
411 let source: AvatarSource?
412
413 @State private var image: UIImage?
414 @State private var blur: UIImage?
415
416 var body: some View {
417 GeometryReader { geo in
418 ZStack(alignment: .bottomTrailing) {
419 Rectangle().fill(Color(.secondarySystemBackground))
420 if let shown = image ?? blur {
421 Image(uiImage: shown)
422 .resizable()
423 .aspectRatio(contentMode: .fill)
424 .frame(width: geo.size.width, height: geo.size.height)
425 .clipped()
426 }
427 if att.isVideo, let dur = att.dur, dur > 0 {
428 Text(formatDuration(dur))
429 .font(.caption2.weight(.semibold))
430 .foregroundStyle(.white)
431 .padding(.horizontal, 5)
432 .padding(.vertical, 1)
433 .background(.black.opacity(0.65), in: Capsule())
434 .padding(5)
435 }
436 }
437 }
438 .aspectRatio(1, contentMode: .fit)
439 .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
440 .task(id: att.id) {
441 if image == nil, let bh = att.bh, blur == nil,
442 let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value {
443 blur = UIImage(cgImage: cg)
444 }
445 guard image == nil, let source else { return }
446 image = await BlobImageLoader.shared.image(source, maxPixel: 320)
447 }
448 }
449}
450
451// A link occurrence: unfurl title (lazily fetched; a failed unfurl leaves the
452// plain row), the URL, sender + time. Tapping opens the browser.
453private struct LinkUnfurlRow: View {
454 let e: ThreadMedia.Entry
455 let unfurl: (URL) async -> LinkPreview?
456 let peek: (URL) -> LinkPreview??
457 let onTitle: (URL, String) -> Void // feeds the sheet's search-by-title memo
458
459 @Environment(\.openURL) private var openURL
460 @State private var title: String?
461
462 var body: some View {
463 Button { if let url = e.url { openURL(url) } } label: {
464 VStack(alignment: .leading, spacing: 2) {
465 if let title, !title.isEmpty {
466 Text(title)
467 .font(.callout.weight(.semibold))
468 .lineLimit(2)
469 }
470 Text(e.url?.absoluteString ?? "")
471 .font(.footnote)
472 .foregroundStyle(.secondary)
473 .lineLimit(1)
474 Text("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))")
475 .font(.footnote)
476 .foregroundStyle(.tertiary)
477 }
478 .frame(maxWidth: .infinity, alignment: .leading)
479 .padding(.horizontal, 16)
480 .padding(.vertical, 6)
481 .contentShape(Rectangle())
482 }
483 .buttonStyle(.plain)
484 .task(id: e.id) {
485 guard let url = e.url else { return }
486 if case .some(let hit) = peek(url) { title = hit?.title } else { title = await unfurl(url)?.title }
487 if let title, !title.isEmpty { onTitle(url, title) }
488 }
489 }
490}
491
492// 🤖 The media grid's collection viewer: QLPreviewController over EVERY entry
493// in the tab, staged lazily — an item's bytes decrypt to a temp file only
494// when swiped to (plus its neighbours, prefetched), so opening a thousand-
495// photo grid stages three files, not a thousand. Quick Look gives the
496// swipe-between-items, pinch-zoom and share chrome natively; videos play in
497// place (their real bytes are staged, not the poster). The whole temp dir is
498// burned on dismiss, so plaintext never lingers.
499private struct MediaCollectionPreview: UIViewControllerRepresentable {
500 let entries: [ThreadMedia.Entry]
501 let index: Int
502 let load: (Attachment) async -> Data?
503 let onClose: () -> Void
504
505 func makeCoordinator() -> Coordinator {
506 Coordinator(entries: entries, load: load, onClose: onClose)
507 }
508
509 func makeUIViewController(context: Context) -> UINavigationController {
510 let ql = QLPreviewController()
511 ql.dataSource = context.coordinator
512 ql.delegate = context.coordinator
513 ql.currentPreviewItemIndex = index
514 ql.navigationItem.leftBarButtonItem = UIBarButtonItem(
515 barButtonSystemItem: .done, target: context.coordinator,
516 action: #selector(Coordinator.done))
517 context.coordinator.attach(ql)
518 return UINavigationController(rootViewController: ql)
519 }
520
521 func updateUIViewController(_ controller: UINavigationController, context: Context) {}
522
523 final class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
524 private let entries: [ThreadMedia.Entry]
525 private let load: (Attachment) async -> Data?
526 private let onClose: () -> Void
527 private let dir: URL
528 private var staged: [Int: URL] = [:]
529 private var placeholders: [Int: URL] = [:] // BlurHash ghosts, shown while decrypting
530 private var staging: Set<Int> = []
531 private var indexWatch: NSKeyValueObservation?
532 private weak var controller: QLPreviewController?
533
534 init(entries: [ThreadMedia.Entry], load: @escaping (Attachment) async -> Data?,
535 onClose: @escaping () -> Void) {
536 self.entries = entries
537 self.load = load
538 self.onClose = onClose
539 self.dir = FileManager.default.temporaryDirectory
540 .appendingPathComponent("media-view-\(UUID().uuidString)", isDirectory: true)
541 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
542 }
543
544 func attach(_ ql: QLPreviewController) {
545 controller = ql
546 ensure(ql.currentPreviewItemIndex)
547 // Prefetch neighbours as the user swipes, so the next item is
548 // usually ready before it's on screen; staged files outside the
549 // window burn as the user moves on, so a long swipe-through never
550 // accumulates the whole collection's plaintext on disk.
551 indexWatch = ql.observe(\.currentPreviewItemIndex) { [weak self] ql, _ in
552 let i = ql.currentPreviewItemIndex
553 self?.ensure(i)
554 self?.ensure(i - 1, prefetch: true)
555 self?.ensure(i + 1, prefetch: true)
556 self?.prune(around: i)
557 }
558 }
559
560 // Keep only the current item's neighbourhood staged (±3 — enough that
561 // a quick back-swipe re-lands on a ready file).
562 private func prune(around i: Int) {
563 for (idx, url) in staged where abs(idx - i) > 3 {
564 staged[idx] = nil
565 try? FileManager.default.removeItem(at: url)
566 }
567 }
568
569 private func ensure(_ i: Int, prefetch: Bool = false) {
570 guard entries.indices.contains(i), staged[i] == nil, !staging.contains(i),
571 let att = entries[i].att else { return }
572 // A neighbour prefetch never decrypts a whole video — the app's
573 // standing rule (posters exist so it needn't); the real bytes
574 // stage only once the user actually lands on the clip.
575 if prefetch, att.isVideo { return }
576 staging.insert(i)
577 Task { @MainActor [weak self] in
578 guard let self else { return }
579 guard let data = await self.load(att) else { self.staging.remove(i); return }
580 // The index prefixes the filename so two same-named photos
581 // never collide in the shared dir.
582 let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type)
583 let url = self.dir.appendingPathComponent("\(i)-\(name)")
584 guard (try? data.write(to: url, options: .completeFileProtection)) != nil else {
585 self.staging.remove(i)
586 return
587 }
588 self.staged[i] = url
589 self.staging.remove(i)
590 // The current page was waiting on these bytes — swap the
591 // loading placeholder for the real file in place.
592 if let ql = self.controller, ql.currentPreviewItemIndex == i {
593 ql.refreshCurrentPreviewItem()
594 }
595 }
596 }
597
598 func numberOfPreviewItems(in controller: QLPreviewController) -> Int { entries.count }
599
600 func previewController(_ controller: QLPreviewController,
601 previewItemAt index: Int) -> QLPreviewItem {
602 ensure(index)
603 // Quick Look has no loading state: handed a nil URL it flashes
604 // "Unsupported file format". So an item that hasn't finished
605 // decrypting previews its BlurHash ghost — the same blur the grid
606 // tile shows — and refreshCurrentPreviewItem swaps in the real
607 // file the moment the bytes land.
608 return LazyItem(url: staged[index] ?? placeholder(index))
609 }
610
611 // The ghost file for an item, written once: its BlurHash at a size
612 // Quick Look can scale up, or a plain dark tile when the payload
613 // carries no hash. Same temp dir, so it burns with everything else.
614 private func placeholder(_ i: Int) -> URL? {
615 if let hit = placeholders[i] { return hit }
616 guard entries.indices.contains(i) else { return nil }
617 let att = entries[i].stillAttachment ?? entries[i].att
618 let url = dir.appendingPathComponent("\(i)-loading.jpg")
619 // Match the photo's aspect so the blur doesn't jump on swap.
620 let w = 48
621 let h = max(1, Int((Double(att?.h ?? 3) / Double(max(att?.w ?? 4, 1)) * Double(w)).rounded()))
622 var image: UIImage?
623 if let bh = att?.bh, let cg = Blurhash.decode(bh, width: w, height: h) {
624 image = UIImage(cgImage: cg)
625 } else {
626 let renderer = UIGraphicsImageRenderer(size: CGSize(width: w, height: h))
627 image = renderer.image { ctx in
628 UIColor.secondarySystemBackground.setFill()
629 ctx.fill(CGRect(x: 0, y: 0, width: w, height: h))
630 }
631 }
632 guard let data = image?.jpegData(compressionQuality: 0.8),
633 (try? data.write(to: url, options: .completeFileProtection)) != nil else { return nil }
634 placeholders[i] = url
635 return url
636 }
637
638 @objc func done() { onClose() }
639
640 func previewControllerDidDismiss(_ controller: QLPreviewController) { onClose() }
641
642 deinit {
643 indexWatch?.invalidate()
644 try? FileManager.default.removeItem(at: dir)
645 }
646 }
647
648 private final class LazyItem: NSObject, QLPreviewItem {
649 let previewItemURL: URL?
650 init(url: URL?) { previewItemURL = url }
651 }
652}
653