Files
Outpost/Outpost/Features/Collections/DocumentReaderViewModel.swift
T
Puranjay Savar Mattas 43f9d6052f fix(shares): match documented API shape, fill in list/revoke, harden share sheet
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.
2026-08-14 17:41:28 +01:00

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
}
}
}