Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 101 additions & 62 deletions resources/js/components/commit-feed.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 =
Expand All @@ -62,12 +72,7 @@ export function CommitFeed({
page.component === 'organization/show'
? (page.props.organization as { id: string }).id
: null;
const [highlightedAuthor, setHighlightedAuthor] = useState<string | null>(
null,
);
const [highlightedCommit, setHighlightedCommit] = useState<string | null>(
null,
);
const [highlight] = useState(createFeedHighlight);
const unhighlightTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const isScrolling = useRef(false);
const authorUnderPointer = useRef<string | null>(null);
Expand All @@ -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(() => {
Expand All @@ -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 });
}
};

Expand All @@ -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;
Expand All @@ -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,
);
};
Expand Down Expand Up @@ -204,37 +210,35 @@ export function CommitFeed({
return <div className="px-6 py-16 text-center sm:px-12">{empty}</div>;
}

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) => (
<CommitCard
commit={commit}
endsWithDot
isDimmed={isDimmed(commit)}
isHighlighted={
highlightedPeriod !== null &&
commit.created_at.startsWith(highlightedPeriod)
}
actions={actions?.(commit)}
threadActions={threadActions?.(commit)}
isFolded={isFolded}
showsParent={isModeration}
onAuthorHover={(isHovering) =>
highlightAuthor(commit.author.id, isHovering)
}
onHover={(isHovering, event) =>
highlightCommit(commit.id, isHovering, event)
}
/>
<Highlighted highlight={highlight} commit={commit}>
{(isDimmed) => (
<CommitCard
commit={commit}
endsWithDot
isDimmed={isDimmed}
isHighlighted={
highlightedPeriod !== null &&
commit.created_at.startsWith(highlightedPeriod)
}
actions={actions?.(commit)}
threadActions={threadActions?.(commit)}
isFolded={isFolded}
showsParent={isModeration}
onAuthorHover={(isHovering) =>
highlightAuthor(commit.author.id, isHovering)
}
onHover={(isHovering, event) =>
highlightCommit(commit.id, isHovering, event)
}
/>
)}
</Highlighted>
);

return (
Expand All @@ -248,21 +252,29 @@ export function CommitFeed({
Pinned
</p>
</div>
<CommitCard
commit={pinnedCommit}
endsWithDot
isDimmed={isDimmed(pinnedCommit)}
onHover={(isHovering, event) =>
highlightCommit(pinnedCommit.id, isHovering, event)
}
isHighlighted={
highlightedPeriod !== null &&
pinnedCommit.created_at.startsWith(
highlightedPeriod,
)
}
threadActions={threadActions?.(pinnedCommit)}
/>
<Highlighted highlight={highlight} commit={pinnedCommit}>
{(isDimmed) => (
<CommitCard
commit={pinnedCommit}
endsWithDot
isDimmed={isDimmed}
onHover={(isHovering, event) =>
highlightCommit(
pinnedCommit.id,
isHovering,
event,
)
}
isHighlighted={
highlightedPeriod !== null &&
pinnedCommit.created_at.startsWith(
highlightedPeriod,
)
}
threadActions={threadActions?.(pinnedCommit)}
/>
)}
</Highlighted>
</section>
)}
<InfiniteScroll
Expand Down Expand Up @@ -303,11 +315,19 @@ export function CommitFeed({
/>
)}
{card(commit)}
{index === interludeIndex &&
interlude?.(
authorLaneColor(commit.author),
isDimmed(commit),
)}
{index === interludeIndex && interlude && (
<Highlighted
highlight={highlight}
commit={commit}
>
{(isDimmed) =>
interlude(
authorLaneColor(commit.author),
isDimmed,
)
}
</Highlighted>
)}
</Fragment>
);
})}
Expand Down Expand Up @@ -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 (
<div aria-hidden className="flex">
Expand Down
49 changes: 49 additions & 0 deletions resources/js/lib/feed-hover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createFeedHighlight>;

/**
* 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<FeedHighlight>) => {
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);
};
},
};
}
51 changes: 50 additions & 1 deletion tests/Unit/FeedHover.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
});