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: " · ") } }