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}