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.
This commit is contained in:
2026-08-14 15:43:14 +01:00
parent 9991302683
commit fa31cd707e
4 changed files with 94 additions and 48 deletions
@@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
public let append: Bool? public let append: Bool?
public let fullWidth: Bool? public let fullWidth: Bool?
public let insightsEnabled: 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( public init(
id: String, id: String,
@@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
text: String? = nil, text: String? = nil,
append: Bool? = nil, append: Bool? = nil,
fullWidth: Bool? = nil, fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil, insightsEnabled: Bool? = nil
documentEmbeds: Bool? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
self.append = append self.append = append
self.fullWidth = fullWidth self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled self.insightsEnabled = insightsEnabled
self.documentEmbeds = documentEmbeds
} }
} }
@@ -9,6 +9,7 @@ import OutlineKit
/// client-side rather than `collections.documents`. /// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View { struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption let sortOption: SidebarSortOption
let refreshToken: Int let refreshToken: Int
@@ -17,6 +18,11 @@ struct CollectionDocumentsOutline: View {
/// toolbar render the real hierarchy instead of just the leaf title. /// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void 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] { private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
} }
@@ -30,6 +36,7 @@ struct CollectionDocumentsOutline: View {
onSelectDocument: @escaping ([OutlineDocument]) -> Void onSelectDocument: @escaping ([OutlineDocument]) -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption self.sortOption = sortOption
self.refreshToken = refreshToken self.refreshToken = refreshToken
@@ -56,13 +63,23 @@ struct CollectionDocumentsOutline: View {
depth: 0, depth: 0,
ancestors: [], ancestors: [],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, 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. /// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument] let ancestors: [OutlineDocument]
let selectedDocumentID: String? let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false @State private var isExpanded = false
@@ -172,8 +191,10 @@ private struct DocumentNodeRow: View {
depth: depth + 1, depth: depth + 1,
ancestors: ancestors + [node.document], ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, 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") { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await 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") {} Button("Unsubscribe") {}
.disabled(true) .disabled(true)
@@ -305,9 +330,9 @@ private struct DocumentNodeRow: View {
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } Task { await createChildDocument() }
} }
// No `pins.*` endpoint in the API nothing to back this with. Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") {
Button("Pin") {} Task { await togglePin() }
.disabled(true) }
Divider() 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 { private func rename() async {
do { do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
@@ -133,6 +133,9 @@ struct DocumentReaderView: View {
.task { .task {
await viewModel.loadPinAndSubscriptionState() await viewModel.loadPinAndSubscriptionState()
} }
.task {
await viewModel.loadInsightsEnabledState()
}
.task { .task {
while !Task.isCancelled { while !Task.isCancelled {
await viewModel.loadViewers() await viewModel.loadViewers()
@@ -249,9 +252,10 @@ struct DocumentReaderView: View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { Toggle("Subscribed", isOn: Binding(
Task { await toggleSubscription() } get: { viewModel.isSubscribed },
} set: { _ in Task { await toggleSubscription() } }
))
Divider() Divider()
@@ -323,15 +327,20 @@ struct DocumentReaderView: View {
Divider() Divider()
Button("Enable Viewer Insights") { Toggle("Viewer Insights", isOn: Binding(
Task { await enableInsights() } get: { viewModel.isInsightsEnabled ?? false },
} set: { _ in Task { await toggleInsights() } }
Button("Enable Embeds") { ))
Task { await enableEmbeds() } // Confirmed against a live server: there's no per-document embeds
} // field. Only a workspace-level setting exists, and that's not
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { // reachable via the API either (no `team.update` endpoint in the
Task { await toggleFullWidth() } // 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() Divider()
@@ -372,19 +381,11 @@ struct DocumentReaderView: View {
} }
} }
private func enableInsights() async { private func toggleInsights() async {
do { do {
try await viewModel.enableViewerInsights() try await viewModel.toggleViewerInsights()
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
}
}
private func enableEmbeds() async {
do {
try await viewModel.enableEmbeds()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
} }
} }
@@ -30,6 +30,12 @@ final class DocumentReaderViewModel {
private var subscriptionId: String? private var subscriptionId: String?
private(set) var share: OutlineShare? 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 let documentId: String
private let apiClient: OutlineAPIClient private let apiClient: OutlineAPIClient
@@ -98,6 +104,15 @@ final class DocumentReaderViewModel {
share = try? await apiClient.shareInfo(documentId: documentId) 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 { func togglePin() async throws {
if let pinId { if let pinId {
self.pinId = nil self.pinId = nil
@@ -168,14 +183,14 @@ final class DocumentReaderViewModel {
} }
} }
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` func toggleViewerInsights() async throws {
/// in the vendored spec, so there's no state to reflect as a checkmark. let newValue = !(isInsightsEnabled ?? false)
func enableViewerInsights() async throws { isInsightsEnabled = newValue
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
} catch {
isInsightsEnabled = !newValue
throw error
} }
/// Fire-and-forget, speculative field see `UpdateDocumentRequest.documentEmbeds`.
func enableEmbeds() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
} }
} }