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 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
} }
} }
@@ -128,12 +128,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()
@@ -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 @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(
@@ -255,9 +281,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()
@@ -329,15 +356,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()
@@ -378,19 +410,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
@@ -100,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
@@ -170,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))
} }
} }