feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
Recommended cards already routed to /browse#post-<id>, but the stream had no logic to scroll to the target bubble — and the post might not be paged in yet. MessageStream now resolves the #post-<id> hash, auto-loads more pages until the bubble renders, scrolls to it, and gives it a brief gold highlight. Bubbles get scroll-mt so they clear the sticky header. Also adds a global floating back-to-top button (BackToTop) mounted in PublicLayout, shown after scrolling past 400px. Bundles related staging UI work already present in the working tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { useI18n } from "../../i18n";
|
||||
import type { PostScope } from "../../types/post";
|
||||
import { Reveal } from "../../motion";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { FilterChips } from "./FilterChips";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||
@@ -15,6 +17,7 @@ export type MessageStreamProps = {
|
||||
export function MessageStream({ scope }: MessageStreamProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const { hash } = useLocation();
|
||||
|
||||
const type = sp.get("type") || "all";
|
||||
const q = (sp.get("q") || "").trim();
|
||||
@@ -65,6 +68,38 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
return () => io.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
// When arriving with a `#post-<id>` hash (e.g. from a recommended card),
|
||||
// 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-") ? hash.slice("#post-".length) : "";
|
||||
const handledTargetRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
handledTargetRef.current = "";
|
||||
}, [targetPostId]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
||||
if (hasMore && !isLoading) loadMore();
|
||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
||||
|
||||
const updateParam = (key: string, value: string) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (!value || value === "all") n.delete(key);
|
||||
@@ -72,41 +107,64 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
setSp(n, { replace: true });
|
||||
};
|
||||
|
||||
const isInitialLoad = isLoading && items.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||
|
||||
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-3">
|
||||
{group.items.map((post) => (
|
||||
<MessageBubble key={post.id} post={post} />
|
||||
{isInitialLoad ? (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
|
||||
>
|
||||
<Skeleton
|
||||
className={`rounded-2xl ${
|
||||
i % 3 === 0 ? "h-[220px]" : "h-[80px]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-3">
|
||||
{group.items.map((post, index) => (
|
||||
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
|
||||
<MessageBubble post={post} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isLoading && !error && items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-400">
|
||||
{t("noResults")}
|
||||
</p>
|
||||
) : 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>
|
||||
<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"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : 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>
|
||||
<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"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-neutral-500">…</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden className="h-1" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user