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:
@@ -129,7 +129,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]
|
||||
}
|
||||
|
||||
private struct PinsListPayload: Decodable {
|
||||
let pins: [OutlinePin]
|
||||
let documents: [OutlineDocument]
|
||||
}
|
||||
|
||||
private struct ListStarsResponse: Decodable {
|
||||
let stars: [OutlineStar]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// A document pinned to the top of a collection (or the team home), backed by
|
||||
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
|
||||
/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's
|
||||
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
|
||||
/// from general knowledge of Outline's API, not verified against this spec;
|
||||
/// treat field names as best-effort until confirmed against a live server.
|
||||
/// A document pinned to the top of a collection, or to team Home when
|
||||
/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI
|
||||
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
|
||||
/// confirmed real against a live server's network traffic. `collectionId: nil`
|
||||
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
|
||||
/// Collection", a distinct action.
|
||||
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 let documentId: String
|
||||
public let collectionId: String?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 let collectionId: String?
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
@@ -9,14 +9,26 @@ 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
|
||||
/// 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?
|
||||
/// Full chain from root to the clicked document (inclusive) — lets the
|
||||
/// 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)
|
||||
}
|
||||
@@ -26,13 +38,16 @@ struct CollectionDocumentsOutline: View {
|
||||
collection: OutlineCollection,
|
||||
sortOption: SidebarSortOption,
|
||||
refreshToken: Int,
|
||||
externalRefreshToken: Int,
|
||||
selectedDocumentID: String?,
|
||||
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
self.sortOption = sortOption
|
||||
self.refreshToken = refreshToken
|
||||
self.externalRefreshToken = externalRefreshToken
|
||||
self.selectedDocumentID = selectedDocumentID
|
||||
self.onSelectDocument = onSelectDocument
|
||||
}
|
||||
@@ -56,13 +71,26 @@ 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() }
|
||||
// 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.
|
||||
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 +202,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 +291,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 +341,11 @@ 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)
|
||||
// Scoped to this collection (`collection.id`) — "Pin to Collection",
|
||||
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
|
||||
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
|
||||
Task { await togglePin() }
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
||||
|
||||
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
|
||||
let isExpanded: Bool
|
||||
let isSelected: Bool
|
||||
let selectedDocumentID: String?
|
||||
/// See the identical parameter on `CollectionDocumentsOutline`.
|
||||
let externalRefreshToken: Int
|
||||
let onToggle: () -> Void
|
||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||
let onSearchInCollection: (OutlineCollection) -> Void
|
||||
@@ -62,6 +64,7 @@ struct CollectionTreeRow: View {
|
||||
collection: collection,
|
||||
sortOption: sortOption,
|
||||
refreshToken: documentsRefreshToken,
|
||||
externalRefreshToken: externalRefreshToken,
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
onSelectDocument: onSelectDocument
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@ struct CollectionsTreeView: View {
|
||||
@Binding private var selectedCollection: OutlineCollection?
|
||||
let selectedDocumentID: 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 onSearchInCollection: (OutlineCollection) -> Void
|
||||
|
||||
@@ -14,12 +18,14 @@ struct CollectionsTreeView: View {
|
||||
apiClient: OutlineAPIClient,
|
||||
selectedCollection: Binding<OutlineCollection?>,
|
||||
selectedDocumentID: String?,
|
||||
externalRefreshToken: Int,
|
||||
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
||||
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||
_selectedCollection = selectedCollection
|
||||
self.selectedDocumentID = selectedDocumentID
|
||||
self.externalRefreshToken = externalRefreshToken
|
||||
self.onSelectDocument = onSelectDocument
|
||||
self.onSearchInCollection = onSearchInCollection
|
||||
}
|
||||
@@ -81,6 +87,7 @@ struct CollectionsTreeView: View {
|
||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||
isSelected: selectedCollection?.id == collection.id,
|
||||
selectedDocumentID: selectedDocumentID,
|
||||
externalRefreshToken: externalRefreshToken,
|
||||
onToggle: { toggle(collection) },
|
||||
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
||||
onSearchInCollection: onSearchInCollection,
|
||||
|
||||
@@ -12,6 +12,11 @@ struct ContentView_macOS: View {
|
||||
@State private var contextualSearchQuery = ""
|
||||
@State private var isContextualSearchExpanded = false
|
||||
@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 {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -153,6 +158,7 @@ struct ContentView_macOS: View {
|
||||
apiClient: apiClient,
|
||||
selectedCollection: $selectedCollection,
|
||||
selectedDocumentID: documentPath.last?.id,
|
||||
externalRefreshToken: documentsChangedToken,
|
||||
onSelectDocument: selectDocumentChain,
|
||||
onSearchInCollection: searchInCollection
|
||||
)
|
||||
@@ -229,7 +235,8 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ struct DocumentReaderView: View {
|
||||
/// longer visible in the collection it was opened from, so the reader
|
||||
/// pops itself off the navigation stack.
|
||||
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 isShowingArchiveConfirmation = false
|
||||
@@ -39,13 +44,15 @@ struct DocumentReaderView: View {
|
||||
apiClient: OutlineAPIClient,
|
||||
document: OutlineDocument,
|
||||
onOpenChild: @escaping (OutlineDocument) -> Void,
|
||||
onDeleted: @escaping () -> Void
|
||||
onDeleted: @escaping () -> Void,
|
||||
onDocumentCreated: @escaping () -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -127,12 +134,22 @@ struct DocumentReaderView: View {
|
||||
} label: {
|
||||
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.loadPinAndSubscriptionState()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
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
|
||||
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) {
|
||||
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
||||
AvatarBadge(
|
||||
@@ -249,9 +282,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()
|
||||
|
||||
@@ -290,7 +324,7 @@ struct DocumentReaderView: View {
|
||||
Button("New Document") {
|
||||
Task { await createChildDocument() }
|
||||
}
|
||||
Button(viewModel.isPinned ? "Unpin" : "Pin") {
|
||||
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
||||
Task { await togglePin() }
|
||||
}
|
||||
|
||||
@@ -323,15 +357,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 +411,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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +471,7 @@ struct DocumentReaderView: View {
|
||||
let child = try await apiClient.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
|
||||
)
|
||||
onDocumentCreated()
|
||||
onOpenChild(child)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,7 +81,9 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
|
||||
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 }) {
|
||||
isPinned = true
|
||||
pinId = match.id
|
||||
@@ -98,6 +106,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
|
||||
@@ -110,7 +127,7 @@ final class DocumentReaderViewModel {
|
||||
throw error
|
||||
}
|
||||
} 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
|
||||
isPinned = true
|
||||
}
|
||||
@@ -168,14 +185,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))
|
||||
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
|
||||
}
|
||||
|
||||
/// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`.
|
||||
func enableEmbeds() async throws {
|
||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user