diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 407faea..f4e46ac 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -303,6 +303,18 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { try await live.listViews(request) } + public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { + try await live.listComments(request) + } + + public func resolveComment(id: String) async throws -> OutlineComment { + try await live.resolveComment(id: id) + } + + public func unresolveComment(id: String) async throws -> OutlineComment { + try await live.unresolveComment(id: id) + } + 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 6f7ef5b..b8ea864 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -48,6 +48,12 @@ 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. + func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] + func resolveComment(id: String) async throws -> OutlineComment + func unresolveComment(id: String) async throws -> OutlineComment + /// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed /// from Outline's official docs, the rest are best-effort. func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 4d9cd93..9eb1f79 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -175,6 +175,18 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("views.list", body: request) } + public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { + try await post("comments.list", body: request) + } + + public func resolveComment(id: String) async throws -> OutlineComment { + try await post("comments.resolve", body: StarIDParams(id: id)) + } + + public func unresolveComment(id: String) async throws -> OutlineComment { + try await post("comments.unresolve", body: StarIDParams(id: id)) + } + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { try await post("documents.add_user", body: request) } diff --git a/OutlineKit/Sources/OutlineKit/Models/JSONValue.swift b/OutlineKit/Sources/OutlineKit/Models/JSONValue.swift new file mode 100644 index 0000000..f7659ee --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/JSONValue.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Minimal recursive JSON tree for fields the API returns as genuinely +/// arbitrary/untyped JSON (`type: object` with no fixed schema in the +/// OpenAPI spec) — currently just `Comment.data`, a ProseMirror document. +/// Decode-only: nothing in this codebase writes comment bodies back yet. +public enum JSONValue: Decodable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } +} + +extension JSONValue { + /// Best-effort plain-text extraction from a ProseMirror-shaped document — + /// walks `content` arrays, concatenates `text` node strings, and adds a + /// trailing newline after known block-level node types so paragraphs + /// don't run together. Good enough for a read-only comment list; not a + /// real ProseMirror renderer (no marks, no lists/tables structure). + public func plainText() -> String { + switch self { + case .object(let fields): + if case .string(let text)? = fields["text"] { + return text + } + let childText: String + if case .array(let items)? = fields["content"] { + childText = items.map { $0.plainText() }.joined() + } else { + childText = "" + } + if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) { + return childText + "\n" + } + return childText + case .array(let items): + return items.map { $0.plainText() }.joined() + case .string(let value): + return value + case .number, .bool, .null: + return "" + } + } + + private static let blockTypes: Set = [ + "paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block" + ] +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift new file mode 100644 index 0000000..01d8da3 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineComment.swift @@ -0,0 +1,24 @@ +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()`. +public struct OutlineComment: Decodable, Identifiable, Sendable { + public let id: String + public let data: JSONValue + public let documentId: String + public let parentCommentId: String? + public let createdAt: Date + public let createdBy: OutlineUser? + public let updatedAt: Date? + public let resolvedAt: Date? + public let resolvedBy: OutlineUser? + + public var isResolved: Bool { resolvedAt != nil } + public var bodyText: String { + data.plainText().trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift new file mode 100644 index 0000000..24e152d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListCommentsRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct ListCommentsRequest: Encodable, Sendable { + public let documentId: String + public let offset: Int + public let limit: Int + + public init(documentId: String, offset: Int = 0, limit: Int = 100) { + self.documentId = documentId + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 2aa7cd3..83fd0a6 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -71,6 +71,9 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } 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 resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() } + func unresolveComment(id: String) async throws -> OutlineComment { 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 bfee9c3..ea76950 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -846,6 +846,81 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list") } + func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "comment-1", + "documentId": "doc-1", + "parentCommentId": null, + "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, + "data": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Sounds great" } + ] + } + ] + } + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1")) + + XCTAssertEqual(comments.first?.id, "comment-1") + XCTAssertEqual(comments.first?.bodyText, "Sounds great") + XCTAssertFalse(comments.first?.isResolved ?? true) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list") + } + + func testResolveCommentDecodesResolvedComment() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "comment-1", + "documentId": "doc-1", + "parentCommentId": null, + "createdAt": "2026-01-01T00:00:00.000Z", + "createdBy": { "id": "user-1", "name": "Jane Doe" }, + "updatedAt": "2026-01-01T00:00:00.000Z", + "resolvedAt": "2026-01-02T00:00:00.000Z", + "resolvedBy": { "id": "user-2", "name": "John Roe" }, + "data": { "type": "doc", "content": [] } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let comment = try await client.resolveComment(id: "comment-1") + + XCTAssertTrue(comment.isResolved) + XCTAssertEqual(comment.resolvedBy?.name, "John Roe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve") + } + func testCreatePinDecodesPin() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/DocumentCommentsSheet.swift b/Outpost/Features/Collections/DocumentCommentsSheet.swift new file mode 100644 index 0000000..403eea2 --- /dev/null +++ b/Outpost/Features/Collections/DocumentCommentsSheet.swift @@ -0,0 +1,138 @@ +#if os(macOS) +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. +@MainActor +struct DocumentCommentsSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + + @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? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Comments") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + .padding() + + Divider() + + Group { + if isLoading && comments.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView { + Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if comments.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) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 460, height: 480) + .task { await load() } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + } + + private func commentRow(_ comment: OutlineComment) -> 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) + .foregroundStyle(.secondary) + } + + Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText) + .font(.callout) + .foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary) + + HStack { + if comment.isResolved { + Label("Resolved", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } + 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) + } + } + } + .padding(.vertical, 4) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.") + } + } + + private func toggleResolved(_ comment: OutlineComment) async { + resolvingCommentIds.insert(comment.id) + defer { resolvingCommentIds.remove(comment.id) } + do { + 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 + } + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index b88d721..28b54b3 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -88,6 +88,12 @@ struct DocumentReaderView: View { @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 + /// independently (see `DocumentCommentsSheet`), same as every other + /// self-contained sheet in this file. + @State private var commentCount: Int? @State private var isShowingPresentSheet = false @State private var isShowingSearchSheet = false @State private var isShowingShareSheet = false @@ -195,6 +201,31 @@ 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). + if let commentCount, commentCount > 0 { + Button { + isShowingCommentsSheet = true + } label: { + Image(systemName: "bubble.left.and.bubble.right") + } + .help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")") + .disabled(!isEffectivelyOnline) + .overlay(alignment: .topTrailing) { + Text("\(commentCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(3) + .background(.blue, in: Circle()) + .offset(x: 6, y: -6) + } + .sheet(isPresented: $isShowingCommentsSheet) { + DocumentCommentsSheet(apiClient: apiClient, document: document) + } + } + if isImagePlaygroundEnabled && isImagePlaygroundSupported { Button { imagePlaygroundSeedText = currentSelectedText @@ -270,6 +301,11 @@ struct DocumentReaderView: View { .task { await viewModel.loadInsightsEnabledState() } + .task { + commentCount = try? await apiClient.listComments( + ListCommentsRequest(documentId: viewModel.documentId) + ).count + } .task { while !Task.isCancelled { await viewModel.loadViewers()