Timeline.tsx

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