Files
Outpost/Outpost/Features/Collections/DocumentCommentsSheet.swift
T
Puranjay Savar Mattas 2d0972de1e feat: create inline (anchored) comments from a text selection
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.
2026-08-20 21:52:13 +01:00

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