The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
104 lines
4.3 KiB
Swift
104 lines
4.3 KiB
Swift
import SwiftUI
|
|
|
|
/// The read screen's comment thread — the phone's rendering of `CommentThread`, oldest first,
|
|
/// below the card's own content.
|
|
///
|
|
/// **The wording is the Mac's** (`CommentsHeader` / `CommentAuthorLine` in `CardCommentsLayout` —
|
|
/// pure, but homed in `Kanban/UI`, which the phone deliberately does not compile, so the two lines
|
|
/// are restated rather than imported): the header is "Comments · N" with the zero rendered as a
|
|
/// count rather than an absence, and the author line is "author · timestamp · edited" with each
|
|
/// absent segment dropped and the whole line dropped when there is neither a name nor a date to
|
|
/// hang "edited" from. Absent means absent — no "Unattributed" placeholder, no avatar: the file
|
|
/// deliberately carries no identity system, and the render must not invent one.
|
|
///
|
|
/// Read-only rows plus two doors: **Add Comment** under the thread opens the composer, and each
|
|
/// row's context menu carries **Edit** — both sheets, both transactional, both the containing
|
|
/// screen's to present. A posted comment's attachments render as read-only paperclip rows, the
|
|
/// Mac's own rule for chips outside an authoring surface.
|
|
struct CardCommentsSection: View {
|
|
/// `nil` while the first read is in flight — the header and the Add button render, rows wait.
|
|
let thread: CommentThread?
|
|
let onAdd: () -> Void
|
|
let onEdit: (Comment) -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Divider()
|
|
|
|
Text("Comments · \(thread?.comments.count ?? 0)")
|
|
.font(.subheadline.smallCaps().weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let thread {
|
|
ForEach(thread.comments) { comment in
|
|
CommentRow(comment: comment)
|
|
.contextMenu {
|
|
Button {
|
|
onEdit(comment)
|
|
} label: {
|
|
Label("Edit Comment", systemImage: "pencil")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Button(action: onAdd) {
|
|
Label("Add Comment", systemImage: "plus.bubble")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One posted comment: author line, body through the same block renderer the card's own body
|
|
/// uses (a comment's Markdown is the card-body subset, so the renderer is shared by construction),
|
|
/// and the attachment listing.
|
|
private struct CommentRow: View {
|
|
let comment: Comment
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let line = authorLine {
|
|
Text(line)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Only a non-empty body reaches the renderer: `CardBodyView`'s empty case says
|
|
// "No description", which is a card's word, not a comment's — an attachment-only
|
|
// comment renders its chips alone.
|
|
if !comment.body.isEmpty {
|
|
CardBodyView(body: comment.body)
|
|
}
|
|
|
|
ForEach(comment.attachments, id: \.self) { name in
|
|
Label(name, systemImage: "paperclip")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
/// `CommentAuthorLine.text(author:timestamp:isEdited:)`, restated. An empty `author` string is
|
|
/// treated as absent for the Mac's reason: the Writer never writes `author: ""` (it omits the
|
|
/// key), so one on disk is a hand edit, and a blank name beside a separator would be noise.
|
|
private var authorLine: String? {
|
|
var parts: [String] = []
|
|
if let author = comment.author.value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!author.isEmpty {
|
|
parts.append(author)
|
|
}
|
|
if let created = comment.created.value {
|
|
parts.append(created.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
guard !parts.isEmpty else { return nil }
|
|
if comment.isEdited {
|
|
parts.append("edited")
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
}
|