Merge pull request 'Fix Pin, Subscribe, sidebar toggles, and stale-sidebar bugs found in live testing' (#3) from chore/verify-pins-subscriptions-api into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-14 17:02:31 +01:00
committed by PSM Git
12 changed files with 211 additions and 63 deletions
@@ -129,7 +129,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await post("pins.list", body: request) // `data` here is `{ pins: [...], documents: [...] }`, not a bare
// array confirmed against a live server. Decoding straight to
// `[OutlinePin]` throws on every call, which `try?` at call sites
// swallows silently, so pins never showed up anywhere.
let payload: PinsListPayload = try await post("pins.list", body: request)
return payload.pins
} }
public func deletePin(id: String) async throws { public func deletePin(id: String) async throws {
@@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct ListStarsResponse: Decodable { private struct ListStarsResponse: Decodable {
let stars: [OutlineStar] let stars: [OutlineStar]
} }
@@ -1,11 +1,11 @@
import Foundation import Foundation
/// A document pinned to the top of a collection (or the team home), backed by /// A document pinned to the top of a collection, or to team Home when
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) /// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's /// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed /// confirmed real against a live server's network traffic. `collectionId: nil`
/// from general knowledge of Outline's API, not verified against this spec; /// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// treat field names as best-effort until confirmed against a live server. /// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable { public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let documentId: String public let documentId: String
@@ -1,6 +1,7 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable { public struct CreatePinRequest: Encodable, Sendable {
public let documentId: String public let documentId: String
public let collectionId: String? public let collectionId: String?
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` lists Home pins only.
public struct ListPinsRequest: Encodable, Sendable { public struct ListPinsRequest: Encodable, Sendable {
public let collectionId: String? public let collectionId: String?
@@ -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
} }
} }
@@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
} }
func testListPinsDecodesNestedPinsArray() async throws {
// Real shape confirmed against a live server: `data` is
// `{ pins: [...], documents: [...] }`, not a bare array.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": {
"pins": [
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
XCTAssertEqual(pins.first?.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
}
func testCreateSubscriptionDecodesSubscription() async throws { func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -9,14 +9,26 @@ 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
/// Bumped from `ContentView_macOS` whenever a document is created from
/// somewhere that has no direct handle on this row the reader's
/// toolbar "New Document" button, specifically, which doesn't know which
/// (if any) sidebar row corresponds to the collection its new document
/// landed in, so every expanded row just reloads itself.
let externalRefreshToken: Int
let selectedDocumentID: String? let selectedDocumentID: String?
/// Full chain from root to the clicked document (inclusive) lets the /// Full chain from root to the clicked document (inclusive) lets the
/// 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)
} }
@@ -26,13 +38,16 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection, collection: OutlineCollection,
sortOption: SidebarSortOption, sortOption: SidebarSortOption,
refreshToken: Int, refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?, selectedDocumentID: String?,
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
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
} }
@@ -56,13 +71,26 @@ 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() } // Combined into one identity rather than two separate `.task(id:)`
// modifiers each of those fires once unconditionally on first
// appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
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 +109,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 +202,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 +291,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 +341,11 @@ 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. // Scoped to this collection (`collection.id`) "Pin to Collection",
Button("Pin") {} // distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
.disabled(true) Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
Task { await togglePin() }
}
Divider() Divider()
@@ -351,6 +389,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))
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
let isExpanded: Bool let isExpanded: Bool
let isSelected: Bool let isSelected: Bool
let selectedDocumentID: String? let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -62,6 +64,7 @@ struct CollectionTreeRow: View {
collection: collection, collection: collection,
sortOption: sortOption, sortOption: sortOption,
refreshToken: documentsRefreshToken, refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument onSelectDocument: onSelectDocument
) )
@@ -7,6 +7,10 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection? @Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String? let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = [] @State private var expandedCollectionIDs: Set<String> = []
/// Bumped from `ContentView_macOS` whenever a document is created
/// somewhere with no direct handle on the sidebar row it belongs
/// under see the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -14,12 +18,14 @@ struct CollectionsTreeView: View {
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>, selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?, selectedDocumentID: String?,
externalRefreshToken: Int,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void onSearchInCollection: @escaping (OutlineCollection) -> Void
) { ) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection _selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection self.onSearchInCollection = onSearchInCollection
} }
@@ -81,6 +87,7 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id), isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id, isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) }, onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) }, onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection, onSearchInCollection: onSearchInCollection,
@@ -12,6 +12,11 @@ struct ContentView_macOS: View {
@State private var contextualSearchQuery = "" @State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false @State private var isContextualSearchExpanded = false
@FocusState private var isContextualSearchFocused: Bool @FocusState private var isContextualSearchFocused: Bool
/// Bumped whenever a document is created from somewhere with no direct
/// handle on the sidebar row it belongs under (the reader toolbar's
/// "New Document" button) every expanded sidebar row reloads itself
/// in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
private var trimmedGlobalQuery: String { private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -153,6 +158,7 @@ struct ContentView_macOS: View {
apiClient: apiClient, apiClient: apiClient,
selectedCollection: $selectedCollection, selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id, selectedDocumentID: documentPath.last?.id,
externalRefreshToken: documentsChangedToken,
onSelectDocument: selectDocumentChain, onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection onSearchInCollection: searchInCollection
) )
@@ -229,7 +235,8 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty { if !documentPath.isEmpty {
documentPath.removeLast() documentPath.removeLast()
} }
} },
onDocumentCreated: { documentsChangedToken += 1 }
) )
} }
} }
@@ -23,6 +23,11 @@ struct DocumentReaderView: View {
/// longer visible in the collection it was opened from, so the reader /// longer visible in the collection it was opened from, so the reader
/// pops itself off the navigation stack. /// pops itself off the navigation stack.
let onDeleted: () -> Void let onDeleted: () -> Void
/// The reader's own "New Document" toolbar button has no direct handle
/// on the sidebar row it belongs under this tells the sidebar a
/// document exists now so it can pick it up. See
/// `CollectionDocumentsOutline.externalRefreshToken`.
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false @State private var isShowingUnpublishConfirmation = false
@State private var isShowingArchiveConfirmation = false @State private var isShowingArchiveConfirmation = false
@@ -39,13 +44,15 @@ struct DocumentReaderView: View {
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
document: OutlineDocument, document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void, onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void onDeleted: @escaping () -> Void,
onDocumentCreated: @escaping () -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild self.onOpenChild = onOpenChild
self.onDeleted = onDeleted self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
} }
var body: some View { var body: some View {
@@ -127,12 +134,22 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
} }
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
// `Toggle`'s checkmark against updated @Observable state on
// its own without a fresh `.id()` per state combination,
// toggling Subscribed/Viewer Insights/Full Width kept
// showing the pre-toggle checkmark until the whole view was
// torn down and rebuilt (e.g. navigating away and back).
.id(menuIdentity)
} }
} }
.task { await viewModel.loadFullContent() } .task { await viewModel.loadFullContent() }
.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()
@@ -202,9 +219,25 @@ struct DocumentReaderView: View {
} }
} }
/// Every toggle-backed piece of state shown as a checkmark inside
/// `menuContent` see the `.id()` comment on the `Menu` above.
private var menuIdentity: String {
[
starStore.isStarred(documentId: viewModel.documentId),
viewModel.isSubscribed,
viewModel.isPinned,
viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth,
viewModel.isEditing
].map(String.init).joined(separator: "-")
}
@ViewBuilder @ViewBuilder
private var viewerAvatars: some View { private var viewerAvatars: some View {
if !viewModel.viewers.isEmpty { // Tied to the Viewer Insights toggle that's the feature this data
// belongs to, so turning it off should hide the avatars immediately
// rather than leaving them showing until the view reloads.
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
HStack(spacing: -6) { HStack(spacing: -6) {
ForEach(viewModel.viewers.prefix(5)) { viewer in ForEach(viewModel.viewers.prefix(5)) { viewer in
AvatarBadge( AvatarBadge(
@@ -249,9 +282,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()
@@ -290,7 +324,7 @@ struct DocumentReaderView: View {
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } Task { await createChildDocument() }
} }
Button(viewModel.isPinned ? "Unpin" : "Pin") { Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() } Task { await togglePin() }
} }
@@ -323,15 +357,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 +411,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.")
} }
} }
@@ -440,6 +471,7 @@ struct DocumentReaderView: View {
let child = try await apiClient.createDocument( let child = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId) CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
) )
onDocumentCreated()
onOpenChild(child) onOpenChild(child)
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
@@ -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
@@ -75,7 +81,9 @@ final class DocumentReaderViewModel {
} }
func loadPinAndSubscriptionState() async { func loadPinAndSubscriptionState() async {
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
let match = pins.first(where: { $0.documentId == documentId }) { let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true isPinned = true
pinId = match.id pinId = match.id
@@ -98,6 +106,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
@@ -110,7 +127,7 @@ final class DocumentReaderViewModel {
throw error throw error
} }
} else { } else {
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
pinId = pin.id pinId = pin.id
isPinned = true isPinned = true
} }
@@ -168,14 +185,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))
} }
} }