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