Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.
Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.
New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.
Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OutlineKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class DocumentReaderViewModel {
|
|
var title: String
|
|
var emoji: String?
|
|
var text: String
|
|
var collectionId: String?
|
|
var parentDocumentId: String?
|
|
/// `nil` = draft (not published/visible to other workspace members).
|
|
var publishedAt: Date?
|
|
var isFullWidth = false
|
|
var isLoading = false
|
|
var errorMessage: String?
|
|
|
|
var isEditing = false
|
|
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<Void, Never>?
|
|
/// 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
|
|
/// socket's awareness protocol, which isn't wired up yet).
|
|
private(set) var viewers: [OutlineView] = []
|
|
|
|
private(set) var isPinned = false
|
|
private var pinId: String?
|
|
private(set) var isSubscribed = false
|
|
private var subscriptionId: String?
|
|
|
|
/// `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
|
|
|
|
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
|
self.apiClient = apiClient
|
|
self.documentId = document.id
|
|
self.title = document.title
|
|
self.emoji = document.emoji
|
|
self.text = document.text
|
|
self.collectionId = document.collectionId
|
|
self.parentDocumentId = document.parentDocumentId
|
|
self.publishedAt = document.publishedAt
|
|
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,
|
|
/// current body — always re-fetch via `documents.info` when actually opened.
|
|
func loadFullContent() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let full = try await apiClient.documentInfo(id: documentId)
|
|
title = full.title
|
|
emoji = full.emoji
|
|
text = full.text
|
|
collectionId = full.collectionId
|
|
parentDocumentId = full.parentDocumentId
|
|
publishedAt = full.publishedAt
|
|
isFullWidth = full.fullWidth ?? false
|
|
lastSyncedText = full.text
|
|
lastSyncedTitle = full.title
|
|
} catch {
|
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
|
}
|
|
}
|
|
|
|
func loadViewers() async {
|
|
// A single blip here used to just leave `viewers` empty forever with
|
|
// no sign anything went wrong — retry-with-backoff absorbs that;
|
|
// `try?` still covers the "still failing after retries" case, same
|
|
// silent-but-harmless fallback as before (an empty viewers list).
|
|
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
|
|
viewers = views.filter { $0.lastViewedAt != nil }
|
|
}
|
|
|
|
func loadPinAndSubscriptionState() async {
|
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
|
if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
|
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
|
isPinned = true
|
|
pinId = match.id
|
|
} else {
|
|
isPinned = false
|
|
pinId = nil
|
|
}
|
|
|
|
if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
|
|
let match = subscriptions.first {
|
|
isSubscribed = true
|
|
subscriptionId = match.id
|
|
} else {
|
|
isSubscribed = false
|
|
subscriptionId = nil
|
|
}
|
|
}
|
|
|
|
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
|
|
isPinned = false
|
|
do {
|
|
try await apiClient.deletePin(id: pinId)
|
|
} catch {
|
|
self.pinId = pinId
|
|
isPinned = true
|
|
throw error
|
|
}
|
|
} else {
|
|
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
|
|
pinId = pin.id
|
|
isPinned = true
|
|
}
|
|
}
|
|
|
|
func toggleSubscription() async throws {
|
|
if let subscriptionId {
|
|
self.subscriptionId = nil
|
|
isSubscribed = false
|
|
do {
|
|
try await apiClient.deleteSubscription(id: subscriptionId)
|
|
} catch {
|
|
self.subscriptionId = subscriptionId
|
|
isSubscribed = true
|
|
throw error
|
|
}
|
|
} else {
|
|
let subscription = try await apiClient.createSubscription(CreateSubscriptionRequest(documentId: documentId))
|
|
subscriptionId = subscription.id
|
|
isSubscribed = true
|
|
}
|
|
}
|
|
|
|
/// 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: 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.")
|
|
}
|
|
}
|
|
|
|
func toggleFullWidth() async throws {
|
|
let newValue = !isFullWidth
|
|
isFullWidth = newValue
|
|
do {
|
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, fullWidth: newValue))
|
|
} catch {
|
|
isFullWidth = !newValue
|
|
throw error
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|