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'
 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}