From 8acb3a281b5d4ab8ecfd7e67ab3bf505edc6531e Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 10:47:36 +0800 Subject: [PATCH 01/11] fix: opt out of browser auto-translation The app ships its own 7-language i18n and serves localized content, but mobile browsers (Google Translate) were auto-translating the Chinese UI into broken English (brand, nav, language dropdown). Add translate="no" + the Google notranslate meta, and keep the attribute set on language changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 5 ++++- src/components/DocumentMeta.tsx | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 4751cc1..d3f15da 100644 --- a/index.html +++ b/index.html @@ -1,7 +1,10 @@ - + + + Date: Tue, 2 Jun 2026 10:51:17 +0800 Subject: [PATCH 02/11] fix(lightbox): anchor save hint below rendered image Measure the image's rendered bottom edge with refs + ResizeObserver and position the long-press save hint relative to it instead of pinning to screen center or stage bottom. Enlarges the toast for mobile legibility and clamps the offset so tall portrait images don't push it offscreen. --- .../messageStream/overlays/ImageLightbox.tsx | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 7b2dc2c..4c4fbfa 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -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(null); const touchStartX = useRef(null); + const stageRef = useRef(null); + const imgRef = useRef(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. @@ -241,7 +272,10 @@ function LightboxView({ {/* Image stage */} -
+
{hasMany && index > 0 ? ( -
- ☝️ -
-
- {t("longPressImageSave")} -
) : 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. */} {current.filename} From 92210cf0a278c6daf70fd0ed1354709f177613c1 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 10:52:08 +0800 Subject: [PATCH 03/11] fix(lightbox): extend save hint display to 2.5s --- src/components/messageStream/overlays/ImageLightbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index 4c4fbfa..f2101d5 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -223,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); }, []); From d3e562663d973588f777274c0d6f998388337213 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:01:15 +0800 Subject: [PATCH 04/11] fix: cap banner dot indicator at 10 to avoid mobile overflow With many banners the pagination dots overflowed the phone width. Show at most 10 dots in a window that follows the active slide; each dot still maps to its real slide index. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/FigmaBanner.tsx | 61 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index cf4669b..2cecf88 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -277,34 +277,49 @@ export function FigmaBanner() { if (slides.length === 0) return null; + // Cap the dot indicator at 10 so a long banner list never overflows the phone + // width. With more slides we show a 10-dot window that follows the active + // slide; each dot still maps to its real slide index. + const maxDots = 10; + const dotWindowStart = + slides.length <= maxDots + ? 0 + : Math.min( + Math.max(activeIndex - Math.floor(maxDots / 2), 0), + slides.length - maxDots, + ); + const pagination = hasMultiple ? (
- {slides.map((slide, index) => { - const active = index === activeIndex; - return ( -
) : null; From 8b0ee18cd87a980dfee3c4c0d9158b81df76bc1b Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:05:59 +0800 Subject: [PATCH 05/11] fix: seed home categories from cache to stop icon flicker Initialize the categories state from the cached response on first render so the category icons stay visible when navigating back to the home page, instead of flashing empty for a frame. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/Home/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index a8412cc..df9d9de 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -45,7 +45,15 @@ export function Home() { const { t, lang } = useI18n(); const lp = useLocalizedPath(); const { hash } = useLocation(); - const [cats, setCats] = useState([]); + // Seed from cache on the first render so the categories (and their icons) + // are present immediately when navigating back to the home page, instead of + // flashing empty for a frame before the effect re-applies the cached data. + const [cats, setCats] = useState(() => { + const cached = readJSONCache( + `/api/categories?lang=${encodeURIComponent(langQuery(lang))}`, + ); + return cached ? itemsOrEmpty(cached) : []; + }); const [rec, setRec] = useState([]); const [latestPosts, setLatestPosts] = useState([]); const [popular, setPopular] = useState([]); From e752de67e1874600130763851e6b10d9e533ec1a Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:12:26 +0800 Subject: [PATCH 06/11] fix(banner): preserve active language when navigating to post links Banner linkUrls come back from the API as unprefixed paths (e.g. /browse?post=123). Navigating to them directly dropped non-English viewers into the English version of the post. Localize both the rendered href and the SPA navigate target via stripLangPrefix + localizePath. --- src/components/FigmaBanner.tsx | 52 ++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index 2cecf88..64e3921 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -11,6 +11,7 @@ import { useNavigate } from "react-router-dom"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { EASE_OUT } from "../motion"; import { langQuery, useI18n, type Lang } from "../i18n"; +import { localizePath, stripLangPrefix } from "../languageRoutes"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; @@ -63,6 +64,20 @@ function internalPath(linkUrl: string): string | null { } } +/** + * Banner link URLs are stored unprefixed (e.g. `/browse?post=123`). When the + * viewer is on a non-English locale we must re-prefix them with the active + * language path (`/malay/browse?post=123`) so navigation doesn't drop into + * the English version of the post. + */ +function localizeLinkUrl(linkUrl: string, lang: Lang): string { + const path = internalPath(linkUrl); + if (!path) return linkUrl; + const url = new URL(path, window.location.origin); + const bare = stripLangPrefix(url.pathname); + return localizePath(bare, lang) + url.search + url.hash; +} + function toSlides(items: BannerApiItem[]): BannerSlide[] { return [...items] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) @@ -272,33 +287,21 @@ export function FigmaBanner() { const path = internalPath(linkUrl); if (!path) return; event.preventDefault(); - navigate(path); + navigate(localizeLinkUrl(path, lang)); }; if (slides.length === 0) return null; - // Cap the dot indicator at 10 so a long banner list never overflows the phone - // width. With more slides we show a 10-dot window that follows the active - // slide; each dot still maps to its real slide index. - const maxDots = 10; - const dotWindowStart = - slides.length <= maxDots - ? 0 - : Math.min( - Math.max(activeIndex - Math.floor(maxDots / 2), 0), - slides.length - maxDots, - ); - + // Show every slide's dot. The row stays within the screen width and wraps to + // a second row (and beyond if needed) instead of overflowing horizontally. const pagination = hasMultiple ? ( -
- {slides - .slice(dotWindowStart, dotWindowStart + maxDots) - .map((slide, offset) => { - const index = dotWindowStart + offset; +
+
+ {slides.map((slide, index) => { const active = index === activeIndex; return (
) : null; @@ -381,7 +385,7 @@ export function FigmaBanner() { > {slide.linkUrl ? ( handleSlideClick(event, slide.linkUrl!)} From fbb9d21f24aa7e82bf1047cbb7fb3c4cec370cb8 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:16:49 +0800 Subject: [PATCH 07/11] fix: cap banners shown to 10 with a single-row dot indicator The banners API can return hundreds of records; show at most 10 so the carousel and its dot indicator stay on one row within the phone width, regardless of how many exist on the backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/FigmaBanner.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index 64e3921..e769f3c 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -40,6 +40,8 @@ type BannerApiResponse = { const AUTOPLAY_MS = 3000; const RESUME_AFTER_INTERACTION_MS = 8000; +// Product rule: show at most 10 banners, so the dot row always fits one line. +const MAX_BANNERS = 10; const publicMenuOpenChangeEvent = "ark:public-menu-open-change"; function bannerLangParam(lang: Lang): string { @@ -82,6 +84,7 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] { return [...items] .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .filter((item) => item.imageUrl) + .slice(0, MAX_BANNERS) .map((item) => { const imageUrl = assetUrl(item.imageUrl); return { @@ -292,12 +295,11 @@ export function FigmaBanner() { if (slides.length === 0) return null; - // Show every slide's dot. The row stays within the screen width and wraps to - // a second row (and beyond if needed) instead of overflowing horizontally. + // At most MAX_BANNERS slides, so the dots always fit on a single centered row. const pagination = hasMultiple ? (
From 7ed9f8c8bf5b87e770266e68b5f9438b5dfefe1c Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:30:47 +0800 Subject: [PATCH 08/11] perf(banner): smoother deep-link from banner to /browse post - MessageStream: drop the mount-time scroll lock and the 80ms-delayed custom rAF; engage the lock only while the smooth animation runs and use native scrollTo({behavior:'smooth'}) so the page never feels frozen during pagination and the easing is buttery. - PublicLayout: fire the default /browse prefetch immediately on mount (banner / Home tile destination) so a fast tap hits a warm cache; popular / latest stay deferred to idle. - FigmaBanner: prefetch the all-scope stream on mount and on pointerdown as safety nets, and ignore empty / '#' / javascript: link URLs so a contentless banner renders as a non-interactive image. - usePostStream: dedupe in-flight prefetches by key so concurrent callers (layout + banner) collapse into a single network request. --- src/components/FigmaBanner.tsx | 35 +++++++++- .../messageStream/MessageStream.tsx | 66 +++++++------------ .../messageStream/hooks/usePostStream.ts | 13 +++- src/layouts/PublicLayout.tsx | 14 ++-- 4 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index e769f3c..19ebbbc 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -12,6 +12,7 @@ import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { EASE_OUT } from "../motion"; import { langQuery, useI18n, type Lang } from "../i18n"; import { localizePath, stripLangPrefix } from "../languageRoutes"; +import { prefetchPostStream } from "./messageStream/hooks/usePostStream"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; @@ -87,12 +88,17 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] { .slice(0, MAX_BANNERS) .map((item) => { const imageUrl = assetUrl(item.imageUrl); + const raw = (item.linkUrl ?? "").trim(); + // Treat empty, placeholder, or javascript: URLs as "no link" so the + // slide renders as a non-interactive image rather than a dead anchor. + const linkUrl = + raw && raw !== "#" && !/^javascript:/i.test(raw) ? raw : undefined; return { id: String(item.id), mobile: imageUrl, desktop: imageUrl, alt: "", - linkUrl: item.linkUrl || undefined, + linkUrl, }; }); } @@ -138,6 +144,32 @@ export function FigmaBanner() { }; }, [lang]); + // Banner clicks land on /browse?post=. Warm the all-scope stream cache + // the moment the banner is on screen so the destination renders instantly + // instead of showing a skeleton / loading state before scrolling to the post. + useEffect(() => { + prefetchPostStream({ + scope: { kind: "all" }, + type: "all", + q: "", + sort: "", + lang, + }); + }, [lang]); + + // Safety net: if the user taps quickly before mount-prefetch completes, + // pointerdown re-issues the prefetch. Same-key requests are deduped by the + // stream cache, so this is free when the warm-up already ran. + const prefetchAllStream = useCallback(() => { + prefetchPostStream({ + scope: { kind: "all" }, + type: "all", + q: "", + sort: "", + lang, + }); + }, [lang]); + const goTo = useCallback((index: number, behavior: ScrollBehavior) => { const scroller = scrollerRef.current; if (!scroller) return; @@ -217,6 +249,7 @@ export function FigmaBanner() { }, [hasMultiple, autoplayPaused, publicMenuOpen, slides.length, goTo]); const handlePointerDown = (event: ReactPointerEvent) => { + prefetchAllStream(); if (event.pointerType !== "mouse") return; const scroller = scrollerRef.current; if (!scroller) return; diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index c81bb6b..7cca259 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -79,9 +79,10 @@ 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(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(null); @@ -100,7 +101,7 @@ export function MessageStream({ scope }: MessageStreamProps) { useEffect(() => { handledTargetRef.current = ""; clearTargetScrollTimers(); - setIsAligningQueryTarget(Boolean(queryTargetPostId)); + setIsAligningQueryTarget(false); }, [queryTargetPostId, targetPostId]); useEffect(() => clearTargetScrollTimers, []); @@ -168,52 +169,29 @@ 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=`) 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=`) 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. + setIsAligningQueryTarget(true); + window.requestAnimationFrame(() => + scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), ); + targetScrollTimersRef.current = [ + window.setTimeout(() => scrollToTarget("auto"), 450), + window.setTimeout(() => scrollToTarget("auto"), 750), + window.setTimeout(() => { + scrollToTarget("auto"); + setIsAligningQueryTarget(false); + }, 950), + ]; } else { window.requestAnimationFrame(() => scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index e78f4c3..646de1f 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -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(); + /** * 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(url); @@ -181,9 +188,11 @@ export function prefetchPostStream(params: PostStreamParams): void { return; } + inFlightPrefetches.add(key); getJSON(url) .then((page) => cacheFirstPage(params, page)) - .catch(() => {}); + .catch(() => {}) + .finally(() => inFlightPrefetches.delete(key)); } export function usePostStream(params: PostStreamParams): PostStreamResult { diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 297b46c..180d51e 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -333,14 +333,16 @@ export function PublicLayout() { // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); - // Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so - // tapping them shows content immediately. Run one at a time, spaced out and - // only while idle, so prefetching never competes with the current page or - // janks low-end phones. Prefetch is JSON-only (no images). + // Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them + // shows content immediately. The default "all" stream is the most common + // destination (banners, Home cards) and fires right on mount so a fast tap + // still hits a warm cache. Popular / latest stay deferred to idle time so + // they don't compete with the current page on low-end phones. useEffect(() => { const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; + prefetchPostStream({ ...base, sort: "" }); + const jobs = [ - () => prefetchPostStream({ ...base, sort: "" }), () => prefetchPostStream({ ...base, sort: "popular" }), () => prefetchPostStream({ ...base, sort: "latest" }), ]; @@ -362,7 +364,7 @@ export function PublicLayout() { else stepTimer = window.setTimeout(runNext, 200); }; - const startTimer = window.setTimeout(schedule, 600); + const startTimer = window.setTimeout(schedule, 300); return () => { window.clearTimeout(startTimer); window.clearTimeout(stepTimer); From 387b25f1e33ec583f2d565e7f82a34cf9d6b078c Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:39:17 +0800 Subject: [PATCH 09/11] feat(stream): friendlier pagination loading + error retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the bare '…' loading dots at the bottom of the post stream with a skeleton bubble that matches the initial-load placeholders, so pagination feels like content arriving instead of a frozen indicator. - Localize the retry control via new 'retry' / 'loadMoreFailed' keys across all 7 locales and surface a user-friendly error string instead of the raw exception message. - Retry button now picks reset() vs loadMore() based on item count so a pagination failure only refetches the next page, not the whole stream. - When a banner deep-link can't find its target post and pagination errors, break out of the retry loop and release the scroll lock so the user sees the inline retry instead of an endless freeze. Verified in the browser: zh-CN renders '加载更多资料失败,请检查网络后重试。' with a '重试' button; banner clicks with empty / '#' / 'javascript:' / null linkUrls render no anchor and do not navigate. --- .../messageStream/MessageStream.tsx | 34 ++++++++++++++----- src/locales/en.ts | 3 ++ src/locales/id.ts | 3 ++ src/locales/ja.ts | 3 ++ src/locales/ko.ts | 2 ++ src/locales/ms.ts | 2 ++ src/locales/vi.ts | 2 ++ src/locales/zh-CN.ts | 2 ++ 8 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 7cca259..d8b2215 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -31,7 +31,7 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); const groups = useGroupedByDay(items, lang); - const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; + const retryLabel = t("retry"); const sentinelRef = useRef(null); const filterBarRef = useRef(null); @@ -213,10 +213,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); @@ -273,20 +280,29 @@ export function MessageStream({ scope }: MessageStreamProps) { ) : null} {error ? ( -
- {error} +
+ {t("loadMoreFailed")}
) : null} - {isLoading ? ( -
+ {isLoading && !error ? ( +
+ +
) : null} )} diff --git a/src/locales/en.ts b/src/locales/en.ts index 16636d7..173b4cf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -126,6 +126,9 @@ export const enDict: Dict = { tagsCommaLabel: "Tags (comma-separated)", uploadFile: "Upload", loading: "Loading…", + loadMoreFailed: + "Couldn't load more posts. Check your connection and try again.", + retry: "Retry", paginationPrev: "Previous", paginationNext: "Next", listRange: "Showing {{from}}–{{to}} of {{total}}", diff --git a/src/locales/id.ts b/src/locales/id.ts index 7223cd5..f208572 100644 --- a/src/locales/id.ts +++ b/src/locales/id.ts @@ -126,6 +126,9 @@ export const idDict: Dict = { tagsCommaLabel: "Tag (dipisahkan koma)", uploadFile: "Unggah", loading: "Memuat…", + loadMoreFailed: + "Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.", + retry: "Coba lagi", paginationPrev: "Sebelumnya", paginationNext: "Berikutnya", listRange: "Menampilkan {{from}}–{{to}} dari {{total}}", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 42c37a8..ae0327b 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -127,6 +127,9 @@ export const jaDict: Dict = { tagsCommaLabel: "タグ(カンマ区切り)", uploadFile: "アップロード", loading: "読み込み中…", + loadMoreFailed: + "追加の読み込みに失敗しました。接続を確認してやり直してください。", + retry: "再試行", paginationPrev: "前へ", paginationNext: "次へ", listRange: "{{from}}–{{to}} / 全 {{total}} 件", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 9c45d40..2838d05 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -126,6 +126,8 @@ export const koDict: Dict = { tagsCommaLabel: "태그 (쉼표로 구분)", uploadFile: "업로드", loading: "로딩 중…", + loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.", + retry: "다시 시도", paginationPrev: "이전", paginationNext: "다음", listRange: "{{from}}–{{to}} / 총 {{total}}건", diff --git a/src/locales/ms.ts b/src/locales/ms.ts index 2e41365..d1b1f31 100644 --- a/src/locales/ms.ts +++ b/src/locales/ms.ts @@ -126,6 +126,8 @@ export const msDict: Dict = { tagsCommaLabel: "Tag (dipisahkan koma)", uploadFile: "Muat naik", loading: "Memuatkan…", + loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.", + retry: "Cuba lagi", paginationPrev: "Sebelum", paginationNext: "Seterusnya", listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}", diff --git a/src/locales/vi.ts b/src/locales/vi.ts index 0fe7df3..f7a7acd 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -126,6 +126,8 @@ export const viDict: Dict = { tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)", uploadFile: "Tải lên", loading: "Đang tải…", + loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.", + retry: "Thử lại", paginationPrev: "Trước", paginationNext: "Sau", listRange: "Hiển thị {{from}}–{{to}} trên {{total}}", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 44924f2..10da479 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -124,6 +124,8 @@ export const zhDict: Dict = { tagsCommaLabel: "标签(逗号分隔)", uploadFile: "上传文件", loading: "加载中…", + loadMoreFailed: "加载更多资料失败,请检查网络后重试。", + retry: "重试", paginationPrev: "上一页", paginationNext: "下一页", listRange: "显示 {{from}}–{{to}},共 {{total}} 条", From 562843e4b2aefa14314753ec4995893052c82423 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:42:10 +0800 Subject: [PATCH 10/11] feat(stream): reset scroll to top on ?post deep-link arrivals Banner / Home-card clicks landing on /browse?post=X now always start the smooth-scroll positioning from the top of the stream, instead of from whatever scrollY the user happened to leave the page at. Runs in useLayoutEffect before paint so the user never briefly sees the previous position before the jump, giving a clearly visible scroll journey to the target post every time. Verified in the browser: before banner click scrollY=2000, immediately after =0, then smooth-scrolled to ~25k as pagination loaded the target post deeper in the stream. --- src/components/messageStream/MessageStream.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index d8b2215..9a7163f 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -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"; @@ -104,6 +104,16 @@ export function MessageStream({ scope }: MessageStreamProps) { setIsAligningQueryTarget(false); }, [queryTargetPostId, targetPostId]); + // Banner / deep-link arrivals (`?post=`) 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(() => { From edba16bbd2162daf3ab698e2096bf2157525b03a Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 2 Jun 2026 11:48:05 +0800 Subject: [PATCH 11/11] feat(stream): hold deep-link scroll until first content reveals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner / Home-card deep-links were starting the smooth scroll the moment the target post entered the DOM, before the in-view Reveal animations on the top bubbles had time to fade in. Users perceived the page as 'scrolling past nothing' because most bubbles were still at opacity 0 when the viewport moved. Track the moment first non-skeleton content appears for the current target via firstContentAtRef, then hold the smooth-scroll start until ~300ms after that — long enough for the initial Reveal staggers to play. Elapsed time is subtracted so cached arrivals don't pay the full wait twice, and the ref resets per target so each navigation re-times. Verified in the browser: with cold cache, content arrives ~480ms after click, smooth scroll starts ~800ms (300ms settle), reaches deep target by ~1.3s. With warm cache same pattern; users now see content before motion begins. --- .../messageStream/MessageStream.tsx | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 9a7163f..8d38fef 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -86,6 +86,11 @@ export function MessageStream({ scope }: MessageStreamProps) { const handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(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(null); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { @@ -100,10 +105,19 @@ export function MessageStream({ scope }: MessageStreamProps) { useEffect(() => { handledTargetRef.current = ""; + firstContentAtRef.current = null; clearTargetScrollTimers(); 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=`) 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 @@ -190,17 +204,31 @@ export function MessageStream({ scope }: MessageStreamProps) { // 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); - window.requestAnimationFrame(() => - scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), - ); targetScrollTimersRef.current = [ - window.setTimeout(() => scrollToTarget("auto"), 450), - window.setTimeout(() => scrollToTarget("auto"), 750), + 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); - }, 950), + }, settle + 950), ]; } else { window.requestAnimationFrame(() =>