Comment Thread

A threaded comment component inspired by Linear. Supports nested replies, emoji reactions, and a keyboard-friendly composer. Drop it into any issue, PR, or task view.

Activity FeedsInspired by Linear
Live PreviewInteractive
ENG-482 · Comments
Comments2
SC
Sarah Chen2h ago

I think we should move this to the next sprint — the API isn't stable enough yet.

MJ
Marcus Johnson1h ago

Agreed — let's revisit after the auth refactor lands.

PP
Priya Patel25m ago

The Figma mockup is updated. Can someone review before we start implementation?

YO

⌘+Enter to send

Click Reply to thread · react with emoji · ⌘+Enter to send

CommentThread.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useRef } from 'react';
import { CornerDownRight, Send } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type Reaction = { emoji: string; count: number; reacted: boolean };

type Comment = {
  id: string;
  author: { name: string; initials: string; color: string };
  body: string;
  timestamp: Date;
  reactions?: Reaction[];
  replies?: Comment[];
};

function formatRelative(date: Date): string {
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffMins = Math.floor(diffMs / 60000);
  const diffHours = Math.floor(diffMins / 60);
  const diffDays = Math.floor(diffHours / 24);
  if (diffMins < 1) return 'Just now';
  if (diffMins < 60) return `${diffMins}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays === 1) return 'Yesterday';
  return `${diffDays}d ago`;
}

function CommentItem({
  comment,
  onReply,
  onReact,
  depth = 0,
}: {
  comment: Comment;
  onReply?: (id: string) => void;
  onReact?: (commentId: string, emoji: string) => void;
  depth?: number;
}) {
  return (
    <div className={cn('flex gap-3', depth > 0 && 'ml-10 mt-2')}>
      <div
        className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[11px] font-bold shrink-0 mt-0.5"
        style={{ backgroundColor: comment.author.color }}
      >
        {comment.author.initials}
      </div>
      <div className="flex-1 min-w-0">
        <div className="flex items-baseline gap-2 mb-1">
          <span className="text-sm font-semibold text-white">{comment.author.name}</span>
          <span className="text-xs text-zinc-600">{formatRelative(comment.timestamp)}</span>
        </div>
        <p className="text-sm text-zinc-300 leading-relaxed">{comment.body}</p>
        {comment.reactions && comment.reactions.length > 0 && (
          <div className="flex items-center gap-1 mt-2">
            {comment.reactions.map((r) => (
              <button
                key={r.emoji}
                onClick={() => onReact?.(comment.id, r.emoji)}
                className={cn(
                  'flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors',
                  r.reacted
                    ? 'bg-indigo-500/20 border-indigo-500/40 text-indigo-300'
                    : 'bg-zinc-800/60 border-zinc-700/60 text-zinc-400 hover:border-zinc-500'
                )}
              >
                <span>{r.emoji}</span>
                <span>{r.count}</span>
              </button>
            ))}
          </div>
        )}
        <button
          onClick={() => onReply?.(comment.id)}
          className="flex items-center gap-1 text-xs text-zinc-600 hover:text-zinc-400 transition-colors mt-2"
        >
          <CornerDownRight className="w-3 h-3" />
          Reply
        </button>
        {comment.replies?.map((reply) => (
          <CommentItem
            key={reply.id}
            comment={reply}
            onReply={onReply}
            onReact={onReact}
            depth={depth + 1}
          />
        ))}
      </div>
    </div>
  );
}

type Props = {
  initialComments?: Comment[];
  currentUser?: { name: string; initials: string; color: string };
};

const DEFAULT_USER = { name: 'You', initials: 'YO', color: '#6366f1' };

export function CommentThread({ initialComments = [], currentUser = DEFAULT_USER }: Props) {
  const [comments, setComments] = useState<Comment[]>(initialComments);
  const [draft, setDraft] = useState('');
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const handleSend = () => {
    const body = draft.trim();
    if (!body) return;
    const newComment: Comment = {
      id: `c-${Date.now()}`,
      author: currentUser,
      body,
      timestamp: new Date(),
    };
    if (replyingTo) {
      setComments((prev) =>
        prev.map((c) =>
          c.id === replyingTo
            ? { ...c, replies: [...(c.replies ?? []), newComment] }
            : c
        )
      );
      setReplyingTo(null);
    } else {
      setComments((prev) => [...prev, newComment]);
    }
    setDraft('');
  };

  const handleReact = (commentId: string, emoji: string) => {
    setComments((prev) =>
      prev.map((c) => {
        if (c.id !== commentId) return c;
        const reactions = (c.reactions ?? []).map((r) =>
          r.emoji === emoji
            ? { ...r, count: r.reacted ? r.count - 1 : r.count + 1, reacted: !r.reacted }
            : r
        );
        return { ...c, reactions };
      })
    );
  };

  const replyTarget = replyingTo ? comments.find((c) => c.id === replyingTo) : null;

  return (
    <div className="flex flex-col h-full bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl overflow-hidden">
      <div className="flex items-center justify-between px-4 py-3 border-b border-[#1a1a1a] shrink-0">
        <span className="text-sm font-semibold text-white">Comments</span>
        <span className="text-xs text-zinc-600">{comments.length}</span>
      </div>
      <div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-4">
        {comments.length === 0 && (
          <div className="flex items-center justify-center h-full">
            <p className="text-sm text-zinc-600">No comments yet</p>
          </div>
        )}
        {comments.map((c) => (
          <CommentItem
            key={c.id}
            comment={c}
            onReply={(id) => {
              setReplyingTo(id);
              textareaRef.current?.focus();
            }}
            onReact={handleReact}
          />
        ))}
      </div>
      <div className="shrink-0 border-t border-[#1a1a1a] px-4 py-3 flex flex-col gap-2">
        {replyTarget && (
          <div className="flex items-center justify-between px-2 py-1 rounded-md bg-indigo-500/10 border border-indigo-500/20">
            <span className="text-xs text-indigo-400">
              Replying to <strong>{replyTarget.author.name}</strong>
            </span>
            <button
              onClick={() => setReplyingTo(null)}
              className="text-zinc-500 hover:text-zinc-300 text-xs"
            >

            </button>
          </div>
        )}
        <div className="flex items-end gap-2">
          <div
            className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[11px] font-bold shrink-0"
            style={{ backgroundColor: currentUser.color }}
          >
            {currentUser.initials}
          </div>
          <textarea
            ref={textareaRef}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                e.preventDefault();
                handleSend();
              }
            }}
            placeholder="Write a comment…"
            rows={1}
            className="flex-1 resize-none bg-[#111] border border-[#2a2a2a] rounded-lg px-3 py-2 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500/50 transition-colors"
            style={{ minHeight: '36px', maxHeight: '120px' }}
          />
          <button
            onClick={handleSend}
            disabled={!draft.trim()}
            className="w-8 h-8 rounded-lg bg-indigo-500 hover:bg-indigo-400 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center transition-colors shrink-0"
          >
            <Send className="w-3.5 h-3.5 text-white" />
          </button>
        </div>
        <p className="text-[11px] text-zinc-700 text-right">⌘+Enter to send</p>
      </div>
    </div>
  );
}