import { InfiniteScroll, router, usePage } from '@inertiajs/react'; import { Pin } from 'lucide-react'; import type { PointerEvent, ReactNode } from 'react'; import { Fragment, useEffect, useRef, useState } from 'react'; import { CommitCard } from '@/components/commit-card'; import { GraphCanvas } from '@/components/graph-canvas'; import { GraphRail, RailSegment } from '@/components/graph-rail'; import { useNow } from '@/hooks/use-now'; import { restoreFeedCommit, updateQuotedCommits, } from '@/lib/commit-mutation-options'; import { isDeliberateHover } from '@/lib/feed-hover'; import { cn, formatDayLabel, laneColor, localDayKey } from '@/lib/utils'; import type { Commit, CursorPaginated } from '@/types'; const interludeAfter = 5; export function CommitFeed({ commits, empty, end, highlightedPeriod = null, firstSeenId = null, actions, threadActions, pinnedCommit = null, isRanked = false, isFolded = true, interlude, isBelowComposer = false, }: { commits: CursorPaginated; empty: ReactNode; end: string; highlightedPeriod?: string | null; firstSeenId?: string | null; actions?: (commit: Commit) => ReactNode; threadActions?: (commit: Commit) => ReactNode; pinnedCommit?: Commit | null; isRanked?: boolean; isFolded?: boolean; interlude?: (lane: string, isDimmed: boolean) => ReactNode; isBelowComposer?: boolean; }) { const now = useNow(); const page = usePage(); const { dimsLanes } = page.props; const isModeration = page.component === 'moderation/show'; const isStarred = page.component === 'starred/index'; const profileUserId = page.component === 'user/show' ? (page.props.user as { id: string }).id : null; const isProfileReplies = profileUserId !== null && page.props.tab === 'replies'; const [highlightedAuthor, setHighlightedAuthor] = useState( null, ); const [highlightedCommit, setHighlightedCommit] = useState( null, ); const unhighlightTimer = useRef>(undefined); const isScrolling = useRef(false); const authorUnderPointer = useRef(null); useEffect(() => () => clearTimeout(unhighlightTimer.current), []); useEffect(() => { let settleTimer: ReturnType | undefined; const unhighlight = () => { isScrolling.current = true; clearTimeout(unhighlightTimer.current); setHighlightedCommit(null); setHighlightedAuthor(null); clearTimeout(settleTimer); settleTimer = setTimeout(() => { isScrolling.current = false; }, 300); }; window.addEventListener('scroll', unhighlight, { passive: true }); return () => { window.removeEventListener('scroll', unhighlight); clearTimeout(settleTimer); }; }, []); const highlightAuthor = (userId: string, isHovering: boolean) => { authorUnderPointer.current = isHovering ? userId : null; if (!isHovering) { setHighlightedAuthor(null); } else if (!isScrolling.current) { setHighlightedAuthor(userId); } }; const highlightCommit = ( commitId: string, isHovering: boolean, event: PointerEvent, ) => { if (isHovering) { if (isDeliberateHover(event, isScrolling.current)) { clearTimeout(unhighlightTimer.current); setHighlightedCommit(commitId); // Author targets only report entering, so one that slid under the pointer while scrolling lights up on the next move. setHighlightedAuthor(authorUnderPointer.current); } return; } clearTimeout(unhighlightTimer.current); // Crossing a day marker between two commits shouldn't flash the whole feed back on. unhighlightTimer.current = setTimeout( () => setHighlightedCommit(null), 150, ); }; useEffect( () => router.on('flash', (event) => { if (isModeration) { return; } const { deleted, restored } = event.detail.flash; if (deleted) { router.replaceProp('commits.data', (rows: Commit[]) => updateQuotedCommits( rows.filter((commit) => commit.id !== deleted.id), deleted, ), ); } if (restored) { const belongsInFeed = (isStarred ? restored.starred === true : isProfileReplies ? restored.parent_id !== null : restored.parent_id === null) && (profileUserId === null || (restored.user_id === profileUserId && !restored.is_pinned)); router.replaceProp('commits.data', (rows: Commit[]) => belongsInFeed ? restoreFeedCommit( rows, restored, isStarred ? 'starred' : isRanked ? 'ranked' : 'latest', ) : updateQuotedCommits(rows, restored), ); } }), [profileUserId, isModeration, isProfileReplies, isStarred, isRanked], ); // A ranked commit can move to a later page while the feed scrolls, so each one is only listed once. const seenIds = new Set(); const listed = commits.data.filter((commit) => { if (seenIds.has(commit.id)) { return false; } seenIds.add(commit.id); return true; }); if (listed.length === 0 && pinnedCommit === null) { return
{empty}
; } const isDimmed = (commit: Commit) => dimsLanes && (highlightedAuthor !== null ? commit.user_id !== highlightedAuthor : highlightedCommit !== null && commit.id !== highlightedCommit); const lastCommit = listed.at(-1) ?? pinnedCommit; const interludeIndex = Math.min(interludeAfter, listed.length) - 1; // Feed commits are unrelated to each other, so each one stands on its own with its author's avatar, even when the // same author committed several in a row. const card = (commit: Commit) => ( highlightAuthor(commit.user_id, isHovering) } onHover={(isHovering, event) => highlightCommit(commit.id, isHovering, event) } /> ); return ( {pinnedCommit && (

Pinned

highlightCommit(pinnedCommit.id, isHovering, event) } isHighlighted={ highlightedPeriod !== null && pinnedCommit.created_at.startsWith( highlightedPeriod, ) } threadActions={threadActions?.(pinnedCommit)} />
)} } > {listed.map((commit, index) => { const previous = listed[index - 1]; const startsDay = !isRanked && (!previous || localDayKey(previous.created_at) !== localDayKey(commit.created_at)); const stagedLane = isBelowComposer && index === 0 && !pinnedCommit ? 'var(--rail)' : undefined; return ( {!isRanked && commit.id === firstSeenId && ( )} {startsDay && ( )} {card(commit)} {index === interludeIndex && interlude?.( laneColor(commit.user.lane), isDimmed(commit), )} ); })} {commits.next_cursor === null && lastCommit && (

{end}

)}
); } function DayMarker({ label, stagedLane, }: { label: string; stagedLane?: string; }) { return (

{label}

); } function LastVisitMarker({ stagedLane }: { stagedLane?: string }) { return (
HEAD@{'{last visit}'}
); } export function CommitSkeleton() { return (
} />
); } export function EmptyFeed({ command, children, }: { command: string; children: ReactNode; }) { return ( <>

{command}

{children}
); }