diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 97e9e9c..80d3a90 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -51,6 +51,11 @@ struct DocumentReaderView: View { ) { self.apiClient = apiClient self.document = document + // `separateEditingEnabled` can't be read from `@Environment` here — + // environment values aren't populated yet inside a view's `init`, + // only from `body` onward. Defaults to `true` (today's only + // behavior) and gets set for real in `.task` below once `session` + // is actually available. _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) self.onOpenChild = onOpenChild self.onDeleted = onDeleted @@ -69,7 +74,7 @@ struct DocumentReaderView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { - if viewModel.isEditing { + if viewModel.isEffectivelyEditable { TextField("Title", text: $viewModel.title) .font(.largeTitle.weight(.bold)) .textFieldStyle(.plain) @@ -93,7 +98,7 @@ struct DocumentReaderView: View { text: $viewModel.text, configuration: .init(heightBehavior: .fitsContent), documentId: viewModel.documentId, - isEditable: viewModel.isEditing + isEditable: viewModel.isEffectivelyEditable ) if !viewModel.children.isEmpty { @@ -127,16 +132,24 @@ struct DocumentReaderView: View { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } - Button { - Task { await viewModel.toggleEditing() } - } label: { - if viewModel.isSaving { - ProgressView().controlSize(.small) - } else { - Text(viewModel.isEditing ? "Done" : "Edit") + if viewModel.separateEditingEnabled { + Button { + Task { await viewModel.toggleEditing() } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text(viewModel.isEditing ? "Done" : "Edit") + } } + .disabled(viewModel.isSaving) + } else if viewModel.isSaving { + // No Edit/Done affordance when documents are always + // editable — this is the only feedback that an autosave + // is actually happening. + ProgressView().controlSize(.small) + .help("Saving…") } - .disabled(viewModel.isSaving) Button { isShowingNewDocumentSheet = true @@ -160,6 +173,17 @@ struct DocumentReaderView: View { } } .task { await viewModel.loadFullContent() } + // See the doc comment on `DocumentReaderViewModel.separateEditingEnabled` + // for why this can't just be read at `init` time. + .task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true } + .onChange(of: viewModel.text) { + guard !viewModel.separateEditingEnabled else { return } + viewModel.scheduleAutosave() + } + .onChange(of: viewModel.title) { + guard !viewModel.separateEditingEnabled else { return } + viewModel.scheduleAutosave() + } .task { await viewModel.loadPinAndSubscriptionState() } @@ -309,8 +333,10 @@ struct DocumentReaderView: View { Divider() - Button(viewModel.isEditing ? "Done Editing" : "Edit") { - Task { await viewModel.toggleEditing() } + if viewModel.separateEditingEnabled { + Button(viewModel.isEditing ? "Done Editing" : "Edit") { + Task { await viewModel.toggleEditing() } + } } // Membership management now lives in DocumentShareSheet's "People // with access" section, alongside the share link — same sheet, diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 92b329a..99afcde 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -18,6 +18,38 @@ final class DocumentReaderViewModel { var isSaving = false var saveErrorMessage: String? + /// Snapshot of the preference, set once via `.task` right after the + /// view appears (can't be read from `@Environment` inside the view's + /// own `init`) rather than a live binding to `SessionStore` — matches + /// how `isFullWidth` etc. are already seeded from the document at init + /// rather than observed reactively. A change made in Settings while a + /// document is already open takes effect the next document opened, not + /// mid-session; an acceptable tradeoff for how rarely this gets + /// toggled versus the complexity of threading a live preference + /// reference through every reader instance. + var separateEditingEnabled: Bool + + /// The single source of truth the view reads for both "show the title + /// field" and "is the text view editable" — when separate editing is + /// off there's no Edit/Done mode at all, the document is just always + /// editable (assuming permission; there's no per-document permission + /// field to pre-check against, so an unauthorized edit simply fails to + /// save rather than being blocked client-side up front). + var isEffectivelyEditable: Bool { + separateEditingEnabled ? isEditing : true + } + + private var autosaveTask: Task? + /// Tracks the last known-synced-with-the-server values so + /// `scheduleAutosave()` can no-op when called just because `text`/ + /// `title` were reassigned *from* a server response (initial load, or + /// a completed save) rather than actually edited — without this, every + /// document open in the always-editable mode would fire one pointless + /// autosave round-trip immediately, re-sending exactly what was just + /// received. + private var lastSyncedText: String + private var lastSyncedTitle: String + /// Recent viewers, `views.list` filtered to entries that actually have a /// `lastViewedAt` — this is historical/aggregated view data, not live /// "viewing right now" presence (that needs the Hocuspocus collaboration @@ -38,7 +70,7 @@ final class DocumentReaderViewModel { let documentId: String private let apiClient: OutlineAPIClient - init(apiClient: OutlineAPIClient, document: OutlineDocument) { + init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) { self.apiClient = apiClient self.documentId = document.id self.title = document.title @@ -46,6 +78,9 @@ final class DocumentReaderViewModel { self.text = document.text self.collectionId = document.collectionId self.isFullWidth = document.fullWidth ?? false + self.separateEditingEnabled = separateEditingEnabled + self.lastSyncedText = document.text + self.lastSyncedTitle = document.title } /// The list endpoint's copy of a document isn't guaranteed to be the full, @@ -62,6 +97,8 @@ final class DocumentReaderViewModel { text = full.text collectionId = full.collectionId isFullWidth = full.fullWidth ?? false + lastSyncedText = full.text + lastSyncedTitle = full.title } catch { errorMessage = "Couldn't load this document. Check your connection and try again." } @@ -146,20 +183,55 @@ final class DocumentReaderViewModel { } } - /// Turning editing off saves; turning it on is just a mode switch. + /// Turning editing off saves; turning it on is just a mode switch. Only + /// meaningful when `separateEditingEnabled` — the always-editable path + /// uses `scheduleAutosave()` instead. func toggleEditing() async { guard isEditing else { isEditing = true return } + await save() + if saveErrorMessage == nil { + isEditing = false + } + } + + /// Debounced save for the always-editable (separate editing off) path — + /// cancels any pending save and starts a fresh countdown on every call, + /// so a save only actually fires once typing pauses, not on every + /// keystroke. Goes through the same `updateDocument` call the explicit + /// Done-button save uses, which is already offline-queue-aware + /// (`CachingOutlineAPIClient`), so autosave while offline just queues + /// like any other edit instead of needing separate handling here. + func scheduleAutosave() { + guard text != lastSyncedText || title != lastSyncedTitle else { return } + autosaveTask?.cancel() + autosaveTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + guard let self, !Task.isCancelled else { return } + await self.save() + } + } + + private func save() async { isSaving = true saveErrorMessage = nil defer { isSaving = false } + let sentTitle = title + let sentText = text do { - let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text)) - title = updated.title - text = updated.text - isEditing = false + let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText)) + // Only reconcile with the server's response if nothing changed + // locally while the request was in flight — otherwise this + // would clobber keystrokes typed during a debounced autosave's + // round trip. Whatever's newer goes out on the next autosave + // cycle regardless, since `scheduleAutosave()` keeps getting + // re-triggered by continued typing. + if title == sentTitle { title = updated.title } + if text == sentText { text = updated.text } + lastSyncedTitle = sentTitle + lastSyncedText = sentText } catch { saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.") } diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index a22bf08..e7a3a1e 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -6,6 +6,13 @@ import OutlineKit @Observable final class SessionStore { private static let serverURLDefaultsKey = "outline.serverURL" + /// Preferences now drive real editor behavior (separate editing, etc.), + /// not just a settings screen — they need to survive a cold launch with + /// no network, not just live in memory from the last successful fetch. + /// Still read-only while offline (Settings already gates every toggle + /// on `isEffectivelyOnline`) — this only makes the *last known* values + /// available, never lets them be changed without a server round-trip. + private static let userPreferencesDefaultsKey = "outline.userPreferences" private let tokenStore: TokenStoring private let defaults: UserDefaults @@ -47,6 +54,7 @@ final class SessionStore { if hasToken, let storedServerURL { isSignedIn = true (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) + userPreferences = Self.loadCachedPreferences(defaults: defaults) } else { // Keychain and the sandboxed UserDefaults container don't // always survive together — a Keychain item written by an @@ -70,6 +78,24 @@ final class SessionStore { isSignedIn = true } + /// `static` (not an instance method) so `init` can call it before every + /// stored property has a value — same reason `makeAPIClient` is static. + private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? { + guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil } + return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data) + } + + /// `nil` clears the cache instead of writing a `null` — happens whenever + /// a fresh fetch legitimately comes back with no preferences set, so a + /// stale cached value from a previous account/state can't linger. + private func cachePreferences(_ preferences: OutlineUserPreferences?) { + guard let preferences, let data = try? JSONEncoder().encode(preferences) else { + defaults.removeObject(forKey: Self.userPreferencesDefaultsKey) + return + } + defaults.set(data, forKey: Self.userPreferencesDefaultsKey) + } + private static func makeAPIClient( serverURL: URL, tokenStore: TokenStoring, @@ -99,6 +125,7 @@ final class SessionStore { teamAvatarURL = nil apiClient = nil cachingClient = nil + defaults.removeObject(forKey: Self.userPreferencesDefaultsKey) } /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this @@ -119,6 +146,7 @@ final class SessionStore { userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userLanguage = user.language userPreferences = user.preferences + cachePreferences(user.preferences) userNotificationSettings = user.notificationSettings } @@ -132,6 +160,7 @@ final class SessionStore { userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userLanguage = user.language userPreferences = user.preferences + cachePreferences(user.preferences) userNotificationSettings = user.notificationSettings teamName = team.name teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }