From 6dccf490c3b9056d23da6ebeee37948254af6ab6 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Sun, 1 Nov 2026 15:58:34 +0300 Subject: [PATCH 1/4] feat: highlight new notifications when the notifications page opens --- resources/css/app.css | 10 +- resources/js/hooks/use-fresh-rows.ts | 93 +++++++++++ resources/js/pages/log/show.tsx | 79 +--------- resources/js/pages/notification/index.tsx | 180 +++++++++++++--------- 4 files changed, 208 insertions(+), 154 deletions(-) create mode 100644 resources/js/hooks/use-fresh-rows.ts diff --git a/resources/css/app.css b/resources/css/app.css index 5faf09245..4ba05bb58 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -821,15 +821,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); diff --git a/resources/js/hooks/use-fresh-rows.ts b/resources/js/hooks/use-fresh-rows.ts new file mode 100644 index 000000000..574af79a1 --- /dev/null +++ b/resources/js/hooks/use-fresh-rows.ts @@ -0,0 +1,93 @@ +import { useLayoutEffect } from 'react'; +import type { RefObject } from 'react'; + +/** + * 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, + key: string, + freshIds: RefObject>, + rows: unknown, +): void { + useLayoutEffect(() => { + const ids = freshIds.current; + + if (ids.size === 0) { + return; + } + + const elements = Array.from( + container.current?.querySelectorAll( + `[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 = ''; + }); + + // They stay lit until they are on screen, then while nobody is touching the page, and fade soon after the pointer + // starts moving. Rows that arrived while the reader was further down wait for them to scroll back up. + let timer: ReturnType | undefined; + let sightedAt = 0; + let hurried = false; + const visible = new Set(); + + const settle = () => { + elements.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 }, + ); + + elements.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; + }, [container, key, freshIds, rows]); +} diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 3930aecb3..bb770dc1a 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -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 AppLayout from '@/layouts/app-layout'; @@ -254,83 +255,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('[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 | undefined; - let sightedAt = 0; - let hurried = false; - const visible = new Set(); - - 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); diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index bcf42a65d..7156bec63 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -36,6 +36,7 @@ import { QuotedCommit } from '@/components/quoted-commit'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; +import { useFreshRows } from '@/hooks/use-fresh-rows'; import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; @@ -151,6 +152,9 @@ export default function Index({ const pollRequest = useRef(null); const header = useRef(null); const pollAnchor = useRef(null); + const feed = useRef(null); + const freshIds = useRef>(new Set()); + const litGroups = useRef>(new Set()); const [arrivals, setArrivals] = useState(0); const { start, stop } = usePoll( 30_000, @@ -242,6 +246,23 @@ export default function Index({ } }, [notifications.data]); + useLayoutEffect(() => { + notifications.data + .filter((group) => isNewSince(group, readAt)) + .filter( + (group) => + !litGroups.current.has( + `${group.group_id}:${group.event_count}`, + ), + ) + .forEach((group) => { + litGroups.current.add(`${group.group_id}:${group.event_count}`); + freshIds.current.add(group.group_id); + }); + }, [notifications.data, readAt]); + + useFreshRows(feed, 'notificationGroup', freshIds, notifications.data); + useEffect(() => { if (arrivals === 0) { return; @@ -328,82 +349,95 @@ export default function Index({ ) : ( - - - - - - } - > - {notifications.data.map((notification, position) => { - const previous = notifications.data[position - 1]; - const next = notifications.data[position + 1]; - const isNew = isNewSince(notification, readAt); - const beforeLastVisit = - isNew && next && !isNewSince(next, readAt); - const startsDay = - !previous || - localDayKey(previous.created_at) !== - localDayKey(notification.created_at); - - return ( - - {previous && - isNewSince(previous, readAt) && - !isNew && } - {startsDay && ( - + + + + + + } + > + {notifications.data.map( + (notification, position) => { + const previous = + notifications.data[position - 1]; + const next = + notifications.data[position + 1]; + const isNew = isNewSince( + notification, + readAt, + ); + const beforeLastVisit = + isNew && + next && + !isNewSince(next, readAt); + const startsDay = + !previous || + localDayKey(previous.created_at) !== + localDayKey( notification.created_at, - now, + ); + + return ( + + {previous && + isNewSince(previous, readAt) && + !isNew && } + {startsDay && ( + )} - /> + + + ); + }, + )} + + {notifications.next_cursor === null && ( +
+ - - ); - })} - - {notifications.next_cursor === null && ( -
- -

- That's the oldest notification. There's nothing - before it. -

-
- )} - + variant="root" + hasTail={false} + /> +

+ That's the oldest notification. There's + nothing before it. +

+
+ )} +
+ )} ); From 686940273ea536af4df3f1b61cd26364a8910955 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Sun, 1 Nov 2026 16:44:31 +0300 Subject: [PATCH 2/4] fix: keep each new row lit until it has been seen --- resources/js/hooks/use-fresh-rows.ts | 78 ++++++++-------- tests/Unit/FreshRows.test.tsx | 129 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 35 deletions(-) create mode 100644 tests/Unit/FreshRows.test.tsx diff --git a/resources/js/hooks/use-fresh-rows.ts b/resources/js/hooks/use-fresh-rows.ts index 574af79a1..118b3b995 100644 --- a/resources/js/hooks/use-fresh-rows.ts +++ b/resources/js/hooks/use-fresh-rows.ts @@ -28,48 +28,56 @@ export function useFreshRows( 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. Rows that arrived while the reader was further down wait for them to scroll back up. - let timer: ReturnType | undefined; - let sightedAt = 0; - let hurried = false; - const visible = new Set(); + // 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>(); + const sightedAt = new Map(); + const hurried = new Set(); + let unsettled = elements.length; - const settle = () => { - elements.forEach((row) => { - row.dataset.fresh = 'fading'; - setTimeout(() => { - if (row.dataset.fresh === 'fading') { - row.removeAttribute('data-fresh'); - } - }, 1400); - }); - freshIds.current = new Set(); - cleanup(); - }; + const settle = (row: HTMLElement) => { + row.dataset.fresh = 'fading'; + setTimeout(() => { + if (row.dataset.fresh === 'fading') { + row.removeAttribute('data-fresh'); + } + }, 1400); + freshIds.current.delete(row.dataset[key] ?? ''); - const hurry = () => { - if (hurried || sightedAt === 0 || Date.now() - sightedAt < 700) { - return; + if (--unsettled === 0) { + cleanup(); } + }; + + const settleIn = (row: HTMLElement, delay: number) => { + clearTimeout(timers.get(row)); + timers.set( + row, + setTimeout(() => settle(row), delay), + ); + }; - hurried = true; - clearTimeout(timer); - timer = setTimeout(settle, 1800); + 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.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); - } + 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 }, ); @@ -77,7 +85,7 @@ export function useFreshRows( elements.forEach((row) => observer.observe(row)); function cleanup() { - clearTimeout(timer); + timers.forEach((timer) => clearTimeout(timer)); observer.disconnect(); window.removeEventListener('pointermove', hurry); window.removeEventListener('wheel', hurry); diff --git a/tests/Unit/FreshRows.test.tsx b/tests/Unit/FreshRows.test.tsx new file mode 100644 index 000000000..0e501f597 --- /dev/null +++ b/tests/Unit/FreshRows.test.tsx @@ -0,0 +1,129 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; + +type Row = { dataset: Record } & { + removeAttribute: (name: string) => void; +}; + +let observerCallback: ( + entries: { target: Row; isIntersecting: boolean }[], +) => void = () => undefined; +const listeners = new Map void>(); + +vi.doMock('react', () => ({ + ...React, + useLayoutEffect: (effect: () => void) => effect(), +})); + +const { useFreshRows } = + await import('../../resources/js/hooks/use-fresh-rows'); + +function row(id: string): Row { + const dataset: Record = { group: id }; + + return { + dataset, + removeAttribute: () => { + delete dataset.fresh; + }, + }; +} + +function light(rows: Row[], ids: string[]): { current: Set } { + const freshIds = { current: new Set(ids) }; + useFreshRows( + { current: { querySelectorAll: () => rows } as unknown as HTMLElement }, + 'group', + freshIds, + rows, + ); + + return freshIds; +} + +function see(...rows: Row[]): void { + observerCallback(rows.map((target) => ({ target, isIntersecting: true }))); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: typeof observerCallback) { + observerCallback = callback; + } + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal('window', { + addEventListener: (name: string, listener: () => void) => + listeners.set(name, listener), + removeEventListener: (name: string) => listeners.delete(name), + }); +}); + +afterEach(() => { + listeners.clear(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('lights the fresh rows only', () => { + const fresh = row('new'); + const seen = row('old'); + light([fresh, seen], ['new']); + + assert.equal(fresh.dataset.fresh, ''); + assert.equal(seen.dataset.fresh, undefined); +}); + +test('keeps a row further down lit after the rows on screen fade', () => { + const top = row('top'); + const below = row('below'); + const freshIds = light([top, below], ['top', 'below']); + + see(top); + vi.advanceTimersByTime(7000); + + assert.equal(top.dataset.fresh, 'fading'); + assert.equal(below.dataset.fresh, ''); + assert.deepEqual([...freshIds.current], ['below']); + + see(below); + vi.advanceTimersByTime(7000); + vi.advanceTimersByTime(1400); + + assert.equal(top.dataset.fresh, undefined); + assert.equal(below.dataset.fresh, undefined); + assert.equal(freshIds.current.size, 0); + assert.equal(listeners.size, 0); +}); + +test('moving the pointer only hurries the rows already seen', () => { + const top = row('top'); + const below = row('below'); + light([top, below], ['top', 'below']); + + see(top); + vi.advanceTimersByTime(700); + listeners.get('pointermove')!(); + vi.advanceTimersByTime(1800); + + assert.equal(top.dataset.fresh, 'fading'); + assert.equal(below.dataset.fresh, ''); +}); + +test('a row stays lit for a moment after it is seen, however the pointer moves', () => { + const top = row('top'); + light([top], ['top']); + + see(top); + listeners.get('pointermove')!(); + vi.advanceTimersByTime(6999); + + assert.equal(top.dataset.fresh, ''); +}); From dbed3c8f6e274f3671b2d13b255d77659c4592ee Mon Sep 17 00:00:00 2001 From: richardkeep Date: Sun, 1 Nov 2026 16:46:54 +0300 Subject: [PATCH 3/4] fix: settle each new row only once --- resources/js/hooks/use-fresh-rows.ts | 1 + tests/Unit/FreshRows.test.tsx | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/resources/js/hooks/use-fresh-rows.ts b/resources/js/hooks/use-fresh-rows.ts index 118b3b995..70f16468a 100644 --- a/resources/js/hooks/use-fresh-rows.ts +++ b/resources/js/hooks/use-fresh-rows.ts @@ -36,6 +36,7 @@ export function useFreshRows( let unsettled = elements.length; const settle = (row: HTMLElement) => { + hurried.add(row); row.dataset.fresh = 'fading'; setTimeout(() => { if (row.dataset.fresh === 'fading') { diff --git a/tests/Unit/FreshRows.test.tsx b/tests/Unit/FreshRows.test.tsx index 0e501f597..ba008ea79 100644 --- a/tests/Unit/FreshRows.test.tsx +++ b/tests/Unit/FreshRows.test.tsx @@ -127,3 +127,23 @@ test('a row stays lit for a moment after it is seen, however the pointer moves', assert.equal(top.dataset.fresh, ''); }); + +test('input after a row faded on its own still waits for the rows below', () => { + const top = row('top'); + const below = row('below'); + light([top, below], ['top', 'below']); + + see(top); + vi.advanceTimersByTime(7000); + listeners.get('pointermove')!(); + vi.advanceTimersByTime(1800); + + assert.equal(below.dataset.fresh, ''); + assert.equal(listeners.size, 3); + + see(below); + vi.advanceTimersByTime(7000); + + assert.equal(below.dataset.fresh, 'fading'); + assert.equal(listeners.size, 0); +}); From 569e1d6dfefcf4ba4777519a831f357424fa31b0 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Sun, 1 Nov 2026 20:56:52 +0300 Subject: [PATCH 4/4] test: run the fresh rows tests with node, as .ts --- resources/js/hooks/use-fresh-rows.ts | 87 +----------------- resources/js/lib/fresh-rows.ts | 91 +++++++++++++++++++ .../{FreshRows.test.tsx => FreshRows.test.ts} | 66 ++++++-------- 3 files changed, 121 insertions(+), 123 deletions(-) create mode 100644 resources/js/lib/fresh-rows.ts rename tests/Unit/{FreshRows.test.tsx => FreshRows.test.ts} (63%) diff --git a/resources/js/hooks/use-fresh-rows.ts b/resources/js/hooks/use-fresh-rows.ts index 70f16468a..394e49509 100644 --- a/resources/js/hooks/use-fresh-rows.ts +++ b/resources/js/hooks/use-fresh-rows.ts @@ -1,5 +1,6 @@ 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. @@ -11,92 +12,10 @@ export function useFreshRows( rows: unknown, ): void { useLayoutEffect(() => { - const ids = freshIds.current; - - if (ids.size === 0) { + if (freshIds.current.size === 0) { return; } - const elements = Array.from( - container.current?.querySelectorAll( - `[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>(); - const sightedAt = new Map(); - const hurried = new Set(); - 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; + return lightFreshRows(container.current, key, freshIds); }, [container, key, freshIds, rows]); } diff --git a/resources/js/lib/fresh-rows.ts b/resources/js/lib/fresh-rows.ts new file mode 100644 index 000000000..56a7efe18 --- /dev/null +++ b/resources/js/lib/fresh-rows.ts @@ -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 }, +): () => void { + const ids = freshIds.current; + const elements = Array.from( + container?.querySelectorAll( + `[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>(); + const sightedAt = new Map(); + const hurried = new Set(); + 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; +} diff --git a/tests/Unit/FreshRows.test.tsx b/tests/Unit/FreshRows.test.ts similarity index 63% rename from tests/Unit/FreshRows.test.tsx rename to tests/Unit/FreshRows.test.ts index ba008ea79..b9f9d6d96 100644 --- a/tests/Unit/FreshRows.test.tsx +++ b/tests/Unit/FreshRows.test.ts @@ -1,7 +1,6 @@ -/// import assert from 'node:assert/strict'; -import * as React from 'react'; -import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; +import { afterEach, beforeEach, mock, test } from 'node:test'; +import { lightFreshRows } from '../../resources/js/lib/fresh-rows.ts'; type Row = { dataset: Record } & { removeAttribute: (name: string) => void; @@ -12,14 +11,6 @@ let observerCallback: ( ) => void = () => undefined; const listeners = new Map void>(); -vi.doMock('react', () => ({ - ...React, - useLayoutEffect: (effect: () => void) => effect(), -})); - -const { useFreshRows } = - await import('../../resources/js/hooks/use-fresh-rows'); - function row(id: string): Row { const dataset: Record = { group: id }; @@ -33,11 +24,10 @@ function row(id: string): Row { function light(rows: Row[], ids: string[]): { current: Set } { const freshIds = { current: new Set(ids) }; - useFreshRows( - { current: { querySelectorAll: () => rows } as unknown as HTMLElement }, + lightFreshRows( + { querySelectorAll: () => rows } as unknown as HTMLElement, 'group', freshIds, - rows, ); return freshIds; @@ -48,31 +38,29 @@ function see(...rows: Row[]): void { } beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal( - 'IntersectionObserver', - class { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + Object.assign(globalThis, { + IntersectionObserver: class { constructor(callback: typeof observerCallback) { observerCallback = callback; } observe() {} disconnect() {} }, - ); - vi.stubGlobal('window', { - addEventListener: (name: string, listener: () => void) => - listeners.set(name, listener), - removeEventListener: (name: string) => listeners.delete(name), + window: { + addEventListener: (name: string, listener: () => void) => + listeners.set(name, listener), + removeEventListener: (name: string) => listeners.delete(name), + }, }); }); afterEach(() => { listeners.clear(); - vi.useRealTimers(); - vi.unstubAllGlobals(); + mock.timers.reset(); }); -test('lights the fresh rows only', () => { +await test('lights the fresh rows only', () => { const fresh = row('new'); const seen = row('old'); light([fresh, seen], ['new']); @@ -81,21 +69,21 @@ test('lights the fresh rows only', () => { assert.equal(seen.dataset.fresh, undefined); }); -test('keeps a row further down lit after the rows on screen fade', () => { +await test('keeps a row further down lit after the rows on screen fade', () => { const top = row('top'); const below = row('below'); const freshIds = light([top, below], ['top', 'below']); see(top); - vi.advanceTimersByTime(7000); + mock.timers.tick(7000); assert.equal(top.dataset.fresh, 'fading'); assert.equal(below.dataset.fresh, ''); assert.deepEqual([...freshIds.current], ['below']); see(below); - vi.advanceTimersByTime(7000); - vi.advanceTimersByTime(1400); + mock.timers.tick(7000); + mock.timers.tick(1400); assert.equal(top.dataset.fresh, undefined); assert.equal(below.dataset.fresh, undefined); @@ -103,46 +91,46 @@ test('keeps a row further down lit after the rows on screen fade', () => { assert.equal(listeners.size, 0); }); -test('moving the pointer only hurries the rows already seen', () => { +await test('moving the pointer only hurries the rows already seen', () => { const top = row('top'); const below = row('below'); light([top, below], ['top', 'below']); see(top); - vi.advanceTimersByTime(700); + mock.timers.tick(700); listeners.get('pointermove')!(); - vi.advanceTimersByTime(1800); + mock.timers.tick(1800); assert.equal(top.dataset.fresh, 'fading'); assert.equal(below.dataset.fresh, ''); }); -test('a row stays lit for a moment after it is seen, however the pointer moves', () => { +await test('a row stays lit for a moment after it is seen, however the pointer moves', () => { const top = row('top'); light([top], ['top']); see(top); listeners.get('pointermove')!(); - vi.advanceTimersByTime(6999); + mock.timers.tick(6999); assert.equal(top.dataset.fresh, ''); }); -test('input after a row faded on its own still waits for the rows below', () => { +await test('input after a row faded on its own still waits for the rows below', () => { const top = row('top'); const below = row('below'); light([top, below], ['top', 'below']); see(top); - vi.advanceTimersByTime(7000); + mock.timers.tick(7000); listeners.get('pointermove')!(); - vi.advanceTimersByTime(1800); + mock.timers.tick(1800); assert.equal(below.dataset.fresh, ''); assert.equal(listeners.size, 3); see(below); - vi.advanceTimersByTime(7000); + mock.timers.tick(7000); assert.equal(below.dataset.fresh, 'fading'); assert.equal(listeners.size, 0);