Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 31s

# Conflicts:
#	src/components/messageStream/MessageStream.tsx
This commit is contained in:
TerryM
2026-06-03 14:42:07 +08:00
20 changed files with 410 additions and 19 deletions

View File

@@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { postJSON } from "../../api";
import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post";
import { LoaderCircle } from "lucide-react";
import { getJSON, postJSON } from "../../api";
import { langQuery, useI18n } from "../../i18n";
import type { Post, PostScope } from "../../types/post";
import { Reveal } from "../../motion";
import { Skeleton } from "../Skeleton";
import { FilterChips } from "./FilterChips";
@@ -32,7 +33,6 @@ export function MessageStream({ scope }: MessageStreamProps) {
const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params);
const { ensureFavoriteIds } = useFavorites();
const groups = useGroupedByDay(items, lang);
const retryLabel = t("retry");
useEffect(() => {
@@ -85,6 +85,27 @@ export function MessageStream({ scope }: MessageStreamProps) {
? hash.slice("#post-".length)
: "";
const targetPostId = queryTargetPostId || hashTargetPostId;
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);
// 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.
@@ -119,10 +140,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
// 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) {
if (
streamItems.length > 0 &&
!isLoading &&
firstContentAtRef.current === null
) {
firstContentAtRef.current = performance.now();
}
}, [items.length, isLoading]);
}, [streamItems.length, isLoading]);
// Banner / deep-link arrivals (`?post=<id>`) should always begin the
// smooth-scroll positioning from the top of the stream, so the user sees a
@@ -134,6 +159,51 @@ export function MessageStream({ scope }: MessageStreamProps) {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [queryTargetPostId]);
// 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]);
useEffect(() => clearTargetScrollTimers, []);
useEffect(() => {
@@ -267,7 +337,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
}
if (hasMore && !isLoading) loadMore();
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
}, [targetPostId, items, hasMore, isLoading, error, loadMore]);
}, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]);
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
@@ -276,7 +346,28 @@ export function MessageStream({ scope }: MessageStreamProps) {
setSp(n, { replace: true });
};
const isInitialLoad = isLoading && items.length === 0;
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;
return (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
@@ -290,6 +381,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{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}
{isInitialLoad ? (
<>
{Array.from({ length: 10 }).map((_, i) => (
@@ -317,7 +426,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
))}
{!isLoading && !error && items.length === 0 ? (
{!isLoading && !error && streamItems.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
@@ -331,7 +440,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
<span className="break-words">{t("loadMoreFailed")}</span>
<button
type="button"
onClick={() => (items.length === 0 ? reset() : loadMore())}
onClick={() =>
streamItems.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}