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            search={{ status: "open" as const, after: "" }}
111            className="text-foreground font-medium hover:underline"
112          >
113            {item.author.displayName}
114          </Link>
115          <span className="text-muted-foreground">
116            {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
117          </span>
118          {item.edited && !editing && <span className="text-muted-foreground text-xs">edited</span>}
119          {canEdit && !editing && (
120            <button
121              onClick={() => setEditing(true)}
122              className="text-muted-foreground hover:bg-muted hover:text-foreground ml-auto rounded-sm px-1.5 py-0.5 text-xs"
123            >
124              Edit
125            </button>
126          )}
127        </div>
128
129        {editing ? (
130          <div className="space-y-2 p-3">
131            {/* Ctrl/Cmd+Enter saves; Escape cancels β€” standard editor shortcuts */}
132            <Textarea
133              value={editValue}
134              onChange={(e) => setEditValue(e.target.value)}
135              className="min-h-24 font-mono text-sm"
136              autoFocus
137              onKeyDown={(e) => {
138                if (e.key === "Escape") handleCancel();
139                if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
140                  e.preventDefault();
141                  handleSave();
142                }
143              }}
144            />
145            <div className="flex gap-2">
146              <Button size="sm" onClick={handleSave} disabled={loading}>
147                {loading ? "Saving…" : "Save"}
148              </Button>
149              <Button size="sm" variant="ghost" onClick={handleCancel} disabled={loading}>
150                Cancel
151              </Button>
152            </div>
153          </div>
154        ) : (
155          <div className="px-4 py-3">
156            {item.message ? (
157              <Markdown content={item.message} />
158            ) : (
159              <p className="text-muted-foreground text-sm italic">No description provided.</p>
160            )}
161          </div>
162        )}
163      </div>
164    </div>
165  );
166}
167
168// ── Inline events ─────────────────────────────────────────────────────────────
169
170type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
171type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
172type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
173
174function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
175  return (
176    <div className="text-muted-foreground flex items-center gap-3 pl-2 text-sm">
177      <span className="flex size-8 shrink-0 items-center justify-center">{icon}</span>
178      {children}
179    </div>
180  );
181}
182
183function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
184  return (
185    <EventRow icon={<Tag className="size-4" />}>
186      <span>
187        <Link
188          to="/$repo/user/$id"
189          params={{ repo: repo!, id: item.author.humanId }}
190          search={{ status: "open" as const, after: "" }}
191          className="text-foreground font-medium hover:underline"
192        >
193          {item.author.displayName}
194        </Link>{" "}
195        {item.added.length > 0 && (
196          <>
197            added{" "}
198            {item.added.map((l) => (
199              <LabelBadge key={l.name} name={l.name} color={l.color} />
200            ))}{" "}
201          </>
202        )}
203        {item.removed.length > 0 && (
204          <>
205            removed{" "}
206            {item.removed.map((l) => (
207              <LabelBadge key={l.name} name={l.name} color={l.color} />
208            ))}{" "}
209          </>
210        )}
211        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
212      </span>
213    </EventRow>
214  );
215}
216
217function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
218  const isOpen = item.status === Status.Open;
219  return (
220    <EventRow
221      icon={
222        isOpen ? (
223          <CircleDot className="size-4 text-green-600 dark:text-green-400" />
224        ) : (
225          <GitPullRequestClosed className="size-4 text-purple-600 dark:text-purple-400" />
226        )
227      }
228    >
229      <span>
230        <Link
231          to="/$repo/user/$id"
232          params={{ repo: repo!, id: item.author.humanId }}
233          search={{ status: "open" as const, after: "" }}
234          className="text-foreground font-medium hover:underline"
235        >
236          {item.author.displayName}
237        </Link>{" "}
238        {isOpen ? "reopened" : "closed"} this{" "}
239        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
240      </span>
241    </EventRow>
242  );
243}
244
245function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
246  return (
247    <EventRow icon={<Pencil className="size-4" />}>
248      <span>
249        <Link
250          to="/$repo/user/$id"
251          params={{ repo: repo!, id: item.author.humanId }}
252          search={{ status: "open" as const, after: "" }}
253          className="text-foreground font-medium hover:underline"
254        >
255          {item.author.displayName}
256        </Link>{" "}
257        changed the title from <span className="line-through">{item.was}</span> to{" "}
258        <span className="text-foreground font-medium">{item.title}</span>{" "}
259        {formatDistanceToNow(new Date(item.date), { addSuffix: true })}
260      </span>
261    </EventRow>
262  );
263}