an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

migration to tanstack react query

rimar1337 843607cb 268124d6

+2215 -1353
+17 -13
README.md
··· 1 - # Initial Red Dwarf Open Source Release 2 - i made red dwarf in three days 3 4 - it isnt really that well made 5 - (go take a look at `UniversalPostRenderer.tsx`) 6 7 - further development is pending 8 - (especially around future plans for user-resolved constellation instances) 9 10 - huge thanks to Constellation ([Microcosm](https://microcosm.blue/)) for making this possible 11 12 ## UniversalPostRenderer 13 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 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 ## PassAuthProvider 19 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 26 ## Custom Feeds 27 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 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 36 ## Tanstack Router 37 - it does the job, nothing very specific was used here
··· 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 4 + ![screenshot of red dwarf](/public/screenshot.png) 5 + 6 + huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 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! 10 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) 12 13 ## UniversalPostRenderer 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. 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). 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 24 25 ## PassAuthProvider 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 27 28 ## Custom Feeds 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 ··· 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 37 38 ## Tanstack Router 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 "@atproto/api": "^0.16.6", 10 "@tailwindcss/vite": "^4.0.6", 11 "@tanstack/react-devtools": "^0.2.2", 12 "@tanstack/react-router": "^1.130.2", 13 "@tanstack/react-router-devtools": "^1.131.5", 14 "@tanstack/router-plugin": "^1.121.2", 15 "idb-keyval": "^6.2.2", 16 "react": "^19.0.0", 17 "react-dom": "^19.0.0", 18 "react-player": "^3.3.2", ··· 1882 "url": "https://github.com/sponsors/tannerlinsley" 1883 } 1884 }, 1885 "node_modules/@tanstack/react-devtools": { 1886 "version": "0.2.2", 1887 "resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz", ··· 1902 "@types/react-dom": ">=16.8", 1903 "react": ">=16.8", 1904 "react-dom": ">=16.8" 1905 } 1906 }, 1907 "node_modules/@tanstack/react-router": { ··· 3353 "license": "MIT", 3354 "bin": { 3355 "jiti": "lib/jiti-cli.mjs" 3356 } 3357 }, 3358 "node_modules/js-tokens": {
··· 9 "@atproto/api": "^0.16.6", 10 "@tailwindcss/vite": "^4.0.6", 11 "@tanstack/react-devtools": "^0.2.2", 12 + "@tanstack/react-query": "^5.85.6", 13 "@tanstack/react-router": "^1.130.2", 14 "@tanstack/react-router-devtools": "^1.131.5", 15 "@tanstack/router-plugin": "^1.121.2", 16 "idb-keyval": "^6.2.2", 17 + "jotai": "^2.13.1", 18 "react": "^19.0.0", 19 "react-dom": "^19.0.0", 20 "react-player": "^3.3.2", ··· 1884 "url": "https://github.com/sponsors/tannerlinsley" 1885 } 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 + }, 1897 "node_modules/@tanstack/react-devtools": { 1898 "version": "0.2.2", 1899 "resolved": "https://registry.npmjs.org/@tanstack/react-devtools/-/react-devtools-0.2.2.tgz", ··· 1914 "@types/react-dom": ">=16.8", 1915 "react": ">=16.8", 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" 1933 } 1934 }, 1935 "node_modules/@tanstack/react-router": { ··· 3381 "license": "MIT", 3382 "bin": { 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 + } 3413 } 3414 }, 3415 "node_modules/js-tokens": {
+2
package.json
··· 13 "@atproto/api": "^0.16.6", 14 "@tailwindcss/vite": "^4.0.6", 15 "@tanstack/react-devtools": "^0.2.2", 16 "@tanstack/react-router": "^1.130.2", 17 "@tanstack/react-router-devtools": "^1.131.5", 18 "@tanstack/router-plugin": "^1.121.2", 19 "idb-keyval": "^6.2.2", 20 "react": "^19.0.0", 21 "react-dom": "^19.0.0", 22 "react-player": "^3.3.2",
··· 13 "@atproto/api": "^0.16.6", 14 "@tailwindcss/vite": "^4.0.6", 15 "@tanstack/react-devtools": "^0.2.2", 16 + "@tanstack/react-query": "^5.85.6", 17 "@tanstack/react-router": "^1.130.2", 18 "@tanstack/react-router-devtools": "^1.131.5", 19 "@tanstack/router-plugin": "^1.121.2", 20 "idb-keyval": "^6.2.2", 21 + "jotai": "^2.13.1", 22 "react": "^19.0.0", 23 "react-dom": "^19.0.0", 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 import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 3 import { useNavigate } from "@tanstack/react-router"; 4 import { type SVGProps } from "react"; 5 6 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 7 return obj as $Typed<T>; ··· 16 detailed?: boolean; 17 bottomReplyLine?: boolean; 18 topReplyLine?: boolean; 19 - bottomBorder?:boolean; 20 - feedviewpost?:boolean; 21 } 22 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"); 59 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 - } 75 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 - } 115 116 export function UniversalPostRendererATURILoader({ 117 atUri, ··· 119 detailed = false, 120 bottomReplyLine, 121 topReplyLine, 122 - bottomBorder= true, 123 feedviewpost = false, 124 }: UniversalPostRendererATURILoaderProps) { 125 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); 129 //const [error, setError] = React.useState<string | null>(null); 130 //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); 133 // const [opProfileCacheTime, setOpProfileCacheTime] = React.useState< 134 // number | null 135 // >(null); ··· 141 console.log("did", did); 142 console.log("rkey", rkey); 143 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]); 172 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]); 188 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]); 209 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]); 250 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]); 315 316 // const displayName = 317 // opProfile?.value?.displayName || resolved?.handle || resolved?.did; ··· 332 setLikes( 333 links 334 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 335 - : null, 336 ); 337 setReposts( 338 links 339 ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0 340 - : null, 341 ); 342 setReplies( 343 links 344 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 345 ?.records || 0 346 - : null, 347 ); 348 }, [links]); 349 ··· 360 return ( 361 <UniversalPostRendererRawRecordShim 362 detailed={detailed} 363 - postRecord={record} 364 profileRecord={opProfile} 365 aturi={atUri} 366 resolved={resolved} ··· 386 detailed = false, 387 bottomReplyLine = false, 388 topReplyLine = false, 389 - bottomBorder= true, 390 - feedviewpost= false, 391 }: { 392 postRecord: any; 393 profileRecord: any; ··· 402 bottomBorder?: boolean; 403 feedviewpost?: boolean; 404 }) { 405 const navigate = useNavigate(); 406 407 - const { get, set } = usePersistentStore(); 408 function getAvatarUrl(opProfile: any) { 409 const link = opProfile?.value?.avatar?.ref?.["$link"]; 410 if (!link) return null; 411 return `https://cdn.bsky.app/img/avatar/plain/${resolved?.did}/${link}@jpeg`; 412 } 413 414 - const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined); 415 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 - } 424 425 - try { 426 - let result: any; 427 428 - if (embed?.$type === "app.bsky.embed.recordWithMedia") { 429 - const mediaEmbed = embed.media; 430 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"); 442 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; 462 463 - result = hydrated instanceof Promise ? await hydrated : hydrated; 464 - } 465 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 - }; 475 476 - run(); 477 - }, [postRecord, resolved?.did]); 478 479 const parsedaturi = parseAtUri(aturi); 480 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) || "", 490 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 - ]); 514 515 - const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined); 516 517 - useEffect(() => { 518 - if(!feedviewpost) return; 519 - let cancelled = false; 520 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 - }); 531 532 - if (!cancelled) { 533 - setFeedviewpostreplyhandle(opi?.handle); 534 - } 535 - } 536 - }; 537 538 - run(); 539 540 - return () => { 541 - cancelled = true; 542 - }; 543 - }, [fakepost, get, set]); 544 545 return ( 546 <> 547 {/* <p> ··· 580 ); 581 } 582 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 export function parseAtUri( 845 - atUri: string, 846 ): { did: string; collection: string; rkey: string } | null { 847 const PREFIX = "at://"; 848 if (!atUri.startsWith(PREFIX)) { ··· 1128 //import Masonry from "@mui/lab/Masonry"; 1129 import { 1130 AppBskyActorDefs, 1131 AppBskyEmbedDefs, 1132 AppBskyEmbedExternal, 1133 AppBskyEmbedImages, ··· 1256 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 1257 return Array.from( 1258 { length }, 1259 - () => chars[Math.floor(Math.random() * chars.length)], 1260 ).join(""); 1261 } 1262 ··· 1276 salt, 1277 bottomBorder = true, 1278 feedviewpostreplyhandle, 1279 }: { 1280 post: PostView; 1281 // optional for now because i havent ported every use to this yet ··· 1293 salt: string; 1294 bottomBorder?: boolean; 1295 feedviewpostreplyhandle?: string; 1296 }) { 1297 const navigate = useNavigate(); 1298 const [hasRetweeted, setHasRetweeted] = useState<Boolean>( 1299 - post.viewer?.repost ? true : false, 1300 ); 1301 const [hasLiked, setHasLiked] = useState<Boolean>( 1302 - post.viewer?.like ? true : false, 1303 ); 1304 const { agent } = useAuth(); 1305 const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1306 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1307 - post.viewer?.repost, 1308 ); 1309 1310 const likeOrUnlikePost = async () => { ··· 1387 //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1388 position: "relative", 1389 // dont cursor: "pointer", 1390 - borderBottomWidth: bottomBorder ? isQuote ? 0 : 1 : 0, 1391 }} 1392 className="border-gray-300 dark:border-gray-600" 1393 > ··· 1603 gap: 4, 1604 alignItems: "center", 1605 //marginLeft: 36, 1606 - height: !(expanded || isQuote) && !!feedviewpostreplyhandle ? "1rem" : 0, 1607 - opacity: !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 1608 }} 1609 className="text-gray-500 dark:text-gray-400" 1610 > ··· 1614 <div 1615 style={{ 1616 fontSize: 16, 1617 - marginBottom: (!post.embed && !expanded) ? 0 : 8, 1618 whiteSpace: "pre-wrap", 1619 textAlign: "left", 1620 overflowWrap: "anywhere", ··· 1623 }} 1624 className="text-gray-900 dark:text-gray-100" 1625 > 1626 - {renderTextWithFacets( 1627 - (post.record as { text?: string }).text ?? "", 1628 - (post.record.facets as Facet[]) ?? [], 1629 - )} 1630 {} 1631 </div> 1632 - {post.embed ? ( 1633 <PostEmbeds 1634 embed={post.embed} 1635 //moderation={moderation} ··· 1638 navigate={navigate} 1639 /> 1640 ) : null} 1641 - <div style={{ paddingTop: post.embed ? 4 : 0 }}> 1642 <> 1643 {expanded && ( 1644 <div ··· 1713 "/profile/" + 1714 post.author.handle + 1715 "/post/" + 1716 - post.uri.split("/").pop(), 1717 ); 1718 } catch {} 1719 }} ··· 1899 }); 1900 } 1901 }} 1902 /> 1903 </div> 1904 {/* <QuotePostRenderer ··· 2015 }); 2016 } 2017 }} 2018 /> 2019 </div> 2020 ); ··· 2042 src: img.fullsize, 2043 alt: img.alt, 2044 })); 2045 - 2046 2047 if (images.length > 0) { 2048 // const items = embed.images.map(img => ({ ··· 2074 }} 2075 className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900" 2076 > 2077 - {lightboxIndex !== null && ( 2078 - <Lightbox 2079 - images={lightboxImages} 2080 - index={lightboxIndex} 2081 - onClose={() => setLightboxIndex(null)} 2082 - onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2083 - /> 2084 - )} 2085 <img 2086 src={image.fullsize} 2087 alt={image.alt} ··· 2090 height: "100%", 2091 objectFit: "contain", // letterbox or scale to fit 2092 }} 2093 - onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}} 2094 /> 2095 </div> 2096 </div> ··· 2133 objectFit: "cover", 2134 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 2135 }} 2136 - onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}} 2137 /> 2138 </div> 2139 ))} ··· 2178 objectFit: "cover", 2179 borderRadius: "12px 0 0 12px", 2180 }} 2181 - onClick={(e) => {e.stopPropagation();setLightboxIndex(0)}} 2182 /> 2183 </div> 2184 {/* Right: two stacked 2:1 */} ··· 2208 objectFit: "cover", 2209 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 2210 }} 2211 - onClick={(e) => {e.stopPropagation();setLightboxIndex(i+1)}} 2212 /> 2213 </div> 2214 ))} ··· 2269 ? "0 0 0 12px" 2270 : "0 0 12px 0", 2271 }} 2272 - onClick={(e) => {e.stopPropagation();setLightboxIndex(i)}} 2273 /> 2274 </div> 2275 ))} ··· 2331 } 2332 2333 import { createPortal } from "react-dom"; 2334 type LightboxProps = { 2335 images: { src: string; alt?: string }[]; 2336 index: number; 2337 onClose: () => void; 2338 onNavigate?: (newIndex: number) => void; 2339 }; 2340 - export function Lightbox({ images, index, onClose, onNavigate }: LightboxProps) { 2341 const image = images[index]; 2342 2343 useEffect(() => { 2344 function handleKey(e: KeyboardEvent) { 2345 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); 2348 } 2349 window.addEventListener("keydown", handleKey); 2350 return () => window.removeEventListener("keydown", handleKey); ··· 2353 return createPortal( 2354 <div 2355 className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" 2356 - onClick={(e)=>{e.stopPropagation();onClose()}} 2357 > 2358 <img 2359 src={image.src} ··· 2371 }} 2372 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 > 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> 2375 </button> 2376 <button 2377 onClick={(e) => { ··· 2380 }} 2381 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 > 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> 2384 </button> 2385 </> 2386 )} ··· 2428 function facetByteRangeToCharRange( 2429 byteStart: number, 2430 byteEnd: number, 2431 - byteToCharMap: number[], 2432 ): [number, number] { 2433 return [ 2434 byteToCharMap[byteStart] ?? 0, ··· 2448 const [start, end] = facetByteRangeToCharRange( 2449 f.index.byteStart, 2450 f.index.byteEnd, 2451 - map, 2452 ); 2453 return { start, end, feature: f.features[0] }; 2454 }); 2455 } 2456 - function renderTextWithFacets(text: string, facets: Facet[]) { 2457 const ranges = extractFacetRanges(text, facets).sort( 2458 - (a: any, b: any) => a.start - b.start, 2459 ); 2460 2461 const result: React.ReactNode[] = []; ··· 2487 }} 2488 > 2489 {fragment} 2490 - </a>, 2491 ); 2492 } else if ( 2493 feature.$type === "app.bsky.richtext.facet#mention" && ··· 2498 <span 2499 key={start} 2500 style={{ color: "rgb(29, 122, 242)" }} 2501 onClick={(e) => { 2502 e.stopPropagation(); 2503 }} 2504 > 2505 {fragment} 2506 - </span>, 2507 ); 2508 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 2509 result.push( ··· 2515 }} 2516 > 2517 {fragment} 2518 - </span>, 2519 ); 2520 } else { 2521 result.push(<span key={start}>{fragment}</span>); ··· 2708 { 2709 root: null, 2710 threshold: 0.25, 2711 - }, 2712 ); 2713 2714 if (containerRef.current) {
··· 2 import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 3 import { useNavigate } from "@tanstack/react-router"; 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"; 12 13 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 14 return obj as $Typed<T>; ··· 23 detailed?: boolean; 24 bottomReplyLine?: boolean; 25 topReplyLine?: boolean; 26 + bottomBorder?: boolean; 27 + feedviewpost?: boolean; 28 } 29 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"); 66 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 + // } 82 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 + // } 122 123 export function UniversalPostRendererATURILoader({ 124 atUri, ··· 126 detailed = false, 127 bottomReplyLine, 128 topReplyLine, 129 + bottomBorder = true, 130 feedviewpost = false, 131 }: UniversalPostRendererATURILoaderProps) { 132 console.log("atUri", atUri); 133 + //const { get, set } = usePersistentStore(); 134 + //const [record, setRecord] = React.useState<any>(null); 135 + //const [links, setLinks] = React.useState<any>(null); 136 //const [error, setError] = React.useState<string | null>(null); 137 //const [cacheTime, setCacheTime] = React.useState<number | null>(null); 138 + //const [resolved, setResolved] = React.useState<any>(null); // { did, pdsUrl, bskyPds, handle } 139 + //const [opProfile, setOpProfile] = React.useState<any>(null); 140 // const [opProfileCacheTime, setOpProfileCacheTime] = React.useState< 141 // number | null 142 // >(null); ··· 148 console.log("did", did); 149 console.log("rkey", rkey); 150 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]); 179 180 + const { 181 + data: postQuery, 182 + isLoading: isPostLoading, 183 + isError: isPostError, 184 + } = useQueryPost(atUri); 185 + //const record = postQuery?.value; 186 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]); 202 203 + const { data: resolved } = useQueryIdentity(did || ""); 204 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 + ); 340 341 // const displayName = 342 // opProfile?.value?.displayName || resolved?.handle || resolved?.did; ··· 357 setLikes( 358 links 359 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 360 + : null 361 ); 362 setReposts( 363 links 364 ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0 365 + : null 366 ); 367 setReplies( 368 links 369 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 370 ?.records || 0 371 + : null 372 ); 373 }, [links]); 374 ··· 385 return ( 386 <UniversalPostRendererRawRecordShim 387 detailed={detailed} 388 + postRecord={postQuery} 389 profileRecord={opProfile} 390 aturi={atUri} 391 resolved={resolved} ··· 411 detailed = false, 412 bottomReplyLine = false, 413 topReplyLine = false, 414 + bottomBorder = true, 415 + feedviewpost = false, 416 }: { 417 postRecord: any; 418 profileRecord: any; ··· 427 bottomBorder?: boolean; 428 feedviewpost?: boolean; 429 }) { 430 + console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 431 const navigate = useNavigate(); 432 433 + //const { get, set } = usePersistentStore(); 434 function getAvatarUrl(opProfile: any) { 435 const link = opProfile?.value?.avatar?.ref?.["$link"]; 436 if (!link) return null; 437 return `https://cdn.bsky.app/img/avatar/plain/${resolved?.did}/${link}@jpeg`; 438 } 439 440 + // const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined); 441 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 + // } 450 451 + // try { 452 + // let result: any; 453 454 + // if (embed?.$type === "app.bsky.embed.recordWithMedia") { 455 + // const mediaEmbed = embed.media; 456 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"); 468 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; 488 489 + // result = hydrated instanceof Promise ? await hydrated : hydrated; 490 + // } 491 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]); 504 505 + const { 506 + data: hydratedEmbed, 507 + isLoading: isEmbedLoading, 508 + error: embedError, 509 + } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 510 511 const parsedaturi = parseAtUri(aturi); 512 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 || "", 534 viewer: undefined, 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 + ); 549 550 + //const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined); 551 552 + // useEffect(() => { 553 + // if(!feedviewpost) return; 554 + // let cancelled = false; 555 556 + // const run = async () => { 557 + // const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri; 558 + // const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 559 560 + // if (feedviewpostreplydid) { 561 + // const opi = await cachedResolveIdentity({ 562 + // didOrHandle: feedviewpostreplydid, 563 + // get, 564 + // set, 565 + // }); 566 567 + // if (!cancelled) { 568 + // setFeedviewpostreplyhandle(opi?.handle); 569 + // } 570 + // } 571 + // }; 572 573 + // run(); 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; 586 return ( 587 <> 588 {/* <p> ··· 621 ); 622 } 623 624 export function parseAtUri( 625 + atUri: string 626 ): { did: string; collection: string; rkey: string } | null { 627 const PREFIX = "at://"; 628 if (!atUri.startsWith(PREFIX)) { ··· 908 //import Masonry from "@mui/lab/Masonry"; 909 import { 910 AppBskyActorDefs, 911 + AppBskyActorProfile, 912 AppBskyEmbedDefs, 913 AppBskyEmbedExternal, 914 AppBskyEmbedImages, ··· 1037 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 1038 return Array.from( 1039 { length }, 1040 + () => chars[Math.floor(Math.random() * chars.length)] 1041 ).join(""); 1042 } 1043 ··· 1057 salt, 1058 bottomBorder = true, 1059 feedviewpostreplyhandle, 1060 + depth = 0, 1061 }: { 1062 post: PostView; 1063 // optional for now because i havent ported every use to this yet ··· 1075 salt: string; 1076 bottomBorder?: boolean; 1077 feedviewpostreplyhandle?: string; 1078 + depth?: number; 1079 }) { 1080 const navigate = useNavigate(); 1081 const [hasRetweeted, setHasRetweeted] = useState<Boolean>( 1082 + post.viewer?.repost ? true : false 1083 ); 1084 const [hasLiked, setHasLiked] = useState<Boolean>( 1085 + post.viewer?.like ? true : false 1086 ); 1087 const { agent } = useAuth(); 1088 const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1089 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1090 + post.viewer?.repost 1091 ); 1092 1093 const likeOrUnlikePost = async () => { ··· 1170 //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1171 position: "relative", 1172 // dont cursor: "pointer", 1173 + borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1174 }} 1175 className="border-gray-300 dark:border-gray-600" 1176 > ··· 1386 gap: 4, 1387 alignItems: "center", 1388 //marginLeft: 36, 1389 + height: 1390 + !(expanded || isQuote) && !!feedviewpostreplyhandle 1391 + ? "1rem" 1392 + : 0, 1393 + opacity: 1394 + !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 1395 }} 1396 className="text-gray-500 dark:text-gray-400" 1397 > ··· 1401 <div 1402 style={{ 1403 fontSize: 16, 1404 + marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1405 whiteSpace: "pre-wrap", 1406 textAlign: "left", 1407 overflowWrap: "anywhere", ··· 1410 }} 1411 className="text-gray-900 dark:text-gray-100" 1412 > 1413 + {renderTextWithFacets({ 1414 + text: (post.record as { text?: string }).text ?? "", 1415 + facets: (post.record.facets as Facet[]) ?? [], 1416 + navigate: navigate 1417 + })} 1418 {} 1419 </div> 1420 + {post.embed && depth < 1 ? ( 1421 <PostEmbeds 1422 embed={post.embed} 1423 //moderation={moderation} ··· 1426 navigate={navigate} 1427 /> 1428 ) : null} 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 }}> 1437 <> 1438 {expanded && ( 1439 <div ··· 1508 "/profile/" + 1509 post.author.handle + 1510 "/post/" + 1511 + post.uri.split("/").pop() 1512 ); 1513 } catch {} 1514 }} ··· 1694 }); 1695 } 1696 }} 1697 + depth={1} 1698 /> 1699 </div> 1700 {/* <QuotePostRenderer ··· 1811 }); 1812 } 1813 }} 1814 + depth={1} 1815 /> 1816 </div> 1817 ); ··· 1839 src: img.fullsize, 1840 alt: img.alt, 1841 })); 1842 1843 if (images.length > 0) { 1844 // const items = embed.images.map(img => ({ ··· 1870 }} 1871 className="border border-gray-200 dark:border-gray-700 bg-gray-200 dark:bg-gray-900" 1872 > 1873 + {lightboxIndex !== null && ( 1874 + <Lightbox 1875 + images={lightboxImages} 1876 + index={lightboxIndex} 1877 + onClose={() => setLightboxIndex(null)} 1878 + onNavigate={(newIndex) => setLightboxIndex(newIndex)} 1879 + /> 1880 + )} 1881 <img 1882 src={image.fullsize} 1883 alt={image.alt} ··· 1886 height: "100%", 1887 objectFit: "contain", // letterbox or scale to fit 1888 }} 1889 + onClick={(e) => { 1890 + e.stopPropagation(); 1891 + setLightboxIndex(0); 1892 + }} 1893 /> 1894 </div> 1895 </div> ··· 1932 objectFit: "cover", 1933 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 1934 }} 1935 + onClick={(e) => { 1936 + e.stopPropagation(); 1937 + setLightboxIndex(i); 1938 + }} 1939 /> 1940 </div> 1941 ))} ··· 1980 objectFit: "cover", 1981 borderRadius: "12px 0 0 12px", 1982 }} 1983 + onClick={(e) => { 1984 + e.stopPropagation(); 1985 + setLightboxIndex(0); 1986 + }} 1987 /> 1988 </div> 1989 {/* Right: two stacked 2:1 */} ··· 2013 objectFit: "cover", 2014 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 2015 }} 2016 + onClick={(e) => { 2017 + e.stopPropagation(); 2018 + setLightboxIndex(i + 1); 2019 + }} 2020 /> 2021 </div> 2022 ))} ··· 2077 ? "0 0 0 12px" 2078 : "0 0 12px 0", 2079 }} 2080 + onClick={(e) => { 2081 + e.stopPropagation(); 2082 + setLightboxIndex(i); 2083 + }} 2084 /> 2085 </div> 2086 ))} ··· 2142 } 2143 2144 import { createPortal } from "react-dom"; 2145 + import type { Record } from "@atproto/api/dist/client/types/app/bsky/actor/profile"; 2146 type LightboxProps = { 2147 images: { src: string; alt?: string }[]; 2148 index: number; 2149 onClose: () => void; 2150 onNavigate?: (newIndex: number) => void; 2151 }; 2152 + export function Lightbox({ 2153 + images, 2154 + index, 2155 + onClose, 2156 + onNavigate, 2157 + }: LightboxProps) { 2158 const image = images[index]; 2159 2160 useEffect(() => { 2161 function handleKey(e: KeyboardEvent) { 2162 if (e.key === "Escape") onClose(); 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); 2167 } 2168 window.addEventListener("keydown", handleKey); 2169 return () => window.removeEventListener("keydown", handleKey); ··· 2172 return createPortal( 2173 <div 2174 className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" 2175 + onClick={(e) => { 2176 + e.stopPropagation(); 2177 + onClose(); 2178 + }} 2179 > 2180 <img 2181 src={image.src} ··· 2193 }} 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" 2195 > 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> 2210 </button> 2211 <button 2212 onClick={(e) => { ··· 2215 }} 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" 2217 > 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> 2232 </button> 2233 </> 2234 )} ··· 2276 function facetByteRangeToCharRange( 2277 byteStart: number, 2278 byteEnd: number, 2279 + byteToCharMap: number[] 2280 ): [number, number] { 2281 return [ 2282 byteToCharMap[byteStart] ?? 0, ··· 2296 const [start, end] = facetByteRangeToCharRange( 2297 f.index.byteStart, 2298 f.index.byteEnd, 2299 + map 2300 ); 2301 return { start, end, feature: f.features[0] }; 2302 }); 2303 } 2304 + function renderTextWithFacets({ 2305 + text, 2306 + facets, 2307 + navigate, 2308 + }: { 2309 + text: string; 2310 + facets: Facet[]; 2311 + navigate: ({}: any) => void; 2312 + }) { 2313 const ranges = extractFacetRanges(text, facets).sort( 2314 + (a: any, b: any) => a.start - b.start 2315 ); 2316 2317 const result: React.ReactNode[] = []; ··· 2343 }} 2344 > 2345 {fragment} 2346 + </a> 2347 ); 2348 } else if ( 2349 feature.$type === "app.bsky.richtext.facet#mention" && ··· 2354 <span 2355 key={start} 2356 style={{ color: "rgb(29, 122, 242)" }} 2357 + className=" cursor-pointer" 2358 onClick={(e) => { 2359 e.stopPropagation(); 2360 + navigate({ 2361 + to: "/profile/$did", 2362 + // @ts-ignore 2363 + params: { did: feature.did}, 2364 + }); 2365 }} 2366 > 2367 {fragment} 2368 + </span> 2369 ); 2370 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 2371 result.push( ··· 2377 }} 2378 > 2379 {fragment} 2380 + </span> 2381 ); 2382 } else { 2383 result.push(<span key={start}>{fragment}</span>); ··· 2570 { 2571 root: null, 2572 threshold: 0.25, 2573 + } 2574 ); 2575 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 8 import "~/styles/app.css"; 9 import reportWebVitals from "./reportWebVitals.ts"; 10 11 // Create a new router instance 12 const router = createRouter({ 13 routeTree, 14 - context: {}, 15 defaultPreload: "intent", 16 scrollRestoration: true, 17 defaultStructuralSharing: true, ··· 32 root.render( 33 // double queries annoys me 34 <StrictMode> 35 - <RouterProvider router={router} /> 36 </StrictMode> 37 ); 38 }
··· 7 8 import "~/styles/app.css"; 9 import reportWebVitals from "./reportWebVitals.ts"; 10 + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 11 12 + const queryClient = new QueryClient(); 13 // Create a new router instance 14 const router = createRouter({ 15 routeTree, 16 + context: { queryClient }, 17 defaultPreload: "intent", 18 scrollRestoration: true, 19 defaultStructuralSharing: true, ··· 34 root.render( 35 // double queries annoys me 36 <StrictMode> 37 + <QueryClientProvider client={queryClient}> 38 + <RouterProvider router={router} /> 39 + </QueryClientProvider> 40 </StrictMode> 41 ); 42 }
+219 -161
src/routes/index.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { 3 CACHE_TIMEOUT, 4 - cachedGetRecord, 5 - cachedResolveIdentity, 6 UniversalPostRendererATURILoader, 7 } from "~/components/UniversalPostRenderer"; 8 import * as React from "react"; 9 import { useAuth } from "~/providers/PassAuthProvider"; 10 - import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 11 12 export const Route = createFileRoute("/")({ 13 component: Home, ··· 22 loading: loadering, 23 authed, 24 } = 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); 29 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 - }); 42 43 - console.log("alistoffeeds", prefs); 44 - setPrefs(prefs || {}); 45 - } catch (err) { 46 - console.error("alistoffeeds Fetch error in preferences effect:", err); 47 - } 48 - }; 49 50 - run(); 51 - } 52 - }, [loadering, authed, agent]); 53 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", 58 ); 59 }, [prefs]); 60 61 - const savedFeeds = savedFeedsPref?.items || []; 62 63 const [selectedFeed, setSelectedFeed] = React.useState<string | null>(null); 64 65 React.useEffect(() => { 66 const fallbackFeed = 67 - "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/wh-hot"; 68 if (authed) { 69 if (savedFeeds.length > 0) { 70 setSelectedFeed((prev) => ··· 80 } 81 }, [savedFeeds, authed]); 82 83 - React.useEffect(() => { 84 - if (loadering || !selectedFeed) return; 85 86 - let ignore = false; 87 88 - const run = async () => { 89 - setLoading(true); 90 - setError(null); 91 92 - try { 93 - if (authed && agent) { 94 - if (!agent.did) return; 95 96 - const pdsurl = await cachedResolveIdentity({ 97 - didOrHandle: agent.did, 98 - get, 99 - set, 100 - }); 101 102 - const fetchstringcomplex = `${pdsurl.pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${selectedFeed}`; 103 - console.log("fetching feed authed: " + fetchstringcomplex); 104 105 - const feeddef = await cachedGetRecord({ 106 - atUri: selectedFeed, 107 - get, 108 - set, 109 - }); 110 111 - const feedservicedid = feeddef.value.did; 112 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 - }); 120 121 - if (!res.ok) throw new Error("Failed to fetch feed"); 122 - const data = await res.json(); 123 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 - // }); 135 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); 139 140 - const res = await fetch(fetchstringsimple); 141 - if (!res.ok) throw new Error("Failed to fetch feed"); 142 - const data = await res.json(); 143 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 - }; 158 159 - run(); 160 161 - return () => { 162 - ignore = true; 163 - }; 164 - }, [authed, agent, loadering, selectedFeed, get, set]); 165 166 return ( 167 <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> ··· 175 key={item.value || idx} 176 className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 177 isActive 178 - ? "bg-gray-600 text-white" 179 : item.pinned 180 ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 181 : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" ··· 196 <span className="text-xl font-bold ml-2">Home</span> 197 )} 198 </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 && ( 202 <div className="p-4 text-gray-500">No posts found.</div> 203 - )} 204 - {feed.map((item, i) => ( 205 <UniversalPostRendererATURILoader 206 key={item.post || i} 207 atUri={item.post} 208 /> 209 - ))} 210 </div> 211 ); 212 } ··· 249 return data; 250 } 251 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(); 268 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 - } 281 282 - const resolved = await cachedResolveIdentity({ 283 - didOrHandle: did, 284 - get, 285 - set, 286 - }); 287 288 - if (!resolved?.pdsUrl) throw new Error("Missing resolved PDS info"); 289 290 - const fetchUrl = `${resolved.pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 291 292 - const res = await agent.fetchHandler(fetchUrl, { 293 - method: "GET", 294 - headers: { 295 - "Content-Type": "application/json", 296 - }, 297 - }); 298 299 - if (!res.ok) throw new Error(`Failed to fetch preferences: ${res.status}`); 300 301 - const text = await res.text(); 302 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 - } 310 311 - set(cacheKey, JSON.stringify(data)); 312 - return data; 313 - }
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { 3 CACHE_TIMEOUT, 4 + //cachedGetRecord, 5 + //cachedResolveIdentity, 6 UniversalPostRendererATURILoader, 7 } from "~/components/UniversalPostRenderer"; 8 import * as React from "react"; 9 import { useAuth } from "~/providers/PassAuthProvider"; 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"; 19 20 export const Route = createFileRoute("/")({ 21 component: Home, ··· 30 loading: loadering, 31 authed, 32 } = useAuth(); 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); 37 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]); 61 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]); 68 69 + // const savedFeeds = savedFeedsPref?.items || []; 70 + 71 + const identityresultmaybe = useQueryIdentity(agent?.did); 72 + const identity = identityresultmaybe?.data 73 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" 80 ); 81 + return savedFeedsPref?.items || []; 82 }, [prefs]); 83 84 + 85 86 const [selectedFeed, setSelectedFeed] = React.useState<string | null>(null); 87 88 React.useEffect(() => { 89 const fallbackFeed = 90 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 91 if (authed) { 92 if (savedFeeds.length > 0) { 93 setSelectedFeed((prev) => ··· 103 } 104 }, [savedFeeds, authed]); 105 106 + // React.useEffect(() => { 107 + // if (loadering || !selectedFeed) return; 108 109 + // let ignore = false; 110 111 + // const run = async () => { 112 + // setLoading(true); 113 + // setError(null); 114 115 + // try { 116 + // if (authed && agent) { 117 + // if (!agent.did) return; 118 119 + // const pdsurl = await cachedResolveIdentity({ 120 + // didOrHandle: agent.did, 121 + // get, 122 + // set, 123 + // }); 124 125 + // const fetchstringcomplex = `${pdsurl.pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${selectedFeed}`; 126 + // console.log("fetching feed authed: " + fetchstringcomplex); 127 128 + // const feeddef = await cachedGetRecord({ 129 + // atUri: selectedFeed, 130 + // get, 131 + // set, 132 + // }); 133 134 + // const feedservicedid = feeddef.value.did; 135 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 + // }); 143 144 + // if (!res.ok) throw new Error("Failed to fetch feed"); 145 + // const data = await res.json(); 146 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 + // // }); 158 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); 162 163 + // const res = await fetch(fetchstringsimple); 164 + // if (!res.ok) throw new Error("Failed to fetch feed"); 165 + // const data = await res.json(); 166 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 + // }); 204 205 + // const feed = feedData?.feed || []; 206 207 + const isReadyForAuthedFeed = authed && agent && identity?.pds && feedServiceDid; 208 + const isReadyForUnauthedFeed = !authed && selectedFeed; 209 210 return ( 211 <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> ··· 219 key={item.value || idx} 220 className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 221 isActive 222 + ? "bg-gray-500 text-white" 223 : item.pinned 224 ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 225 : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" ··· 240 <span className="text-xl font-bold ml-2">Home</span> 241 )} 242 </div> 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 && ( 246 <div className="p-4 text-gray-500">No posts found.</div> 247 + )} */} 248 + {/* {feed.map((item, i) => ( 249 <UniversalPostRendererATURILoader 250 key={item.post || i} 251 atUri={item.post} 252 /> 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 + )} 268 </div> 269 ); 270 } ··· 307 return data; 308 } 309 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(); 326 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 + // } 339 340 + // const resolved = await cachedResolveIdentity({ 341 + // didOrHandle: did, 342 + // get, 343 + // set, 344 + // }); 345 346 + // if (!resolved?.pdsUrl) throw new Error("Missing resolved PDS info"); 347 348 + // const fetchUrl = `${resolved.pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 349 350 + // const res = await agent.fetchHandler(fetchUrl, { 351 + // method: "GET", 352 + // headers: { 353 + // "Content-Type": "application/json", 354 + // }, 355 + // }); 356 357 + // if (!res.ok) throw new Error(`Failed to fetch preferences: ${res.status}`); 358 359 + // const text = await res.text(); 360 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 + // } 368 369 + // set(cacheKey, JSON.stringify(data)); 370 + // return data; 371 + // }
+129 -359
src/routes/profile.$did/index.tsx
··· 1 import { createFileRoute, Link } from "@tanstack/react-router"; 2 import React from "react"; 3 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 4 - import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 5 6 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 7 - const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes 8 9 export const Route = createFileRoute("/profile/$did/")({ 10 component: ProfileComponent, ··· 12 13 function ProfileComponent() { 14 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); 28 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]); 120 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"); 163 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]); 181 182 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; 203 } 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 - } 234 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]); 254 255 - function getAvatarUrl(profile: any) { 256 - const link = profile?.value?.avatar?.ref?.["$link"]; 257 if (!link || !resolvedDid) return null; 258 return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 259 } 260 - function getBannerUrl(profile: any) { 261 - const link = profile?.value?.banner?.ref?.["$link"]; 262 if (!link || !resolvedDid) return null; 263 return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 264 } 265 266 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; 276 } 277 - const description = profile?.value?.description || ""; 278 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>; 283 284 return ( 285 <> 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"> 287 <Link 288 to=".." 289 className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg" ··· 301 </div> 302 303 {/* 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 - > 321 {/* Banner */} 322 <div 323 style={{ 324 - width: "100%", 325 - height: 160, 326 - background: `#222 url(${getBannerUrl(profile)}) center/cover no-repeat`, 327 - position: "relative", 328 }} 329 /> 330 {/* 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 - > 344 <img 345 src={getAvatarUrl(profile) || "/favicon.png"} 346 alt="avatar" 347 - style={{ 348 - width: 112, 349 - height: 112, 350 - borderRadius: "50%", 351 - objectFit: "cover", 352 - display: "block", 353 - }} 354 /> 355 </div> 356 {/* 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 }}> 368 {handle} 369 </div> 370 {description && ( 371 - <div 372 - style={{ 373 - fontSize: 16, 374 - lineHeight: 1.5, 375 - color: "#ddd", 376 - marginBottom: 20, 377 - }} 378 - > 379 {description} 380 </div> 381 )} 382 - {!profile && !error && ( 383 - <div style={{ color: "#888", padding: 16 }}>Loading profile...</div> 384 - )} 385 </div> 386 </div> 387 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 - > 398 Posts 399 </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 - })} 411 </div> 412 - {postsLoading && ( 413 - <div style={{ color: "#888", padding: 16, textAlign: "center" }}> 414 - Loading posts... 415 - </div> 416 )} 417 - {hasMore && !postsLoading && ( 418 <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 - }} 431 > 432 Load More Posts 433 </button> 434 )} 435 - {posts.length === 0 && !postsLoading && !error && ( 436 - <div style={{ color: "#888", padding: 16, textAlign: "center" }}> 437 - No posts found 438 - </div> 439 )} 440 </div> 441 </>
··· 1 import { createFileRoute, Link } from "@tanstack/react-router"; 2 import React from "react"; 3 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 4 + import { useQueryClient } from "@tanstack/react-query"; 5 6 + import { 7 + useQueryIdentity, 8 + useQueryProfile, 9 + useInfiniteQueryAuthorFeed, 10 + } from "~/utils/useQuery"; 11 12 export const Route = createFileRoute("/profile/$did/")({ 13 component: ProfileComponent, ··· 15 16 function ProfileComponent() { 17 const { did } = Route.useParams(); 18 + const queryClient = useQueryClient(); 19 20 + const { 21 + data: identity, 22 + isLoading: isIdentityLoading, 23 + error: identityError, 24 + } = useQueryIdentity(did); 25 26 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 27 + const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 28 + const pdsUrl = identity?.pds; 29 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); 43 44 React.useEffect(() => { 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); 50 } 51 + }); 52 + }); 53 + } 54 + }, [postsData, queryClient]); 55 56 + const posts = React.useMemo( 57 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 58 + [postsData] 59 + ); 60 61 + function getAvatarUrl(p: typeof profile) { 62 + const link = p?.avatar?.ref?.["$link"]; 63 if (!link || !resolvedDid) return null; 64 return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 65 } 66 + function getBannerUrl(p: typeof profile) { 67 + const link = p?.banner?.ref?.["$link"]; 68 if (!link || !resolvedDid) return null; 69 return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 70 } 71 72 const displayName = 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 + ); 81 } 82 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 + } 96 97 return ( 98 <> 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"> 100 <Link 101 to=".." 102 className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg" ··· 114 </div> 115 116 {/* Profile Header */} 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"> 118 {/* Banner */} 119 <div 120 + className="w-full h-40 bg-gray-300 dark:bg-gray-700" 121 style={{ 122 + backgroundImage: `url(${getBannerUrl(profile)})`, 123 + backgroundSize: "cover", 124 + backgroundPosition: "center", 125 }} 126 /> 127 + 128 {/* Avatar (PFP) */} 129 + <div className="absolute left-[16px] top-[100px] "> 130 <img 131 src={getAvatarUrl(profile) || "/favicon.png"} 132 alt="avatar" 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" 134 /> 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 + 163 {/* Info Card */} 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"> 167 {handle} 168 </div> 169 {description && ( 170 + <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 171 {description} 172 </div> 173 )} 174 </div> 175 </div> 176 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"> 180 Posts 181 </div> 182 + <div> 183 + {posts.map((post) => ( 184 + <UniversalPostRendererATURILoader 185 + key={post.uri} 186 + atUri={post.uri} 187 + feedviewpost={true} 188 + /> 189 + ))} 190 </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> 198 )} 199 + {hasNextPage && !isFetchingNextPage && ( 200 <button 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" 203 > 204 Load More Posts 205 </button> 206 )} 207 + {posts.length === 0 && !arePostsLoading && ( 208 + <div className="p-4 text-center text-gray-500">No posts found.</div> 209 )} 210 </div> 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'; 5 6 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 7 8 - export const Route = createFileRoute('/profile/$did/post/$rkey')({ 9 component: RouterWrapper, 10 }); 11 12 function RouterWrapper() { 13 const { did, rkey } = Route.useParams(); 14 15 - return <ProfilePostComponent key={`/profile/${did}/post/${rkey}`} did={did} rkey={rkey} />; 16 } 17 18 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); 23 24 - const [mainPost, setMainPost] = React.useState<any | null>(null); 25 const [parents, setParents] = React.useState<any[]>([]); 26 const [parentsLoading, setParentsLoading] = React.useState(false); 27 - const [replies, setReplies] = React.useState<any[]>([]); 28 29 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); 64 } 65 - } 66 - resolveDidIfNeeded(); 67 - return () => { 68 - ignore = true; 69 }; 70 - }, [did, get, set]); 71 72 - const atUri = resolvedDid && rkey ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` : ''; 73 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); 85 } 86 } 87 - fetchMainPost(); 88 - return () => { 89 - ignore = true; 90 - }; 91 - }, [atUri, get, set]); 92 93 React.useEffect(() => { 94 - if (!mainPost) return; 95 let ignore = false; 96 - async function fetchParents() { 97 setParentsLoading(true); 98 const parentChain: any[] = []; 99 - let currentParentUri = mainPost.value?.reply?.parent?.uri; 100 - const MAX_PARENTS = 25; // Important to know theres a limit 101 let safetyCounter = 0; 102 103 while (currentParentUri && safetyCounter < MAX_PARENTS) { 104 try { 105 - const parentPost = await cachedGetRecord({ atUri: currentParentUri, get, set }); 106 if (!parentPost) break; 107 parentChain.push(parentPost); 108 currentParentUri = parentPost.value?.reply?.parent?.uri; 109 - safetyCounter++; 110 } catch (error) { 111 - console.error('Failed to fetch a parent post:', error); 112 break; 113 } 114 } 115 116 if (!ignore) { 117 setParents(parentChain.reverse()); 118 setParentsLoading(false); 119 } 120 - } 121 122 fetchParents(); 123 return () => { 124 ignore = true; 125 }; 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]); 151 152 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>; 156 157 return ( 158 <> ··· 160 <Link 161 to=".." 162 className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg" 163 - onClick={e => { 164 e.preventDefault(); 165 - window.history.length > 1 ? window.history.back() : window.location.assign('/'); 166 }} 167 aria-label="Go back" 168 > ··· 171 <span className="text-xl font-bold ml-2">Post</span> 172 </div> 173 174 - {parentsLoading && <div className="p-4 text-center text-gray-500 dark:text-gray-400">Loading conversation...</div>} 175 176 {/* we should use the reply lines here thats provided by UPR*/} 177 - <div style={{ maxWidth: 600, margin: '0px auto 0', padding: 0 }}> 178 {parents.map((parent, index) => ( 179 - <UniversalPostRendererATURILoader key={parent.uri} atUri={parent.uri} 180 - topReplyLine={index > 0} 181 bottomReplyLine={true} 182 bottomBorder={false} 183 - /> 184 ))} 185 </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 => { 199 const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 200 - return <UniversalPostRendererATURILoader key={replyAtUri} atUri={replyAtUri} />; 201 })} 202 - </div> 203 </div> 204 - )} 205 </> 206 ); 207 - }
··· 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"; 14 15 + //const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 16 17 + export const Route = createFileRoute("/profile/$did/post/$rkey")({ 18 component: RouterWrapper, 19 }); 20 21 function RouterWrapper() { 22 const { did, rkey } = Route.useParams(); 23 24 + return ( 25 + <> 26 + <ProfilePostComponent 27 + key={`/profile/${did}/post/${rkey}`} 28 + did={did} 29 + rkey={rkey} 30 + /> 31 + {/* <ShrinkingBox /> */} 32 + </> 33 + ); 34 } 35 36 function ProfilePostComponent({ did, rkey }: { did: string; rkey: string }) { 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]); 146 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 + 197 const [parents, setParents] = React.useState<any[]>([]); 198 const [parentsLoading, setParentsLoading] = React.useState(false); 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 + 205 206 React.useEffect(() => { 207 + const onScroll = () => { 208 + 209 + if (window.scrollY > 50) { 210 + userHasScrolled.current = true; 211 + 212 + window.removeEventListener("scroll", onScroll); 213 } 214 }; 215 + 216 + if (!userHasScrolled.current) { 217 + window.addEventListener("scroll", onScroll, { passive: true }); 218 + } 219 + return () => window.removeEventListener("scroll", onScroll); 220 + }, []); 221 222 + useLayoutEffect(() => { 223 + if (parentsLoading && mainPostRef.current && !userHasScrolled.current) { 224 + scrollAnchor.current = { 225 + top: mainPostRef.current.getBoundingClientRect().top, 226 + }; 227 + } 228 + }, [parentsLoading]); 229 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); 240 } 241 + scrollAnchor.current = null; 242 } 243 + }, [parents]); 244 245 React.useEffect(() => { 246 + if (!mainPost?.value?.reply?.parent?.uri) { 247 + setParents([]); 248 + return; 249 + } 250 + 251 let ignore = false; 252 + const fetchParents = async () => { 253 setParentsLoading(true); 254 const parentChain: any[] = []; 255 + let currentParentUri = mainPost?.value.reply?.parent.uri; 256 + const MAX_PARENTS = 25; 257 let safetyCounter = 0; 258 259 while (currentParentUri && safetyCounter < MAX_PARENTS) { 260 try { 261 + const parentPost = await queryClient.fetchQuery( 262 + constructPostQuery(currentParentUri) 263 + ); 264 if (!parentPost) break; 265 parentChain.push(parentPost); 266 currentParentUri = parentPost.value?.reply?.parent?.uri; 267 } catch (error) { 268 + console.error("Failed to fetch a parent post:", error); 269 break; 270 } 271 + safetyCounter++; 272 } 273 274 if (!ignore) { 275 setParents(parentChain.reverse()); 276 setParentsLoading(false); 277 } 278 + }; 279 280 fetchParents(); 281 return () => { 282 ignore = true; 283 }; 284 + }, [mainPost, queryClient]); 285 286 if (!did || !rkey) 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>; 291 292 return ( 293 <> ··· 295 <Link 296 to=".." 297 className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg" 298 + onClick={(e) => { 299 e.preventDefault(); 300 + window.history.length > 1 301 + ? window.history.back() 302 + : window.location.assign("/"); 303 }} 304 aria-label="Go back" 305 > ··· 308 <span className="text-xl font-bold ml-2">Post</span> 309 </div> 310 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 + )} 322 323 {/* we should use the reply lines here thats provided by UPR*/} 324 + <div style={{ maxWidth: 600, margin: "0px auto 0", padding: 0 }}> 325 {parents.map((parent, index) => ( 326 + <UniversalPostRendererATURILoader 327 + key={parent.uri} 328 + atUri={parent.uri} 329 + topReplyLine={index > 0} 330 bottomReplyLine={true} 331 bottomBorder={false} 332 + /> 333 ))} 334 </div> 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) => { 363 const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 364 + return ( 365 + <UniversalPostRendererATURILoader 366 + key={replyAtUri} 367 + atUri={replyAtUri} 368 + /> 369 + ); 370 })} 371 </div> 372 + </div> 373 </> 374 ); 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 + }