+46
package-lock.json
+46
package-lock.json
···
8
8
"dependencies": {
9
9
"@atproto/api": "^0.16.6",
10
10
"@tailwindcss/vite": "^4.0.6",
11
+
"@tanstack/query-sync-storage-persister": "^5.85.6",
11
12
"@tanstack/react-devtools": "^0.2.2",
12
13
"@tanstack/react-query": "^5.85.6",
14
+
"@tanstack/react-query-persist-client": "^5.85.6",
13
15
"@tanstack/react-router": "^1.130.2",
14
16
"@tanstack/react-router-devtools": "^1.131.5",
15
17
"@tanstack/router-plugin": "^1.121.2",
···
1894
1896
"url": "https://github.com/sponsors/tannerlinsley"
1895
1897
}
1896
1898
},
1899
+
"node_modules/@tanstack/query-persist-client-core": {
1900
+
"version": "5.85.6",
1901
+
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.85.6.tgz",
1902
+
"integrity": "sha512-wUdoEurIC0YCNZzR020Xcg3OsJeF4SXmEPqlNwZ6EaGKgWeNjU17hVdK+X4ZeirUm+h0muiEQx+aIQU1lk7roQ==",
1903
+
"license": "MIT",
1904
+
"dependencies": {
1905
+
"@tanstack/query-core": "5.85.6"
1906
+
},
1907
+
"funding": {
1908
+
"type": "github",
1909
+
"url": "https://github.com/sponsors/tannerlinsley"
1910
+
}
1911
+
},
1912
+
"node_modules/@tanstack/query-sync-storage-persister": {
1913
+
"version": "5.85.6",
1914
+
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.85.6.tgz",
1915
+
"integrity": "sha512-Gj/p0paYsdzj3IbRn6SjMMNdjZ0nVQWszn17qbHLiu3Mt6H0b/YbLL3g9uRWcoyYcaB004RawgM0MuA+xJt5iw==",
1916
+
"license": "MIT",
1917
+
"dependencies": {
1918
+
"@tanstack/query-core": "5.85.6",
1919
+
"@tanstack/query-persist-client-core": "5.85.6"
1920
+
},
1921
+
"funding": {
1922
+
"type": "github",
1923
+
"url": "https://github.com/sponsors/tannerlinsley"
1924
+
}
1925
+
},
1897
1926
"node_modules/@tanstack/react-devtools": {
1898
1927
"version": "0.2.2",
1899
1928
"resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz",
···
1929
1958
"url": "https://github.com/sponsors/tannerlinsley"
1930
1959
},
1931
1960
"peerDependencies": {
1961
+
"react": "^18 || ^19"
1962
+
}
1963
+
},
1964
+
"node_modules/@tanstack/react-query-persist-client": {
1965
+
"version": "5.85.6",
1966
+
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.85.6.tgz",
1967
+
"integrity": "sha512-zLUfm8JlI6/s0AqvX5l5CcazdHwj5gwcv0mWYOaJJvADyFzl2wwQKqB/H4nYSeygUtrepBgPwVQKNqH9ZwlZpQ==",
1968
+
"license": "MIT",
1969
+
"dependencies": {
1970
+
"@tanstack/query-persist-client-core": "5.85.6"
1971
+
},
1972
+
"funding": {
1973
+
"type": "github",
1974
+
"url": "https://github.com/sponsors/tannerlinsley"
1975
+
},
1976
+
"peerDependencies": {
1977
+
"@tanstack/react-query": "^5.85.6",
1932
1978
"react": "^18 || ^19"
1933
1979
}
1934
1980
},
+2
package.json
+2
package.json
···
12
12
"dependencies": {
13
13
"@atproto/api": "^0.16.6",
14
14
"@tailwindcss/vite": "^4.0.6",
15
+
"@tanstack/query-sync-storage-persister": "^5.85.6",
15
16
"@tanstack/react-devtools": "^0.2.2",
16
17
"@tanstack/react-query": "^5.85.6",
18
+
"@tanstack/react-query-persist-client": "^5.85.6",
17
19
"@tanstack/react-router": "^1.130.2",
18
20
"@tanstack/react-router-devtools": "^1.131.5",
19
21
"@tanstack/router-plugin": "^1.121.2",
+37
-2
src/components/InfiniteCustomFeed.tsx
+37
-2
src/components/InfiniteCustomFeed.tsx
···
33
33
hasNextPage,
34
34
fetchNextPage,
35
35
isFetchingNextPage,
36
+
refetch,
37
+
isRefetching,
36
38
} = useInfiniteQueryFeedSkeleton({
37
39
feedUri: feedUri,
38
40
agent: agent ?? undefined,
···
40
42
pdsUrl: pdsUrl,
41
43
feedServiceDid: feedServiceDid,
42
44
});
45
+
46
+
const handleRefresh = () => {
47
+
refetch();
48
+
};
43
49
44
50
//const { ref, inView } = useInView();
45
51
···
99
105
Load More Posts
100
106
</button>
101
107
)}
102
-
{!hasNextPage && <div className="p-4 text-center text-gray-500">End of feed.</div>}
108
+
{!hasNextPage && (
109
+
<div className="p-4 text-center text-gray-500">End of feed.</div>
110
+
)}
111
+
<button
112
+
onClick={handleRefresh}
113
+
disabled={isRefetching}
114
+
className="sticky lg:bottom-6 bottom-24 ml-4 w-[42px] h-[42px] z-10 bg-gray-500 hover:bg-gray-600 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:bg-gray-400 disabled:cursor-not-allowed"
115
+
aria-label="Refresh feed"
116
+
>
117
+
{isRefetching ? <RefreshIcon className="h-6 w-6 animate-spin" /> : <RefreshIcon className="h-6 w-6" />}
118
+
</button>
103
119
</>
104
120
);
105
-
}
121
+
}
122
+
123
+
const RefreshIcon = (props: React.SVGProps<SVGSVGElement>) => (
124
+
<svg
125
+
xmlns="http://www.w3.org/2000/svg"
126
+
//width={360}
127
+
//height={360}
128
+
viewBox="0 0 24 24"
129
+
{...props}
130
+
>
131
+
<path
132
+
fill="none"
133
+
stroke="currentColor"
134
+
strokeLinecap="round"
135
+
strokeLinejoin="round"
136
+
strokeWidth={2}
137
+
d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"
138
+
></path>
139
+
</svg>
140
+
);
+24
-4
src/main.tsx
+24
-4
src/main.tsx
···
7
7
8
8
import "~/styles/app.css";
9
9
import reportWebVitals from "./reportWebVitals.ts";
10
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
+
import { QueryClient, QueryClientProvider, } from "@tanstack/react-query";
11
+
import {
12
+
persistQueryClient,
13
+
} from "@tanstack/react-query-persist-client";
14
+
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
11
15
12
-
const queryClient = new QueryClient();
16
+
17
+
const queryClient = new QueryClient({
18
+
defaultOptions: {
19
+
queries: {
20
+
gcTime: 1000 * 60 * 60 * 24 * 24, // 24 days
21
+
},
22
+
},
23
+
});
24
+
const localStoragePersister = createSyncStoragePersister({
25
+
storage: window.localStorage,
26
+
});
27
+
28
+
persistQueryClient({
29
+
queryClient,
30
+
persister: localStoragePersister,
31
+
})
32
+
13
33
// Create a new router instance
14
34
const router = createRouter({
15
35
routeTree,
···
33
53
const root = ReactDOM.createRoot(rootElement);
34
54
root.render(
35
55
// double queries annoys me
36
-
<StrictMode>
56
+
// <StrictMode>
37
57
<QueryClientProvider client={queryClient}>
38
58
<RouterProvider router={router} />
39
59
</QueryClientProvider>
40
-
</StrictMode>
60
+
// </StrictMode>
41
61
);
42
62
}
43
63
+5
-1
src/routes/__root.tsx
+5
-1
src/routes/__root.tsx
···
10
10
Outlet,
11
11
Scripts,
12
12
createRootRoute,
13
+
createRootRouteWithContext,
13
14
useLocation,
14
15
useNavigate,
15
16
} from "@tanstack/react-router";
···
23
24
import { AuthProvider, useAuth } from "~/providers/PassAuthProvider";
24
25
import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider";
25
26
import type AtpAgent from "@atproto/api";
27
+
import type { QueryClient } from "@tanstack/react-query";
26
28
27
-
export const Route = createRootRoute({
29
+
export const Route = createRootRouteWithContext<{
30
+
queryClient: QueryClient;
31
+
}>()({
28
32
head: () => ({
29
33
meta: [
30
34
{
+192
-26
src/routes/index.tsx
+192
-26
src/routes/index.tsx
···
13
13
useQueryPost,
14
14
useQueryFeedSkeleton,
15
15
useQueryPreferences,
16
-
useQueryArbitrary
16
+
useQueryArbitrary,
17
+
constructInfiniteFeedSkeletonQuery,
18
+
constructArbitraryQuery,
19
+
constructIdentityQuery,
20
+
constructPostQuery,
17
21
} from "~/utils/useQuery";
18
22
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
23
+
import { useAtom, useSetAtom } from "jotai";
24
+
import {
25
+
selectedFeedUriAtom,
26
+
store,
27
+
agentAtom,
28
+
authedAtom,
29
+
feedScrollPositionsAtom,
30
+
} from "~/utils/atoms";
31
+
import { useEffect, useLayoutEffect } from "react";
19
32
20
33
export const Route = createFileRoute("/")({
34
+
loader: async ({ context }) => {
35
+
const { queryClient } = context;
36
+
const atomauth = store.get(authedAtom);
37
+
const atomagent = store.get(agentAtom);
38
+
39
+
let identitypds: string | undefined;
40
+
const initialselectedfeed = store.get(selectedFeedUriAtom);
41
+
if (atomagent && atomauth && atomagent?.did) {
42
+
const identityopts = constructIdentityQuery(atomagent.did);
43
+
const identityresultmaybe =
44
+
await queryClient.ensureQueryData(identityopts);
45
+
identitypds = identityresultmaybe?.pds;
46
+
}
47
+
48
+
const arbitraryopts = constructArbitraryQuery(
49
+
initialselectedfeed ??
50
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"
51
+
);
52
+
const feedGengetrecordquery =
53
+
await queryClient.ensureQueryData(arbitraryopts);
54
+
const feedServiceDid = (feedGengetrecordquery?.value as any)?.did;
55
+
//queryClient.ensureInfiniteQueryData()
56
+
57
+
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery({
58
+
feedUri:
59
+
initialselectedfeed ??
60
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot",
61
+
agent: atomagent ?? undefined,
62
+
isAuthed: atomauth ?? false,
63
+
pdsUrl: identitypds,
64
+
feedServiceDid: feedServiceDid,
65
+
});
66
+
67
+
const res = await queryClient.ensureInfiniteQueryData({
68
+
queryKey,
69
+
queryFn,
70
+
initialPageParam: undefined as never,
71
+
getNextPageParam: (lastPage: any) => lastPage.cursor as null | undefined,
72
+
staleTime: Infinity,
73
+
//refetchOnWindowFocus: false,
74
+
//enabled: true,
75
+
});
76
+
await Promise.all(
77
+
res.pages.map(async (page) => {
78
+
await Promise.all(
79
+
page.feed.map(async (feedviewpost) => {
80
+
if (!feedviewpost.post) return;
81
+
console.log("preloading: ", feedviewpost.post);
82
+
const opts = constructPostQuery(feedviewpost.post);
83
+
try {
84
+
await queryClient.ensureQueryData(opts);
85
+
} catch (e) {
86
+
console.log(" failed:", e);
87
+
}
88
+
})
89
+
);
90
+
})
91
+
);
92
+
},
21
93
component: Home,
94
+
pendingComponent: PendingHome,
22
95
});
23
-
96
+
function PendingHome() {
97
+
return <div>loading... (prefetching your timeline)</div>;
98
+
}
24
99
function Home() {
25
100
const {
26
101
agent,
···
30
105
loading: loadering,
31
106
authed,
32
107
} = useAuth();
108
+
109
+
useEffect(() => {
110
+
if (agent?.did) {
111
+
store.set(authedAtom, true);
112
+
} else {
113
+
store.set(authedAtom, false);
114
+
}
115
+
}, [loginStatus, agent, authed]);
116
+
useEffect(() => {
117
+
if (agent) {
118
+
store.set(agentAtom, agent);
119
+
} else {
120
+
store.set(agentAtom, null);
121
+
}
122
+
}, [loginStatus, agent, authed]);
123
+
33
124
//const { get, set } = usePersistentStore();
34
125
// const [feed, setFeed] = React.useState<any[]>([]);
35
126
// const [loading, setLoading] = React.useState(true);
···
67
158
// }, [prefs]);
68
159
69
160
// const savedFeeds = savedFeedsPref?.items || [];
70
-
161
+
71
162
const identityresultmaybe = useQueryIdentity(agent?.did);
72
-
const identity = identityresultmaybe?.data
163
+
const identity = identityresultmaybe?.data;
73
164
74
-
const prefsresultmaybe = useQueryPreferences({agent: agent ?? undefined, pdsUrl: identity?.pds});
75
-
const prefs = prefsresultmaybe?.data
76
-
165
+
const prefsresultmaybe = useQueryPreferences({
166
+
agent: agent ?? undefined,
167
+
pdsUrl: identity?.pds,
168
+
});
169
+
const prefs = prefsresultmaybe?.data;
170
+
77
171
const savedFeeds = React.useMemo(() => {
78
172
const savedFeedsPref = prefs?.preferences?.find(
79
173
(p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2"
···
81
175
return savedFeedsPref?.items || [];
82
176
}, [prefs]);
83
177
84
-
178
+
const [persistentSelectedFeed, setPersistentSelectedFeed] =
179
+
useAtom(selectedFeedUriAtom); // React.useState<string | null>(null);
180
+
const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState(
181
+
persistentSelectedFeed
182
+
); // React.useState<string | null>(null);
183
+
const selectedFeed = agent?.did
184
+
? persistentSelectedFeed
185
+
: unauthedSelectedFeed;
186
+
const setSelectedFeed = agent?.did
187
+
? setPersistentSelectedFeed
188
+
: setUnauthedSelectedFeed;
85
189
86
-
const [selectedFeed, setSelectedFeed] = React.useState<string | null>(null);
87
-
190
+
console.log("my selectedFeed is: ", selectedFeed);
88
191
React.useEffect(() => {
89
192
const fallbackFeed =
90
193
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot";
91
194
if (authed) {
195
+
if (selectedFeed) return;
92
196
if (savedFeeds.length > 0) {
93
197
setSelectedFeed((prev) =>
94
198
prev && savedFeeds.some((f: any) => f.value === prev)
95
199
? prev
96
-
: savedFeeds[0].value,
200
+
: savedFeeds[0].value
97
201
);
98
202
} else {
203
+
if (selectedFeed) return;
99
204
setSelectedFeed(fallbackFeed);
100
205
}
101
206
} else {
207
+
if (selectedFeed) return;
102
208
setSelectedFeed(fallbackFeed);
103
209
}
104
-
}, [savedFeeds, authed]);
210
+
}, [savedFeeds, authed, setSelectedFeed]);
105
211
106
212
// React.useEffect(() => {
107
213
// if (loadering || !selectedFeed) return;
···
185
291
// ignore = true;
186
292
// };
187
293
// }, [authed, agent, loadering, selectedFeed, get, set]);
188
-
294
+
295
+
const [scrollPositions, setScrollPositions] = useAtom(
296
+
feedScrollPositionsAtom
297
+
);
298
+
299
+
const scrollRef = React.useRef<Record<string, number>>({});
300
+
301
+
useEffect(() => {
302
+
const onScroll = () => {
303
+
//if (!selectedFeed) return;
304
+
scrollRef.current[selectedFeed ?? "null"] = window.scrollY;
305
+
};
306
+
window.addEventListener("scroll", onScroll, { passive: true });
307
+
return () => window.removeEventListener("scroll", onScroll);
308
+
}, [selectedFeed]);
309
+
const [donerestored, setdonerestored] = React.useState(false);
310
+
311
+
useEffect(() => {
312
+
return () => {
313
+
if (!donerestored) return;
314
+
console.log("FEEDSCROLLSHIT saving at uhhh: ", scrollRef.current);
315
+
//if (!selectedFeed) return;
316
+
setScrollPositions((prev) => ({
317
+
...prev,
318
+
[selectedFeed ?? "null"]:
319
+
scrollRef.current[selectedFeed ?? "null"] ?? 0,
320
+
}));
321
+
};
322
+
}, [selectedFeed, setScrollPositions, donerestored]);
323
+
324
+
const [restoringScrollPosition, setRestoringScrollPosition] =
325
+
React.useState(false);
326
+
327
+
useLayoutEffect(() => {
328
+
setRestoringScrollPosition(true);
329
+
const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0;
330
+
331
+
let raf = requestAnimationFrame(() => {
332
+
// setRestoringScrollPosition(true);
333
+
// raf = requestAnimationFrame(() => {
334
+
// window.scrollTo({ top: savedPosition, behavior: "instant" });
335
+
// setRestoringScrollPosition(false);
336
+
// setdonerestored(true);
337
+
// });
338
+
window.scrollTo({ top: savedPosition, behavior: "instant" });
339
+
setRestoringScrollPosition(false);
340
+
setdonerestored(true);
341
+
});
342
+
343
+
return () => cancelAnimationFrame(raf);
344
+
}, [selectedFeed, scrollPositions]);
189
345
190
-
const feedGengetrecordquery = useQueryArbitrary(selectedFeed??undefined);
346
+
const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined);
191
347
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
192
348
193
349
// const {
···
204
360
205
361
// const feed = feedData?.feed || [];
206
362
207
-
const isReadyForAuthedFeed = authed && agent && identity?.pds && feedServiceDid;
363
+
const isReadyForAuthedFeed =
364
+
authed && agent && identity?.pds && feedServiceDid;
208
365
const isReadyForUnauthedFeed = !authed && selectedFeed;
209
366
210
367
return (
211
-
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
368
+
<div className="relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
212
369
<div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin">
213
370
{savedFeeds.length > 0 ? (
214
371
savedFeeds.map((item: any, idx: number) => {
···
252
409
/>
253
410
))} */}
254
411
255
-
{(authed && (!identity?.pds || !feedServiceDid)) && (
256
-
<div className="p-4 text-center text-gray-500">Preparing your feed...</div>
412
+
{authed && (!identity?.pds || !feedServiceDid) && (
413
+
<div className="p-4 text-center text-gray-500">
414
+
Preparing your feed...
415
+
</div>
257
416
)}
258
417
259
-
{(isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
260
-
<InfiniteCustomFeed
261
-
feedUri={selectedFeed!}
262
-
pdsUrl={identity?.pds}
263
-
feedServiceDid={feedServiceDid}
264
-
/>
418
+
{isReadyForAuthedFeed || isReadyForUnauthedFeed ? (
419
+
<InfiniteCustomFeed
420
+
feedUri={selectedFeed!}
421
+
pdsUrl={identity?.pds}
422
+
feedServiceDid={feedServiceDid}
423
+
/>
265
424
) : (
266
-
<div className="p-4 text-center text-gray-500">Select a feed to get started.</div>
425
+
<div className="p-4 text-center text-gray-500">
426
+
Select a feed to get started.
427
+
</div>
428
+
)}
429
+
{false && restoringScrollPosition && (
430
+
<div className="fixed top-1/2 left-1/2 right-1/2">
431
+
restoringScrollPosition
432
+
</div>
267
433
)}
268
434
</div>
269
435
);
···
295
461
} catch {}
296
462
}
297
463
const url = `https://free-fly-24.deno.dev/resolve-did-web?did=${encodeURIComponent(
298
-
didweb,
464
+
didweb
299
465
)}`;
300
466
const res = await fetch(url);
301
467
if (!res.ok) throw new Error("Failed to resolve didwebdoc");
+23
-3
src/utils/atoms.ts
+23
-3
src/utils/atoms.ts
···
1
-
import { atom } from "jotai";
1
+
import type AtpAgent from "@atproto/api";
2
+
import { atom, createStore } from "jotai";
3
+
import { atomWithStorage } from 'jotai/utils';
4
+
5
+
export const store = createStore();
2
6
3
-
export const selectedFeedUriAtom = atom<string | null>(null);
7
+
export const selectedFeedUriAtom = atomWithStorage<string | null>(
8
+
'selectedFeedUri',
9
+
null
10
+
);
4
11
5
-
export const feedScrollPositionsAtom = atom<Record<string, number>>({});
12
+
//export const feedScrollPositionsAtom = atom<Record<string, number>>({});
13
+
14
+
export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>(
15
+
'feedscrollpositions',
16
+
{}
17
+
);
18
+
19
+
export const likedPostsAtom = atomWithStorage<Record<string, boolean>>(
20
+
'likedPosts',
21
+
{}
22
+
);
23
+
24
+
export const agentAtom = atom<AtpAgent|null>(null);
25
+
export const authedAtom = atom<boolean>(false);
+4
-1
src/utils/useQuery.ts
+4
-1
src/utils/useQuery.ts
···
234
234
return undefined;
235
235
}
236
236
},
237
+
// enforce short lifespan
238
+
staleTime: 5 * 60 * 1000, // 5 minutes
239
+
gcTime: 5 * 60 * 1000,
237
240
});
238
241
}
239
242
export function useQueryConstellation(query: {
···
399
402
400
403
export function constructArbitraryQuery(uri?: string) {
401
404
return queryOptions({
402
-
queryKey: ["post", uri],
405
+
queryKey: ["arbitrary", uri],
403
406
queryFn: async () => {
404
407
if (!uri) return undefined as undefined
405
408
const res = await fetch(