an appview-less Bluesky client using Constellation and PDS Queries - https://reddwarf.whey.party/

migration to tanstack react query

rimar1337 843607cb 268124d6

+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 + ![screenshot of red dwarf](/public/screenshot.png) 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
··· 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
··· 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

This is a binary file and will not be displayed.

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + import { atom } from "jotai"; 2 + 3 + export const selectedFeedUriAtom = atom<string | null>(null); 4 + 5 + export const feedScrollPositionsAtom = atom<Record<string, number>>({});
+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
··· 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 + }