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