fix(reader): real toggle checkmarks for Subscribed/Viewer Insights/Full Width

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.
This commit is contained in:
2026-08-14 16:41:20 +01:00
parent c1810f38f1
commit b45d72238c
3 changed files with 73 additions and 42 deletions
@@ -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
}
}
@@ -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.")
}
}
@@ -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
}
}
}