Merge remote-tracking branch 'origin/main' into terry-wallet-login
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { useI18n } from "../../i18n";
|
||||
@@ -33,7 +33,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
usePostStream(params);
|
||||
const { ensureFavoriteIds } = useFavorites();
|
||||
const groups = useGroupedByDay(items, lang);
|
||||
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
||||
const retryLabel = t("retry");
|
||||
|
||||
useEffect(() => {
|
||||
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
||||
@@ -85,12 +85,18 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
? hash.slice("#post-".length)
|
||||
: "";
|
||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
|
||||
Boolean(queryTargetPostId),
|
||||
);
|
||||
// Lock only engages while we are actively running the smooth-scroll animation
|
||||
// — not during the wait/pagination phase — so the page never feels frozen
|
||||
// before the bubble exists.
|
||||
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(false);
|
||||
const handledTargetRef = useRef<string>("");
|
||||
const targetScrollTimersRef = useRef<number[]>([]);
|
||||
const targetScrollFrameRef = useRef<number | null>(null);
|
||||
// Timestamp (perf clock) when the first batch of real items became visible
|
||||
// for this target. Used to delay the deep-link smooth scroll until the
|
||||
// initial Reveal in-view animations have had a moment to play, so the user
|
||||
// sees content before the page starts moving.
|
||||
const firstContentAtRef = useRef<number | null>(null);
|
||||
|
||||
const clearTargetScrollTimers = () => {
|
||||
for (const timer of targetScrollTimersRef.current) {
|
||||
@@ -105,10 +111,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
|
||||
useEffect(() => {
|
||||
handledTargetRef.current = "";
|
||||
firstContentAtRef.current = null;
|
||||
clearTargetScrollTimers();
|
||||
setIsAligningQueryTarget(Boolean(queryTargetPostId));
|
||||
setIsAligningQueryTarget(false);
|
||||
}, [queryTargetPostId, targetPostId]);
|
||||
|
||||
// Mark when first real content becomes visible (skeletons gone, items in).
|
||||
// Captured per-target via the reset above so a later navigation re-measures.
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !isLoading && firstContentAtRef.current === null) {
|
||||
firstContentAtRef.current = performance.now();
|
||||
}
|
||||
}, [items.length, isLoading]);
|
||||
|
||||
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
|
||||
// smooth-scroll positioning from the top of the stream, so the user sees a
|
||||
// clear, satisfying journey to the target post instead of a tiny nudge when
|
||||
// they happen to revisit the page mid-scroll. Run before paint so the user
|
||||
// never briefly sees the previous scrollY before the jump.
|
||||
useLayoutEffect(() => {
|
||||
if (!queryTargetPostId) return;
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, [queryTargetPostId]);
|
||||
|
||||
useEffect(() => clearTargetScrollTimers, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,52 +199,43 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
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(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
if (queryTargetPostId) {
|
||||
// Query deep-links (`?post=<id>`) usually come from Home cards/list
|
||||
// rows. Keep the premium motion, but drive it ourselves so scrolling is
|
||||
// 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),
|
||||
);
|
||||
// Query deep-links (`?post=<id>`) come from banner / Home card clicks.
|
||||
// Use the browser-native smooth scroll (runs on the compositor, much
|
||||
// smoother than a hand-rolled rAF) and lock user scroll only for the
|
||||
// duration of the animation so the page never feels frozen. Quiet
|
||||
// re-alignments run inside the lock window to absorb late image-shift
|
||||
// above the target; nothing nudges the user after the lock releases.
|
||||
//
|
||||
// Hold the animation until the page has had ~300ms after the first
|
||||
// content paint, so the in-view Reveal staggers on the top bubbles
|
||||
// have time to fade in. Otherwise the smooth scroll starts before the
|
||||
// user can see anything, and the journey reads as "blank flash".
|
||||
const REVEAL_SETTLE_MS = 300;
|
||||
const elapsed =
|
||||
firstContentAtRef.current !== null
|
||||
? performance.now() - firstContentAtRef.current
|
||||
: 0;
|
||||
const settle = Math.max(0, REVEAL_SETTLE_MS - elapsed);
|
||||
|
||||
setIsAligningQueryTarget(true);
|
||||
targetScrollTimersRef.current = [
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() =>
|
||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||
);
|
||||
}, settle),
|
||||
window.setTimeout(() => scrollToTarget("auto"), settle + 450),
|
||||
window.setTimeout(() => scrollToTarget("auto"), settle + 750),
|
||||
window.setTimeout(() => {
|
||||
scrollToTarget("auto");
|
||||
setIsAligningQueryTarget(false);
|
||||
}, settle + 950),
|
||||
];
|
||||
} else {
|
||||
window.requestAnimationFrame(() =>
|
||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||
@@ -241,10 +257,17 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 the previous loadMore errored, stop the loop so the user
|
||||
// sees the inline retry button instead of an endless retry cycle, and
|
||||
// release the scroll lock so they can interact with the page.
|
||||
if (error) {
|
||||
setIsAligningQueryTarget(false);
|
||||
return;
|
||||
}
|
||||
if (hasMore && !isLoading) loadMore();
|
||||
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
||||
}, [targetPostId, items, hasMore, isLoading, error, loadMore]);
|
||||
|
||||
const updateParam = (key: string, value: string) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
@@ -301,20 +324,29 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
<span className="break-all">{error}</span>
|
||||
<div
|
||||
role="alert"
|
||||
className="my-4 flex flex-col gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span className="break-words">{t("loadMoreFailed")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
|
||||
onClick={() => (items.length === 0 ? reset() : loadMore())}
|
||||
className="shrink-0 self-start rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500 sm:self-auto"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
{isLoading && !error ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
|
||||
>
|
||||
<Skeleton className="h-[80px] rounded-2xl" />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user