Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
170 lines
5.3 KiB
TypeScript
170 lines
5.3 KiB
TypeScript
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import type { TimelineEvent } from '../../../shared/types';
|
|
import { fetchTimeline, markAllRead, markRead } from '../api';
|
|
import { relativeTime, shortRepo } from '../format';
|
|
|
|
const GROUP_WINDOW_MS = 10 * 60 * 1000;
|
|
|
|
interface Group {
|
|
key: string;
|
|
actor: string;
|
|
repo: string;
|
|
prNumber: number;
|
|
prTitle: string;
|
|
events: TimelineEvent[];
|
|
}
|
|
|
|
function groupEvents(events: TimelineEvent[]): Group[] {
|
|
const groups: Group[] = [];
|
|
for (const e of events) {
|
|
const last = groups[groups.length - 1];
|
|
const lastEvent = last?.events[last.events.length - 1];
|
|
if (
|
|
last &&
|
|
lastEvent &&
|
|
last.actor === e.actor &&
|
|
last.repo === e.repo &&
|
|
last.prNumber === e.prNumber &&
|
|
Date.parse(lastEvent.createdAt) - Date.parse(e.createdAt) < GROUP_WINDOW_MS
|
|
) {
|
|
last.events.push(e);
|
|
} else {
|
|
groups.push({
|
|
key: e.id,
|
|
actor: e.actor,
|
|
repo: e.repo,
|
|
prNumber: e.prNumber,
|
|
prTitle: e.prTitle,
|
|
events: [e],
|
|
});
|
|
}
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function KindLabel({ event }: { event: TimelineEvent }) {
|
|
if (event.kind === 'review') {
|
|
const cls =
|
|
event.reviewState === 'APPROVED'
|
|
? 'approved'
|
|
: event.reviewState === 'CHANGES_REQUESTED'
|
|
? 'changes'
|
|
: '';
|
|
const label =
|
|
event.reviewState === 'APPROVED'
|
|
? 'approved'
|
|
: event.reviewState === 'CHANGES_REQUESTED'
|
|
? 'requested changes'
|
|
: 'reviewed';
|
|
return <span className={`tl-review-state ${cls}`}>{label}</span>;
|
|
}
|
|
if (event.kind === 'review_comment') {
|
|
return (
|
|
<span>
|
|
commented{event.path ? ' on ' : ''}
|
|
{event.path && <span className="path">{event.path.split('/').pop()}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
return <span>commented</span>;
|
|
}
|
|
|
|
export default function Timeline() {
|
|
const [unreadOnly, setUnreadOnly] = useState(false);
|
|
const queryClient = useQueryClient();
|
|
|
|
const query = useInfiniteQuery({
|
|
queryKey: ['timeline', unreadOnly],
|
|
queryFn: ({ pageParam }) => fetchTimeline({ cursor: pageParam, unreadOnly }),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
|
});
|
|
|
|
const invalidate = () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['timeline'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['state'] });
|
|
};
|
|
|
|
const readMutation = useMutation({
|
|
mutationFn: ({ id, read }: { id: string; read: boolean }) => markRead(id, read),
|
|
onSettled: invalidate,
|
|
});
|
|
|
|
const readAllMutation = useMutation({ mutationFn: markAllRead, onSettled: invalidate });
|
|
|
|
const events = query.data?.pages.flatMap((p) => p.events) ?? [];
|
|
const groups = groupEvents(events);
|
|
|
|
const openEvent = (e: TimelineEvent) => {
|
|
if (!e.read) readMutation.mutate({ id: e.id, read: true });
|
|
window.open(e.url, '_blank', 'noopener');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="timeline-head">
|
|
<h2>Activity</h2>
|
|
<button className={unreadOnly ? 'active' : ''} onClick={() => setUnreadOnly((v) => !v)}>
|
|
Unread only
|
|
</button>
|
|
<button onClick={() => readAllMutation.mutate()}>Mark all read</button>
|
|
</div>
|
|
<div className="timeline-list">
|
|
{query.isLoading && <div className="empty-note">Loading…</div>}
|
|
{!query.isLoading && groups.length === 0 && (
|
|
<div className="empty-note">{unreadOnly ? 'Nothing unread.' : 'No activity yet.'}</div>
|
|
)}
|
|
{groups.map((group) => (
|
|
<div className="tl-group" key={group.key}>
|
|
<div className="tl-group-head">
|
|
<span className="actor">{group.actor}</span>
|
|
<a
|
|
href={`https://github.com/${group.repo}/pull/${group.prNumber}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={group.prTitle}
|
|
>
|
|
{shortRepo(group.repo)}#{group.prNumber} · {group.prTitle}
|
|
</a>
|
|
<span>{relativeTime(group.events[0]!.createdAt)}</span>
|
|
</div>
|
|
{group.events.map((e) => (
|
|
<div
|
|
key={e.id}
|
|
className={`tl-item${e.read ? '' : ' unread'}`}
|
|
onClick={() => openEvent(e)}
|
|
>
|
|
<button
|
|
className="dot"
|
|
aria-label={e.read ? 'mark unread' : 'mark read'}
|
|
title={e.read ? 'Mark unread' : 'Mark read'}
|
|
onClick={(ev) => {
|
|
ev.stopPropagation();
|
|
readMutation.mutate({ id: e.id, read: !e.read });
|
|
}}
|
|
/>
|
|
<div>
|
|
<div className="tl-kind">
|
|
<KindLabel event={e} />
|
|
</div>
|
|
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
{query.hasNextPage && (
|
|
<button
|
|
className="load-more"
|
|
onClick={() => query.fetchNextPage()}
|
|
disabled={query.isFetchingNextPage}
|
|
>
|
|
{query.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|