diff --git a/resources/css/app.css b/resources/css/app.css index f3445529b..aca57b1b7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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); diff --git a/resources/js/hooks/use-fresh-rows.ts b/resources/js/hooks/use-fresh-rows.ts new file mode 100644 index 000000000..394e49509 --- /dev/null +++ b/resources/js/hooks/use-fresh-rows.ts @@ -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, + key: string, + freshIds: RefObject>, + rows: unknown, +): void { + useLayoutEffect(() => { + if (freshIds.current.size === 0) { + return; + } + + 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/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 9c549611f..a7369b803 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 { useOwnsCommit } from '@/hooks/use-owns-commit'; @@ -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('[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 b2450aa29..abcc73cd0 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -45,6 +45,7 @@ import { NotificationFilters } from '@/components/notification-filters'; import { QuotedCommit } from '@/components/quoted-commit'; import { Button } from '@/components/ui/button'; 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'; @@ -160,6 +161,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, @@ -273,6 +277,23 @@ export default function Index({ } }, [newestAt, hasUnread]); + 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; @@ -359,82 +380,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. +

+
+ )} +
+ )} ); diff --git a/tests/Unit/FreshRows.test.ts b/tests/Unit/FreshRows.test.ts new file mode 100644 index 000000000..b9f9d6d96 --- /dev/null +++ b/tests/Unit/FreshRows.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +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; +}; + +let observerCallback: ( + entries: { target: Row; isIntersecting: boolean }[], +) => void = () => undefined; +const listeners = new Map void>(); + +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) }; + lightFreshRows( + { querySelectorAll: () => rows } as unknown as HTMLElement, + 'group', + freshIds, + ); + + return freshIds; +} + +function see(...rows: Row[]): void { + observerCallback(rows.map((target) => ({ target, isIntersecting: true }))); +} + +beforeEach(() => { + mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + Object.assign(globalThis, { + IntersectionObserver: class { + constructor(callback: typeof observerCallback) { + observerCallback = callback; + } + observe() {} + disconnect() {} + }, + window: { + addEventListener: (name: string, listener: () => void) => + listeners.set(name, listener), + removeEventListener: (name: string) => listeners.delete(name), + }, + }); +}); + +afterEach(() => { + listeners.clear(); + mock.timers.reset(); +}); + +await 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); +}); + +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); + mock.timers.tick(7000); + + assert.equal(top.dataset.fresh, 'fading'); + assert.equal(below.dataset.fresh, ''); + assert.deepEqual([...freshIds.current], ['below']); + + see(below); + mock.timers.tick(7000); + mock.timers.tick(1400); + + assert.equal(top.dataset.fresh, undefined); + assert.equal(below.dataset.fresh, undefined); + assert.equal(freshIds.current.size, 0); + assert.equal(listeners.size, 0); +}); + +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); + mock.timers.tick(700); + listeners.get('pointermove')!(); + mock.timers.tick(1800); + + assert.equal(top.dataset.fresh, 'fading'); + assert.equal(below.dataset.fresh, ''); +}); + +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')!(); + mock.timers.tick(6999); + + assert.equal(top.dataset.fresh, ''); +}); + +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); + mock.timers.tick(7000); + listeners.get('pointermove')!(); + mock.timers.tick(1800); + + assert.equal(below.dataset.fresh, ''); + assert.equal(listeners.size, 3); + + see(below); + mock.timers.tick(7000); + + assert.equal(below.dataset.fresh, 'fading'); + assert.equal(listeners.size, 0); +});