The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width. The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.7 KiB
TypeScript
159 lines
5.7 KiB
TypeScript
/**
|
|
* Renders the GeoCities style's marquee: the community feed crawling across
|
|
* the page under the header, as a `<marquee>` 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<Loop | null>(() => feed && lastLoop);
|
|
if (!loop && feed) setLoop({ feed, online, turn: 0 });
|
|
useEffect(() => {
|
|
lastLoop = loop;
|
|
}, [loop]);
|
|
const shown = still ? feed && { feed, online } : loop;
|
|
|
|
const trackRef = useRef<HTMLDivElement>(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 (
|
|
<div
|
|
className="site-marquee"
|
|
// A link focused from the keyboard stops the crawl, and the browser
|
|
// scrolls the line to show it: the line goes back to its start once the
|
|
// focus has left.
|
|
onBlur={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
e.currentTarget.scrollLeft = 0;
|
|
}
|
|
}}
|
|
>
|
|
{shown && (
|
|
<div
|
|
key={loop?.turn}
|
|
ref={trackRef}
|
|
className="site-marquee-track"
|
|
onAnimationIteration={(e) => {
|
|
// 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 });
|
|
}}
|
|
>
|
|
<CommunityFeed feed={shown.feed} online={shown.online} />
|
|
{!still && (
|
|
<div aria-hidden="true">
|
|
<CommunityFeed
|
|
feed={shown.feed}
|
|
online={shown.online}
|
|
tabIndex={-1}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 <FeedMarquee />;
|
|
}
|