fix(reader): grey out share/permissions and other live-only actions offline

Share, Permissions, Templatize, Duplicate, Unpublish, Archive, Move,
New Document, History, Insights (sheet + Viewer Insights toggle), and
Download all hit the server directly with no offline path — disabled
(with a tooltip on the two toolbar buttons) whenever the app isn't
effectively online, instead of failing confusingly on tap.

Left enabled: Edit, Pin/Unpin, Star/Unstar, Subscribed, Full Width
(all queue via CachingOutlineAPIClient and sync later), Present and
Search in Document (both read documents.info, which is cached), and
Copy/Print (read already-loaded text directly, no network at all).
This commit is contained in:
2026-08-15 01:22:47 +01:00
parent a9db3e1412
commit 0d9f7d752d
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View { struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -56,6 +57,15 @@ struct DocumentReaderView: View {
self.onDocumentCreated = onDocumentCreated self.onDocumentCreated = onDocumentCreated
} }
/// Editing, pin/star/subscribe, and Full Width all queue and sync later
/// (see `CachingOutlineAPIClient`) everything else here (sharing,
/// permissions, move/archive/delete/duplicate/templatize, history,
/// insights, export) hits the server directly with no offline path, so
/// it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -110,7 +120,8 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
.help("Share") .help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
} }
@@ -131,7 +142,8 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "doc.badge.plus") Image(systemName: "doc.badge.plus")
} }
.help("New Document") .help(isEffectivelyOnline ? "New Document" : "Creating documents needs an internet connection")
.disabled(!isEffectivelyOnline)
Menu { Menu {
menuContent menuContent
@@ -235,7 +247,8 @@ struct DocumentReaderView: View {
viewModel.isPinned, viewModel.isPinned,
viewModel.isInsightsEnabled ?? false, viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth, viewModel.isFullWidth,
viewModel.isEditing viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-") ].map(String.init).joined(separator: "-")
} }
@@ -305,27 +318,33 @@ struct DocumentReaderView: View {
Button("Permissions…") { Button("Permissions…") {
isShowingShareSheet = true isShowingShareSheet = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Templatize") { Button("Templatize") {
Task { await templatize() } Task { await templatize() }
} }
.disabled(!isEffectivelyOnline)
Button("Duplicate") { Button("Duplicate") {
Task { await duplicate() } Task { await duplicate() }
} }
.disabled(!isEffectivelyOnline)
Button("Unpublish") { Button("Unpublish") {
isShowingUnpublishConfirmation = true isShowingUnpublishConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Button("Archive…") { Button("Archive…") {
isShowingArchiveConfirmation = true isShowingArchiveConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Move") { Button("Move") {
isShowingMoveSheet = true isShowingMoveSheet = true
} }
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than // Multipart file upload is its own subsystem deferred rather than
// half-built here. // half-built here.
Button("Import Document…") {} Button("Import Document…") {}
@@ -333,6 +352,7 @@ struct DocumentReaderView: View {
Button("New Document") { Button("New Document") {
isShowingNewDocumentSheet = true isShowingNewDocumentSheet = true
} }
.disabled(!isEffectivelyOnline)
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() } Task { await togglePin() }
} }
@@ -342,9 +362,13 @@ struct DocumentReaderView: View {
Button("History") { Button("History") {
isShowingHistorySheet = true isShowingHistorySheet = true
} }
.disabled(!isEffectivelyOnline)
Button("Insights") { Button("Insights") {
isShowingInsightsSheet = true isShowingInsightsSheet = true
} }
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") { Button("Present") {
isShowingPresentSheet = true isShowingPresentSheet = true
} }
@@ -354,6 +378,7 @@ struct DocumentReaderView: View {
Button("Download") { Button("Download") {
Task { await download() } Task { await download() }
} }
.disabled(!isEffectivelyOnline)
Button("Copy") { Button("Copy") {
Task { await copyMarkdown() } Task { await copyMarkdown() }
} }
@@ -370,6 +395,7 @@ struct DocumentReaderView: View {
get: { viewModel.isInsightsEnabled ?? false }, get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } } set: { _ in Task { await toggleInsights() } }
)) ))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds // Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not // field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the // reachable via the API either (no `team.update` endpoint in the
@@ -386,6 +412,7 @@ struct DocumentReaderView: View {
Button("Delete…", role: .destructive) { Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true isShowingDeleteConfirmation = true
} }
.disabled(!isEffectivelyOnline)
} }
private func star() async { private func star() async {