| 1 | import LChatCore |
| 2 | import QuickLook |
| 3 | import 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. |
| 12 | struct 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. |
| 395 | private 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). |
| 409 | private 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. |
| 453 | private 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. |
| 499 | private 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 | |