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);
+});