Merge remote-tracking branch 'origin/main' into terry-wallet-login

This commit is contained in:
TerryM
2026-06-02 12:08:45 +08:00
15 changed files with 271 additions and 107 deletions

View File

@@ -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}
</>
)}

View File

@@ -164,15 +164,22 @@ function cacheFirstPage(
});
}
// In-flight prefetch keys so concurrent callers (PublicLayout + FigmaBanner +
// hover prefetch, etc.) collapse into a single network request instead of
// firing one per call site before the first response lands.
const inFlightPrefetches = new Set<string>();
/**
* Warm the cache for a stream view before the user navigates to it, so opening
* the page shows content immediately instead of starting to load on arrival.
* No-op for the mock backend or when the first page is already cached.
* No-op for the mock backend, when the first page is already cached, or when
* an identical prefetch is already in flight.
*/
export function prefetchPostStream(params: PostStreamParams): void {
if (USE_MOCK) return;
const key = streamKey(params);
if (readStreamCache(key)) return;
if (inFlightPrefetches.has(key)) return;
const url = buildRealUrl(params);
const cachedPage = readJSONCache<PostListResponse>(url);
@@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void {
return;
}
inFlightPrefetches.add(key);
getJSON<PostListResponse>(url)
.then((page) => cacheFirstPage(params, page))
.catch(() => {});
.catch(() => {})
.finally(() => inFlightPrefetches.delete(key));
}
export function usePostStream(params: PostStreamParams): PostStreamResult {

View File

@@ -152,7 +152,38 @@ function LightboxView({
const [index, setIndex] = useState(startIndex);
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
const [showSaveHint, setShowSaveHint] = useState(true);
const [hintTop, setHintTop] = useState<number | null>(null);
const touchStartX = useRef<number | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const measureHintPosition = useCallback(() => {
const stage = stageRef.current;
const img = imgRef.current;
if (!stage || !img) return;
const stageRect = stage.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
const gap = 12;
const safeBottomReserve = 80;
const desired = imgRect.bottom - stageRect.top + gap;
const maxTop = stageRect.height - safeBottomReserve;
setHintTop(Math.max(0, Math.min(desired, maxTop)));
}, []);
useEffect(() => {
if (!showSaveHint) return;
measureHintPosition();
const img = imgRef.current;
const stage = stageRef.current;
const ro = new ResizeObserver(() => measureHintPosition());
if (img) ro.observe(img);
if (stage) ro.observe(stage);
window.addEventListener("resize", measureHintPosition);
return () => {
ro.disconnect();
window.removeEventListener("resize", measureHintPosition);
};
}, [measureHintPosition, showSaveHint, index]);
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
// keys should all behave like a linear gallery, not a carousel.
@@ -192,7 +223,7 @@ function LightboxView({
const current = images[index];
useEffect(() => {
const timer = window.setTimeout(() => setShowSaveHint(false), 2000);
const timer = window.setTimeout(() => setShowSaveHint(false), 2500);
return () => window.clearTimeout(timer);
}, []);
@@ -241,7 +272,10 @@ function LightboxView({
</div>
{/* Image stage */}
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
<div
ref={stageRef}
className="relative flex min-h-0 w-full flex-1 items-center justify-center"
>
{hasMany && index > 0 ? (
<button
type="button"
@@ -270,25 +304,26 @@ function LightboxView({
) : null}
{showSaveHint ? (
<div className="pointer-events-none fixed inset-x-0 top-[58%] z-30 flex -translate-y-1/2 justify-center px-4">
<div
className="pointer-events-none absolute inset-x-0 z-30 flex justify-center px-4"
style={hintTop != null ? { top: hintTop } : { bottom: 16 }}
>
<div
className="pointer-events-auto relative flex w-44 max-w-[calc(100vw-2rem)] flex-col items-center gap-1.5 rounded-xl bg-black/70 px-4 py-3 text-center text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
className="pointer-events-auto relative flex max-w-[calc(100vw-2rem)] items-center gap-3 rounded-full bg-black/75 px-5 py-3 text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<span className="text-2xl leading-none animate-bounce"></span>
<span className="text-sm font-semibold leading-5">
{t("longPressImageSave")}
</span>
<button
type="button"
onClick={() => setShowSaveHint(false)}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
aria-label="Close hint"
>
<X className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</button>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-2xl animate-bounce">
</div>
<div className="max-w-full break-words text-xs font-medium leading-4">
{t("longPressImageSave")}
</div>
</div>
</div>
) : null}
@@ -315,8 +350,10 @@ function LightboxView({
{/* No select-none / touch-callout:none here so iOS Safari's native
long-press menu ("Save in Photos") works on the full-size image. */}
<img
ref={imgRef}
src={current.url}
alt={current.filename}
onLoad={measureHintPosition}
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
/>
</div>