Timeline.tsx

  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}