diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 32a573ab0..1e99d6a11 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -1,7 +1,13 @@ 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 { + Fragment, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import { CommitCard } from '@/components/commit-card'; import { GraphCanvas } from '@/components/graph-canvas'; import { GraphRail, RailSegment } from '@/components/graph-rail'; @@ -10,7 +16,12 @@ import { restoreFeedCommit, updateQuotedCommits, } from '@/lib/commit-mutation-options'; -import { isDeliberateHover } from '@/lib/feed-hover'; +import type { FeedHighlightStore } from '@/lib/feed-hover'; +import { + createFeedHighlight, + isDeliberateHover, + isDimmedBy, +} from '@/lib/feed-hover'; import { authorLaneColor, cn, formatDayLabel, localDayKey } from '@/lib/utils'; import type { Commit, CursorPaginated } from '@/types'; @@ -48,7 +59,6 @@ export function CommitFeed({ }) { const now = useNow(); const page = usePage(); - const { dimsLanes } = page.props; const isModeration = page.component === 'moderation/show'; const isStarred = page.component === 'starred/index'; const profileUserId = @@ -62,12 +72,7 @@ export function CommitFeed({ page.component === 'organization/show' ? (page.props.organization as { id: string }).id : null; - const [highlightedAuthor, setHighlightedAuthor] = useState( - null, - ); - const [highlightedCommit, setHighlightedCommit] = useState( - null, - ); + const [highlight] = useState(createFeedHighlight); const unhighlightTimer = useRef>(undefined); const isScrolling = useRef(false); const authorUnderPointer = useRef(null); @@ -80,8 +85,7 @@ export function CommitFeed({ const unhighlight = () => { isScrolling.current = true; clearTimeout(unhighlightTimer.current); - setHighlightedCommit(null); - setHighlightedAuthor(null); + highlight.set({ commitId: null, authorId: null }); clearTimeout(settleTimer); settleTimer = setTimeout(() => { @@ -95,15 +99,15 @@ export function CommitFeed({ window.removeEventListener('scroll', unhighlight); clearTimeout(settleTimer); }; - }, []); + }, [highlight]); const highlightAuthor = (userId: string, isHovering: boolean) => { authorUnderPointer.current = isHovering ? userId : null; if (!isHovering) { - setHighlightedAuthor(null); + highlight.set({ authorId: null }); } else if (!isScrolling.current) { - setHighlightedAuthor(userId); + highlight.set({ authorId: userId }); } }; @@ -115,9 +119,11 @@ export function CommitFeed({ 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); + highlight.set({ + commitId, + authorId: authorUnderPointer.current, + }); } return; @@ -127,7 +133,7 @@ export function CommitFeed({ // Crossing a day marker between two commits shouldn't flash the whole feed back on. unhighlightTimer.current = setTimeout( - () => setHighlightedCommit(null), + () => highlight.set({ commitId: null }), 150, ); }; @@ -204,37 +210,35 @@ export function CommitFeed({ return
{empty}
; } - const isDimmed = (commit: Commit) => - dimsLanes && - (highlightedAuthor !== null - ? commit.author.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.author.id, isHovering) - } - onHover={(isHovering, event) => - highlightCommit(commit.id, isHovering, event) - } - /> + + {(isDimmed) => ( + + highlightAuthor(commit.author.id, isHovering) + } + onHover={(isHovering, event) => + highlightCommit(commit.id, isHovering, event) + } + /> + )} + ); return ( @@ -248,21 +252,29 @@ export function CommitFeed({ Pinned

- - highlightCommit(pinnedCommit.id, isHovering, event) - } - isHighlighted={ - highlightedPeriod !== null && - pinnedCommit.created_at.startsWith( - highlightedPeriod, - ) - } - threadActions={threadActions?.(pinnedCommit)} - /> + + {(isDimmed) => ( + + highlightCommit( + pinnedCommit.id, + isHovering, + event, + ) + } + isHighlighted={ + highlightedPeriod !== null && + pinnedCommit.created_at.startsWith( + highlightedPeriod, + ) + } + threadActions={threadActions?.(pinnedCommit)} + /> + )} + )} )} {card(commit)} - {index === interludeIndex && - interlude?.( - authorLaneColor(commit.author), - isDimmed(commit), - )} + {index === interludeIndex && interlude && ( + + {(isDimmed) => + interlude( + authorLaneColor(commit.author), + isDimmed, + ) + } + + )} ); })} @@ -382,6 +402,25 @@ function LastVisitMarker({ stagedLane }: { stagedLane?: string }) { ); } +function Highlighted({ + highlight, + commit, + children, +}: { + highlight: FeedHighlightStore; + commit: Commit; + children: (isDimmed: boolean) => ReactNode; +}) { + const { dimsLanes } = usePage().props; + const isDimmed = useSyncExternalStore( + highlight.subscribe, + () => dimsLanes && isDimmedBy(highlight.get(), commit), + () => false, + ); + + return children(isDimmed); +} + export function CommitSkeleton() { return (
diff --git a/resources/js/lib/feed-hover.ts b/resources/js/lib/feed-hover.ts index df7dc40b3..f59b0f449 100644 --- a/resources/js/lib/feed-hover.ts +++ b/resources/js/lib/feed-hover.ts @@ -10,3 +10,52 @@ export function isDeliberateHover( !isScrolling && (movement.movementX !== 0 || movement.movementY !== 0) ); } + +export type FeedHighlight = { + commitId: string | null; + authorId: string | null; +}; + +export function isDimmedBy( + highlight: FeedHighlight, + commit: { id: string; author: { id: string } }, +): boolean { + return highlight.authorId !== null + ? commit.author.id !== highlight.authorId + : highlight.commitId !== null && commit.id !== highlight.commitId; +} + +export type FeedHighlightStore = ReturnType; + +/** + * Holds what the pointer highlights outside React state, so each commit re-renders only when its dimming changes + * instead of the whole feed re-rendering on every move between commits. + */ +export function createFeedHighlight() { + let highlight: FeedHighlight = { commitId: null, authorId: null }; + const listeners = new Set<() => void>(); + + return { + get: (): FeedHighlight => highlight, + set: (next: Partial) => { + const updated = { ...highlight, ...next }; + + if ( + updated.commitId === highlight.commitId && + updated.authorId === highlight.authorId + ) { + return; + } + + highlight = updated; + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/tests/Unit/FeedHover.test.ts b/tests/Unit/FeedHover.test.ts index ddc8b359a..2c101a4c5 100644 --- a/tests/Unit/FeedHover.test.ts +++ b/tests/Unit/FeedHover.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { isDeliberateHover } from '../../resources/js/lib/feed-hover.ts'; +import { + createFeedHighlight, + isDeliberateHover, + isDimmedBy, +} from '../../resources/js/lib/feed-hover.ts'; await test('moving the pointer over a commit highlights it', () => { assert.equal( @@ -26,3 +30,48 @@ await test('nudging the pointer while the feed scrolls does not highlight', () = false, ); }); + +await test('pointing at an author dims everyone else, even the commit under the pointer', () => { + const commit = { id: 'a', author: { id: 'nuno' } }; + const other = { id: 'b', author: { id: 'taylor' } }; + const highlight = { commitId: 'b', authorId: 'nuno' }; + + assert.equal(isDimmedBy(highlight, commit), false); + assert.equal(isDimmedBy(highlight, other), true); +}); + +await test('pointing at a commit dims the rest of the feed', () => { + const highlight = { commitId: 'a', authorId: null }; + + assert.equal( + isDimmedBy(highlight, { id: 'a', author: { id: 'x' } }), + false, + ); + assert.equal(isDimmedBy(highlight, { id: 'b', author: { id: 'x' } }), true); + assert.equal( + isDimmedBy( + { commitId: null, authorId: null }, + { id: 'b', author: { id: 'x' } }, + ), + false, + ); +}); + +await test('commits are only told about highlights that change something', () => { + const highlight = createFeedHighlight(); + let notified = 0; + const unsubscribe = highlight.subscribe(() => notified++); + + highlight.set({ commitId: 'a' }); + highlight.set({ commitId: 'a' }); + highlight.set({ authorId: null }); + assert.equal(notified, 1); + + highlight.set({ commitId: 'b', authorId: 'nuno' }); + assert.deepEqual(highlight.get(), { commitId: 'b', authorId: 'nuno' }); + assert.equal(notified, 2); + + unsubscribe(); + highlight.set({ commitId: null }); + assert.equal(notified, 2); +});