Timeline.tsx

  1import { formatDistanceToNow } from "date-fns";
  2import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
  3import { useState } from "react";
  4import { Link } from "react-router-dom";
  5
  6import {
  7  Status,
  8  type BugDetailQuery,
  9  useBugEditCommentMutation,
 10  BugDetailDocument,
 11} from "@/__generated__/graphql";
 12import { Markdown } from "@/components/content/Markdown";
 13import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 14import { Button } from "@/components/ui/button";
 15import { Textarea } from "@/components/ui/textarea";
 16import { useAuth } from "@/lib/auth";
 17import { useRepo } from "@/lib/repo";
 18
 19import { LabelBadge } from "./LabelBadge";
 20
 21type TimelineNode = NonNullable<
 22  NonNullable<NonNullable<BugDetailQuery["repository"]>["bug"]>["timeline"]["nodes"][number]
 23>;
 24
 25interface TimelineProps {
 26  bugPrefix: string;
 27  items: TimelineNode[];
 28}
 29
 30// Ordered sequence of events on a bug: comments (create and add-comment) and
 31// inline events (label changes, status changes, title edits). Comment items
 32// support inline editing for the logged-in user.
 33export function Timeline({ bugPrefix, items }: TimelineProps) {
 34  return (
 35    <div className="space-y-4">
 36      {items.map((item) => {
 37        switch (item.__typename) {
 38          case "BugCreateTimelineItem":
 39          case "BugAddCommentTimelineItem":
 40            return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} />;
 41          case "BugLabelChangeTimelineItem":
 42            return <LabelChangeItem key={item.id} item={item} />;
 43          case "BugSetStatusTimelineItem":
 44            return <StatusChangeItem key={item.id} item={item} />;
 45          case "BugSetTitleTimelineItem":
 46            return <TitleChangeItem key={item.id} item={item} />;
 47          default:
 48            return null;
 49        }
 50      })}
 51    </div>
 52  );
 53}
 54
 55// ── Comment (create or add-comment) ──────────────────────────────────────────
 56
 57type CommentItem = Extract<
 58  TimelineNode,
 59  { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
 60>;
 61
 62function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string }) {
 63  const { user } = useAuth();
 64  const repo = useRepo();
 65  const [editing, setEditing] = useState(false);
 66  const [editValue, setEditValue] = useState(item.message ?? "");
 67
 68  const [editComment, { loading }] = useBugEditCommentMutation({
 69    refetchQueries: [{ query: BugDetailDocument, variables: { prefix: bugPrefix } }],
 70  });
 71
 72  function handleSave() {
 73    if (editValue.trim() === (item.message ?? "").trim()) {
 74      setEditing(false);
 75      return;
 76    }
 77    editComment({
 78      variables: { input: { targetPrefix: item.id, message: editValue } },
 79    }).then(() => setEditing(false));
 80  }
 81
 82  function handleCancel() {
 83    setEditValue(item.message ?? "");
 84    setEditing(false);
 85  }
 86
 87  const canEdit = user !== null && user.id === item.author.id;
 88
 89  return (
 90    <div className="flex gap-3">
 91      <Avatar className="mt-1 size-8 shrink-0">
 92        <AvatarImage src={item.author.avatarUrl ?? undefined} alt={item.author.displayName} />
 93        <AvatarFallback className="text-xs">
 94          {item.author.displayName.slice(0, 2).toUpperCase()}
 95        </AvatarFallback>
 96      </Avatar>
 97
 98      <div className="min-w-0 flex-1 rounded-md border border-border">
 99        <div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-sm">
100          <Link
101            to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
102            className="font-medium text-foreground hover:underline"
103          >
104            {item.author.displayName}
105          </Link>
106          <span className="text-muted-foreground">
107            {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
108          </span>
109          {item.edited && !editing && <span className="text-xs text-muted-foreground">edited</span>}
110          {canEdit && !editing && (
111            <button
112              onClick={() => setEditing(true)}
113              className="ml-auto rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
114            >
115              Edit
116            </button>
117          )}
118        </div>
119
120        {editing ? (
121          <div className="space-y-2 p-3">
122            {/* Ctrl/Cmd+Enter saves; Escape cancels β€” standard editor shortcuts */}
123            <Textarea
124              value={editValue}
125              onChange={(e) => setEditValue(e.target.value)}
126              className="min-h-24 font-mono text-sm"
127              autoFocus
128              onKeyDown={(e) => {
129                if (e.key === "Escape") handleCancel();
130                if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
131                  e.preventDefault();
132                  handleSave();
133                }
134              }}
135            />
136            <div className="flex gap-2">
137              <Button size="sm" onClick={handleSave} disabled={loading}>
138                {loading ? "Saving…" : "Save"}
139              </Button>
140              <Button size="sm" variant="ghost" onClick={handleCancel} disabled={loading}>
141                Cancel
142              </Button>
143            </div>
144          </div>
145        ) : (
146          <div className="px-4 py-3">
147            {item.message ? (
148              <Markdown content={item.message} />
149            ) : (
150              <p className="text-sm italic text-muted-foreground">No description provided.</p>
151            )}
152          </div>
153        )}
154      </div>
155    </div>
156  );
157}
158
159// ── Inline events ─────────────────────────────────────────────────────────────
160
161type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
162type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
163type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
164
165function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
166  return (
167    <div className="flex items-center gap-3 pl-2 text-sm text-muted-foreground">
168      <span className="flex size-8 shrink-0 items-center justify-center">{icon}</span>
169      {children}
170    </div>
171  );
172}
173
174function LabelChangeItem({ item }: { item: LabelChangeItem }) {
175  const repo = useRepo();
176  return (
177    <EventRow icon={<Tag className="size-4" />}>
178      <span>
179        <Link
180          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
181          className="font-medium text-foreground hover:underline"
182        >
183          {item.author.displayName}
184        </Link>{" "}
185        {item.added.length > 0 && (
186          <>
187            added{" "}
188            {item.added.map((l) => (
189              <LabelBadge key={l.name} name={l.name} color={l.color} />
190            ))}{" "}
191          </>
192        )}
193        {item.removed.length > 0 && (
194          <>
195            removed{" "}
196            {item.removed.map((l) => (
197              <LabelBadge key={l.name} name={l.name} color={l.color} />
198            ))}{" "}
199          </>
200        )}
201        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
202      </span>
203    </EventRow>
204  );
205}
206
207function StatusChangeItem({ item }: { item: StatusChangeItem }) {
208  const repo = useRepo();
209  const isOpen = item.status === Status.Open;
210  return (
211    <EventRow
212      icon={
213        isOpen ? (
214          <CircleDot className="size-4 text-green-600 dark:text-green-400" />
215        ) : (
216          <GitPullRequestClosed className="size-4 text-purple-600 dark:text-purple-400" />
217        )
218      }
219    >
220      <span>
221        <Link
222          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
223          className="font-medium text-foreground hover:underline"
224        >
225          {item.author.displayName}
226        </Link>{" "}
227        {isOpen ? "reopened" : "closed"} this{" "}
228        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
229      </span>
230    </EventRow>
231  );
232}
233
234function TitleChangeItem({ item }: { item: TitleChangeItem }) {
235  const repo = useRepo();
236  return (
237    <EventRow icon={<Pencil className="size-4" />}>
238      <span>
239        <Link
240          to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
241          className="font-medium text-foreground hover:underline"
242        >
243          {item.author.displayName}
244        </Link>{" "}
245        changed the title from <span className="line-through">{item.was}</span> to{" "}
246        <span className="font-medium text-foreground">{item.title}</span>{" "}
247        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
248      </span>
249    </EventRow>
250  );
251}