diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 7e4f071..89f5c4d 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -34,6 +34,7 @@ export function MessageStream({ scope }: MessageStreamProps) { const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; const sentinelRef = useRef(null); + const filterBarRef = useRef(null); const hasMoreRef = useRef(hasMore); const isLoadingRef = useRef(isLoading); useEffect(() => { @@ -70,33 +71,77 @@ export function MessageStream({ scope }: MessageStreamProps) { return () => io.disconnect(); }, [loadMore]); - // When arriving with a `#post-` hash (e.g. from a recommended card), + // When arriving with a `?post=` query (or legacy `#post-` hash), // scroll to that bubble — loading more pages until it shows up — then give // it a brief highlight so the user can see where they landed. - const targetPostId = hash.startsWith("#post-") + const queryTargetPostId = sp.get("post") || ""; + const hashTargetPostId = hash.startsWith("#post-") ? hash.slice("#post-".length) : ""; + const targetPostId = queryTargetPostId || hashTargetPostId; const handledTargetRef = useRef(""); + const targetScrollTimersRef = useRef([]); + + const clearTargetScrollTimers = () => { + for (const timer of targetScrollTimersRef.current) { + window.clearTimeout(timer); + } + targetScrollTimersRef.current = []; + }; useEffect(() => { handledTargetRef.current = ""; + clearTargetScrollTimers(); }, [targetPostId]); + useEffect(() => clearTargetScrollTimers, []); + useEffect(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; const el = document.getElementById(`post-${targetPostId}`); if (el) { handledTargetRef.current = targetPostId; - const frame = window.requestAnimationFrame(() => { - el.scrollIntoView({ block: "start", behavior: "smooth" }); - el.classList.add("ark-bubble-highlight"); - window.setTimeout( - () => el.classList.remove("ark-bubble-highlight"), - 2000, - ); - }); - return () => window.cancelAnimationFrame(frame); + clearTargetScrollTimers(); + + const scrollToTarget = (behavior: ScrollBehavior = "auto") => { + const target = document.getElementById(`post-${targetPostId}`); + if (!target) return; + const filterBottom = + filterBarRef.current?.getBoundingClientRect().bottom ?? 0; + const targetTop = target.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ + top: Math.max(0, targetTop - filterBottom - 12), + left: 0, + behavior, + }); + }; + + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + + // Show a deliberate "from top to target" transition when opening a card + // from Home. The later auto re-alignments are intentionally delayed so + // they don't interrupt the visible smooth scroll animation. + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + window.requestAnimationFrame(() => + scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), + ); + + // Media above the target can finish loading after the first scroll and + // shift the target downward. Re-align after the smooth animation while + // stream image/video heights settle, so the final resting point is exact. + targetScrollTimersRef.current = [900, 1400, 2000].map((ms) => + window.setTimeout(() => scrollToTarget("auto"), ms), + ); + + el.classList.add("ark-bubble-highlight"); + window.setTimeout( + () => el.classList.remove("ark-bubble-highlight"), + 2000, + ); + return; } // Not loaded yet — keep paging until it appears or the stream is exhausted. @@ -116,7 +161,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
{/* Filters stay pinned below the global header (which shows the page name) so users can switch filters while scrolling. */} -
+
updateParam("type", v)} />
diff --git a/src/pages/PostRedirect/index.tsx b/src/pages/PostRedirect/index.tsx index 3a97e4c..99f9a7f 100644 --- a/src/pages/PostRedirect/index.tsx +++ b/src/pages/PostRedirect/index.tsx @@ -19,9 +19,12 @@ export function PostRedirect() { if (POST_STREAM_USES_MOCK) { const post = MOCK_POSTS.find((p) => p.id === id); - navigate(post ? `/browse#post-${post.id}` : "/browse", { - replace: true, - }); + navigate( + post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse", + { + replace: true, + }, + ); return; } @@ -29,7 +32,7 @@ export function PostRedirect() { `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, ) .then((post) => { - navigate(`/browse#post-${post.id}`, { + navigate(`/browse?post=${encodeURIComponent(post.id)}`, { replace: true, }); })