+17
-13
README.md
+17
-13
README.md
···
1
-
# Initial Red Dwarf Open Source Release
2
-
i made red dwarf in three days
1
+
# Red Dwarf
2
+
Red Dwarf is a Bluesky client that does not use any AppView servers, instead it gathers the data from [Constellation](https://constellation.microcosm.blue/) and each users' PDS.
3
3
4
-
it isnt really that well made
5
-
(go take a look at `UniversalPostRenderer.tsx`)
4
+

5
+
6
+
huge thanks to [Microcosm](https://microcosm.blue/) for making this possible
6
7
7
-
further development is pending
8
-
(especially around future plans for user-resolved constellation instances)
8
+
## useQuery
9
+
Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch!
9
10
10
-
huge thanks to Constellation ([Microcosm](https://microcosm.blue/)) for making this possible
11
+
all core data fetching logic is now centralized in `src/utils/useQuery.ts` and exposed as a collection of custom react hooks. theres two basic types of custom hooks, the use-once, and the inifinite query ones (used for paginated requests like feed skeletons and listrecord)
11
12
12
13
## UniversalPostRenderer
13
14
its a mega component rooted in my Masonry "[TestFront](https://testfront-87q.pages.dev/)" project. its goal is simple: have one component render everything. it has several shims to normalize different post data formats into a single format the component can handle. unlike TestFront, it has no animations, though some weird component splits might linger from the old version.
14
15
15
16
to adapt TestFront's bsky-api-based `UniversalPostRenderer` to Red Dwarf's model of fetching records directly from each user's PDS and then querying constellation for backlinks, i wrap it in `UniversalPostRendererATURILoader`, which handles raw record and backlink fetching. to bridge the gap between bsky api shapes like `PostView` and the raw record, i use `UniversalPostRendererRawRecordShim`. this way, the core `UniversalPostRenderer` remains the same between TestFront and Red Dwarf (with the only difference being in the red dwarf version the framer motion animations are removed).
16
17
18
+
## Microcosm
19
+
### Constellation
20
+
the beating heart of Red Dwarf, the backlink index that provides contextual information not available from direct PDS queries. Every post's likes, replies, and reposts all come from constellation. Unfortunately i wasnt using tanstack query at the time (compared to its intensive use in the old version of ForumTest) so it is not using any caching
21
+
22
+
### Slingshot
23
+
though Red Dwarf was made before Microcosm [Slingshot](https://slingshot.microcosm.blue) existed, it now uses Slingshot to reduce load from each respective PDS server. Slignshot
17
24
18
25
## PassAuthProvider
19
26
a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). in ForumTest, its been superseded by the [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx). i havent backported it here and maybe soon, although oauth makes it slightly more annoying to do development because it requires a tunnel so maybe someday if i managed to merge the password and oauth logins to provide both options
20
-
21
-
## Constellation
22
-
the beating heart of Red Dwarf, the backlink index that provides contextual information not available from direct PDS queries. Every post's likes, replies, and reposts all come from constellation. Unfortunately i wasnt using tanstack query at the time (compared to its intensive use in the old version of ForumTest) so it is not using any caching
23
-
24
-
Red Dwarf was made before Microcosm [Slingshot](https://slingshot.microcosm.blue) existed, and it would be a very good idea to migrate to it in the future maybe (to reduce load from individual PDS servers)
25
27
26
28
## Custom Feeds
27
29
they work, but i havent implemented a simple way of viewing arbitraty feeds. currently it either loads discover (logged out) or your saved feeds (logged in) and its not a technical limitation i just havent implemented it yet
···
34
36
and for list feeds, you can just use something like graze or skyfeed to input a list of users and output a custom feed
35
37
36
38
## Tanstack Router
37
-
it does the job, nothing very specific was used here
39
+
it does the job, nothing very specific was used here
40
+
41
+
im planning to use the loader system on select pages to prevent loss of scroll positon and state though its really complex so i havent done it yet but the migration to tanstack query is a huge first step towards this goal
+57
package-lock.json
+57
package-lock.json
···
9
9
"@atproto/api": "^0.16.6",
10
10
"@tailwindcss/vite": "^4.0.6",
11
11
"@tanstack/react-devtools": "^0.2.2",
12
+
"@tanstack/react-query": "^5.85.6",
12
13
"@tanstack/react-router": "^1.130.2",
13
14
"@tanstack/react-router-devtools": "^1.131.5",
14
15
"@tanstack/router-plugin": "^1.121.2",
15
16
"idb-keyval": "^6.2.2",
17
+
"jotai": "^2.13.1",
16
18
"react": "^19.0.0",
17
19
"react-dom": "^19.0.0",
18
20
"react-player": "^3.3.2",
···
1882
1884
"url": "https://github.com/sponsors/tannerlinsley"
1883
1885
}
1884
1886
},
1887
+
"node_modules/@tanstack/query-core": {
1888
+
"version": "5.85.6",
1889
+
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.6.tgz",
1890
+
"integrity": "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==",
1891
+
"license": "MIT",
1892
+
"funding": {
1893
+
"type": "github",
1894
+
"url": "https://github.com/sponsors/tannerlinsley"
1895
+
}
1896
+
},
1885
1897
"node_modules/@tanstack/react-devtools": {
1886
1898
"version": "0.2.2",
1887
1899
"resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz",
···
1902
1914
"@types/react-dom": ">=16.8",
1903
1915
"react": ">=16.8",
1904
1916
"react-dom": ">=16.8"
1917
+
}
1918
+
},
1919
+
"node_modules/@tanstack/react-query": {
1920
+
"version": "5.85.6",
1921
+
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
1922
+
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
1923
+
"license": "MIT",
1924
+
"dependencies": {
1925
+
"@tanstack/query-core": "5.85.6"
1926
+
},
1927
+
"funding": {
1928
+
"type": "github",
1929
+
"url": "https://github.com/sponsors/tannerlinsley"
1930
+
},
1931
+
"peerDependencies": {
1932
+
"react": "^18 || ^19"
1905
1933
}
1906
1934
},
1907
1935
"node_modules/@tanstack/react-router": {
···
3353
3381
"license": "MIT",
3354
3382
"bin": {
3355
3383
"jiti": "lib/jiti-cli.mjs"
3384
+
}
3385
+
},
3386
+
"node_modules/jotai": {
3387
+
"version": "2.13.1",
3388
+
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.1.tgz",
3389
+
"integrity": "sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==",
3390
+
"license": "MIT",
3391
+
"engines": {
3392
+
"node": ">=12.20.0"
3393
+
},
3394
+
"peerDependencies": {
3395
+
"@babel/core": ">=7.0.0",
3396
+
"@babel/template": ">=7.0.0",
3397
+
"@types/react": ">=17.0.0",
3398
+
"react": ">=17.0.0"
3399
+
},
3400
+
"peerDependenciesMeta": {
3401
+
"@babel/core": {
3402
+
"optional": true
3403
+
},
3404
+
"@babel/template": {
3405
+
"optional": true
3406
+
},
3407
+
"@types/react": {
3408
+
"optional": true
3409
+
},
3410
+
"react": {
3411
+
"optional": true
3412
+
}
3356
3413
}
3357
3414
},
3358
3415
"node_modules/js-tokens": {
+2
package.json
+2
package.json
···
13
13
"@atproto/api": "^0.16.6",
14
14
"@tailwindcss/vite": "^4.0.6",
15
15
"@tanstack/react-devtools": "^0.2.2",
16
+
"@tanstack/react-query": "^5.85.6",
16
17
"@tanstack/react-router": "^1.130.2",
17
18
"@tanstack/react-router-devtools": "^1.131.5",
18
19
"@tanstack/router-plugin": "^1.121.2",
19
20
"idb-keyval": "^6.2.2",
21
+
"jotai": "^2.13.1",
20
22
"react": "^19.0.0",
21
23
"react-dom": "^19.0.0",
22
24
"react-player": "^3.3.2",
public/screenshot.png
public/screenshot.png
This is a binary file and will not be displayed.
+81
src/components/InfiniteCustomFeed.tsx
+81
src/components/InfiniteCustomFeed.tsx
···
1
+
import * as React from "react";
2
+
//import { useInView } from "react-intersection-observer";
3
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
4
+
import { useAuth } from "~/providers/PassAuthProvider";
5
+
import { useQueryArbitrary, useQueryIdentity, useInfiniteQueryFeedSkeleton } from "~/utils/useQuery";
6
+
7
+
interface InfiniteCustomFeedProps {
8
+
feedUri: string;
9
+
pdsUrl?: string;
10
+
feedServiceDid?: string;
11
+
}
12
+
13
+
export function InfiniteCustomFeed({ feedUri, pdsUrl, feedServiceDid }: InfiniteCustomFeedProps) {
14
+
const { agent, authed } = useAuth();
15
+
16
+
// const identityresultmaybe = useQueryIdentity(agent?.did);
17
+
// const identity = identityresultmaybe?.data;
18
+
// const feedGenGetRecordQuery = useQueryArbitrary(feedUri);
19
+
20
+
const {
21
+
data,
22
+
error,
23
+
isLoading,
24
+
isError,
25
+
hasNextPage,
26
+
fetchNextPage,
27
+
isFetchingNextPage,
28
+
} = useInfiniteQueryFeedSkeleton({
29
+
feedUri: feedUri,
30
+
agent: agent ?? undefined,
31
+
isAuthed: authed ?? false,
32
+
pdsUrl: pdsUrl,
33
+
feedServiceDid: feedServiceDid,
34
+
});
35
+
36
+
//const { ref, inView } = useInView();
37
+
38
+
// React.useEffect(() => {
39
+
// if (inView && hasNextPage && !isFetchingNextPage) {
40
+
// fetchNextPage();
41
+
// }
42
+
// }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
43
+
44
+
if (isLoading) {
45
+
return <div className="p-4 text-center text-gray-500">Loading feed...</div>;
46
+
}
47
+
48
+
if (isError) {
49
+
return <div className="p-4 text-center text-red-500">Error: {error.message}</div>;
50
+
}
51
+
52
+
const allPosts = data?.pages.flatMap((page) => {if (page) return page.feed}) ?? [];
53
+
54
+
if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) {
55
+
return <div className="p-4 text-center text-gray-500">No posts in this feed.</div>;
56
+
}
57
+
58
+
return (
59
+
<>
60
+
{allPosts.map((item, i) => {
61
+
if (item) return (
62
+
<UniversalPostRendererATURILoader key={item.post || i} atUri={item.post} />
63
+
)})}
64
+
{/* allPosts?: {allPosts ? "true" : "false"}
65
+
hasNextPage?: {hasNextPage ? "true" : "false"}
66
+
isFetchingNextPage?: {isFetchingNextPage ? "true" : "false"} */}
67
+
{isFetchingNextPage && (
68
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
69
+
)}
70
+
{hasNextPage && !isFetchingNextPage && (
71
+
<button
72
+
onClick={() => fetchNextPage()}
73
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
74
+
>
75
+
Load More Posts
76
+
</button>
77
+
)}
78
+
{!hasNextPage && <div className="p-4 text-center text-gray-500">End of feed.</div>}
79
+
</>
80
+
);
81
+
}
+551
-689
src/components/UniversalPostRenderer.tsx
+551
-689
src/components/UniversalPostRenderer.tsx
···
2
2
import { usePersistentStore } from "~/providers/PersistentStoreProvider";
3
3
import { useNavigate } from "@tanstack/react-router";
4
4
import { type SVGProps } from "react";
5
+
import { useHydratedEmbed } from "~/utils/useHydrated";
6
+
import {
7
+
useQueryPost,
8
+
useQueryIdentity,
9
+
useQueryProfile,
10
+
useQueryConstellation,
11
+
} from "~/utils/useQuery";
5
12
6
13
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
7
14
return obj as $Typed<T>;
···
16
23
detailed?: boolean;
17
24
bottomReplyLine?: boolean;
18
25
topReplyLine?: boolean;
19
-
bottomBorder?:boolean;
20
-
feedviewpost?:boolean;
26
+
bottomBorder?: boolean;
27
+
feedviewpost?: boolean;
21
28
}
22
29
23
-
export async function cachedGetRecord({
24
-
atUri,
25
-
cacheTimeout = CACHE_TIMEOUT,
26
-
get,
27
-
set,
28
-
}: {
29
-
atUri: string;
30
-
//resolved: { pdsUrl: string; did: string } | null | undefined;
31
-
cacheTimeout?: number;
32
-
get: (key: string) => any;
33
-
set: (key: string, value: string) => void;
34
-
}): Promise<any> {
35
-
const cacheKey = `record:${atUri}`;
36
-
const cached = get(cacheKey);
37
-
const now = Date.now();
38
-
if (
39
-
cached &&
40
-
cached.value &&
41
-
cached.time &&
42
-
now - cached.time < cacheTimeout
43
-
) {
44
-
try {
45
-
return JSON.parse(cached.value);
46
-
} catch {
47
-
// fall through to fetch
48
-
}
49
-
}
50
-
const parsed = parseAtUri(atUri);
51
-
if (!parsed) return null;
52
-
const resolved = await cachedResolveIdentity({
53
-
didOrHandle: parsed.did,
54
-
get,
55
-
set,
56
-
});
57
-
if (!resolved?.pdsUrl || !resolved?.did)
58
-
throw new Error("Missing resolved PDS info");
30
+
// export async function cachedGetRecord({
31
+
// atUri,
32
+
// cacheTimeout = CACHE_TIMEOUT,
33
+
// get,
34
+
// set,
35
+
// }: {
36
+
// atUri: string;
37
+
// //resolved: { pdsUrl: string; did: string } | null | undefined;
38
+
// cacheTimeout?: number;
39
+
// get: (key: string) => any;
40
+
// set: (key: string, value: string) => void;
41
+
// }): Promise<any> {
42
+
// const cacheKey = `record:${atUri}`;
43
+
// const cached = get(cacheKey);
44
+
// const now = Date.now();
45
+
// if (
46
+
// cached &&
47
+
// cached.value &&
48
+
// cached.time &&
49
+
// now - cached.time < cacheTimeout
50
+
// ) {
51
+
// try {
52
+
// return JSON.parse(cached.value);
53
+
// } catch {
54
+
// // fall through to fetch
55
+
// }
56
+
// }
57
+
// const parsed = parseAtUri(atUri);
58
+
// if (!parsed) return null;
59
+
// const resolved = await cachedResolveIdentity({
60
+
// didOrHandle: parsed.did,
61
+
// get,
62
+
// set,
63
+
// });
64
+
// if (!resolved?.pdsUrl || !resolved?.did)
65
+
// throw new Error("Missing resolved PDS info");
59
66
60
-
if (!parsed) throw new Error("Invalid atUri");
61
-
const { collection, rkey } = parsed;
62
-
const url = `${
63
-
resolved.pdsUrl
64
-
}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
65
-
resolved.did,
66
-
)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(
67
-
rkey,
68
-
)}`;
69
-
const res = await fetch(url);
70
-
if (!res.ok) throw new Error("Failed to fetch base record");
71
-
const data = await res.json();
72
-
set(cacheKey, JSON.stringify(data));
73
-
return data;
74
-
}
67
+
// if (!parsed) throw new Error("Invalid atUri");
68
+
// const { collection, rkey } = parsed;
69
+
// const url = `${
70
+
// resolved.pdsUrl
71
+
// }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
72
+
// resolved.did,
73
+
// )}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(
74
+
// rkey,
75
+
// )}`;
76
+
// const res = await fetch(url);
77
+
// if (!res.ok) throw new Error("Failed to fetch base record");
78
+
// const data = await res.json();
79
+
// set(cacheKey, JSON.stringify(data));
80
+
// return data;
81
+
// }
75
82
76
-
export async function cachedResolveIdentity({
77
-
didOrHandle,
78
-
cacheTimeout = HANDLE_DID_CACHE_TIMEOUT,
79
-
get,
80
-
set,
81
-
}: {
82
-
didOrHandle: string;
83
-
cacheTimeout?: number;
84
-
get: (key: string) => any;
85
-
set: (key: string, value: string) => void;
86
-
}): Promise<any> {
87
-
const isDidInput = didOrHandle.startsWith("did:");
88
-
const cacheKey = `handleDid:${didOrHandle}`;
89
-
const now = Date.now();
90
-
const cached = get(cacheKey);
91
-
if (
92
-
cached &&
93
-
cached.value &&
94
-
cached.time &&
95
-
now - cached.time < cacheTimeout
96
-
) {
97
-
try {
98
-
return JSON.parse(cached.value);
99
-
} catch {}
100
-
}
101
-
const url = `https://free-fly-24.deno.dev/?${
102
-
isDidInput
103
-
? `did=${encodeURIComponent(didOrHandle)}`
104
-
: `handle=${encodeURIComponent(didOrHandle)}`
105
-
}`;
106
-
const res = await fetch(url);
107
-
if (!res.ok) throw new Error("Failed to resolve handle/did");
108
-
const data = await res.json();
109
-
set(cacheKey, JSON.stringify(data));
110
-
if (!isDidInput && data.did) {
111
-
set(`handleDid:${data.did}`, JSON.stringify(data));
112
-
}
113
-
return data;
114
-
}
83
+
// export async function cachedResolveIdentity({
84
+
// didOrHandle,
85
+
// cacheTimeout = HANDLE_DID_CACHE_TIMEOUT,
86
+
// get,
87
+
// set,
88
+
// }: {
89
+
// didOrHandle: string;
90
+
// cacheTimeout?: number;
91
+
// get: (key: string) => any;
92
+
// set: (key: string, value: string) => void;
93
+
// }): Promise<any> {
94
+
// const isDidInput = didOrHandle.startsWith("did:");
95
+
// const cacheKey = `handleDid:${didOrHandle}`;
96
+
// const now = Date.now();
97
+
// const cached = get(cacheKey);
98
+
// if (
99
+
// cached &&
100
+
// cached.value &&
101
+
// cached.time &&
102
+
// now - cached.time < cacheTimeout
103
+
// ) {
104
+
// try {
105
+
// return JSON.parse(cached.value);
106
+
// } catch {}
107
+
// }
108
+
// const url = `https://free-fly-24.deno.dev/?${
109
+
// isDidInput
110
+
// ? `did=${encodeURIComponent(didOrHandle)}`
111
+
// : `handle=${encodeURIComponent(didOrHandle)}`
112
+
// }`;
113
+
// const res = await fetch(url);
114
+
// if (!res.ok) throw new Error("Failed to resolve handle/did");
115
+
// const data = await res.json();
116
+
// set(cacheKey, JSON.stringify(data));
117
+
// if (!isDidInput && data.did) {
118
+
// set(`handleDid:${data.did}`, JSON.stringify(data));
119
+
// }
120
+
// return data;
121
+
// }
115
122
116
123
export function UniversalPostRendererATURILoader({
117
124
atUri,
···
119
126
detailed = false,
120
127
bottomReplyLine,
121
128
topReplyLine,
122
-
bottomBorder= true,
129
+
bottomBorder = true,
123
130
feedviewpost = false,
124
131
}: UniversalPostRendererATURILoaderProps) {
125
132
console.log("atUri", atUri);
126
-
const { get, set } = usePersistentStore();
127
-
const [record, setRecord] = React.useState<any>(null);
128
-
const [links, setLinks] = React.useState<any>(null);
133
+
//const { get, set } = usePersistentStore();
134
+
//const [record, setRecord] = React.useState<any>(null);
135
+
//const [links, setLinks] = React.useState<any>(null);
129
136
//const [error, setError] = React.useState<string | null>(null);
130
137
//const [cacheTime, setCacheTime] = React.useState<number | null>(null);
131
-
const [resolved, setResolved] = React.useState<any>(null); // { did, pdsUrl, bskyPds, handle }
132
-
const [opProfile, setOpProfile] = React.useState<any>(null);
138
+
//const [resolved, setResolved] = React.useState<any>(null); // { did, pdsUrl, bskyPds, handle }
139
+
//const [opProfile, setOpProfile] = React.useState<any>(null);
133
140
// const [opProfileCacheTime, setOpProfileCacheTime] = React.useState<
134
141
// number | null
135
142
// >(null);
···
141
148
console.log("did", did);
142
149
console.log("rkey", rkey);
143
150
144
-
React.useEffect(() => {
145
-
const checkCache = async () => {
146
-
const postUri = atUri;
147
-
const cacheKey = `record:${postUri}`;
148
-
const cached = await get(cacheKey);
149
-
const now = Date.now();
150
-
console.log(
151
-
"UniversalPostRenderer checking cache for",
152
-
cacheKey,
153
-
"cached:",
154
-
!!cached,
155
-
);
156
-
if (
157
-
cached &&
158
-
cached.value &&
159
-
cached.time &&
160
-
now - cached.time < CACHE_TIMEOUT
161
-
) {
162
-
try {
163
-
console.log("UniversalPostRenderer found cached data for", cacheKey);
164
-
setRecord(JSON.parse(cached.value));
165
-
} catch {
166
-
setRecord(null);
167
-
}
168
-
}
169
-
};
170
-
checkCache();
171
-
}, [atUri, get]);
151
+
// React.useEffect(() => {
152
+
// const checkCache = async () => {
153
+
// const postUri = atUri;
154
+
// const cacheKey = `record:${postUri}`;
155
+
// const cached = await get(cacheKey);
156
+
// const now = Date.now();
157
+
// console.log(
158
+
// "UniversalPostRenderer checking cache for",
159
+
// cacheKey,
160
+
// "cached:",
161
+
// !!cached,
162
+
// );
163
+
// if (
164
+
// cached &&
165
+
// cached.value &&
166
+
// cached.time &&
167
+
// now - cached.time < CACHE_TIMEOUT
168
+
// ) {
169
+
// try {
170
+
// console.log("UniversalPostRenderer found cached data for", cacheKey);
171
+
// setRecord(JSON.parse(cached.value));
172
+
// } catch {
173
+
// setRecord(null);
174
+
// }
175
+
// }
176
+
// };
177
+
// checkCache();
178
+
// }, [atUri, get]);
172
179
173
-
React.useEffect(() => {
174
-
if (!did || record) return;
175
-
(async () => {
176
-
try {
177
-
const resolvedData = await cachedResolveIdentity({
178
-
didOrHandle: did,
179
-
get,
180
-
set,
181
-
});
182
-
setResolved(resolvedData);
183
-
} catch (e: any) {
184
-
//setError("Failed to resolve handle/did: " + e?.message);
185
-
}
186
-
})();
187
-
}, [did, get, set, record]);
180
+
const {
181
+
data: postQuery,
182
+
isLoading: isPostLoading,
183
+
isError: isPostError,
184
+
} = useQueryPost(atUri);
185
+
//const record = postQuery?.value;
188
186
189
-
React.useEffect(() => {
190
-
if (!resolved || !resolved.pdsUrl || !resolved.did || !rkey || record)
191
-
return;
192
-
let ignore = false;
193
-
(async () => {
194
-
try {
195
-
const data = await cachedGetRecord({
196
-
atUri,
197
-
get,
198
-
set,
199
-
});
200
-
if (!ignore) setRecord(data);
201
-
} catch (e: any) {
202
-
//if (!ignore) setError("Failed to fetch base record: " + e?.message);
203
-
}
204
-
})();
205
-
return () => {
206
-
ignore = true;
207
-
};
208
-
}, [resolved, rkey, atUri, record]);
187
+
// React.useEffect(() => {
188
+
// if (!did || record) return;
189
+
// (async () => {
190
+
// try {
191
+
// const resolvedData = await cachedResolveIdentity({
192
+
// didOrHandle: did,
193
+
// get,
194
+
// set,
195
+
// });
196
+
// setResolved(resolvedData);
197
+
// } catch (e: any) {
198
+
// //setError("Failed to resolve handle/did: " + e?.message);
199
+
// }
200
+
// })();
201
+
// }, [did, get, set, record]);
209
202
210
-
React.useEffect(() => {
211
-
if (!resolved || !resolved.did || !rkey) return;
212
-
const fetchLinks = async () => {
213
-
const postUri = atUri;
214
-
const cacheKey = `constellation:${postUri}`;
215
-
const cached = await get(cacheKey);
216
-
const now = Date.now();
217
-
if (
218
-
cached &&
219
-
cached.value &&
220
-
cached.time &&
221
-
now - cached.time < CACHE_TIMEOUT
222
-
) {
223
-
try {
224
-
const data = JSON.parse(cached.value);
225
-
setLinks(data);
226
-
if (onConstellation) onConstellation(data);
227
-
} catch {
228
-
setLinks(null);
229
-
}
230
-
//setCacheTime(cached.time);
231
-
return;
232
-
}
233
-
try {
234
-
const url = `https://constellation.microcosm.blue/links/all?target=${encodeURIComponent(
235
-
atUri,
236
-
)}`;
237
-
const res = await fetch(url);
238
-
if (!res.ok) throw new Error("Failed to fetch constellation links");
239
-
const data = await res.json();
240
-
setLinks(data);
241
-
//setCacheTime(now);
242
-
set(cacheKey, JSON.stringify(data));
243
-
if (onConstellation) onConstellation(data);
244
-
} catch (e: any) {
245
-
//setError("Failed to fetch constellation links: " + e?.message);
246
-
}
247
-
};
248
-
fetchLinks();
249
-
}, [resolved, rkey, get, set, atUri, onConstellation]);
203
+
const { data: resolved } = useQueryIdentity(did || "");
250
204
251
-
React.useEffect(() => {
252
-
if (!record || !resolved || !resolved.did) return;
253
-
const fetchOpProfile = async () => {
254
-
const opDid = resolved.did;
255
-
const postUri = atUri;
256
-
const cacheKey = `profile:${postUri}`;
257
-
const cached = await get(cacheKey);
258
-
const now = Date.now();
259
-
if (
260
-
cached &&
261
-
cached.value &&
262
-
cached.time &&
263
-
now - cached.time < CACHE_TIMEOUT
264
-
) {
265
-
try {
266
-
setOpProfile(JSON.parse(cached.value));
267
-
} catch {
268
-
setOpProfile(null);
269
-
}
270
-
//setOpProfileCacheTime(cached.time);
271
-
return;
272
-
}
273
-
try {
274
-
let opResolvedRaw = await get(`handleDid:${opDid}`);
275
-
let opResolved: any = null;
276
-
if (
277
-
opResolvedRaw &&
278
-
opResolvedRaw.value &&
279
-
opResolvedRaw.time &&
280
-
now - opResolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT
281
-
) {
282
-
try {
283
-
opResolved = JSON.parse(opResolvedRaw.value);
284
-
} catch {
285
-
opResolved = null;
286
-
}
287
-
} else {
288
-
const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(
289
-
opDid,
290
-
)}`;
291
-
const res = await fetch(url);
292
-
if (!res.ok) throw new Error("Failed to resolve OP did");
293
-
opResolved = await res.json();
294
-
set(`handleDid:${opDid}`, JSON.stringify(opResolved));
295
-
}
296
-
if (!opResolved || !opResolved.pdsUrl)
297
-
throw new Error("OP did resolution failed or missing pdsUrl");
298
-
const profileUrl = `${
299
-
opResolved.pdsUrl
300
-
}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
301
-
opDid,
302
-
)}&collection=app.bsky.actor.profile&rkey=self`;
303
-
const profileRes = await fetch(profileUrl);
304
-
if (!profileRes.ok) throw new Error("Failed to fetch OP profile");
305
-
const profileData = await profileRes.json();
306
-
setOpProfile(profileData);
307
-
//setOpProfileCacheTime(now);
308
-
set(cacheKey, JSON.stringify(profileData));
309
-
} catch (e: any) {
310
-
//setError("Failed to fetch OP profile: " + e?.message);
311
-
}
312
-
};
313
-
fetchOpProfile();
314
-
}, [record, get, set, rkey, resolved, atUri]);
205
+
// React.useEffect(() => {
206
+
// if (!resolved || !resolved.pdsUrl || !resolved.did || !rkey || record)
207
+
// return;
208
+
// let ignore = false;
209
+
// (async () => {
210
+
// try {
211
+
// const data = await cachedGetRecord({
212
+
// atUri,
213
+
// get,
214
+
// set,
215
+
// });
216
+
// if (!ignore) setRecord(data);
217
+
// } catch (e: any) {
218
+
// //if (!ignore) setError("Failed to fetch base record: " + e?.message);
219
+
// }
220
+
// })();
221
+
// return () => {
222
+
// ignore = true;
223
+
// };
224
+
// }, [resolved, rkey, atUri, record]);
225
+
226
+
// React.useEffect(() => {
227
+
// if (!resolved || !resolved.did || !rkey) return;
228
+
// const fetchLinks = async () => {
229
+
// const postUri = atUri;
230
+
// const cacheKey = `constellation:${postUri}`;
231
+
// const cached = await get(cacheKey);
232
+
// const now = Date.now();
233
+
// if (
234
+
// cached &&
235
+
// cached.value &&
236
+
// cached.time &&
237
+
// now - cached.time < CACHE_TIMEOUT
238
+
// ) {
239
+
// try {
240
+
// const data = JSON.parse(cached.value);
241
+
// setLinks(data);
242
+
// if (onConstellation) onConstellation(data);
243
+
// } catch {
244
+
// setLinks(null);
245
+
// }
246
+
// //setCacheTime(cached.time);
247
+
// return;
248
+
// }
249
+
// try {
250
+
// const url = `https://constellation.microcosm.blue/links/all?target=${encodeURIComponent(
251
+
// atUri,
252
+
// )}`;
253
+
// const res = await fetch(url);
254
+
// if (!res.ok) throw new Error("Failed to fetch constellation links");
255
+
// const data = await res.json();
256
+
// setLinks(data);
257
+
// //setCacheTime(now);
258
+
// set(cacheKey, JSON.stringify(data));
259
+
// if (onConstellation) onConstellation(data);
260
+
// } catch (e: any) {
261
+
// //setError("Failed to fetch constellation links: " + e?.message);
262
+
// }
263
+
// };
264
+
// fetchLinks();
265
+
// }, [resolved, rkey, get, set, atUri, onConstellation]);
266
+
267
+
const { data: links } = useQueryConstellation({
268
+
method: "/links/all",
269
+
target: atUri,
270
+
});
271
+
272
+
// React.useEffect(() => {
273
+
// if (!record || !resolved || !resolved.did) return;
274
+
// const fetchOpProfile = async () => {
275
+
// const opDid = resolved.did;
276
+
// const postUri = atUri;
277
+
// const cacheKey = `profile:${postUri}`;
278
+
// const cached = await get(cacheKey);
279
+
// const now = Date.now();
280
+
// if (
281
+
// cached &&
282
+
// cached.value &&
283
+
// cached.time &&
284
+
// now - cached.time < CACHE_TIMEOUT
285
+
// ) {
286
+
// try {
287
+
// setOpProfile(JSON.parse(cached.value));
288
+
// } catch {
289
+
// setOpProfile(null);
290
+
// }
291
+
// //setOpProfileCacheTime(cached.time);
292
+
// return;
293
+
// }
294
+
// try {
295
+
// let opResolvedRaw = await get(`handleDid:${opDid}`);
296
+
// let opResolved: any = null;
297
+
// if (
298
+
// opResolvedRaw &&
299
+
// opResolvedRaw.value &&
300
+
// opResolvedRaw.time &&
301
+
// now - opResolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT
302
+
// ) {
303
+
// try {
304
+
// opResolved = JSON.parse(opResolvedRaw.value);
305
+
// } catch {
306
+
// opResolved = null;
307
+
// }
308
+
// } else {
309
+
// const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(
310
+
// opDid,
311
+
// )}`;
312
+
// const res = await fetch(url);
313
+
// if (!res.ok) throw new Error("Failed to resolve OP did");
314
+
// opResolved = await res.json();
315
+
// set(`handleDid:${opDid}`, JSON.stringify(opResolved));
316
+
// }
317
+
// if (!opResolved || !opResolved.pdsUrl)
318
+
// throw new Error("OP did resolution failed or missing pdsUrl");
319
+
// const profileUrl = `${
320
+
// opResolved.pdsUrl
321
+
// }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
322
+
// opDid,
323
+
// )}&collection=app.bsky.actor.profile&rkey=self`;
324
+
// const profileRes = await fetch(profileUrl);
325
+
// if (!profileRes.ok) throw new Error("Failed to fetch OP profile");
326
+
// const profileData = await profileRes.json();
327
+
// setOpProfile(profileData);
328
+
// //setOpProfileCacheTime(now);
329
+
// set(cacheKey, JSON.stringify(profileData));
330
+
// } catch (e: any) {
331
+
// //setError("Failed to fetch OP profile: " + e?.message);
332
+
// }
333
+
// };
334
+
// fetchOpProfile();
335
+
// }, [record, get, set, rkey, resolved, atUri]);
336
+
337
+
const { data: opProfile } = useQueryProfile(
338
+
resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined
339
+
);
315
340
316
341
// const displayName =
317
342
// opProfile?.value?.displayName || resolved?.handle || resolved?.did;
···
332
357
setLikes(
333
358
links
334
359
? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0
335
-
: null,
360
+
: null
336
361
);
337
362
setReposts(
338
363
links
339
364
? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0
340
-
: null,
365
+
: null
341
366
);
342
367
setReplies(
343
368
links
344
369
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
345
370
?.records || 0
346
-
: null,
371
+
: null
347
372
);
348
373
}, [links]);
349
374
···
360
385
return (
361
386
<UniversalPostRendererRawRecordShim
362
387
detailed={detailed}
363
-
postRecord={record}
388
+
postRecord={postQuery}
364
389
profileRecord={opProfile}
365
390
aturi={atUri}
366
391
resolved={resolved}
···
386
411
detailed = false,
387
412
bottomReplyLine = false,
388
413
topReplyLine = false,
389
-
bottomBorder= true,
390
-
feedviewpost= false,
414
+
bottomBorder = true,
415
+
feedviewpost = false,
391
416
}: {
392
417
postRecord: any;
393
418
profileRecord: any;
···
402
427
bottomBorder?: boolean;
403
428
feedviewpost?: boolean;
404
429
}) {
430
+
console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
405
431
const navigate = useNavigate();
406
432
407
-
const { get, set } = usePersistentStore();
433
+
//const { get, set } = usePersistentStore();
408
434
function getAvatarUrl(opProfile: any) {
409
435
const link = opProfile?.value?.avatar?.ref?.["$link"];
410
436
if (!link) return null;
411
437
return `https://cdn.bsky.app/img/avatar/plain/${resolved?.did}/${link}@jpeg`;
412
438
}
413
439
414
-
const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined);
440
+
// const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined);
415
441
416
-
useEffect(() => {
417
-
const run = async () => {
418
-
if (!postRecord?.value?.embed) return;
419
-
const embed = postRecord?.value?.embed;
420
-
if (!embed || !embed.$type) {
421
-
setHydratedEmbed(undefined);
422
-
return;
423
-
}
442
+
// useEffect(() => {
443
+
// const run = async () => {
444
+
// if (!postRecord?.value?.embed) return;
445
+
// const embed = postRecord?.value?.embed;
446
+
// if (!embed || !embed.$type) {
447
+
// setHydratedEmbed(undefined);
448
+
// return;
449
+
// }
424
450
425
-
try {
426
-
let result: any;
451
+
// try {
452
+
// let result: any;
427
453
428
-
if (embed?.$type === "app.bsky.embed.recordWithMedia") {
429
-
const mediaEmbed = embed.media;
454
+
// if (embed?.$type === "app.bsky.embed.recordWithMedia") {
455
+
// const mediaEmbed = embed.media;
430
456
431
-
let hydratedMedia;
432
-
if (mediaEmbed?.$type === "app.bsky.embed.images") {
433
-
hydratedMedia = hydrateEmbedImages(mediaEmbed, resolved?.did);
434
-
} else if (mediaEmbed?.$type === "app.bsky.embed.external") {
435
-
hydratedMedia = hydrateEmbedExternal(mediaEmbed, resolved?.did);
436
-
} else if (mediaEmbed?.$type === "app.bsky.embed.video") {
437
-
hydratedMedia = hydrateEmbedVideo(mediaEmbed, resolved?.did);
438
-
} else {
439
-
throw new Error("idiot");
440
-
}
441
-
if (!hydratedMedia) throw new Error("idiot");
457
+
// let hydratedMedia;
458
+
// if (mediaEmbed?.$type === "app.bsky.embed.images") {
459
+
// hydratedMedia = hydrateEmbedImages(mediaEmbed, resolved?.did);
460
+
// } else if (mediaEmbed?.$type === "app.bsky.embed.external") {
461
+
// hydratedMedia = hydrateEmbedExternal(mediaEmbed, resolved?.did);
462
+
// } else if (mediaEmbed?.$type === "app.bsky.embed.video") {
463
+
// hydratedMedia = hydrateEmbedVideo(mediaEmbed, resolved?.did);
464
+
// } else {
465
+
// throw new Error("idiot");
466
+
// }
467
+
// if (!hydratedMedia) throw new Error("idiot");
442
468
443
-
// hydrate the outer recordWithMedia now using the hydrated media
444
-
result = await hydrateEmbedRecordWithMedia(
445
-
embed,
446
-
resolved?.did,
447
-
hydratedMedia,
448
-
get,
449
-
set,
450
-
);
451
-
} else {
452
-
const hydrated =
453
-
embed?.$type === "app.bsky.embed.images"
454
-
? hydrateEmbedImages(embed, resolved?.did)
455
-
: embed?.$type === "app.bsky.embed.external"
456
-
? hydrateEmbedExternal(embed, resolved?.did)
457
-
: embed?.$type === "app.bsky.embed.video"
458
-
? hydrateEmbedVideo(embed, resolved?.did)
459
-
: embed?.$type === "app.bsky.embed.record"
460
-
? hydrateEmbedRecord(embed, resolved?.did, get, set)
461
-
: undefined;
469
+
// // hydrate the outer recordWithMedia now using the hydrated media
470
+
// result = await hydrateEmbedRecordWithMedia(
471
+
// embed,
472
+
// resolved?.did,
473
+
// hydratedMedia,
474
+
// get,
475
+
// set,
476
+
// );
477
+
// } else {
478
+
// const hydrated =
479
+
// embed?.$type === "app.bsky.embed.images"
480
+
// ? hydrateEmbedImages(embed, resolved?.did)
481
+
// : embed?.$type === "app.bsky.embed.external"
482
+
// ? hydrateEmbedExternal(embed, resolved?.did)
483
+
// : embed?.$type === "app.bsky.embed.video"
484
+
// ? hydrateEmbedVideo(embed, resolved?.did)
485
+
// : embed?.$type === "app.bsky.embed.record"
486
+
// ? hydrateEmbedRecord(embed, resolved?.did, get, set)
487
+
// : undefined;
462
488
463
-
result = hydrated instanceof Promise ? await hydrated : hydrated;
464
-
}
489
+
// result = hydrated instanceof Promise ? await hydrated : hydrated;
490
+
// }
465
491
466
-
console.log(
467
-
String(result) + " hydrateEmbedRecordWithMedia hey hyeh ye",
468
-
);
469
-
setHydratedEmbed(result);
470
-
} catch (e) {
471
-
console.error("Error hydrating embed", e);
472
-
setHydratedEmbed(undefined);
473
-
}
474
-
};
492
+
// console.log(
493
+
// String(result) + " hydrateEmbedRecordWithMedia hey hyeh ye",
494
+
// );
495
+
// setHydratedEmbed(result);
496
+
// } catch (e) {
497
+
// console.error("Error hydrating embed", e);
498
+
// setHydratedEmbed(undefined);
499
+
// }
500
+
// };
501
+
502
+
// run();
503
+
// }, [postRecord, resolved?.did]);
475
504
476
-
run();
477
-
}, [postRecord, resolved?.did]);
505
+
const {
506
+
data: hydratedEmbed,
507
+
isLoading: isEmbedLoading,
508
+
error: embedError,
509
+
} = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
478
510
479
511
const parsedaturi = parseAtUri(aturi);
480
512
481
-
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(() => ({
482
-
$type: "app.bsky.feed.defs#postView",
483
-
uri: aturi,
484
-
cid: postRecord?.cid || "",
485
-
author: {
486
-
did: resolved?.did || "",
487
-
handle: resolved?.handle || "",
488
-
displayName: profileRecord?.value?.displayName || "",
489
-
avatar: getAvatarUrl(profileRecord) || "",
513
+
const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
514
+
() => ({
515
+
$type: "app.bsky.feed.defs#postView",
516
+
uri: aturi,
517
+
cid: postRecord?.cid || "",
518
+
author: {
519
+
did: resolved?.did || "",
520
+
handle: resolved?.handle || "",
521
+
displayName: profileRecord?.value?.displayName || "",
522
+
avatar: getAvatarUrl(profileRecord) || "",
523
+
viewer: undefined,
524
+
labels: profileRecord?.labels || undefined,
525
+
verification: undefined,
526
+
},
527
+
record: postRecord?.value || {},
528
+
embed: hydratedEmbed ?? undefined,
529
+
replyCount: repliesCount ?? 0,
530
+
repostCount: repostsCount ?? 0,
531
+
likeCount: likesCount ?? 0,
532
+
quoteCount: 0,
533
+
indexedAt: postRecord?.value?.createdAt || "",
490
534
viewer: undefined,
491
-
labels: profileRecord?.labels || undefined,
492
-
verification: undefined,
493
-
},
494
-
record: postRecord?.value || {},
495
-
embed: hydratedEmbed ?? undefined,
496
-
replyCount: repliesCount ?? 0,
497
-
repostCount: repostsCount ?? 0,
498
-
likeCount: likesCount ?? 0,
499
-
quoteCount: 0,
500
-
indexedAt: postRecord?.value?.createdAt || "",
501
-
viewer: undefined,
502
-
labels: postRecord?.labels || undefined,
503
-
threadgate: undefined,
504
-
}), [
505
-
aturi,
506
-
postRecord,
507
-
profileRecord,
508
-
hydratedEmbed,
509
-
repliesCount,
510
-
repostsCount,
511
-
likesCount,
512
-
resolved,
513
-
]);
535
+
labels: postRecord?.labels || undefined,
536
+
threadgate: undefined,
537
+
}),
538
+
[
539
+
aturi,
540
+
postRecord,
541
+
profileRecord,
542
+
hydratedEmbed,
543
+
repliesCount,
544
+
repostsCount,
545
+
likesCount,
546
+
resolved,
547
+
]
548
+
);
514
549
515
-
const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined);
550
+
//const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined);
516
551
517
-
useEffect(() => {
518
-
if(!feedviewpost) return;
519
-
let cancelled = false;
552
+
// useEffect(() => {
553
+
// if(!feedviewpost) return;
554
+
// let cancelled = false;
520
555
521
-
const run = async () => {
522
-
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri;
523
-
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
524
-
525
-
if (feedviewpostreplydid) {
526
-
const opi = await cachedResolveIdentity({
527
-
didOrHandle: feedviewpostreplydid,
528
-
get,
529
-
set,
530
-
});
556
+
// const run = async () => {
557
+
// const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri;
558
+
// const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
531
559
532
-
if (!cancelled) {
533
-
setFeedviewpostreplyhandle(opi?.handle);
534
-
}
535
-
}
536
-
};
560
+
// if (feedviewpostreplydid) {
561
+
// const opi = await cachedResolveIdentity({
562
+
// didOrHandle: feedviewpostreplydid,
563
+
// get,
564
+
// set,
565
+
// });
537
566
538
-
run();
567
+
// if (!cancelled) {
568
+
// setFeedviewpostreplyhandle(opi?.handle);
569
+
// }
570
+
// }
571
+
// };
539
572
540
-
return () => {
541
-
cancelled = true;
542
-
};
543
-
}, [fakepost, get, set]);
573
+
// run();
544
574
575
+
// return () => {
576
+
// cancelled = true;
577
+
// };
578
+
// }, [fakepost, get, set]);
579
+
const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
580
+
?.uri;
581
+
const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
582
+
const replyhookvalue = useQueryIdentity(
583
+
feedviewpost ? feedviewpostreplydid : undefined
584
+
);
585
+
const feedviewpostreplyhandle = replyhookvalue?.data?.handle;
545
586
return (
546
587
<>
547
588
{/* <p>
···
580
621
);
581
622
}
582
623
583
-
function hydrateEmbedImages(
584
-
embed: any,
585
-
did: string,
586
-
): $Typed<AppBskyEmbedImages.View> | undefined {
587
-
if (!embed || embed.$type !== "app.bsky.embed.images") return undefined;
588
-
if (!Array.isArray(embed.images)) return undefined;
589
-
return asTyped({
590
-
$type: "app.bsky.embed.images#view" as const, // <-- literal type
591
-
images: embed.images
592
-
.map((img: any) => {
593
-
const link = img?.image?.ref?.["$link"];
594
-
if (!link) return null;
595
-
return {
596
-
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
597
-
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
598
-
alt: img.alt || "",
599
-
aspectRatio: img.aspectRatio,
600
-
};
601
-
})
602
-
.filter(Boolean),
603
-
});
604
-
}
605
-
606
-
function hydrateEmbedExternal(
607
-
/*{embed, did} : {*/ embed: any,
608
-
did: string, //}
609
-
): $Typed<AppBskyEmbedExternal.View> | undefined {
610
-
if (!embed || embed.$type !== "app.bsky.embed.external") return undefined;
611
-
if (!embed.external) return undefined;
612
-
return asTyped({
613
-
$type: "app.bsky.embed.external#view" as const,
614
-
external: {
615
-
uri: embed.external.uri,
616
-
title: embed.external.title,
617
-
description: embed.external.description,
618
-
thumb: embed?.external?.thumb?.ref?.$link
619
-
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
620
-
: undefined,
621
-
},
622
-
});
623
-
}
624
-
625
-
function hydrateEmbedVideo(
626
-
embed: any,
627
-
did: string,
628
-
): $Typed<AppBskyEmbedVideo.View> | undefined {
629
-
if (!embed || embed.$type !== "app.bsky.embed.video") return undefined;
630
-
if (!embed.video || !embed.video.ref?.$link) return undefined;
631
-
632
-
const videoLink = embed.video.ref.$link;
633
-
634
-
return asTyped({
635
-
$type: "app.bsky.embed.video#view" as const,
636
-
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
637
-
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
638
-
aspectRatio: embed.aspectRatio,
639
-
cid: videoLink,
640
-
});
641
-
}
642
-
async function hydrateEmbedRecordWithMedia(
643
-
embed: any,
644
-
did: string,
645
-
mediaHydratedEmbed:
646
-
| $Typed<AppBskyEmbedImages.View>
647
-
| $Typed<AppBskyEmbedVideo.View>
648
-
| $Typed<AppBskyEmbedExternal.View>
649
-
| { $type: string },
650
-
get: (key: string) => any,
651
-
set: (key: string, value: string) => void,
652
-
): Promise<$Typed<AppBskyEmbedRecordWithMedia.View> | undefined> {
653
-
//return({"hello": "wow"} as any)
654
-
console.log("hydrateEmbedRecordWithMedia called!!");
655
-
if (!embed || embed.$type !== "app.bsky.embed.recordWithMedia")
656
-
return undefined;
657
-
console.log("hydrateEmbedRecordWithMedia 1!!");
658
-
async function deferredrecordget(): Promise<
659
-
$Typed<AppBskyEmbedRecord.ViewRecord>
660
-
> {
661
-
console.log("hydrateEmbedRecordWithMedia 3!!");
662
-
const quoterr = await cachedGetRecord({
663
-
atUri: embed.record.record.uri,
664
-
get,
665
-
set,
666
-
});
667
-
async function defferedQuotedRecordget(): Promise<{
668
-
[_ in string]: unknown;
669
-
}> {
670
-
console.log("hydrateEmbedRecordWithMedia 4!!");
671
-
return quoterr.value;
672
-
}
673
-
async function defferedOPRecordget(): Promise<
674
-
$Typed<AppBskyActorDefs.ProfileViewBasic>
675
-
> {
676
-
const parseduri = parseAtUri(embed.record.record.uri);
677
-
if (!parseduri) throw new Error("invalid uri");
678
-
console.log("deep- hydrateEmbedRecordWithMedia " + parseduri.did);
679
-
const didwhat = parseduri?.did;
680
-
console.log("hydrateEmbedRecordWithMedia 4.97!!");
681
-
const opr = await cachedGetRecord({
682
-
atUri: `at://${didwhat}/app.bsky.actor.profile/self`,
683
-
get,
684
-
set,
685
-
});
686
-
console.log("hydrateEmbedRecordWithMedia 4.98!! opr:" + opr);
687
-
const opi = await cachedResolveIdentity({
688
-
didOrHandle: didwhat,
689
-
get,
690
-
set,
691
-
});
692
-
console.log("hydrateEmbedRecordWithMedia 4.99!!");
693
-
console.log("hydrateEmbedRecordWithMedia 5!!");
694
-
const thedid = didwhat;
695
-
console.log("hydrateEmbedRecordWithMedia 5.01!! " + thedid);
696
-
const thehandle = opi?.handle || "";
697
-
console.log("hydrateEmbedRecordWithMedia 5.02!! " + thehandle);
698
-
const thedisplayname = (opr.value?.displayName ?? opi?.handle) || "";
699
-
console.log("hydrateEmbedRecordWithMedia 5.03!! " + thedisplayname);
700
-
const theavatar = opr.value?.avatar?.ref?.$link
701
-
? `https://cdn.bsky.app/img/avatar/plain/${didwhat}/${opr.value?.avatar?.ref?.$link}@jpeg`
702
-
: undefined;
703
-
console.log("hydrateEmbedRecordWithMedia 5.04!! " + theavatar);
704
-
console.log("hydrateEmbedRecordWithMedia 5.05!!");
705
-
const thecreatedat = opr.value?.createdAt ?? undefined;
706
-
console.log("hydrateEmbedRecordWithMedia 5.06!! " + thecreatedat);
707
-
console.log("hydrateEmbedRecordWithMedia 5.07!!");
708
-
console.log("hydrateEmbedRecordWithMedia 5.08!!");
709
-
const crying = {
710
-
$type: "app.bsky.actor.defs#profileViewBasic" as const,
711
-
did: thedid,
712
-
handle: thehandle,
713
-
displayName: thedisplayname,
714
-
avatar: theavatar,
715
-
associated: {
716
-
chat: {
717
-
allowIncoming: "all",
718
-
},
719
-
},
720
-
labels: [],
721
-
createdAt: thecreatedat,
722
-
};
723
-
return asTyped(crying);
724
-
}
725
-
726
-
const record = await defferedQuotedRecordget();
727
-
const OP = await defferedOPRecordget();
728
-
729
-
console.log("hydrateEmbedRecordWithMedia victory-lap 6!!");
730
-
return asTyped({
731
-
$type: "app.bsky.embed.record#viewRecord" as const,
732
-
uri: embed.record.record.uri,
733
-
cid: embed.record.record.cid,
734
-
indexedAt: String(record.createdAt || "") || "",
735
-
author: OP,
736
-
value: record,
737
-
});
738
-
}
739
-
console.log("hydrateEmbedRecordWithMedia 2!!");
740
-
741
-
const recordion = await deferredrecordget();
742
-
console.log("hydrateEmbedRecordWithMedia victory-lap 7!!");
743
-
744
-
const final = asTyped({
745
-
$type: "app.bsky.embed.recordWithMedia#view" as const,
746
-
record: {
747
-
//$type: "app.bsky.embed.record#view" as const,
748
-
record: recordion,
749
-
},
750
-
media: mediaHydratedEmbed,
751
-
// media: asTyped({
752
-
// $type: "app.bsky.embed.images" as const,
753
-
// images: embed.media.images
754
-
// ? embed.media.images
755
-
// .map((img: any) => {
756
-
// const link = img?.image?.ref?.["$link"];
757
-
// if (!link) return null;
758
-
// return {
759
-
// thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
760
-
// fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
761
-
// alt: img.alt || "",
762
-
// aspectRatio: img.aspectRatio,
763
-
// };
764
-
// })
765
-
// .filter(Boolean)
766
-
// : undefined,
767
-
// }),
768
-
});
769
-
console.log("hydrateEmbedRecordWithMedia final " + final);
770
-
return final;
771
-
}
772
-
773
-
async function hydrateEmbedRecord(
774
-
embed: any,
775
-
did: string,
776
-
get: (key: string) => any,
777
-
set: (key: string, value: string) => void,
778
-
): Promise<$Typed<AppBskyEmbedRecord.View> | undefined> {
779
-
if (!embed || embed.$type !== "app.bsky.embed.record") return undefined;
780
-
781
-
const recordRef = embed.record?.record?.uri
782
-
? embed.record.record
783
-
: embed.record;
784
-
785
-
const quoted = await cachedGetRecord({
786
-
atUri: recordRef.uri,
787
-
get,
788
-
set,
789
-
});
790
-
791
-
const parseduri = parseAtUri(recordRef.uri);
792
-
if (!parseduri) throw new Error("invalid uri");
793
-
const didwhat = parseduri.did;
794
-
795
-
const opr = await cachedGetRecord({
796
-
atUri: `at://${didwhat}/app.bsky.actor.profile/self`,
797
-
get,
798
-
set,
799
-
});
800
-
const opi = await cachedResolveIdentity({
801
-
didOrHandle: didwhat,
802
-
get,
803
-
set,
804
-
});
805
-
806
-
const author = {
807
-
$type: "app.bsky.actor.defs#profileViewBasic" as const,
808
-
did: didwhat,
809
-
handle: opi?.handle || "",
810
-
displayName: (opr.value?.displayName ?? opi?.handle) || "",
811
-
avatar: opr.value?.avatar?.ref?.$link
812
-
? `https://cdn.bsky.app/img/avatar/plain/${didwhat}/${opr.value?.avatar?.ref?.$link}@jpeg`
813
-
: undefined,
814
-
associated: {
815
-
chat: {
816
-
allowIncoming: "all",
817
-
},
818
-
},
819
-
labels: [],
820
-
createdAt: opr.value?.createdAt ?? undefined,
821
-
};
822
-
823
-
const viewRecord: $Typed<AppBskyEmbedRecord.ViewRecord> = asTyped({
824
-
$type: "app.bsky.embed.record#viewRecord" as const,
825
-
uri: recordRef.uri,
826
-
cid: recordRef.cid,
827
-
indexedAt: String(quoted.value.createdAt || "") || "",
828
-
author,
829
-
value: quoted.value,
830
-
replyCount: quoted.value.replyCount,
831
-
repostCount: quoted.value.repostCount,
832
-
likeCount: quoted.value.likeCount,
833
-
quoteCount: quoted.value.quoteCount,
834
-
labels: quoted.value.labels,
835
-
embeds: quoted.value.embed ? [quoted.value.embed] : undefined,
836
-
});
837
-
838
-
return asTyped({
839
-
$type: "app.bsky.embed.record#view" as const,
840
-
record: viewRecord,
841
-
});
842
-
}
843
-
844
624
export function parseAtUri(
845
-
atUri: string,
625
+
atUri: string
846
626
): { did: string; collection: string; rkey: string } | null {
847
627
const PREFIX = "at://";
848
628
if (!atUri.startsWith(PREFIX)) {
···
1128
908
//import Masonry from "@mui/lab/Masonry";
1129
909
import {
1130
910
AppBskyActorDefs,
911
+
AppBskyActorProfile,
1131
912
AppBskyEmbedDefs,
1132
913
AppBskyEmbedExternal,
1133
914
AppBskyEmbedImages,
···
1256
1037
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
1257
1038
return Array.from(
1258
1039
{ length },
1259
-
() => chars[Math.floor(Math.random() * chars.length)],
1040
+
() => chars[Math.floor(Math.random() * chars.length)]
1260
1041
).join("");
1261
1042
}
1262
1043
···
1276
1057
salt,
1277
1058
bottomBorder = true,
1278
1059
feedviewpostreplyhandle,
1060
+
depth = 0,
1279
1061
}: {
1280
1062
post: PostView;
1281
1063
// optional for now because i havent ported every use to this yet
···
1293
1075
salt: string;
1294
1076
bottomBorder?: boolean;
1295
1077
feedviewpostreplyhandle?: string;
1078
+
depth?: number;
1296
1079
}) {
1297
1080
const navigate = useNavigate();
1298
1081
const [hasRetweeted, setHasRetweeted] = useState<Boolean>(
1299
-
post.viewer?.repost ? true : false,
1082
+
post.viewer?.repost ? true : false
1300
1083
);
1301
1084
const [hasLiked, setHasLiked] = useState<Boolean>(
1302
-
post.viewer?.like ? true : false,
1085
+
post.viewer?.like ? true : false
1303
1086
);
1304
1087
const { agent } = useAuth();
1305
1088
const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1306
1089
const [retweetUri, setRetweetUri] = useState<string | undefined>(
1307
-
post.viewer?.repost,
1090
+
post.viewer?.repost
1308
1091
);
1309
1092
1310
1093
const likeOrUnlikePost = async () => {
···
1387
1170
//boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
1388
1171
position: "relative",
1389
1172
// dont cursor: "pointer",
1390
-
borderBottomWidth: bottomBorder ? isQuote ? 0 : 1 : 0,
1173
+
borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0,
1391
1174
}}
1392
1175
className="border-gray-300 dark:border-gray-600"
1393
1176
>
···
1603
1386
gap: 4,
1604
1387
alignItems: "center",
1605
1388
//marginLeft: 36,
1606
-
height: !(expanded || isQuote) && !!feedviewpostreplyhandle ? "1rem" : 0,
1607
-
opacity: !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1389
+
height:
1390
+
!(expanded || isQuote) && !!feedviewpostreplyhandle
1391
+
? "1rem"
1392
+
: 0,
1393
+
opacity:
1394
+
!(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1608
1395
}}
1609
1396
className="text-gray-500 dark:text-gray-400"
1610
1397
>
···
1614
1401
<div
1615
1402
style={{
1616
1403
fontSize: 16,
1617
-
marginBottom: (!post.embed && !expanded) ? 0 : 8,
1404
+
marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1618
1405
whiteSpace: "pre-wrap",
1619
1406
textAlign: "left",
1620
1407
overflowWrap: "anywhere",
···
1623
1410
}}
1624
1411
className="text-gray-900 dark:text-gray-100"
1625
1412
>
1626
-
{renderTextWithFacets(
1627
-
(post.record as { text?: string }).text ?? "",
1628
-
(post.record.facets as Facet[]) ?? [],
1629
-
)}
1413
+
{renderTextWithFacets({
1414
+
text: (post.record as { text?: string }).text ?? "",
1415
+
facets: (post.record.facets as Facet[]) ?? [],
1416
+
navigate: navigate
1417
+
})}
1630
1418
{}
1631
1419
</div>
1632
-
{post.embed ? (
1420
+
{post.embed && depth < 1 ? (
1633
1421
<PostEmbeds
1634
1422
embed={post.embed}
1635
1423
//moderation={moderation}
···
1638
1426
navigate={navigate}
1639
1427
/>
1640
1428
) : null}
1641
-
<div style={{ paddingTop: post.embed ? 4 : 0 }}>
1429
+
{post.embed && depth > 0 && (
1430
+
<>
1431
+
<div className="border-gray-300 dark:border-gray-600 p-3 rounded-xl border italic text-gray-400">
1432
+
(there is an embed here thats too deep to render)
1433
+
</div>
1434
+
</>
1435
+
)}
1436
+
<div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1642
1437
<>
1643
1438
{expanded && (
1644
1439
<div
···
1713
1508
"/profile/" +
1714
1509
post.author.handle +
1715
1510
"/post/" +
1716
-
post.uri.split("/").pop(),
1511
+
post.uri.split("/").pop()
1717
1512
);
1718
1513
} catch {}
1719
1514
}}
···
1899
1694
});
1900
1695
}
1901
1696
}}
1697
+
depth={1}
1902
1698
/>
1903
1699
</div>
1904
1700
{/* <QuotePostRenderer
···
2015
1811
});
2016
1812
}
2017
1813
}}
1814
+
depth={1}
2018
1815
/>
2019
1816
</div>
2020
1817
);
···
2042
1839
src: img.fullsize,
2043
1840
alt: img.alt,
2044
1841
}));
2045
-
2046
1842
2047
1843
if (images.length > 0) {
2048
1844
// const items = embed.images.map(img => ({
···
2074
1870
}}
2075
1871
className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900"
2076
1872
>
2077
-
{lightboxIndex !== null && (
2078
-
<Lightbox
2079
-
images={lightboxImages}
2080
-
index={lightboxIndex}
2081
-
onClose={() => setLightboxIndex(null)}
2082
-
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2083
-
/>
2084
-
)}
1873
+
{lightboxIndex !== null && (
1874
+
<Lightbox
1875
+
images={lightboxImages}
1876
+
index={lightboxIndex}
1877
+
onClose={() => setLightboxIndex(null)}
1878
+
onNavigate={(newIndex) => setLightboxIndex(newIndex)}
1879
+
/>
1880
+
)}
2085
1881
<img
2086
1882
src={image.fullsize}
2087
1883
alt={image.alt}
···
2090
1886
height: "100%",
2091
1887
objectFit: "contain", // letterbox or scale to fit
2092
1888
}}
2093
-
onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}}
1889
+
onClick={(e) => {
1890
+
e.stopPropagation();
1891
+
setLightboxIndex(0);
1892
+
}}
2094
1893
/>
2095
1894
</div>
2096
1895
</div>
···
2133
1932
objectFit: "cover",
2134
1933
borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0",
2135
1934
}}
2136
-
onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}}
1935
+
onClick={(e) => {
1936
+
e.stopPropagation();
1937
+
setLightboxIndex(i);
1938
+
}}
2137
1939
/>
2138
1940
</div>
2139
1941
))}
···
2178
1980
objectFit: "cover",
2179
1981
borderRadius: "12px 0 0 12px",
2180
1982
}}
2181
-
onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}}
1983
+
onClick={(e) => {
1984
+
e.stopPropagation();
1985
+
setLightboxIndex(0);
1986
+
}}
2182
1987
/>
2183
1988
</div>
2184
1989
{/* Right: two stacked 2:1 */}
···
2208
2013
objectFit: "cover",
2209
2014
borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0",
2210
2015
}}
2211
-
onClick={(e) => {e.stopPropagation();setLightboxIndex(i+1)}}
2016
+
onClick={(e) => {
2017
+
e.stopPropagation();
2018
+
setLightboxIndex(i + 1);
2019
+
}}
2212
2020
/>
2213
2021
</div>
2214
2022
))}
···
2269
2077
? "0 0 0 12px"
2270
2078
: "0 0 12px 0",
2271
2079
}}
2272
-
onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}}
2080
+
onClick={(e) => {
2081
+
e.stopPropagation();
2082
+
setLightboxIndex(i);
2083
+
}}
2273
2084
/>
2274
2085
</div>
2275
2086
))}
···
2331
2142
}
2332
2143
2333
2144
import { createPortal } from "react-dom";
2145
+
import type { Record } from "@atproto/api/dist/client/types/app/bsky/actor/profile";
2334
2146
type LightboxProps = {
2335
2147
images: { src: string; alt?: string }[];
2336
2148
index: number;
2337
2149
onClose: () => void;
2338
2150
onNavigate?: (newIndex: number) => void;
2339
2151
};
2340
-
export function Lightbox({ images, index, onClose, onNavigate }: LightboxProps) {
2152
+
export function Lightbox({
2153
+
images,
2154
+
index,
2155
+
onClose,
2156
+
onNavigate,
2157
+
}: LightboxProps) {
2341
2158
const image = images[index];
2342
2159
2343
2160
useEffect(() => {
2344
2161
function handleKey(e: KeyboardEvent) {
2345
2162
if (e.key === "Escape") onClose();
2346
-
if (e.key === "ArrowRight" && onNavigate) onNavigate((index + 1) % images.length);
2347
-
if (e.key === "ArrowLeft" && onNavigate) onNavigate((index - 1 + images.length) % images.length);
2163
+
if (e.key === "ArrowRight" && onNavigate)
2164
+
onNavigate((index + 1) % images.length);
2165
+
if (e.key === "ArrowLeft" && onNavigate)
2166
+
onNavigate((index - 1 + images.length) % images.length);
2348
2167
}
2349
2168
window.addEventListener("keydown", handleKey);
2350
2169
return () => window.removeEventListener("keydown", handleKey);
···
2353
2172
return createPortal(
2354
2173
<div
2355
2174
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
2356
-
onClick={(e)=>{e.stopPropagation();onClose()}}
2175
+
onClick={(e) => {
2176
+
e.stopPropagation();
2177
+
onClose();
2178
+
}}
2357
2179
>
2358
2180
<img
2359
2181
src={image.src}
···
2371
2193
}}
2372
2194
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
2373
2195
>
2374
-
<svg xmlns="http://www.w3.org/2000/svg" width={28} height={28} viewBox="0 0 24 24"><g fill="none" fillRule="evenodd"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path><path fill="currentColor" d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414z"></path></g></svg>
2196
+
<svg
2197
+
xmlns="http://www.w3.org/2000/svg"
2198
+
width={28}
2199
+
height={28}
2200
+
viewBox="0 0 24 24"
2201
+
>
2202
+
<g fill="none" fillRule="evenodd">
2203
+
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
2204
+
<path
2205
+
fill="currentColor"
2206
+
d="M8.293 12.707a1 1 0 0 1 0-1.414l5.657-5.657a1 1 0 1 1 1.414 1.414L10.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414z"
2207
+
></path>
2208
+
</g>
2209
+
</svg>
2375
2210
</button>
2376
2211
<button
2377
2212
onClick={(e) => {
···
2380
2215
}}
2381
2216
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl h-8 w-8 rounded-full bg-gray-900 flex items-center justify-center"
2382
2217
>
2383
-
<svg xmlns="http://www.w3.org/2000/svg" width={28} height={28} viewBox="0 0 24 24"><g fill="none" fillRule="evenodd"><path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path><path fill="currentColor" d="M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414z"></path></g></svg>
2218
+
<svg
2219
+
xmlns="http://www.w3.org/2000/svg"
2220
+
width={28}
2221
+
height={28}
2222
+
viewBox="0 0 24 24"
2223
+
>
2224
+
<g fill="none" fillRule="evenodd">
2225
+
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path>
2226
+
<path
2227
+
fill="currentColor"
2228
+
d="M15.707 11.293a1 1 0 0 1 0 1.414l-5.657 5.657a1 1 0 1 1-1.414-1.414l4.95-4.95l-4.95-4.95a1 1 0 0 1 1.414-1.414z"
2229
+
></path>
2230
+
</g>
2231
+
</svg>
2384
2232
</button>
2385
2233
</>
2386
2234
)}
···
2428
2276
function facetByteRangeToCharRange(
2429
2277
byteStart: number,
2430
2278
byteEnd: number,
2431
-
byteToCharMap: number[],
2279
+
byteToCharMap: number[]
2432
2280
): [number, number] {
2433
2281
return [
2434
2282
byteToCharMap[byteStart] ?? 0,
···
2448
2296
const [start, end] = facetByteRangeToCharRange(
2449
2297
f.index.byteStart,
2450
2298
f.index.byteEnd,
2451
-
map,
2299
+
map
2452
2300
);
2453
2301
return { start, end, feature: f.features[0] };
2454
2302
});
2455
2303
}
2456
-
function renderTextWithFacets(text: string, facets: Facet[]) {
2304
+
function renderTextWithFacets({
2305
+
text,
2306
+
facets,
2307
+
navigate,
2308
+
}: {
2309
+
text: string;
2310
+
facets: Facet[];
2311
+
navigate: ({}: any) => void;
2312
+
}) {
2457
2313
const ranges = extractFacetRanges(text, facets).sort(
2458
-
(a: any, b: any) => a.start - b.start,
2314
+
(a: any, b: any) => a.start - b.start
2459
2315
);
2460
2316
2461
2317
const result: React.ReactNode[] = [];
···
2487
2343
}}
2488
2344
>
2489
2345
{fragment}
2490
-
</a>,
2346
+
</a>
2491
2347
);
2492
2348
} else if (
2493
2349
feature.$type === "app.bsky.richtext.facet#mention" &&
···
2498
2354
<span
2499
2355
key={start}
2500
2356
style={{ color: "rgb(29, 122, 242)" }}
2357
+
className=" cursor-pointer"
2501
2358
onClick={(e) => {
2502
2359
e.stopPropagation();
2360
+
navigate({
2361
+
to: "/profile/$did",
2362
+
// @ts-ignore
2363
+
params: { did: feature.did},
2364
+
});
2503
2365
}}
2504
2366
>
2505
2367
{fragment}
2506
-
</span>,
2368
+
</span>
2507
2369
);
2508
2370
} else if (feature.$type === "app.bsky.richtext.facet#tag") {
2509
2371
result.push(
···
2515
2377
}}
2516
2378
>
2517
2379
{fragment}
2518
-
</span>,
2380
+
</span>
2519
2381
);
2520
2382
} else {
2521
2383
result.push(<span key={start}>{fragment}</span>);
···
2708
2570
{
2709
2571
root: null,
2710
2572
threshold: 0.25,
2711
-
},
2573
+
}
2712
2574
);
2713
2575
2714
2576
if (containerRef.current) {
+24
src/components/shrinkpadding.tsx
+24
src/components/shrinkpadding.tsx
···
1
+
import { useEffect, useState } from "react";
2
+
3
+
export default function ShrinkingBox() {
4
+
const [size, setSize] = useState(2000);
5
+
6
+
useEffect(() => {
7
+
const interval = setInterval(() => {
8
+
setSize(prev => Math.max(prev - 125, 0));
9
+
}, 250);
10
+
11
+
return () => clearInterval(interval);
12
+
}, []);
13
+
14
+
return (
15
+
<div
16
+
style={{
17
+
//width: `${size}px`,
18
+
height: `${size}px`,
19
+
//backgroundColor: "skyblue",
20
+
transition: "all 0.5s ease",
21
+
}}
22
+
/>
23
+
);
24
+
}
+6
-2
src/main.tsx
+6
-2
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
11
12
+
const queryClient = new QueryClient();
11
13
// Create a new router instance
12
14
const router = createRouter({
13
15
routeTree,
14
-
context: {},
16
+
context: { queryClient },
15
17
defaultPreload: "intent",
16
18
scrollRestoration: true,
17
19
defaultStructuralSharing: true,
···
32
34
root.render(
33
35
// double queries annoys me
34
36
<StrictMode>
35
-
<RouterProvider router={router} />
37
+
<QueryClientProvider client={queryClient}>
38
+
<RouterProvider router={router} />
39
+
</QueryClientProvider>
36
40
</StrictMode>
37
41
);
38
42
}
+219
-161
src/routes/index.tsx
+219
-161
src/routes/index.tsx
···
1
1
import { createFileRoute } from "@tanstack/react-router";
2
2
import {
3
3
CACHE_TIMEOUT,
4
-
cachedGetRecord,
5
-
cachedResolveIdentity,
4
+
//cachedGetRecord,
5
+
//cachedResolveIdentity,
6
6
UniversalPostRendererATURILoader,
7
7
} from "~/components/UniversalPostRenderer";
8
8
import * as React from "react";
9
9
import { useAuth } from "~/providers/PassAuthProvider";
10
-
import { usePersistentStore } from "~/providers/PersistentStoreProvider";
10
+
//import { usePersistentStore } from "~/providers/PersistentStoreProvider";
11
+
import {
12
+
useQueryIdentity,
13
+
useQueryPost,
14
+
useQueryFeedSkeleton,
15
+
useQueryPreferences,
16
+
useQueryArbitrary
17
+
} from "~/utils/useQuery";
18
+
import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed";
11
19
12
20
export const Route = createFileRoute("/")({
13
21
component: Home,
···
22
30
loading: loadering,
23
31
authed,
24
32
} = useAuth();
25
-
const { get, set } = usePersistentStore();
26
-
const [feed, setFeed] = React.useState<any[]>([]);
27
-
const [loading, setLoading] = React.useState(true);
28
-
const [error, setError] = React.useState<string | null>(null);
33
+
//const { get, set } = usePersistentStore();
34
+
// const [feed, setFeed] = React.useState<any[]>([]);
35
+
// const [loading, setLoading] = React.useState(true);
36
+
// const [error, setError] = React.useState<string | null>(null);
29
37
30
-
const [prefs, setPrefs] = React.useState<any>({});
31
-
React.useEffect(() => {
32
-
if (!loadering && authed && agent && agent.did) {
33
-
const run = async () => {
34
-
try {
35
-
if (!agent.did) return;
36
-
const prefs = await cachedGetPrefs({
37
-
did: agent.did,
38
-
agent,
39
-
get,
40
-
set,
41
-
});
38
+
// const [prefs, setPrefs] = React.useState<any>({});
39
+
// React.useEffect(() => {
40
+
// if (!loadering && authed && agent && agent.did) {
41
+
// const run = async () => {
42
+
// try {
43
+
// if (!agent.did) return;
44
+
// const prefs = await cachedGetPrefs({
45
+
// did: agent.did,
46
+
// agent,
47
+
// get,
48
+
// set,
49
+
// });
50
+
51
+
// console.log("alistoffeeds", prefs);
52
+
// setPrefs(prefs || {});
53
+
// } catch (err) {
54
+
// console.error("alistoffeeds Fetch error in preferences effect:", err);
55
+
// }
56
+
// };
57
+
58
+
// run();
59
+
// }
60
+
// }, [loadering, authed, agent]);
42
61
43
-
console.log("alistoffeeds", prefs);
44
-
setPrefs(prefs || {});
45
-
} catch (err) {
46
-
console.error("alistoffeeds Fetch error in preferences effect:", err);
47
-
}
48
-
};
62
+
// const savedFeedsPref = React.useMemo(() => {
63
+
// if (!prefs?.preferences) return null;
64
+
// return prefs.preferences.find(
65
+
// (p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
66
+
// );
67
+
// }, [prefs]);
49
68
50
-
run();
51
-
}
52
-
}, [loadering, authed, agent]);
69
+
// const savedFeeds = savedFeedsPref?.items || [];
70
+
71
+
const identityresultmaybe = useQueryIdentity(agent?.did);
72
+
const identity = identityresultmaybe?.data
53
73
54
-
const savedFeedsPref = React.useMemo(() => {
55
-
if (!prefs?.preferences) return null;
56
-
return prefs.preferences.find(
57
-
(p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
74
+
const prefsresultmaybe = useQueryPreferences({agent: agent ?? undefined, pdsUrl: identity?.pds});
75
+
const prefs = prefsresultmaybe?.data
76
+
77
+
const savedFeeds = React.useMemo(() => {
78
+
const savedFeedsPref = prefs?.preferences?.find(
79
+
(p: any) => p?.$type === "app.bsky.actor.defs#savedFeedsPrefV2"
58
80
);
81
+
return savedFeedsPref?.items || [];
59
82
}, [prefs]);
60
83
61
-
const savedFeeds = savedFeedsPref?.items || [];
84
+
62
85
63
86
const [selectedFeed, setSelectedFeed] = React.useState<string | null>(null);
64
87
65
88
React.useEffect(() => {
66
89
const fallbackFeed =
67
-
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/wh-hot";
90
+
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot";
68
91
if (authed) {
69
92
if (savedFeeds.length > 0) {
70
93
setSelectedFeed((prev) =>
···
80
103
}
81
104
}, [savedFeeds, authed]);
82
105
83
-
React.useEffect(() => {
84
-
if (loadering || !selectedFeed) return;
106
+
// React.useEffect(() => {
107
+
// if (loadering || !selectedFeed) return;
85
108
86
-
let ignore = false;
109
+
// let ignore = false;
87
110
88
-
const run = async () => {
89
-
setLoading(true);
90
-
setError(null);
111
+
// const run = async () => {
112
+
// setLoading(true);
113
+
// setError(null);
91
114
92
-
try {
93
-
if (authed && agent) {
94
-
if (!agent.did) return;
115
+
// try {
116
+
// if (authed && agent) {
117
+
// if (!agent.did) return;
95
118
96
-
const pdsurl = await cachedResolveIdentity({
97
-
didOrHandle: agent.did,
98
-
get,
99
-
set,
100
-
});
119
+
// const pdsurl = await cachedResolveIdentity({
120
+
// didOrHandle: agent.did,
121
+
// get,
122
+
// set,
123
+
// });
101
124
102
-
const fetchstringcomplex = `${pdsurl.pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${selectedFeed}`;
103
-
console.log("fetching feed authed: " + fetchstringcomplex);
125
+
// const fetchstringcomplex = `${pdsurl.pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${selectedFeed}`;
126
+
// console.log("fetching feed authed: " + fetchstringcomplex);
104
127
105
-
const feeddef = await cachedGetRecord({
106
-
atUri: selectedFeed,
107
-
get,
108
-
set,
109
-
});
128
+
// const feeddef = await cachedGetRecord({
129
+
// atUri: selectedFeed,
130
+
// get,
131
+
// set,
132
+
// });
110
133
111
-
const feedservicedid = feeddef.value.did;
134
+
// const feedservicedid = feeddef.value.did;
112
135
113
-
const res = await agent.fetchHandler(fetchstringcomplex, {
114
-
method: "GET",
115
-
headers: {
116
-
"atproto-proxy": `${feedservicedid}#bsky_fg`,
117
-
"Content-Type": "application/json",
118
-
},
119
-
});
136
+
// const res = await agent.fetchHandler(fetchstringcomplex, {
137
+
// method: "GET",
138
+
// headers: {
139
+
// "atproto-proxy": `${feedservicedid}#bsky_fg`,
140
+
// "Content-Type": "application/json",
141
+
// },
142
+
// });
120
143
121
-
if (!res.ok) throw new Error("Failed to fetch feed");
122
-
const data = await res.json();
144
+
// if (!res.ok) throw new Error("Failed to fetch feed");
145
+
// const data = await res.json();
123
146
124
-
if (!ignore) setFeed(data.feed || []);
125
-
} else {
126
-
console.log("falling back");
127
-
// always use fallback feed for not logged in
128
-
const fallbackFeed =
129
-
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot";
130
-
// const feeddef = await cachedGetRecord({
131
-
// atUri: fallbackFeed,
132
-
// get,
133
-
// set,
134
-
// });
147
+
// if (!ignore) setFeed(data.feed || []);
148
+
// } else {
149
+
// console.log("falling back");
150
+
// // always use fallback feed for not logged in
151
+
// const fallbackFeed =
152
+
// "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot";
153
+
// // const feeddef = await cachedGetRecord({
154
+
// // atUri: fallbackFeed,
155
+
// // get,
156
+
// // set,
157
+
// // });
135
158
136
-
//const feedservicedid = "did:web:discover.bsky.app" //feeddef.did;
137
-
const fetchstringsimple = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${fallbackFeed}`;
138
-
console.log("fetching feed unauthed: " + fetchstringsimple);
159
+
// //const feedservicedid = "did:web:discover.bsky.app" //feeddef.did;
160
+
// const fetchstringsimple = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${fallbackFeed}`;
161
+
// console.log("fetching feed unauthed: " + fetchstringsimple);
139
162
140
-
const res = await fetch(fetchstringsimple);
141
-
if (!res.ok) throw new Error("Failed to fetch feed");
142
-
const data = await res.json();
163
+
// const res = await fetch(fetchstringsimple);
164
+
// if (!res.ok) throw new Error("Failed to fetch feed");
165
+
// const data = await res.json();
143
166
144
-
if (!ignore) setFeed(data.feed || []);
145
-
}
146
-
} catch (e) {
147
-
if (!ignore) {
148
-
if (e instanceof Error) {
149
-
setError(e.message);
150
-
} else {
151
-
setError("Unknown error");
152
-
}
153
-
}
154
-
} finally {
155
-
if (!ignore) setLoading(false);
156
-
}
157
-
};
167
+
// if (!ignore) setFeed(data.feed || []);
168
+
// }
169
+
// } catch (e) {
170
+
// if (!ignore) {
171
+
// if (e instanceof Error) {
172
+
// setError(e.message);
173
+
// } else {
174
+
// setError("Unknown error");
175
+
// }
176
+
// }
177
+
// } finally {
178
+
// if (!ignore) setLoading(false);
179
+
// }
180
+
// };
181
+
182
+
// run();
183
+
184
+
// return () => {
185
+
// ignore = true;
186
+
// };
187
+
// }, [authed, agent, loadering, selectedFeed, get, set]);
188
+
189
+
190
+
const feedGengetrecordquery = useQueryArbitrary(selectedFeed??undefined);
191
+
const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did;
192
+
193
+
// const {
194
+
// data: feedData,
195
+
// isLoading: isFeedLoading,
196
+
// error: feedError,
197
+
// } = useQueryFeedSkeleton({
198
+
// feedUri: selectedFeed!,
199
+
// agent: agent ?? undefined,
200
+
// isAuthed: authed ?? false,
201
+
// pdsUrl: identity?.pds,
202
+
// feedServiceDid: feedServiceDid,
203
+
// });
158
204
159
-
run();
205
+
// const feed = feedData?.feed || [];
160
206
161
-
return () => {
162
-
ignore = true;
163
-
};
164
-
}, [authed, agent, loadering, selectedFeed, get, set]);
207
+
const isReadyForAuthedFeed = authed && agent && identity?.pds && feedServiceDid;
208
+
const isReadyForUnauthedFeed = !authed && selectedFeed;
165
209
166
210
return (
167
211
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
···
175
219
key={item.value || idx}
176
220
className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${
177
221
isActive
178
-
? "bg-gray-600 text-white"
222
+
? "bg-gray-500 text-white"
179
223
: item.pinned
180
224
? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
181
225
: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
···
196
240
<span className="text-xl font-bold ml-2">Home</span>
197
241
)}
198
242
</div>
199
-
{loading && <div className="p-4 text-gray-500">Loading...</div>}
200
-
{error && <div className="p-4 text-red-500">{error}</div>}
201
-
{!loading && !error && feed.length === 0 && (
243
+
{/* {isFeedLoading && <div className="p-4 text-gray-500">Loading...</div>}
244
+
{feedError && <div className="p-4 text-red-500">{feedError.message}</div>}
245
+
{!isFeedLoading && !feedError && feed.length === 0 && (
202
246
<div className="p-4 text-gray-500">No posts found.</div>
203
-
)}
204
-
{feed.map((item, i) => (
247
+
)} */}
248
+
{/* {feed.map((item, i) => (
205
249
<UniversalPostRendererATURILoader
206
250
key={item.post || i}
207
251
atUri={item.post}
208
252
/>
209
-
))}
253
+
))} */}
254
+
255
+
{(authed && (!identity?.pds || !feedServiceDid)) && (
256
+
<div className="p-4 text-center text-gray-500">Preparing your feed...</div>
257
+
)}
258
+
259
+
{(isReadyForAuthedFeed || isReadyForUnauthedFeed) ? (
260
+
<InfiniteCustomFeed
261
+
feedUri={selectedFeed!}
262
+
pdsUrl={identity?.pds}
263
+
feedServiceDid={feedServiceDid}
264
+
/>
265
+
) : (
266
+
<div className="p-4 text-center text-gray-500">Select a feed to get started.</div>
267
+
)}
210
268
</div>
211
269
);
212
270
}
···
249
307
return data;
250
308
}
251
309
252
-
export async function cachedGetPrefs({
253
-
did,
254
-
agent,
255
-
get,
256
-
set,
257
-
cacheTimeout = CACHE_TIMEOUT,
258
-
}: {
259
-
did: string;
260
-
agent: any; // or type properly if available
261
-
get: (key: string) => any;
262
-
set: (key: string, value: string) => void;
263
-
cacheTimeout?: number;
264
-
}): Promise<any> {
265
-
const cacheKey = `prefs:${did}`;
266
-
const cached = get(cacheKey);
267
-
const now = Date.now();
310
+
// export async function cachedGetPrefs({
311
+
// did,
312
+
// agent,
313
+
// get,
314
+
// set,
315
+
// cacheTimeout = CACHE_TIMEOUT,
316
+
// }: {
317
+
// did: string;
318
+
// agent: any; // or type properly if available
319
+
// get: (key: string) => any;
320
+
// set: (key: string, value: string) => void;
321
+
// cacheTimeout?: number;
322
+
// }): Promise<any> {
323
+
// const cacheKey = `prefs:${did}`;
324
+
// const cached = get(cacheKey);
325
+
// const now = Date.now();
268
326
269
-
if (
270
-
cached &&
271
-
cached.value &&
272
-
cached.time &&
273
-
now - cached.time < cacheTimeout
274
-
) {
275
-
try {
276
-
return JSON.parse(cached.value);
277
-
} catch {
278
-
// fall through to fetch
279
-
}
280
-
}
327
+
// if (
328
+
// cached &&
329
+
// cached.value &&
330
+
// cached.time &&
331
+
// now - cached.time < cacheTimeout
332
+
// ) {
333
+
// try {
334
+
// return JSON.parse(cached.value);
335
+
// } catch {
336
+
// // fall through to fetch
337
+
// }
338
+
// }
281
339
282
-
const resolved = await cachedResolveIdentity({
283
-
didOrHandle: did,
284
-
get,
285
-
set,
286
-
});
340
+
// const resolved = await cachedResolveIdentity({
341
+
// didOrHandle: did,
342
+
// get,
343
+
// set,
344
+
// });
287
345
288
-
if (!resolved?.pdsUrl) throw new Error("Missing resolved PDS info");
346
+
// if (!resolved?.pdsUrl) throw new Error("Missing resolved PDS info");
289
347
290
-
const fetchUrl = `${resolved.pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
348
+
// const fetchUrl = `${resolved.pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
291
349
292
-
const res = await agent.fetchHandler(fetchUrl, {
293
-
method: "GET",
294
-
headers: {
295
-
"Content-Type": "application/json",
296
-
},
297
-
});
350
+
// const res = await agent.fetchHandler(fetchUrl, {
351
+
// method: "GET",
352
+
// headers: {
353
+
// "Content-Type": "application/json",
354
+
// },
355
+
// });
298
356
299
-
if (!res.ok) throw new Error(`Failed to fetch preferences: ${res.status}`);
357
+
// if (!res.ok) throw new Error(`Failed to fetch preferences: ${res.status}`);
300
358
301
-
const text = await res.text();
359
+
// const text = await res.text();
302
360
303
-
let data: any;
304
-
try {
305
-
data = JSON.parse(text);
306
-
} catch (err) {
307
-
console.error("Failed to parse preferences JSON:", err);
308
-
throw err;
309
-
}
361
+
// let data: any;
362
+
// try {
363
+
// data = JSON.parse(text);
364
+
// } catch (err) {
365
+
// console.error("Failed to parse preferences JSON:", err);
366
+
// throw err;
367
+
// }
310
368
311
-
set(cacheKey, JSON.stringify(data));
312
-
return data;
313
-
}
369
+
// set(cacheKey, JSON.stringify(data));
370
+
// return data;
371
+
// }
+129
-359
src/routes/profile.$did/index.tsx
+129
-359
src/routes/profile.$did/index.tsx
···
1
1
import { createFileRoute, Link } from "@tanstack/react-router";
2
2
import React from "react";
3
3
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
4
-
import { usePersistentStore } from "~/providers/PersistentStoreProvider";
4
+
import { useQueryClient } from "@tanstack/react-query";
5
5
6
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
-
const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
6
+
import {
7
+
useQueryIdentity,
8
+
useQueryProfile,
9
+
useInfiniteQueryAuthorFeed,
10
+
} from "~/utils/useQuery";
8
11
9
12
export const Route = createFileRoute("/profile/$did/")({
10
13
component: ProfileComponent,
···
12
15
13
16
function ProfileComponent() {
14
17
const { did } = Route.useParams();
15
-
const { get, set } = usePersistentStore();
16
-
const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
17
-
const [resolvedHandle, setResolvedHandle] = React.useState<string | null>(
18
-
null,
19
-
);
20
-
const [loading, setLoading] = React.useState(false);
21
-
const [error, setError] = React.useState<string | null>(null);
22
-
const [profile, setProfile] = React.useState<any>(null);
23
-
const [posts, setPosts] = React.useState<any[]>([]);
24
-
const [postsLoading, setPostsLoading] = React.useState(false);
25
-
const [cursor, setCursor] = React.useState<string | null>(null);
26
-
const [hasMore, setHasMore] = React.useState(true);
27
-
const [postsCached, setPostsCached] = React.useState(false);
18
+
const queryClient = useQueryClient();
28
19
29
-
React.useEffect(() => {
30
-
let ignore = false;
31
-
async function resolveDidIfNeeded() {
32
-
if (!did) {
33
-
setResolvedDid(null);
34
-
setResolvedHandle(null);
35
-
return;
36
-
}
37
-
if (did.startsWith("did:")) {
38
-
setResolvedDid(did);
39
-
setLoading(true);
40
-
setError(null);
41
-
const cacheKey = `handleDid:${did}`;
42
-
const now = Date.now();
43
-
const cached = await get(cacheKey);
44
-
if (
45
-
cached &&
46
-
cached.value &&
47
-
cached.time &&
48
-
now - cached.time < HANDLE_DID_CACHE_TIMEOUT
49
-
) {
50
-
try {
51
-
const data = JSON.parse(cached.value);
52
-
if (!ignore) {
53
-
setResolvedDid(data.did);
54
-
setResolvedHandle(data.handle || null);
55
-
}
56
-
setLoading(false);
57
-
return;
58
-
} catch {}
59
-
}
60
-
try {
61
-
const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(did)}`;
62
-
const res = await fetch(url);
63
-
if (!res.ok) throw new Error("Failed to resolve DID");
64
-
const data = await res.json();
65
-
set(cacheKey, JSON.stringify(data));
66
-
if (!ignore) {
67
-
setResolvedDid(data.did);
68
-
setResolvedHandle(data.handle || null);
69
-
}
70
-
} catch (e: any) {
71
-
if (!ignore)
72
-
setError("Failed to resolve handle: " + (e?.message || e));
73
-
} finally {
74
-
setLoading(false);
75
-
}
76
-
return;
77
-
}
78
-
setLoading(true);
79
-
setError(null);
80
-
const cacheKey = `handleDid:${did}`;
81
-
const now = Date.now();
82
-
const cached = await get(cacheKey);
83
-
if (
84
-
cached &&
85
-
cached.value &&
86
-
cached.time &&
87
-
now - cached.time < HANDLE_DID_CACHE_TIMEOUT
88
-
) {
89
-
try {
90
-
const data = JSON.parse(cached.value);
91
-
if (!ignore) {
92
-
setResolvedDid(data.did);
93
-
setResolvedHandle(data.handle || did);
94
-
}
95
-
setLoading(false);
96
-
return;
97
-
} catch {}
98
-
}
99
-
try {
100
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(did)}`;
101
-
const res = await fetch(url);
102
-
if (!res.ok) throw new Error("Failed to resolve handle");
103
-
const data = await res.json();
104
-
set(cacheKey, JSON.stringify(data));
105
-
if (!ignore) {
106
-
setResolvedDid(data.did);
107
-
setResolvedHandle(data.handle || did);
108
-
}
109
-
} catch (e: any) {
110
-
if (!ignore) setError("Failed to resolve handle: " + (e?.message || e));
111
-
} finally {
112
-
setLoading(false);
113
-
}
114
-
}
115
-
resolveDidIfNeeded();
116
-
return () => {
117
-
ignore = true;
118
-
};
119
-
}, [did, get, set]);
20
+
const {
21
+
data: identity,
22
+
isLoading: isIdentityLoading,
23
+
error: identityError,
24
+
} = useQueryIdentity(did);
120
25
121
-
React.useEffect(() => {
122
-
if (!resolvedDid) return;
123
-
let ignore = false;
124
-
async function fetchProfile() {
125
-
const cacheKey = `profile:${resolvedDid}`;
126
-
const now = Date.now();
127
-
const cached = await get(cacheKey);
128
-
if (
129
-
cached &&
130
-
cached.value &&
131
-
cached.time &&
132
-
now - cached.time < CACHE_TIMEOUT
133
-
) {
134
-
try {
135
-
if (!ignore) setProfile(JSON.parse(cached.value));
136
-
return;
137
-
} catch {}
138
-
}
139
-
try {
140
-
if (!resolvedDid) return;
141
-
let resolvedRaw = await get(`handleDid:${resolvedDid}`);
142
-
let resolved: any = null;
143
-
if (
144
-
resolvedRaw &&
145
-
resolvedRaw.value &&
146
-
resolvedRaw.time &&
147
-
now - resolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT
148
-
) {
149
-
try {
150
-
resolved = JSON.parse(resolvedRaw.value);
151
-
} catch {
152
-
resolved = null;
153
-
}
154
-
} else {
155
-
const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(resolvedDid)}`;
156
-
const res = await fetch(url);
157
-
if (!res.ok) throw new Error("Failed to resolve DID");
158
-
resolved = await res.json();
159
-
set(`handleDid:${resolvedDid}`, JSON.stringify(resolved));
160
-
}
161
-
if (!resolved || !resolved.pdsUrl)
162
-
throw new Error("DID resolution failed or missing pdsUrl");
26
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
27
+
const resolvedHandle = did.startsWith("did:") ? identity?.handle : did;
28
+
const pdsUrl = identity?.pds;
163
29
164
-
const profileUrl = `${resolved.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(resolvedDid)}&collection=app.bsky.actor.profile&rkey=self`;
165
-
const profileRes = await fetch(profileUrl);
166
-
if (!profileRes.ok) throw new Error("Failed to fetch profile");
167
-
const profileData = await profileRes.json();
168
-
if (!ignore) {
169
-
setProfile(profileData);
170
-
set(cacheKey, JSON.stringify(profileData));
171
-
}
172
-
} catch (e: any) {
173
-
if (!ignore) setError("Failed to fetch profile: " + (e?.message || e));
174
-
}
175
-
}
176
-
fetchProfile();
177
-
return () => {
178
-
ignore = true;
179
-
};
180
-
}, [resolvedDid, get, set]);
30
+
const profileUri = resolvedDid
31
+
? `at://${resolvedDid}/app.bsky.actor.profile/self`
32
+
: undefined;
33
+
const { data: profileRecord } = useQueryProfile(profileUri);
34
+
const profile = profileRecord?.value;
35
+
36
+
const {
37
+
data: postsData,
38
+
fetchNextPage,
39
+
hasNextPage,
40
+
isFetchingNextPage,
41
+
isLoading: arePostsLoading,
42
+
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
181
43
182
44
React.useEffect(() => {
183
-
if (!resolvedDid) return;
184
-
let ignore = false;
185
-
async function fetchPosts() {
186
-
setPostsLoading(true);
187
-
setPostsCached(false);
188
-
try {
189
-
if (!resolvedDid) return;
190
-
let resolvedRaw = await get(`handleDid:${resolvedDid}`);
191
-
let resolved: any = null;
192
-
const now = Date.now();
193
-
if (
194
-
resolvedRaw &&
195
-
resolvedRaw.value &&
196
-
resolvedRaw.time &&
197
-
now - resolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT
198
-
) {
199
-
try {
200
-
resolved = JSON.parse(resolvedRaw.value);
201
-
} catch {
202
-
resolved = null;
45
+
if (postsData) {
46
+
postsData.pages.forEach((page) => {
47
+
page.records.forEach((record) => {
48
+
if (!queryClient.getQueryData(["post", record.uri])) {
49
+
queryClient.setQueryData(["post", record.uri], record);
203
50
}
204
-
} else {
205
-
const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(resolvedDid)}`;
206
-
const res = await fetch(url);
207
-
if (!res.ok) throw new Error("Failed to resolve DID");
208
-
resolved = await res.json();
209
-
set(`handleDid:${resolvedDid}`, JSON.stringify(resolved));
210
-
}
211
-
if (!resolved || !resolved.pdsUrl)
212
-
throw new Error("DID resolution failed or missing pdsUrl");
213
-
214
-
const postsUrl = `${resolved.pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${resolvedDid}&collection=app.bsky.feed.post${cursor && false ? `&cursor=${cursor}` : ""}&limit=20`;
215
-
const postsRes = await fetch(postsUrl);
216
-
if (!postsRes.ok) throw new Error("Failed to fetch posts");
217
-
const postsData = await postsRes.json();
218
-
219
-
if (postsData.records) {
220
-
await Promise.all(
221
-
postsData.records.map(async (post: any) => {
222
-
if (post.uri && post.value) {
223
-
const postCacheKey = `record:${post.uri}`;
224
-
console.log(
225
-
"caching post",
226
-
postCacheKey,
227
-
JSON.stringify(post, null, 2),
228
-
);
229
-
await set(postCacheKey, JSON.stringify(post));
230
-
}
231
-
}),
232
-
);
233
-
}
51
+
});
52
+
});
53
+
}
54
+
}, [postsData, queryClient]);
234
55
235
-
if (!ignore) {
236
-
setPosts((prev) =>
237
-
cursor ? [...prev, ...postsData.records] : postsData.records,
238
-
);
239
-
setCursor(postsData.cursor || null);
240
-
setHasMore(postsData.records.length === 20);
241
-
setPostsCached(true);
242
-
}
243
-
} catch (e: any) {
244
-
if (!ignore) setError("Failed to fetch posts: " + (e?.message || e));
245
-
} finally {
246
-
if (!ignore) setPostsLoading(false);
247
-
}
248
-
}
249
-
fetchPosts();
250
-
return () => {
251
-
ignore = true;
252
-
};
253
-
}, [resolvedDid, cursor, get, set]);
56
+
const posts = React.useMemo(
57
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
58
+
[postsData]
59
+
);
254
60
255
-
function getAvatarUrl(profile: any) {
256
-
const link = profile?.value?.avatar?.ref?.["$link"];
61
+
function getAvatarUrl(p: typeof profile) {
62
+
const link = p?.avatar?.ref?.["$link"];
257
63
if (!link || !resolvedDid) return null;
258
64
return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
259
65
}
260
-
function getBannerUrl(profile: any) {
261
-
const link = profile?.value?.banner?.ref?.["$link"];
66
+
function getBannerUrl(p: typeof profile) {
67
+
const link = p?.banner?.ref?.["$link"];
262
68
if (!link || !resolvedDid) return null;
263
69
return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`;
264
70
}
265
71
266
72
const displayName =
267
-
profile?.value?.displayName ||
268
-
(resolvedHandle ? `@${resolvedHandle}` : did);
269
-
let handle: string;
270
-
if (resolvedHandle) {
271
-
handle = `@${resolvedHandle}`;
272
-
} else if (did && !did.startsWith("did:")) {
273
-
handle = `@${did}`;
274
-
} else {
275
-
handle = resolvedDid || did;
73
+
profile?.displayName || (resolvedHandle ? `@${resolvedHandle}` : did);
74
+
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
75
+
const description = profile?.description || "";
76
+
77
+
if (isIdentityLoading) {
78
+
return (
79
+
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
80
+
);
276
81
}
277
-
const description = profile?.value?.description || "";
278
82
279
-
if (!did) return <div>Invalid profile</div>;
280
-
if (loading) return <div>Resolving handle...</div>;
281
-
if (error) return <div style={{ color: "red" }}>{error}</div>;
282
-
if (!resolvedDid) return <div>Invalid profile</div>;
83
+
if (identityError) {
84
+
return (
85
+
<div className="p-4 text-center text-red-500">
86
+
Error: {identityError.message}
87
+
</div>
88
+
);
89
+
}
90
+
91
+
if (!resolvedDid) {
92
+
return (
93
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
94
+
);
95
+
}
283
96
284
97
return (
285
98
<>
286
-
<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">
99
+
<div className="flex 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">
287
100
<Link
288
101
to=".."
289
102
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
···
301
114
</div>
302
115
303
116
{/* Profile Header */}
304
-
<div
305
-
style={{
306
-
width: "100%",
307
-
maxWidth: 600,
308
-
margin: "0 auto",
309
-
boxShadow: "0 2px 12px #0002",
310
-
padding: 0,
311
-
color: "#eee",
312
-
fontFamily: "system-ui, sans-serif",
313
-
// marginTop: 20,
314
-
//background: '#181a20',
315
-
borderRadius: 16,
316
-
overflow: "hidden",
317
-
position: "relative",
318
-
}}
319
-
className="bg-gray-200 dark:bg-gray-900"
320
-
>
117
+
<div className="w-full max-w-2xl mx-auto shadow-lg rounded-b-lg overflow-hidden relative bg-gray-200 dark:bg-gray-900">
321
118
{/* Banner */}
322
119
<div
120
+
className="w-full h-40 bg-gray-300 dark:bg-gray-700"
323
121
style={{
324
-
width: "100%",
325
-
height: 160,
326
-
background: `#222 url(${getBannerUrl(profile)}) center/cover no-repeat`,
327
-
position: "relative",
122
+
backgroundImage: `url(${getBannerUrl(profile)})`,
123
+
backgroundSize: "cover",
124
+
backgroundPosition: "center",
328
125
}}
329
126
/>
127
+
330
128
{/* Avatar (PFP) */}
331
-
<div
332
-
style={{
333
-
position: "absolute",
334
-
left: "50%",
335
-
top: 120,
336
-
transform: "translateX(-50%)",
337
-
zIndex: 2,
338
-
borderRadius: "50%",
339
-
border: "4px solid #181a20",
340
-
boxShadow: "0 2px 8px #0006",
341
-
background: "#222",
342
-
}}
343
-
>
129
+
<div className="absolute left-[16px] top-[100px] ">
344
130
<img
345
131
src={getAvatarUrl(profile) || "/favicon.png"}
346
132
alt="avatar"
347
-
style={{
348
-
width: 112,
349
-
height: 112,
350
-
borderRadius: "50%",
351
-
objectFit: "cover",
352
-
display: "block",
353
-
}}
133
+
className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700"
354
134
/>
355
135
</div>
136
+
137
+
<div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5">
138
+
{/*
139
+
todo: full follow and unfollow backfill (along with partial likes backfill,
140
+
just enough for it to be useful)
141
+
also delay the backfill to be on demand because it would be pretty intense
142
+
also save it persistently
143
+
*/}
144
+
{true ? (
145
+
<>
146
+
<button className="rounded-full bg-gray-600 px-3 py-2 text-[14px]">
147
+
Follow
148
+
</button>
149
+
<button className="rounded-full bg-gray-600 px-3 py-2 text-[14px]">
150
+
Unfollow
151
+
</button>
152
+
</>
153
+
) : (
154
+
<button className="rounded-full bg-gray-600 px-3 py-2 text-[14px]">
155
+
Edit Profile
156
+
</button>
157
+
)}
158
+
<button className="rounded-full bg-gray-600 px-3 py-2 text-[14px]">
159
+
... {/* todo: icon */}
160
+
</button>
161
+
</div>
162
+
356
163
{/* Info Card */}
357
-
<div
358
-
style={{
359
-
marginTop: 72,
360
-
padding: "0 24px 24px 24px",
361
-
textAlign: "center",
362
-
}}
363
-
>
364
-
<div style={{ fontWeight: 700, fontSize: 24, marginBottom: 4 }}>
365
-
{displayName}
366
-
</div>
367
-
<div style={{ color: "#aaa", fontSize: 16, marginBottom: 12 }}>
164
+
<div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100">
165
+
<div className="font-bold text-2xl">{displayName}</div>
166
+
<div className="text-gray-500 dark:text-gray-400 text-base mb-3">
368
167
{handle}
369
168
</div>
370
169
{description && (
371
-
<div
372
-
style={{
373
-
fontSize: 16,
374
-
lineHeight: 1.5,
375
-
color: "#ddd",
376
-
marginBottom: 20,
377
-
}}
378
-
>
170
+
<div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]">
379
171
{description}
380
172
</div>
381
173
)}
382
-
{!profile && !error && (
383
-
<div style={{ color: "#888", padding: 16 }}>Loading profile...</div>
384
-
)}
385
174
</div>
386
175
</div>
387
176
388
-
{/* Posts */}
389
-
<div style={{ maxWidth: 600, margin: "0px auto 0", padding: 0 }}>
390
-
<div
391
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
392
-
style={{
393
-
fontSize: 18,
394
-
margin: "12px 16px 12px 16px",
395
-
fontWeight: 600,
396
-
}}
397
-
>
177
+
{/* Posts Section */}
178
+
<div className="max-w-2xl mx-auto">
179
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
398
180
Posts
399
181
</div>
400
-
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
401
-
{postsCached &&
402
-
posts.map((post) => {
403
-
return (
404
-
<UniversalPostRendererATURILoader
405
-
key={post.uri}
406
-
atUri={post.uri}
407
-
feedviewpost={true}
408
-
/>
409
-
);
410
-
})}
182
+
<div>
183
+
{posts.map((post) => (
184
+
<UniversalPostRendererATURILoader
185
+
key={post.uri}
186
+
atUri={post.uri}
187
+
feedviewpost={true}
188
+
/>
189
+
))}
411
190
</div>
412
-
{postsLoading && (
413
-
<div style={{ color: "#888", padding: 16, textAlign: "center" }}>
414
-
Loading posts...
415
-
</div>
191
+
192
+
{/* Loading and "Load More" states */}
193
+
{arePostsLoading && posts.length === 0 && (
194
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
195
+
)}
196
+
{isFetchingNextPage && (
197
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
416
198
)}
417
-
{hasMore && !postsLoading && (
199
+
{hasNextPage && !isFetchingNextPage && (
418
200
<button
419
-
onClick={() => setCursor(cursor)}
420
-
style={{
421
-
width: "100%",
422
-
padding: 12,
423
-
background: "#222",
424
-
color: "#eee",
425
-
border: "none",
426
-
borderRadius: 8,
427
-
cursor: "pointer",
428
-
fontSize: 16,
429
-
marginTop: 16,
430
-
}}
201
+
onClick={() => fetchNextPage()}
202
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
431
203
>
432
204
Load More Posts
433
205
</button>
434
206
)}
435
-
{posts.length === 0 && !postsLoading && !error && (
436
-
<div style={{ color: "#888", padding: 16, textAlign: "center" }}>
437
-
No posts found
438
-
</div>
207
+
{posts.length === 0 && !arePostsLoading && (
208
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
439
209
)}
440
210
</div>
441
211
</>
+297
-129
src/routes/profile.$did/post.$rkey.tsx
+297
-129
src/routes/profile.$did/post.$rkey.tsx
···
1
-
import { createFileRoute, Link } from '@tanstack/react-router';
2
-
import React from 'react';
3
-
import { UniversalPostRendererATURILoader, cachedGetRecord } from '~/components/UniversalPostRenderer';
4
-
import { usePersistentStore } from '~/providers/PersistentStoreProvider';
1
+
import { useQueryClient } from "@tanstack/react-query";
2
+
import { createFileRoute, Link } from "@tanstack/react-router";
3
+
import React, { useLayoutEffect } from "react";
4
+
import ShrinkingBox from "~/components/shrinkpadding";
5
+
import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer";
6
+
//import { usePersistentStore } from '~/providers/PersistentStoreProvider';
7
+
import {
8
+
useQueryIdentity,
9
+
useQueryPost,
10
+
useQueryConstellation,
11
+
constructPostQuery,
12
+
useQueryArbitrary,
13
+
} from "~/utils/useQuery";
5
14
6
-
const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
15
+
//const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
7
16
8
-
export const Route = createFileRoute('/profile/$did/post/$rkey')({
17
+
export const Route = createFileRoute("/profile/$did/post/$rkey")({
9
18
component: RouterWrapper,
10
19
});
11
20
12
21
function RouterWrapper() {
13
22
const { did, rkey } = Route.useParams();
14
23
15
-
return <ProfilePostComponent key={`/profile/${did}/post/${rkey}`} did={did} rkey={rkey} />;
24
+
return (
25
+
<>
26
+
<ProfilePostComponent
27
+
key={`/profile/${did}/post/${rkey}`}
28
+
did={did}
29
+
rkey={rkey}
30
+
/>
31
+
{/* <ShrinkingBox /> */}
32
+
</>
33
+
);
16
34
}
17
35
18
36
function ProfilePostComponent({ did, rkey }: { did: string; rkey: string }) {
19
-
const { get, set } = usePersistentStore();
20
-
const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
21
-
const [loading, setLoading] = React.useState(false);
22
-
const [error, setError] = React.useState<string | null>(null);
37
+
//const { get, set } = usePersistentStore();
38
+
const queryClient = useQueryClient();
39
+
// const [resolvedDid, setResolvedDid] = React.useState<string | null>(null);
40
+
// const [loading, setLoading] = React.useState(false);
41
+
// const [error, setError] = React.useState<string | null>(null);
42
+
43
+
// const [mainPost, setMainPost] = React.useState<any | null>(null);
44
+
// const [parents, setParents] = React.useState<any[]>([]);
45
+
// const [parentsLoading, setParentsLoading] = React.useState(false);
46
+
// const [replies, setReplies] = React.useState<any[]>([]);
47
+
48
+
// React.useEffect(() => {
49
+
// let ignore = false;
50
+
// async function resolveDidIfNeeded() {
51
+
// if (!did) {
52
+
// setResolvedDid(null);
53
+
// return;
54
+
// }
55
+
// if (did.startsWith('did:')) {
56
+
// setResolvedDid(did);
57
+
// return;
58
+
// }
59
+
// setLoading(true);
60
+
// setError(null);
61
+
// const cacheKey = `handleDid:${did}`;
62
+
// const now = Date.now();
63
+
// const cached = await get(cacheKey); // <-- await here
64
+
// if (cached && cached.value && cached.time && now - cached.time < HANDLE_DID_CACHE_TIMEOUT) {
65
+
// try {
66
+
// const data = JSON.parse(cached.value);
67
+
// if (!ignore) setResolvedDid(data.did);
68
+
// setLoading(false);
69
+
// return;
70
+
// } catch {}
71
+
// }
72
+
// try {
73
+
// const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(did)}`;
74
+
// const res = await fetch(url);
75
+
// if (!res.ok) throw new Error('Failed to resolve handle');
76
+
// const data = await res.json();
77
+
// await set(cacheKey, JSON.stringify(data)); // <-- await here
78
+
// if (!ignore) setResolvedDid(data.did);
79
+
// } catch (e: any) {
80
+
// if (!ignore) setError('Failed to resolve handle: ' + (e?.message || e));
81
+
// } finally {
82
+
// setLoading(false);
83
+
// }
84
+
// }
85
+
// resolveDidIfNeeded();
86
+
// return () => {
87
+
// ignore = true;
88
+
// };
89
+
// }, [did, get, set]);
90
+
91
+
// const atUri = resolvedDid && rkey ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` : '';
92
+
93
+
// React.useEffect(() => {
94
+
// if (!atUri) return;
95
+
// let ignore = false;
96
+
// async function fetchMainPost() {
97
+
// try {
98
+
// const postData = await cachedGetRecord({ atUri, get, set });
99
+
// if (!ignore) {
100
+
// setMainPost(postData);
101
+
// }
102
+
// } catch (e) {
103
+
// console.error('Failed to fetch main post record:', e);
104
+
// }
105
+
// }
106
+
// fetchMainPost();
107
+
// return () => {
108
+
// ignore = true;
109
+
// };
110
+
// }, [atUri, get, set]);
111
+
112
+
// React.useEffect(() => {
113
+
// if (!mainPost) return;
114
+
// let ignore = false;
115
+
// async function fetchParents() {
116
+
// setParentsLoading(true);
117
+
// const parentChain: any[] = [];
118
+
// let currentParentUri = mainPost.value?.reply?.parent?.uri;
119
+
// const MAX_PARENTS = 25; // Important to know theres a limit
120
+
// let safetyCounter = 0;
121
+
122
+
// while (currentParentUri && safetyCounter < MAX_PARENTS) {
123
+
// try {
124
+
// const parentPost = await cachedGetRecord({ atUri: currentParentUri, get, set });
125
+
// if (!parentPost) break;
126
+
// parentChain.push(parentPost);
127
+
// currentParentUri = parentPost.value?.reply?.parent?.uri;
128
+
// safetyCounter++;
129
+
// } catch (error) {
130
+
// console.error('Failed to fetch a parent post:', error);
131
+
// break;
132
+
// }
133
+
// }
134
+
135
+
// if (!ignore) {
136
+
// setParents(parentChain.reverse());
137
+
// setParentsLoading(false);
138
+
// }
139
+
// }
140
+
141
+
// fetchParents();
142
+
// return () => {
143
+
// ignore = true;
144
+
// };
145
+
// }, [mainPost, get, set]);
23
146
24
-
const [mainPost, setMainPost] = React.useState<any | null>(null);
147
+
// React.useEffect(() => {
148
+
// if (!atUri) return;
149
+
// let ignore = false;
150
+
// async function fetchReplies() {
151
+
// try {
152
+
// const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent(
153
+
// atUri,
154
+
// )}&collection=app.bsky.feed.post&path=.reply.parent.uri`;
155
+
// const res = await fetch(url);
156
+
// if (!res.ok) throw new Error('Failed to fetch replies');
157
+
// const data = await res.json();
158
+
// if (!ignore && data.linking_records) {
159
+
// setReplies(data.linking_records.slice(0, 50));
160
+
// }
161
+
// } catch (e) {
162
+
// if (!ignore) setReplies([]);
163
+
// }
164
+
// }
165
+
// fetchReplies();
166
+
// return () => {
167
+
// ignore = true;
168
+
// };
169
+
// }, [atUri]);
170
+
171
+
const {
172
+
data: identity,
173
+
isLoading: isIdentityLoading,
174
+
error: identityError,
175
+
} = useQueryIdentity(did);
176
+
177
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
178
+
179
+
const atUri = React.useMemo(
180
+
() =>
181
+
resolvedDid
182
+
? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}`
183
+
: "",
184
+
[resolvedDid, rkey]
185
+
);
186
+
187
+
const { data: mainPost } = useQueryPost(atUri);
188
+
189
+
const { data: repliesData } = useQueryConstellation({
190
+
method: "/links",
191
+
target: atUri,
192
+
collection: "app.bsky.feed.post",
193
+
path: ".reply.parent.uri",
194
+
});
195
+
const replies = repliesData?.linking_records.slice(0, 50) ?? [];
196
+
25
197
const [parents, setParents] = React.useState<any[]>([]);
26
198
const [parentsLoading, setParentsLoading] = React.useState(false);
27
-
const [replies, setReplies] = React.useState<any[]>([]);
199
+
200
+
const mainPostRef = React.useRef<HTMLDivElement>(null);
201
+
const userHasScrolled = React.useRef(false);
202
+
203
+
const scrollAnchor = React.useRef<{ top: number } | null>(null);
204
+
28
205
29
206
React.useEffect(() => {
30
-
let ignore = false;
31
-
async function resolveDidIfNeeded() {
32
-
if (!did) {
33
-
setResolvedDid(null);
34
-
return;
35
-
}
36
-
if (did.startsWith('did:')) {
37
-
setResolvedDid(did);
38
-
return;
39
-
}
40
-
setLoading(true);
41
-
setError(null);
42
-
const cacheKey = `handleDid:${did}`;
43
-
const now = Date.now();
44
-
const cached = await get(cacheKey); // <-- await here
45
-
if (cached && cached.value && cached.time && now - cached.time < HANDLE_DID_CACHE_TIMEOUT) {
46
-
try {
47
-
const data = JSON.parse(cached.value);
48
-
if (!ignore) setResolvedDid(data.did);
49
-
setLoading(false);
50
-
return;
51
-
} catch {}
52
-
}
53
-
try {
54
-
const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(did)}`;
55
-
const res = await fetch(url);
56
-
if (!res.ok) throw new Error('Failed to resolve handle');
57
-
const data = await res.json();
58
-
await set(cacheKey, JSON.stringify(data)); // <-- await here
59
-
if (!ignore) setResolvedDid(data.did);
60
-
} catch (e: any) {
61
-
if (!ignore) setError('Failed to resolve handle: ' + (e?.message || e));
62
-
} finally {
63
-
setLoading(false);
207
+
const onScroll = () => {
208
+
209
+
if (window.scrollY > 50) {
210
+
userHasScrolled.current = true;
211
+
212
+
window.removeEventListener("scroll", onScroll);
64
213
}
65
-
}
66
-
resolveDidIfNeeded();
67
-
return () => {
68
-
ignore = true;
69
214
};
70
-
}, [did, get, set]);
215
+
216
+
if (!userHasScrolled.current) {
217
+
window.addEventListener("scroll", onScroll, { passive: true });
218
+
}
219
+
return () => window.removeEventListener("scroll", onScroll);
220
+
}, []);
71
221
72
-
const atUri = resolvedDid && rkey ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` : '';
222
+
useLayoutEffect(() => {
223
+
if (parentsLoading && mainPostRef.current && !userHasScrolled.current) {
224
+
scrollAnchor.current = {
225
+
top: mainPostRef.current.getBoundingClientRect().top,
226
+
};
227
+
}
228
+
}, [parentsLoading]);
73
229
74
-
React.useEffect(() => {
75
-
if (!atUri) return;
76
-
let ignore = false;
77
-
async function fetchMainPost() {
78
-
try {
79
-
const postData = await cachedGetRecord({ atUri, get, set });
80
-
if (!ignore) {
81
-
setMainPost(postData);
82
-
}
83
-
} catch (e) {
84
-
console.error('Failed to fetch main post record:', e);
230
+
useLayoutEffect(() => {
231
+
if (
232
+
scrollAnchor.current &&
233
+
mainPostRef.current &&
234
+
!userHasScrolled.current
235
+
) {
236
+
const newTop = mainPostRef.current.getBoundingClientRect().top;
237
+
const topDiff = newTop - scrollAnchor.current.top;
238
+
if (topDiff > 0) {
239
+
window.scrollBy(0, topDiff);
85
240
}
241
+
scrollAnchor.current = null;
86
242
}
87
-
fetchMainPost();
88
-
return () => {
89
-
ignore = true;
90
-
};
91
-
}, [atUri, get, set]);
243
+
}, [parents]);
92
244
93
245
React.useEffect(() => {
94
-
if (!mainPost) return;
246
+
if (!mainPost?.value?.reply?.parent?.uri) {
247
+
setParents([]);
248
+
return;
249
+
}
250
+
95
251
let ignore = false;
96
-
async function fetchParents() {
252
+
const fetchParents = async () => {
97
253
setParentsLoading(true);
98
254
const parentChain: any[] = [];
99
-
let currentParentUri = mainPost.value?.reply?.parent?.uri;
100
-
const MAX_PARENTS = 25; // Important to know theres a limit
255
+
let currentParentUri = mainPost?.value.reply?.parent.uri;
256
+
const MAX_PARENTS = 25;
101
257
let safetyCounter = 0;
102
258
103
259
while (currentParentUri && safetyCounter < MAX_PARENTS) {
104
260
try {
105
-
const parentPost = await cachedGetRecord({ atUri: currentParentUri, get, set });
261
+
const parentPost = await queryClient.fetchQuery(
262
+
constructPostQuery(currentParentUri)
263
+
);
106
264
if (!parentPost) break;
107
265
parentChain.push(parentPost);
108
266
currentParentUri = parentPost.value?.reply?.parent?.uri;
109
-
safetyCounter++;
110
267
} catch (error) {
111
-
console.error('Failed to fetch a parent post:', error);
268
+
console.error("Failed to fetch a parent post:", error);
112
269
break;
113
270
}
271
+
safetyCounter++;
114
272
}
115
273
116
274
if (!ignore) {
117
275
setParents(parentChain.reverse());
118
276
setParentsLoading(false);
119
277
}
120
-
}
278
+
};
121
279
122
280
fetchParents();
123
281
return () => {
124
282
ignore = true;
125
283
};
126
-
}, [mainPost, get, set]);
127
-
128
-
React.useEffect(() => {
129
-
if (!atUri) return;
130
-
let ignore = false;
131
-
async function fetchReplies() {
132
-
try {
133
-
const url = `https://constellation.microcosm.blue/links?target=${encodeURIComponent(
134
-
atUri,
135
-
)}&collection=app.bsky.feed.post&path=.reply.parent.uri`;
136
-
const res = await fetch(url);
137
-
if (!res.ok) throw new Error('Failed to fetch replies');
138
-
const data = await res.json();
139
-
if (!ignore && data.linking_records) {
140
-
setReplies(data.linking_records.slice(0, 50));
141
-
}
142
-
} catch (e) {
143
-
if (!ignore) setReplies([]);
144
-
}
145
-
}
146
-
fetchReplies();
147
-
return () => {
148
-
ignore = true;
149
-
};
150
-
}, [atUri]);
284
+
}, [mainPost, queryClient]);
151
285
152
286
if (!did || !rkey) return <div>Invalid post URI</div>;
153
-
if (loading) return <div>Resolving handle...</div>;
154
-
if (error) return <div style={{ color: 'red' }}>{error}</div>;
155
-
if (!atUri) return <div>Invalid post URI</div>;
287
+
if (isIdentityLoading) return <div>Resolving handle...</div>;
288
+
if (identityError)
289
+
return <div style={{ color: "red" }}>{identityError.message}</div>;
290
+
if (!atUri) return <div>Could not construct post URI.</div>;
156
291
157
292
return (
158
293
<>
···
160
295
<Link
161
296
to=".."
162
297
className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
163
-
onClick={e => {
298
+
onClick={(e) => {
164
299
e.preventDefault();
165
-
window.history.length > 1 ? window.history.back() : window.location.assign('/');
300
+
window.history.length > 1
301
+
? window.history.back()
302
+
: window.location.assign("/");
166
303
}}
167
304
aria-label="Go back"
168
305
>
···
171
308
<span className="text-xl font-bold ml-2">Post</span>
172
309
</div>
173
310
174
-
{parentsLoading && <div className="p-4 text-center text-gray-500 dark:text-gray-400">Loading conversation...</div>}
311
+
{parentsLoading && (
312
+
<div className="text-center text-gray-500 dark:text-gray-400 flex flex-row">
313
+
<div className="ml-4 w-[42px] flex justify-center">
314
+
<div
315
+
style={{ width: 2, height: "100%", opacity: 0.5 }}
316
+
className="bg-gray-500 dark:bg-gray-400"
317
+
></div>
318
+
</div>
319
+
Loading conversation...
320
+
</div>
321
+
)}
175
322
176
323
{/* we should use the reply lines here thats provided by UPR*/}
177
-
<div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}>
324
+
<div style={{ maxWidth: 600, margin: "0px auto 0", padding: 0 }}>
178
325
{parents.map((parent, index) => (
179
-
<UniversalPostRendererATURILoader key={parent.uri} atUri={parent.uri}
180
-
topReplyLine={index > 0}
326
+
<UniversalPostRendererATURILoader
327
+
key={parent.uri}
328
+
atUri={parent.uri}
329
+
topReplyLine={index > 0}
181
330
bottomReplyLine={true}
182
331
bottomBorder={false}
183
-
/>
332
+
/>
184
333
))}
185
334
</div>
186
-
187
-
<UniversalPostRendererATURILoader atUri={atUri} detailed={true} topReplyLine={parents.length > 0} />
188
-
189
-
{replies.length > 0 && (
190
-
<div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}>
191
-
<div
192
-
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
193
-
style={{ fontSize: 18, margin: '12px 16px 12px 16px', fontWeight: 600 }}
194
-
>
195
-
Replies
196
-
</div>
197
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
198
-
{replies.map(reply => {
335
+
<div ref={mainPostRef}>
336
+
<UniversalPostRendererATURILoader
337
+
atUri={atUri}
338
+
detailed={true}
339
+
topReplyLine={parentsLoading || parents.length > 0}
340
+
/>
341
+
</div>
342
+
<div
343
+
style={{
344
+
maxWidth: 600,
345
+
margin: "0px auto 0",
346
+
padding: 0,
347
+
minHeight: "100dvh",
348
+
}}
349
+
>
350
+
<div
351
+
className="text-gray-500 dark:text-gray-400 text-sm font-bold"
352
+
style={{
353
+
fontSize: 18,
354
+
margin: "12px 16px 12px 16px",
355
+
fontWeight: 600,
356
+
}}
357
+
>
358
+
Replies
359
+
</div>
360
+
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
361
+
{replies.length > 0 &&
362
+
replies.map((reply) => {
199
363
const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`;
200
-
return <UniversalPostRendererATURILoader key={replyAtUri} atUri={replyAtUri} />;
364
+
return (
365
+
<UniversalPostRendererATURILoader
366
+
key={replyAtUri}
367
+
atUri={replyAtUri}
368
+
/>
369
+
);
201
370
})}
202
-
</div>
203
371
</div>
204
-
)}
372
+
</div>
205
373
</>
206
374
);
207
-
}
375
+
}
+5
src/utils/atoms.ts
+5
src/utils/atoms.ts
+276
src/utils/useHydrated.ts
+276
src/utils/useHydrated.ts
···
1
+
import { useState, useEffect, useMemo } from "react";
2
+
import {
3
+
AppBskyEmbedExternal,
4
+
AppBskyEmbedImages,
5
+
AppBskyEmbedRecord,
6
+
AppBskyEmbedRecordWithMedia,
7
+
AppBskyEmbedVideo,
8
+
AppBskyActorDefs,
9
+
AppBskyFeedPost,
10
+
AtUri,
11
+
type $Typed,
12
+
} from "@atproto/api";
13
+
import * as ATPAPI from "@atproto/api"
14
+
15
+
import { useQueryPost, useQueryProfile, useQueryIdentity } from "./useQuery";
16
+
17
+
type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends
18
+
| { data: infer D }
19
+
| undefined
20
+
? D
21
+
: never;
22
+
23
+
function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
24
+
return obj as $Typed<T>;
25
+
}
26
+
27
+
export function hydrateEmbedImages(
28
+
embed: AppBskyEmbedImages.Main,
29
+
did: string,
30
+
): $Typed<AppBskyEmbedImages.View> {
31
+
return asTyped({
32
+
$type: "app.bsky.embed.images#view" as const,
33
+
images: embed.images
34
+
.map((img) => {
35
+
const link = img.image.ref?.["$link"];
36
+
if (!link) return null;
37
+
return {
38
+
thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`,
39
+
fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`,
40
+
alt: img.alt || "",
41
+
aspectRatio: img.aspectRatio,
42
+
};
43
+
})
44
+
.filter(Boolean) as AppBskyEmbedImages.ViewImage[],
45
+
});
46
+
}
47
+
48
+
export function hydrateEmbedExternal(
49
+
embed: AppBskyEmbedExternal.Main,
50
+
did: string,
51
+
): $Typed<AppBskyEmbedExternal.View> {
52
+
return asTyped({
53
+
$type: "app.bsky.embed.external#view" as const,
54
+
external: {
55
+
uri: embed.external.uri,
56
+
title: embed.external.title,
57
+
description: embed.external.description,
58
+
thumb: embed.external.thumb?.ref?.$link
59
+
? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg`
60
+
: undefined,
61
+
},
62
+
});
63
+
}
64
+
65
+
export function hydrateEmbedVideo(
66
+
embed: AppBskyEmbedVideo.Main,
67
+
did: string,
68
+
): $Typed<AppBskyEmbedVideo.View> {
69
+
const videoLink = embed.video.ref.$link;
70
+
return asTyped({
71
+
$type: "app.bsky.embed.video#view" as const,
72
+
playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`,
73
+
thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`,
74
+
aspectRatio: embed.aspectRatio,
75
+
cid: videoLink,
76
+
});
77
+
}
78
+
79
+
function hydrateEmbedRecord(
80
+
embed: AppBskyEmbedRecord.Main,
81
+
quotedPost: QueryResultData<typeof useQueryPost>,
82
+
quotedProfile: QueryResultData<typeof useQueryProfile>,
83
+
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
84
+
): $Typed<AppBskyEmbedRecord.View> | undefined {
85
+
if (!quotedPost || !quotedProfile || !quotedIdentity) {
86
+
return undefined;
87
+
}
88
+
89
+
const author: $Typed<AppBskyActorDefs.ProfileViewBasic> = asTyped({
90
+
$type: "app.bsky.actor.defs#profileViewBasic" as const,
91
+
did: quotedIdentity.did,
92
+
handle: quotedIdentity.handle,
93
+
displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
94
+
avatar: quotedProfile.value.avatar?.ref?.$link
95
+
? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
96
+
: undefined,
97
+
viewer: {},
98
+
labels: [],
99
+
});
100
+
101
+
const viewRecord: $Typed<AppBskyEmbedRecord.ViewRecord> = asTyped({
102
+
$type: "app.bsky.embed.record#viewRecord" as const,
103
+
uri: quotedPost.uri,
104
+
cid: quotedPost.cid,
105
+
author,
106
+
value: quotedPost.value,
107
+
indexedAt: quotedPost.value.createdAt,
108
+
embeds: quotedPost.value.embed ? [quotedPost.value.embed] : undefined,
109
+
});
110
+
111
+
return asTyped({
112
+
$type: "app.bsky.embed.record#view" as const,
113
+
record: viewRecord,
114
+
});
115
+
}
116
+
117
+
function hydrateEmbedRecordWithMedia(
118
+
embed: AppBskyEmbedRecordWithMedia.Main,
119
+
mediaHydratedEmbed:
120
+
| $Typed<AppBskyEmbedImages.View>
121
+
| $Typed<AppBskyEmbedVideo.View>
122
+
| $Typed<AppBskyEmbedExternal.View>,
123
+
quotedPost: QueryResultData<typeof useQueryPost>,
124
+
quotedProfile: QueryResultData<typeof useQueryProfile>,
125
+
quotedIdentity: QueryResultData<typeof useQueryIdentity>,
126
+
): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
127
+
const hydratedRecord = hydrateEmbedRecord(
128
+
embed.record,
129
+
quotedPost,
130
+
quotedProfile,
131
+
quotedIdentity,
132
+
);
133
+
134
+
if (!hydratedRecord) return undefined;
135
+
136
+
return asTyped({
137
+
$type: "app.bsky.embed.recordWithMedia#view" as const,
138
+
record: hydratedRecord,
139
+
media: mediaHydratedEmbed,
140
+
});
141
+
}
142
+
143
+
type HydratedEmbedView =
144
+
| $Typed<AppBskyEmbedImages.View>
145
+
| $Typed<AppBskyEmbedExternal.View>
146
+
| $Typed<AppBskyEmbedVideo.View>
147
+
| $Typed<AppBskyEmbedRecord.View>
148
+
| $Typed<AppBskyEmbedRecordWithMedia.View>;
149
+
150
+
export function useHydratedEmbed(
151
+
embed: AppBskyFeedPost.Record["embed"],
152
+
postAuthorDid: string | undefined,
153
+
) {
154
+
const recordInfo = useMemo(() => {
155
+
if (
156
+
AppBskyEmbedRecordWithMedia.isMain(embed)
157
+
) {
158
+
const recordUri = embed.record.record.uri;
159
+
const quotedAuthorDid = new AtUri(recordUri).hostname;
160
+
return { recordUri, quotedAuthorDid, isRecordType: true };
161
+
} else
162
+
if (
163
+
AppBskyEmbedRecord.isMain(embed)
164
+
) {
165
+
const recordUri = embed.record.uri;
166
+
const quotedAuthorDid = new AtUri(recordUri).hostname;
167
+
return { recordUri, quotedAuthorDid, isRecordType: true };
168
+
}
169
+
return {
170
+
recordUri: undefined,
171
+
quotedAuthorDid: undefined,
172
+
isRecordType: false,
173
+
};
174
+
}, [embed]);
175
+
const { isRecordType, recordUri, quotedAuthorDid } = recordInfo;
176
+
177
+
178
+
const usequerypostresults = useQueryPost(recordUri);
179
+
// const {
180
+
// data: quotedPost,
181
+
// isLoading: isLoadingPost,
182
+
// error: postError,
183
+
// } = usequerypostresults
184
+
185
+
const profileUri = quotedAuthorDid ? `at://${quotedAuthorDid}/app.bsky.actor.profile/self` : undefined;
186
+
187
+
const {
188
+
data: quotedProfile,
189
+
isLoading: isLoadingProfile,
190
+
error: profileError,
191
+
} = useQueryProfile(profileUri);
192
+
193
+
const queryidentityresult = useQueryIdentity(quotedAuthorDid);
194
+
// const {
195
+
// data: quotedIdentity,
196
+
// isLoading: isLoadingIdentity,
197
+
// error: identityError,
198
+
// } = queryidentityresult
199
+
200
+
const [hydratedEmbed, setHydratedEmbed] = useState<
201
+
HydratedEmbedView | undefined
202
+
>(undefined);
203
+
204
+
useEffect(() => {
205
+
if (!embed || !postAuthorDid) {
206
+
setHydratedEmbed(undefined);
207
+
return;
208
+
}
209
+
210
+
if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) {
211
+
setHydratedEmbed(undefined);
212
+
return;
213
+
}
214
+
215
+
try {
216
+
let result: HydratedEmbedView | undefined;
217
+
218
+
if (AppBskyEmbedImages.isMain(embed)) {
219
+
result = hydrateEmbedImages(embed, postAuthorDid);
220
+
} else if (AppBskyEmbedExternal.isMain(embed)) {
221
+
result = hydrateEmbedExternal(embed, postAuthorDid);
222
+
} else if (AppBskyEmbedVideo.isMain(embed)) {
223
+
result = hydrateEmbedVideo(embed, postAuthorDid);
224
+
} else if (AppBskyEmbedRecord.isMain(embed)) {
225
+
result = hydrateEmbedRecord(
226
+
embed,
227
+
usequerypostresults?.data,
228
+
quotedProfile,
229
+
queryidentityresult?.data,
230
+
);
231
+
} else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
232
+
let hydratedMedia:
233
+
| $Typed<AppBskyEmbedImages.View>
234
+
| $Typed<AppBskyEmbedVideo.View>
235
+
| $Typed<AppBskyEmbedExternal.View>
236
+
| undefined;
237
+
238
+
if (AppBskyEmbedImages.isMain(embed.media)) {
239
+
hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid);
240
+
} else if (AppBskyEmbedExternal.isMain(embed.media)) {
241
+
hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid);
242
+
} else if (AppBskyEmbedVideo.isMain(embed.media)) {
243
+
hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid);
244
+
}
245
+
246
+
if (hydratedMedia) {
247
+
result = hydrateEmbedRecordWithMedia(
248
+
embed,
249
+
hydratedMedia,
250
+
usequerypostresults?.data,
251
+
quotedProfile,
252
+
queryidentityresult?.data,
253
+
);
254
+
}
255
+
}
256
+
setHydratedEmbed(result);
257
+
} catch (e) {
258
+
console.error("Error hydrating embed", e);
259
+
setHydratedEmbed(undefined);
260
+
}
261
+
}, [
262
+
embed,
263
+
postAuthorDid,
264
+
isRecordType,
265
+
usequerypostresults?.data,
266
+
quotedProfile,
267
+
queryidentityresult?.data,
268
+
]);
269
+
270
+
const isLoading = isRecordType
271
+
? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading
272
+
: false;
273
+
const error = usequerypostresults?.error || profileError || queryidentityresult?.error;
274
+
275
+
return { data: hydratedEmbed, isLoading, error };
276
+
}
+551
src/utils/useQuery.ts
+551
src/utils/useQuery.ts
···
1
+
import {
2
+
queryOptions,
3
+
useQuery,
4
+
useInfiniteQuery,
5
+
type QueryFunctionContext,
6
+
type UseQueryResult,
7
+
type InfiniteData
8
+
} from "@tanstack/react-query";
9
+
import * as ATPAPI from "@atproto/api";
10
+
11
+
export function constructIdentityQuery(didorhandle?: string) {
12
+
return queryOptions({
13
+
queryKey: ["identity", didorhandle],
14
+
queryFn: async () => {
15
+
if (!didorhandle) return undefined as undefined
16
+
const res = await fetch(
17
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
18
+
);
19
+
if (!res.ok) throw new Error("Failed to fetch post");
20
+
try {
21
+
return (await res.json()) as {
22
+
did: string;
23
+
handle: string;
24
+
pds: string;
25
+
signing_key: string;
26
+
};
27
+
} catch (_e) {
28
+
return undefined;
29
+
}
30
+
},
31
+
});
32
+
}
33
+
export function useQueryIdentity(didorhandle: string): UseQueryResult<
34
+
{
35
+
did: string;
36
+
handle: string;
37
+
pds: string;
38
+
signing_key: string;
39
+
},
40
+
Error
41
+
>;
42
+
export function useQueryIdentity(): UseQueryResult<
43
+
undefined,
44
+
Error
45
+
>
46
+
export function useQueryIdentity(didorhandle?: string):
47
+
UseQueryResult<
48
+
{
49
+
did: string;
50
+
handle: string;
51
+
pds: string;
52
+
signing_key: string;
53
+
} | undefined,
54
+
Error
55
+
>
56
+
export function useQueryIdentity(didorhandle?: string) {
57
+
return useQuery(constructIdentityQuery(didorhandle));
58
+
}
59
+
60
+
export function constructPostQuery(uri?: string) {
61
+
return queryOptions({
62
+
queryKey: ["post", uri],
63
+
queryFn: async () => {
64
+
if (!uri) return undefined as undefined
65
+
const res = await fetch(
66
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
67
+
);
68
+
if (!res.ok) throw new Error("Failed to fetch post");
69
+
try {
70
+
return (await res.json()) as {
71
+
uri: string;
72
+
cid: string;
73
+
value: ATPAPI.AppBskyFeedPost.Record;
74
+
};
75
+
} catch (_e) {
76
+
return undefined;
77
+
}
78
+
},
79
+
});
80
+
}
81
+
export function useQueryPost(uri: string): UseQueryResult<
82
+
{
83
+
uri: string;
84
+
cid: string;
85
+
value: ATPAPI.AppBskyFeedPost.Record;
86
+
},
87
+
Error
88
+
>;
89
+
export function useQueryPost(): UseQueryResult<
90
+
undefined,
91
+
Error
92
+
>
93
+
export function useQueryPost(uri?: string):
94
+
UseQueryResult<
95
+
{
96
+
uri: string;
97
+
cid: string;
98
+
value: ATPAPI.AppBskyFeedPost.Record;
99
+
} | undefined,
100
+
Error
101
+
>
102
+
export function useQueryPost(uri?: string) {
103
+
return useQuery(constructPostQuery(uri));
104
+
}
105
+
106
+
export function constructProfileQuery(uri?: string) {
107
+
return queryOptions({
108
+
queryKey: ["profile", uri],
109
+
queryFn: async () => {
110
+
if (!uri) return undefined as undefined
111
+
const res = await fetch(
112
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
113
+
);
114
+
if (!res.ok) throw new Error("Failed to fetch post");
115
+
try {
116
+
return (await res.json()) as {
117
+
uri: string;
118
+
cid: string;
119
+
value: ATPAPI.AppBskyActorProfile.Record;
120
+
};
121
+
} catch (_e) {
122
+
return undefined;
123
+
}
124
+
},
125
+
});
126
+
}
127
+
export function useQueryProfile(uri: string): UseQueryResult<
128
+
{
129
+
uri: string;
130
+
cid: string;
131
+
value: ATPAPI.AppBskyActorProfile.Record;
132
+
},
133
+
Error
134
+
>;
135
+
export function useQueryProfile(): UseQueryResult<
136
+
undefined,
137
+
Error
138
+
>;
139
+
export function useQueryProfile(uri?: string):
140
+
UseQueryResult<
141
+
{
142
+
uri: string;
143
+
cid: string;
144
+
value: ATPAPI.AppBskyActorProfile.Record;
145
+
} | undefined,
146
+
Error
147
+
>
148
+
export function useQueryProfile(uri?: string) {
149
+
return useQuery(constructProfileQuery(uri));
150
+
}
151
+
152
+
// export function constructConstellationQuery(
153
+
// method: "/links",
154
+
// target: string,
155
+
// collection: string,
156
+
// path: string,
157
+
// cursor?: string
158
+
// ): QueryOptions<linksRecordsResponse, Error>;
159
+
// export function constructConstellationQuery(
160
+
// method: "/links/distinct-dids",
161
+
// target: string,
162
+
// collection: string,
163
+
// path: string,
164
+
// cursor?: string
165
+
// ): QueryOptions<linksDidsResponse, Error>;
166
+
// export function constructConstellationQuery(
167
+
// method: "/links/count",
168
+
// target: string,
169
+
// collection: string,
170
+
// path: string,
171
+
// cursor?: string
172
+
// ): QueryOptions<linksCountResponse, Error>;
173
+
// export function constructConstellationQuery(
174
+
// method: "/links/count/distinct-dids",
175
+
// target: string,
176
+
// collection: string,
177
+
// path: string,
178
+
// cursor?: string
179
+
// ): QueryOptions<linksCountResponse, Error>;
180
+
// export function constructConstellationQuery(
181
+
// method: "/links/all",
182
+
// target: string
183
+
// ): QueryOptions<linksAllResponse, Error>;
184
+
export function constructConstellationQuery(query?:{
185
+
method:
186
+
| "/links"
187
+
| "/links/distinct-dids"
188
+
| "/links/count"
189
+
| "/links/count/distinct-dids"
190
+
| "/links/all",
191
+
target: string,
192
+
collection?: string,
193
+
path?: string,
194
+
cursor?: string
195
+
}
196
+
) {
197
+
// : QueryOptions<
198
+
// | linksRecordsResponse
199
+
// | linksDidsResponse
200
+
// | linksCountResponse
201
+
// | linksAllResponse
202
+
// | undefined,
203
+
// Error
204
+
// >
205
+
return queryOptions({
206
+
queryKey: ["post", query?.method, query?.target, query?.collection, query?.path, query?.cursor] as const,
207
+
queryFn: async () => {
208
+
if (!query) return undefined as undefined
209
+
const method = query.method
210
+
const target = query.target
211
+
const collection = query?.collection
212
+
const path = query?.path
213
+
const cursor = query.cursor
214
+
const res = await fetch(
215
+
`https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
216
+
);
217
+
if (!res.ok) throw new Error("Failed to fetch post");
218
+
try {
219
+
switch (method) {
220
+
case "/links":
221
+
return (await res.json()) as linksRecordsResponse;
222
+
case "/links/distinct-dids":
223
+
return (await res.json()) as linksDidsResponse;
224
+
case "/links/count":
225
+
return (await res.json()) as linksCountResponse;
226
+
case "/links/count/distinct-dids":
227
+
return (await res.json()) as linksCountResponse;
228
+
case "/links/all":
229
+
return (await res.json()) as linksAllResponse;
230
+
default:
231
+
return undefined;
232
+
}
233
+
} catch (_e) {
234
+
return undefined;
235
+
}
236
+
},
237
+
});
238
+
}
239
+
export function useQueryConstellation(query: {
240
+
method: "/links";
241
+
target: string;
242
+
collection: string;
243
+
path: string;
244
+
cursor?: string;
245
+
}): UseQueryResult<linksRecordsResponse, Error>;
246
+
export function useQueryConstellation(query: {
247
+
method: "/links/distinct-dids";
248
+
target: string;
249
+
collection: string;
250
+
path: string;
251
+
cursor?: string;
252
+
}): UseQueryResult<linksDidsResponse, Error>;
253
+
export function useQueryConstellation(query: {
254
+
method: "/links/count";
255
+
target: string;
256
+
collection: string;
257
+
path: string;
258
+
cursor?: string;
259
+
}): UseQueryResult<linksCountResponse, Error>;
260
+
export function useQueryConstellation(query: {
261
+
method: "/links/count/distinct-dids";
262
+
target: string;
263
+
collection: string;
264
+
path: string;
265
+
cursor?: string;
266
+
}): UseQueryResult<linksCountResponse, Error>;
267
+
export function useQueryConstellation(query: {
268
+
method: "/links/all";
269
+
target: string;
270
+
}): UseQueryResult<linksAllResponse, Error>;
271
+
export function useQueryConstellation(): undefined;
272
+
export function useQueryConstellation(query?: {
273
+
method:
274
+
| "/links"
275
+
| "/links/distinct-dids"
276
+
| "/links/count"
277
+
| "/links/count/distinct-dids"
278
+
| "/links/all";
279
+
target: string;
280
+
collection?: string;
281
+
path?: string;
282
+
cursor?: string;
283
+
}):
284
+
| UseQueryResult<
285
+
| linksRecordsResponse
286
+
| linksDidsResponse
287
+
| linksCountResponse
288
+
| linksAllResponse
289
+
| undefined,
290
+
Error
291
+
>
292
+
| undefined {
293
+
//if (!query) return;
294
+
return useQuery(
295
+
constructConstellationQuery(query)
296
+
);
297
+
}
298
+
299
+
type linksRecord = {
300
+
did: string;
301
+
collection: string;
302
+
rkey: string;
303
+
};
304
+
type linksRecordsResponse = {
305
+
total: string;
306
+
linking_records: linksRecord[];
307
+
cursor?: string;
308
+
};
309
+
type linksDidsResponse = {
310
+
total: string;
311
+
linking_dids: string[];
312
+
cursor?: string;
313
+
};
314
+
type linksCountResponse = {
315
+
total: string;
316
+
};
317
+
type linksAllResponse = {
318
+
links: Record<
319
+
string,
320
+
Record<
321
+
string,
322
+
{
323
+
records: number;
324
+
distinct_dids: number;
325
+
}
326
+
>
327
+
>;
328
+
};
329
+
330
+
export function constructFeedSkeletonQuery(options?: {
331
+
feedUri: string;
332
+
agent?: ATPAPI.AtpAgent;
333
+
isAuthed: boolean;
334
+
pdsUrl?: string;
335
+
feedServiceDid?: string;
336
+
}) {
337
+
return queryOptions({
338
+
// The query key includes all dependencies to ensure it refetches when they change
339
+
queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
340
+
queryFn: async () => {
341
+
if (!options) return undefined as undefined
342
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
343
+
if (isAuthed) {
344
+
// Authenticated flow
345
+
if (!agent || !pdsUrl || !feedServiceDid) {
346
+
throw new Error("Missing required info for authenticated feed fetch.");
347
+
}
348
+
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
349
+
const res = await agent.fetchHandler(url, {
350
+
method: "GET",
351
+
headers: {
352
+
"atproto-proxy": `${feedServiceDid}#bsky_fg`,
353
+
"Content-Type": "application/json",
354
+
},
355
+
});
356
+
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
357
+
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
358
+
} else {
359
+
// Unauthenticated flow (using a public PDS/AppView)
360
+
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
361
+
const res = await fetch(url);
362
+
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
363
+
return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
364
+
}
365
+
},
366
+
//enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true),
367
+
});
368
+
}
369
+
370
+
export function useQueryFeedSkeleton(options?: {
371
+
feedUri: string;
372
+
agent?: ATPAPI.AtpAgent;
373
+
isAuthed: boolean;
374
+
pdsUrl?: string;
375
+
feedServiceDid?: string;
376
+
}) {
377
+
return useQuery(constructFeedSkeletonQuery(options));
378
+
}
379
+
380
+
export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined) {
381
+
return queryOptions({
382
+
queryKey: ['preferences', agent?.did],
383
+
queryFn: async () => {
384
+
if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
385
+
const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
386
+
const res = await agent.fetchHandler(url, { method: "GET" });
387
+
if (!res.ok) throw new Error("Failed to fetch preferences");
388
+
return res.json();
389
+
},
390
+
});
391
+
}
392
+
export function useQueryPreferences(options: {
393
+
agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined
394
+
}) {
395
+
return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
396
+
}
397
+
398
+
399
+
400
+
export function constructArbitraryQuery(uri?: string) {
401
+
return queryOptions({
402
+
queryKey: ["post", uri],
403
+
queryFn: async () => {
404
+
if (!uri) return undefined as undefined
405
+
const res = await fetch(
406
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
407
+
);
408
+
if (!res.ok) throw new Error("Failed to fetch post");
409
+
try {
410
+
return (await res.json()) as {
411
+
uri: string;
412
+
cid: string;
413
+
value: any;
414
+
};
415
+
} catch (_e) {
416
+
return undefined;
417
+
}
418
+
},
419
+
});
420
+
}
421
+
export function useQueryArbitrary(uri: string): UseQueryResult<
422
+
{
423
+
uri: string;
424
+
cid: string;
425
+
value: any;
426
+
},
427
+
Error
428
+
>;
429
+
export function useQueryArbitrary(): UseQueryResult<
430
+
undefined,
431
+
Error
432
+
>;
433
+
export function useQueryArbitrary(uri?: string): UseQueryResult<
434
+
{
435
+
uri: string;
436
+
cid: string;
437
+
value: any;
438
+
} | undefined,
439
+
Error
440
+
>;
441
+
export function useQueryArbitrary(uri?: string) {
442
+
return useQuery(constructArbitraryQuery(uri));
443
+
}
444
+
445
+
export function constructFallbackNothingQuery(){
446
+
return queryOptions({
447
+
queryKey: ["nothing"],
448
+
queryFn: async () => {
449
+
return undefined
450
+
},
451
+
});
452
+
}
453
+
454
+
type ListRecordsResponse = {
455
+
cursor?: string;
456
+
records: {
457
+
uri: string;
458
+
cid: string;
459
+
value: ATPAPI.AppBskyFeedPost.Record;
460
+
}[];
461
+
};
462
+
463
+
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
464
+
return queryOptions({
465
+
queryKey: ['authorFeed', did],
466
+
queryFn: async ({ pageParam }: QueryFunctionContext) => {
467
+
const limit = 25;
468
+
469
+
const cursor = pageParam as string | undefined;
470
+
const cursorParam = cursor ? `&cursor=${cursor}` : '';
471
+
472
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
473
+
474
+
const res = await fetch(url);
475
+
if (!res.ok) throw new Error("Failed to fetch author's posts");
476
+
477
+
return res.json() as Promise<ListRecordsResponse>;
478
+
},
479
+
});
480
+
}
481
+
482
+
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
483
+
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
484
+
485
+
return useInfiniteQuery({
486
+
queryKey,
487
+
queryFn,
488
+
initialPageParam: undefined as never, // ???? what is this shit
489
+
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
490
+
enabled: !!did && !!pdsUrl,
491
+
});
492
+
}
493
+
494
+
type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
495
+
496
+
export function constructInfiniteFeedSkeletonQuery(options: {
497
+
feedUri: string;
498
+
agent?: ATPAPI.AtpAgent;
499
+
isAuthed: boolean;
500
+
pdsUrl?: string;
501
+
feedServiceDid?: string;
502
+
}) {
503
+
const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
504
+
505
+
return queryOptions({
506
+
queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
507
+
508
+
queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
509
+
const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
510
+
511
+
if (isAuthed) {
512
+
if (!agent || !pdsUrl || !feedServiceDid) {
513
+
throw new Error("Missing required info for authenticated feed fetch.");
514
+
}
515
+
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
516
+
const res = await agent.fetchHandler(url, {
517
+
method: "GET",
518
+
headers: {
519
+
"atproto-proxy": `${feedServiceDid}#bsky_fg`,
520
+
"Content-Type": "application/json",
521
+
},
522
+
});
523
+
if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
524
+
return (await res.json()) as FeedSkeletonPage;
525
+
} else {
526
+
const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
527
+
const res = await fetch(url);
528
+
if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
529
+
return (await res.json()) as FeedSkeletonPage;
530
+
}
531
+
},
532
+
});
533
+
}
534
+
535
+
export function useInfiniteQueryFeedSkeleton(options: {
536
+
feedUri: string;
537
+
agent?: ATPAPI.AtpAgent;
538
+
isAuthed: boolean;
539
+
pdsUrl?: string;
540
+
feedServiceDid?: string;
541
+
}) {
542
+
const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
543
+
544
+
return useInfiniteQuery({
545
+
queryKey,
546
+
queryFn,
547
+
initialPageParam: undefined as never,
548
+
getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
549
+
enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
550
+
});
551
+
}