diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 8d38fef..d0f50da 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -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"; @@ -30,7 +31,6 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); - const groups = useGroupedByDay(items, lang); const retryLabel = t("retry"); const sentinelRef = useRef(null); @@ -79,6 +79,27 @@ export function MessageStream({ scope }: MessageStreamProps) { ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; + const [resolvedTargetPost, setResolvedTargetPost] = useState( + 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. @@ -113,10 +134,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=`) should always begin the // smooth-scroll positioning from the top of the stream, so the user sees a @@ -128,6 +153,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( + `/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(() => { @@ -261,7 +331,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); @@ -270,7 +340,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= (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 (
@@ -284,6 +375,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
+ {isSearchingDeepTarget ? ( +
+ + {t("searchingForPost")} +
+ ) : null} + {targetNotFoundInStream ? ( +
+ {t("postNotFound")} +
+ ) : null} {isInitialLoad ? ( <> {Array.from({ length: 10 }).map((_, i) => ( @@ -311,7 +420,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
))} - {!isLoading && !error && items.length === 0 ? ( + {!isLoading && !error && streamItems.length === 0 ? (

{t("noResults")}

@@ -325,7 +434,9 @@ export function MessageStream({ scope }: MessageStreamProps) { {t("loadMoreFailed")}