From b45d72238c275d340eb46c86e02e21d91d63b712 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:41:20 +0100 Subject: [PATCH] fix(reader): real toggle checkmarks for Subscribed/Viewer Insights/Full Width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch never got the toggle-menu fixes that landed on chore/verify-pins-subscriptions-api — Subscribed/Viewer Insights/Full Width were plain Buttons with no on/off indicator, and the menu didn't force a rebuild on state change, so even a real Toggle would've shown a stale checkmark until the view was torn down and rebuilt. Ported that branch's fixes: Subscribed/Viewer Insights/Full Width are now real Toggle views bound to observable state; the overflow Menu is keyed to a .id() built from every toggle-backed state so SwiftUI actually re-evaluates the checkmarks; viewer avatars are gated on isInsightsEnabled so they hide immediately when insights are turned off. Also dropped the dead documentEmbeds field (confirmed no per-document embeds endpoint exists) — Enable Embeds is a disabled button with an explanation, matching the other branch. --- .../Requests/UpdateDocumentRequest.swift | 10 +-- .../Collections/DocumentReaderView.swift | 72 ++++++++++++------- .../Collections/DocumentReaderViewModel.swift | 33 ++++++--- 3 files changed, 73 insertions(+), 42 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/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 5bc5353..0772981 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -128,12 +128,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() @@ -208,9 +218,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( @@ -255,9 +281,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() @@ -329,15 +356,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() @@ -378,19 +410,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 4ec090b..3005440 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 @@ -100,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 @@ -170,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)) - } - - /// 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 + } } }