import LChatCore import SwiftUI // 🤖 "Media, Links & Docs" on the Mac — the per-thread inventory of shared // photos/videos, links and files (spec: docs/superpowers/specs/ // 2026-08-05-media-links-docs-design.md § Native; iOS twin // MediaLinksDocsSheet.swift, web twin mediaview.js). Same shared extraction // (ThreadMedia over MessageStore) and the same auto-run per-thread history // walk. It lives in its own RESIZABLE window (MediaWindows) rather than a // sheet — see that file for why. Viewing honours the Mac's deliberate // anti-QuickLook stance (Lightbox.swift: decrypted bytes never touch disk // uninvited): photos open an in-window viewer with ‹ › / arrow-key // collection navigation, all in memory; a video tile shows its poster there // (playing rides "Show in chat", where the feed's in-memory player lives); a // doc tap saves to ~/Downloads — the feed's own file behaviour. struct MediaLinksDocsSheet: View { let instance: SavedInstance let thread: ChatThread @ObservedObject var store: ThreadStore let model: ChatFeedModel let onJump: (Int64) -> Void @Environment(\.openURL) private var openURL @State private var tabs = ThreadMedia.Tabs() @State private var loaded = false @State private var tab = "media" @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() // The in-sheet photo viewer's position in the media tab (nil = closed). @State private var viewerIndex: Int? @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] = [:] @State private var savingID: String? // one save at a time (blob id) 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 ‹ › walk the search results). private func visible(_ entries: [ThreadMedia.Entry]) -> [ThreadMedia.Entry] { // `_ = imageIndex.tick` isn't needed: the @StateObject's @Published // tick re-renders the body, which re-runs this filter. ThreadMedia.filtered(ThreadMedia.filtered(entries, hiddenUids: hiddenUids), query: query, titleFor: { linkTitles[$0] }, imageTextFor: { imageIndex.textFor($0) }) } private var mediaEntries: [ThreadMedia.Entry] { visible(tabs.media) } var body: some View { VStack(spacing: 0) { Picker("Kind", selection: $tab) { Text(segLabel("Media", mediaEntries.count)).tag("media") Text(segLabel("Links", ThreadMedia.filtered(tabs.links, hiddenUids: hiddenUids).count)).tag("links") Text(segLabel("Docs", ThreadMedia.filtered(tabs.docs, hiddenUids: hiddenUids).count)).tag("docs") } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, 12) .padding(.top, 10) // Search within the open tab — sender, URL/title, filename // (ThreadMedia.filtered(query:)); the sidebar search's shape. HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(.tertiary) TextField("Search", text: $query) .textFieldStyle(.plain) .font(.system(size: 13)) .accessibilityLabel("Search media, links and docs") if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) .accessibilityLabel("Clear search") } } .padding(.horizontal, 8) .padding(.vertical, 5) .background(RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(.primary.opacity(0.06))) .padding(.horizontal, 12) .padding(.top, 8) if backfill.running { progressRow(backfill.progress ?? "Loading earlier messages…") { backfill.cancel() } } if imageIndex.running { progressRow(imageIndex.progress ?? "Indexing photos…") { imageIndex.cancel() } } content .padding(.top, 6) } // The window (MediaWindows) owns the size; the content just fills it, // with a floor so the grid never collapses. .frame(minWidth: 480, minHeight: 400) .overlay { if let i = viewerIndex, mediaEntries.indices.contains(i) { MediaViewerOverlay(entries: mediaEntries, index: i, source: { viewerSource($0) }, onStep: { viewerIndex = $0 }, onSave: { if let att = mediaEntries[i].att { saveToDownloads(att) } }, onClose: { viewerIndex = nil }) } } .task(id: backfill.pageTick) { await reload() } .onAppear { startWalkIfIncomplete() } .onDisappear { backfill.cancel() imageIndex.cancel() } } 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(.small) .scaleEffect(0.7) .frame(width: 12, height: 12) Text(text) .font(.system(size: 12)) .foregroundStyle(.secondary) Spacer(minLength: 0) Button("Stop", action: stop) .buttonStyle(.plain) .font(.system(size: 12, weight: .semibold)) } .padding(.horizontal, 12) .padding(.top, 6) .accessibilityElement(children: .combine) } // Save a photo/video/file to ~/Downloads and reveal it — the Mac feed's // own file behaviour (a deliberate save, never a silent temp file). private func saveToDownloads(_ att: Attachment) { guard savingID == nil else { return } savingID = att.id Task { defer { savingID = nil } guard let data = await model.blobData(att) else { return } let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type) let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let dest = uniqueDestination(in: dir, filename: name) guard (try? data.write(to: dest, options: .atomic)) != nil else { return } NSWorkspace.shared.activateFileViewerSelecting([dest]) } } // Never overwrite: "name 2.ext", the Finder rule (NativeFeedView's twin). private func uniqueDestination(in dir: URL, filename: String) -> URL { let fm = FileManager.default let name = filename.isEmpty ? "download" : filename var candidate = dir.appendingPathComponent(name) guard fm.fileExists(atPath: candidate.path) else { return candidate } let ext = (name as NSString).pathExtension let base = (name as NSString).deletingPathExtension var n = 2 repeat { let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)" candidate = dir.appendingPathComponent(next) n += 1 } while fm.fileExists(atPath: candidate.path) return candidate } @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) { ForEach(ThreadMedia.bucketByMonth(entries)) { bucket in Text(bucket.label) .font(.system(size: 11)) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.top, 8) 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, 12) } } } private func mediaGrid(_ items: [ThreadMedia.Entry], all: [ThreadMedia.Entry]) -> some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 118), spacing: 2)], spacing: 2) { ForEach(items) { e in if let att = e.att { MediaGridTile(att: att, source: tileSource(att)) .onTapGesture { viewerIndex = 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("Save to Downloads…") { saveToDownloads(att) } Button("Show in Chat") { onJump(e.sid) } } } } } .padding(.horizontal, 2) } // Video tiles (and the viewer) show the poster frame — whole videos never // decrypt for a thumbnail. 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 viewerSource(_ e: ThreadMedia.Entry) -> AvatarSource? { e.att.flatMap { tileSource($0) } } private func linkRow(_ e: ThreadMedia.Entry) -> some View { LinkUnfurlRow(e: e, unfurl: { await model.unfurl($0) }, peek: { model.cachedUnfurl($0) }, open: { openURL($0) }, onTitle: { url, title in linkTitles[url] = title }) .contextMenu { if let url = e.url { Button("Open in Browser") { openURL(url) } Button("Copy Link") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.absoluteString, forType: .string) } } Button("Show in Chat") { onJump(e.sid) } } } private func docRow(_ e: ThreadMedia.Entry) -> some View { Button { if let att = e.att { saveToDownloads(att) } } label: { HStack(spacing: 10) { Text(fileIcon(e.att?.name)) .font(.system(size: 18)) VStack(alignment: .leading, spacing: 1) { Text(e.att?.name ?? "file") .font(.system(size: 13, weight: .medium)) .lineLimit(1) Text(docSubline(e)) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 0) // The save is a fetch + decrypt; show it's running rather // than leaving the row inert until Finder pops. if savingID == e.att?.id { ProgressView() .controlSize(.small) .scaleEffect(0.7) .frame(width: 12, height: 12) } } .padding(.horizontal, 14) .padding(.vertical, 5) .contentShape(Rectangle()) } .buttonStyle(.plain) .modifier(RowHover()) .contextMenu { if let att = e.att { Button("Save to Downloads…") { saveToDownloads(att) } } Button("Show in Chat") { onJump(e.sid) } } } 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: " · ") } // 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. @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) }) } 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) } } } // One grid tile: BlurHash ghost immediately, decrypted thumbnail when loaded. // LazyVGrid materialises only visible tiles, so decryption stays proportional // to what's viewed. private struct MediaGridTile: View { let att: Attachment let source: AvatarSource? @State private var image: NSImage? @State private var blur: NSImage? var body: some View { GeometryReader { geo in ZStack(alignment: .bottomTrailing) { Rectangle().fill(Color(nsColor: .quaternarySystemFill)) if let shown = image ?? blur { Image(nsImage: 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(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 5) .padding(.vertical, 1) .background(.black.opacity(0.65), in: Capsule()) .padding(4) } } } .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 = NSImage(cgImage: cg, size: .zero) } guard image == nil, let source else { return } image = await BlobImageLoader.shared.image(source, maxPixel: 320) } } } private struct LinkUnfurlRow: View { let e: ThreadMedia.Entry let unfurl: (URL) async -> LinkPreview? let peek: (URL) -> LinkPreview?? let open: (URL) -> Void let onTitle: (URL, String) -> Void // feeds the sheet's search-by-title memo @State private var title: String? var body: some View { Button { if let url = e.url { open(url) } } label: { VStack(alignment: .leading, spacing: 1) { if let title, !title.isEmpty { Text(title) .font(.system(size: 13, weight: .semibold)) .lineLimit(2) } Text(e.url?.absoluteString ?? "") .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) Text("\(e.name.isEmpty ? "Someone" : e.name) · \(relTime(e.ts / 1000))") .font(.system(size: 11)) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 5) .contentShape(Rectangle()) } .buttonStyle(.plain) .modifier(RowHover()) .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() } .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) } } } } // Rows highlight under the pointer — the sidebar hover idiom // (NativeChatView's `.primary.opacity(0.08)` wash), inset a touch so the // wash doesn't kiss the sheet's edges. private struct RowHover: ViewModifier { @State private var hovering = false func body(content: Content) -> some View { content .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(.primary.opacity(hovering ? 0.07 : 0)) .padding(.horizontal, 6)) .onHover { hovering = $0 } } } // 🤖 The in-window collection viewer: the current photo (or a video's poster) // over a scrim, ‹ › buttons + ←/→ arrow keys stepping through the whole tab, // clamped at the ends (no wrap — the web lightbox's stepIndex rule). All in // memory via BlobImageLoader; Escape (or a scrim click) closes the viewer // without closing the window. // // Pinch to zoom, drag to pan, double-click to toggle — iOS gets all three // from Quick Look, so the Mac hand-rolls them to keep the two in step (the // feed's own lightbox deliberately doesn't zoom; here it's the whole point, // since this is where you go to read the text in a screenshot). Zoom resets // when you step to another photo, so every arrival starts fitted. private struct MediaViewerOverlay: View { let entries: [ThreadMedia.Entry] let index: Int let source: (ThreadMedia.Entry) -> AvatarSource? let onStep: (Int) -> Void let onSave: () -> Void let onClose: () -> Void @State private var image: NSImage? // Live gesture values plus what they settle to, so a second pinch picks // up where the last left off (the feed lightbox's steady/live split). @State private var scale: CGFloat = 1 @State private var steadyScale: CGFloat = 1 @State private var offset: CGSize = .zero @State private var steadyOffset: CGSize = .zero private let maxScale: CGFloat = 8 var body: some View { ZStack { Color.black.opacity(0.82) .onTapGesture { onClose() } if let image { // Fills the (resizable) window, so a screenshot full of small // text is readable by growing the window — then zooming in. Image(nsImage: image) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .scaleEffect(scale) .offset(offset) .gesture(magnify.simultaneously(with: pan)) .onTapGesture(count: 2) { toggleZoom() } .padding(.horizontal, 12) .padding(.top, 44) .padding(.bottom, 12) .clipped() // a zoomed photo stays inside the viewer .accessibilityLabel("Photo \((index + 1).formatted()) of \(entries.count.formatted())") } else { ProgressView() .controlSize(.large) .tint(.white) } HStack { stepButton("chevron.left", "Previous photo", enabled: index > 0) { onStep(index - 1) } Spacer() stepButton("chevron.right", "Next photo", enabled: index < entries.count - 1) { onStep(index + 1) } } .padding(.horizontal, 8) VStack { HStack(spacing: 10) { Spacer() Button(action: onSave) { Label("Save", systemImage: "arrow.down.circle") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) .background(.black.opacity(0.45), in: Capsule()) } .buttonStyle(.plain) .keyboardShortcut("s", modifiers: .command) .accessibilityLabel("Save photo to Downloads") .arrowCursor() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 20)) .foregroundStyle(.white.opacity(0.8)) } .buttonStyle(.plain) .keyboardShortcut(.cancelAction) .accessibilityLabel("Close viewer") .arrowCursor() } Spacer() } .padding(10) // Hidden arrow-key handlers — ← / → step the collection. Button("") { if index > 0 { onStep(index - 1) } } .keyboardShortcut(.leftArrow, modifiers: []) .opacity(0).frame(width: 0, height: 0) Button("") { if index < entries.count - 1 { onStep(index + 1) } } .keyboardShortcut(.rightArrow, modifiers: []) .opacity(0).frame(width: 0, height: 0) } .task(id: entries[index].id) { image = nil resetZoom() guard let src = source(entries[index]) else { return } image = await BlobImageLoader.shared.image(src, maxPixel: nil) } } // Trackpad pinch. Clamped at 1 (fitted) so a photo can't shrink away. private var magnify: some Gesture { MagnifyGesture() .onChanged { value in scale = min(max(steadyScale * value.magnification, 1), maxScale) } .onEnded { _ in steadyScale = scale if scale <= 1 { resetPan() } } } // Drag pans, but only while zoomed — at 1× a drag would just slide the // fitted photo off its own scrim. private var pan: some Gesture { DragGesture() .onChanged { value in guard scale > 1 else { return } offset = CGSize(width: steadyOffset.width + value.translation.width, height: steadyOffset.height + value.translation.height) } .onEnded { _ in steadyOffset = offset } } private func toggleZoom() { withAnimation(.easeOut(duration: 0.18)) { if scale > 1 { resetZoom() } else { scale = 2.5 steadyScale = 2.5 } } } private func resetZoom() { scale = 1 steadyScale = 1 resetPan() } private func resetPan() { offset = .zero steadyOffset = .zero } private func stepButton(_ symbol: String, _ label: String, enabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(width: 34, height: 34) .background(.black.opacity(0.45), in: Circle()) } .buttonStyle(.plain) .arrowCursor() .accessibilityLabel(label) .opacity(enabled ? 1 : 0) .disabled(!enabled) } }