From fa31cd707ee05352ca624ddcbb2f2b9a9c7e200c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 15:43:14 +0100 Subject: [PATCH] fix(collections): wire up sidebar Pin, drop dead Embeds field, real toggles Smoke-tested against a live server: Pin didn't work from the sidebar context menu, Subscribe/Unsubscribe worked, Enable Embeds didn't. - Sidebar document context menu still had the pre-pins.*-support disabled Pin/Unsubscribe stubs from before that endpoint existed - only the reader's menu got updated at the time. Wired up real Pin (collection-scoped pins.list loaded once per CollectionDocumentsOutline, not per row - avoids an N+1 call storm); left Unsubscribe disabled there since subscriptions.list is per-document, with an accurate comment pointing at the reader's menu instead. - documentEmbeds confirmed not a real field - removed from UpdateDocumentRequest entirely rather than leave a menu item that silently no-ops. - Subscribe, Viewer Insights, and Full Width are now real Toggle menu items (checkmark reflects actual state) instead of static action buttons. Viewer Insights state is inferred from whether documents.insights succeeds/fails, since insightsEnabled isn't readable back off Document - heuristic, flagged in code. --- .../Requests/UpdateDocumentRequest.swift | 10 +--- .../CollectionDocumentsOutline.swift | 52 ++++++++++++++++--- .../Collections/DocumentReaderView.swift | 47 +++++++++-------- .../Collections/DocumentReaderViewModel.swift | 33 ++++++++---- 4 files changed, 94 insertions(+), 48 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index b7a5611..5e38781 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable { public let append: Bool? public let fullWidth: Bool? public let insightsEnabled: Bool? - /// Not in the vendored spec's `Document`/`documents.update` shape at all - /// (only a workspace-level `documentEmbeds` flag exists there) — included - /// speculatively since the field may exist on newer self-hosted servers. - /// Unrecognized fields are typically ignored server-side rather than - /// rejected, so this is low-risk even if unsupported. - public let documentEmbeds: Bool? public init( id: String, @@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable { text: String? = nil, append: Bool? = nil, fullWidth: Bool? = nil, - insightsEnabled: Bool? = nil, - documentEmbeds: Bool? = nil + insightsEnabled: Bool? = nil ) { self.id = id self.title = title @@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable { self.append = append self.fullWidth = fullWidth self.insightsEnabled = insightsEnabled - self.documentEmbeds = documentEmbeds } } diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 6db03f2..12a2856 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -9,6 +9,7 @@ import OutlineKit /// client-side rather than `collections.documents`. struct CollectionDocumentsOutline: View { let apiClient: OutlineAPIClient + let collection: OutlineCollection @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int @@ -17,6 +18,11 @@ struct CollectionDocumentsOutline: View { /// toolbar render the real hierarchy instead of just the leaf title. let onSelectDocument: ([OutlineDocument]) -> Void + /// Loaded once per collection (`pins.list` is collection-scoped) rather + /// than per-row — a per-row `pins.list`/lookup would be an N+1 call for + /// every document in the tree. + @State private var pinsByDocumentID: [String: OutlinePin] = [:] + private var tree: [DocumentNode] { buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) } @@ -30,6 +36,7 @@ struct CollectionDocumentsOutline: View { onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { self.apiClient = apiClient + self.collection = collection _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken @@ -56,13 +63,23 @@ struct CollectionDocumentsOutline: View { depth: 0, ancestors: [], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: { await viewModel.load() } + onDocumentsChanged: { await viewModel.load() }, + onPinsChanged: { await loadPins() } ) } } } - .task(id: refreshToken) { await viewModel.load() } + .task(id: refreshToken) { + await viewModel.load() + await loadPins() + } + } + + private func loadPins() async { + guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } + pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) } } @@ -81,8 +98,10 @@ private struct DocumentNodeRow: View { /// Chain from root down to (not including) this node. let ancestors: [OutlineDocument] let selectedDocumentID: String? + let pinsByDocumentID: [String: OutlinePin] let onSelectDocument: ([OutlineDocument]) -> Void let onDocumentsChanged: () async -> Void + let onPinsChanged: () async -> Void @State private var isExpanded = false @@ -172,8 +191,10 @@ private struct DocumentNodeRow: View { depth: depth + 1, ancestors: ancestors + [node.document], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: onDocumentsChanged + onDocumentsChanged: onDocumentsChanged, + onPinsChanged: onPinsChanged ) } } @@ -259,7 +280,11 @@ private struct DocumentNodeRow: View { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Task { await star() } } - // No `subscriptions.*` endpoint in the API — nothing to back this with. + // subscriptions.* does exist and works (confirmed against a live + // server via the reader's menu) — not shown here because + // subscriptions.list is per-document, so reflecting accurate + // per-row state for every document in the tree would mean an N+1 + // call storm. Use the reader's ⋯ menu instead. Button("Unsubscribe") {} .disabled(true) @@ -305,9 +330,9 @@ private struct DocumentNodeRow: View { Button("New Document") { Task { await createChildDocument() } } - // No `pins.*` endpoint in the API — nothing to back this with. - Button("Pin") {} - .disabled(true) + Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") { + Task { await togglePin() } + } Divider() @@ -351,6 +376,19 @@ private struct DocumentNodeRow: View { } } + private func togglePin() async { + do { + if let pin = pinsByDocumentID[node.document.id] { + try await apiClient.deletePin(id: pin.id) + } else { + _ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId)) + } + await onPinsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.") + } + } + private func rename() async { do { _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index cbd8786..fedb00d 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -133,6 +133,9 @@ struct DocumentReaderView: View { .task { await viewModel.loadPinAndSubscriptionState() } + .task { + await viewModel.loadInsightsEnabledState() + } .task { while !Task.isCancelled { await viewModel.loadViewers() @@ -249,9 +252,10 @@ struct DocumentReaderView: View { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { Task { await star() } } - Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { - Task { await toggleSubscription() } - } + Toggle("Subscribed", isOn: Binding( + get: { viewModel.isSubscribed }, + set: { _ in Task { await toggleSubscription() } } + )) Divider() @@ -323,15 +327,20 @@ struct DocumentReaderView: View { Divider() - Button("Enable Viewer Insights") { - Task { await enableInsights() } - } - Button("Enable Embeds") { - Task { await enableEmbeds() } - } - Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { - Task { await toggleFullWidth() } - } + Toggle("Viewer Insights", isOn: Binding( + get: { viewModel.isInsightsEnabled ?? false }, + set: { _ in Task { await toggleInsights() } } + )) + // Confirmed against a live server: there's no per-document embeds + // field. Only a workspace-level setting exists, and that's not + // reachable via the API either (no `team.update` endpoint in the + // vendored spec) — disabled rather than kept as a broken action. + Button("Enable Embeds") {} + .disabled(true) + Toggle("Full Width", isOn: Binding( + get: { viewModel.isFullWidth }, + set: { _ in Task { await toggleFullWidth() } } + )) Divider() @@ -372,19 +381,11 @@ struct DocumentReaderView: View { } } - private func enableInsights() async { + private func toggleInsights() async { do { - try await viewModel.enableViewerInsights() + try await viewModel.toggleViewerInsights() } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") - } - } - - private func enableEmbeds() async { - do { - try await viewModel.enableEmbeds() - } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.") } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 47bc3c2..4a482fc 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -30,6 +30,12 @@ final class DocumentReaderViewModel { private var subscriptionId: String? private(set) var share: OutlineShare? + /// `nil` until checked. Inferred from whether `documents.insights` + /// succeeds or fails — `insightsEnabled` isn't readable back off + /// `Document` in the vendored spec, so there's no direct field to read. + /// This is a heuristic, not confirmed server behavior. + private(set) var isInsightsEnabled: Bool? + let documentId: String private let apiClient: OutlineAPIClient @@ -98,6 +104,15 @@ final class DocumentReaderViewModel { share = try? await apiClient.shareInfo(documentId: documentId) } + func loadInsightsEnabledState() async { + do { + _ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId)) + isInsightsEnabled = true + } catch { + isInsightsEnabled = false + } + } + func togglePin() async throws { if let pinId { self.pinId = nil @@ -168,14 +183,14 @@ final class DocumentReaderViewModel { } } - /// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` - /// in the vendored spec, so there's no state to reflect as a checkmark. - func enableViewerInsights() async throws { - _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) - } - - /// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`. - func enableEmbeds() async throws { - _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true)) + func toggleViewerInsights() async throws { + let newValue = !(isInsightsEnabled ?? false) + isInsightsEnabled = newValue + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue)) + } catch { + isInsightsEnabled = !newValue + throw error + } } }