From b31d49bb7fd32ebcb75cfe6961167c651ad8f1ce Mon Sep 17 00:00:00 2001 From: psmattas Date: Thu, 20 Aug 2026 21:31:36 +0100 Subject: [PATCH] feat: drafts, publish flow, and full comment threading Drafts + Publish: - New Home tab backed by documents.drafts (undocumented request shape confirmed from a live network capture, not the OpenAPI spec). - Reader toolbar menu now shows Publish... for an unpublished document instead of an unconditional Unpublish (which could previously be tapped on a draft at all). Publish opens a MoveDocumentSheet-style collection/parent picker, pre-filled from the draft's own collectionId/parentDocumentId when it already has one. Publishing itself is documents.update(publish: true, collectionId:) for the collection placement, plus a second documents.move call only when a specific parent document was also picked (documents.update has no parentDocumentId field). - New Document defaults to Draft (collectionId/publish both now optional on CreateDocumentRequest, previously collectionId was required so a draft couldn't be created from this sheet at all). Contextual entry points (right-click a collection/document) still pre-fill that location, but now show a warning that doing so auto-publishes. - Fixed onDeleted only popping the reader's nav path without telling the sidebar to refresh - Delete/Archive/Unpublish/Move all left the sidebar showing stale state until an unrelated trigger (the 45s poll, navigating away and back) happened to catch it up. Comments: - Replies (comments.create with parentCommentId, one level of nesting same as Outline's own limit) and emoji reactions (comments.add_reaction/remove_reaction, confirmed against Outline's server source - not in the spec, and return {success: true} rather than the updated comment, so a toggle refetches via comments.info for the real post-toggle state) plus a document-level "new comment" composer, since replying needs something to reply to. - Inline anchor markers: a new engine-side mechanism (CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange) resolves an anchored comment's anchorText to an on-screen rect via the same viewRect utility the code-block copy button uses, kept in sync on typing/resize/reflow the same way the code-block and image positioning fixes earlier this session are. Renders as a thin blue bar next to the commented text; tapping it opens the comments sheet scrolled and highlighted to that thread. First-occurrence text search only (Outline's API returns no position data, and no prefix/suffix on read) - creating new anchored comments from this app still isn't supported. - Toolbar badge: tighter offset so the count doesn't clip past the icon, caps at "10+". --- .../Caching/CachingOutlineAPIClient.swift | 22 ++ .../OutlineKit/Core/OutlineAPIClient.swift | 15 +- .../OutlineKit/LiveOutlineAPIClient.swift | 25 ++ .../OutlineKit/Models/OutlineComment.swift | 30 +- .../Requests/CreateCommentRequest.swift | 21 ++ .../Requests/CreateDocumentRequest.swift | 6 +- .../Requests/ListCommentsRequest.swift | 7 +- .../Requests/ListDraftsRequest.swift | 11 + .../Requests/UpdateDocumentRequest.swift | 13 +- .../CachingOutlineAPIClientTests.swift | 5 + .../LiveOutlineAPIClientTests.swift | 56 ++++ .../Collections/ContentView_macOS.swift | 7 + .../Collections/DocumentCommentsSheet.swift | 314 ++++++++++++++++-- .../Collections/DocumentReaderView.swift | 128 +++++-- .../Collections/DocumentReaderViewModel.swift | 7 + .../Collections/NewDocumentSheet.swift | 28 +- .../Collections/PublishDocumentSheet.swift | 144 ++++++++ Outpost/Features/Home/HomeTab.swift | 1 + Outpost/Features/Home/HomeViewModel.swift | 5 + .../TextView/CommentAnchor.swift | 44 +++ ...veTextViewCoordinator+CommentAnchors.swift | 33 ++ ...tiveTextViewCoordinator+TextDelegate.swift | 2 + .../NativeTextViewCoordinator.swift | 2 + .../NativeTextView+FrameAndOverscroll.swift | 6 + .../TextView/NativeTextViewWrapper.swift | 33 ++ 25 files changed, 893 insertions(+), 72 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListDraftsRequest.swift create mode 100644 Outpost/Features/Collections/PublishDocumentSheet.swift create mode 100644 Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CommentAnchor.swift create mode 100644 Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index f4e46ac..3c9bf20 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -92,6 +92,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } } + public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { + try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") { + try await self.live.listDrafts(request) + } + } + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { try await cachedFetch(key: "collections:\(offset):\(limit)") { try await self.live.listCollections(offset: offset, limit: limit) @@ -307,6 +313,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.listComments(request) } + public func commentInfo(id: String) async throws -> OutlineComment { + try await live.commentInfo(id: id) + } + + public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { + try await live.createComment(request) + } + public func resolveComment(id: String) async throws -> OutlineComment { try await live.resolveComment(id: id) } @@ -315,6 +329,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.unresolveComment(id: id) } + public func addReaction(commentId: String, emoji: String) async throws { + try await live.addReaction(commentId: commentId, emoji: emoji) + } + + public func removeReaction(commentId: String, emoji: String) async throws { + try await live.removeReaction(commentId: commentId, emoji: emoji) + } + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { try await live.addDocumentUser(request) } diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index b8ea864..c5e8760 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable { func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] /// Documents the current user has recently viewed. Backed by `documents.viewed`. func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] + /// Draft (unpublished) documents belonging to the current user. Backed + /// by `documents.drafts`. + func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] /// Full-text search with snippets/ranking. Backed by `documents.search`. func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] /// Title-only search — faster, no snippets. Backed by `documents.search_titles`. @@ -48,11 +51,19 @@ public protocol OutlineAPIClient: Sendable { /// Historical view records, not live presence. Backed by `views.list`. func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] - /// See `OutlineComment`. `resolve`/`unresolve` are confirmed real - /// against Outline's own server source but aren't in the vendored spec. + /// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/ + /// `remove_reaction` are confirmed real against Outline's own server + /// source but aren't in the vendored spec. func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] + func commentInfo(id: String) async throws -> OutlineComment + func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment func resolveComment(id: String) async throws -> OutlineComment func unresolveComment(id: String) async throws -> OutlineComment + /// Reaction endpoints return `{success: true}`, not the updated + /// comment — callers refetch via `commentInfo` for the fresh + /// `reactions` array. + func addReaction(commentId: String, emoji: String) async throws + func removeReaction(commentId: String, emoji: String) async throws /// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed /// from Outline's official docs, the rest are best-effort. diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 9eb1f79..d91b838 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit)) } + public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { + try await post("documents.drafts", body: request) + } + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { try await post("documents.search", body: request) } @@ -179,6 +183,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("comments.list", body: request) } + public func commentInfo(id: String) async throws -> OutlineComment { + try await post("comments.info", body: StarIDParams(id: id)) + } + + public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { + try await post("comments.create", body: request) + } + public func resolveComment(id: String) async throws -> OutlineComment { try await post("comments.resolve", body: StarIDParams(id: id)) } @@ -187,6 +199,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("comments.unresolve", body: StarIDParams(id: id)) } + public func addReaction(commentId: String, emoji: String) async throws { + try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji)) + } + + public func removeReaction(commentId: String, emoji: String) async throws { + try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji)) + } + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { try await post("documents.add_user", body: request) } @@ -536,6 +556,11 @@ private struct StarIDParams: Encodable { let id: String } +private struct CommentReactionParams: Encodable { + let id: String + let emoji: String +} + private struct MoveDocumentResponse: Decodable { let documents: [OutlineDocument]? let collections: [OutlineCollection]? diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift index 01d8da3..1b28093 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift @@ -2,10 +2,11 @@ import Foundation /// A comment (or reply, via `parentCommentId`) on a document. Backed by /// `comments.*` — `create`/`info`/`update`/`delete`/`list` are in the -/// vendored OpenAPI spec; `resolve`/`unresolve` are not (confirmed against -/// Outline's own server source instead — same "spec mirror is incomplete" -/// lesson as `OutlinePin`). `data` is the comment body as a ProseMirror -/// document, not plain text — see `JSONValue.plainText()`. +/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/ +/// `remove_reaction` are not (confirmed against Outline's own server +/// source instead — same "spec mirror is incomplete" lesson as +/// `OutlinePin`). `data` is the comment body as a ProseMirror document, +/// not plain text — see `JSONValue.plainText()`. public struct OutlineComment: Decodable, Identifiable, Sendable { public let id: String public let data: JSONValue @@ -16,9 +17,30 @@ public struct OutlineComment: Decodable, Identifiable, Sendable { public let updatedAt: Date? public let resolvedAt: Date? public let resolvedBy: OutlineUser? + public let reactions: [ReactionSummary] + /// The document text this comment is anchored to — only populated when + /// the request set `includeAnchorText: true`; `nil` for a document-level + /// (non-anchored) comment either way. No prefix/suffix comes back on + /// read (only accepted as create-time disambiguation input), so + /// re-finding this text in the rendered document is inherently + /// best-effort — first occurrence wins, same as Outline's own create + /// behavior when nothing else disambiguates. + public let anchorText: String? public var isResolved: Bool { resolvedAt != nil } public var bodyText: String { data.plainText().trimmingCharacters(in: .whitespacesAndNewlines) } } + +/// One emoji's worth of reactions on a comment — grouped by emoji server-side +/// (`ReactionSummary` in Outline's own source), not one object per reaction. +public struct ReactionSummary: Codable, Sendable, Equatable { + public let emoji: String + public let userIds: [String] + + public init(emoji: String, userIds: [String]) { + self.emoji = emoji + self.userIds = userIds + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift new file mode 100644 index 0000000..cd8c2f5 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateCommentRequest.swift @@ -0,0 +1,21 @@ +import Foundation + +/// `parentCommentId: nil` creates a document-level (top-level) comment; +/// non-nil creates a reply (Outline supports one level of nesting — a +/// reply's own `parentCommentId` should always be a top-level comment's +/// id, never another reply's). Anchored (text-selection) comments aren't +/// supported here — that needs `anchorText`, out of scope for this app. +/// `text` is the documented markdown convenience field for `data` +/// (a ProseMirror document) — simplest path for plain-text comment bodies, +/// no need to construct ProseMirror JSON by hand. +public struct CreateCommentRequest: Encodable, Sendable { + public let documentId: String + public let parentCommentId: String? + public let text: String + + public init(documentId: String, parentCommentId: String? = nil, text: String) { + self.documentId = documentId + self.parentCommentId = parentCommentId + self.text = text + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift index 7aafdfc..3cbd9f6 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -3,14 +3,16 @@ import Foundation public struct CreateDocumentRequest: Codable, Sendable { public let title: String public let text: String - public let collectionId: String + /// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline + /// requires one of the two to publish at all, regardless of `publish`. + public let collectionId: String? public let parentDocumentId: String? public let publish: Bool public init( title: String, text: String, - collectionId: String, + collectionId: String? = nil, parentDocumentId: String? = nil, publish: Bool = true ) { diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift index 24e152d..f90130e 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift @@ -4,10 +4,15 @@ public struct ListCommentsRequest: Encodable, Sendable { public let documentId: String public let offset: Int public let limit: Int + /// Include each anchored comment's `anchorText` (the document text it's + /// attached to) in the response. Off by default — it's extra payload + /// only needed when actually positioning inline markers. + public let includeAnchorText: Bool - public init(documentId: String, offset: Int = 0, limit: Int = 100) { + public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) { self.documentId = documentId self.offset = offset self.limit = limit + self.includeAnchorText = includeAnchorText } } diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListDraftsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListDraftsRequest.swift new file mode 100644 index 0000000..3bd0481 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListDraftsRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct ListDraftsRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + + public init(offset: Int = 0, limit: Int = 25) { + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index e1bf459..4193919 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable { public let append: Bool? public let fullWidth: Bool? public let insightsEnabled: Bool? + /// Moves the document to this collection. Combined with `publish: true`, + /// this is how a draft (no `collectionId`, or one it just hasn't left + /// yet) gets published into a specific collection in one call. + public let collectionId: String? + /// Publishes a draft, making it visible to other workspace members. + /// Documented as a no-op if the document is already published. + public let publish: Bool? public init( id: String, @@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable { text: String? = nil, append: Bool? = nil, fullWidth: Bool? = nil, - insightsEnabled: Bool? = nil + insightsEnabled: Bool? = nil, + collectionId: String? = nil, + publish: Bool? = nil ) { self.id = id self.title = title @@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable { self.append = append self.fullWidth = fullWidth self.insightsEnabled = insightsEnabled + self.collectionId = collectionId + self.publish = publish } } diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 83fd0a6..f0f3f67 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -29,6 +29,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } + func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { @@ -72,8 +73,12 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func deleteSubscription(id: String) async throws { throw NotStubbed() } func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() } func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() } + func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() } + func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() } func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() } func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() } + func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() } + func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() } func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() } func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() } func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index ea76950..9e57d1e 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -860,6 +860,9 @@ final class LiveOutlineAPIClientTests: XCTestCase { "updatedAt": "2026-01-01T00:00:00.000Z", "resolvedAt": null, "resolvedBy": null, + "reactions": [ + { "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] } + ], "data": { "type": "doc", "content": [ @@ -887,6 +890,8 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(comments.first?.id, "comment-1") XCTAssertEqual(comments.first?.bodyText, "Sounds great") XCTAssertFalse(comments.first?.isResolved ?? true) + XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}") + XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"]) XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list") } @@ -903,6 +908,7 @@ final class LiveOutlineAPIClientTests: XCTestCase { "updatedAt": "2026-01-01T00:00:00.000Z", "resolvedAt": "2026-01-02T00:00:00.000Z", "resolvedBy": { "id": "user-2", "name": "John Roe" }, + "reactions": [], "data": { "type": "doc", "content": [] } } } @@ -921,6 +927,56 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve") } + func testCreateCommentSendsParentCommentIdForAReply() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "comment-2", + "documentId": "doc-1", + "parentCommentId": "comment-1", + "createdAt": "2026-01-01T00:00:00.000Z", + "createdBy": { "id": "user-1", "name": "Jane Doe" }, + "updatedAt": "2026-01-01T00:00:00.000Z", + "resolvedAt": null, + "resolvedBy": null, + "reactions": [], + "data": { "type": "doc", "content": [] } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let reply = try await client.createComment( + CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed") + ) + + XCTAssertEqual(reply.parentCommentId, "comment-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create") + } + + func testAddReactionPostsIdAndEmoji() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction") + } + func testCreatePinDecodesPin() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 1a9ce25..3a5a6b4 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -445,6 +445,13 @@ struct ContentView_macOS: View { if !documentPath.isEmpty { documentPath.removeLast() } + // Delete/Archive/Unpublish/Move all change what + // should show in the sidebar tree — this only + // popped the reader before, leaving the sidebar + // showing the document until some unrelated + // trigger (the 45s poll, navigating away and + // back) happened to refresh it. + documentsChangedToken += 1 }, onDocumentCreated: { documentsChangedToken += 1 } ) diff --git a/Outpost/Features/Collections/DocumentCommentsSheet.swift b/Outpost/Features/Collections/DocumentCommentsSheet.swift index 403eea2..f6090f2 100644 --- a/Outpost/Features/Collections/DocumentCommentsSheet.swift +++ b/Outpost/Features/Collections/DocumentCommentsSheet.swift @@ -2,23 +2,61 @@ import SwiftUI import OutlineKit -/// Read-only comment list + resolve/unresolve — see `OutlineComment` for why -/// creating/replying isn't here yet (needs a selection→anchorText flow on -/// top of this, scoped out for now). Flat, not threaded: replies -/// (`parentCommentId != nil`) are just marked with a small "Reply" label -/// rather than nested/grouped, to keep this a straightforward list. +/// Document-level and anchored comments + single-level replies + emoji +/// reactions + resolve/unresolve. Composing new comments/replies is still +/// plain text only (no bold/italic/lists/etc.) — sent through the `text` +/// (markdown) convenience field, not a hand-built ProseMirror `data` +/// document. Creating a NEW anchored comment (from a text selection) isn't +/// supported here — only viewing/replying to ones already anchored via the +/// web app, whose position `DocumentReaderView` resolves to an inline +/// marker. @MainActor struct DocumentCommentsSheet: View { @Environment(\.dismiss) private var dismiss let apiClient: OutlineAPIClient let document: OutlineDocument + /// Set when opened by tapping an inline anchor marker — scrolls to and + /// briefly highlights that comment/reply. `nil` opens unfocused (the + /// plain toolbar marker). + var focusedCommentId: String? = nil + + /// A small curated set rather than the full system emoji picker — quick + /// taps for the common reactions, matching how most chat apps default. + private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"] @State private var comments: [OutlineComment] = [] @State private var isLoading = false @State private var errorMessage: String? - @State private var resolvingCommentIds: Set = [] @State private var actionErrorMessage: String? + @State private var currentUserId: String? + + @State private var newCommentText = "" + @State private var isPostingNewComment = false + + @State private var replyingToThreadId: String? + @State private var replyText = "" + @State private var isPostingReply = false + + @State private var resolvingCommentIds: Set = [] + @State private var reactingCommentIds: Set = [] + @State private var reactionPickerCommentId: String? + + private struct CommentThread: Identifiable { + let top: OutlineComment + let replies: [OutlineComment] + var id: String { top.id } + } + + private var threads: [CommentThread] { + let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt } + return topLevel.map { top in + let replies = comments + .filter { $0.parentCommentId == top.id } + .sorted { $0.createdAt < $1.createdAt } + return CommentThread(top: top, replies: replies) + } + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -43,22 +81,37 @@ struct DocumentCommentsSheet: View { } actions: { Button("Retry") { Task { await load() } } } - } else if comments.isEmpty { + } else if threads.isEmpty { ContentUnavailableView { Label("No Comments", systemImage: "bubble.left.and.bubble.right") } description: { Text("This document has no comments yet.") } } else { - List(comments.sorted { $0.createdAt < $1.createdAt }) { comment in - commentRow(comment) + ScrollViewReader { proxy in + List(threads) { thread in + threadSection(thread) + } + .task { + guard let focusedCommentId else { return } + // The list needs a beat to lay out before a + // scrollTo lands correctly on first appear. + try? await Task.sleep(for: .milliseconds(50)) + withAnimation { + proxy.scrollTo(focusedCommentId, anchor: .center) + } + } } } } .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + newCommentComposer } - .frame(width: 460, height: 480) + .frame(width: 480, height: 560) .task { await load() } + .task { currentUserId = try? await apiClient.currentUser().id } .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { Button("OK") { actionErrorMessage = nil } } message: { @@ -66,18 +119,39 @@ struct DocumentCommentsSheet: View { } } - private func commentRow(_ comment: OutlineComment) -> some View { + // MARK: - Thread + + private func threadSection(_ thread: CommentThread) -> some View { + VStack(alignment: .leading, spacing: 8) { + commentRow(thread.top, isReply: false) + + ForEach(thread.replies) { reply in + commentRow(reply, isReply: true) + .padding(.leading, 20) + } + + if replyingToThreadId == thread.id { + replyComposer(for: thread) + .padding(.leading, 20) + } else { + Button("Reply") { + replyingToThreadId = thread.id + replyText = "" + } + .buttonStyle(.plain) + .font(.caption.weight(.semibold)) + .foregroundStyle(.blue) + .padding(.leading, 20) + } + } + .padding(.vertical, 6) + } + + private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text(comment.createdBy?.name ?? "Unknown") .font(.callout.weight(.semibold)) - if comment.parentCommentId != nil { - Text("Reply") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.quaternary, in: Capsule()) - } Spacer() Text(comment.createdAt, format: .relative(presentation: .named)) .font(.caption) @@ -88,28 +162,139 @@ struct DocumentCommentsSheet: View { .font(.callout) .foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary) - HStack { - if comment.isResolved { - Label("Resolved", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) + if !comment.reactions.isEmpty { + reactionChips(comment) + } + + HStack(spacing: 12) { + Button { + reactionPickerCommentId = comment.id + } label: { + Image(systemName: "face.smiling") } - Spacer() - if resolvingCommentIds.contains(comment.id) { - ProgressView().controlSize(.small) - } else { - Button(comment.isResolved ? "Unresolve" : "Resolve") { - Task { await toggleResolved(comment) } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .popover(isPresented: Binding( + get: { reactionPickerCommentId == comment.id }, + set: { if !$0 { reactionPickerCommentId = nil } } + )) { + quickReactionPicker(comment) + } + + if !isReply { + if comment.isResolved { + Label("Resolved", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) } - .buttonStyle(.plain) - .font(.caption.weight(.semibold)) - .foregroundStyle(.blue) + Spacer() + if resolvingCommentIds.contains(comment.id) { + ProgressView().controlSize(.small) + } else { + Button(comment.isResolved ? "Unresolve" : "Resolve") { + Task { await toggleResolved(comment) } + } + .buttonStyle(.plain) + .font(.caption.weight(.semibold)) + .foregroundStyle(.blue) + } + } else { + Spacer() } } } - .padding(.vertical, 4) + .padding(6) + .background( + comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + .id(comment.id) } + private func reactionChips(_ comment: OutlineComment) -> some View { + HStack(spacing: 4) { + ForEach(comment.reactions, id: \.emoji) { reaction in + let mine = currentUserId.map(reaction.userIds.contains) ?? false + Button { + Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) } + } label: { + Text("\(reaction.emoji) \(reaction.userIds.count)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + .disabled(reactingCommentIds.contains(comment.id)) + } + } + } + + private func quickReactionPicker(_ comment: OutlineComment) -> some View { + HStack(spacing: 8) { + ForEach(Self.quickReactions, id: \.self) { emoji in + let mine = currentUserId.map { userId in + comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false + } ?? false + Button { + reactionPickerCommentId = nil + Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) } + } label: { + Text(emoji) + .font(.title2) + .opacity(mine ? 1 : 0.5) + } + .buttonStyle(.plain) + } + } + .padding(10) + } + + // MARK: - Composers + + private var newCommentComposer: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Add a comment…", text: $newCommentText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + .onSubmit { Task { await postNewComment() } } + if isPostingNewComment { + ProgressView().controlSize(.small) + } else { + Button("Post") { + Task { await postNewComment() } + } + .disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding() + } + + private func replyComposer(for thread: CommentThread) -> some View { + HStack(alignment: .bottom, spacing: 8) { + TextField("Reply…", text: $replyText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + .onSubmit { Task { await postReply(to: thread) } } + if isPostingReply { + ProgressView().controlSize(.small) + } else { + Button("Send") { + Task { await postReply(to: thread) } + } + .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { + replyingToThreadId = nil + replyText = "" + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions + private func load() async { isLoading = true defer { isLoading = false } @@ -120,6 +305,39 @@ struct DocumentCommentsSheet: View { } } + private func postNewComment() async { + let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + isPostingNewComment = true + defer { isPostingNewComment = false } + do { + let created = try await apiClient.createComment( + CreateCommentRequest(documentId: document.id, text: text) + ) + comments.append(created) + newCommentText = "" + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.") + } + } + + private func postReply(to thread: CommentThread) async { + let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + isPostingReply = true + defer { isPostingReply = false } + do { + let created = try await apiClient.createComment( + CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text) + ) + comments.append(created) + replyText = "" + replyingToThreadId = nil + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.") + } + } + private func toggleResolved(_ comment: OutlineComment) async { resolvingCommentIds.insert(comment.id) defer { resolvingCommentIds.remove(comment.id) } @@ -127,12 +345,36 @@ struct DocumentCommentsSheet: View { let updated = comment.isResolved ? try await apiClient.unresolveComment(id: comment.id) : try await apiClient.resolveComment(id: comment.id) - if let index = comments.firstIndex(where: { $0.id == updated.id }) { - comments[index] = updated - } + replace(updated) } catch { actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.") } } + + /// Reaction endpoints return `{success: true}`, not the updated comment + /// (see `OutlineAPIClient.addReaction`) — refetch via `commentInfo` for + /// the real post-toggle `reactions` array instead of guessing the merge + /// locally (another user reacting concurrently would make a guess wrong). + private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async { + reactingCommentIds.insert(comment.id) + defer { reactingCommentIds.remove(comment.id) } + do { + if currentlyReacted { + try await apiClient.removeReaction(commentId: comment.id, emoji: emoji) + } else { + try await apiClient.addReaction(commentId: comment.id, emoji: emoji) + } + let refreshed = try await apiClient.commentInfo(id: comment.id) + replace(refreshed) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.") + } + } + + private func replace(_ updated: OutlineComment) { + if let index = comments.firstIndex(where: { $0.id == updated.id }) { + comments[index] = updated + } + } } #endif diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 28b54b3..61c147b 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -83,17 +83,34 @@ struct DocumentReaderView: View { let onDocumentCreated: () -> Void @State private var isShowingUnpublishConfirmation = false + @State private var isShowingPublishSheet = 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 isShowingCommentsSheet = false - /// Fetched once on load, purely to decide whether to show the comment - /// marker + its count — the sheet itself fetches its own copy + /// Fetched once on load, purely to drive the toolbar marker/badge and + /// the inline anchor bars below — the sheet itself fetches its own copy /// independently (see `DocumentCommentsSheet`), same as every other /// self-contained sheet in this file. - @State private var commentCount: Int? + @State private var loadedComments: [OutlineComment] = [] + /// Comment id to scroll/focus to when the sheet opens — set when an + /// inline anchor bar is tapped, `nil` for the plain toolbar marker. + @State private var focusedCommentId: String? + /// Resolved on-screen positions for each anchored comment's text, from + /// `NativeTextViewWrapper.onCommentAnchorRectsChange`. + @State private var commentAnchorRects: [CommentAnchorRect] = [] + + private var commentCount: Int? { + loadedComments.isEmpty ? nil : loadedComments.count + } + private var commentAnchorQueries: [CommentAnchorQuery] { + loadedComments.compactMap { comment in + guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil } + return CommentAnchorQuery(id: comment.id, anchorText: anchorText) + } + } @State private var isShowingPresentSheet = false @State private var isShowingSearchSheet = false @State private var isShowingShareSheet = false @@ -201,12 +218,15 @@ struct DocumentReaderView: View { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } - // Marker, not a permanent chrome element — only shows once - // there's actually something to point at, per explicit - // instruction ("a simple app-side symbol", not a true - // in-document gutter marker at each comment's anchor). + // Toolbar marker — only shows once there's actually + // something to point at. Anchored comments additionally get + // an inline vertical bar next to their text (see + // `commentAnchorMarkers`/`onCommentAnchorRectsChange` + // below) — this button opens the sheet unfocused, showing + // every comment/thread. if let commentCount, commentCount > 0 { Button { + focusedCommentId = nil isShowingCommentsSheet = true } label: { Image(systemName: "bubble.left.and.bubble.right") @@ -214,15 +234,16 @@ struct DocumentReaderView: View { .help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")") .disabled(!isEffectivelyOnline) .overlay(alignment: .topTrailing) { - Text("\(commentCount)") - .font(.system(size: 9, weight: .bold)) + Text(commentCount > 10 ? "10+" : "\(commentCount)") + .font(.system(size: 8, weight: .bold)) .foregroundStyle(.white) - .padding(3) + .padding(2) + .frame(minWidth: 12, minHeight: 12) .background(.blue, in: Circle()) - .offset(x: 6, y: -6) + .offset(x: 0, y: -1) } .sheet(isPresented: $isShowingCommentsSheet) { - DocumentCommentsSheet(apiClient: apiClient, document: document) + DocumentCommentsSheet(apiClient: apiClient, document: document, focusedCommentId: focusedCommentId) } } @@ -302,9 +323,9 @@ struct DocumentReaderView: View { await viewModel.loadInsightsEnabledState() } .task { - commentCount = try? await apiClient.listComments( - ListCommentsRequest(documentId: viewModel.documentId) - ).count + loadedComments = (try? await apiClient.listComments( + ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) + )) ?? [] } .task { while !Task.isCancelled { @@ -353,6 +374,21 @@ struct DocumentReaderView: View { } message: { Text(actionErrorMessage ?? "") } + .sheet(isPresented: $isShowingPublishSheet) { + PublishDocumentSheet(apiClient: apiClient, document: document) { + // Stays in the reader — unlike Move/Unpublish, publishing + // doesn't make the document any less visible from here, so + // there's no reason to pop back like onDeleted() does + // elsewhere. Refresh from the server rather than guessing + // the new collectionId locally (moveDocument may have run + // as a second step inside the sheet). + await viewModel.loadFullContent() + // The doc previously had no collectionId (or a different + // one) — the sidebar tree for its new collection needs to + // know it exists now, same signal "New Document" sends. + onDocumentCreated() + } + } .sheet(isPresented: $isShowingMoveSheet) { MoveDocumentSheet(apiClient: apiClient, document: document) { onDeleted() @@ -488,13 +524,21 @@ struct DocumentReaderView: View { documentId: viewModel.documentId, isEditable: viewModel.isEffectivelyEditable, onCodeBlockSelectionChange: { readerCodeBlocks = $0 }, - onSelectedTextChange: { currentSelectedText = $0 } + onSelectedTextChange: { currentSelectedText = $0 }, + commentAnchorQueries: commentAnchorQueries, + onCommentAnchorRectsChange: { commentAnchorRects = $0 } ) if showCodeBlockLineNumbers { ForEach(readerCodeBlocks) { selection in CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) } } + ForEach(commentAnchorRects) { anchor in + CommentAnchorMarker(rect: anchor.rect) { + focusedCommentId = anchor.id + isShowingCommentsSheet = true + } + } } } } @@ -562,13 +606,21 @@ struct DocumentReaderView: View { documentId: viewModel.documentId, isEditable: false, onCodeBlockSelectionChange: { previewCodeBlocks = $0 }, - onSelectedTextChange: { currentSelectedText = $0 } + onSelectedTextChange: { currentSelectedText = $0 }, + commentAnchorQueries: commentAnchorQueries, + onCommentAnchorRectsChange: { commentAnchorRects = $0 } ) if showCodeBlockLineNumbers { ForEach(previewCodeBlocks) { selection in CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) } } + ForEach(commentAnchorRects) { anchor in + CommentAnchorMarker(rect: anchor.rect) { + focusedCommentId = anchor.id + isShowingCommentsSheet = true + } + } } .padding(8) .frame(maxWidth: .infinity, alignment: .topLeading) @@ -613,10 +665,17 @@ struct DocumentReaderView: View { Task { await duplicate() } } .disabled(!isEffectivelyOnline) - Button("Unpublish") { - isShowingUnpublishConfirmation = true + if viewModel.publishedAt == nil { + Button("Publish…") { + isShowingPublishSheet = true + } + .disabled(!isEffectivelyOnline) + } else { + Button("Unpublish") { + isShowingUnpublishConfirmation = true + } + .disabled(!isEffectivelyOnline) } - .disabled(!isEffectivelyOnline) Button("Archive…") { isShowingArchiveConfirmation = true } @@ -834,6 +893,35 @@ struct DocumentReaderView: View { /// char-wraps code blocks, no way to opt out without forking the package) /// throws this off — every row below it reads one line low. Documented in /// TODO.local.md alongside the same package's other gaps. +/// Thin vertical bar next to an anchored comment's text — same visual +/// language as Word/Google Docs' margin comment indicators. Tapping opens +/// the comments sheet focused to that thread. +private struct CommentAnchorMarker: View { + let rect: CGRect + let onTap: () -> Void + + private static let width: CGFloat = 3 + private static let gap: CGFloat = 4 + private static let hitTargetWidth: CGFloat = 16 + + var body: some View { + Color.clear + .frame(width: Self.hitTargetWidth, height: max(rect.height, 4)) + .contentShape(Rectangle()) + .overlay { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.blue.opacity(0.6)) + .frame(width: Self.width) + } + .position( + x: rect.minX - Self.gap - Self.width / 2, + y: rect.minY + rect.height / 2 + ) + .onTapGesture(perform: onTap) + .help("View comment") + } +} + private struct CodeBlockLineNumberGutter: View { let selection: CodeBlockSelection let gutterWidth: CGFloat diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 7b2bc7b..59ad5ff 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -9,6 +9,9 @@ final class DocumentReaderViewModel { 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? @@ -76,6 +79,8 @@ final class DocumentReaderViewModel { 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 @@ -95,6 +100,8 @@ final class DocumentReaderViewModel { 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 diff --git a/Outpost/Features/Collections/NewDocumentSheet.swift b/Outpost/Features/Collections/NewDocumentSheet.swift index 3a8d1a8..8b8a186 100644 --- a/Outpost/Features/Collections/NewDocumentSheet.swift +++ b/Outpost/Features/Collections/NewDocumentSheet.swift @@ -71,7 +71,7 @@ struct NewDocumentSheet: View { ProgressView().frame(maxWidth: .infinity) } else { Picker("Collection", selection: $selectedCollectionID) { - Text("Choose a collection").tag(String?.none) + Text("Draft (not published)").tag(String?.none) ForEach(collections) { collection in Text(collection.name).tag(Optional(collection.id)) } @@ -86,6 +86,12 @@ struct NewDocumentSheet: View { } .labelsHidden() .disabled(selectedCollectionID == nil || isLoadingDestinationDocuments) + + if selectedCollectionID != nil { + Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } } if let errorMessage { @@ -97,18 +103,24 @@ struct NewDocumentSheet: View { HStack { Spacer() Button("Cancel", role: .cancel) { dismiss() } - Button("Create") { + Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") { Task { await create() } } .keyboardShortcut(.defaultAction) - .disabled(selectedCollectionID == nil || isCreating) + .disabled(isCreating) } } .padding(20) .frame(width: 380) .task { await loadCollections() - selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id + // "By default" means a bare New Document (Home, reader + // toolbar) starts as a draft — no `collections.first` fallback + // like there used to be. A contextual entry point (right-click + // a specific collection/document) still pre-fills that + // location, since that already carries clear placement intent; + // the publish warning above still applies to it either way. + selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId selectedParentID = initialParentDocument?.id isTitleFocused = true } @@ -147,7 +159,6 @@ struct NewDocumentSheet: View { } private func create() async { - guard let selectedCollectionID else { return } isCreating = true defer { isCreating = false } do { @@ -156,7 +167,12 @@ struct NewDocumentSheet: View { title: title.isEmpty ? "Untitled" : title, text: "", collectionId: selectedCollectionID, - parentDocumentId: selectedParentID + parentDocumentId: selectedParentID, + // No collection/parent selected = draft; Outline can't + // publish without one of the two regardless of this + // flag, but setting it explicitly rather than relying + // on that keeps intent obvious at the call site. + publish: selectedCollectionID != nil ) ) onCreated(document) diff --git a/Outpost/Features/Collections/PublishDocumentSheet.swift b/Outpost/Features/Collections/PublishDocumentSheet.swift new file mode 100644 index 0000000..7c0234d --- /dev/null +++ b/Outpost/Features/Collections/PublishDocumentSheet.swift @@ -0,0 +1,144 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Destination picker for publishing a draft — same collection-then- +/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's +/// own web app treats "where does this go" identically for both actions. +/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it +/// already has one (a draft can already belong to a collection without +/// being published) rather than starting blank, per explicit instruction. +/// +/// Publishing itself is two calls, not one: `documents.update(publish: +/// true, collectionId:)` does the actual publish and top-level collection +/// placement in one round-trip (confirmed from a live network capture); +/// nesting under a specific parent document isn't a documented +/// `documents.update` field, so that part reuses `documents.move` — the +/// same already-working mechanism `MoveDocumentSheet` uses — as a second +/// step, only when a parent was actually chosen. +@MainActor +struct PublishDocumentSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + let onPublished: () async -> Void + + @State private var collections: [OutlineCollection] = [] + @State private var selectedCollectionID: String? + @State private var rootDocuments: [OutlineDocument] = [] + @State private var selectedParentID: String? + @State private var isLoadingCollections = false + @State private var isLoadingDestinationDocuments = false + @State private var isPublishing = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"") + .font(.headline) + Text("Choose where this document should live once published.") + .font(.callout) + .foregroundStyle(.secondary) + + if isLoadingCollections { + ProgressView().frame(maxWidth: .infinity) + } else { + Picker("Collection", selection: $selectedCollectionID) { + Text("Choose a collection").tag(String?.none) + ForEach(collections) { collection in + Text(collection.name).tag(Optional(collection.id)) + } + } + .labelsHidden() + + Picker("Location", selection: $selectedParentID) { + Text("Collection root").tag(String?.none) + ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in + Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id)) + } + } + .labelsHidden() + .disabled(selectedCollectionID == nil || isLoadingDestinationDocuments) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Publish") { + Task { await publish() } + } + .disabled(selectedCollectionID == nil || isPublishing) + } + } + .padding(20) + .frame(width: 360) + .task { + await loadCollections() + selectedCollectionID = document.collectionId + selectedParentID = document.parentDocumentId + } + .task(id: selectedCollectionID) { + await loadRootDocuments() + } + } + + private func loadCollections() async { + isLoadingCollections = true + defer { isLoadingCollections = false } + do { + collections = try await apiClient.listCollections(offset: 0, limit: 100) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.") + } + } + + private func loadRootDocuments() async { + guard let selectedCollectionID else { + rootDocuments = [] + return + } + isLoadingDestinationDocuments = true + defer { isLoadingDestinationDocuments = false } + do { + rootDocuments = try await apiClient.listDocuments( + collectionId: selectedCollectionID, + parentDocumentId: nil, + offset: 0, + limit: 100 + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.") + } + } + + private func publish() async { + guard let selectedCollectionID else { return } + isPublishing = true + defer { isPublishing = false } + do { + _ = try await apiClient.updateDocument( + UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true) + ) + // Only a documents.move call actually supports parentDocumentId — + // skip it entirely when publishing straight to the collection root, + // the update above already placed it there. + if let selectedParentID { + try await apiClient.moveDocument( + MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID) + ) + } + await onPublished() + dismiss() + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.") + } + } +} +#endif diff --git a/Outpost/Features/Home/HomeTab.swift b/Outpost/Features/Home/HomeTab.swift index 44faf3d..abb2862 100644 --- a/Outpost/Features/Home/HomeTab.swift +++ b/Outpost/Features/Home/HomeTab.swift @@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable { case popular = "Popular" case recentlyUpdated = "Recently Updated" case createdByMe = "Created by Me" + case drafts = "Drafts" var id: String { rawValue } } diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 56543ab..eb35471 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -10,6 +10,7 @@ final class HomeViewModel { private(set) var popular: [OutlineDocument] = [] private(set) var recentlyUpdated: [OutlineDocument] = [] private(set) var createdByMe: [OutlineDocument] = [] + private(set) var drafts: [OutlineDocument] = [] var isLoadingPinned = false var isLoadingTab = false @@ -32,6 +33,7 @@ final class HomeViewModel { case .popular: popular case .recentlyUpdated: recentlyUpdated case .createdByMe: createdByMe + case .drafts: drafts } } @@ -115,6 +117,8 @@ final class HomeViewModel { return try await apiClient.documentsList( DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25) ) + case .drafts: + return try await apiClient.listDrafts(ListDraftsRequest(limit: 25)) } } @@ -124,6 +128,7 @@ final class HomeViewModel { case .popular: popular = documents case .recentlyUpdated: recentlyUpdated = documents case .createdByMe: createdByMe = documents + case .drafts: drafts = documents } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CommentAnchor.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CommentAnchor.swift new file mode 100644 index 0000000..7fea9f2 --- /dev/null +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CommentAnchor.swift @@ -0,0 +1,44 @@ +// +// CommentAnchor.swift +// MarkdownEngine +// +// Lets an embedder ask "where on screen is this text?" for arbitrary +// plain-text needles (e.g. an anchored comment's `anchorText`) that the +// engine has no concept of on its own — it doesn't know what a comment is, +// it just resolves a search term to a rect the same way it already +// resolves code-block token ranges. +// + +import SwiftUI + +/// A plain-text search term the embedder wants a screen rect for, tagged +/// with an opaque id so results can be matched back to whatever the +/// embedder cares about (e.g. a comment id). Engine does a first-occurrence +/// substring search against the current display text — no fuzzier +/// disambiguation than that, since the engine has no positional data to +/// work from beyond the text itself. +public struct CommentAnchorQuery: Sendable, Equatable { + public let id: String + public let anchorText: String + + public init(id: String, anchorText: String) { + self.id = id + self.anchorText = anchorText + } +} + +/// Resolved screen rect for one ``CommentAnchorQuery``. Delivered through +/// ``NativeTextViewWrapper/onCommentAnchorRectsChange`` — queries whose +/// `anchorText` isn't found in the current text (deleted, edited past +/// recognition) simply don't appear in the result. +public struct CommentAnchorRect: Identifiable, Sendable { + public let id: String + /// Frame of the matched text in the text view's coordinate space — + /// same coordinate system as ``CodeBlockSelection/rect``. + public let rect: CGRect + + public init(id: String, rect: CGRect) { + self.id = id + self.rect = rect + } +} diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift new file mode 100644 index 0000000..ffa133e --- /dev/null +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift @@ -0,0 +1,33 @@ +// +// NativeTextViewCoordinator+CommentAnchors.swift +// MarkdownEngine +// +// Resolves embedder-supplied CommentAnchorQuery search terms to on-screen +// rects, the same viewRect(forCharacterRange:using:) utility the code-block +// copy-button overlay already uses. Deliberately no per-keystroke caching +// like updateCodeBlockSelection has — queries only change when the +// embedder's comment list changes (rare), not every keystroke, so the +// first-occurrence substring search + a handful of viewRect calls is cheap +// enough to just always redo. +// + +import AppKit + +extension NativeTextViewCoordinator { + func updateCommentAnchorRects(textView: NSTextView) { + guard !commentAnchorQueries.isEmpty else { + onCommentAnchorRectsChange?([]) + return + } + let nsText = textView.string as NSString + var results: [CommentAnchorRect] = [] + for query in commentAnchorQueries { + guard !query.anchorText.isEmpty else { continue } + let found = nsText.range(of: query.anchorText) + guard found.location != NSNotFound, + let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue } + results.append(CommentAnchorRect(id: query.id, rect: rect)) + } + onCommentAnchorRectsChange?(results) + } +} diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 2656049..b603db5 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -337,6 +337,7 @@ extension NativeTextViewCoordinator { PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) } PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } + updateCommentAnchorRects(textView: tv) if wtActive { previousActiveTokenIndices = activeTokenIndices PerfTrace.end() @@ -700,6 +701,7 @@ extension NativeTextViewCoordinator { // Skip during a pending edit — viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling. if !shouldSkipSelectionRestyle { updateCodeBlockSelection(textView: tv, parsed: parsed) + updateCommentAnchorRects(textView: tv) } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index fb137b8..fa8111b 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -85,6 +85,8 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? var onSelectedTextChange: ((String?) -> Void)? + var commentAnchorQueries: [CommentAnchorQuery] = [] + var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? var didInitialFormatting: Bool = false /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. var didEnsureLayoutForCurrentDocument: Bool = false diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 7c4e6ff..eca565f 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -281,6 +281,12 @@ extension NativeTextView { self.restyleWidthDependentParagraphsForWidthChange() } self.updateWideTableOverlays() + // Comment anchor bars are just as width-dependent as the + // table/image restyle above — wrapped lines reflow, shifting + // every anchor's y-position. + if let coordinator = self.delegate as? NativeTextViewCoordinator { + coordinator.updateCommentAnchorRects(textView: self) + } } } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index ba6f3b6..096ea0d 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -104,6 +104,14 @@ public struct NativeTextViewWrapper: NSViewRepresentable { /// for embedder features that act on "whatever's selected" (e.g. seeding a /// generator's prompt). public var onSelectedTextChange: ((String?) -> Void)? + /// Plain-text search terms the embedder wants a screen rect for — e.g. + /// anchored comments' `anchorText`, to draw an inline marker next to + /// them. See ``CommentAnchorQuery``/``CommentAnchorRect``. + public var commentAnchorQueries: [CommentAnchorQuery] = [] + /// Fires whenever `commentAnchorQueries`' resolved positions change + /// (queries updated, or the document reflows). A query whose text isn't + /// found in the current document simply doesn't appear in the array. + public var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? /// Fires after the user toggles any of the three spell/grammar/auto-correction /// menu items. Embedders persist the policy and pass it back via /// ``MarkdownEditorConfiguration/spellChecking`` on next launch. @@ -164,6 +172,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil, onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil, onSelectedTextChange: ((String?) -> Void)? = nil, + commentAnchorQueries: [CommentAnchorQuery] = [], + onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? = nil, onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil, placeholder: NSAttributedString? = nil, header: AnyView? = nil, @@ -191,6 +201,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { self.onInlinePreviewKey = onInlinePreviewKey self.onCodeBlockSelectionChange = onCodeBlockSelectionChange self.onSelectedTextChange = onSelectedTextChange + self.commentAnchorQueries = commentAnchorQueries + self.onCommentAnchorRectsChange = onCommentAnchorRectsChange self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged self.placeholder = placeholder self.header = header @@ -342,6 +354,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.onInlinePreviewKey = onInlinePreviewKey context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange context.coordinator.onSelectedTextChange = onSelectedTextChange + context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange + context.coordinator.commentAnchorQueries = commentAnchorQueries textView.recalcOverscroll(for: scrollView) textView.setPlaceholder(placeholder) @@ -576,6 +590,20 @@ public struct NativeTextViewWrapper: NSViewRepresentable { } } } + // Comment queries can change (comments finished loading, resolved, + // etc.) with the document's own text completely unchanged — the + // "nothing to do" early-return just below would otherwise skip + // recomputing their positions forever. Handled the same way as + // imageChanged/wikiChanged just above: detect and act on it before + // that gate, independent of whether a real rebuild is happening. + if context.coordinator.commentAnchorQueries != commentAnchorQueries { + context.coordinator.commentAnchorQueries = commentAnchorQueries + let coordinator = context.coordinator + DispatchQueue.main.async { [weak textView] in + guard let textView else { return } + coordinator.updateCommentAnchorRects(textView: textView) + } + } textView.isEditable = isEditable textView.isSelectable = true // Keep the caret ink the selection handler resolved (an extension span @@ -706,8 +734,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable { } // Document rebuilds bypass textDidChange — re-derive emptiness here. textView.refreshPlaceholderVisibility() + context.coordinator.commentAnchorQueries = commentAnchorQueries DispatchQueue.main.async { context.coordinator.updateCodeBlockSelection(textView: textView) + context.coordinator.updateCommentAnchorRects(textView: textView) } context.coordinator.onCaretRectChange = onCaretRectChange @@ -716,6 +746,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.onInlinePreviewKey = onInlinePreviewKey context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange context.coordinator.onSelectedTextChange = onSelectedTextChange + context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange context.coordinator.didInitialFormatting = true } @@ -739,6 +770,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable { coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint() coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange coordinator.onSelectedTextChange = onSelectedTextChange + coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange + coordinator.commentAnchorQueries = commentAnchorQueries coordinator.onInlinePreviewKey = onInlinePreviewKey coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking