'use client'
import { useId, useState, type KeyboardEvent } from 'react'
import { cn } from '../../lib/cn'
import { Avatar } from '../Avatar'
import { Badge } from '../Badge'
import { Button } from '../Button'
import { RelativeTime } from '../RelativeTime'
import { Text } from '../Text'
import { Textarea } from '../Textarea'
import { CheckIcon } from '../internal/icons'
export interface CommentThreadAuthor {
id: string
name: string
avatarSrc?: string
}
export interface CommentThreadReaction {
emoji: string
count: number
/** Whether the current person is one of the count. */
reacted: boolean
}
export interface CommentThreadComment {
id: string
author: CommentThreadAuthor
body: string
createdAt: Date | number | string
/** Set once the body has been changed after posting. */
editedAt?: Date | number | string
reactions?: CommentThreadReaction[]
replies?: CommentThreadComment[]
}
export interface CommentThreadProps {
/** Top-level comments, oldest first. The caller owns the tree. */
comments: CommentThreadComment[]
/** Whose comments get Edit and Delete. */
currentUserId: string
/** A reply to a comment, or a new top-level comment when `parentId` is null. */
onReply: (parentId: string | null, body: string) => void
onEdit?: (id: string, body: string) => void
onDelete?: (id: string) => void
/** Toggles one reaction for the current person. Omit to hide reactions. */
onReact?: (id: string, emoji: string) => void
/** The reactions offered on every comment. */
reactionChoices?: string[]
/** Nesting stops here. Replying at the deepest level answers the parent, with a mention. */
maxDepth?: number
/** Resolved threads hide their composers and read as settled. */
resolved?: boolean
/** Shows the Resolve / Reopen control. */
onResolvedChange?: (resolved: boolean) => void
/** Merged last, so it wins. */
className?: string
}
function InlineComposer({ label, initial = '', submitLabel, onSubmit, onCancel }: { label: string; initial?: string; submitLabel: string; onSubmit: (body: string) => void; onCancel?: () => void }) {
const [body, setBody] = useState(initial)
const submit = () => {
if (!body.trim()) return
onSubmit(body.trim())
setBody('')
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && onCancel) {
event.preventDefault()
onCancel()
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
submit()
}
}
return (
)
}
const count = (list: CommentThreadComment[] = []): number => list.reduce((sum, item) => sum + 1 + count(item.replies), 0)
/**
* Discussion attached to something β a document, a design, a line of code.
*
* Nesting is capped, three levels by default. Unlimited threading reads well for
* the first two replies and then becomes a staircase too narrow to read on a
* laptop, let alone a phone. Past the cap, Reply answers the parent and starts
* with an @mention, which keeps who-is-talking-to-whom without another indent.
*
* Editing and deleting appear only on the current personβs comments, and delete
* asks once, inline, rather than in a modal: it is a small, local decision and a
* dialog would take the reader out of the conversation to make it. Resolving
* keeps the thread readable but removes every composer, so a settled discussion
* is not quietly reopened by a stray reply.
*/
export function CommentThread({
comments,
currentUserId,
onReply,
onEdit,
onDelete,
onReact,
reactionChoices = ['π', 'π', 'π'],
maxDepth = 3,
resolved = false,
onResolvedChange,
className,
}: CommentThreadProps) {
const baseId = useId()
const [replying, setReplying] = useState(null)
const [editing, setEditing] = useState(null)
const [confirming, setConfirming] = useState(null)
const [collapsed, setCollapsed] = useState>(new Set())
const toggleCollapsed = (id: string) =>
setCollapsed((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const renderComment = (comment: CommentThreadComment, depth: number, parentId: string | null) => {
const own = comment.author.id === currentUserId
const replies = comment.replies ?? []
const hidden = collapsed.has(comment.id)
const repliesId = `${baseId}-replies-${comment.id}`
const replyTarget = depth < maxDepth - 1 ? comment.id : parentId
const reactions = reactionChoices.map((emoji) => comment.reactions?.find((item) => item.emoji === emoji) ?? { emoji, count: 0, reacted: false })
return (