feat: add comment marker (list + resolve/unresolve)
Wraps Outline's Comments API in OutlineKit for the first time - comments.list is documented in the vendored spec; comments.resolve/ unresolve are not, confirmed real against Outline's own server source instead of guessed. Comment bodies are ProseMirror documents (data), not plain text - added a small recursive JSONValue tree plus a best-effort plainText() walk for display, since nothing here needs to write comment bodies back (out of scope for this pass, see DocumentCommentsSheet's doc comment). Reader toolbar gets a marker (bubble icon + count badge) only when the document actually has comments, per explicit instruction that this should be a simple symbol rather than a true in-document gutter marker at each comment's anchor position - anchoring is plain-text- substring-based server-side and doesn't map cleanly onto this app's own Markdown rendering. Opens a read-only comment list with a Resolve/Unresolve toggle per the confirmed scope for this pass; no creating or replying yet.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<String> = [
|
||||
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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<String> = []
|
||||
@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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user