/** * Renders the GeoCities style's marquee: the community feed crawling across * the page under the header, as a `` did on a homepage of that era. * `AppHeader` renders it on every page. It draws nothing in any other style, * nor for a visitor, the feed being for signed-in users. * * @module */ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts"; import type { OnlineUser } from "../../../shared/ws.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useCommunityFeed } from "../../hooks/social/useCommunityFeed.ts"; import { useTheme } from "../../hooks/theme/useTheme.ts"; import { useWSPresence } from "../../hooks/ws/useWS.ts"; import { CommunityFeed } from "../social/CommunityFeed.tsx"; const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; function onMotionChange(callback: () => void): () => void { const query = matchMedia(REDUCED_MOTION); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } // Where the crawl had got to, and what its loop showed, when the marquee was // last on screen. They are kept out of the component, which every page mounts // anew with its header: the next page's marquee carries on from that point of // that loop, rather than from the start. A crawl that was running has gone on // since; one paused under the pointer, which has just clicked a link in it, // hasn't. let lastPosition: | { elapsed: number; at: number; running: boolean } | undefined; let lastLoop: Loop | null = null; /** What one loop of the crawl shows, from its start to its end. */ interface Loop { feed: CommunityFeedData; online: OnlineUser[]; /** Counts the loops, and keys the track: a new loop is a new animation. */ turn: number; } /** * Renders the feed twice, side by side: geocities.css slides the pair left by * the width of one, so the second copy arrives where the first began and the * loop has no seam. The second copy is hidden from assistive technology and * from the Tab key. * * A loop shows what the feed held when it began. Text that changed mid-crawl * would jump, the track's width being what the slide is measured by, so what * came meanwhile waits for the next loop, which also brings the lines' times * up to date. The crawl's speed is the style's: this sets `--marquee-length`, * one copy's width in pixels, from which the stylesheet works out how long a * loop lasts. * * For a reader who asked for reduced motion nothing moves or repeats: the * line shows once, and follows the feed as it changes. */ function FeedMarquee(): React.JSX.Element { const feed = useCommunityFeed(); const { onlineUsers: online } = useWSPresence(); const still = useSyncExternalStore( onMotionChange, () => matchMedia(REDUCED_MOTION).matches, ); // A marquee with no feed yet is a first one, or follows a sign-out. const [loop, setLoop] = useState(() => feed && lastLoop); if (!loop && feed) setLoop({ feed, online, turn: 0 }); useEffect(() => { lastLoop = loop; }, [loop]); const shown = still ? feed && { feed, online } : loop; const trackRef = useRef(null); useLayoutEffect(() => { const track = trackRef.current; const length = track?.firstElementChild?.getBoundingClientRect().width; if (!track || !length) return; track.style.setProperty("--marquee-length", String(Math.round(length))); const last = lastPosition; lastPosition = undefined; const elapsed = !last ? 0 : last.elapsed + (last.running ? performance.now() - last.at : 0); // A negative delay starts the animation that far in. track.style.animationDelay = `${-elapsed / 1000}s`; return () => { // The track's own animation: the focus can have stopped it. const animation = track.getAnimations()[0]; const timing = animation?.effect?.getComputedTiming(); if (!timing?.progress || typeof timing.duration !== "number") return; lastPosition = { elapsed: timing.progress * timing.duration, at: performance.now(), running: animation.playState === "running", }; }; }, [loop?.turn, still]); return (
{ if (!e.currentTarget.contains(e.relatedTarget)) { e.currentTarget.scrollLeft = 0; } }} > {shown && (
{ // Not a child's animation ending a round of its own. if (e.target !== e.currentTarget || !feed || !loop) return; setLoop({ feed, online, turn: loop.turn + 1 }); }} > {!still && ( )}
)}
); } /** * Renders the marquee for a signed-in user in the GeoCities style, an empty * band until the feed is loaded, so that the page under it doesn't move when * it comes. */ export function Marquee(): React.JSX.Element | null { const { style } = useTheme(); const { user } = useAuth(); if (style !== "geocities" || !user) return null; return ; }