Share sheet errored "Got an unexpected response from the server" (a client-side decode failure) on every open. OutlineShare only declared 5 fields against the real response's ~20, with url non-optional — something in a real payload came back null and blew up the strict decode, same class of bug as OutlinePin's history: self-hosted responses keep diverging from what the hosted-app docs imply is non-nullable. - Rewrote OutlineShare to match the full documented shares.* response shape. Only id/published are trusted non-optional; everything else (documentTitle, sourceTitle, urlId, domain, title, iconUrl, includeChildDocuments, allowSubscriptions, allowIndexing, showLastUpdated, showTOC, views, createdBy, createdAt, updatedAt, lastAccessedAt) is optional so an unexpectedly-null field can't crash the decode again. - shares.list and shares.revoke were never wrapped at all — added both. - UpdateShareRequest was missing the documented title/iconUrl overrides. - DocumentShareSheet: handles share.url being optional, adds a public-page title override field, adds a Revoke Link button (confirmation dialog, resets back to "Create Share Link" after). - Removed dead DocumentReaderViewModel.share/loadShare()/ createOrLoadShare() — never called by anything; DocumentShareSheet manages its own share state independently. - Added a decode test using the exact payload from Outline's official shares.info docs, plus tests for shares.list and shares.revoke. Branched fresh off main (post home-page merge) rather than continuing on feature/home-page, to keep this its own PR.
190 lines
6.2 KiB
Swift
190 lines
6.2 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 isFullWidth = false
|
|
var children: [OutlineDocument] = []
|
|
var isLoading = false
|
|
var errorMessage: String?
|
|
|
|
var isEditing = false
|
|
var isSaving = false
|
|
var saveErrorMessage: 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) {
|
|
self.apiClient = apiClient
|
|
self.documentId = document.id
|
|
self.title = document.title
|
|
self.emoji = document.emoji
|
|
self.text = document.text
|
|
self.collectionId = document.collectionId
|
|
self.isFullWidth = document.fullWidth ?? false
|
|
}
|
|
|
|
/// 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
|
|
isFullWidth = full.fullWidth ?? false
|
|
} catch {
|
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
|
}
|
|
|
|
children = (try? await apiClient.listDocuments(
|
|
collectionId: nil,
|
|
parentDocumentId: documentId,
|
|
offset: 0,
|
|
limit: 100
|
|
)) ?? []
|
|
}
|
|
|
|
func loadViewers() async {
|
|
guard let views = 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 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 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.
|
|
func toggleEditing() async {
|
|
guard isEditing else {
|
|
isEditing = true
|
|
return
|
|
}
|
|
isSaving = true
|
|
saveErrorMessage = nil
|
|
defer { isSaving = false }
|
|
do {
|
|
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
|
title = updated.title
|
|
text = updated.text
|
|
isEditing = false
|
|
} 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
|
|
}
|
|
}
|
|
}
|