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