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