195 lines · 4.9 KB · TypeScript
RawDownload
import {	useInfiniteQuery,	useMutation,	useQuery,	useQueryClient,} from "@tanstack/react-query";import { useMemo } from "react";import {	fetchQueryEnvelope,	fetchQueryResponse,	postAction,} from "#/lib/api-client";import { queryKeys } from "#/lib/query-client";import type { ReplyFilter, ResourceKind, TimelineItem } from "#/lib/types";import { useSelectedAccountId } from "./account-selection";import { useDebouncedValue } from "./useDebouncedValue"; const PAGE_SIZE = 50;const TIMELINE_STALE_TIME_MS = 5 * 60_000; interface UseTimelineRouteDataOptions {	resource: Exclude<ResourceKind, "dms">;	search: string;	errorFallback: string;	replyFilter?: ReplyFilter;	likedOnly?: boolean;	bookmarkedOnly?: boolean;} interface TimelinePageParam {	until: string;	untilId: string;} function buildTimelineQueryUrl({	resource,	search,	replyFilter,	likedOnly,	bookmarkedOnly,	selectedAccountId,	pageParam,}: {	resource: Exclude<ResourceKind, "dms">;	search: string;	replyFilter?: ReplyFilter;	likedOnly: boolean;	bookmarkedOnly: boolean;	selectedAccountId?: string;	pageParam?: TimelinePageParam;}) {	const params = new URLSearchParams({		resource,		limit: String(PAGE_SIZE),	});	if (selectedAccountId) params.set("account", selectedAccountId);	if (replyFilter) params.set("replyFilter", replyFilter);	if (likedOnly) params.set("liked", "true");	if (bookmarkedOnly) params.set("bookmarked", "true");	if (search.trim()) params.set("search", search.trim());	if (pageParam) {		params.set("until", pageParam.until);		params.set("untilId", pageParam.untilId);	}	params.sort();	const base =		typeof window === "undefined"			? "http://birdclaw.local"			: window.location.origin;	return new URL(`/api/query?${params.toString()}`, base).toString();} export function useTimelineRouteData({	resource,	search,	errorFallback,	replyFilter,	likedOnly = false,	bookmarkedOnly = false,}: UseTimelineRouteDataOptions) {	const queryClient = useQueryClient();	const statusQuery = useQuery({		queryKey: queryKeys.status,		queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),	});	const meta = statusQuery.data ?? null;	const selectedAccountId = useSelectedAccountId(meta?.accounts);	const debouncedSearch = useDebouncedValue(search, 180);	const timelineQueryKey = [		...queryKeys.timelines,		{			resource,			search: debouncedSearch,			replyFilter: replyFilter ?? "all",			likedOnly,			bookmarkedOnly,			selectedAccountId: selectedAccountId ?? null,		},	] as const;	const timelineQuery = useInfiniteQuery({		queryKey: timelineQueryKey,		initialPageParam: undefined as TimelinePageParam | undefined,		queryFn: ({ pageParam, signal }) =>			fetchQueryResponse(				buildTimelineQueryUrl({					resource,					search: debouncedSearch,					replyFilter,					likedOnly,					bookmarkedOnly,					selectedAccountId,					pageParam,				}),				{ signal },			),		getNextPageParam: (lastPage) => {			if (lastPage.resource === "dms") return undefined;			const items = lastPage.items;			const lastItem = items.at(-1);			return items.length >= PAGE_SIZE && lastItem				? { until: lastItem.createdAt, untilId: lastItem.id }				: undefined;		},		staleTime: TIMELINE_STALE_TIME_MS,	});	const items = useMemo(() => {		const seen = new Set<string>();		const merged: TimelineItem[] = [];		for (const page of timelineQuery.data?.pages ?? []) {			if (page.resource === "dms") continue;			for (const item of page.items) {				if (seen.has(item.id)) continue;				seen.add(item.id);				merged.push(item);			}		}		return merged;	}, [timelineQuery.data]);	const replyMutation = useMutation({		mutationFn: ({ tweetId, text }: { tweetId: string; text: string }) =>			postAction({				kind: "replyTweet",				accountId: selectedAccountId ?? "acct_primary",				tweetId,				text,			}),		onSuccess: () =>			queryClient.invalidateQueries({ queryKey: timelineQueryKey }),	}); 	function retry() {		void timelineQuery.refetch();	} 	function refreshLocalView() {		void Promise.all([			queryClient.invalidateQueries({ queryKey: queryKeys.timelines }),			queryClient.invalidateQueries({ queryKey: queryKeys.status }),		]);	} 	async function replyToTweet(tweetId: string) {		const text = window.prompt("Reply text");		if (!text?.trim()) return;		await replyMutation			.mutateAsync({ tweetId, text: text.trim() })			.catch(() => {				// The mutation error is exposed below for the route frame.			});	} 	const queryError = timelineQuery.error;	return {		meta,		items,		loading: timelineQuery.isPending,		error: queryError			? queryError instanceof Error				? queryError.message				: errorFallback			: null,		replyError: replyMutation.error			? replyMutation.error instanceof Error				? replyMutation.error.message				: "Reply failed"			: null,		retry,		refreshLocalView,		replyToTweet,		selectedAccountId,		hasMore: timelineQuery.hasNextPage,		loadingMore: timelineQuery.isFetchingNextPage,		loadMore: () => timelineQuery.fetchNextPage().then(() => undefined),	};}