Right-click a selection -> "Comment on Selection..." -> opens the comments sheet straight into composing, with a removable chip showing what's being anchored to (clearing it falls back to a plain document-level comment). Posts via comments.create with anchorText set to the selection. Only wired on rendered panes (main reader pane, Split View's preview pane) - not the raw-markdown pane, since a selection there would capture literal Markdown syntax as the anchor instead of clean prose. NSMenuItem has no closure initializer, so this needed a small target-is-self subclass (ClosureMenuItem) to wire a Swift closure into onBuildContextMenu's NSMenu without a separate selector per action. Also moved the comments sheet's .sheet(...) modifier off the toolbar badge button (which only exists once the doc already has comments) onto the view root, since a document with zero comments still needs to be able to open the sheet to create its first one. Reader now refetches its own comment list after any create/reply via the sheet's new onCommentsChanged callback, so a freshly anchored comment's inline marker appears without reopening the document.
426 lines
17 KiB
Swift
426 lines
17 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
/// 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.
|
|
@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
|
|
/// Set when opened via "Comment on Selection…" from the editor's
|
|
/// right-click menu — the selected text to anchor a NEW comment to.
|
|
/// The first occurrence of this exact substring in the document is
|
|
/// what the server (and later, this app's own inline marker) anchors
|
|
/// to; no prefix/suffix disambiguation UI for multiple identical
|
|
/// occurrences.
|
|
var pendingAnchorText: String? = nil
|
|
/// Called after any successful create/reply/resolve/reaction — lets the
|
|
/// reader refresh its own lightweight copy (inline anchor markers, the
|
|
/// toolbar badge count) without waiting for the document to be reopened.
|
|
var onCommentsChanged: (() -> Void)? = 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 actionErrorMessage: String?
|
|
@State private var currentUserId: String?
|
|
|
|
@State private var newCommentText = ""
|
|
@State private var isPostingNewComment = false
|
|
/// Mutable mirror of `pendingAnchorText` — lets the user clear it (fall
|
|
/// back to a plain document-level comment) without touching the init
|
|
/// param itself.
|
|
@State private var composingAnchorText: String?
|
|
|
|
@State private var replyingToThreadId: String?
|
|
@State private var replyText = ""
|
|
@State private var isPostingReply = false
|
|
|
|
@State private var resolvingCommentIds: Set<String> = []
|
|
@State private var reactingCommentIds: Set<String> = []
|
|
@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) {
|
|
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 threads.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
|
} description: {
|
|
Text("This document has no comments yet.")
|
|
}
|
|
} else {
|
|
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: 480, height: 560)
|
|
.task { await load() }
|
|
.task { currentUserId = try? await apiClient.currentUser().id }
|
|
.task { composingAnchorText = pendingAnchorText }
|
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
|
Button("OK") { actionErrorMessage = nil }
|
|
} message: {
|
|
Text(actionErrorMessage ?? "")
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
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)
|
|
|
|
if !comment.reactions.isEmpty {
|
|
reactionChips(comment)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
reactionPickerCommentId = comment.id
|
|
} label: {
|
|
Image(systemName: "face.smiling")
|
|
}
|
|
.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)
|
|
}
|
|
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(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 {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let composingAnchorText {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "text.quote")
|
|
.foregroundStyle(.blue)
|
|
Text(composingAnchorText)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
Spacer()
|
|
Button {
|
|
self.composingAnchorText = nil
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.help("Remove — this will post as a document-level comment instead")
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
|
}
|
|
HStack(alignment: .bottom, spacing: 8) {
|
|
TextField(
|
|
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
|
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 }
|
|
do {
|
|
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
|
|
}
|
|
}
|
|
|
|
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, anchorText: composingAnchorText)
|
|
)
|
|
comments.append(created)
|
|
composingAnchorText = nil
|
|
newCommentText = ""
|
|
onCommentsChanged?()
|
|
} 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
|
|
onCommentsChanged?()
|
|
} 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) }
|
|
do {
|
|
let updated = comment.isResolved
|
|
? try await apiClient.unresolveComment(id: comment.id)
|
|
: try await apiClient.resolveComment(id: comment.id)
|
|
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
|