Skip to content
Closed
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
10 changes: 6 additions & 4 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -842,15 +842,17 @@ html body[data-scroll-locked] {
}
}

/* Commits that just arrived on their own, until the reader moves on. Only they fade slowly, as this unlayered rule
would otherwise override the hover transition of every commit. */
[data-commit][data-fresh] {
/* Commits and notifications that just arrived, until the reader moves on. Only they fade slowly, as this unlayered
rule would otherwise override the hover transition of every row. */
[data-commit][data-fresh],
[data-notification-group][data-fresh] {
transition:
background-color 1.4s ease,
box-shadow 1.4s ease;
}

[data-commit][data-fresh=''] {
[data-commit][data-fresh=''],
[data-notification-group][data-fresh=''] {
background-color: color-mix(in oklab, var(--primary) 7%, transparent);
box-shadow: inset 3px 0 0
color-mix(in oklab, var(--primary) 70%, transparent);
Expand Down
21 changes: 21 additions & 0 deletions resources/js/hooks/use-fresh-rows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useLayoutEffect } from 'react';
import type { RefObject } from 'react';
import { lightFreshRows } from '@/lib/fresh-rows';

/**
* Lights the rows whose `data-*` id, read from `dataset[key]`, is in `freshIds`, with the fresh treatment (`data-fresh`) whenever `rows` change.
*/
export function useFreshRows(
container: RefObject<HTMLElement | null>,
key: string,
freshIds: RefObject<Set<string>>,
rows: unknown,
): void {
useLayoutEffect(() => {
if (freshIds.current.size === 0) {
return;
}

return lightFreshRows(container.current, key, freshIds);
}, [container, key, freshIds, rows]);
}
91 changes: 91 additions & 0 deletions resources/js/lib/fresh-rows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Lights the rows in `container` whose `data-*` id, read from `dataset[key]`, is in `freshIds`, with the fresh treatment
* (`data-fresh`), and returns the cleanup that stops tracking them.
*/
export function lightFreshRows(
container: HTMLElement | null,
key: string,
freshIds: { current: Set<string> },
): () => void {
const ids = freshIds.current;
const elements = Array.from(
container?.querySelectorAll<HTMLElement>(
`[data-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`,
) ?? [],
).filter((row) => ids.has(row.dataset[key] ?? ''));
elements
.filter((row) => row.dataset.fresh === undefined)
.forEach((row) => {
row.dataset.fresh = '';
});

// Each row stays lit until it is on screen, then while nobody is touching the page, and fades soon after the
// pointer starts moving. Rows further down wait for the reader to scroll to them.
const timers = new Map<Element, ReturnType<typeof setTimeout>>();
const sightedAt = new Map<Element, number>();
const hurried = new Set<Element>();
let unsettled = elements.length;

const settle = (row: HTMLElement) => {
hurried.add(row);
row.dataset.fresh = 'fading';
setTimeout(() => {
if (row.dataset.fresh === 'fading') {
row.removeAttribute('data-fresh');
}
}, 1400);
freshIds.current.delete(row.dataset[key] ?? '');

if (--unsettled === 0) {
cleanup();
}
};

const settleIn = (row: HTMLElement, delay: number) => {
clearTimeout(timers.get(row));
timers.set(
row,
setTimeout(() => settle(row), delay),
);
};

const hurry = () => {
sightedAt.forEach((at, row) => {
if (!hurried.has(row) && Date.now() - at >= 700) {
hurried.add(row);
settleIn(row as HTMLElement, 1800);
}
});
};

const observer = new IntersectionObserver(
(entries) => {
entries
.filter(
(entry) =>
entry.isIntersecting && !sightedAt.has(entry.target),
)
.forEach((entry) => {
sightedAt.set(entry.target, Date.now());
settleIn(entry.target as HTMLElement, 7000);
});
},
{ threshold: 0.6 },
);

elements.forEach((row) => observer.observe(row));

function cleanup() {
timers.forEach((timer) => clearTimeout(timer));
observer.disconnect();
window.removeEventListener('pointermove', hurry);
window.removeEventListener('wheel', hurry);
window.removeEventListener('keydown', hurry);
}

window.addEventListener('pointermove', hurry, { passive: true });
window.addEventListener('wheel', hurry, { passive: true });
window.addEventListener('keydown', hurry);

return cleanup;
}
79 changes: 2 additions & 77 deletions resources/js/pages/log/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PageTabs } from '@/components/page-tabs';
import { UserPreview } from '@/components/user-preview';
import { WhoToFollowInLog } from '@/components/who-to-follow';
import { useComposerVisibility } from '@/hooks/use-composer-visibility';
import { useFreshRows } from '@/hooks/use-fresh-rows';
import { useLaneSwipe } from '@/hooks/use-lane-swipe';
import { useLastVisit } from '@/hooks/use-last-visit';
import { useOwnsCommit } from '@/hooks/use-owns-commit';
Expand Down Expand Up @@ -256,83 +257,7 @@ export default function Show({

useLayoutEffect(restoreReadingPosition, [restoreReadingPosition]);

useLayoutEffect(() => {
const ids = freshIds.current;

if (ids.size === 0) {
return;
}

const rows = Array.from(
feedRef.current?.querySelectorAll<HTMLElement>('[data-commit]') ??
[],
).filter((row) => ids.has(row.dataset.commit ?? ''));
rows.filter((row) => row.dataset.fresh === undefined).forEach((row) => {
row.dataset.fresh = '';
});

// They stay lit until they are on screen, then while nobody is touching the page, and fade soon after the pointer
// starts moving. Commits that arrived while the reader was further down wait for them to scroll back up.
let timer: ReturnType<typeof setTimeout> | undefined;
let sightedAt = 0;
let hurried = false;
const visible = new Set<Element>();

const settle = () => {
rows.forEach((row) => {
row.dataset.fresh = 'fading';
setTimeout(() => {
if (row.dataset.fresh === 'fading') {
row.removeAttribute('data-fresh');
}
}, 1400);
});
freshIds.current = new Set();
cleanup();
};

const hurry = () => {
if (hurried || sightedAt === 0 || Date.now() - sightedAt < 700) {
return;
}

hurried = true;
clearTimeout(timer);
timer = setTimeout(settle, 1800);
};

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) =>
entry.isIntersecting
? visible.add(entry.target)
: visible.delete(entry.target),
);

if (visible.size > 0 && sightedAt === 0) {
sightedAt = Date.now();
timer = setTimeout(settle, 7000);
}
},
{ threshold: 0.6 },
);

rows.forEach((row) => observer.observe(row));

function cleanup() {
clearTimeout(timer);
observer.disconnect();
window.removeEventListener('pointermove', hurry);
window.removeEventListener('wheel', hurry);
window.removeEventListener('keydown', hurry);
}

window.addEventListener('pointermove', hurry, { passive: true });
window.addEventListener('wheel', hurry, { passive: true });
window.addEventListener('keydown', hurry);

return cleanup;
}, [commits]);
useFreshRows(feedRef, 'commit', freshIds, commits);

const firstSeenId = useLastVisit(viewerId, log, commits.data);

Expand Down
Loading