eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev c0c1a1d0cbfcc4a1d8ef06a552c6a9078deef98f parent df2ff7a
11 files changed, +1417 −21
  • docs/superpowers/plans/2026-08-28-media-links-docs-native.md +94 −0
  • ios/LChat/Views/MediaLinksDocsSheet.swift +504 −0
  • ios/LChat/Views/NativeChatScreen.swift +26 −0
  • ios/LChatCore/Package.resolved +9 −0
  • ios/LChatCore/Sources/LChatCore/HistoryBackfill.swift +54 −21
  • ios/LChatCore/Sources/LChatCore/MessageStore.swift +20 −0
  • ios/LChatCore/Sources/LChatCore/RichText.swift +17 −0
  • ios/LChatCore/Sources/LChatCore/ThreadMedia.swift +114 −0
  • ios/LChatCore/Tests/LChatCoreTests/ThreadMediaTests.swift +119 −0
  • ios/LChatMac/MediaLinksDocsSheet.swift +441 −0
  • ios/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") {