2026-06-02 11:42:10 +08:00
|
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { useLocation, useSearchParams } from "react-router-dom";
|
2026-06-03 01:40:21 +08:00
|
|
|
import { LoaderCircle } from "lucide-react";
|
|
|
|
|
import { getJSON, postJSON } from "../../api";
|
|
|
|
|
import { langQuery, useI18n } from "../../i18n";
|
|
|
|
|
import type { Post, PostScope } from "../../types/post";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { Reveal } from "../../motion";
|
|
|
|
|
import { Skeleton } from "../Skeleton";
|
2026-05-25 05:25:57 +08:00
|
|
|
import { FilterChips } from "./FilterChips";
|
|
|
|
|
import { MessageBubble } from "./MessageBubble";
|
|
|
|
|
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
|
|
|
|
import { usePostStream } from "./hooks/usePostStream";
|
2026-06-02 00:36:11 +08:00
|
|
|
import { useFavorites } from "../../favorites/FavoritesProvider";
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
export type MessageStreamProps = {
|
|
|
|
|
scope: PostScope;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-30 02:37:30 +08:00
|
|
|
export function MessageStream({ scope }: MessageStreamProps) {
|
2026-05-25 05:25:57 +08:00
|
|
|
const { t, lang } = useI18n();
|
|
|
|
|
const [sp, setSp] = useSearchParams();
|
2026-05-29 11:50:27 +08:00
|
|
|
const { hash } = useLocation();
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
const type = sp.get("type") || "all";
|
2026-05-27 11:33:48 +08:00
|
|
|
const q = (sp.get("q") || "").trim();
|
2026-05-28 16:37:00 +08:00
|
|
|
const sort = sp.get("sort") || "";
|
2026-05-25 05:25:57 +08:00
|
|
|
|
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
|
|
|
);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
const { items, isLoading, error, hasMore, loadMore, reset } =
|
|
|
|
|
usePostStream(params);
|
2026-06-02 00:36:11 +08:00
|
|
|
const { ensureFavoriteIds } = useFavorites();
|
2026-06-02 11:39:17 +08:00
|
|
|
const retryLabel = t("retry");
|
2026-05-25 05:25:57 +08:00
|
|
|
|
2026-06-02 00:36:11 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
|
|
|
|
}, [ensureFavoriteIds, items]);
|
|
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
2026-05-30 03:11:03 +08:00
|
|
|
const filterBarRef = useRef<HTMLDivElement>(null);
|
2026-05-25 05:25:57 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-29 19:04:31 +08:00
|
|
|
// 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" },
|
2026-05-25 05:25:57 +08:00
|
|
|
);
|
|
|
|
|
io.observe(el);
|
|
|
|
|
return () => io.disconnect();
|
|
|
|
|
}, [loadMore]);
|
|
|
|
|
|
2026-05-30 03:11:03 +08:00
|
|
|
// When arriving with a `?post=<id>` query (or legacy `#post-<id>` hash),
|
2026-05-29 11:50:27 +08:00
|
|
|
// 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.
|
2026-05-30 03:11:03 +08:00
|
|
|
const queryTargetPostId = sp.get("post") || "";
|
|
|
|
|
const hashTargetPostId = hash.startsWith("#post-")
|
2026-05-29 12:49:22 +08:00
|
|
|
? hash.slice("#post-".length)
|
|
|
|
|
: "";
|
2026-05-30 03:11:03 +08:00
|
|
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
2026-06-03 01:40:21 +08:00
|
|
|
const [resolvedTargetPost, setResolvedTargetPost] = useState<Post | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
|
|
|
|
const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false);
|
|
|
|
|
const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false);
|
|
|
|
|
const targetAlreadyInBaseItems = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
!!queryTargetPostId &&
|
|
|
|
|
items.some((post) => post.id === queryTargetPostId),
|
|
|
|
|
[items, queryTargetPostId],
|
|
|
|
|
);
|
|
|
|
|
const streamItems = useMemo(() => {
|
|
|
|
|
if (
|
|
|
|
|
resolvedTargetPost &&
|
|
|
|
|
!items.some((post) => post.id === resolvedTargetPost.id)
|
|
|
|
|
) {
|
|
|
|
|
return [resolvedTargetPost, ...items];
|
|
|
|
|
}
|
|
|
|
|
return items;
|
|
|
|
|
}, [items, resolvedTargetPost]);
|
|
|
|
|
const groups = useGroupedByDay(streamItems, lang);
|
2026-06-02 11:30:47 +08:00
|
|
|
// 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);
|
2026-05-29 11:50:27 +08:00
|
|
|
const handledTargetRef = useRef<string>("");
|
2026-05-30 03:11:03 +08:00
|
|
|
const targetScrollTimersRef = useRef<number[]>([]);
|
2026-05-30 18:08:39 +08:00
|
|
|
const targetScrollFrameRef = useRef<number | null>(null);
|
2026-06-02 11:48:05 +08:00
|
|
|
// 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);
|
2026-05-30 03:11:03 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-30 03:11:03 +08:00
|
|
|
};
|
2026-05-29 11:50:27 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
handledTargetRef.current = "";
|
2026-06-02 11:48:05 +08:00
|
|
|
firstContentAtRef.current = null;
|
2026-05-30 03:11:03 +08:00
|
|
|
clearTargetScrollTimers();
|
2026-06-02 11:30:47 +08:00
|
|
|
setIsAligningQueryTarget(false);
|
2026-05-30 18:08:39 +08:00
|
|
|
}, [queryTargetPostId, targetPostId]);
|
2026-05-29 11:50:27 +08:00
|
|
|
|
2026-06-02 11:48:05 +08:00
|
|
|
// 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(() => {
|
2026-06-03 01:40:21 +08:00
|
|
|
if (
|
|
|
|
|
streamItems.length > 0 &&
|
|
|
|
|
!isLoading &&
|
|
|
|
|
firstContentAtRef.current === null
|
|
|
|
|
) {
|
2026-06-02 11:48:05 +08:00
|
|
|
firstContentAtRef.current = performance.now();
|
|
|
|
|
}
|
2026-06-03 01:40:21 +08:00
|
|
|
}, [streamItems.length, isLoading]);
|
2026-06-02 11:48:05 +08:00
|
|
|
|
2026-06-02 11:42:10 +08:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-06-03 01:40:21 +08:00
|
|
|
// Search result clicks can target very old posts that are nowhere near the
|
|
|
|
|
// first paginated /browse page. Do not make the user wait while the stream
|
|
|
|
|
// loads page after page; fetch the target post directly and inject it at the
|
|
|
|
|
// top so it can render and be highlighted immediately. The normal stream
|
|
|
|
|
// still loads underneath for context / scrolling.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!queryTargetPostId) {
|
|
|
|
|
setResolvedTargetPost(null);
|
|
|
|
|
setIsFetchingTargetPost(false);
|
|
|
|
|
setTargetPostFetchFailed(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (targetAlreadyInBaseItems) {
|
|
|
|
|
setResolvedTargetPost(null);
|
|
|
|
|
setIsFetchingTargetPost(false);
|
|
|
|
|
setTargetPostFetchFailed(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setIsFetchingTargetPost(true);
|
|
|
|
|
setTargetPostFetchFailed(false);
|
|
|
|
|
getJSON<Post>(
|
|
|
|
|
`/api/posts/${encodeURIComponent(queryTargetPostId)}?lang=${encodeURIComponent(
|
|
|
|
|
langQuery(lang),
|
|
|
|
|
)}`,
|
|
|
|
|
)
|
|
|
|
|
.then((post) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setResolvedTargetPost(post);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setResolvedTargetPost(null);
|
|
|
|
|
setTargetPostFetchFailed(true);
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (!cancelled) setIsFetchingTargetPost(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [lang, queryTargetPostId, targetAlreadyInBaseItems]);
|
|
|
|
|
|
2026-05-30 03:11:03 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-29 11:50:27 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
|
|
|
|
|
|
|
|
|
const el = document.getElementById(`post-${targetPostId}`);
|
|
|
|
|
if (el) {
|
|
|
|
|
handledTargetRef.current = targetPostId;
|
2026-05-30 03:11:03 +08:00
|
|
|
clearTargetScrollTimers();
|
|
|
|
|
|
2026-05-30 18:08:39 +08:00
|
|
|
const targetScrollTop = () => {
|
2026-05-30 03:11:03 +08:00
|
|
|
const target = document.getElementById(`post-${targetPostId}`);
|
2026-05-30 18:08:39 +08:00
|
|
|
if (!target) return null;
|
2026-05-30 03:11:03 +08:00
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-30 03:11:03 +08:00
|
|
|
const prefersReducedMotion = window.matchMedia(
|
|
|
|
|
"(prefers-reduced-motion: reduce)",
|
|
|
|
|
).matches;
|
|
|
|
|
|
2026-05-30 18:08:39 +08:00
|
|
|
if (queryTargetPostId) {
|
2026-06-02 11:30:47 +08:00
|
|
|
// 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.
|
2026-06-02 11:48:05 +08:00
|
|
|
//
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-06-02 11:30:47 +08:00
|
|
|
setIsAligningQueryTarget(true);
|
|
|
|
|
targetScrollTimersRef.current = [
|
2026-06-02 11:48:05 +08:00
|
|
|
window.setTimeout(() => {
|
|
|
|
|
window.requestAnimationFrame(() =>
|
|
|
|
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
|
|
|
|
);
|
|
|
|
|
}, settle),
|
|
|
|
|
window.setTimeout(() => scrollToTarget("auto"), settle + 450),
|
|
|
|
|
window.setTimeout(() => scrollToTarget("auto"), settle + 750),
|
2026-06-02 11:30:47 +08:00
|
|
|
window.setTimeout(() => {
|
|
|
|
|
scrollToTarget("auto");
|
|
|
|
|
setIsAligningQueryTarget(false);
|
2026-06-02 11:48:05 +08:00
|
|
|
}, settle + 950),
|
2026-06-02 11:30:47 +08:00
|
|
|
];
|
2026-05-30 18:08:39 +08:00
|
|
|
} else {
|
|
|
|
|
window.requestAnimationFrame(() =>
|
|
|
|
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
|
|
|
|
);
|
2026-05-30 03:11:03 +08:00
|
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-30 03:11:03 +08:00
|
|
|
|
|
|
|
|
el.classList.add("ark-bubble-highlight");
|
|
|
|
|
window.setTimeout(
|
|
|
|
|
() => el.classList.remove("ark-bubble-highlight"),
|
|
|
|
|
2000,
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-05-29 11:50:27 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 11:39:17 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-05-29 11:50:27 +08:00
|
|
|
if (hasMore && !isLoading) loadMore();
|
2026-05-30 18:08:39 +08:00
|
|
|
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
2026-06-03 01:40:21 +08:00
|
|
|
}, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]);
|
2026-05-29 11:50:27 +08:00
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-03 01:40:21 +08:00
|
|
|
const isInitialLoad = isLoading && streamItems.length === 0;
|
|
|
|
|
|
|
|
|
|
// When the user arrives via /browse?post=<id> (typically from search or a
|
|
|
|
|
// banner) and the target post lives deep in the stream, pagination has to
|
|
|
|
|
// keep loading older pages until it surfaces. Show an explicit "finding
|
|
|
|
|
// your post" indicator so the user knows we're actively searching for
|
|
|
|
|
// their specific post, not just lazily loading the feed.
|
|
|
|
|
const targetInLoadedItems =
|
|
|
|
|
!!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId);
|
|
|
|
|
const isSearchingDeepTarget =
|
|
|
|
|
!!queryTargetPostId &&
|
|
|
|
|
!targetInLoadedItems &&
|
|
|
|
|
!error &&
|
|
|
|
|
(isFetchingTargetPost || hasMore || isLoading);
|
|
|
|
|
const targetNotFoundInStream =
|
|
|
|
|
!!queryTargetPostId &&
|
|
|
|
|
!targetInLoadedItems &&
|
|
|
|
|
!error &&
|
|
|
|
|
targetPostFetchFailed &&
|
|
|
|
|
!hasMore &&
|
|
|
|
|
!isLoading &&
|
|
|
|
|
streamItems.length > 0;
|
2026-05-29 11:50:27 +08:00
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
return (
|
2026-05-28 15:11:13 +08:00
|
|
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
2026-05-30 02:37:30 +08:00
|
|
|
{/* Filters stay pinned below the global header (which shows the page
|
|
|
|
|
name) so users can switch filters while scrolling. */}
|
2026-05-30 03:11:03 +08:00
|
|
|
<div
|
|
|
|
|
ref={filterBarRef}
|
|
|
|
|
className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]"
|
|
|
|
|
>
|
2026-05-30 01:31:10 +08:00
|
|
|
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
|
|
|
|
</div>
|
2026-05-25 05:25:57 +08:00
|
|
|
|
2026-05-28 15:11:13 +08:00
|
|
|
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
2026-06-03 01:40:21 +08:00
|
|
|
{isSearchingDeepTarget ? (
|
|
|
|
|
<div
|
|
|
|
|
role="status"
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
className="mx-auto flex w-fit max-w-full items-center gap-2 rounded-full border border-ark-gold/40 bg-ark-gold/10 px-4 py-2 text-sm text-ark-gold shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
|
|
|
|
|
<span>{t("searchingForPost")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{targetNotFoundInStream ? (
|
|
|
|
|
<div
|
|
|
|
|
role="status"
|
|
|
|
|
className="mx-auto w-fit max-w-full rounded-full border border-yellow-700/40 bg-yellow-950/30 px-4 py-2 text-center text-sm text-yellow-200"
|
|
|
|
|
>
|
|
|
|
|
{t("postNotFound")}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-29 11:50:27 +08:00
|
|
|
{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>
|
2026-05-25 05:25:57 +08:00
|
|
|
))}
|
2026-05-29 11:50:27 +08:00
|
|
|
|
2026-06-03 01:40:21 +08:00
|
|
|
{!isLoading && !error && streamItems.length === 0 ? (
|
2026-05-29 11:50:27 +08:00
|
|
|
<p className="py-10 text-center text-sm text-neutral-400">
|
|
|
|
|
{t("noResults")}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{error ? (
|
2026-06-02 11:39:17 +08:00
|
|
|
<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>
|
2026-05-29 11:50:27 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-06-03 01:40:21 +08:00
|
|
|
onClick={() =>
|
|
|
|
|
streamItems.length === 0 ? reset() : loadMore()
|
|
|
|
|
}
|
2026-06-02 11:39:17 +08:00
|
|
|
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"
|
2026-05-29 11:50:27 +08:00
|
|
|
>
|
|
|
|
|
{retryLabel}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-02 11:39:17 +08:00
|
|
|
{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>
|
2026-05-29 11:50:27 +08:00
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
<div ref={sentinelRef} aria-hidden className="h-1" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|