Files
Arkie-Library-Frontend/src/components/messageStream/MessageStream.tsx

325 lines
12 KiB
TypeScript
Raw Normal View History

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
2026-05-27 11:33:48 +08:00
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";
import { usePostStream } from "./hooks/usePostStream";
export type MessageStreamProps = {
scope: PostScope;
};
export function MessageStream({ scope }: MessageStreamProps) {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const { hash } = useLocation();
const type = sp.get("type") || "all";
2026-05-27 11:33:48 +08:00
const q = (sp.get("q") || "").trim();
const sort = sp.get("sort") || "";
2026-05-27 11:33:48 +08:00
const params = useMemo(
2026-05-28 16:49:30 +08:00
() => ({ scope, type, q, sort, lang }),
[scope, type, q, sort, lang],
2026-05-27 11:33:48 +08:00
);
const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params);
const groups = useGroupedByDay(items, lang);
const retryLabel = t("retry");
const sentinelRef = useRef<HTMLDivElement>(null);
const filterBarRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
isLoadingRef.current = isLoading;
}, [isLoading]);
2026-05-27 11:33:48 +08:00
useEffect(() => {
if (q) postJSON("/api/search-log", { query: q }).catch(() => {});
}, [q]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (
entry.isIntersecting &&
hasMoreRef.current &&
!isLoadingRef.current
) {
loadMore();
}
}
},
// Prefetch the next page well before the sentinel is visible so content
// is already in place as the user scrolls — no blank gap at the bottom.
{ rootMargin: "1000px" },
);
io.observe(el);
return () => io.disconnect();
}, [loadMore]);
// When arriving with a `?post=<id>` query (or legacy `#post-<id>` 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 queryTargetPostId = sp.get("post") || "";
const hashTargetPostId = hash.startsWith("#post-")
? hash.slice("#post-".length)
: "";
const targetPostId = queryTargetPostId || hashTargetPostId;
// 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[]>([]);
2026-05-30 18:08:39 +08:00
const targetScrollFrameRef = useRef<number | null>(null);
const clearTargetScrollTimers = () => {
for (const timer of targetScrollTimersRef.current) {
window.clearTimeout(timer);
}
targetScrollTimersRef.current = [];
2026-05-30 18:08:39 +08:00
if (targetScrollFrameRef.current !== null) {
window.cancelAnimationFrame(targetScrollFrameRef.current);
targetScrollFrameRef.current = null;
}
};
useEffect(() => {
handledTargetRef.current = "";
clearTargetScrollTimers();
setIsAligningQueryTarget(false);
2026-05-30 18:08:39 +08:00
}, [queryTargetPostId, targetPostId]);
// 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, []);
2026-05-30 18:08:39 +08:00
useEffect(() => {
if (!isAligningQueryTarget) return;
const preventScroll = (event: Event) => event.preventDefault();
const preventScrollKeys = (event: KeyboardEvent) => {
if (
[
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
" ",
].includes(event.key)
) {
event.preventDefault();
}
};
const html = document.documentElement;
const previousOverscroll = html.style.overscrollBehavior;
html.style.overscrollBehavior = "none";
window.addEventListener("wheel", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("keydown", preventScrollKeys, { capture: true });
return () => {
html.style.overscrollBehavior = previousOverscroll;
window.removeEventListener("wheel", preventScroll, { capture: true });
window.removeEventListener("touchmove", preventScroll, { capture: true });
window.removeEventListener("keydown", preventScrollKeys, {
capture: true,
});
};
}, [isAligningQueryTarget]);
useEffect(() => {
if (!targetPostId || handledTargetRef.current === targetPostId) return;
const el = document.getElementById(`post-${targetPostId}`);
if (el) {
handledTargetRef.current = targetPostId;
clearTargetScrollTimers();
2026-05-30 18:08:39 +08:00
const targetScrollTop = () => {
const target = document.getElementById(`post-${targetPostId}`);
2026-05-30 18:08:39 +08:00
if (!target) return null;
const filterBottom =
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
2026-05-30 18:08:39 +08:00
return Math.max(0, targetTop - filterBottom - 12);
};
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
const top = targetScrollTop();
if (top === null) return;
window.scrollTo({ top, left: 0, behavior });
};
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
2026-05-30 18:08:39 +08:00
if (queryTargetPostId) {
// 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.
setIsAligningQueryTarget(true);
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
2026-05-30 18:08:39 +08:00
);
targetScrollTimersRef.current = [
window.setTimeout(() => scrollToTarget("auto"), 450),
window.setTimeout(() => scrollToTarget("auto"), 750),
window.setTimeout(() => {
scrollToTarget("auto");
setIsAligningQueryTarget(false);
}, 950),
];
2026-05-30 18:08:39 +08:00
} else {
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
);
2026-05-30 18:08:39 +08:00
// 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. 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();
2026-05-30 18:08:39 +08:00
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
}, [targetPostId, items, hasMore, isLoading, error, loadMore]);
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key);
else n.set(key, value);
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]">
{/* Filters stay pinned below the global header (which shows the page
name) so users can switch filters while scrolling. */}
<div
ref={filterBarRef}
className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]"
>
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
</div>
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{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>
))}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{error ? (
<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={() => (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 && !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}
</>
)}
<div ref={sentinelRef} aria-hidden className="h-1" />
</div>
</div>
);
}