From 5293a5dba2bab02504f1ed5cf7b50208194e6853 Mon Sep 17 00:00:00 2001 From: rimar1337 <132627503+rimar1337@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:26:32 +0700 Subject: [PATCH] persistent data, persistent scroll position, reload feeds, home screen loader --- package-lock.json | 46 ++++++ package.json | 2 + src/components/InfiniteCustomFeed.tsx | 39 ++++- src/main.tsx | 28 +++- src/routes/__root.tsx | 6 +- src/routes/index.tsx | 220 ++++++++++++++++++++++---- src/utils/atoms.ts | 26 ++- src/utils/useQuery.ts | 5 +- 8 files changed, 334 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index e4d0b92..b0ea903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "dependencies": { "@atproto/api": "^0.16.6", "@tailwindcss/vite": "^4.0.6", + "@tanstack/query-sync-storage-persister": "^5.85.6", "@tanstack/react-devtools": "^0.2.2", "@tanstack/react-query": "^5.85.6", + "@tanstack/react-query-persist-client": "^5.85.6", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.131.5", "@tanstack/router-plugin": "^1.121.2", @@ -1894,6 +1896,33 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.85.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.6.tgz", + "integrity": "sha512-wUdoEurIC0YCNZzR020Xcg3OsJeF4SXmEPqlNwZ6EaGKgWeNjU17hVdK+X4ZeirUm+h0muiEQx+aIQU1lk7roQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-sync-storage-persister": { + "version": "5.85.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.85.6.tgz", + "integrity": "sha512-Gj/p0paYsdzj3IbRn6SjMMNdjZ0nVQWszn17qbHLiu3Mt6H0b/YbLL3g9uRWcoyYcaB004RawgM0MuA+xJt5iw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.6", + "@tanstack/query-persist-client-core": "5.85.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-devtools": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz", @@ -1932,6 +1961,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.85.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.6.tgz", + "integrity": "sha512-zLUfm8JlI6/s0AqvX5l5CcazdHwj5gwcv0mWYOaJJvADyFzl2wwQKqB/H4nYSeygUtrepBgPwVQKNqH9ZwlZpQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.85.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.85.6", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.131.28", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.28.tgz", diff --git a/package.json b/package.json index f4c45e0..5f79a12 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "dependencies": { "@atproto/api": "^0.16.6", "@tailwindcss/vite": "^4.0.6", + "@tanstack/query-sync-storage-persister": "^5.85.6", "@tanstack/react-devtools": "^0.2.2", "@tanstack/react-query": "^5.85.6", + "@tanstack/react-query-persist-client": "^5.85.6", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.131.5", "@tanstack/router-plugin": "^1.121.2", diff --git a/src/components/InfiniteCustomFeed.tsx b/src/components/InfiniteCustomFeed.tsx index 1fdda3f..92367c0 100644 --- a/src/components/InfiniteCustomFeed.tsx +++ b/src/components/InfiniteCustomFeed.tsx @@ -33,6 +33,8 @@ export function InfiniteCustomFeed({ hasNextPage, fetchNextPage, isFetchingNextPage, + refetch, + isRefetching, } = useInfiniteQueryFeedSkeleton({ feedUri: feedUri, agent: agent ?? undefined, @@ -41,6 +43,10 @@ export function InfiniteCustomFeed({ feedServiceDid: feedServiceDid, }); + const handleRefresh = () => { + refetch(); + }; + //const { ref, inView } = useInView(); // React.useEffect(() => { @@ -99,7 +105,36 @@ export function InfiniteCustomFeed({ Load More Posts )} - {!hasNextPage &&
End of feed.
} + {!hasNextPage && ( +
End of feed.
+ )} + ); -} \ No newline at end of file +} + +const RefreshIcon = (props: React.SVGProps) => ( + + + +); \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index a960a8b..72f720b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,9 +7,29 @@ import { routeTree } from "./routeTree.gen"; import "~/styles/app.css"; import reportWebVitals from "./reportWebVitals.ts"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; +import { + persistQueryClient, +} from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; + + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24 * 24, // 24 days + }, + }, +}); +const localStoragePersister = createSyncStoragePersister({ + storage: window.localStorage, +}); + +persistQueryClient({ + queryClient, + persister: localStoragePersister, +}) -const queryClient = new QueryClient(); // Create a new router instance const router = createRouter({ routeTree, @@ -33,11 +53,11 @@ if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( // double queries annoys me - + // - + // ); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c805f7e..91eebbd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { Outlet, Scripts, createRootRoute, + createRootRouteWithContext, useLocation, useNavigate, } from "@tanstack/react-router"; @@ -23,8 +24,11 @@ import { seo } from "~/utils/seo"; import { AuthProvider, useAuth } from "~/providers/PassAuthProvider"; import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider"; import type AtpAgent from "@atproto/api"; +import type { QueryClient } from "@tanstack/react-query"; -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; +}>()({ head: () => ({ meta: [ { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2d63ec0..3f9c6ab 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -13,14 +13,89 @@ import { useQueryPost, useQueryFeedSkeleton, useQueryPreferences, - useQueryArbitrary + useQueryArbitrary, + constructInfiniteFeedSkeletonQuery, + constructArbitraryQuery, + constructIdentityQuery, + constructPostQuery, } from "~/utils/useQuery"; import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; +import { useAtom, useSetAtom } from "jotai"; +import { + selectedFeedUriAtom, + store, + agentAtom, + authedAtom, + feedScrollPositionsAtom, +} from "~/utils/atoms"; +import { useEffect, useLayoutEffect } from "react"; export const Route = createFileRoute("/")({ + loader: async ({ context }) => { + const { queryClient } = context; + const atomauth = store.get(authedAtom); + const atomagent = store.get(agentAtom); + + let identitypds: string | undefined; + const initialselectedfeed = store.get(selectedFeedUriAtom); + if (atomagent && atomauth && atomagent?.did) { + const identityopts = constructIdentityQuery(atomagent.did); + const identityresultmaybe = + await queryClient.ensureQueryData(identityopts); + identitypds = identityresultmaybe?.pds; + } + + const arbitraryopts = constructArbitraryQuery( + initialselectedfeed ?? + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot" + ); + const feedGengetrecordquery = + await queryClient.ensureQueryData(arbitraryopts); + const feedServiceDid = (feedGengetrecordquery?.value as any)?.did; + //queryClient.ensureInfiniteQueryData() + + const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery({ + feedUri: + initialselectedfeed ?? + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot", + agent: atomagent ?? undefined, + isAuthed: atomauth ?? false, + pdsUrl: identitypds, + feedServiceDid: feedServiceDid, + }); + + const res = await queryClient.ensureInfiniteQueryData({ + queryKey, + queryFn, + initialPageParam: undefined as never, + getNextPageParam: (lastPage: any) => lastPage.cursor as null | undefined, + staleTime: Infinity, + //refetchOnWindowFocus: false, + //enabled: true, + }); + await Promise.all( + res.pages.map(async (page) => { + await Promise.all( + page.feed.map(async (feedviewpost) => { + if (!feedviewpost.post) return; + console.log("preloading: ", feedviewpost.post); + const opts = constructPostQuery(feedviewpost.post); + try { + await queryClient.ensureQueryData(opts); + } catch (e) { + console.log(" failed:", e); + } + }) + ); + }) + ); + }, component: Home, + pendingComponent: PendingHome, }); - +function PendingHome() { + return
loading... (prefetching your timeline)
; +} function Home() { const { agent, @@ -30,6 +105,22 @@ function Home() { loading: loadering, authed, } = useAuth(); + + useEffect(() => { + if (agent?.did) { + store.set(authedAtom, true); + } else { + store.set(authedAtom, false); + } + }, [loginStatus, agent, authed]); + useEffect(() => { + if (agent) { + store.set(agentAtom, agent); + } else { + store.set(agentAtom, null); + } + }, [loginStatus, agent, authed]); + //const { get, set } = usePersistentStore(); // const [feed, setFeed] = React.useState([]); // const [loading, setLoading] = React.useState(true); @@ -67,13 +158,16 @@ function Home() { // }, [prefs]); // const savedFeeds = savedFeedsPref?.items || []; - + const identityresultmaybe = useQueryIdentity(agent?.did); - const identity = identityresultmaybe?.data + const identity = identityresultmaybe?.data; + + const prefsresultmaybe = useQueryPreferences({ + agent: agent ?? undefined, + pdsUrl: identity?.pds, + }); + const prefs = prefsresultmaybe?.data; - const prefsresultmaybe = useQueryPreferences({agent: agent ?? undefined, pdsUrl: identity?.pds}); - const prefs = prefsresultmaybe?.data - const savedFeeds = React.useMemo(() => { const savedFeedsPref = prefs?.preferences?.find( (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2" @@ -81,27 +175,39 @@ function Home() { return savedFeedsPref?.items || []; }, [prefs]); - - - const [selectedFeed, setSelectedFeed] = React.useState(null); - + const [persistentSelectedFeed, setPersistentSelectedFeed] = + useAtom(selectedFeedUriAtom); // React.useState(null); + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( + persistentSelectedFeed + ); // React.useState(null); + const selectedFeed = agent?.did + ? persistentSelectedFeed + : unauthedSelectedFeed; + const setSelectedFeed = agent?.did + ? setPersistentSelectedFeed + : setUnauthedSelectedFeed; + + console.log("my selectedFeed is: ", selectedFeed); React.useEffect(() => { const fallbackFeed = "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; if (authed) { + if (selectedFeed) return; if (savedFeeds.length > 0) { setSelectedFeed((prev) => prev && savedFeeds.some((f: any) => f.value === prev) ? prev - : savedFeeds[0].value, + : savedFeeds[0].value ); } else { + if (selectedFeed) return; setSelectedFeed(fallbackFeed); } } else { + if (selectedFeed) return; setSelectedFeed(fallbackFeed); } - }, [savedFeeds, authed]); + }, [savedFeeds, authed, setSelectedFeed]); // React.useEffect(() => { // if (loadering || !selectedFeed) return; @@ -185,9 +291,59 @@ function Home() { // ignore = true; // }; // }, [authed, agent, loadering, selectedFeed, get, set]); - - const feedGengetrecordquery = useQueryArbitrary(selectedFeed??undefined); + const [scrollPositions, setScrollPositions] = useAtom( + feedScrollPositionsAtom + ); + + const scrollRef = React.useRef>({}); + + useEffect(() => { + const onScroll = () => { + //if (!selectedFeed) return; + scrollRef.current[selectedFeed ?? "null"] = window.scrollY; + }; + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, [selectedFeed]); + const [donerestored, setdonerestored] = React.useState(false); + + useEffect(() => { + return () => { + if (!donerestored) return; + console.log("FEEDSCROLLSHIT saving at uhhh: ", scrollRef.current); + //if (!selectedFeed) return; + setScrollPositions((prev) => ({ + ...prev, + [selectedFeed ?? "null"]: + scrollRef.current[selectedFeed ?? "null"] ?? 0, + })); + }; + }, [selectedFeed, setScrollPositions, donerestored]); + + const [restoringScrollPosition, setRestoringScrollPosition] = + React.useState(false); + + useLayoutEffect(() => { + setRestoringScrollPosition(true); + const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; + + let raf = requestAnimationFrame(() => { + // setRestoringScrollPosition(true); + // raf = requestAnimationFrame(() => { + // window.scrollTo({ top: savedPosition, behavior: "instant" }); + // setRestoringScrollPosition(false); + // setdonerestored(true); + // }); + window.scrollTo({ top: savedPosition, behavior: "instant" }); + setRestoringScrollPosition(false); + setdonerestored(true); + }); + + return () => cancelAnimationFrame(raf); + }, [selectedFeed, scrollPositions]); + + const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; // const { @@ -204,11 +360,12 @@ function Home() { // const feed = feedData?.feed || []; - const isReadyForAuthedFeed = authed && agent && identity?.pds && feedServiceDid; + const isReadyForAuthedFeed = + authed && agent && identity?.pds && feedServiceDid; const isReadyForUnauthedFeed = !authed && selectedFeed; return ( -
+
{savedFeeds.length > 0 ? ( savedFeeds.map((item: any, idx: number) => { @@ -252,18 +409,27 @@ function Home() { /> ))} */} - {(authed && (!identity?.pds || !feedServiceDid)) && ( -
Preparing your feed...
+ {authed && (!identity?.pds || !feedServiceDid) && ( +
+ Preparing your feed... +
)} - {(isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( - + {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( + ) : ( -
Select a feed to get started.
+
+ Select a feed to get started. +
+ )} + {false && restoringScrollPosition && ( +
+ restoringScrollPosition +
)}
); @@ -295,7 +461,7 @@ export async function cachedResolveDIDWEBDOC({ } catch {} } const url = `https://free-fly-24.deno.dev/resolve-did-web?did=${encodeURIComponent( - didweb, + didweb )}`; const res = await fetch(url); if (!res.ok) throw new Error("Failed to resolve didwebdoc"); diff --git a/src/utils/atoms.ts b/src/utils/atoms.ts index e0905ed..2f44428 100644 --- a/src/utils/atoms.ts +++ b/src/utils/atoms.ts @@ -1,5 +1,25 @@ -import { atom } from "jotai"; +import type AtpAgent from "@atproto/api"; +import { atom, createStore } from "jotai"; +import { atomWithStorage } from 'jotai/utils'; -export const selectedFeedUriAtom = atom(null); +export const store = createStore(); -export const feedScrollPositionsAtom = atom>({}); \ No newline at end of file +export const selectedFeedUriAtom = atomWithStorage( + 'selectedFeedUri', + null +); + +//export const feedScrollPositionsAtom = atom>({}); + +export const feedScrollPositionsAtom = atomWithStorage>( + 'feedscrollpositions', + {} +); + +export const likedPostsAtom = atomWithStorage>( + 'likedPosts', + {} +); + +export const agentAtom = atom(null); +export const authedAtom = atom(false); diff --git a/src/utils/useQuery.ts b/src/utils/useQuery.ts index e384c42..5f12ccb 100644 --- a/src/utils/useQuery.ts +++ b/src/utils/useQuery.ts @@ -234,6 +234,9 @@ export function constructConstellationQuery(query?:{ return undefined; } }, + // enforce short lifespan + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 5 * 60 * 1000, }); } export function useQueryConstellation(query: { @@ -399,7 +402,7 @@ export function useQueryPreferences(options: { export function constructArbitraryQuery(uri?: string) { return queryOptions({ - queryKey: ["post", uri], + queryKey: ["arbitrary", uri], queryFn: async () => { if (!uri) return undefined as undefined const res = await fetch( -- 2.51.2