Flipping "Published" 403'd with authorization_error, confirmed via a raw curl (bypassing our client entirely, real token) to be a genuine server-side restriction independent of this app — workspace public sharing is enabled, token is full-scope, it's not document-specific. Root cause is most likely Outline gating that action behind an interactive session rather than API-token auth, but that's not definitively confirmed server-side. Removed the toggle per explicit instruction; kept link create/copy/revoke and title override (same endpoint, not reported broken). Replaced it with real per-document user permissions: - OutlineMembership/OutlineDocumentMember models - documents.add_user (confirmed shape from official docs), documents.remove_user/documents.users (speculative, same "best-effort until a live server confirms" treatment OutlinePin originally got), users.list for the invite search (standard, high-confidence) - DocumentShareSheet gets a "People with access" section: search and invite with a Can-view/Can-edit picker, existing members listed with a remove button - Reader toolbar's long-disabled "Permissions…" menu item now opens this same sheet instead of doing nothing Sidebar's own disabled "Permissions…" stub has no sheet wired up to it yet — comment updated to be accurate, not fixed (use the reader's menu instead). documents.users/documents.remove_user are unverified against a live server, same as every other speculative endpoint this session — expect a correction round once tested.
509 lines
18 KiB
Swift
509 lines
18 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import MarkdownEngine
|
|
import OutlineKit
|
|
|
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
|
/// below must run on the main thread — see the identical note on
|
|
/// `DocumentNodeRow` in `CollectionDocumentsOutline.swift`.
|
|
@MainActor
|
|
struct DocumentReaderView: View {
|
|
@Environment(SessionStore.self) private var session
|
|
@Environment(StarStore.self) private var starStore
|
|
|
|
@State private var viewModel: DocumentReaderViewModel
|
|
let apiClient: OutlineAPIClient
|
|
let document: OutlineDocument
|
|
/// Pushes a genuine new stack entry (not a reset+replace) — the back
|
|
/// button then correctly returns to this document, not the collection.
|
|
let onOpenChild: (OutlineDocument) -> Void
|
|
/// Called after Delete/Archive/Unpublish succeed — the document is no
|
|
/// longer visible in the collection it was opened from, so the reader
|
|
/// pops itself off the navigation stack.
|
|
let onDeleted: () -> Void
|
|
/// The reader's own "New Document" toolbar button has no direct handle
|
|
/// on the sidebar row it belongs under — this tells the sidebar a
|
|
/// document exists now so it can pick it up. See
|
|
/// `CollectionDocumentsOutline.externalRefreshToken`.
|
|
let onDocumentCreated: () -> Void
|
|
|
|
@State private var isShowingUnpublishConfirmation = false
|
|
@State private var isShowingArchiveConfirmation = false
|
|
@State private var isShowingDeleteConfirmation = false
|
|
@State private var isShowingMoveSheet = false
|
|
@State private var isShowingHistorySheet = false
|
|
@State private var isShowingInsightsSheet = false
|
|
@State private var isShowingPresentSheet = false
|
|
@State private var isShowingSearchSheet = false
|
|
@State private var isShowingShareSheet = false
|
|
@State private var isShowingNewDocumentSheet = false
|
|
@State private var actionErrorMessage: String?
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
document: OutlineDocument,
|
|
onOpenChild: @escaping (OutlineDocument) -> Void,
|
|
onDeleted: @escaping () -> Void,
|
|
onDocumentCreated: @escaping () -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.document = document
|
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
|
self.onOpenChild = onOpenChild
|
|
self.onDeleted = onDeleted
|
|
self.onDocumentCreated = onDocumentCreated
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if viewModel.isEditing {
|
|
TextField("Title", text: $viewModel.title)
|
|
.font(.largeTitle.weight(.bold))
|
|
.textFieldStyle(.plain)
|
|
}
|
|
|
|
if viewModel.isLoading && viewModel.text.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else if let errorMessage = viewModel.errorMessage {
|
|
ContentUnavailableView {
|
|
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(errorMessage)
|
|
} actions: {
|
|
Button("Retry") {
|
|
Task { await viewModel.loadFullContent() }
|
|
}
|
|
}
|
|
} else {
|
|
NativeTextViewWrapper(
|
|
text: $viewModel.text,
|
|
configuration: .init(heightBehavior: .fitsContent),
|
|
isEditable: viewModel.isEditing
|
|
)
|
|
|
|
if !viewModel.children.isEmpty {
|
|
childrenSection
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.overlay(alignment: .topTrailing) {
|
|
if viewModel.isLoading && !viewModel.text.isEmpty {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.padding(12)
|
|
}
|
|
}
|
|
// No `.navigationTitle` here either — same reason as CollectionOverviewView.
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
viewerAvatars
|
|
Button {
|
|
isShowingShareSheet = true
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
.help("Share")
|
|
|
|
Button {
|
|
Task { await viewModel.toggleEditing() }
|
|
} label: {
|
|
if viewModel.isSaving {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text(viewModel.isEditing ? "Done" : "Edit")
|
|
}
|
|
}
|
|
.disabled(viewModel.isSaving)
|
|
|
|
Button {
|
|
isShowingNewDocumentSheet = true
|
|
} label: {
|
|
Image(systemName: "doc.badge.plus")
|
|
}
|
|
.help("New Document")
|
|
|
|
Menu {
|
|
menuContent
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
|
|
// `Toggle`'s checkmark against updated @Observable state on
|
|
// its own — without a fresh `.id()` per state combination,
|
|
// toggling Subscribed/Viewer Insights/Full Width kept
|
|
// showing the pre-toggle checkmark until the whole view was
|
|
// torn down and rebuilt (e.g. navigating away and back).
|
|
.id(menuIdentity)
|
|
}
|
|
}
|
|
.task { await viewModel.loadFullContent() }
|
|
.task {
|
|
await viewModel.loadPinAndSubscriptionState()
|
|
}
|
|
.task {
|
|
await viewModel.loadInsightsEnabledState()
|
|
}
|
|
.task {
|
|
while !Task.isCancelled {
|
|
await viewModel.loadViewers()
|
|
try? await Task.sleep(for: .seconds(20))
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Unpublish \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingUnpublishConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Unpublish", role: .destructive) {
|
|
Task { await unpublish() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This moves the document back to a draft and out of the collection.")
|
|
}
|
|
.confirmationDialog(
|
|
"Archive \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingArchiveConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Archive", role: .destructive) {
|
|
Task { await archive() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Archived documents are hidden from the collection but can be restored later.")
|
|
}
|
|
.confirmationDialog(
|
|
"Delete \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
Task { await delete() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This moves the document to the trash. If not restored within 30 days it's permanently deleted.")
|
|
}
|
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
|
Button("OK") { actionErrorMessage = nil }
|
|
} message: {
|
|
Text(actionErrorMessage ?? "")
|
|
}
|
|
.sheet(isPresented: $isShowingMoveSheet) {
|
|
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
|
onDeleted()
|
|
}
|
|
}
|
|
.sheet(isPresented: $isShowingHistorySheet) {
|
|
DocumentHistorySheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingInsightsSheet) {
|
|
DocumentInsightsSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingPresentSheet) {
|
|
DocumentPresentSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingSearchSheet) {
|
|
DocumentSearchSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingShareSheet) {
|
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
|
}
|
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
|
|
onDocumentCreated()
|
|
onOpenChild(child)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every toggle-backed piece of state shown as a checkmark inside
|
|
/// `menuContent` — see the `.id()` comment on the `Menu` above.
|
|
private var menuIdentity: String {
|
|
[
|
|
starStore.isStarred(documentId: viewModel.documentId),
|
|
viewModel.isSubscribed,
|
|
viewModel.isPinned,
|
|
viewModel.isInsightsEnabled ?? false,
|
|
viewModel.isFullWidth,
|
|
viewModel.isEditing
|
|
].map(String.init).joined(separator: "-")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var viewerAvatars: some View {
|
|
// Tied to the Viewer Insights toggle — that's the feature this data
|
|
// belongs to, so turning it off should hide the avatars immediately
|
|
// rather than leaving them showing until the view reloads.
|
|
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
|
|
HStack(spacing: -6) {
|
|
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
|
AvatarBadge(
|
|
avatarURL: viewer.user.avatarUrl.flatMap { URL(string: $0, relativeTo: session.serverURL)?.absoluteURL },
|
|
size: 20
|
|
)
|
|
.overlay(Circle().stroke(.background, lineWidth: 1.5))
|
|
.help(Text(viewer.user.name))
|
|
}
|
|
}
|
|
.padding(.trailing, 4)
|
|
}
|
|
}
|
|
|
|
private var childrenSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Divider()
|
|
.padding(.vertical, 4)
|
|
|
|
Text("Sub-documents")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
ForEach(viewModel.children) { child in
|
|
Button {
|
|
onOpenChild(child)
|
|
} label: {
|
|
DocumentRowView(document: child)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.vertical, 4)
|
|
|
|
if child.id != viewModel.children.last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var menuContent: some View {
|
|
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
|
Task { await star() }
|
|
}
|
|
Toggle("Subscribed", isOn: Binding(
|
|
get: { viewModel.isSubscribed },
|
|
set: { _ in Task { await toggleSubscription() } }
|
|
))
|
|
|
|
Divider()
|
|
|
|
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,
|
|
// same isShowingShareSheet state.
|
|
Button("Permissions…") {
|
|
isShowingShareSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Templatize") {
|
|
Task { await templatize() }
|
|
}
|
|
Button("Duplicate") {
|
|
Task { await duplicate() }
|
|
}
|
|
Button("Unpublish") {
|
|
isShowingUnpublishConfirmation = true
|
|
}
|
|
Button("Archive…") {
|
|
isShowingArchiveConfirmation = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Move") {
|
|
isShowingMoveSheet = true
|
|
}
|
|
// Multipart file upload is its own subsystem — deferred rather than
|
|
// half-built here.
|
|
Button("Import Document…") {}
|
|
.disabled(true)
|
|
Button("New Document") {
|
|
isShowingNewDocumentSheet = true
|
|
}
|
|
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
|
Task { await togglePin() }
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("History") {
|
|
isShowingHistorySheet = true
|
|
}
|
|
Button("Insights") {
|
|
isShowingInsightsSheet = true
|
|
}
|
|
Button("Present") {
|
|
isShowingPresentSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Download") {
|
|
Task { await download() }
|
|
}
|
|
Button("Copy") {
|
|
Task { await copyMarkdown() }
|
|
}
|
|
Button("Print") {
|
|
Task { await printDocument() }
|
|
}
|
|
Button("Search in Document") {
|
|
isShowingSearchSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Toggle("Viewer Insights", isOn: Binding(
|
|
get: { viewModel.isInsightsEnabled ?? false },
|
|
set: { _ in Task { await toggleInsights() } }
|
|
))
|
|
// Confirmed against a live server: there's no per-document embeds
|
|
// field. Only a workspace-level setting exists, and that's not
|
|
// reachable via the API either (no `team.update` endpoint in the
|
|
// vendored spec) — disabled rather than kept as a broken action.
|
|
Button("Enable Embeds") {}
|
|
.disabled(true)
|
|
Toggle("Full Width", isOn: Binding(
|
|
get: { viewModel.isFullWidth },
|
|
set: { _ in Task { await toggleFullWidth() } }
|
|
))
|
|
|
|
Divider()
|
|
|
|
Button("Delete…", role: .destructive) {
|
|
isShowingDeleteConfirmation = true
|
|
}
|
|
}
|
|
|
|
private func star() async {
|
|
do {
|
|
try await starStore.toggleDocument(viewModel.documentId, apiClient: apiClient)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
|
|
}
|
|
}
|
|
|
|
private func togglePin() async {
|
|
do {
|
|
try await viewModel.togglePin()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
|
|
}
|
|
}
|
|
|
|
private func toggleSubscription() async {
|
|
do {
|
|
try await viewModel.toggleSubscription()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update subscription state.")
|
|
}
|
|
}
|
|
|
|
private func toggleFullWidth() async {
|
|
do {
|
|
try await viewModel.toggleFullWidth()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't change document width.")
|
|
}
|
|
}
|
|
|
|
private func toggleInsights() async {
|
|
do {
|
|
try await viewModel.toggleViewerInsights()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
|
|
}
|
|
}
|
|
|
|
private func templatize() async {
|
|
do {
|
|
_ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: viewModel.documentId))
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.")
|
|
}
|
|
}
|
|
|
|
private func duplicate() async {
|
|
do {
|
|
_ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: viewModel.documentId))
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.")
|
|
}
|
|
}
|
|
|
|
private func unpublish() async {
|
|
do {
|
|
_ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: viewModel.documentId))
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.")
|
|
}
|
|
}
|
|
|
|
private func archive() async {
|
|
do {
|
|
_ = try await apiClient.archiveDocument(id: viewModel.documentId)
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.")
|
|
}
|
|
}
|
|
|
|
private func delete() async {
|
|
do {
|
|
try await apiClient.deleteDocument(DeleteDocumentRequest(id: viewModel.documentId))
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.")
|
|
}
|
|
}
|
|
|
|
private func download() async {
|
|
do {
|
|
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = "\(viewModel.title.isEmpty ? "Untitled" : viewModel.title).md"
|
|
panel.allowedContentTypes = [.text]
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
try markdown.write(to: url, atomically: true, encoding: .utf8)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't download this document.")
|
|
}
|
|
}
|
|
|
|
private func copyMarkdown() async {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(viewModel.text, forType: .string)
|
|
}
|
|
|
|
/// No access to the reader's rendered `NSTextView` (MarkdownEngine
|
|
/// doesn't expose it), so this prints the raw markdown source as plain
|
|
/// monospaced text via a standalone `NSTextView` built just for the print
|
|
/// job, rather than a styled rendering of the document.
|
|
private func printDocument() async {
|
|
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792))
|
|
textView.string = viewModel.text
|
|
textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
|
|
|
|
let operation = NSPrintOperation(view: textView)
|
|
operation.printInfo.horizontalPagination = .fit
|
|
operation.printInfo.verticalPagination = .automatic
|
|
operation.run()
|
|
}
|
|
}
|
|
#endif
|