import LChatCore import QuickLook import SwiftUI // 🤖 "Media, Links & Docs" — the per-thread inventory of everything ever // shared in a chat (spec: docs/superpowers/specs/ // 2026-08-05-media-links-docs-design.md § Native; web twin mediaview.js). // Three tabs over ThreadMedia's extraction of the local store's decrypted // rows; opening auto-runs the per-thread history walk (HistoryBackfill — // marks shared with search, so either finishing the walk finishes both). // Zero server involvement: the store holds what the feed already decrypted. struct MediaLinksDocsSheet: View { let instance: SavedInstance let thread: ChatThread @ObservedObject var store: ThreadStore let model: ChatFeedModel // "Show in chat": the parent dismisses this sheet and lands the feed on // the message (NativeChatScreen's pendingJump walk). let onJump: (Int64) -> Void @Environment(\.dismiss) private var dismiss @State private var tabs = ThreadMedia.Tabs() @State private var loaded = false @State private var tab = "media" // The sheet's own walker — deliberately not shared with the search UI's // instance-wide runner; the store marks are the shared state. @StateObject private var backfill = HistoryBackfill() // What's IN each photo (Vision, on device) — search matches "dog" or the // text on a whiteboard. Indexes the thread's stills on open, resumably. @StateObject private var imageIndex = ImageSearchIndex() @State private var query = "" // Unfurl titles the Links tab has fetched so far, so search matches a // link by title too (a title only exists once its row was on screen — // search never triggers fetches of its own). @State private var linkTitles: [URL: String] = [:] // A tapped tile's collection viewer (Quick Look, staged lazily). @State private var viewer: MediaCollectionItem? struct MediaCollectionItem: Identifiable { let id = UUID() let entries: [ThreadMedia.Entry] let index: Int } // A tapped doc, staged for the single-file Quick Look (FilePreview's). @State private var docPreview: DocPreviewItem? @State private var docCleanupDir: URL? @State private var docBusy: String? // att id mid-decrypt — one at a time struct DocPreviewItem: Identifiable { let id = UUID() let url: URL } // A "Save…" from the grid/rows: the staged file behind the system share // sheet (Save to Photos / Files / AirDrop). @State private var shareItem: ShareItem? @State private var shareCleanupDir: URL? @State private var sharingID: String? struct ShareItem: Identifiable { let id = UUID() let url: URL } // Muted/blocked senders hide at query time (web hiddenUids) — un-muting // needs no re-extraction. private var hiddenUids: Set { store.mutedPeople.union(store.blockedPeople) } // Moderation applies always; the query narrows the open tab (and the // viewer's collection with it, so swipes walk the search results). private func visible(_ entries: [ThreadMedia.Entry]) -> [ThreadMedia.Entry] { ThreadMedia.filtered(ThreadMedia.filtered(entries, hiddenUids: hiddenUids), query: query, titleFor: { linkTitles[$0] }, imageTextFor: { imageIndex.textFor($0) }) } private var counts: (media: Int, links: Int, docs: Int) { (visible(tabs.media).count, visible(tabs.links).count, visible(tabs.docs).count) } var body: some View { NavigationStack { VStack(spacing: 0) { Picker("Kind", selection: $tab) { Text(segLabel("Media", counts.media)).tag("media") Text(segLabel("Links", counts.links)).tag("links") Text(segLabel("Docs", counts.docs)).tag("docs") } .pickerStyle(.segmented) .padding(.horizontal, 16) .padding(.vertical, 8) if backfill.running { progressRow(backfill.progress ?? "Loading earlier messages…") { backfill.cancel() } } if imageIndex.running { progressRow(imageIndex.progress ?? "Indexing photos…") { imageIndex.cancel() } } content } .navigationTitle("Media, links and docs") .navigationBarTitleDisplayMode(.inline) // Search within the open tab — sender, URL/title, filename // (ThreadMedia.filtered(query:)), the system bar-drawer field // the people picker uses. .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } .task(id: backfill.pageTick) { await reload() } .onAppear { startWalkIfIncomplete() } .onDisappear { backfill.cancel() imageIndex.cancel() } .fullScreenCover(item: $viewer) { item in MediaCollectionPreview(entries: item.entries, index: item.index, load: { await model.blobData($0) }, onClose: { viewer = nil }) .ignoresSafeArea() } .sheet(item: $docPreview, onDismiss: cleanupDoc) { item in QuickLookPreview(url: item.url) { docPreview = nil } .ignoresSafeArea() } .sheet(item: $shareItem, onDismiss: cleanupShare) { item in ActivitySheet(url: item.url) } } // "Media · 12" — counts pluralise implicitly (the label is the kind) and // localise (formatted() delimits digits per locale). private func segLabel(_ label: String, _ n: Int) -> String { n > 0 ? "\(label) · \(n.formatted())" : label } // Inline, occupying layout (never a floating toast): a walk's narration // plus its Stop — the history walk and the photo indexer share the shape. private func progressRow(_ text: String, stop: @escaping () -> Void) -> some View { HStack(spacing: 8) { // The spinner is the "still working" signal: a count that sits on // "0 of 5" while a slow photo decrypts otherwise reads as stuck. ProgressView() .controlSize(.mini) Text(text) .font(.footnote) .foregroundStyle(.secondary) Spacer(minLength: 0) Button("Stop", action: stop) .font(.footnote.weight(.semibold)) } .padding(.horizontal, 16) .padding(.bottom, 6) .accessibilityElement(children: .combine) } // Hand a photo/video/file to the system share sheet — Save to Photos, // Save to Files, AirDrop. (The tap path's Quick Look carries the same // button; this is the shortcut from the grid.) Staged like every other // preview: a temp dir burned when the sheet closes. private func share(_ att: Attachment) { guard shareItem == nil else { return } sharingID = att.id Task { defer { sharingID = nil } guard let data = await model.blobData(att), let url = stageShareFile( data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) else { return } shareItem = ShareItem(url: url) } } private func stageShareFile(_ data: Data, name: String) -> URL? { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("media-share-\(UUID().uuidString)", isDirectory: true) do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent(name) try data.write(to: url, options: .completeFileProtection) shareCleanupDir = dir return url } catch { return nil } } private func cleanupShare() { if let dir = shareCleanupDir { try? FileManager.default.removeItem(at: dir) } shareCleanupDir = nil } @ViewBuilder private var content: some View { let all = tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs let entries = visible(all) if entries.isEmpty { Spacer() if !loaded { ProgressView() } else if !ThreadMedia.filtered(all, hiddenUids: hiddenUids).isEmpty { Text("Nothing matches.") .foregroundStyle(.secondary) } else { Text(tab == "media" ? "No photos or videos shared yet." : tab == "links" ? "No links shared yet." : "No files shared yet.") .foregroundStyle(.secondary) } Spacer() } else { ScrollView { LazyVStack(alignment: .leading, spacing: 6, pinnedViews: []) { ForEach(ThreadMedia.bucketByMonth(entries)) { bucket in Text(bucket.label) .font(.footnote) .foregroundStyle(.secondary) .padding(.horizontal, 16) .padding(.top, 10) if tab == "media" { mediaGrid(bucket.items, all: entries) } else if tab == "links" { ForEach(bucket.items) { e in linkRow(e) } } else { ForEach(bucket.items) { e in docRow(e) } } } } .padding(.bottom, 16) } } } private func mediaGrid(_ items: [ThreadMedia.Entry], all: [ThreadMedia.Entry]) -> some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 2)], spacing: 2) { ForEach(items) { e in if let att = e.att { MediaGridTile(att: att, source: tileSource(att)) .onTapGesture { viewer = MediaCollectionItem(entries: all, index: all.firstIndex { $0.id == e.id } ?? 0) } .accessibilityLabel("\(att.isVideo ? "Video" : "Photo") from \(e.name.isEmpty ? "someone" : e.name), \(relTime(e.ts / 1000))") .accessibilityAddTraits(.isButton) .contextMenu { Button { share(att) } label: { Label("Save…", systemImage: "square.and.arrow.down") } Button { onJump(e.sid) } label: { Label("Show in chat", systemImage: "arrow.uturn.left") } } } } } .padding(.horizontal, 2) } // The tile's thumbnail source — a video tile shows its (much smaller) // poster frame, so the grid never decrypts whole videos. private func tileSource(_ att: Attachment) -> AvatarSource? { guard var source = model.blobSource(att) else { return nil } if att.isVideo, let poster = att.poster { source.blobID = poster } return source } private func linkRow(_ e: ThreadMedia.Entry) -> some View { LinkUnfurlRow(e: e, unfurl: { await model.unfurl($0) }, peek: { model.cachedUnfurl($0) }, onTitle: { url, title in linkTitles[url] = title }) .contextMenu { if let url = e.url { Button { UIPasteboard.general.string = url.absoluteString } label: { Label("Copy Link", systemImage: "doc.on.doc") } ShareLink(item: url) { Label("Share…", systemImage: "square.and.arrow.up") } } Button { onJump(e.sid) } label: { Label("Show in chat", systemImage: "arrow.uturn.left") } } } private func docRow(_ e: ThreadMedia.Entry) -> some View { Button { if let att = e.att { openDoc(att) } } label: { HStack(spacing: 10) { Text(fileIcon(e.att?.name)) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text(e.att?.name ?? "file") .font(.callout.weight(.medium)) .lineLimit(1) Text(docSubline(e)) .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 0) if docBusy == e.att?.id || sharingID == e.att?.id { ProgressView().controlSize(.small) } } .padding(.horizontal, 16) .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { if let att = e.att { Button { share(att) } label: { Label("Save…", systemImage: "square.and.arrow.down") } } Button { onJump(e.sid) } label: { Label("Show in chat", systemImage: "arrow.uturn.left") } } } private func docSubline(_ e: ThreadMedia.Entry) -> String { var parts: [String] = [] if let size = e.att?.size, size > 0 { parts.append(formatBytes(size)) } parts.append("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))") return parts.joined(separator: " · ") } // A doc tap decrypts to a temp file and opens Quick Look — the feed's // file-chip recipe (deliberate act; the dir is burned on dismiss). private func openDoc(_ att: Attachment) { guard docBusy == nil else { return } docBusy = att.id Task { defer { docBusy = nil } guard let data = await model.blobData(att) else { return } let dir = FileManager.default.temporaryDirectory .appendingPathComponent("media-doc-\(UUID().uuidString)", isDirectory: true) let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type) do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent(name) try data.write(to: url, options: .completeFileProtection) docCleanupDir = dir docPreview = DocPreviewItem(url: url) } catch {} } } private func cleanupDoc() { if let dir = docCleanupDir { try? FileManager.default.removeItem(at: dir) } docCleanupDir = nil } // First load sweeps the whole store; each backfill page then folds ONLY // the newly-filed (strictly older) rows — the web overlay's incremental // onPage fold, not a full re-extract per tick (a 10k-message walk would // otherwise re-decode every stored row on every page). @State private var oldestSID: Int64 = 0 private func reload() async { if oldestSID == 0 { let rows = await MessageStore.shared.allMessages(instance: instance.id, thread: thread.id) tabs = ThreadMedia.extractAll(rows) oldestSID = rows.last?.id ?? 0 // newest-first, so last = oldest } else { let older = await MessageStore.shared.page(instance: instance.id, thread: thread.id, before: oldestSID, limit: 50_000) guard let first = older.first else { loaded = true; return } // Strictly older rows: appending keeps each tab's newest-first order. let ex = ThreadMedia.extractAll(older) tabs.media.append(contentsOf: ex.media) tabs.links.append(contentsOf: ex.links) tabs.docs.append(contentsOf: ex.docs) oldestSID = first.id // page() returns ascending, so first = oldest } loaded = true // Every photo the thread now holds gets looked at once (on device). imageIndex.ensure(instance: instance.id, entries: tabs.media, load: { await model.blobData($0) }) } // Auto-complete on open (spec § Completeness): quiet when the walk has // already finished — from here or from search, whichever got there first. private func startWalkIfIncomplete() { Task { let mark = await MessageStore.shared.backfillMark(instance: instance.id, thread: thread.id) guard !mark.complete else { return } backfill.start(instance: instance, threadID: thread.id, threadKind: thread.kind, threadKeyB64: thread.threadKeyB64) } } } // 🤖 The system share sheet over a staged file — Save to Photos, Save to // Files, AirDrop. SwiftUI's ShareLink needs its URL up front, and ours only // exists after an async decrypt, so the item drives a sheet instead. private struct ActivitySheet: UIViewControllerRepresentable { let url: URL func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: [url], applicationActivities: nil) } func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} } // One grid tile: BlurHash ghost immediately (the payload carries it), the // decrypted thumbnail when loaded — MessageBubble.AttachmentImage's recipe at // grid size. LazyVGrid only materialises visible tiles, so decryption stays // proportional to what's viewed (the web's IntersectionObserver, for free). private struct MediaGridTile: View { let att: Attachment let source: AvatarSource? @State private var image: UIImage? @State private var blur: UIImage? var body: some View { GeometryReader { geo in ZStack(alignment: .bottomTrailing) { Rectangle().fill(Color(.secondarySystemBackground)) if let shown = image ?? blur { Image(uiImage: shown) .resizable() .aspectRatio(contentMode: .fill) .frame(width: geo.size.width, height: geo.size.height) .clipped() } if att.isVideo, let dur = att.dur, dur > 0 { Text(formatDuration(dur)) .font(.caption2.weight(.semibold)) .foregroundStyle(.white) .padding(.horizontal, 5) .padding(.vertical, 1) .background(.black.opacity(0.65), in: Capsule()) .padding(5) } } } .aspectRatio(1, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .task(id: att.id) { if image == nil, let bh = att.bh, blur == nil, let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value { blur = UIImage(cgImage: cg) } guard image == nil, let source else { return } image = await BlobImageLoader.shared.image(source, maxPixel: 320) } } } // A link occurrence: unfurl title (lazily fetched; a failed unfurl leaves the // plain row), the URL, sender + time. Tapping opens the browser. private struct LinkUnfurlRow: View { let e: ThreadMedia.Entry let unfurl: (URL) async -> LinkPreview? let peek: (URL) -> LinkPreview?? let onTitle: (URL, String) -> Void // feeds the sheet's search-by-title memo @Environment(\.openURL) private var openURL @State private var title: String? var body: some View { Button { if let url = e.url { openURL(url) } } label: { VStack(alignment: .leading, spacing: 2) { if let title, !title.isEmpty { Text(title) .font(.callout.weight(.semibold)) .lineLimit(2) } Text(e.url?.absoluteString ?? "") .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) Text("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))") .font(.footnote) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) .task(id: e.id) { guard let url = e.url else { return } if case .some(let hit) = peek(url) { title = hit?.title } else { title = await unfurl(url)?.title } if let title, !title.isEmpty { onTitle(url, title) } } } } // 🤖 The media grid's collection viewer: QLPreviewController over EVERY entry // in the tab, staged lazily — an item's bytes decrypt to a temp file only // when swiped to (plus its neighbours, prefetched), so opening a thousand- // photo grid stages three files, not a thousand. Quick Look gives the // swipe-between-items, pinch-zoom and share chrome natively; videos play in // place (their real bytes are staged, not the poster). The whole temp dir is // burned on dismiss, so plaintext never lingers. private struct MediaCollectionPreview: UIViewControllerRepresentable { let entries: [ThreadMedia.Entry] let index: Int let load: (Attachment) async -> Data? let onClose: () -> Void func makeCoordinator() -> Coordinator { Coordinator(entries: entries, load: load, onClose: onClose) } func makeUIViewController(context: Context) -> UINavigationController { let ql = QLPreviewController() ql.dataSource = context.coordinator ql.delegate = context.coordinator ql.currentPreviewItemIndex = index ql.navigationItem.leftBarButtonItem = UIBarButtonItem( barButtonSystemItem: .done, target: context.coordinator, action: #selector(Coordinator.done)) context.coordinator.attach(ql) return UINavigationController(rootViewController: ql) } func updateUIViewController(_ controller: UINavigationController, context: Context) {} final class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { private let entries: [ThreadMedia.Entry] private let load: (Attachment) async -> Data? private let onClose: () -> Void private let dir: URL private var staged: [Int: URL] = [:] private var placeholders: [Int: URL] = [:] // BlurHash ghosts, shown while decrypting private var staging: Set = [] private var indexWatch: NSKeyValueObservation? private weak var controller: QLPreviewController? init(entries: [ThreadMedia.Entry], load: @escaping (Attachment) async -> Data?, onClose: @escaping () -> Void) { self.entries = entries self.load = load self.onClose = onClose self.dir = FileManager.default.temporaryDirectory .appendingPathComponent("media-view-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } func attach(_ ql: QLPreviewController) { controller = ql ensure(ql.currentPreviewItemIndex) // Prefetch neighbours as the user swipes, so the next item is // usually ready before it's on screen; staged files outside the // window burn as the user moves on, so a long swipe-through never // accumulates the whole collection's plaintext on disk. indexWatch = ql.observe(\.currentPreviewItemIndex) { [weak self] ql, _ in let i = ql.currentPreviewItemIndex self?.ensure(i) self?.ensure(i - 1, prefetch: true) self?.ensure(i + 1, prefetch: true) self?.prune(around: i) } } // Keep only the current item's neighbourhood staged (±3 — enough that // a quick back-swipe re-lands on a ready file). private func prune(around i: Int) { for (idx, url) in staged where abs(idx - i) > 3 { staged[idx] = nil try? FileManager.default.removeItem(at: url) } } private func ensure(_ i: Int, prefetch: Bool = false) { guard entries.indices.contains(i), staged[i] == nil, !staging.contains(i), let att = entries[i].att else { return } // A neighbour prefetch never decrypts a whole video — the app's // standing rule (posters exist so it needn't); the real bytes // stage only once the user actually lands on the clip. if prefetch, att.isVideo { return } staging.insert(i) Task { @MainActor [weak self] in guard let self else { return } guard let data = await self.load(att) else { self.staging.remove(i); return } // The index prefixes the filename so two same-named photos // never collide in the shared dir. let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type) let url = self.dir.appendingPathComponent("\(i)-\(name)") guard (try? data.write(to: url, options: .completeFileProtection)) != nil else { self.staging.remove(i) return } self.staged[i] = url self.staging.remove(i) // The current page was waiting on these bytes — swap the // loading placeholder for the real file in place. if let ql = self.controller, ql.currentPreviewItemIndex == i { ql.refreshCurrentPreviewItem() } } } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { entries.count } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { ensure(index) // Quick Look has no loading state: handed a nil URL it flashes // "Unsupported file format". So an item that hasn't finished // decrypting previews its BlurHash ghost — the same blur the grid // tile shows — and refreshCurrentPreviewItem swaps in the real // file the moment the bytes land. return LazyItem(url: staged[index] ?? placeholder(index)) } // The ghost file for an item, written once: its BlurHash at a size // Quick Look can scale up, or a plain dark tile when the payload // carries no hash. Same temp dir, so it burns with everything else. private func placeholder(_ i: Int) -> URL? { if let hit = placeholders[i] { return hit } guard entries.indices.contains(i) else { return nil } let att = entries[i].stillAttachment ?? entries[i].att let url = dir.appendingPathComponent("\(i)-loading.jpg") // Match the photo's aspect so the blur doesn't jump on swap. let w = 48 let h = max(1, Int((Double(att?.h ?? 3) / Double(max(att?.w ?? 4, 1)) * Double(w)).rounded())) var image: UIImage? if let bh = att?.bh, let cg = Blurhash.decode(bh, width: w, height: h) { image = UIImage(cgImage: cg) } else { let renderer = UIGraphicsImageRenderer(size: CGSize(width: w, height: h)) image = renderer.image { ctx in UIColor.secondarySystemBackground.setFill() ctx.fill(CGRect(x: 0, y: 0, width: w, height: h)) } } guard let data = image?.jpegData(compressionQuality: 0.8), (try? data.write(to: url, options: .completeFileProtection)) != nil else { return nil } placeholders[i] = url return url } @objc func done() { onClose() } func previewControllerDidDismiss(_ controller: QLPreviewController) { onClose() } deinit { indexWatch?.invalidate() try? FileManager.default.removeItem(at: dir) } } private final class LazyItem: NSObject, QLPreviewItem { let previewItemURL: URL? init(url: URL?) { previewItemURL = url } } }