native: Media, Links & Docs — iOS + Mac (PR 3 of 3) (#1459)
* ios: Media, Links & Docs — shared extractor + iOS/Mac screens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GokbEx2gTwsXD8Yvf6jpe7 * ios: review fix wave — /me divergence honesty, incremental page fold, video-prefetch guard + staged-file pruning Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GokbEx2gTwsXD8Yvf6jpe7 ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+1417
−21
docs/superpowers/plans/2026-08-28-media-links-docs-native.md+94 −0ios/LChat/Views/MediaLinksDocsSheet.swift+504 −0ios/LChat/Views/NativeChatScreen.swift+26 −0ios/LChatCore/Package.resolved+9 −0ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift+54 −21ios/LChatCore/Sources/LChatCore/MessageStore.swift+20 −0ios/LChatCore/Sources/LChatCore/RichText.swift+17 −0ios/LChatCore/Sources/LChatCore/ThreadMedia.swift+114 −0ios/LChatCore/Tests/LChatCoreTests/ThreadMediaTests.swift+119 −0ios/LChatMac/MediaLinksDocsSheet.swift+441 −0ios/LChatMac/NativeChatView.swift+19 −0
diff --git a/docs/superpowers/plans/2026-08-28-media-links-docs-native.md b/docs/superpowers/plans/2026-08-28-media-links-docs-native.md| new file mode 100644 |
| index 0000000..a7e85fe |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-28-media-links-docs-native.md |
| @@ -0,0 +1,94 @@ |
| +# 🤖 Media, Links & Docs (native) — iOS + Mac, one PR |
| + |
| +The native half the web plan hands off to |
| +(`2026-08-05-media-links-docs-web.md` § After PR 2; spec |
| +`docs/superpowers/specs/2026-08-05-media-links-docs-design.md` § Native). |
| +Both web PRs are merged (#1424, #1426); this PR is the third and final slice. |
| +Written alongside the implementation — it records the shape and the two |
| +decisions the spec left open, for the reviewer and the next reader. |
| + |
| +## Shared logic (LChatCore — iOS and Mac can't drift) |
| + |
| +- **`ThreadMedia.swift`** — the web extractor's twin (`threadmedia.js`): |
| + `extractEntries` / `extractAll` / `filtered` / `monthLabel` / |
| + `bucketByMonth`. Parity is pinned by `ThreadMediaTests.swift`, which |
| + mirrors `test/threadmedia.test.mjs` case-for-case (the discipline the spec |
| + names — no event vectors change, the feature adds no event types). |
| +- **`RichText.swift`** gains `allLinks(_:)` — `firstLink`'s loop without the |
| + early return: every URL in order, fenced code skipped, quotes included. |
| +- **`MessageStore.allMessages(instance:thread:limit:)`** — the extraction |
| + sweep, capped at 50k rows like the web store's `allMessages`. |
| +- **`HistoryBackfill.start(instance:threadID:threadKind:threadKeyB64:)`** — |
| + a per-thread walk beside the instance-wide search walk, taking the |
| + caller's already-unwrapped `ChatThread.threadKeyB64`. Same resumable |
| + store marks: either feature finishing the walk finishes it for both. |
| + Its narration reads "Loading earlier messages… back to March 2025" |
| + (`progressLabel(_:loading:)`); the search walk's copy is unchanged. |
| + |
| +**One deliberate divergence from the web's extraction rule.** The web skips |
| +typed app events (`if (data.type) return null`). Native can't: MessageStore |
| +rows have been through `ChatFeedModel.renderable`, which rewrites app events |
| +into `type: "chat"` projections with `action: true` — erasing the |
| +discriminator. So the native guard is `isRenderableChat && action != true`, |
| +which knowingly also skips a member's /me lines (the web's Links tab keeps |
| +a /me line's URL): the two shapes are indistinguishable in the store, and |
| +letting them through would fill Links with app-event noise. The cost — a |
| +/me line's rare URL missing from native Links — is accepted until stored |
| +rows carry a discriminator. Recorded in `ThreadMedia.swift`'s header and |
| +tested. |
| + |
| +## iOS (`ios/LChat/Views/MediaLinksDocsSheet.swift`) |
| + |
| +- Kebab item "Media, Links & Docs" in `NativeChatScreen`'s menu — DMs and |
| + groups alike (not inside the `isGroup` block), hidden on app threads |
| + (`appView == nil && !thread.isApp`, the web's `chatAppFor` gate). Opens a |
| + sheet: segmented Media | Links | Docs with localised counts, month |
| + sections, inline walk narration + Stop (never a toast). |
| +- Media grid: `LazyVGrid`, BlurHash ghost → `BlobImageLoader` thumbnail |
| + (video tiles show their poster + duration badge). Lazy by construction — |
| + LazyVGrid only materialises visible tiles (the web's |
| + IntersectionObserver, for free). |
| +- **Collection swipe = Quick Look multi-item, staged lazily.** The spec |
| + named QuickLook's multi-item data source; the open question was staging — |
| + `previewItemAt` is synchronous, and staging a whole grid up-front is |
| + unbounded. Resolution: a lazy data source (`MediaCollectionPreview`) that |
| + returns a nil-URL item (Quick Look's own loading placeholder), decrypts |
| + current±1 to a per-open temp dir, and swaps via |
| + `refreshCurrentPreviewItem`; staged files outside the current±3 window are |
| + burned as the user swipes on, and the whole dir on dismiss. Videos stage |
| + their real bytes so Quick Look plays them in place — but only when landed |
| + on, never as a neighbour prefetch (the app's standing "no whole-video |
| + decrypt for a thumbnail" rule; their tiles use posters). |
| +- Links: every occurrence, lazy unfurl title through the model's memoised |
| + fetcher; taps open the browser. Docs: emoji file icon, `formatBytes`, |
| + sender · time; tap stages + opens the existing `QuickLookPreview`. |
| +- "Show in chat" is each item's context menu (long-press; VoiceOver |
| + actions): dismisses the sheet and reuses the search hit's bounded |
| + page-back walk (`pendingJump` → `jumpAndWave`). |
| + |
| +## Mac (`ios/LChatMac/MediaLinksDocsSheet.swift`) |
| + |
| +- `threadMenu` item (all three menu surfaces at once), gated `!t.isApp`; |
| + fixed-size sheet, Discover's idiom. |
| +- **The spec's "QuickLook multi-item" vs the Mac's anti-QuickLook stance** |
| + (`Lightbox.swift`: decrypted bytes never touch disk uninvited) resolves in |
| + favour of the standing stance: photos open an **in-sheet, in-memory |
| + collection viewer** — ‹ › buttons + ←/→ keys, clamped at the ends (the |
| + web lightbox's `stepIndex` rule), `BlobImageLoader` full-size. A video |
| + opens as its poster there; playing rides "Show in chat" (the feed's |
| + in-memory player) — the web's v1 rule. |
| +- Docs save to ~/Downloads with Finder-style "(2)" de-collision — the Mac |
| + feed's own file behaviour — with an inline "Saved to Downloads" note. |
| +- "Show in chat" selects the thread and sets `app.nativeJump` (the search |
| + hit's path). |
| + |
| +## Verification |
| + |
| +- `swift test` in `ios/LChatCore`: `ThreadMediaTests` (10) green; the one |
| + standing failure (`FlowEngineTests.testQuickFormTrailingChip`) pre-exists |
| + on main, untouched here. |
| +- `./tools/dev build mac` green; iOS target compiles |
| + (`xcodebuild -destination generic/platform=iOS`, signing off). |
| +- Hand-check on device/Mac against a canary rides the next session on a |
| + machine with the iOS platform installed; changelog entry **held for the |
| + release mint**, per convention. |
diff --git a/ios/LChat/Views/MediaLinksDocsSheet.swift b/ios/LChat/Views/MediaLinksDocsSheet.swift| new file mode 100644 |
| index 0000000..061e110 |
| --- /dev/null |
| +++ b/ios/LChat/Views/MediaLinksDocsSheet.swift |
| @@ -0,0 +1,504 @@ |
| +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() |
| + // 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 |
| + } |
| + |
| + // Muted/blocked senders hide at query time (web hiddenUids) — un-muting |
| + // needs no re-extraction. |
| + private var hiddenUids: Set<String> { store.mutedPeople.union(store.blockedPeople) } |
| + |
| + private var counts: (media: Int, links: Int, docs: Int) { |
| + (ThreadMedia.filtered(tabs.media, hiddenUids: hiddenUids).count, |
| + ThreadMedia.filtered(tabs.links, hiddenUids: hiddenUids).count, |
| + ThreadMedia.filtered(tabs.docs, hiddenUids: hiddenUids).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 { |
| + // Inline, occupying layout (never a floating toast): the |
| + // walk's narration plus Stop, exactly the web overlay's. |
| + HStack(spacing: 8) { |
| + Text(backfill.progress ?? "Loading earlier messages…") |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + Spacer(minLength: 0) |
| + Button("Stop") { backfill.cancel() } |
| + .font(.footnote.weight(.semibold)) |
| + } |
| + .padding(.horizontal, 16) |
| + .padding(.bottom, 6) |
| + .accessibilityElement(children: .combine) |
| + } |
| + content |
| + } |
| + .navigationTitle("Media, links and docs") |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .confirmationAction) { |
| + Button("Done") { dismiss() } |
| + } |
| + } |
| + } |
| + .task(id: backfill.pageTick) { await reload() } |
| + .onAppear { startWalkIfIncomplete() } |
| + .onDisappear { backfill.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() |
| + } |
| + } |
| + |
| + // "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 |
| + } |
| + |
| + @ViewBuilder private var content: some View { |
| + let entries = ThreadMedia.filtered( |
| + tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs, |
| + hiddenUids: hiddenUids) |
| + if entries.isEmpty { |
| + Spacer() |
| + if loaded { |
| + Text(tab == "media" ? "No photos or videos shared yet." |
| + : tab == "links" ? "No links shared yet." : "No files shared yet.") |
| + .foregroundStyle(.secondary) |
| + } else { |
| + ProgressView() |
| + } |
| + 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 { 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) }) |
| + .contextMenu { |
| + 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 { ProgressView().controlSize(.small) } |
| + } |
| + .padding(.horizontal, 16) |
| + .padding(.vertical, 6) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .contextMenu { |
| + 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 |
| + } |
| + |
| + // 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) |
| + } |
| + } |
| +} |
| + |
| +// 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?? |
| + |
| + @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; return } |
| + title = await unfurl(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 staging: Set<Int> = [] |
| + 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) |
| + // A nil URL renders Quick Look's own loading placeholder until |
| + // refreshCurrentPreviewItem swaps the staged file in. |
| + return LazyItem(url: staged[index]) |
| + } |
| + |
| + @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 } |
| + } |
| +} |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 152b233..5006780 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -211,6 +211,10 @@ struct NativeChatScreen: View { |
| @State private var showMembers = false |
| @State private var showRename = false |
| @State private var showGroupApps = false |
| + // The per-thread shared-content inventory (spec: media-links-docs) — |
| + // DMs and groups alike, hidden on app threads (they have no chat feed |
| + // of shared content to inventory). |
| + @State private var showMediaView = false |
| // End conversation (#578): the destructive confirm, and the closed |
| // banner's in-flight / refusal state (inline copy, never an alert). |
| @State private var showEndConfirm = false |
| @@ -478,6 +482,16 @@ struct NativeChatScreen: View { |
| Label("Manage Apps", systemImage: "square.grid.2x2") |
| } |
| } |
| + // Media, Links & Docs — NOT group-gated (DMs too); |
| + // app threads have no shared-content feed, so it |
| + // hides there (the web's chatAppFor gate). |
| + if appView == nil, !thread.isApp { |
| + Button { |
| + showMediaView = true |
| + } label: { |
| + Label("Media, Links & Docs", systemImage: "photo.on.rectangle") |
| + } |
| + } |
| Button { |
| store.setPinned(thread, !isPinned) |
| } label: { |
| @@ -539,6 +553,18 @@ struct NativeChatScreen: View { |
| } |
| .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } |
| .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } |
| + .sheet(isPresented: $showMediaView) { |
| + MediaLinksDocsSheet(instance: instance, thread: thread, store: store, |
| + model: model, |
| + onJump: { sid in |
| + showMediaView = false |
| + // The feed may not hold the message — |
| + // reuse the search hit's bounded |
| + // page-back walk to land on it. |
| + Self.pendingJump = (thread.id, sid) |
| + runPendingJump() |
| + }) |
| + } |
| .onAppear { |
| model.start() |
| LastOpenedChat.note(origin: instance.origin, thread: thread.id, |
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index d82f91f..fe25d1c 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,5 +1,14 @@ |
| { |
| "pins" : [ |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| + "version" : "2.9.4" |
| + } |
| + }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |
diff --git a/ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift b/ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift| index 42533dd..2cd2cc2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift |
| @@ -52,6 +52,37 @@ public final class HistoryBackfill: ObservableObject { |
| } |
| } |
| + /// Walk ONE thread's history — the Media/Links/Docs screen's auto-complete |
| + /// (spec § Completeness). Same resumable marks as the search walk, so |
| + /// either feature finishing the walk finishes it for both. The key is the |
| + /// caller's already-unwrapped copy (ChatThread.threadKeyB64), so this |
| + /// works from a screen that never saw the wrapped blob. |
| + public func start(instance: SavedInstance, threadID: String, threadKind: String, |
| + threadKeyB64: String) { |
| + guard !running, task == nil else { return } |
| + guard let base = instance.baseURL, |
| + let raw = Data(base64urlEncoded: threadKeyB64), |
| + let key = try? GroupKey(rawKey: raw) else { return } |
| + running = true |
| + loadingLabel = true |
| + progress = "Loading earlier messages…" |
| + let api = LChatAPI(baseURL: base, token: instance.deviceToken) |
| + task = Task { [weak self] in |
| + _ = await self?.walkThread(inst: instance, api: api, |
| + threadID: threadID, kind: threadKind, key: key) |
| + await MainActor.run { |
| + self?.running = false |
| + self?.progress = nil |
| + self?.task = nil |
| + self?.loadingLabel = false |
| + } |
| + } |
| + } |
| + |
| + // The single-thread walk narrates "Loading earlier messages…" (it loads |
| + // the chat's history, not a search); the instance walk keeps "Searching". |
| + private var loadingLabel = false |
| + |
| public func cancel() { |
| task?.cancel() |
| } |
| @@ -67,7 +98,8 @@ public final class HistoryBackfill: ObservableObject { |
| let wrapped = t.wrapped, !wrapped.isEmpty, |
| let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: inst.userPriv), |
| let key = try? GroupKey(rawKey: raw) else { continue } |
| - let done = await walkThread(inst: inst, api: api, thread: t, key: key) |
| + let done = await walkThread(inst: inst, api: api, |
| + threadID: t.id, kind: t.kind, key: key) |
| if !done { sawIncomplete = true } |
| } |
| if !Task.isCancelled, !sawIncomplete { |
| @@ -78,38 +110,38 @@ public final class HistoryBackfill: ObservableObject { |
| /// Walk one thread to its beginning (or cancellation). Returns whether the |
| /// thread is complete. |
| private func walkThread(inst: SavedInstance, api: LChatAPI, |
| - thread t: ServerThread, key: GroupKey) async -> Bool { |
| - let mark = await MessageStore.shared.backfillMark(instance: inst.id, thread: t.id) |
| - let oldestStored = await MessageStore.shared.oldestStoredId(instance: inst.id, thread: t.id) |
| + threadID: String, kind: String, key: GroupKey) async -> Bool { |
| + let mark = await MessageStore.shared.backfillMark(instance: inst.id, thread: threadID) |
| + let oldestStored = await MessageStore.shared.oldestStoredId(instance: inst.id, thread: threadID) |
| guard var cursor = Self.cursor(markOldest: mark.oldest, markComplete: mark.complete, |
| oldestStored: oldestStored) else { return true } |
| if cursor == 0 { |
| // Empty store for this thread: seed the recent window for a cursor. |
| - let rows = await api.messages(thread: t.id, after: 0) |
| + let rows = await api.messages(thread: threadID, after: 0) |
| guard let first = rows.first else { |
| // No history at all — trivially complete. |
| - await MessageStore.shared.setBackfillMark(instance: inst.id, thread: t.id, |
| + await MessageStore.shared.setBackfillMark(instance: inst.id, thread: threadID, |
| oldest: 0, complete: true) |
| return true |
| } |
| - await file(rows, inst: inst, thread: t, key: key) |
| + await file(rows, inst: inst, threadID: threadID, kind: kind, key: key) |
| cursor = first.id |
| - await MessageStore.shared.setBackfillMark(instance: inst.id, thread: t.id, |
| + await MessageStore.shared.setBackfillMark(instance: inst.id, thread: threadID, |
| oldest: cursor, complete: false) |
| } |
| while !Task.isCancelled { |
| - let rows = await api.messagesBefore(thread: t.id, before: cursor) |
| + let rows = await api.messagesBefore(thread: threadID, before: cursor) |
| guard let first = rows.first else { |
| - await MessageStore.shared.setBackfillMark(instance: inst.id, thread: t.id, |
| + await MessageStore.shared.setBackfillMark(instance: inst.id, thread: threadID, |
| oldest: cursor, complete: true) |
| return true |
| } |
| - await file(rows, inst: inst, thread: t, key: key) |
| + await file(rows, inst: inst, threadID: threadID, kind: kind, key: key) |
| cursor = first.id |
| - await MessageStore.shared.setBackfillMark(instance: inst.id, thread: t.id, |
| + await MessageStore.shared.setBackfillMark(instance: inst.id, thread: threadID, |
| oldest: cursor, complete: false) |
| await MainActor.run { |
| - self.progress = Self.progressLabel(first.created_at) |
| + self.progress = Self.progressLabel(first.created_at, loading: self.loadingLabel) |
| self.pageTick += 1 |
| } |
| } |
| @@ -119,28 +151,29 @@ public final class HistoryBackfill: ObservableObject { |
| /// Decrypt + file one page — BackgroundSync's recipe: tombstones evict |
| /// their targets (#582), renderable rows upsert (decrypted once, here). |
| private func file(_ rows: [ServerMessage], inst: SavedInstance, |
| - thread t: ServerThread, key: GroupKey) async { |
| + threadID: String, kind: String, key: GroupKey) async { |
| for id in ChatFeedModel.tombstoneEvictions(rows) { |
| - await MessageStore.shared.delete(instance: inst.id, thread: t.id, id: id) |
| + await MessageStore.shared.delete(instance: inst.id, thread: threadID, id: id) |
| } |
| let batch: [MessageStore.StoredMessage] = rows.compactMap { row -> MessageStore.StoredMessage? in |
| guard (row.tombstone_of ?? 0) <= 0 else { return nil } |
| guard let p = ChatFeedModel.renderable(row.blob, key: key, |
| - group: t.kind == "group") else { return nil } |
| + group: kind == "group") else { return nil } |
| return .init(id: row.id, at: row.created_at, |
| edited: (row.edited_at ?? 0) > 0, |
| reactions: [], payload: p) |
| } |
| guard !batch.isEmpty else { return } |
| - await MessageStore.shared.upsert(instance: inst.id, thread: t.id, batch) |
| + await MessageStore.shared.upsert(instance: inst.id, thread: threadID, batch) |
| } |
| - /// "Searching back to March 2025…" — how deep the time machine has dug. |
| - nonisolated static func progressLabel(_ unixSecs: Int64) -> String { |
| - guard unixSecs > 0 else { return "Searching…" } |
| + /// "Searching back to March 2025…" — how deep the time machine has dug — |
| + /// or the media screen's "Loading earlier messages… back to March 2025". |
| + nonisolated static func progressLabel(_ unixSecs: Int64, loading: Bool = false) -> String { |
| + guard unixSecs > 0 else { return loading ? "Loading earlier messages…" : "Searching…" } |
| let f = DateFormatter() |
| f.dateFormat = "MMMM yyyy" |
| let month = f.string(from: Date(timeIntervalSince1970: TimeInterval(unixSecs))) |
| - return "Searching back to \(month)…" |
| + return loading ? "Loading earlier messages… back to \(month)" : "Searching back to \(month)…" |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageStore.swift b/ios/LChatCore/Sources/LChatCore/MessageStore.swift| index 4a36ee7..0027a66 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageStore.swift |
| @@ -349,6 +349,26 @@ public actor MessageStore { |
| return out.reversed() |
| } |
| + /// Everything stored for a thread, newest first — the Media/Links/Docs |
| + /// extraction sweep. The cap mirrors the web store's allMessages 50k |
| + /// watermark (spec § Edge cases: huge threads inherit the documented gap). |
| + public func allMessages(instance: String, thread: String, |
| + limit: Int = 50_000) -> [StoredMessage] { |
| + guard let handle = open() else { return [] } |
| + var out: [StoredMessage] = [] |
| + var stmt: OpaquePointer? |
| + guard sqlite3_prepare_v2(handle, |
| + "SELECT id, at, edited, reactions, payload FROM messages WHERE instance=? AND thread=? ORDER BY id DESC LIMIT \(limit)", |
| + -1, &stmt, nil) == SQLITE_OK else { return [] } |
| + defer { sqlite3_finalize(stmt) } |
| + bind(stmt, 1, instance) |
| + bind(stmt, 2, thread) |
| + while sqlite3_step(stmt) == SQLITE_ROW { |
| + if let m = readRow(stmt) { out.append(m) } |
| + } |
| + return out |
| + } |
| + |
| /// The newest stored message's (id, at) for a thread — the incremental-sync |
| /// watermark background refresh and the NSE-ingest catch-up key off. (0, 0) |
| /// when nothing is stored yet. |
diff --git a/ios/LChatCore/Sources/LChatCore/RichText.swift b/ios/LChatCore/Sources/LChatCore/RichText.swift| index 30b981a..822492a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/RichText.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/RichText.swift |
| @@ -219,6 +219,23 @@ public func firstLink(_ text: String) -> URL? { |
| return nil |
| } |
| +/// Every URL in a message, in order, skipping code fences — firstLink's loop |
| +/// without the early return. The Links tab is an inventory (the web extractor |
| +/// in threadmedia.js extracts every href the same way), so the same URL |
| +/// posted twice is two entries. |
| +public func allLinks(_ text: String) -> [URL] { |
| + var out: [URL] = [] |
| + for block in blockTokens(text) { |
| + switch block { |
| + case .code: |
| + continue |
| + case .p(let body), .quote(let body): |
| + out.append(contentsOf: tokenizeText(body).compactMap { $0.href }) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // Compiled once — youtubeId runs from every link-bearing bubble's body, and |
| // NSRegularExpression construction is the expensive half. |
| private let ytStartRE = try! NSRegularExpression(pattern: "^(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?$") |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadMedia.swift b/ios/LChatCore/Sources/LChatCore/ThreadMedia.swift| new file mode 100644 |
| index 0000000..1be5e08 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadMedia.swift |
| @@ -0,0 +1,114 @@ |
| +import Foundation |
| + |
| +// 🤖 Media, Links & Docs — the native twin of the web's pure extractor |
| +// (web/static/threadmedia.js; spec docs/superpowers/specs/ |
| +// 2026-08-05-media-links-docs-design.md). Classifies a thread's stored, |
| +// already-decrypted payloads into the three tabs. Pure functions in the |
| +// shared core so iOS and Mac can't drift, pinned to the web extractor's |
| +// rules by ThreadMediaTests (same fixtures as test/threadmedia.test.mjs). |
| +// |
| +// One deliberate divergence from the web's "skip typed app events" rule: |
| +// MessageStore rows have already been through ChatFeedModel.renderable, which |
| +// rewrites app events into `type: "chat"` projections with `action: true` — |
| +// erasing the `type` discriminator the web filters on. So the native guard is |
| +// on `action`, and it knowingly also skips a member's own /me lines (which |
| +// the web's Links tab keeps when one carries a URL): the two shapes are |
| +// indistinguishable in the store, and letting them through would fill the |
| +// Links tab with app-event noise. The cost is a /me line's rare URL missing |
| +// from Links on native — accepted until stored rows carry a discriminator. |
| +public enum ThreadMedia { |
| + /// One tab entry: the source message's id + sender, plus the attachment |
| + /// (media/docs) or URL (links) needed to render without re-reading rows. |
| + public struct Entry: Identifiable, Sendable { |
| + public let sid: Int64 |
| + public let ts: Int64 // ms — sender clock, falling back to server time |
| + public let uid: String |
| + public let name: String |
| + public let att: Attachment? |
| + public let url: URL? |
| + // An attachment id is unique per upload; a repeated URL in one message |
| + // is two rows (the web extractor's rule), so links key on position. |
| + public var id: String { att.map { "a\(sid):\($0.id)" } ?? "l\(sid):\(url?.absoluteString ?? "")" } |
| + } |
| + |
| + public struct Tabs: Sendable { |
| + public var media: [Entry] = [] |
| + public var links: [Entry] = [] |
| + public var docs: [Entry] = [] |
| + public var isEmpty: Bool { media.isEmpty && links.isEmpty && docs.isEmpty } |
| + public init() {} |
| + } |
| + |
| + /// One decrypted payload → its entries, or nil when the message |
| + /// contributes nothing. `at` is the server row's created_at (seconds). |
| + public static func extractEntries(_ p: MessagePayload, sid: Int64, at: Int64) -> Tabs? { |
| + // Renderable chat only (web: `if (data.type) return null` — reactions |
| + // and unknown kinds never reach the store, but guard anyway); |
| + // projections and /me action lines are invisible (see header note). |
| + guard p.isRenderableChat, p.action != true else { return nil } |
| + let ts = p.ts ?? at * 1000 |
| + var out = Tabs() |
| + for a in p.attachments ?? [] { |
| + guard !a.id.isEmpty else { continue } |
| + let entry = Entry(sid: sid, ts: ts, uid: p.uid ?? "", name: p.name ?? "", att: a, url: nil) |
| + if a.isImage || a.isVideo { out.media.append(entry) } else { out.docs.append(entry) } |
| + } |
| + for url in allLinks(p.text ?? "") { |
| + out.links.append(Entry(sid: sid, ts: ts, uid: p.uid ?? "", name: p.name ?? "", att: nil, url: url)) |
| + } |
| + return out.isEmpty ? nil : out |
| + } |
| + |
| + /// Fold a thread's stored rows (any order) into sorted tabs, newest |
| + /// message first — the whole per-open index. MessageStore rows are |
| + /// tombstone-free and unique by id, so no seen-set is needed here. |
| + public static func extractAll(_ rows: [MessageStore.StoredMessage]) -> Tabs { |
| + var out = Tabs() |
| + for row in rows { |
| + guard let ex = extractEntries(row.payload, sid: row.id, at: row.at) else { continue } |
| + out.media.append(contentsOf: ex.media) |
| + out.links.append(contentsOf: ex.links) |
| + out.docs.append(contentsOf: ex.docs) |
| + } |
| + out.media.sort { $0.sid > $1.sid } |
| + out.links.sort { $0.sid > $1.sid } |
| + out.docs.sort { $0.sid > $1.sid } |
| + return out |
| + } |
| + |
| + /// Hide muted/blocked senders at query time (the web filters its index |
| + /// the same way), so un-muting needs no re-extraction. |
| + public static func filtered(_ entries: [Entry], hiddenUids: Set<String>) -> [Entry] { |
| + hiddenUids.isEmpty ? entries : entries.filter { !hiddenUids.contains($0.uid) } |
| + } |
| + |
| + /// "August 2026" — the month section header (web threadmedia monthLabel). |
| + public static func monthLabel(_ tsMs: Int64) -> String { |
| + Date(timeIntervalSince1970: TimeInterval(tsMs) / 1000) |
| + .formatted(.dateTime.month(.wide).year()) |
| + } |
| + |
| + public struct MonthBucket: Identifiable, Sendable { |
| + public let key: String |
| + public let label: String |
| + public var items: [Entry] |
| + public var id: String { key } |
| + } |
| + |
| + /// Group an already-sorted (newest-first) entry list into month sections. |
| + /// Consecutive grouping is enough BECAUSE the input is sorted. |
| + public static func bucketByMonth(_ entries: [Entry]) -> [MonthBucket] { |
| + var out: [MonthBucket] = [] |
| + let cal = Calendar.current |
| + for e in entries { |
| + let d = Date(timeIntervalSince1970: TimeInterval(e.ts) / 1000) |
| + let c = cal.dateComponents([.year, .month], from: d) |
| + let key = "\(c.year ?? 0)-\(c.month ?? 0)" |
| + if out.last?.key != key { |
| + out.append(MonthBucket(key: key, label: monthLabel(e.ts), items: [])) |
| + } |
| + out[out.count - 1].items.append(e) |
| + } |
| + return out |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ThreadMediaTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ThreadMediaTests.swift| new file mode 100644 |
| index 0000000..e40688d |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ThreadMediaTests.swift |
| @@ -0,0 +1,119 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 Media, Links & Docs: extractor parity with the web's pure extractor |
| +// (web/static/threadmedia.js, pinned by test/threadmedia.test.mjs — these |
| +// cases mirror that suite, the cross-platform discipline the spec names in |
| +// docs/superpowers/specs/2026-08-05-media-links-docs-design.md § Native). |
| +// No event vectors change: the feature adds no event types. |
| +final class ThreadMediaTests: XCTestCase { |
| + private func payload(_ json: String) -> MessagePayload { |
| + try! JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8)) |
| + } |
| + |
| + func testClassifiesImageGifVideoAsMediaOtherFilesAsDocs() { |
| + let p = payload(""" |
| + {"uid":"u1","name":"Ada","text":"","ts":1754000000000,"attachments":[ |
| + {"id":"b1","type":"image/jpeg","w":100,"h":50}, |
| + {"id":"b2","type":"image/gif"}, |
| + {"id":"b3","type":"video/mp4","poster":"p3","dur":12}, |
| + {"id":"b4","type":"application/pdf","name":"notes.pdf","size":900}]} |
| + """) |
| + let out = ThreadMedia.extractEntries(p, sid: 42, at: 1754000000) |
| + XCTAssertEqual(out?.media.count, 3, "image + gif + video are media") |
| + XCTAssertEqual(out?.docs.count, 1, "the pdf is a doc") |
| + XCTAssertEqual(out?.docs.first?.att?.name, "notes.pdf") |
| + XCTAssertEqual(out?.media.first?.sid, 42) |
| + } |
| + |
| + func testExtractsEveryLinkNotJustTheFirst() { |
| + let p = payload(#"{"uid":"u1","name":"Ada","ts":1,"text":"see https://a.example and https://b.example"}"#) |
| + let out = ThreadMedia.extractEntries(p, sid: 1, at: 1) |
| + XCTAssertEqual(out?.links.map { $0.url?.absoluteString }, |
| + ["https://a.example", "https://b.example"]) |
| + } |
| + |
| + func testLinksInsideFencedCodeBlocksDontExtract() { |
| + let p = payload(#"{"uid":"u1","name":"A","ts":1,"text":"```\nhttps://code.example\n```\nhttps://real.example"}"#) |
| + let out = ThreadMedia.extractEntries(p, sid: 1, at: 1) |
| + XCTAssertEqual(out?.links.count, 1, "the fenced URL is code, not a shared link") |
| + XCTAssertEqual(out?.links.first?.url?.absoluteString, "https://real.example") |
| + } |
| + |
| + func testNonContributingMessagesReturnNil() { |
| + // Typed app events (web parity — native stores them projected, but the |
| + // raw shape must still be rejected). |
| + XCTAssertNil(ThreadMedia.extractEntries( |
| + payload(#"{"type":"todo.add","text":"https://x.example"}"#), sid: 1, at: 1)) |
| + // Plain words, no attachments. |
| + XCTAssertNil(ThreadMedia.extractEntries( |
| + payload(#"{"uid":"u","name":"A","text":"plain words","ts":1}"#), sid: 1, at: 1)) |
| + // Native nuance: a stored app-event projection is type "chat" with |
| + // action:true — invisible here (see ThreadMedia's header note). |
| + XCTAssertNil(ThreadMedia.extractEntries( |
| + payload(#"{"type":"chat","action":true,"uid":"u","name":"A","text":"added https://x.example","ts":1}"#), |
| + sid: 1, at: 1)) |
| + } |
| + |
| + func testAttachmentWithoutABlobIdIsSkipped() { |
| + let p = payload(#"{"uid":"u","name":"A","text":"","ts":1,"attachments":[{"id":"","type":"image/png"}]}"#) |
| + XCTAssertNil(ThreadMedia.extractEntries(p, sid: 1, at: 1)) |
| + } |
| + |
| + func testSenderTimestampFallsBackToServerTime() { |
| + let p = payload(#"{"uid":"u","name":"A","text":"https://a.example"}"#) |
| + let out = ThreadMedia.extractEntries(p, sid: 1, at: 1_754_000_000) |
| + XCTAssertEqual(out?.links.first?.ts, 1_754_000_000_000, "created_at seconds → ms, the web's meta.ts rule") |
| + } |
| + |
| + func testExtractAllSortsNewestFirst() { |
| + let rows: [MessageStore.StoredMessage] = [ |
| + .init(id: 1, at: 100, edited: false, reactions: [], |
| + payload: payload(#"{"uid":"a","name":"A","ts":100000,"text":"","attachments":[{"id":"x","type":"image/png"}]}"#)), |
| + .init(id: 3, at: 300, edited: false, reactions: [], |
| + payload: payload(#"{"uid":"c","name":"C","ts":300000,"text":"","attachments":[{"id":"z","type":"image/png"}]}"#)), |
| + .init(id: 2, at: 200, edited: false, reactions: [], |
| + payload: payload(#"{"uid":"b","name":"B","ts":200000,"text":"","attachments":[{"id":"y","type":"image/png"}]}"#)), |
| + ] |
| + let tabs = ThreadMedia.extractAll(rows) |
| + XCTAssertEqual(tabs.media.map { $0.sid }, [3, 2, 1]) |
| + } |
| + |
| + func testFilteredHidesMutedSendersAtQueryTime() { |
| + let rows: [MessageStore.StoredMessage] = [ |
| + .init(id: 1, at: 100, edited: false, reactions: [], |
| + payload: payload(#"{"uid":"muted","name":"M","ts":100000,"text":"","attachments":[{"id":"a","type":"image/png"}]}"#)), |
| + .init(id: 2, at: 200, edited: false, reactions: [], |
| + payload: payload(#"{"uid":"ok","name":"O","ts":200000,"text":"","attachments":[{"id":"b","type":"image/png"}]}"#)), |
| + ] |
| + let tabs = ThreadMedia.extractAll(rows) |
| + XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: []).map { $0.sid }, [2, 1]) |
| + XCTAssertEqual(ThreadMedia.filtered(tabs.media, hiddenUids: ["muted"]).map { $0.sid }, [2]) |
| + } |
| + |
| + func testBucketByMonthGroupsConsecutiveSameMonthEntriesKeepsOrder() { |
| + var comps = DateComponents(); comps.year = 2026; comps.month = 8; comps.day = 10 |
| + let augMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000) |
| + comps.month = 7; comps.day = 1 |
| + let julMs = Int64(Calendar.current.date(from: comps)!.timeIntervalSince1970 * 1000) |
| + let mk = { (sid: Int64, ts: Int64) in |
| + ThreadMedia.Entry(sid: sid, ts: ts, uid: "u", name: "A", att: nil, |
| + url: URL(string: "https://x.example")) |
| + } |
| + let buckets = ThreadMedia.bucketByMonth([mk(3, augMs), mk(2, augMs), mk(1, julMs)]) |
| + XCTAssertEqual(buckets.count, 2) |
| + XCTAssertEqual(buckets[0].items.count, 2) |
| + XCTAssertEqual(buckets[0].label, ThreadMedia.monthLabel(augMs)) |
| + XCTAssertEqual(buckets[1].items.first?.sid, 1) |
| + } |
| + |
| + func testAllLinksMatchesFirstLinkSemantics() { |
| + // allLinks (RichText) backs the extractor: every href, in order, |
| + // fenced code skipped, quote blocks included — firstLink's rules. |
| + let text = "> quoted https://q.example\n```\nhttps://code.example\n```\nwww.plain.example and go.dev/x" |
| + XCTAssertEqual(allLinks(text).map { $0.absoluteString }, |
| + ["https://q.example", "https://www.plain.example", "https://go.dev/x"]) |
| + XCTAssertEqual(firstLink(text)?.absoluteString, "https://q.example", |
| + "first of allLinks is firstLink") |
| + } |
| +} |
diff --git a/ios/LChatMac/MediaLinksDocsSheet.swift b/ios/LChatMac/MediaLinksDocsSheet.swift| new file mode 100644 |
| index 0000000..304d711 |
| --- /dev/null |
| +++ b/ios/LChatMac/MediaLinksDocsSheet.swift |
| @@ -0,0 +1,441 @@ |
| +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; presentation is the Mac's fixed-size sheet idiom (Discover's shape). |
| +// Viewing honours the Mac's deliberate anti-QuickLook stance (Lightbox.swift: |
| +// decrypted bytes never touch disk uninvited): photos open an in-sheet |
| +// 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(\.dismiss) private var dismiss |
| + @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() |
| + // The in-sheet photo viewer's position in the media tab (nil = closed). |
| + @State private var viewerIndex: Int? |
| + @State private var savedDocID: String? // brief "Saved to Downloads" state |
| + |
| + private var hiddenUids: Set<String> { store.mutedPeople.union(store.blockedPeople) } |
| + |
| + private var mediaEntries: [ThreadMedia.Entry] { |
| + ThreadMedia.filtered(tabs.media, hiddenUids: hiddenUids) |
| + } |
| + |
| + var body: some View { |
| + VStack(spacing: 0) { |
| + HStack { |
| + Text("Media, links and docs") |
| + .font(.headline) |
| + Spacer() |
| + Button("Done") { dismiss() } |
| + .keyboardShortcut(.cancelAction) |
| + } |
| + .padding(12) |
| + 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) |
| + if backfill.running { |
| + HStack(spacing: 8) { |
| + Text(backfill.progress ?? "Loading earlier messages…") |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + Spacer(minLength: 0) |
| + Button("Stop") { backfill.cancel() } |
| + .buttonStyle(.plain) |
| + .font(.system(size: 12, weight: .semibold)) |
| + } |
| + .padding(.horizontal, 12) |
| + .padding(.top, 6) |
| + .accessibilityElement(children: .combine) |
| + } |
| + content |
| + .padding(.top, 6) |
| + } |
| + .frame(width: 640, height: 560) |
| + .overlay { |
| + if let i = viewerIndex, mediaEntries.indices.contains(i) { |
| + MediaViewerOverlay(entries: mediaEntries, index: i, |
| + source: { viewerSource($0) }, |
| + onStep: { viewerIndex = $0 }, |
| + onClose: { viewerIndex = nil }) |
| + } |
| + } |
| + .task(id: backfill.pageTick) { await reload() } |
| + .onAppear { startWalkIfIncomplete() } |
| + .onDisappear { backfill.cancel() } |
| + } |
| + |
| + private func segLabel(_ label: String, _ n: Int) -> String { |
| + n > 0 ? "\(label) · \(n.formatted())" : label |
| + } |
| + |
| + @ViewBuilder private var content: some View { |
| + let entries = ThreadMedia.filtered( |
| + tab == "media" ? tabs.media : tab == "links" ? tabs.links : tabs.docs, |
| + hiddenUids: hiddenUids) |
| + if entries.isEmpty { |
| + Spacer() |
| + if loaded { |
| + Text(tab == "media" ? "No photos or videos shared yet." |
| + : tab == "links" ? "No links shared yet." : "No files shared yet.") |
| + .foregroundStyle(.secondary) |
| + } else { |
| + ProgressView() |
| + } |
| + 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("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) }) |
| + .contextMenu { |
| + Button("Show in Chat") { onJump(e.sid) } |
| + } |
| + } |
| + |
| + private func docRow(_ e: ThreadMedia.Entry) -> some View { |
| + Button { if let att = e.att { saveDoc(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) |
| + if savedDocID == e.att?.id { |
| + Text("Saved to Downloads") |
| + .font(.system(size: 11)) |
| + .foregroundStyle(.secondary) |
| + } |
| + } |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 5) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .contextMenu { |
| + 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: " · ") |
| + } |
| + |
| + // A doc tap decrypts and saves to ~/Downloads — the Mac feed's file-chip |
| + // behaviour (a deliberate save, never a silent temp file). |
| + private func saveDoc(_ att: Attachment) { |
| + Task { |
| + guard let data = await model.blobData(att) else { return } |
| + let name = AttachmentDownload.safeFilename(name: att.name, mime: att.type) |
| + let downloads = FileManager.default.urls(for: .downloadsDirectory, |
| + in: .userDomainMask).first |
| + guard var dest = downloads?.appendingPathComponent(name) else { return } |
| + // Never overwrite: "name (2).ext", the Finder rule. |
| + var n = 2 |
| + let base = dest.deletingPathExtension().lastPathComponent |
| + let ext = dest.pathExtension |
| + while FileManager.default.fileExists(atPath: dest.path) { |
| + let numbered = ext.isEmpty ? "\(base) (\(n))" : "\(base) (\(n)).\(ext)" |
| + dest = dest.deletingLastPathComponent().appendingPathComponent(numbered) |
| + n += 1 |
| + } |
| + try? data.write(to: dest) |
| + savedDocID = att.id |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { |
| + if savedDocID == att.id { savedDocID = 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. |
| + @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 |
| + } |
| + |
| + 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 |
| + |
| + @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) |
| + .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; return } |
| + title = await unfurl(url)?.title |
| + } |
| + } |
| +} |
| + |
| +// 🤖 The in-sheet 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 sheet. |
| +private struct MediaViewerOverlay: View { |
| + let entries: [ThreadMedia.Entry] |
| + let index: Int |
| + let source: (ThreadMedia.Entry) -> AvatarSource? |
| + let onStep: (Int) -> Void |
| + let onClose: () -> Void |
| + |
| + @State private var image: NSImage? |
| + |
| + var body: some View { |
| + ZStack { |
| + Color.black.opacity(0.82) |
| + .onTapGesture { onClose() } |
| + if let image { |
| + Image(nsImage: image) |
| + .resizable() |
| + .interpolation(.high) |
| + .aspectRatio(contentMode: .fit) |
| + .padding(36) |
| + } 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 { |
| + Spacer() |
| + 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 |
| + guard let src = source(entries[index]) else { return } |
| + image = await BlobImageLoader.shared.image(src, maxPixel: nil) |
| + } |
| + .accessibilityLabel("Photo \((index + 1).formatted()) of \(entries.count.formatted())") |
| + } |
| + |
| + 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) |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index ea87c0c..6574139 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -268,6 +268,17 @@ struct NativeChatView: View { |
| .sheet(item: $renameSheetThread) { t in |
| RenameGroupSheet(store: store, thread: t) |
| } |
| + .sheet(item: $mediaSheetThread) { t in |
| + MediaLinksDocsSheet(instance: instance, thread: t, store: store, |
| + model: feedModel(t), |
| + onJump: { sid in |
| + mediaSheetThread = nil |
| + // Land the (already-selected or not) feed |
| + // on the message — the search hit's path. |
| + selectedID = t.id |
| + app.nativeJump = .init(thread: t.id, message: sid) |
| + }) |
| + } |
| .sheet(item: $appsSheetThread) { t in |
| GroupAppsSheet(model: feedModel(t)) |
| } |
| @@ -781,6 +792,8 @@ struct NativeChatView: View { |
| @State private var membersSheetThread: ChatThread? |
| @State private var renameSheetThread: ChatThread? |
| + // The per-thread shared-content inventory (spec: media-links-docs). |
| + @State private var mediaSheetThread: ChatThread? |
| // A DM header's profile card (rename them for yourself there — #577). |
| @State private var headerProfile: HeaderProfileTarget? |
| struct HeaderProfileTarget: Identifiable { let id: String } |
| @@ -802,6 +815,12 @@ struct NativeChatView: View { |
| Button("Manage Apps…") { appsSheetThread = t } |
| Divider() |
| } |
| + // Media, Links & Docs — DMs and groups alike (NOT in the group |
| + // block); app threads have no shared-content feed, so it hides there. |
| + if !t.isApp { |
| + Button("Media, Links & Docs…") { mediaSheetThread = t } |
| + Divider() |
| + } |
| Button(t.pinned ? "Unpin" : "Pin") { store.setPinned(t, !t.pinned) } |
| Button(t.isMuted ? "Unmute chat" : "Mute chat") { store.setMuted(t, !t.isMuted) } |
| Button(t.unread ? "Mark as Read" : "Mark as Unread") { |