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)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await live.addDocumentUser(request)
|
try await live.addDocumentUser(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// Historical view records, not live presence. Backed by `views.list`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
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
|
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||||
/// from Outline's official docs, the rest are best-effort.
|
/// from Outline's official docs, the rest are best-effort.
|
||||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await post("documents.add_user", body: request)
|
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 listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { 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 addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { 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")
|
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 {
|
func testCreatePinDecodesPin() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
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 isShowingMoveSheet = false
|
||||||
@State private var isShowingHistorySheet = false
|
@State private var isShowingHistorySheet = false
|
||||||
@State private var isShowingInsightsSheet = 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 isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
@@ -195,6 +201,31 @@ struct DocumentReaderView: View {
|
|||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
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 {
|
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
|
||||||
Button {
|
Button {
|
||||||
imagePlaygroundSeedText = currentSelectedText
|
imagePlaygroundSeedText = currentSelectedText
|
||||||
@@ -270,6 +301,11 @@ struct DocumentReaderView: View {
|
|||||||
.task {
|
.task {
|
||||||
await viewModel.loadInsightsEnabledState()
|
await viewModel.loadInsightsEnabledState()
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
commentCount = try? await apiClient.listComments(
|
||||||
|
ListCommentsRequest(documentId: viewModel.documentId)
|
||||||
|
).count
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await viewModel.loadViewers()
|
await viewModel.loadViewers()
|
||||||
|
|||||||
Reference in New Issue
Block a user