terry-staging #12

Merged
terry merged 13 commits from terry-staging into main 2026-05-30 10:45:30 +00:00
Showing only changes of commit b2e4a4e710 - Show all commits

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom"; import { useLocation, useSearchParams } from "react-router-dom";
import { postJSON } from "../../api"; import { postJSON } from "../../api";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
@@ -79,23 +79,72 @@ export function MessageStream({ scope }: MessageStreamProps) {
? hash.slice("#post-".length) ? hash.slice("#post-".length)
: ""; : "";
const targetPostId = queryTargetPostId || hashTargetPostId; const targetPostId = queryTargetPostId || hashTargetPostId;
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
Boolean(queryTargetPostId),
);
const handledTargetRef = useRef<string>(""); const handledTargetRef = useRef<string>("");
const targetScrollTimersRef = useRef<number[]>([]); const targetScrollTimersRef = useRef<number[]>([]);
const targetScrollFrameRef = useRef<number | null>(null);
const clearTargetScrollTimers = () => { const clearTargetScrollTimers = () => {
for (const timer of targetScrollTimersRef.current) { for (const timer of targetScrollTimersRef.current) {
window.clearTimeout(timer); window.clearTimeout(timer);
} }
targetScrollTimersRef.current = []; targetScrollTimersRef.current = [];
if (targetScrollFrameRef.current !== null) {
window.cancelAnimationFrame(targetScrollFrameRef.current);
targetScrollFrameRef.current = null;
}
}; };
useEffect(() => { useEffect(() => {
handledTargetRef.current = ""; handledTargetRef.current = "";
clearTargetScrollTimers(); clearTargetScrollTimers();
}, [targetPostId]); setIsAligningQueryTarget(Boolean(queryTargetPostId));
}, [queryTargetPostId, targetPostId]);
useEffect(() => clearTargetScrollTimers, []); useEffect(() => clearTargetScrollTimers, []);
useEffect(() => {
if (!isAligningQueryTarget) return;
const preventScroll = (event: Event) => event.preventDefault();
const preventScrollKeys = (event: KeyboardEvent) => {
if (
[
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
" ",
].includes(event.key)
) {
event.preventDefault();
}
};
const html = document.documentElement;
const previousOverscroll = html.style.overscrollBehavior;
html.style.overscrollBehavior = "none";
window.addEventListener("wheel", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("keydown", preventScrollKeys, { capture: true });
return () => {
html.style.overscrollBehavior = previousOverscroll;
window.removeEventListener("wheel", preventScroll, { capture: true });
window.removeEventListener("touchmove", preventScroll, { capture: true });
window.removeEventListener("keydown", preventScrollKeys, {
capture: true,
});
};
}, [isAligningQueryTarget]);
useEffect(() => { useEffect(() => {
if (!targetPostId || handledTargetRef.current === targetPostId) return; if (!targetPostId || handledTargetRef.current === targetPostId) return;
@@ -104,31 +153,70 @@ export function MessageStream({ scope }: MessageStreamProps) {
handledTargetRef.current = targetPostId; handledTargetRef.current = targetPostId;
clearTargetScrollTimers(); clearTargetScrollTimers();
const scrollToTarget = (behavior: ScrollBehavior = "auto") => { const targetScrollTop = () => {
const target = document.getElementById(`post-${targetPostId}`); const target = document.getElementById(`post-${targetPostId}`);
if (!target) return; if (!target) return null;
const filterBottom = const filterBottom =
filterBarRef.current?.getBoundingClientRect().bottom ?? 0; filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
const targetTop = target.getBoundingClientRect().top + window.scrollY; const targetTop = target.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ return Math.max(0, targetTop - filterBottom - 12);
top: Math.max(0, targetTop - filterBottom - 12), };
left: 0,
behavior, const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
}); const top = targetScrollTop();
if (top === null) return;
window.scrollTo({ top, left: 0, behavior });
};
const boundedSmoothScrollToTarget = () => {
const firstTarget = targetScrollTop();
if (firstTarget === null) return;
const start = window.scrollY;
const startedAt = performance.now();
const duration = 520;
const direction = firstTarget >= start ? 1 : -1;
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
const tick = (now: number) => {
const latestTarget = targetScrollTop();
if (latestTarget === null) return;
const progress = Math.min(1, (now - startedAt) / duration);
const ideal = start + (latestTarget - start) * easeOutCubic(progress);
const next =
direction >= 0
? Math.min(ideal, latestTarget)
: Math.max(ideal, latestTarget);
window.scrollTo({ top: next, left: 0, behavior: "auto" });
if (progress < 1) {
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
return;
}
scrollToTarget("auto");
setIsAligningQueryTarget(false);
targetScrollFrameRef.current = null;
};
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
}; };
const prefersReducedMotion = window.matchMedia( const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)", "(prefers-reduced-motion: reduce)",
).matches; ).matches;
// Query deep-links (`?post=<id>`) usually come from Home cards/list rows. if (queryTargetPostId) {
// Keep that navigation stable by jumping directly to the target instead // Query deep-links (`?post=<id>`) usually come from Home cards/list
// of first resetting to the top and then animating through the stream. // rows. Keep the premium motion, but drive it ourselves so scrolling is
// Legacy hash links can still use the visible smooth scroll. // bounded to the current target position and cannot visibly pass the
// target before snapping back. User-driven scroll is temporarily locked
// by the effect above; programmatic scroll remains allowed.
targetScrollTimersRef.current = [80].map((ms) =>
window.setTimeout(boundedSmoothScrollToTarget, ms),
);
} else {
window.requestAnimationFrame(() => window.requestAnimationFrame(() =>
scrollToTarget( scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
queryTargetPostId || prefersReducedMotion ? "auto" : "smooth",
),
); );
// Media above the target can finish loading after the first scroll and // Media above the target can finish loading after the first scroll and
@@ -137,6 +225,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) => targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
window.setTimeout(() => scrollToTarget("auto"), ms), window.setTimeout(() => scrollToTarget("auto"), ms),
); );
}
el.classList.add("ark-bubble-highlight"); el.classList.add("ark-bubble-highlight");
window.setTimeout( window.setTimeout(
@@ -148,6 +237,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
// Not loaded yet — keep paging until it appears or the stream is exhausted. // Not loaded yet — keep paging until it appears or the stream is exhausted.
if (hasMore && !isLoading) loadMore(); if (hasMore && !isLoading) loadMore();
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
}, [targetPostId, items, hasMore, isLoading, loadMore]); }, [targetPostId, items, hasMore, isLoading, loadMore]);
const updateParam = (key: string, value: string) => { const updateParam = (key: string, value: string) => {