an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 7 .env 8 .nitro 9 .tanstack 10 - public/client-metadata.json
··· 7 .env 8 .nitro 9 .tanstack 10 + public/client-metadata.json 11 + public/resolvers.json
+10 -1
README.md
··· 8 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work 15 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 18 ## useQuery 19 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!
··· 8 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 + const PROD_URL = "https://reddwarf.app" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work 15 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 + 18 + 19 + 20 + you probably dont need to change these 21 + ```ts 22 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 23 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 24 + ``` 25 + if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins 26 27 ## useQuery 28 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!
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 6 7 return { 8 - "client_id": `${appOrigin}/client-metadata.json`, 9 - "client_name": "ForumTest", 10 - "client_uri": appOrigin, 11 - "logo_uri": `${appOrigin}/logo192.png`, 12 - "tos_uri": `${appOrigin}/terms-of-service`, 13 - "policy_uri": `${appOrigin}/privacy-policy`, 14 - "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 - "scope": "atproto transition:generic", 16 - "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 - "response_types": ["code"] as ["code"], 18 - "token_endpoint_auth_method": "none" as "none", 19 - "application_type": "web" as "web", 20 - "dpop_bound_access_tokens": true 21 - }; 22 - } 23 - 24 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 26 return { 27 - name: 'vite-plugin-generate-metadata', 28 config(_config: any, { mode }: any) { 29 - let appOrigin; 30 - if (mode === 'production') { 31 - appOrigin = prod 32 - if (!appOrigin || !appOrigin.startsWith('https://')) { 33 - throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 34 } 35 } else { 36 appOrigin = dev; 37 } 38 - 39 - 40 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 42 43 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 45 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 }, 47 }; 48 - }
··· 1 + import fs from "fs"; 2 + import path from "path"; 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 export const generateClientMetadata = (appOrigin: string) => { 5 + const callbackPath = "/callback"; 6 7 return { 8 + client_id: `${appOrigin}/client-metadata.json`, 9 + client_name: "ForumTest", 10 + client_uri: appOrigin, 11 + logo_uri: `${appOrigin}/logo192.png`, 12 + tos_uri: `${appOrigin}/terms-of-service`, 13 + policy_uri: `${appOrigin}/privacy-policy`, 14 + redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + scope: "atproto transition:generic", 16 + grant_types: ["authorization_code", "refresh_token"] as [ 17 + "authorization_code", 18 + "refresh_token", 19 + ], 20 + response_types: ["code"] as ["code"], 21 + token_endpoint_auth_method: "none" as "none", 22 + application_type: "web" as "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + }; 26 27 + export function generateMetadataPlugin({ 28 + prod, 29 + dev, 30 + prodResolver = "https://bsky.social", 31 + devResolver = prodResolver, 32 + }: { 33 + prod: string; 34 + dev: string; 35 + prodResolver?: string; 36 + devResolver?: string; 37 + }) { 38 return { 39 + name: "vite-plugin-generate-metadata", 40 config(_config: any, { mode }: any) { 41 + console.log('๐Ÿ’ก vite mode =', mode) 42 + let appOrigin, resolver; 43 + if (mode === "production") { 44 + appOrigin = prod; 45 + resolver = prodResolver; 46 + if (!appOrigin || !appOrigin.startsWith("https://")) { 47 + throw new Error( 48 + "VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build." 49 + ); 50 } 51 } else { 52 appOrigin = dev; 53 + resolver = devResolver; 54 } 55 + 56 const metadata = generateClientMetadata(appOrigin); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 62 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 64 65 + const resolvers = { 66 + resolver: resolver, 67 + }; 68 + const resolverOutPath = path.resolve( 69 + process.cwd(), 70 + "public", 71 + "resolvers.json" 72 + ); 73 + 74 + fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2)); 75 + 76 + 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 78 }, 79 }; 80 + }
+14
package-lock.json
··· 8 "dependencies": { 9 "@atproto/api": "^0.16.6", 10 "@atproto/oauth-client-browser": "^0.3.33", 11 "@radix-ui/react-dropdown-menu": "^2.1.16", 12 "@tailwindcss/vite": "^4.0.6", 13 "@tanstack/query-sync-storage-persister": "^5.85.6", 14 "@tanstack/react-devtools": "^0.2.2", ··· 26 "react": "^19.0.0", 27 "react-dom": "^19.0.0", 28 "react-player": "^3.3.2", 29 "tailwindcss": "^4.0.6", 30 "tanstack-router-keepalive": "^1.0.0" 31 }, ··· 2400 "version": "1.1.15", 2401 "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", 2402 "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", 2403 "dependencies": { 2404 "@radix-ui/primitive": "1.1.3", 2405 "@radix-ui/react-compose-refs": "1.1.2", ··· 12539 "csstype": "^3.1.0", 12540 "seroval": "~1.3.0", 12541 "seroval-plugins": "~1.3.0" 12542 } 12543 }, 12544 "node_modules/source-map": {
··· 8 "dependencies": { 9 "@atproto/api": "^0.16.6", 10 "@atproto/oauth-client-browser": "^0.3.33", 11 + "@radix-ui/react-dialog": "^1.1.15", 12 "@radix-ui/react-dropdown-menu": "^2.1.16", 13 + "@radix-ui/react-hover-card": "^1.1.15", 14 + "@radix-ui/react-slider": "^1.3.6", 15 "@tailwindcss/vite": "^4.0.6", 16 "@tanstack/query-sync-storage-persister": "^5.85.6", 17 "@tanstack/react-devtools": "^0.2.2", ··· 29 "react": "^19.0.0", 30 "react-dom": "^19.0.0", 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 33 "tailwindcss": "^4.0.6", 34 "tanstack-router-keepalive": "^1.0.0" 35 }, ··· 2404 "version": "1.1.15", 2405 "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", 2406 "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", 2407 + "license": "MIT", 2408 "dependencies": { 2409 "@radix-ui/primitive": "1.1.3", 2410 "@radix-ui/react-compose-refs": "1.1.2", ··· 12544 "csstype": "^3.1.0", 12545 "seroval": "~1.3.0", 12546 "seroval-plugins": "~1.3.0" 12547 + } 12548 + }, 12549 + "node_modules/sonner": { 12550 + "version": "2.0.7", 12551 + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", 12552 + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", 12553 + "peerDependencies": { 12554 + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", 12555 + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" 12556 } 12557 }, 12558 "node_modules/source-map": {
+4
package.json
··· 12 "dependencies": { 13 "@atproto/api": "^0.16.6", 14 "@atproto/oauth-client-browser": "^0.3.33", 15 "@radix-ui/react-dropdown-menu": "^2.1.16", 16 "@tailwindcss/vite": "^4.0.6", 17 "@tanstack/query-sync-storage-persister": "^5.85.6", 18 "@tanstack/react-devtools": "^0.2.2", ··· 30 "react": "^19.0.0", 31 "react-dom": "^19.0.0", 32 "react-player": "^3.3.2", 33 "tailwindcss": "^4.0.6", 34 "tanstack-router-keepalive": "^1.0.0" 35 },
··· 12 "dependencies": { 13 "@atproto/api": "^0.16.6", 14 "@atproto/oauth-client-browser": "^0.3.33", 15 + "@radix-ui/react-dialog": "^1.1.15", 16 "@radix-ui/react-dropdown-menu": "^2.1.16", 17 + "@radix-ui/react-hover-card": "^1.1.15", 18 + "@radix-ui/react-slider": "^1.3.6", 19 "@tailwindcss/vite": "^4.0.6", 20 "@tanstack/query-sync-storage-persister": "^5.85.6", 21 "@tanstack/react-devtools": "^0.2.2", ··· 33 "react": "^19.0.0", 34 "react-dom": "^19.0.0", 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 37 "tailwindcss": "^4.0.6", 38 "tanstack-router-keepalive": "^1.0.0" 39 },
+6
src/auto-imports.d.ts
··· 18 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 22 }
··· 18 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 + const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 + const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 24 + const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 26 + const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 27 + const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 28 }
+153 -107
src/components/Composer.tsx
··· 1 - import { RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 import { useEffect, useRef, useState } from "react"; 4 5 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 9 import { ProfileThing } from "./Login"; 10 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 11 12 - const MAX_POST_LENGTH = 300 13 14 export function Composer() { 15 const [composerState, setComposerState] = useAtom(composerAtom); ··· 31 composerState.kind === "reply" 32 ? composerState.parent 33 : composerState.kind === "quote" 34 - ? composerState.subject 35 - : undefined; 36 37 - const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri); 38 39 async function handlePost() { 40 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 46 const rt = new RichText({ text: postText }); 47 await rt.detectFacets(agent); 48 49 const record: Record<string, unknown> = { 50 $type: "app.bsky.feed.post", 51 text: rt.text, ··· 95 setPosting(false); 96 } 97 } 98 - 99 - if (composerState.kind === "closed") { 100 - return null; 101 - } 102 103 const getPlaceholder = () => { 104 switch (composerState.kind) { ··· 111 return "What's happening?!"; 112 } 113 }; 114 - 115 const charsLeft = MAX_POST_LENGTH - postText.length; 116 const isPostButtonDisabled = 117 - posting || 118 - !postText.trim() || 119 - isParentLoading || 120 - charsLeft < 0; 121 122 return ( 123 - <div className="fixed inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 bg-black/40 dark:bg-black/50"> 124 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 125 - <div className="flex flex-row justify-between p-2"> 126 - <button 127 - className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 128 - onClick={() => !posting && setComposerState({ kind: "closed" })} 129 - disabled={posting} 130 - aria-label="Close" 131 - > 132 - <svg 133 - xmlns="http://www.w3.org/2000/svg" 134 - width="20" 135 - height="20" 136 - viewBox="0 0 24 24" 137 - fill="none" 138 - stroke="currentColor" 139 - strokeWidth="2.5" 140 - strokeLinecap="round" 141 - strokeLinejoin="round" 142 - > 143 - <line x1="18" y1="6" x2="6" y2="18"></line> 144 - <line x1="6" y1="6" x2="18" y2="18"></line> 145 - </svg> 146 - </button> 147 - <div className="flex-1" /> 148 - <div className="flex items-center gap-4"> 149 - <span className={`text-sm ${charsLeft < 0 ? 'text-red-500' : 'text-gray-500'}`}> 150 - {charsLeft} 151 - </span> 152 - 153 - <button 154 - className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 155 - onClick={handlePost} 156 - disabled={isPostButtonDisabled} 157 - > 158 - {posting ? "Posting..." : "Post"} 159 - </button> 160 - </div> 161 - </div> 162 163 - {postSuccess ? ( 164 - <div className="flex flex-col items-center justify-center py-16"> 165 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 166 - <span className="text-xl font-bold text-black dark:text-white">Posted!</span> 167 - </div> 168 - ) : ( 169 - <div className="px-4"> 170 - {(composerState.kind === "reply") && ( 171 - <div className="mb-1 -mx-4"> 172 - {isParentLoading ? ( 173 - <div className="text-sm text-gray-500 animate-pulse"> 174 - Loading parent post... 175 - </div> 176 - ) : parentUri ? ( 177 - <UniversalPostRendererATURILoader atUri={parentUri} bottomReplyLine bottomBorder={false} /> 178 - ) : ( 179 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 180 - Could not load parent post. 181 - </div> 182 - )} 183 - </div> 184 - )} 185 - 186 - <div className="flex w-full gap-1 flex-col"> 187 - <ProfileThing agent={agent} large/> 188 - <div className="flex pl-[50px]"> 189 - <AutoGrowTextarea 190 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 191 - rows={5} 192 - placeholder={getPlaceholder()} 193 - value={postText} 194 - onChange={(e) => setPostText(e.target.value)} 195 disabled={posting} 196 - autoFocus 197 - /> 198 </div> 199 </div> 200 - {(composerState.kind === "quote") && ( 201 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 202 - {isParentLoading ? ( 203 - <div className="text-sm text-gray-500 animate-pulse"> 204 - Loading parent post... 205 </div> 206 - ) : parentUri ? ( 207 - <UniversalPostRendererATURILoader atUri={parentUri} isQuote /> 208 - ) : ( 209 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 210 - Could not load parent post. 211 </div> 212 )} 213 - </div> 214 - )} 215 216 - {postError && ( 217 - <div className="text-red-500 text-sm my-2 text-center">{postError}</div> 218 )} 219 - 220 </div> 221 - )} 222 - </div> 223 - </div> 224 ); 225 } 226 227 - function AutoGrowTextarea({ value, className, onChange, ...props }: React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>) { 228 const ref = useRef<HTMLTextAreaElement>(null); 229 230 useEffect(() => { ··· 243 {...props} 244 /> 245 ); 246 - }
··· 1 + import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 + import { Dialog } from "radix-ui"; 4 import { useEffect, useRef, useState } from "react"; 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 10 import { ProfileThing } from "./Login"; 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 13 + const MAX_POST_LENGTH = 300; 14 15 export function Composer() { 16 const [composerState, setComposerState] = useAtom(composerAtom); ··· 32 composerState.kind === "reply" 33 ? composerState.parent 34 : composerState.kind === "quote" 35 + ? composerState.subject 36 + : undefined; 37 38 + const { data: parentPost, isLoading: isParentLoading } = 39 + useQueryPost(parentUri); 40 41 async function handlePost() { 42 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 48 const rt = new RichText({ text: postText }); 49 await rt.detectFacets(agent); 50 51 + if (rt.facets?.length) { 52 + rt.facets = rt.facets.filter((item) => { 53 + if (item.$type !== "app.bsky.richtext.facet") return true; 54 + if (!item.features?.length) return true; 55 + 56 + item.features = item.features.filter((feature) => { 57 + if (feature.$type !== "app.bsky.richtext.facet#mention") return true; 58 + const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined; 59 + return typeof did === "string" && did.startsWith("did:"); 60 + }); 61 + 62 + return item.features.length > 0; 63 + }); 64 + } 65 + 66 const record: Record<string, unknown> = { 67 $type: "app.bsky.feed.post", 68 text: rt.text, ··· 112 setPosting(false); 113 } 114 } 115 + // if (composerState.kind === "closed") { 116 + // return null; 117 + // } 118 119 const getPlaceholder = () => { 120 switch (composerState.kind) { ··· 127 return "What's happening?!"; 128 } 129 }; 130 + 131 const charsLeft = MAX_POST_LENGTH - postText.length; 132 const isPostButtonDisabled = 133 + posting || !postText.trim() || isParentLoading || charsLeft < 0; 134 135 return ( 136 + <Dialog.Root 137 + open={composerState.kind !== "closed"} 138 + onOpenChange={(open) => { 139 + if (!open) setComposerState({ kind: "closed" }); 140 + }} 141 + > 142 + <Dialog.Portal> 143 + <Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 144 145 + <Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 146 + <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 147 + <div className="flex flex-row justify-between p-2"> 148 + <Dialog.Close asChild> 149 + <button 150 + className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 151 disabled={posting} 152 + aria-label="Close" 153 + > 154 + <svg 155 + xmlns="http://www.w3.org/2000/svg" 156 + width="20" 157 + height="20" 158 + viewBox="0 0 24 24" 159 + fill="none" 160 + stroke="currentColor" 161 + strokeWidth="2.5" 162 + strokeLinecap="round" 163 + strokeLinejoin="round" 164 + > 165 + <line x1="18" y1="6" x2="6" y2="18"></line> 166 + <line x1="6" y1="6" x2="18" y2="18"></line> 167 + </svg> 168 + </button> 169 + </Dialog.Close> 170 + 171 + <div className="flex-1" /> 172 + <div className="flex items-center gap-4"> 173 + <span 174 + className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 175 + > 176 + {charsLeft} 177 + </span> 178 + <button 179 + className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 180 + onClick={handlePost} 181 + disabled={isPostButtonDisabled} 182 + > 183 + {posting ? "Posting..." : "Post"} 184 + </button> 185 </div> 186 </div> 187 + 188 + {postSuccess ? ( 189 + <div className="flex flex-col items-center justify-center py-16"> 190 + <span className="text-gray-500 text-6xl mb-4">โœ“</span> 191 + <span className="text-xl font-bold text-black dark:text-white"> 192 + Posted! 193 + </span> 194 + </div> 195 + ) : ( 196 + <div className="px-4"> 197 + {composerState.kind === "reply" && ( 198 + <div className="mb-1 -mx-4"> 199 + {isParentLoading ? ( 200 + <div className="text-sm text-gray-500 animate-pulse"> 201 + Loading parent post... 202 + </div> 203 + ) : parentUri ? ( 204 + <UniversalPostRendererATURILoader 205 + atUri={parentUri} 206 + bottomReplyLine 207 + bottomBorder={false} 208 + /> 209 + ) : ( 210 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 211 + Could not load parent post. 212 + </div> 213 + )} 214 </div> 215 + )} 216 + 217 + <div className="flex w-full gap-1 flex-col"> 218 + <ProfileThing agent={agent} large /> 219 + <div className="flex pl-[50px]"> 220 + <AutoGrowTextarea 221 + className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 222 + rows={5} 223 + placeholder={getPlaceholder()} 224 + value={postText} 225 + onChange={(e) => setPostText(e.target.value)} 226 + disabled={posting} 227 + autoFocus 228 + /> 229 + </div> 230 + </div> 231 + 232 + {composerState.kind === "quote" && ( 233 + <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 234 + {isParentLoading ? ( 235 + <div className="text-sm text-gray-500 animate-pulse"> 236 + Loading parent post... 237 + </div> 238 + ) : parentUri ? ( 239 + <UniversalPostRendererATURILoader 240 + atUri={parentUri} 241 + isQuote 242 + /> 243 + ) : ( 244 + <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 245 + Could not load parent post. 246 + </div> 247 + )} 248 </div> 249 )} 250 251 + {postError && ( 252 + <div className="text-red-500 text-sm my-2 text-center"> 253 + {postError} 254 + </div> 255 + )} 256 + </div> 257 )} 258 </div> 259 + </Dialog.Content> 260 + </Dialog.Portal> 261 + </Dialog.Root> 262 ); 263 } 264 265 + function AutoGrowTextarea({ 266 + value, 267 + className, 268 + onChange, 269 + ...props 270 + }: React.DetailedHTMLProps< 271 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 272 + HTMLTextAreaElement 273 + >) { 274 const ref = useRef<HTMLTextAreaElement>(null); 275 276 useEffect(() => { ··· 289 {...props} 290 /> 291 ); 292 + }
+4 -2
src/components/Header.tsx
··· 5 6 export function Header({ 7 backButtonCallback, 8 - title 9 }: { 10 backButtonCallback?: () => void; 11 title?: string; 12 }) { 13 const router = useRouter(); 14 const [isAtTop] = useAtom(isAtTopAtom); 15 //const what = router.history. 16 return ( 17 - <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 18 {backButtonCallback ? (<Link 19 to=".." 20 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
··· 5 6 export function Header({ 7 backButtonCallback, 8 + title, 9 + bottomBorderDisabled, 10 }: { 11 backButtonCallback?: () => void; 12 title?: string; 13 + bottomBorderDisabled?: boolean; 14 }) { 15 const router = useRouter(); 16 const [isAtTop] = useAtom(isAtTopAtom); 17 //const what = router.history. 18 return ( 19 + <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 20 {backButtonCallback ? (<Link 21 to=".." 22 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+184
src/components/Import.tsx
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 9 + 10 + /** 11 + * Basically the best equivalent to Search that i can do 12 + */ 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 21 + const navigate = useNavigate(); 22 + 23 + const { status } = useAuth(); 24 + const [lycandomain] = useAtom(lycanURLAtom); 25 + const lycanExists = lycandomain !== ""; 26 + const { data: lycanstatusdata } = useQueryLycanStatus(); 27 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 28 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 29 + const lycanIndexingProgress = lycanIndexing 30 + ? lycanstatusdata?.progress 31 + : undefined; 32 + const authed = status === "signedIn"; 33 + 34 + const lycanReady = lycanExists && lycanIndexed && authed; 35 + 36 + const handleEnter = () => { 37 + if (!textInput) return; 38 + handleImport({ 39 + text: textInput, 40 + navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 43 + }); 44 + }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 47 + 48 + return ( 49 + <div className="w-full relative"> 50 + <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 51 + 52 + <input 53 + type="text" 54 + placeholder={placeholder} 55 + value={textInput} 56 + onChange={(e) => setTextInput(e.target.value)} 57 + onKeyDown={(e) => { 58 + if (e.key === "Enter") handleEnter(); 59 + }} 60 + className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition" 61 + /> 62 + </div> 63 + ); 64 + } 65 + 66 + function handleImport({ 67 + text, 68 + navigate, 69 + lycanReady, 70 + }: { 71 + text: string; 72 + navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 74 + }) { 75 + const trimmed = text.trim(); 76 + // parse text 77 + /** 78 + * text might be 79 + * 1. bsky dot app url (reddwarf link segments might be uri encoded,) 80 + * 2. aturi 81 + * 3. plain handle 82 + * 4. plain did 83 + */ 84 + 85 + // 1. Check if itโ€™s a URL 86 + try { 87 + const url = new URL(text); 88 + const knownHosts = [ 89 + "bsky.app", 90 + "social.daniela.lol", 91 + "deer.social", 92 + "reddwarf.whey.party", 93 + "reddwarf.app", 94 + "main.bsky.dev", 95 + "catsky.social", 96 + "blacksky.community", 97 + "red-dwarf-social-app.whey.party", 98 + "zeppelin.social", 99 + ]; 100 + if (knownHosts.includes(url.hostname)) { 101 + // parse path to get URI or handle 102 + const path = decodeURIComponent(url.pathname.slice(1)); // remove leading / 103 + console.log("BSky URL path:", path); 104 + navigate({ 105 + to: `/${path}`, 106 + }); 107 + return; 108 + } 109 + } catch { 110 + // not a URL, continue 111 + } 112 + 113 + // 2. Check if text looks like an at-uri 114 + try { 115 + if (text.startsWith("at://")) { 116 + console.log("AT URI detected:", text); 117 + const aturi = new AtUri(text); 118 + switch (aturi.collection) { 119 + case "app.bsky.feed.post": { 120 + navigate({ 121 + to: "/profile/$did/post/$rkey", 122 + params: { 123 + did: aturi.host, 124 + rkey: aturi.rkey, 125 + }, 126 + }); 127 + return; 128 + } 129 + case "app.bsky.actor.profile": { 130 + navigate({ 131 + to: "/profile/$did", 132 + params: { 133 + did: aturi.host, 134 + }, 135 + }); 136 + return; 137 + } 138 + // todo add more handlers as more routes are added. like feeds, lists, etc etc thanks! 139 + default: { 140 + // continue 141 + } 142 + } 143 + } 144 + } catch { 145 + // continue 146 + } 147 + 148 + // 3. Plain handle (starts with @) 149 + try { 150 + if (text.startsWith("@")) { 151 + const handle = text.slice(1); 152 + console.log("Handle detected:", handle); 153 + navigate({ to: "/profile/$did", params: { did: handle } }); 154 + return; 155 + } 156 + } catch { 157 + // continue 158 + } 159 + 160 + // 4. Plain DID (starts with did:) 161 + try { 162 + if (text.startsWith("did:")) { 163 + console.log("did detected:", text); 164 + navigate({ to: "/profile/$did", params: { did: text } }); 165 + return; 166 + } 167 + } catch { 168 + // continue 169 + } 170 + 171 + // if all else fails 172 + 173 + // try { 174 + // // probably a user? 175 + // navigate({ to: "/profile/$did", params: { did: text } }); 176 + // return; 177 + // } catch { 178 + // // continue 179 + // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 184 + }
+38 -7
src/components/InfiniteCustomFeed.tsx
··· 1 import * as React from "react"; 2 3 //import { useInView } from "react-intersection-observer"; ··· 13 feedUri: string; 14 pdsUrl?: string; 15 feedServiceDid?: string; 16 } 17 18 export function InfiniteCustomFeed({ 19 feedUri, 20 pdsUrl, 21 feedServiceDid, 22 }: InfiniteCustomFeedProps) { 23 const { agent } = useAuth(); 24 - const authed = !!agent?.did; 25 26 // const identityresultmaybe = useQueryIdentity(agent?.did); 27 // const identity = identityresultmaybe?.data; ··· 37 isFetchingNextPage, 38 refetch, 39 isRefetching, 40 } = useInfiniteQueryFeedSkeleton({ 41 feedUri: feedUri, 42 agent: agent ?? undefined, 43 isAuthed: authed ?? false, 44 pdsUrl: pdsUrl, 45 feedServiceDid: feedServiceDid, 46 }); 47 48 const handleRefresh = () => { 49 refetch(); 50 }; 51 52 //const { ref, inView } = useInView(); 53 54 // React.useEffect(() => { ··· 67 ); 68 } 69 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 74 75 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 return ( ··· 116 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 aria-label="Refresh feed" 118 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 120 </button> 121 </> 122 ); ··· 139 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 140 ></path> 141 </svg> 142 - );
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 import * as React from "react"; 3 4 //import { useInView } from "react-intersection-observer"; ··· 14 feedUri: string; 15 pdsUrl?: string; 16 feedServiceDid?: string; 17 + authedOverride?: boolean; 18 + unauthedfeedurl?: string; 19 } 20 21 export function InfiniteCustomFeed({ 22 feedUri, 23 pdsUrl, 24 feedServiceDid, 25 + authedOverride, 26 + unauthedfeedurl, 27 }: InfiniteCustomFeedProps) { 28 const { agent } = useAuth(); 29 + const authed = authedOverride || !!agent?.did; 30 31 // const identityresultmaybe = useQueryIdentity(agent?.did); 32 // const identity = identityresultmaybe?.data; ··· 42 isFetchingNextPage, 43 refetch, 44 isRefetching, 45 + queryKey, 46 } = useInfiniteQueryFeedSkeleton({ 47 feedUri: feedUri, 48 agent: agent ?? undefined, 49 isAuthed: authed ?? false, 50 pdsUrl: pdsUrl, 51 feedServiceDid: feedServiceDid, 52 + unauthedfeedurl: unauthedfeedurl, 53 }); 54 + const queryClient = useQueryClient(); 55 + 56 57 const handleRefresh = () => { 58 + queryClient.removeQueries({queryKey: queryKey}); 59 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 60 refetch(); 61 }; 62 63 + const allPosts = React.useMemo(() => { 64 + const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? []; 65 + 66 + const seenUris = new Set<string>(); 67 + 68 + return flattenedPosts.filter((item) => { 69 + if (!item?.post) return false; 70 + 71 + if (seenUris.has(item.post)) { 72 + return false; 73 + } 74 + 75 + seenUris.add(item.post); 76 + 77 + return true; 78 + }); 79 + }, [data]); 80 + 81 //const { ref, inView } = useInView(); 82 83 // React.useEffect(() => { ··· 96 ); 97 } 98 99 + // const allPosts = 100 + // data?.pages.flatMap((page) => { 101 + // if (page) return page.feed; 102 + // }) ?? []; 103 104 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 105 return ( ··· 145 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 146 aria-label="Refresh feed" 147 > 148 + <RefreshIcon 149 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 150 + /> 151 </button> 152 </> 153 ); ··· 170 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 171 ></path> 172 </svg> 173 + );
+64 -21
src/components/Login.tsx
··· 1 // src/components/Login.tsx 2 import AtpAgent, { Agent } from "@atproto/api"; 3 import React, { useEffect, useRef, useState } from "react"; 4 5 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 7 8 // --- 1. The Main Component (Orchestrator with `compact` prop) --- ··· 22 className={ 23 compact 24 ? "flex items-center justify-center p-1" 25 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 26 } 27 > 28 <span ··· 41 // Large view 42 if (!compact) { 43 return ( 44 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 45 <div className="flex flex-col items-center justify-center text-center"> 46 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 47 You are logged in! ··· 75 if (!compact) { 76 // Large view renders the form directly in the card 77 return ( 78 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 79 <UnifiedLoginForm /> 80 </div> 81 ); ··· 190 <p className="text-xs text-gray-500 dark:text-gray-400"> 191 Sign in with AT. Your password is never shared. 192 </p> 193 - <input 194 type="text" 195 placeholder="handle.bsky.social" 196 value={handle} 197 onChange={(e) => setHandle(e.target.value)} 198 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 199 - /> 200 - <button 201 - type="submit" 202 - className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 203 - > 204 - Log in 205 - </button> 206 </form> 207 ); 208 }; ··· 235 <p className="text-xs text-red-500 dark:text-red-400"> 236 Warning: Less secure. Use an App Password. 237 </p> 238 - <input 239 type="text" 240 placeholder="handle.bsky.social" 241 value={user} ··· 257 value={serviceURL} 258 onChange={(e) => setServiceURL(e.target.value)} 259 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 260 - /> 261 {error && <p className="text-xs text-red-500">{error}</p>} 262 <button 263 type="submit" ··· 271 272 // --- Profile Component (now supports a `large` prop for styling) --- 273 export const ProfileThing = ({ 274 - agent: _unused, 275 large = false, 276 }: { 277 - agent?: Agent | null; 278 large?: boolean; 279 }) => { 280 - const { agent } = useAuth(); 281 - const did = ((agent as AtpAgent).session?.did ?? (agent as AtpAgent)?.assertDid ?? agent?.did) as 282 - | string 283 - | undefined; 284 const { data: identity } = useQueryIdentity(did); 285 - const { data: profiledata } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`); 286 const profile = profiledata?.value; 287 288 function getAvatarUrl(p: typeof profile) { 289 const link = p?.avatar?.ref?.["$link"]; 290 if (!link || !did) return null; 291 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 292 } 293 294 if (!profiledata) {
··· 1 // src/components/Login.tsx 2 import AtpAgent, { Agent } from "@atproto/api"; 3 + import { useAtom } from "jotai"; 4 import React, { useEffect, useRef, useState } from "react"; 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { imgCDNAtom } from "~/utils/atoms"; 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- ··· 24 className={ 25 compact 26 ? "flex items-center justify-center p-1" 27 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 28 } 29 > 30 <span ··· 43 // Large view 44 if (!compact) { 45 return ( 46 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 47 <div className="flex flex-col items-center justify-center text-center"> 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 49 You are logged in! ··· 77 if (!compact) { 78 // Large view renders the form directly in the card 79 return ( 80 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 81 <UnifiedLoginForm /> 82 </div> 83 ); ··· 192 <p className="text-xs text-gray-500 dark:text-gray-400"> 193 Sign in with AT. Your password is never shared. 194 </p> 195 + {/* <input 196 type="text" 197 placeholder="handle.bsky.social" 198 value={handle} 199 onChange={(e) => setHandle(e.target.value)} 200 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 201 + /> */} 202 + <div className="flex flex-col gap-3"> 203 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 204 + <input 205 + type="text" 206 + placeholder=" " 207 + value={handle} 208 + onChange={(e) => setHandle(e.target.value)} 209 + /> 210 + <label>AT Handle</label> 211 + </div> 212 + <button 213 + type="submit" 214 + className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 215 + > 216 + Log in 217 + </button> 218 + </div> 219 </form> 220 ); 221 }; ··· 248 <p className="text-xs text-red-500 dark:text-red-400"> 249 Warning: Less secure. Use an App Password. 250 </p> 251 + {/* <input 252 type="text" 253 placeholder="handle.bsky.social" 254 value={user} ··· 270 value={serviceURL} 271 onChange={(e) => setServiceURL(e.target.value)} 272 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 273 + /> */} 274 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 275 + <input 276 + type="text" 277 + placeholder=" " 278 + value={user} 279 + onChange={(e) => setUser(e.target.value)} 280 + /> 281 + <label>AT Handle</label> 282 + </div> 283 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 284 + <input 285 + type="text" 286 + placeholder=" " 287 + value={password} 288 + onChange={(e) => setPassword(e.target.value)} 289 + /> 290 + <label>App Password</label> 291 + </div> 292 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 293 + <input 294 + type="text" 295 + placeholder=" " 296 + value={serviceURL} 297 + onChange={(e) => setServiceURL(e.target.value)} 298 + /> 299 + <label>PDS</label> 300 + </div> 301 {error && <p className="text-xs text-red-500">{error}</p>} 302 <button 303 type="submit" ··· 311 312 // --- Profile Component (now supports a `large` prop for styling) --- 313 export const ProfileThing = ({ 314 + agent, 315 large = false, 316 }: { 317 + agent: Agent | null; 318 large?: boolean; 319 }) => { 320 + const did = ((agent as AtpAgent)?.session?.did ?? 321 + (agent as AtpAgent)?.assertDid ?? 322 + agent?.did) as string | undefined; 323 const { data: identity } = useQueryIdentity(did); 324 + const { data: profiledata } = useQueryProfile( 325 + `at://${did}/app.bsky.actor.profile/self` 326 + ); 327 const profile = profiledata?.value; 328 + 329 + const [imgcdn] = useAtom(imgCDNAtom) 330 331 function getAvatarUrl(p: typeof profile) { 332 const link = p?.avatar?.ref?.["$link"]; 333 if (!link || !did) return null; 334 + return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 335 } 336 337 if (!profiledata) {
+124
src/components/ReusableTabRoute.tsx
···
··· 1 + import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 + import { useAtom } from "jotai"; 3 + import { useEffect, useLayoutEffect } from "react"; 4 + 5 + import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms"; 6 + 7 + /** 8 + * Please wrap your Route in a div, do not return a top-level fragment, 9 + * it will break navigation scroll restoration 10 + */ 11 + export function ReusableTabRoute({ 12 + route, 13 + tabs, 14 + }: { 15 + route: string; 16 + tabs: Record<string, React.ReactNode>; 17 + }) { 18 + const [reusableTabState, setReusableTabState] = useAtom( 19 + reusableTabRouteScrollAtom 20 + ); 21 + const [isAtTop] = useAtom(isAtTopAtom); 22 + 23 + const routeState = reusableTabState?.[route] ?? { 24 + activeTab: Object.keys(tabs)[0], 25 + scrollPositions: {}, 26 + }; 27 + const activeTab = routeState.activeTab; 28 + 29 + const handleValueChange = (newTab: string) => { 30 + setReusableTabState((prev) => { 31 + const current = prev?.[route] ?? routeState; 32 + return { 33 + ...prev, 34 + [route]: { 35 + ...current, 36 + scrollPositions: { 37 + ...current.scrollPositions, 38 + [current.activeTab]: window.scrollY, 39 + }, 40 + activeTab: newTab, 41 + }, 42 + }; 43 + }); 44 + }; 45 + 46 + // // todo, warning experimental, usually this doesnt work, 47 + // // like at all, and i usually do this for each tab 48 + // useLayoutEffect(() => { 49 + // const savedScroll = routeState.scrollPositions[activeTab] ?? 0; 50 + // window.scrollTo({ top: savedScroll }); 51 + // // eslint-disable-next-line react-hooks/exhaustive-deps 52 + // }, [activeTab, route]); 53 + 54 + useLayoutEffect(() => { 55 + return () => { 56 + setReusableTabState((prev) => { 57 + const current = prev?.[route] ?? routeState; 58 + return { 59 + ...prev, 60 + [route]: { 61 + ...current, 62 + scrollPositions: { 63 + ...current.scrollPositions, 64 + [current.activeTab]: window.scrollY, 65 + }, 66 + }, 67 + }; 68 + }); 69 + }; 70 + // eslint-disable-next-line react-hooks/exhaustive-deps 71 + }, []); 72 + 73 + return ( 74 + <TabsPrimitive.Root 75 + value={activeTab} 76 + onValueChange={handleValueChange} 77 + className={`w-full`} 78 + > 79 + <TabsPrimitive.List 80 + className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`} 81 + > 82 + {Object.entries(tabs).map(([key]) => ( 83 + <TabsPrimitive.Trigger key={key} value={key} className="m3tab"> 84 + {key} 85 + </TabsPrimitive.Trigger> 86 + ))} 87 + </TabsPrimitive.List> 88 + 89 + {Object.entries(tabs).map(([key, node]) => ( 90 + <TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]"> 91 + {activeTab === key && node} 92 + </TabsPrimitive.Content> 93 + ))} 94 + </TabsPrimitive.Root> 95 + ); 96 + } 97 + 98 + export function useReusableTabScrollRestore(route: string) { 99 + const [reusableTabState] = useAtom( 100 + reusableTabRouteScrollAtom 101 + ); 102 + 103 + const routeState = reusableTabState?.[route]; 104 + const activeTab = routeState?.activeTab; 105 + 106 + useEffect(() => { 107 + const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0; 108 + //window.scrollTo(0, savedScroll); 109 + window.scrollTo({ top: savedScroll }); 110 + // eslint-disable-next-line react-hooks/exhaustive-deps 111 + }, []); 112 + } 113 + 114 + 115 + /* 116 + 117 + const [notifState] = useAtom(notificationsScrollAtom); 118 + const activeTab = notifState.activeTab; 119 + useEffect(() => { 120 + const savedY = notifState.scrollPositions[activeTab] ?? 0; 121 + window.scrollTo(0, savedY); 122 + }, [activeTab, notifState.scrollPositions]); 123 + 124 + */
+6
src/components/Star.tsx
···
··· 1 + import type { SVGProps } from 'react'; 2 + import React from 'react'; 3 + 4 + export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 5 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 6 + }
+348 -118
src/components/UniversalPostRenderer.tsx
··· 1 import { useNavigate } from "@tanstack/react-router"; 2 import DOMPurify from "dompurify"; 3 import { useAtom } from "jotai"; 4 import { DropdownMenu } from "radix-ui"; 5 import * as React from "react"; 6 import { type SVGProps } from "react"; 7 8 - import { composerAtom, likedPostsAtom } from "~/utils/atoms"; 9 import { useHydratedEmbed } from "~/utils/useHydrated"; 10 import { 11 useQueryConstellation, ··· 32 feedviewpost?: boolean; 33 repostedby?: string; 34 style?: React.CSSProperties; 35 - ref?: React.Ref<HTMLDivElement>; 36 dataIndexPropPass?: number; 37 nopics?: boolean; 38 lightboxCallback?: (d: LightboxProps) => void; 39 maxReplies?: number; 40 isQuote?: boolean; 41 } 42 43 // export async function cachedGetRecord({ ··· 146 ref, 147 dataIndexPropPass, 148 nopics, 149 lightboxCallback, 150 maxReplies, 151 isQuote, 152 }: UniversalPostRendererATURILoaderProps) { 153 // /*mass comment*/ console.log("atUri", atUri); 154 //const { get, set } = usePersistentStore(); 155 //const [record, setRecord] = React.useState<any>(null); ··· 401 // path: ".reply.parent.uri", 402 // }); 403 404 const infinitequeryresults = useInfiniteQuery({ 405 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 406 { 407 method: "/links", 408 target: atUri, 409 collection: "app.bsky.feed.post", ··· 422 423 // auto-fetch all pages 424 useEffect(() => { 425 - if (!maxReplies || isQuote) return; 426 if ( 427 infinitequeryresults.hasNextPage && 428 !infinitequeryresults.isFetchingNextPage ··· 430 console.log("Fetching the next page..."); 431 infinitequeryresults.fetchNextPage(); 432 } 433 - }, [infinitequeryresults]); 434 435 const replyAturis = repliesData 436 ? repliesData.pages.flatMap((page) => ··· 507 ? true 508 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 509 ? false 510 - : bottomReplyLine 511 } 512 topReplyLine={topReplyLine} 513 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 525 ref={ref} 526 dataIndexPropPass={dataIndexPropPass} 527 nopics={nopics} 528 lightboxCallback={lightboxCallback} 529 maxReplies={maxReplies} 530 isQuote={isQuote} 531 /> 532 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 533 <> 534 {/* <span>hello {maxReplies}</span> */} ··· 548 ref={ref} 549 dataIndexPropPass={dataIndexPropPass} 550 nopics={nopics} 551 lightboxCallback={lightboxCallback} 552 maxReplies={ 553 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 554 } 555 /> 556 - {maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && ( 557 - <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 558 - )} 559 </> 560 )} 561 </> ··· 596 ); 597 } 598 599 - function getAvatarUrl(opProfile: any, did: string) { 600 const link = opProfile?.value?.avatar?.ref?.["$link"]; 601 if (!link) return null; 602 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 603 } 604 605 export function UniversalPostRendererRawRecordShim({ ··· 620 ref, 621 dataIndexPropPass, 622 nopics, 623 lightboxCallback, 624 maxReplies, 625 isQuote, 626 }: { 627 postRecord: any; 628 profileRecord: any; ··· 638 feedviewpost?: boolean; 639 repostedby?: string; 640 style?: React.CSSProperties; 641 - ref?: React.Ref<HTMLDivElement>; 642 dataIndexPropPass?: number; 643 nopics?: boolean; 644 lightboxCallback?: (d: LightboxProps) => void; 645 maxReplies?: number; 646 isQuote?: boolean; 647 }) { 648 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 649 const navigate = useNavigate(); ··· 714 // run(); 715 // }, [postRecord, resolved?.did]); 716 717 const { 718 data: hydratedEmbed, 719 isLoading: isEmbedLoading, 720 error: embedError, 721 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 722 723 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 724 725 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 726 () => ({ 727 $type: "app.bsky.feed.defs#postView", 728 uri: aturi, 729 cid: postRecord?.cid || "", 730 - author: { 731 - did: resolved?.did || "", 732 - handle: resolved?.handle || "", 733 - displayName: profileRecord?.value?.displayName || "", 734 - avatar: getAvatarUrl(profileRecord, resolved?.did) || "", 735 - viewer: undefined, 736 - labels: profileRecord?.labels || undefined, 737 - verification: undefined, 738 - }, 739 record: postRecord?.value || {}, 740 embed: hydratedEmbed ?? undefined, 741 replyCount: repliesCount ?? 0, ··· 752 postRecord?.cid, 753 postRecord?.value, 754 postRecord?.labels, 755 - resolved?.did, 756 - resolved?.handle, 757 - profileRecord, 758 hydratedEmbed, 759 repliesCount, 760 repostsCount, ··· 793 // }, [fakepost, get, set]); 794 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 795 ?.uri; 796 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 797 const replyhookvalue = useQueryIdentity( 798 feedviewpost ? feedviewpostreplydid : undefined 799 ); ··· 804 repostedby ? aturirepostbydid : undefined 805 ); 806 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 807 return ( 808 <> 809 {/* <p> 810 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 811 </p> */} 812 <UniversalPostRenderer 813 expanded={detailed} 814 onPostClick={() => ··· 831 } 832 }} 833 post={fakepost} 834 salt={aturi} 835 bottomReplyLine={bottomReplyLine} 836 topReplyLine={topReplyLine} ··· 842 ref={ref} 843 dataIndexPropPass={dataIndexPropPass} 844 nopics={nopics} 845 lightboxCallback={lightboxCallback} 846 maxReplies={maxReplies} 847 isQuote={isQuote} ··· 883 {...props} 884 > 885 <path 886 - fill="oklch(0.704 0.05 28)" 887 d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z" 888 ></path> 889 </svg> ··· 900 {...props} 901 > 902 <path 903 - fill="oklch(0.704 0.05 28)" 904 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 905 ></path> 906 </svg> ··· 951 {...props} 952 > 953 <path 954 - fill="oklch(0.704 0.05 28)" 955 d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3" 956 ></path> 957 </svg> ··· 968 {...props} 969 > 970 <path 971 - fill="oklch(0.704 0.05 28)" 972 d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08" 973 ></path> 974 </svg> ··· 985 {...props} 986 > 987 <path 988 - fill="oklch(0.704 0.05 28)" 989 d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" 990 ></path> 991 </svg> ··· 1002 {...props} 1003 > 1004 <path 1005 - fill="oklch(0.704 0.05 28)" 1006 d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" 1007 ></path> 1008 </svg> ··· 1036 {...props} 1037 > 1038 <path 1039 - fill="oklch(0.704 0.05 28)" 1040 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1041 ></path> 1042 </svg> ··· 1090 {...props} 1091 > 1092 <path 1093 - fill="oklch(0.704 0.05 28)" 1094 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1095 ></path> 1096 </svg> ··· 1107 {...props} 1108 > 1109 <path 1110 - fill="oklch(0.704 0.05 28)" 1111 d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z" 1112 ></path> 1113 </svg> ··· 1135 //import Masonry from "@mui/lab/Masonry"; 1136 import { 1137 type $Typed, 1138 AppBskyEmbedDefs, 1139 AppBskyEmbedExternal, 1140 AppBskyEmbedImages, ··· 1164 1165 import defaultpfp from "~/../public/favicon.png"; 1166 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1167 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1168 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1169 // import type { 1170 // ViewRecord, ··· 1272 1273 function UniversalPostRenderer({ 1274 post, 1275 //setMainItem, 1276 //isMainItem, 1277 onPostClick, ··· 1292 ref, 1293 dataIndexPropPass, 1294 nopics, 1295 lightboxCallback, 1296 maxReplies, 1297 }: { 1298 post: PostView; 1299 // optional for now because i havent ported every use to this yet 1300 // setMainItem?: React.Dispatch< 1301 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1314 depth?: number; 1315 repostedby?: string; 1316 style?: React.CSSProperties; 1317 - ref?: React.Ref<HTMLDivElement>; 1318 dataIndexPropPass?: number; 1319 nopics?: boolean; 1320 lightboxCallback?: (d: LightboxProps) => void; 1321 maxReplies?: number; 1322 }) { 1323 const parsed = new AtUri(post.uri); 1324 const navigate = useNavigate(); 1325 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1326 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1327 post.viewer?.repost ? true : false 1328 ); 1329 - const [hasLiked, setHasLiked] = useState<boolean>( 1330 - post.uri in likedPosts || post.viewer?.like ? true : false 1331 - ); 1332 const [, setComposerPost] = useAtom(composerAtom); 1333 const { agent } = useAuth(); 1334 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1335 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1336 post.viewer?.repost 1337 ); 1338 - 1339 - const likeOrUnlikePost = async () => { 1340 - const newLikedPosts = { ...likedPosts }; 1341 - if (!agent) { 1342 - console.error("Agent is null or undefined"); 1343 - return; 1344 - } 1345 - if (hasLiked) { 1346 - if (post.uri in likedPosts) { 1347 - const likeUri = likedPosts[post.uri]; 1348 - setLikeUri(likeUri); 1349 - } 1350 - if (likeUri) { 1351 - await agent.deleteLike(likeUri); 1352 - setHasLiked(false); 1353 - delete newLikedPosts[post.uri]; 1354 - } 1355 - } else { 1356 - const { uri } = await agent.like(post.uri, post.cid); 1357 - setLikeUri(uri); 1358 - setHasLiked(true); 1359 - newLikedPosts[post.uri] = uri; 1360 - } 1361 - setLikedPosts(newLikedPosts); 1362 - }; 1363 1364 const repostOrUnrepostPost = async () => { 1365 if (!agent) { ··· 1390 : undefined; 1391 1392 const emergencySalt = randomString(); 1393 - const fedi = (post.record as { bridgyOriginalText?: string }) 1394 .bridgyOriginalText; 1395 1396 /* fuck you */ 1397 const isMainItem = false; 1398 const setMainItem = (any: any) => {}; 1399 // eslint-disable-next-line react-hooks/refs 1400 - console.log("Received ref in UniversalPostRenderer:", ref); 1401 return ( 1402 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1403 <div ··· 1479 className="bg-gray-500 dark:bg-gray-400" 1480 /> 1481 )} 1482 - <div 1483 - style={{ 1484 - position: "absolute", 1485 - //top: isRepost ? "calc(16px + 1rem)" : 16, 1486 - //left: 16, 1487 - zIndex: 1, 1488 - top: isRepost 1489 - ? "calc(16px + 1rem)" 1490 - : isQuote 1491 - ? 12 1492 - : topReplyLine 1493 - ? 8 1494 - : 16, 1495 - left: isQuote ? 12 : 16, 1496 - }} 1497 - onClick={onProfileClick} 1498 - > 1499 - <img 1500 - src={post.author.avatar || defaultpfp} 1501 - alt="avatar" 1502 - // transition={{ 1503 - // type: "spring", 1504 - // stiffness: 260, 1505 - // damping: 20, 1506 - // }} 1507 - style={{ 1508 - borderRadius: "50%", 1509 - marginRight: 12, 1510 - objectFit: "cover", 1511 - //background: theme.border, 1512 - //border: `1px solid ${theme.border}`, 1513 - width: isQuote ? 16 : 42, 1514 - height: isQuote ? 16 : 42, 1515 - }} 1516 - className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1517 - /> 1518 - </div> 1519 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1520 <div 1521 style={{ ··· 1675 <div 1676 style={{ 1677 fontSize: 16, 1678 - marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1679 whiteSpace: "pre-wrap", 1680 textAlign: "left", 1681 overflowWrap: "anywhere", 1682 wordBreak: "break-word", 1683 - //color: theme.text, 1684 }} 1685 className="text-gray-900 dark:text-gray-100" 1686 > ··· 1703 </> 1704 )} 1705 </div> 1706 - {post.embed && depth < 1 ? ( 1707 <PostEmbeds 1708 embed={post.embed} 1709 //moderation={moderation} ··· 1725 </div> 1726 </> 1727 )} 1728 - <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1729 <> 1730 {expanded && ( 1731 <div ··· 1821 </DropdownMenu.Root> 1822 <HitSlopButton 1823 onClick={() => { 1824 - likeOrUnlikePost(); 1825 }} 1826 style={{ 1827 ...btnstyle, 1828 - ...(hasLiked ? { color: "#EC4899" } : {}), 1829 }} 1830 > 1831 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1832 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 1833 </HitSlopButton> 1834 <div style={{ display: "flex", gap: 8 }}> 1835 <HitSlopButton ··· 1843 "/post/" + 1844 post.uri.split("/").pop() 1845 ); 1846 } catch (_e) { 1847 // idk 1848 } 1849 }} 1850 style={{ ··· 1853 > 1854 <MdiShareVariant /> 1855 </HitSlopButton> 1856 - <span style={btnstyle}> 1857 - <MdiMoreHoriz /> 1858 - </span> 1859 </div> 1860 </div> 1861 )} ··· 2081 } 2082 2083 if (AppBskyEmbedRecord.isView(embed)) { 2084 // custom feed embed (i.e. generator view) 2085 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2086 // stopgap sorry ··· 2090 // <MaybeFeedCard view={embed.record} /> 2091 // </div> 2092 // ) 2093 } 2094 2095 // list embed ··· 2101 // <MaybeListCard view={embed.record} /> 2102 // </div> 2103 // ) 2104 } 2105 2106 // starter pack embed ··· 2112 // <StarterPackCard starterPack={embed.record} /> 2113 // </div> 2114 // ) 2115 } 2116 2117 // quote post ··· 2171 </div> 2172 ); 2173 } else { 2174 return <>sorry</>; 2175 } 2176 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2480 // = 2481 if (AppBskyEmbedVideo.isView(embed)) { 2482 // hls playlist 2483 const playlist = embed.playlist; 2484 return ( 2485 <SmartHLSPlayer ··· 2571 return { start, end, feature: f.features[0] }; 2572 }); 2573 } 2574 - function renderTextWithFacets({ 2575 text, 2576 facets, 2577 navigate, ··· 2603 className="link" 2604 style={{ 2605 textDecoration: "none", 2606 - color: "rgb(29, 122, 242)", 2607 wordBreak: "break-all", 2608 }} 2609 target="_blank" ··· 2623 result.push( 2624 <span 2625 key={start} 2626 - style={{ color: "rgb(29, 122, 242)" }} 2627 className=" cursor-pointer" 2628 onClick={(e) => { 2629 e.stopPropagation(); ··· 2641 result.push( 2642 <span 2643 key={start} 2644 - style={{ color: "rgb(29, 122, 242)" }} 2645 onClick={(e) => { 2646 e.stopPropagation(); 2647 }}
··· 1 + import * as ATPAPI from "@atproto/api"; 2 import { useNavigate } from "@tanstack/react-router"; 3 import DOMPurify from "dompurify"; 4 import { useAtom } from "jotai"; 5 import { DropdownMenu } from "radix-ui"; 6 + import { HoverCard } from "radix-ui"; 7 import * as React from "react"; 8 import { type SVGProps } from "react"; 9 10 + import { 11 + composerAtom, 12 + constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 15 + imgCDNAtom, 16 + } from "~/utils/atoms"; 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 18 import { 19 useQueryConstellation, ··· 40 feedviewpost?: boolean; 41 repostedby?: string; 42 style?: React.CSSProperties; 43 + ref?: React.RefObject<HTMLDivElement>; 44 dataIndexPropPass?: number; 45 nopics?: boolean; 46 + concise?: boolean; 47 lightboxCallback?: (d: LightboxProps) => void; 48 maxReplies?: number; 49 isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 53 } 54 55 // export async function cachedGetRecord({ ··· 158 ref, 159 dataIndexPropPass, 160 nopics, 161 + concise, 162 lightboxCallback, 163 maxReplies, 164 isQuote, 165 + filterNoReplies, 166 + filterMustHaveMedia, 167 + filterMustBeReply, 168 }: UniversalPostRendererATURILoaderProps) { 169 + // todo remove this once tree rendering is implemented, use a prop like isTree 170 + const TEMPLINEAR = true; 171 // /*mass comment*/ console.log("atUri", atUri); 172 //const { get, set } = usePersistentStore(); 173 //const [record, setRecord] = React.useState<any>(null); ··· 419 // path: ".reply.parent.uri", 420 // }); 421 422 + const [constellationurl] = useAtom(constellationURLAtom); 423 + 424 const infinitequeryresults = useInfiniteQuery({ 425 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 426 { 427 + constellation: constellationurl, 428 method: "/links", 429 target: atUri, 430 collection: "app.bsky.feed.post", ··· 443 444 // auto-fetch all pages 445 useEffect(() => { 446 + if (!maxReplies || isQuote || TEMPLINEAR) return; 447 if ( 448 infinitequeryresults.hasNextPage && 449 !infinitequeryresults.isFetchingNextPage ··· 451 console.log("Fetching the next page..."); 452 infinitequeryresults.fetchNextPage(); 453 } 454 + }, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]); 455 456 const replyAturis = repliesData 457 ? repliesData.pages.flatMap((page) => ··· 528 ? true 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 530 ? false 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 534 } 535 topReplyLine={topReplyLine} 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 548 ref={ref} 549 dataIndexPropPass={dataIndexPropPass} 550 nopics={nopics} 551 + concise={concise} 552 lightboxCallback={lightboxCallback} 553 maxReplies={maxReplies} 554 isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 557 + filterMustBeReply={filterMustBeReply} 558 /> 559 + <> 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 561 + <> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 564 + </> 565 + ) : ( 566 + <></> 567 + )} 568 + </> 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 570 <> 571 {/* <span>hello {maxReplies}</span> */} ··· 585 ref={ref} 586 dataIndexPropPass={dataIndexPropPass} 587 nopics={nopics} 588 + concise={concise} 589 lightboxCallback={lightboxCallback} 590 maxReplies={ 591 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 592 } 593 /> 594 </> 595 )} 596 </> ··· 631 ); 632 } 633 634 + function getAvatarUrl(opProfile: any, did: string, cdn: string) { 635 const link = opProfile?.value?.avatar?.ref?.["$link"]; 636 if (!link) return null; 637 + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 638 } 639 640 export function UniversalPostRendererRawRecordShim({ ··· 655 ref, 656 dataIndexPropPass, 657 nopics, 658 + concise, 659 lightboxCallback, 660 maxReplies, 661 isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 665 }: { 666 postRecord: any; 667 profileRecord: any; ··· 677 feedviewpost?: boolean; 678 repostedby?: string; 679 style?: React.CSSProperties; 680 + ref?: React.RefObject<HTMLDivElement>; 681 dataIndexPropPass?: number; 682 nopics?: boolean; 683 + concise?: boolean; 684 lightboxCallback?: (d: LightboxProps) => void; 685 maxReplies?: number; 686 isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 690 }) { 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 692 const navigate = useNavigate(); ··· 757 // run(); 758 // }, [postRecord, resolved?.did]); 759 760 + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 761 + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 762 + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 763 + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 764 + const isQuotewithImages = 765 + isquotewithmedia && 766 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 767 + "app.bsky.embed.images"; 768 + const isQuotewithVideo = 769 + isquotewithmedia && 770 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 771 + "app.bsky.embed.video"; 772 + 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 776 + 777 const { 778 data: hydratedEmbed, 779 isLoading: isEmbedLoading, 780 error: embedError, 781 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 782 783 + const [imgcdn] = useAtom(imgCDNAtom); 784 + 785 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 786 787 + const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 788 + () => ({ 789 + did: resolved?.did || "", 790 + handle: resolved?.handle || "", 791 + displayName: profileRecord?.value?.displayName || "", 792 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 793 + viewer: undefined, 794 + labels: profileRecord?.labels || undefined, 795 + verification: undefined, 796 + }), 797 + [imgcdn, profileRecord, resolved?.did, resolved?.handle] 798 + ); 799 + 800 + const fakeprofileviewdetailed = 801 + React.useMemo<AppBskyActorDefs.ProfileViewDetailed>( 802 + () => ({ 803 + ...fakeprofileviewbasic, 804 + $type: "app.bsky.actor.defs#profileViewDetailed", 805 + description: profileRecord?.value?.description || undefined, 806 + }), 807 + [fakeprofileviewbasic, profileRecord?.value?.description] 808 + ); 809 + 810 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 811 () => ({ 812 $type: "app.bsky.feed.defs#postView", 813 uri: aturi, 814 cid: postRecord?.cid || "", 815 + author: fakeprofileviewbasic, 816 record: postRecord?.value || {}, 817 embed: hydratedEmbed ?? undefined, 818 replyCount: repliesCount ?? 0, ··· 829 postRecord?.cid, 830 postRecord?.value, 831 postRecord?.labels, 832 + fakeprofileviewbasic, 833 hydratedEmbed, 834 repliesCount, 835 repostsCount, ··· 868 // }, [fakepost, get, set]); 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 870 ?.uri; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 873 const replyhookvalue = useQueryIdentity( 874 feedviewpost ? feedviewpostreplydid : undefined 875 ); ··· 880 repostedby ? aturirepostbydid : undefined 881 ); 882 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 883 + 884 + if (filterNoReplies && thereply) return null; 885 + 886 + if (filterMustHaveMedia && !hasMedia) return null; 887 + 888 + if (filterMustBeReply && !thereply) return null; 889 + 890 return ( 891 <> 892 {/* <p> 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 897 <UniversalPostRenderer 898 expanded={detailed} 899 onPostClick={() => ··· 916 } 917 }} 918 post={fakepost} 919 + uprrrsauthor={fakeprofileviewdetailed} 920 salt={aturi} 921 bottomReplyLine={bottomReplyLine} 922 topReplyLine={topReplyLine} ··· 928 ref={ref} 929 dataIndexPropPass={dataIndexPropPass} 930 nopics={nopics} 931 + concise={concise} 932 lightboxCallback={lightboxCallback} 933 maxReplies={maxReplies} 934 isQuote={isQuote} ··· 970 {...props} 971 > 972 <path 973 + fill="var(--color-gray-400)" 974 d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z" 975 ></path> 976 </svg> ··· 987 {...props} 988 > 989 <path 990 + fill="var(--color-gray-400)" 991 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 992 ></path> 993 </svg> ··· 1038 {...props} 1039 > 1040 <path 1041 + fill="var(--color-gray-400)" 1042 d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3" 1043 ></path> 1044 </svg> ··· 1055 {...props} 1056 > 1057 <path 1058 + fill="var(--color-gray-400)" 1059 d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08" 1060 ></path> 1061 </svg> ··· 1072 {...props} 1073 > 1074 <path 1075 + fill="var(--color-gray-400)" 1076 d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" 1077 ></path> 1078 </svg> ··· 1089 {...props} 1090 > 1091 <path 1092 + fill="var(--color-gray-400)" 1093 d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" 1094 ></path> 1095 </svg> ··· 1123 {...props} 1124 > 1125 <path 1126 + fill="var(--color-gray-400)" 1127 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1128 ></path> 1129 </svg> ··· 1177 {...props} 1178 > 1179 <path 1180 + fill="var(--color-gray-400)" 1181 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1182 ></path> 1183 </svg> ··· 1194 {...props} 1195 > 1196 <path 1197 + fill="var(--color-gray-400)" 1198 d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z" 1199 ></path> 1200 </svg> ··· 1222 //import Masonry from "@mui/lab/Masonry"; 1223 import { 1224 type $Typed, 1225 + AppBskyActorDefs, 1226 AppBskyEmbedDefs, 1227 AppBskyEmbedExternal, 1228 AppBskyEmbedImages, ··· 1252 1253 import defaultpfp from "~/../public/favicon.png"; 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262 + import { useFastLike } from "~/utils/likeMutationQueue"; 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1264 // import type { 1265 // ViewRecord, ··· 1367 1368 function UniversalPostRenderer({ 1369 post, 1370 + uprrrsauthor, 1371 //setMainItem, 1372 //isMainItem, 1373 onPostClick, ··· 1388 ref, 1389 dataIndexPropPass, 1390 nopics, 1391 + concise, 1392 lightboxCallback, 1393 maxReplies, 1394 }: { 1395 post: PostView; 1396 + uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1397 // optional for now because i havent ported every use to this yet 1398 // setMainItem?: React.Dispatch< 1399 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1412 depth?: number; 1413 repostedby?: string; 1414 style?: React.CSSProperties; 1415 + ref?: React.RefObject<HTMLDivElement>; 1416 dataIndexPropPass?: number; 1417 nopics?: boolean; 1418 + concise?: boolean; 1419 lightboxCallback?: (d: LightboxProps) => void; 1420 maxReplies?: number; 1421 }) { 1422 const parsed = new AtUri(post.uri); 1423 const navigate = useNavigate(); 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1425 post.viewer?.repost ? true : false 1426 ); 1427 const [, setComposerPost] = useAtom(composerAtom); 1428 const { agent } = useAuth(); 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1430 post.viewer?.repost 1431 ); 1432 + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1433 + // const bovref = useBackfillOnView(post.uri, post.cid); 1434 + // React.useLayoutEffect(()=>{ 1435 + // if (expanded && !isQuote) { 1436 + // backfill(); 1437 + // } 1438 + // },[backfill, expanded, isQuote]) 1439 1440 const repostOrUnrepostPost = async () => { 1441 if (!agent) { ··· 1466 : undefined; 1467 1468 const emergencySalt = randomString(); 1469 + 1470 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 + 1473 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1474 .bridgyOriginalText; 1475 + const unfediwafrnPartial = (post.record as { fullText?: string }).fullText; 1476 + const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags; 1477 + const unfediwafrnUnHost = (post.record as { fediverseId?: string }) 1478 + .fediverseId; 1479 + 1480 + const undfediwafrnHost = unfediwafrnUnHost 1481 + ? new URL(unfediwafrnUnHost).hostname 1482 + : undefined; 1483 + 1484 + const tags = unfediwafrnTags 1485 + ? unfediwafrnTags 1486 + .split("\n") 1487 + .map((t) => t.trim()) 1488 + .filter(Boolean) 1489 + : undefined; 1490 + 1491 + const links = tags 1492 + ? tags 1493 + .map((tag) => { 1494 + const encoded = encodeURIComponent(tag); 1495 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1496 + }) 1497 + .join("<br>") 1498 + : ""; 1499 + 1500 + const unfediwafrn = unfediwafrnPartial 1501 + ? unfediwafrnPartial + (links ? `<br>${links}` : "") 1502 + : undefined; 1503 + 1504 + const fedi = 1505 + (showBridgyText ? unfedibridgy : undefined) ?? 1506 + (showWafrnText ? unfediwafrn : undefined); 1507 1508 /* fuck you */ 1509 const isMainItem = false; 1510 const setMainItem = (any: any) => {}; 1511 // eslint-disable-next-line react-hooks/refs 1512 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1513 return ( 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1515 <div ··· 1591 className="bg-gray-500 dark:bg-gray-400" 1592 /> 1593 )} 1594 + <HoverCard.Root> 1595 + <HoverCard.Trigger asChild> 1596 + <div 1597 + className={`absolute`} 1598 + style={{ 1599 + top: isRepost 1600 + ? "calc(16px + 1rem)" 1601 + : isQuote 1602 + ? 12 1603 + : topReplyLine 1604 + ? 8 1605 + : 16, 1606 + left: isQuote ? 12 : 16, 1607 + }} 1608 + onClick={onProfileClick} 1609 + > 1610 + <img 1611 + src={post.author.avatar || defaultpfp} 1612 + alt="avatar" 1613 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1614 + style={{ 1615 + width: isQuote ? 16 : 42, 1616 + height: isQuote ? 16 : 42, 1617 + }} 1618 + /> 1619 + </div> 1620 + </HoverCard.Trigger> 1621 + <HoverCard.Portal> 1622 + <HoverCard.Content 1623 + className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50" 1624 + side={"bottom"} 1625 + sideOffset={5} 1626 + onClick={onProfileClick} 1627 + > 1628 + <div className="flex flex-col gap-2"> 1629 + <div className="flex flex-row"> 1630 + <img 1631 + src={post.author.avatar || defaultpfp} 1632 + alt="avatar" 1633 + className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1634 + /> 1635 + <div className=" flex-1 flex flex-row align-middle justify-end"> 1636 + <FollowButton targetdidorhandle={post.author.did} /> 1637 + </div> 1638 + </div> 1639 + <div className="flex flex-col gap-3"> 1640 + <div> 1641 + <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1642 + {post.author.displayName || post.author.handle}{" "} 1643 + </div> 1644 + <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1647 + </div> 1648 + </div> 1649 + {uprrrsauthor?.description && ( 1650 + <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1651 + {uprrrsauthor.description} 1652 + </div> 1653 + )} 1654 + {/* <div className="flex gap-4"> 1655 + <div className="flex gap-1"> 1656 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1657 + 0 1658 + </div> 1659 + <div className="text-gray-500 dark:text-gray-400"> 1660 + Following 1661 + </div> 1662 + </div> 1663 + <div className="flex gap-1"> 1664 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1665 + 2,900 1666 + </div> 1667 + <div className="text-gray-500 dark:text-gray-400"> 1668 + Followers 1669 + </div> 1670 + </div> 1671 + </div> */} 1672 + </div> 1673 + </div> 1674 + 1675 + {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1676 + </HoverCard.Content> 1677 + </HoverCard.Portal> 1678 + </HoverCard.Root> 1679 + 1680 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1681 <div 1682 style={{ ··· 1836 <div 1837 style={{ 1838 fontSize: 16, 1839 + marginBottom: !post.embed || concise ? 0 : 8, 1840 whiteSpace: "pre-wrap", 1841 textAlign: "left", 1842 overflowWrap: "anywhere", 1843 wordBreak: "break-word", 1844 + ...(concise && { 1845 + display: "-webkit-box", 1846 + WebkitBoxOrient: "vertical", 1847 + WebkitLineClamp: 2, 1848 + overflow: "hidden", 1849 + }), 1850 }} 1851 className="text-gray-900 dark:text-gray-100" 1852 > ··· 1869 </> 1870 )} 1871 </div> 1872 + {post.embed && depth < 1 && !concise ? ( 1873 <PostEmbeds 1874 embed={post.embed} 1875 //moderation={moderation} ··· 1891 </div> 1892 </> 1893 )} 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1899 <> 1900 {expanded && ( 1901 <div ··· 1991 </DropdownMenu.Root> 1992 <HitSlopButton 1993 onClick={() => { 1994 + toggle(); 1995 }} 1996 style={{ 1997 ...btnstyle, 1998 + ...(liked ? { color: "#EC4899" } : {}), 1999 }} 2000 > 2001 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 + {(post.likeCount || 0) + (liked ? 1 : 0)} 2003 </HitSlopButton> 2004 <div style={{ display: "flex", gap: 8 }}> 2005 <HitSlopButton ··· 2013 "/post/" + 2014 post.uri.split("/").pop() 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 2019 } catch (_e) { 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 2024 } 2025 }} 2026 style={{ ··· 2029 > 2030 <MdiShareVariant /> 2031 </HitSlopButton> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 2043 </div> 2044 </div> 2045 )} ··· 2265 } 2266 2267 if (AppBskyEmbedRecord.isView(embed)) { 2268 + // hey im really lazy and im gonna do it the bad way 2269 + const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2270 + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2271 + 2272 // custom feed embed (i.e. generator view) 2273 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2274 // stopgap sorry ··· 2278 // <MaybeFeedCard view={embed.record} /> 2279 // </div> 2280 // ) 2281 + } else if ( 2282 + !!reallybaduri && 2283 + !!reallybadaturi && 2284 + reallybadaturi.collection === "app.bsky.feed.generator" 2285 + ) { 2286 + return ( 2287 + <div className="rounded-xl border"> 2288 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2289 + </div> 2290 + ); 2291 } 2292 2293 // list embed ··· 2299 // <MaybeListCard view={embed.record} /> 2300 // </div> 2301 // ) 2302 + } else if ( 2303 + !!reallybaduri && 2304 + !!reallybadaturi && 2305 + reallybadaturi.collection === "app.bsky.graph.list" 2306 + ) { 2307 + return ( 2308 + <div className="rounded-xl border"> 2309 + <FeedItemRenderAturiLoader 2310 + aturi={reallybaduri} 2311 + disableBottomBorder 2312 + listmode 2313 + disablePropagation 2314 + /> 2315 + </div> 2316 + ); 2317 } 2318 2319 // starter pack embed ··· 2325 // <StarterPackCard starterPack={embed.record} /> 2326 // </div> 2327 // ) 2328 + } else if ( 2329 + !!reallybaduri && 2330 + !!reallybadaturi && 2331 + reallybadaturi.collection === "app.bsky.graph.starterpack" 2332 + ) { 2333 + return ( 2334 + <div className="rounded-xl border"> 2335 + <FeedItemRenderAturiLoader 2336 + aturi={reallybaduri} 2337 + disableBottomBorder 2338 + listmode 2339 + disablePropagation 2340 + /> 2341 + </div> 2342 + ); 2343 } 2344 2345 // quote post ··· 2399 </div> 2400 ); 2401 } else { 2402 + console.log("what the hell is a ", embed); 2403 return <>sorry</>; 2404 } 2405 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2709 // = 2710 if (AppBskyEmbedVideo.isView(embed)) { 2711 // hls playlist 2712 + if (nopics) return; 2713 const playlist = embed.playlist; 2714 return ( 2715 <SmartHLSPlayer ··· 2801 return { start, end, feature: f.features[0] }; 2802 }); 2803 } 2804 + export function renderTextWithFacets({ 2805 text, 2806 facets, 2807 navigate, ··· 2833 className="link" 2834 style={{ 2835 textDecoration: "none", 2836 + color: "var(--link-text-color)", 2837 wordBreak: "break-all", 2838 }} 2839 target="_blank" ··· 2853 result.push( 2854 <span 2855 key={start} 2856 + style={{ color: "var(--link-text-color)" }} 2857 className=" cursor-pointer" 2858 onClick={(e) => { 2859 e.stopPropagation(); ··· 2871 result.push( 2872 <span 2873 key={start} 2874 + style={{ color: "var(--link-text-color)" }} 2875 onClick={(e) => { 2876 e.stopPropagation(); 2877 }}
+2
src/main.tsx
··· 14 import { routeTree } from "./routeTree.gen"; 15 import { isAtTopAtom } from "./utils/atoms.ts"; 16 17 const queryClient = new QueryClient({ 18 defaultOptions: { 19 queries: {
··· 14 import { routeTree } from "./routeTree.gen"; 15 import { isAtTopAtom } from "./utils/atoms.ts"; 16 17 + //initAtomToCssVar(hueAtom, "--tw-gray-hue") 18 + 19 const queryClient = new QueryClient({ 20 defaultOptions: { 21 queries: {
+163
src/providers/LikeMutationQueueProvider.tsx
···
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { TID } from "@atproto/common-web"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { useAtom } from "jotai"; 5 + import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 + 7 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 9 + import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 + import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 + 12 + export type LikeRecord = { uri: string; target: string; cid: string }; 13 + export type LikeMutation = { type: 'like'; target: string; cid: string }; 14 + export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 15 + export type Mutation = LikeMutation | UnlikeMutation; 16 + 17 + interface LikeMutationQueueContextType { 18 + fastState: (target: string) => LikeRecord | null | undefined; 19 + fastToggle: (target:string, cid:string) => void; 20 + backfillState: (target: string, user: string) => Promise<void>; 21 + } 22 + 23 + const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 24 + 25 + export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 26 + const { agent } = useAuth(); 27 + const queryClient = useQueryClient(); 28 + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 29 + const [constellationurl] = useAtom(constellationURLAtom); 30 + 31 + const likedPostsRef = useRef(likedPosts); 32 + useEffect(() => { 33 + likedPostsRef.current = likedPosts; 34 + }, [likedPosts]); 35 + 36 + const queueRef = useRef<Mutation[]>([]); 37 + const runningRef = useRef(false); 38 + 39 + const fastState = (target: string) => likedPosts[target]; 40 + 41 + const setFastState = useCallback( 42 + (target: string, record: LikeRecord | null) => 43 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 44 + [setLikedPosts] 45 + ); 46 + 47 + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 48 + 49 + const fastToggle = useCallback((target: string, cid: string) => { 50 + const likedRecord = likedPostsRef.current[target]; 51 + 52 + if (likedRecord) { 53 + setFastState(target, null); 54 + if (likedRecord.uri !== 'pending') { 55 + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 56 + } 57 + } else { 58 + setFastState(target, { uri: "pending", target, cid }); 59 + enqueue({ type: "like", target, cid }); 60 + } 61 + }, [setFastState]); 62 + 63 + /** 64 + * 65 + * @deprecated dont use it yet, will cause infinite rerenders 66 + */ 67 + const backfillState = async (target: string, user: string) => { 68 + const query = constructConstellationQuery({ 69 + constellation: constellationurl, 70 + method: "/links", 71 + target, 72 + collection: "app.bsky.feed.like", 73 + path: ".subject.uri", 74 + dids: [user], 75 + }); 76 + const data = await queryClient.fetchQuery(query); 77 + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 78 + const found = likes.find((r) => r.did === user); 79 + if (found) { 80 + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 81 + const ciddata = await queryClient.fetchQuery( 82 + constructArbitraryQuery(uri) 83 + ); 84 + if (ciddata?.cid) 85 + setFastState(target, { uri, target, cid: ciddata?.cid }); 86 + } else { 87 + setFastState(target, null); 88 + } 89 + }; 90 + 91 + 92 + useEffect(() => { 93 + if (!agent?.did) return; 94 + 95 + const processQueue = async () => { 96 + if (runningRef.current || queueRef.current.length === 0) return; 97 + runningRef.current = true; 98 + 99 + while (queueRef.current.length > 0) { 100 + const mutation = queueRef.current.shift()!; 101 + try { 102 + if (mutation.type === "like") { 103 + const newRecord = { 104 + repo: agent.did!, 105 + collection: "app.bsky.feed.like", 106 + rkey: TID.next().toString(), 107 + record: { 108 + $type: "app.bsky.feed.like", 109 + subject: { uri: mutation.target, cid: mutation.cid }, 110 + createdAt: new Date().toISOString(), 111 + }, 112 + }; 113 + const response = await agent.com.atproto.repo.createRecord(newRecord); 114 + if (!response.success) throw new Error("createRecord failed"); 115 + 116 + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 117 + setFastState(mutation.target, { 118 + uri, 119 + target: mutation.target, 120 + cid: mutation.cid, 121 + }); 122 + } else if (mutation.type === "unlike") { 123 + const aturi = new AtUri(mutation.likeRecordUri); 124 + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 125 + setFastState(mutation.target, null); 126 + } 127 + } catch (err) { 128 + console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 + if (mutation.type === 'like') { 135 + setFastState(mutation.target, null); 136 + } else if (mutation.type === 'unlike') { 137 + setFastState(mutation.target, mutation.originalRecord); 138 + } 139 + } 140 + } 141 + runningRef.current = false; 142 + }; 143 + 144 + const interval = setInterval(processQueue, 1000); 145 + return () => clearInterval(interval); 146 + }, [agent, setFastState]); 147 + 148 + const value = { fastState, fastToggle, backfillState }; 149 + 150 + return ( 151 + <LikeMutationQueueContext value={value}> 152 + {children} 153 + </LikeMutationQueueContext> 154 + ); 155 + } 156 + 157 + export function useLikeMutationQueue() { 158 + const context = use(LikeMutationQueueContext); 159 + if (context === undefined) { 160 + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 161 + } 162 + return context; 163 + }
+26 -23
src/providers/UnifiedAuthProvider.tsx
··· 1 - // src/providers/UnifiedAuthProvider.tsx 2 - // Import both Agent and the (soon to be deprecated) AtpAgent 3 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 4 import { 5 type OAuthSession, ··· 7 TokenRefreshError, 8 TokenRevokedError, 9 } from "@atproto/oauth-client-browser"; 10 import React, { 11 createContext, 12 use, ··· 15 useState, 16 } from "react"; 17 18 - import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 19 20 - // Define the unified status and authentication method 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 type AuthMethod = "password" | "oauth" | null; 23 24 interface AuthContextValue { 25 - agent: Agent | null; // The agent is typed as the base class `Agent` 26 status: AuthStatus; 27 authMethod: AuthMethod; 28 loginWithPassword: ( ··· 41 }: { 42 children: React.ReactNode; 43 }) => { 44 - // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 45 const [agent, setAgent] = useState<Agent | null>(null); 46 const [status, setStatus] = useState<AuthStatus>("loading"); 47 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 48 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 49 50 - // Unified Initialization Logic 51 const initialize = useCallback(async () => { 52 - // --- 1. Try OAuth initialization first --- 53 try { 54 const oauthResult = await oauthClient.init(); 55 if (oauthResult) { 56 // /*mass comment*/ console.log("OAuth session restored."); 57 - const apiAgent = new Agent(oauthResult.session); // Standard Agent 58 setAgent(apiAgent); 59 setOauthSession(oauthResult.session); 60 setAuthMethod("oauth"); 61 setStatus("signedIn"); 62 - return; // Success 63 } 64 } catch (e) { 65 console.error("OAuth init failed, checking password session.", e); 66 } 67 68 - // --- 2. If no OAuth, try password-based session using AtpAgent --- 69 try { 70 const service = localStorage.getItem("service"); 71 const sessionString = localStorage.getItem("sess"); 72 73 if (service && sessionString) { 74 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 75 - // Use the original, working AtpAgent logic 76 const apiAgent = new AtpAgent({ service }); 77 const session: AtpSessionData = JSON.parse(sessionString); 78 await apiAgent.resumeSession(session); 79 80 // /*mass comment*/ console.log("Password-based session resumed successfully."); 81 - setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 82 setAuthMethod("password"); 83 setStatus("signedIn"); 84 - return; // Success 85 } 86 } catch (e) { 87 console.error("Failed to resume password-based session.", e); ··· 89 localStorage.removeItem("service"); 90 } 91 92 - // --- 3. If neither worked, user is signed out --- 93 // /*mass comment*/ console.log("No active session found."); 94 setStatus("signedOut"); 95 setAgent(null); 96 setAuthMethod(null); 97 - }, []); 98 99 useEffect(() => { 100 const handleOAuthSessionDeleted = ( ··· 105 setOauthSession(null); 106 setAuthMethod(null); 107 setStatus("signedOut"); 108 }; 109 110 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 113 return () => { 114 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 115 }; 116 - }, [initialize]); 117 118 - // --- Login Methods --- 119 const loginWithPassword = async ( 120 user: string, 121 password: string, ··· 125 setStatus("loading"); 126 try { 127 let sessionData: AtpSessionData | undefined; 128 - // Use the AtpAgent for its simple login and session persistence 129 const apiAgent = new AtpAgent({ 130 service, 131 persistSession: (_evt, sess) => { ··· 137 if (sessionData) { 138 localStorage.setItem("service", service); 139 localStorage.setItem("sess", JSON.stringify(sessionData)); 140 - setAgent(apiAgent); // Store the AtpAgent instance in our state 141 setAuthMethod("password"); 142 setStatus("signedIn"); 143 // /*mass comment*/ console.log("Successfully logged in with password."); 144 } else { 145 throw new Error("Session data not persisted after login."); ··· 147 } catch (e) { 148 console.error("Password login failed:", e); 149 setStatus("signedOut"); 150 throw e; 151 } 152 }; ··· 161 } 162 }, [status]); 163 164 - // --- Unified Logout --- 165 const logout = useCallback(async () => { 166 if (status !== "signedIn" || !agent) return; 167 setStatus("loading"); ··· 173 } else if (authMethod === "password") { 174 localStorage.removeItem("service"); 175 localStorage.removeItem("sess"); 176 - // AtpAgent has its own logout methods 177 await (agent as AtpAgent).com.atproto.server.deleteSession(); 178 // /*mass comment*/ console.log("Password-based session deleted."); 179 } ··· 184 setAuthMethod(null); 185 setOauthSession(null); 186 setStatus("signedOut"); 187 } 188 - }, [status, authMethod, agent, oauthSession]); 189 190 return ( 191 <AuthContext
··· 1 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 2 import { 3 type OAuthSession, ··· 5 TokenRefreshError, 6 TokenRevokedError, 7 } from "@atproto/oauth-client-browser"; 8 + import { useAtom } from "jotai"; 9 import React, { 10 createContext, 11 use, ··· 14 useState, 15 } from "react"; 16 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + 19 + import { oauthClient } from "../utils/oauthClient"; 20 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 type AuthMethod = "password" | "oauth" | null; 23 24 interface AuthContextValue { 25 + agent: Agent | null; 26 status: AuthStatus; 27 authMethod: AuthMethod; 28 loginWithPassword: ( ··· 41 }: { 42 children: React.ReactNode; 43 }) => { 44 const [agent, setAgent] = useState<Agent | null>(null); 45 const [status, setStatus] = useState<AuthStatus>("loading"); 46 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 47 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 49 50 const initialize = useCallback(async () => { 51 try { 52 const oauthResult = await oauthClient.init(); 53 if (oauthResult) { 54 // /*mass comment*/ console.log("OAuth session restored."); 55 + const apiAgent = new Agent(oauthResult.session); 56 setAgent(apiAgent); 57 setOauthSession(oauthResult.session); 58 setAuthMethod("oauth"); 59 setStatus("signedIn"); 60 + setQuickAuth(apiAgent?.did || null); 61 + return; 62 } 63 } catch (e) { 64 console.error("OAuth init failed, checking password session.", e); 65 + if (!quickAuth) { 66 + // quickAuth restoration. if last used method is oauth we immediately call for oauth redo 67 + // (and set a persistent atom somewhere to not retry again if it failed) 68 + } 69 } 70 71 try { 72 const service = localStorage.getItem("service"); 73 const sessionString = localStorage.getItem("sess"); 74 75 if (service && sessionString) { 76 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 77 const apiAgent = new AtpAgent({ service }); 78 const session: AtpSessionData = JSON.parse(sessionString); 79 await apiAgent.resumeSession(session); 80 81 // /*mass comment*/ console.log("Password-based session resumed successfully."); 82 + setAgent(apiAgent); 83 setAuthMethod("password"); 84 setStatus("signedIn"); 85 + setQuickAuth(apiAgent?.did || null); 86 + return; 87 } 88 } catch (e) { 89 console.error("Failed to resume password-based session.", e); ··· 91 localStorage.removeItem("service"); 92 } 93 94 // /*mass comment*/ console.log("No active session found."); 95 setStatus("signedOut"); 96 setAgent(null); 97 setAuthMethod(null); 98 + // do we want to null it here? 99 + setQuickAuth(null); 100 + }, [quickAuth, setQuickAuth]); 101 102 useEffect(() => { 103 const handleOAuthSessionDeleted = ( ··· 108 setOauthSession(null); 109 setAuthMethod(null); 110 setStatus("signedOut"); 111 + setQuickAuth(null); 112 }; 113 114 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 117 return () => { 118 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 119 }; 120 + }, [initialize, setQuickAuth]); 121 122 const loginWithPassword = async ( 123 user: string, 124 password: string, ··· 128 setStatus("loading"); 129 try { 130 let sessionData: AtpSessionData | undefined; 131 const apiAgent = new AtpAgent({ 132 service, 133 persistSession: (_evt, sess) => { ··· 139 if (sessionData) { 140 localStorage.setItem("service", service); 141 localStorage.setItem("sess", JSON.stringify(sessionData)); 142 + setAgent(apiAgent); 143 setAuthMethod("password"); 144 setStatus("signedIn"); 145 + setQuickAuth(apiAgent?.did || null); 146 // /*mass comment*/ console.log("Successfully logged in with password."); 147 } else { 148 throw new Error("Session data not persisted after login."); ··· 150 } catch (e) { 151 console.error("Password login failed:", e); 152 setStatus("signedOut"); 153 + setQuickAuth(null); 154 throw e; 155 } 156 }; ··· 165 } 166 }, [status]); 167 168 const logout = useCallback(async () => { 169 if (status !== "signedIn" || !agent) return; 170 setStatus("loading"); ··· 176 } else if (authMethod === "password") { 177 localStorage.removeItem("service"); 178 localStorage.removeItem("sess"); 179 await (agent as AtpAgent).com.atproto.server.deleteSession(); 180 // /*mass comment*/ console.log("Password-based session deleted."); 181 } ··· 186 setAuthMethod(null); 187 setOauthSession(null); 188 setStatus("signedOut"); 189 + setQuickAuth(null); 190 } 191 + }, [status, agent, authMethod, oauthSession, setQuickAuth]); 192 193 return ( 194 <AuthContext
+150
src/routeTree.gen.ts
··· 12 import { Route as SettingsRouteImport } from './routes/settings' 13 import { Route as SearchRouteImport } from './routes/search' 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 import { Route as FeedsRouteImport } from './routes/feeds' 16 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 import { Route as IndexRouteImport } from './routes/index' 18 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 21 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 24 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 25 26 const SettingsRoute = SettingsRouteImport.update({ ··· 38 path: '/notifications', 39 getParentRoute: () => rootRouteImport, 40 } as any) 41 const FeedsRoute = FeedsRouteImport.update({ 42 id: '/feeds', 43 path: '/feeds', ··· 67 path: '/profile/$did/', 68 getParentRoute: () => rootRouteImport, 69 } as any) 70 const PathlessLayoutNestedLayoutRouteBRoute = 71 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 72 id: '/route-b', ··· 84 path: '/profile/$did/post/$rkey', 85 getParentRoute: () => rootRouteImport, 86 } as any) 87 const ProfileDidPostRkeyImageIRoute = 88 ProfileDidPostRkeyImageIRouteImport.update({ 89 id: '/image/$i', ··· 94 export interface FileRoutesByFullPath { 95 '/': typeof IndexRoute 96 '/feeds': typeof FeedsRoute 97 '/notifications': typeof NotificationsRoute 98 '/search': typeof SearchRoute 99 '/settings': typeof SettingsRoute 100 '/callback': typeof CallbackIndexRoute 101 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 102 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 103 '/profile/$did': typeof ProfileDidIndexRoute 104 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 105 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 106 } 107 export interface FileRoutesByTo { 108 '/': typeof IndexRoute 109 '/feeds': typeof FeedsRoute 110 '/notifications': typeof NotificationsRoute 111 '/search': typeof SearchRoute 112 '/settings': typeof SettingsRoute 113 '/callback': typeof CallbackIndexRoute 114 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 115 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 116 '/profile/$did': typeof ProfileDidIndexRoute 117 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 118 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 119 } 120 export interface FileRoutesById { ··· 122 '/': typeof IndexRoute 123 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 124 '/feeds': typeof FeedsRoute 125 '/notifications': typeof NotificationsRoute 126 '/search': typeof SearchRoute 127 '/settings': typeof SettingsRoute ··· 129 '/callback/': typeof CallbackIndexRoute 130 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 131 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 132 '/profile/$did/': typeof ProfileDidIndexRoute 133 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 134 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 135 } 136 export interface FileRouteTypes { ··· 138 fullPaths: 139 | '/' 140 | '/feeds' 141 | '/notifications' 142 | '/search' 143 | '/settings' 144 | '/callback' 145 | '/route-a' 146 | '/route-b' 147 | '/profile/$did' 148 | '/profile/$did/post/$rkey' 149 | '/profile/$did/post/$rkey/image/$i' 150 fileRoutesByTo: FileRoutesByTo 151 to: 152 | '/' 153 | '/feeds' 154 | '/notifications' 155 | '/search' 156 | '/settings' 157 | '/callback' 158 | '/route-a' 159 | '/route-b' 160 | '/profile/$did' 161 | '/profile/$did/post/$rkey' 162 | '/profile/$did/post/$rkey/image/$i' 163 id: 164 | '__root__' 165 | '/' 166 | '/_pathlessLayout' 167 | '/feeds' 168 | '/notifications' 169 | '/search' 170 | '/settings' ··· 172 | '/callback/' 173 | '/_pathlessLayout/_nested-layout/route-a' 174 | '/_pathlessLayout/_nested-layout/route-b' 175 | '/profile/$did/' 176 | '/profile/$did/post/$rkey' 177 | '/profile/$did/post/$rkey/image/$i' 178 fileRoutesById: FileRoutesById 179 } ··· 181 IndexRoute: typeof IndexRoute 182 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 183 FeedsRoute: typeof FeedsRoute 184 NotificationsRoute: typeof NotificationsRoute 185 SearchRoute: typeof SearchRoute 186 SettingsRoute: typeof SettingsRoute 187 CallbackIndexRoute: typeof CallbackIndexRoute 188 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 189 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 190 } 191 ··· 212 preLoaderRoute: typeof NotificationsRouteImport 213 parentRoute: typeof rootRouteImport 214 } 215 '/feeds': { 216 id: '/feeds' 217 path: '/feeds' ··· 254 preLoaderRoute: typeof ProfileDidIndexRouteImport 255 parentRoute: typeof rootRouteImport 256 } 257 '/_pathlessLayout/_nested-layout/route-b': { 258 id: '/_pathlessLayout/_nested-layout/route-b' 259 path: '/route-b' ··· 275 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 276 parentRoute: typeof rootRouteImport 277 } 278 '/profile/$did/post/$rkey/image/$i': { 279 id: '/profile/$did/post/$rkey/image/$i' 280 path: '/image/$i' ··· 316 ) 317 318 interface ProfileDidPostRkeyRouteChildren { 319 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 320 } 321 322 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 323 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 324 } 325 ··· 330 IndexRoute: IndexRoute, 331 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 332 FeedsRoute: FeedsRoute, 333 NotificationsRoute: NotificationsRoute, 334 SearchRoute: SearchRoute, 335 SettingsRoute: SettingsRoute, 336 CallbackIndexRoute: CallbackIndexRoute, 337 ProfileDidIndexRoute: ProfileDidIndexRoute, 338 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 339 } 340 export const routeTree = rootRouteImport
··· 12 import { Route as SettingsRouteImport } from './routes/settings' 13 import { Route as SearchRouteImport } from './routes/search' 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 16 import { Route as FeedsRouteImport } from './routes/feeds' 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 18 import { Route as IndexRouteImport } from './routes/index' 19 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 20 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 21 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 22 + import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows' 23 + import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers' 24 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 25 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 26 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' 27 + import { Route as ProfileDidFeedRkeyRouteImport } from './routes/profile.$did/feed.$rkey' 28 + import { Route as ProfileDidPostRkeyRepostedByRouteImport } from './routes/profile.$did/post.$rkey.reposted-by' 29 + import { Route as ProfileDidPostRkeyQuotesRouteImport } from './routes/profile.$did/post.$rkey.quotes' 30 + import { Route as ProfileDidPostRkeyLikedByRouteImport } from './routes/profile.$did/post.$rkey.liked-by' 31 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 32 33 const SettingsRoute = SettingsRouteImport.update({ ··· 45 path: '/notifications', 46 getParentRoute: () => rootRouteImport, 47 } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 51 + getParentRoute: () => rootRouteImport, 52 + } as any) 53 const FeedsRoute = FeedsRouteImport.update({ 54 id: '/feeds', 55 path: '/feeds', ··· 79 path: '/profile/$did/', 80 getParentRoute: () => rootRouteImport, 81 } as any) 82 + const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({ 83 + id: '/profile/$did/follows', 84 + path: '/profile/$did/follows', 85 + getParentRoute: () => rootRouteImport, 86 + } as any) 87 + const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({ 88 + id: '/profile/$did/followers', 89 + path: '/profile/$did/followers', 90 + getParentRoute: () => rootRouteImport, 91 + } as any) 92 const PathlessLayoutNestedLayoutRouteBRoute = 93 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 94 id: '/route-b', ··· 106 path: '/profile/$did/post/$rkey', 107 getParentRoute: () => rootRouteImport, 108 } as any) 109 + const ProfileDidFeedRkeyRoute = ProfileDidFeedRkeyRouteImport.update({ 110 + id: '/profile/$did/feed/$rkey', 111 + path: '/profile/$did/feed/$rkey', 112 + getParentRoute: () => rootRouteImport, 113 + } as any) 114 + const ProfileDidPostRkeyRepostedByRoute = 115 + ProfileDidPostRkeyRepostedByRouteImport.update({ 116 + id: '/reposted-by', 117 + path: '/reposted-by', 118 + getParentRoute: () => ProfileDidPostRkeyRoute, 119 + } as any) 120 + const ProfileDidPostRkeyQuotesRoute = 121 + ProfileDidPostRkeyQuotesRouteImport.update({ 122 + id: '/quotes', 123 + path: '/quotes', 124 + getParentRoute: () => ProfileDidPostRkeyRoute, 125 + } as any) 126 + const ProfileDidPostRkeyLikedByRoute = 127 + ProfileDidPostRkeyLikedByRouteImport.update({ 128 + id: '/liked-by', 129 + path: '/liked-by', 130 + getParentRoute: () => ProfileDidPostRkeyRoute, 131 + } as any) 132 const ProfileDidPostRkeyImageIRoute = 133 ProfileDidPostRkeyImageIRouteImport.update({ 134 id: '/image/$i', ··· 139 export interface FileRoutesByFullPath { 140 '/': typeof IndexRoute 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 143 '/notifications': typeof NotificationsRoute 144 '/search': typeof SearchRoute 145 '/settings': typeof SettingsRoute 146 '/callback': typeof CallbackIndexRoute 147 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 148 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 149 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 150 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 151 '/profile/$did': typeof ProfileDidIndexRoute 152 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 153 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 154 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 155 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 156 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 157 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 158 } 159 export interface FileRoutesByTo { 160 '/': typeof IndexRoute 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 163 '/notifications': typeof NotificationsRoute 164 '/search': typeof SearchRoute 165 '/settings': typeof SettingsRoute 166 '/callback': typeof CallbackIndexRoute 167 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 168 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 169 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 170 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 171 '/profile/$did': typeof ProfileDidIndexRoute 172 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 173 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 174 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 175 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 176 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 177 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 178 } 179 export interface FileRoutesById { ··· 181 '/': typeof IndexRoute 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 185 '/notifications': typeof NotificationsRoute 186 '/search': typeof SearchRoute 187 '/settings': typeof SettingsRoute ··· 189 '/callback/': typeof CallbackIndexRoute 190 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 191 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 192 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 193 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 194 '/profile/$did/': typeof ProfileDidIndexRoute 195 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 196 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren 197 + '/profile/$did/post/$rkey/liked-by': typeof ProfileDidPostRkeyLikedByRoute 198 + '/profile/$did/post/$rkey/quotes': typeof ProfileDidPostRkeyQuotesRoute 199 + '/profile/$did/post/$rkey/reposted-by': typeof ProfileDidPostRkeyRepostedByRoute 200 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 201 } 202 export interface FileRouteTypes { ··· 204 fullPaths: 205 | '/' 206 | '/feeds' 207 + | '/moderation' 208 | '/notifications' 209 | '/search' 210 | '/settings' 211 | '/callback' 212 | '/route-a' 213 | '/route-b' 214 + | '/profile/$did/followers' 215 + | '/profile/$did/follows' 216 | '/profile/$did' 217 + | '/profile/$did/feed/$rkey' 218 | '/profile/$did/post/$rkey' 219 + | '/profile/$did/post/$rkey/liked-by' 220 + | '/profile/$did/post/$rkey/quotes' 221 + | '/profile/$did/post/$rkey/reposted-by' 222 | '/profile/$did/post/$rkey/image/$i' 223 fileRoutesByTo: FileRoutesByTo 224 to: 225 | '/' 226 | '/feeds' 227 + | '/moderation' 228 | '/notifications' 229 | '/search' 230 | '/settings' 231 | '/callback' 232 | '/route-a' 233 | '/route-b' 234 + | '/profile/$did/followers' 235 + | '/profile/$did/follows' 236 | '/profile/$did' 237 + | '/profile/$did/feed/$rkey' 238 | '/profile/$did/post/$rkey' 239 + | '/profile/$did/post/$rkey/liked-by' 240 + | '/profile/$did/post/$rkey/quotes' 241 + | '/profile/$did/post/$rkey/reposted-by' 242 | '/profile/$did/post/$rkey/image/$i' 243 id: 244 | '__root__' 245 | '/' 246 | '/_pathlessLayout' 247 | '/feeds' 248 + | '/moderation' 249 | '/notifications' 250 | '/search' 251 | '/settings' ··· 253 | '/callback/' 254 | '/_pathlessLayout/_nested-layout/route-a' 255 | '/_pathlessLayout/_nested-layout/route-b' 256 + | '/profile/$did/followers' 257 + | '/profile/$did/follows' 258 | '/profile/$did/' 259 + | '/profile/$did/feed/$rkey' 260 | '/profile/$did/post/$rkey' 261 + | '/profile/$did/post/$rkey/liked-by' 262 + | '/profile/$did/post/$rkey/quotes' 263 + | '/profile/$did/post/$rkey/reposted-by' 264 | '/profile/$did/post/$rkey/image/$i' 265 fileRoutesById: FileRoutesById 266 } ··· 268 IndexRoute: typeof IndexRoute 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 272 NotificationsRoute: typeof NotificationsRoute 273 SearchRoute: typeof SearchRoute 274 SettingsRoute: typeof SettingsRoute 275 CallbackIndexRoute: typeof CallbackIndexRoute 276 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 277 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 278 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 279 + ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 280 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 281 } 282 ··· 303 preLoaderRoute: typeof NotificationsRouteImport 304 parentRoute: typeof rootRouteImport 305 } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 311 + parentRoute: typeof rootRouteImport 312 + } 313 '/feeds': { 314 id: '/feeds' 315 path: '/feeds' ··· 352 preLoaderRoute: typeof ProfileDidIndexRouteImport 353 parentRoute: typeof rootRouteImport 354 } 355 + '/profile/$did/follows': { 356 + id: '/profile/$did/follows' 357 + path: '/profile/$did/follows' 358 + fullPath: '/profile/$did/follows' 359 + preLoaderRoute: typeof ProfileDidFollowsRouteImport 360 + parentRoute: typeof rootRouteImport 361 + } 362 + '/profile/$did/followers': { 363 + id: '/profile/$did/followers' 364 + path: '/profile/$did/followers' 365 + fullPath: '/profile/$did/followers' 366 + preLoaderRoute: typeof ProfileDidFollowersRouteImport 367 + parentRoute: typeof rootRouteImport 368 + } 369 '/_pathlessLayout/_nested-layout/route-b': { 370 id: '/_pathlessLayout/_nested-layout/route-b' 371 path: '/route-b' ··· 387 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 388 parentRoute: typeof rootRouteImport 389 } 390 + '/profile/$did/feed/$rkey': { 391 + id: '/profile/$did/feed/$rkey' 392 + path: '/profile/$did/feed/$rkey' 393 + fullPath: '/profile/$did/feed/$rkey' 394 + preLoaderRoute: typeof ProfileDidFeedRkeyRouteImport 395 + parentRoute: typeof rootRouteImport 396 + } 397 + '/profile/$did/post/$rkey/reposted-by': { 398 + id: '/profile/$did/post/$rkey/reposted-by' 399 + path: '/reposted-by' 400 + fullPath: '/profile/$did/post/$rkey/reposted-by' 401 + preLoaderRoute: typeof ProfileDidPostRkeyRepostedByRouteImport 402 + parentRoute: typeof ProfileDidPostRkeyRoute 403 + } 404 + '/profile/$did/post/$rkey/quotes': { 405 + id: '/profile/$did/post/$rkey/quotes' 406 + path: '/quotes' 407 + fullPath: '/profile/$did/post/$rkey/quotes' 408 + preLoaderRoute: typeof ProfileDidPostRkeyQuotesRouteImport 409 + parentRoute: typeof ProfileDidPostRkeyRoute 410 + } 411 + '/profile/$did/post/$rkey/liked-by': { 412 + id: '/profile/$did/post/$rkey/liked-by' 413 + path: '/liked-by' 414 + fullPath: '/profile/$did/post/$rkey/liked-by' 415 + preLoaderRoute: typeof ProfileDidPostRkeyLikedByRouteImport 416 + parentRoute: typeof ProfileDidPostRkeyRoute 417 + } 418 '/profile/$did/post/$rkey/image/$i': { 419 id: '/profile/$did/post/$rkey/image/$i' 420 path: '/image/$i' ··· 456 ) 457 458 interface ProfileDidPostRkeyRouteChildren { 459 + ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute 460 + ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute 461 + ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute 462 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 463 } 464 465 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 466 + ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute, 467 + ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute, 468 + ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute, 469 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 470 } 471 ··· 476 IndexRoute: IndexRoute, 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 480 NotificationsRoute: NotificationsRoute, 481 SearchRoute: SearchRoute, 482 SettingsRoute: SettingsRoute, 483 CallbackIndexRoute: CallbackIndexRoute, 484 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 485 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 486 ProfileDidIndexRoute: ProfileDidIndexRoute, 487 + ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 488 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 489 } 490 export const routeTree = rootRouteImport
+193 -27
src/routes/__root.tsx
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 19 import { Composer } from "~/components/Composer"; 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 import Login from "~/components/Login"; 22 import { NotFound } from "~/components/NotFound"; 23 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 24 - import { composerAtom } from "~/utils/atoms"; 25 import { seo } from "~/utils/seo"; 26 27 export const Route = createRootRouteWithContext<{ ··· 77 function RootComponent() { 78 return ( 79 <UnifiedAuthProvider> 80 - <RootDocument> 81 - <KeepAliveProvider> 82 - <KeepAliveOutlet /> 83 - </KeepAliveProvider> 84 - </RootDocument> 85 </UnifiedAuthProvider> 86 ); 87 } 88 89 function RootDocument({ children }: { children: React.ReactNode }) { 90 const location = useLocation(); 91 const navigate = useNavigate(); 92 const { agent } = useAuth(); ··· 100 const isSettings = location.pathname.startsWith("/settings"); 101 const isSearch = location.pathname.startsWith("/search"); 102 const isFeeds = location.pathname.startsWith("/feeds"); 103 104 const locationEnum: 105 | "feeds" ··· 107 | "settings" 108 | "notifications" 109 | "profile" 110 | "home" = isFeeds 111 ? "feeds" 112 : isSearch ··· 117 ? "notifications" 118 : isProfile 119 ? "profile" 120 - : "home"; 121 122 const [, setComposerPost] = useAtom(composerAtom); 123 ··· 128 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 129 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 130 <div className="flex items-center gap-3 mb-4"> 131 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 132 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 133 Red Dwarf{" "} 134 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 151 text="Home" 152 /> 153 154 <MaterialNavItem 155 InactiveIcon={ 156 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> ··· 180 text="Feeds" 181 /> 182 <MaterialNavItem 183 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 184 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 185 - active={locationEnum === "search"} 186 onClickCallbback={() => 187 navigate({ 188 - to: "/search", 189 //params: { did: agent.assertDid }, 190 }) 191 } 192 - text="Search" 193 /> 194 <MaterialNavItem 195 InactiveIcon={ ··· 229 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 230 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 231 //active={true} 232 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 233 text="Post" 234 /> 235 </div> ··· 367 368 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 369 <div className="flex items-center gap-3 mb-4"> 370 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 371 </div> 372 <MaterialNavItem 373 small ··· 387 388 <MaterialNavItem 389 small 390 InactiveIcon={ 391 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 392 } ··· 417 /> 418 <MaterialNavItem 419 small 420 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 421 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 422 - active={locationEnum === "search"} 423 onClickCallbback={() => 424 navigate({ 425 - to: "/search", 426 //params: { did: agent.assertDid }, 427 }) 428 } 429 - text="Search" 430 /> 431 <MaterialNavItem 432 small ··· 469 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 470 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 471 //active={true} 472 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 473 text="Post" 474 /> 475 </div> ··· 479 <button 480 className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all" 481 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 482 - onClick={() => setComposerPost({ kind: 'root' })} 483 type="button" 484 aria-label="Create Post" 485 > ··· 496 </main> 497 498 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 499 <Login /> 500 501 <div className="flex-1"></div> 502 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 503 - Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation) 504 </p> 505 </aside> 506 </div> ··· 549 //params: { did: agent.assertDid }, 550 }) 551 } 552 - text="Search" 553 /> 554 {/* <Link 555 to="/search" ··· 647 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 648 } 649 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 650 - active={locationEnum === "settings"} 651 onClickCallbback={() => 652 navigate({ 653 to: "/settings", ··· 676 ) : ( 677 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 678 <div className="flex items-center gap-2"> 679 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 680 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 681 Red Dwarf{" "} 682 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 696 ); 697 } 698 699 - function MaterialNavItem({ 700 InactiveIcon, 701 ActiveIcon, 702 text,
··· 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 import { useAtom } from "jotai"; 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 20 21 import { Composer } from "~/components/Composer"; 22 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 23 + import { Import } from "~/components/Import"; 24 import Login from "~/components/Login"; 25 import { NotFound } from "~/components/NotFound"; 26 + import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 29 + import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 30 import { seo } from "~/utils/seo"; 31 32 export const Route = createRootRouteWithContext<{ ··· 82 function RootComponent() { 83 return ( 84 <UnifiedAuthProvider> 85 + <LikeMutationQueueProvider> 86 + <RootDocument> 87 + <KeepAliveProvider> 88 + <AppToaster /> 89 + <KeepAliveOutlet /> 90 + </KeepAliveProvider> 91 + </RootDocument> 92 + </LikeMutationQueueProvider> 93 </UnifiedAuthProvider> 94 ); 95 } 96 97 + export function AppToaster() { 98 + return ( 99 + <Toaster 100 + position="bottom-center" 101 + toastOptions={{ 102 + duration: 4000, 103 + }} 104 + /> 105 + ); 106 + } 107 + 108 + export function renderSnack({ 109 + title, 110 + description, 111 + button, 112 + }: Omit<ToastProps, "id">) { 113 + return sonnerToast.custom((id) => ( 114 + <Snack 115 + id={id} 116 + title={title} 117 + description={description} 118 + button={ 119 + button?.label 120 + ? { 121 + label: button?.label, 122 + onClick: () => { 123 + button?.onClick?.(); 124 + }, 125 + } 126 + : undefined 127 + } 128 + /> 129 + )); 130 + } 131 + 132 + function Snack(props: ToastProps) { 133 + const { title, description, button, id } = props; 134 + 135 + return ( 136 + <div 137 + role="status" 138 + aria-live="polite" 139 + className=" 140 + w-full md:max-w-[520px] 141 + flex items-center justify-between 142 + rounded-md 143 + px-4 py-3 144 + shadow-sm 145 + dark:bg-gray-300 dark:text-gray-900 146 + bg-gray-700 text-gray-100 147 + ring-1 dark:ring-gray-200 ring-gray-800 148 + " 149 + > 150 + <div className="flex-1 min-w-0"> 151 + <p className="text-sm font-medium truncate">{title}</p> 152 + {description ? ( 153 + <p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate"> 154 + {description} 155 + </p> 156 + ) : null} 157 + </div> 158 + 159 + {button ? ( 160 + <div className="ml-4 flex-shrink-0"> 161 + <button 162 + className=" 163 + text-sm font-medium 164 + px-3 py-1 rounded-md 165 + bg-gray-200 text-gray-900 166 + hover:bg-gray-300 167 + dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 168 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700 169 + " 170 + onClick={() => { 171 + button.onClick(); 172 + sonnerToast.dismiss(id); 173 + }} 174 + > 175 + {button.label} 176 + </button> 177 + </div> 178 + ) : null} 179 + <button className=" ml-4" 180 + onClick={() => { 181 + sonnerToast.dismiss(id); 182 + }} 183 + > 184 + <IconMdiClose /> 185 + </button> 186 + </div> 187 + ); 188 + } 189 + 190 + /* Types */ 191 + interface ToastProps { 192 + id: string | number; 193 + title: string; 194 + description?: string; 195 + button?: { 196 + label: string; 197 + onClick: () => void; 198 + }; 199 + } 200 + 201 function RootDocument({ children }: { children: React.ReactNode }) { 202 + useAtomCssVar(hueAtom, "--tw-gray-hue"); 203 const location = useLocation(); 204 const navigate = useNavigate(); 205 const { agent } = useAuth(); ··· 213 const isSettings = location.pathname.startsWith("/settings"); 214 const isSearch = location.pathname.startsWith("/search"); 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 217 218 const locationEnum: 219 | "feeds" ··· 221 | "settings" 222 | "notifications" 223 | "profile" 224 + | "moderation" 225 | "home" = isFeeds 226 ? "feeds" 227 : isSearch ··· 232 ? "notifications" 233 : isProfile 234 ? "profile" 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 238 239 const [, setComposerPost] = useAtom(composerAtom); 240 ··· 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 246 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 247 <div className="flex items-center gap-3 mb-4"> 248 + <FluentEmojiHighContrastGlowingStar 249 + className="h-8 w-8" 250 + style={{ 251 + color: 252 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 253 + }} 254 + /> 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 256 Red Dwarf{" "} 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 274 text="Home" 275 /> 276 277 + <MaterialNavItem 278 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 279 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 280 + active={locationEnum === "search"} 281 + onClickCallbback={() => 282 + navigate({ 283 + to: "/search", 284 + //params: { did: agent.assertDid }, 285 + }) 286 + } 287 + text="Explore" 288 + /> 289 <MaterialNavItem 290 InactiveIcon={ 291 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> ··· 315 text="Feeds" 316 /> 317 <MaterialNavItem 318 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 319 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 320 + active={locationEnum === "moderation"} 321 onClickCallbback={() => 322 navigate({ 323 + to: "/moderation", 324 //params: { did: agent.assertDid }, 325 }) 326 } 327 + text="Moderation" 328 /> 329 <MaterialNavItem 330 InactiveIcon={ ··· 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 366 //active={true} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 368 text="Post" 369 /> 370 </div> ··· 502 503 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 504 <div className="flex items-center gap-3 mb-4"> 505 + <FluentEmojiHighContrastGlowingStar 506 + className="h-8 w-8" 507 + style={{ 508 + color: 509 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 510 + }} 511 + /> 512 </div> 513 <MaterialNavItem 514 small ··· 528 529 <MaterialNavItem 530 small 531 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 532 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 533 + active={locationEnum === "search"} 534 + onClickCallbback={() => 535 + navigate({ 536 + to: "/search", 537 + //params: { did: agent.assertDid }, 538 + }) 539 + } 540 + text="Explore" 541 + /> 542 + <MaterialNavItem 543 + small 544 InactiveIcon={ 545 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 546 } ··· 571 /> 572 <MaterialNavItem 573 small 574 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 575 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 576 + active={locationEnum === "moderation"} 577 onClickCallbback={() => 578 navigate({ 579 + to: "/moderation", 580 //params: { did: agent.assertDid }, 581 }) 582 } 583 + text="Moderation" 584 /> 585 <MaterialNavItem 586 small ··· 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 625 //active={true} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 627 text="Post" 628 /> 629 </div> ··· 633 <button 634 className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all" 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 636 + onClick={() => setComposerPost({ kind: "root" })} 637 type="button" 638 aria-label="Create Post" 639 > ··· 650 </main> 651 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 656 <Login /> 657 658 <div className="flex-1"></div> 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 660 + Red Dwarf is a Bluesky client that does not rely on any Bluesky API 661 + App Servers. Instead, it uses Microcosm to fetch records directly 662 + from each users' PDS (via Slingshot) and connect them using 663 + backlinks (via Constellation) 664 </p> 665 </aside> 666 </div> ··· 709 //params: { did: agent.assertDid }, 710 }) 711 } 712 + text="Explore" 713 /> 714 {/* <Link 715 to="/search" ··· 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 808 } 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 811 onClickCallbback={() => 812 navigate({ 813 to: "/settings", ··· 836 ) : ( 837 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 838 <div className="flex items-center gap-2"> 839 + <FluentEmojiHighContrastGlowingStar 840 + className="h-6 w-6" 841 + style={{ 842 + color: 843 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 844 + }} 845 + /> 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 847 Red Dwarf{" "} 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 862 ); 863 } 864 865 + export function MaterialNavItem({ 866 InactiveIcon, 867 ActiveIcon, 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 export const Route = createFileRoute("/feeds")({ 4 component: Feeds, 5 }); 6 7 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 9 }
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 + import { Header } from "~/components/Header"; 4 + 5 export const Route = createFileRoute("/feeds")({ 6 component: Feeds, 7 }); 8 9 export function Feeds() { 10 + return ( 11 + <div className=""> 12 + <Header 13 + title={`Feeds`} 14 + backButtonCallback={() => { 15 + if (window.history.length > 1) { 16 + window.history.back(); 17 + } else { 18 + window.location.assign("/"); 19 + } 20 + }} 21 + bottomBorderDisabled={true} 22 + /> 23 + Feeds page (coming soon) 24 + </div> 25 + ); 26 }
+84 -73
src/routes/index.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom } from "jotai"; 3 import * as React from "react"; 4 - import { useEffect, useLayoutEffect } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 import { 10 - agentAtom, 11 - authedAtom, 12 feedScrollPositionsAtom, 13 isAtTopAtom, 14 selectedFeedUriAtom, 15 - store, 16 } from "~/utils/atoms"; 17 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 18 import { ··· 107 } = useAuth(); 108 const authed = !!agent?.did; 109 110 - useEffect(() => { 111 - if (agent?.did) { 112 - store.set(authedAtom, true); 113 - } else { 114 - store.set(authedAtom, false); 115 - } 116 - }, [status, agent, authed]); 117 - useEffect(() => { 118 - if (agent) { 119 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment 120 - // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 121 - store.set(agentAtom, agent); 122 - } else { 123 - store.set(agentAtom, null); 124 - } 125 - }, [status, agent, authed]); 126 127 //const { get, set } = usePersistentStore(); 128 // const [feed, setFeed] = React.useState<any[]>([]); ··· 162 163 // const savedFeeds = savedFeedsPref?.items || []; 164 165 - const identityresultmaybe = useQueryIdentity(agent?.did); 166 const identity = identityresultmaybe?.data; 167 168 const prefsresultmaybe = useQueryPreferences({ 169 - agent: agent ?? undefined, 170 - pdsUrl: identity?.pds, 171 }); 172 const prefs = prefsresultmaybe?.data; 173 ··· 178 return savedFeedsPref?.items || []; 179 }, [prefs]); 180 181 - const [persistentSelectedFeed, setPersistentSelectedFeed] = 182 - useAtom(selectedFeedUriAtom); // React.useState<string | null>(null); 183 - const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( 184 - persistentSelectedFeed 185 - ); // React.useState<string | null>(null); 186 const selectedFeed = agent?.did 187 ? persistentSelectedFeed 188 : unauthedSelectedFeed; ··· 306 }, [scrollPositions]); 307 308 useLayoutEffect(() => { 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 - }, [selectedFeed]); 314 315 useLayoutEffect(() => { 316 - if (!selectedFeed) return; 317 318 const handleScroll = () => { 319 scrollPositionsRef.current = { ··· 328 329 setScrollPositions(scrollPositionsRef.current); 330 }; 331 - }, [selectedFeed, setScrollPositions]); 332 333 - const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); 334 - const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; 335 336 // const { 337 // data: feedData, ··· 347 348 // const feed = feedData?.feed || []; 349 350 - const isReadyForAuthedFeed = 351 - authed && agent && identity?.pds && feedServiceDid; 352 - const isReadyForUnauthedFeed = !authed && selectedFeed; 353 354 355 const [isAtTop] = useAtom(isAtTopAtom); ··· 358 <div 359 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 360 > 361 - {savedFeeds.length > 0 ? ( 362 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 363 - {savedFeeds.map((item: any, idx: number) => { 364 - const label = item.value.split("/").pop() || item.value; 365 - const isActive = selectedFeed === item.value; 366 - return ( 367 - <button 368 - key={item.value || idx} 369 - className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 370 - isActive 371 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 372 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 373 - // ? "bg-gray-500 text-white" 374 - // : item.pinned 375 - // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 376 - // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 377 - }`} 378 - onClick={() => setSelectedFeed(item.value)} 379 - title={item.value} 380 - > 381 - {label} 382 - {item.pinned && ( 383 - <span 384 - className={`ml-1 text-xs ${ 385 - isActive 386 - ? "text-gray-900 dark:text-gray-100" 387 - : "text-gray-600 dark:text-gray-400" 388 - }`} 389 - > 390 - โ˜… 391 - </span> 392 - )} 393 - </button> 394 - ); 395 - })} 396 </div> 397 ) : ( 398 // <span className="text-xl font-bold ml-2">Home</span> ··· 410 /> 411 ))} */} 412 413 - {authed && (!identity?.pds || !feedServiceDid) && ( 414 <div className="p-4 text-center text-gray-500"> 415 Preparing your feed... 416 </div> 417 )} 418 419 - {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 420 <InfiniteCustomFeed 421 feedUri={selectedFeed!} 422 pdsUrl={identity?.pds} 423 feedServiceDid={feedServiceDid} 424 /> 425 ) : ( 426 <div className="p-4 text-center text-gray-500"> 427 - Select a feed to get started. 428 </div> 429 )} 430 {/* {false && restoringScrollPosition && ( ··· 435 </div> 436 ); 437 } 438 // not even used lmaooo 439 440 // export async function cachedResolveDIDWEBDOC({
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom } from "jotai"; 3 import * as React from "react"; 4 + import { useLayoutEffect, useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 import { 10 feedScrollPositionsAtom, 11 isAtTopAtom, 12 + quickAuthAtom, 13 selectedFeedUriAtom, 14 } from "~/utils/atoms"; 15 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 16 import { ··· 105 } = useAuth(); 106 const authed = !!agent?.did; 107 108 + // i dont remember why this is even here 109 + // useEffect(() => { 110 + // if (agent?.did) { 111 + // store.set(authedAtom, true); 112 + // } else { 113 + // store.set(authedAtom, false); 114 + // } 115 + // }, [status, agent, authed]); 116 + // useEffect(() => { 117 + // if (agent) { 118 + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 119 + // // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 120 + // store.set(agentAtom, agent); 121 + // } else { 122 + // store.set(agentAtom, null); 123 + // } 124 + // }, [status, agent, authed]); 125 126 //const { get, set } = usePersistentStore(); 127 // const [feed, setFeed] = React.useState<any[]>([]); ··· 161 162 // const savedFeeds = savedFeedsPref?.items || []; 163 164 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 165 + const isAuthRestoring = quickAuth ? status === "loading" : false; 166 + 167 + const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined); 168 const identity = identityresultmaybe?.data; 169 170 const prefsresultmaybe = useQueryPreferences({ 171 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 172 + pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined, 173 }); 174 const prefs = prefsresultmaybe?.data; 175 ··· 180 return savedFeedsPref?.items || []; 181 }, [prefs]); 182 183 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 184 + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed); 185 const selectedFeed = agent?.did 186 ? persistentSelectedFeed 187 : unauthedSelectedFeed; ··· 305 }, [scrollPositions]); 306 307 useLayoutEffect(() => { 308 + if (isAuthRestoring) return; 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 + }, [selectedFeed, isAuthRestoring]); 314 315 useLayoutEffect(() => { 316 + if (!selectedFeed || isAuthRestoring) return; 317 318 const handleScroll = () => { 319 scrollPositionsRef.current = { ··· 328 329 setScrollPositions(scrollPositionsRef.current); 330 }; 331 + }, [isAuthRestoring, selectedFeed, setScrollPositions]); 332 333 + const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined); 334 + const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined; 335 336 // const { 337 // data: feedData, ··· 347 348 // const feed = feedData?.feed || []; 349 350 + const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 351 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed; 352 353 354 const [isAtTop] = useAtom(isAtTopAtom); ··· 357 <div 358 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 359 > 360 + {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 + {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 363 </div> 364 ) : ( 365 // <span className="text-xl font-bold ml-2">Home</span> ··· 377 /> 378 ))} */} 379 380 + {isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && ( 381 <div className="p-4 text-center text-gray-500"> 382 Preparing your feed... 383 </div> 384 )} 385 386 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 387 <InfiniteCustomFeed 388 + key={selectedFeed!} 389 feedUri={selectedFeed!} 390 pdsUrl={identity?.pds} 391 feedServiceDid={feedServiceDid} 392 /> 393 ) : ( 394 <div className="p-4 text-center text-gray-500"> 395 + Loading....... 396 </div> 397 )} 398 {/* {false && restoringScrollPosition && ( ··· 403 </div> 404 ); 405 } 406 + 407 + 408 + // todo please use types this is dangerous very dangerous. 409 + // todo fix this whenever proper preferences is handled 410 + function FeedTabOnTop({item, idx}:{item: any, idx: number}) { 411 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 412 + const selectedFeed = persistentSelectedFeed 413 + const setSelectedFeed = setPersistentSelectedFeed 414 + const rkey = item.value.split("/").pop() || item.value; 415 + const isActive = selectedFeed === item.value; 416 + const { data: feedrecord } = useQueryArbitrary(item.value) 417 + const label = feedrecord?.value?.displayName || rkey 418 + return ( 419 + <button 420 + key={item.value || idx} 421 + className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 422 + isActive 423 + ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 424 + : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 425 + // ? "bg-gray-500 text-white" 426 + // : item.pinned 427 + // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 428 + // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 429 + }`} 430 + onClick={() => setSelectedFeed(item.value)} 431 + title={item.value} 432 + > 433 + {label} 434 + {item.pinned && ( 435 + <span 436 + className={`ml-1 text-xs ${ 437 + isActive 438 + ? "text-gray-900 dark:text-gray-100" 439 + : "text-gray-600 dark:text-gray-400" 440 + }`} 441 + > 442 + โ˜… 443 + </span> 444 + )} 445 + </button> 446 + ); 447 + } 448 + 449 // not even used lmaooo 450 451 // export async function cachedResolveDIDWEBDOC({
+269
src/routes/moderation.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { 3 + isAdultContentPref, 4 + isBskyAppStatePref, 5 + isContentLabelPref, 6 + isFeedViewPref, 7 + isLabelersPref, 8 + isMutedWordsPref, 9 + isSavedFeedsPref, 10 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import { createFileRoute } from "@tanstack/react-router"; 12 + import { useAtom } from "jotai"; 13 + import { Switch } from "radix-ui"; 14 + 15 + import { Header } from "~/components/Header"; 16 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 19 + 20 + import { renderSnack } from "./__root"; 21 + import { NotificationItem } from "./notifications"; 22 + import { SettingHeading } from "./settings"; 23 + 24 + export const Route = createFileRoute("/moderation")({ 25 + component: RouteComponent, 26 + }); 27 + 28 + function RouteComponent() { 29 + const { agent } = useAuth(); 30 + 31 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 32 + const isAuthRestoring = quickAuth ? status === "loading" : false; 33 + 34 + const identityresultmaybe = useQueryIdentity( 35 + !isAuthRestoring ? agent?.did : undefined 36 + ); 37 + const identity = identityresultmaybe?.data; 38 + 39 + const prefsresultmaybe = useQueryPreferences({ 40 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 41 + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, 42 + }); 43 + const rawprefs = prefsresultmaybe?.data?.preferences as 44 + | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] 45 + | undefined; 46 + 47 + //console.log(JSON.stringify(prefs, null, 2)) 48 + 49 + const parsedPref = parsePreferences(rawprefs); 50 + 51 + return ( 52 + <div> 53 + <Header 54 + title={`Moderation`} 55 + backButtonCallback={() => { 56 + if (window.history.length > 1) { 57 + window.history.back(); 58 + } else { 59 + window.location.assign("/"); 60 + } 61 + }} 62 + bottomBorderDisabled={true} 63 + /> 64 + {/* <SettingHeading title="Moderation Tools" /> 65 + <p> 66 + todo: add all these: 67 + <br /> 68 + - Interaction settings 69 + <br /> 70 + - Muted words & tags 71 + <br /> 72 + - Moderation lists 73 + <br /> 74 + - Muted accounts 75 + <br /> 76 + - Blocked accounts 77 + <br /> 78 + - Verification settings 79 + <br /> 80 + </p> */} 81 + <SettingHeading title="Content Filters" /> 82 + <div> 83 + <div className="flex items-center gap-4 px-4 py-2 border-b"> 84 + <label 85 + htmlFor={`switch-${"hardcoded"}`} 86 + className="flex flex-row flex-1" 87 + > 88 + <div className="flex flex-col"> 89 + <span className="text-md">{"Adult Content"}</span> 90 + <span className="text-sm text-gray-500 dark:text-gray-400"> 91 + {"Enable adult content"} 92 + </span> 93 + </div> 94 + </label> 95 + 96 + <Switch.Root 97 + id={`switch-${"hardcoded"}`} 98 + checked={parsedPref?.adultContentEnabled} 99 + onCheckedChange={(v) => { 100 + renderSnack({ 101 + title: "Sorry... Modifying preferences is not implemented yet", 102 + description: "You can use another app to change preferences", 103 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 104 + }); 105 + }} 106 + className="m3switch root" 107 + > 108 + <Switch.Thumb className="m3switch thumb " /> 109 + </Switch.Root> 110 + </div> 111 + <div className=""> 112 + {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 113 + ([label, visibility]) => ( 114 + <div 115 + key={label} 116 + className="flex justify-between border-b py-2 px-4" 117 + > 118 + <label 119 + htmlFor={`switch-${"hardcoded"}`} 120 + className="flex flex-row flex-1" 121 + > 122 + <div className="flex flex-col"> 123 + <span className="text-md">{label}</span> 124 + <span className="text-sm text-gray-500 dark:text-gray-400"> 125 + {"uknown labeler"} 126 + </span> 127 + </div> 128 + </label> 129 + {/* <span className="text-md text-gray-500 dark:text-gray-400"> 130 + {visibility} 131 + </span> */} 132 + <TripleToggle 133 + value={visibility as "ignore" | "warn" | "hide"} 134 + /> 135 + </div> 136 + ) 137 + )} 138 + </div> 139 + </div> 140 + <SettingHeading title="Advanced" /> 141 + {parsedPref?.labelers.map((labeler) => { 142 + return ( 143 + <NotificationItem 144 + key={labeler} 145 + notification={labeler} 146 + labeler={true} 147 + /> 148 + ); 149 + })} 150 + </div> 151 + ); 152 + } 153 + 154 + export function TripleToggle({ 155 + value, 156 + onChange, 157 + }: { 158 + value: "ignore" | "warn" | "hide"; 159 + onChange?: (newValue: "ignore" | "warn" | "hide") => void; 160 + }) { 161 + const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"]; 162 + return ( 163 + <div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm"> 164 + {options.map((opt) => { 165 + const isActive = opt === value; 166 + return ( 167 + <button 168 + key={opt} 169 + onClick={() => { 170 + renderSnack({ 171 + title: "Sorry... Modifying preferences is not implemented yet", 172 + description: "You can use another app to change preferences", 173 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 174 + }); 175 + onChange?.(opt); 176 + }} 177 + className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${ 178 + isActive 179 + ? "bg-gray-400 dark:bg-gray-600 text-white" 180 + : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" 181 + }`} 182 + > 183 + {" "} 184 + {opt.charAt(0).toUpperCase() + opt.slice(1)} 185 + </button> 186 + ); 187 + })} 188 + </div> 189 + ); 190 + } 191 + 192 + type PrefItem = 193 + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; 194 + 195 + export interface NormalizedPreferences { 196 + contentLabelPrefs: Record<string, string>; 197 + mutedWords: string[]; 198 + feedViewPrefs: Record<string, any>; 199 + labelers: string[]; 200 + adultContentEnabled: boolean; 201 + savedFeeds: { 202 + pinned: string[]; 203 + saved: string[]; 204 + }; 205 + nuxs: string[]; 206 + } 207 + 208 + export function parsePreferences( 209 + prefs?: PrefItem[] 210 + ): NormalizedPreferences | undefined { 211 + if (!prefs) return undefined; 212 + const normalized: NormalizedPreferences = { 213 + contentLabelPrefs: {}, 214 + mutedWords: [], 215 + feedViewPrefs: {}, 216 + labelers: [], 217 + adultContentEnabled: false, 218 + savedFeeds: { pinned: [], saved: [] }, 219 + nuxs: [], 220 + }; 221 + 222 + for (const pref of prefs) { 223 + switch (pref.$type) { 224 + case "app.bsky.actor.defs#contentLabelPref": 225 + if (!isContentLabelPref(pref)) break; 226 + normalized.contentLabelPrefs[pref.label] = pref.visibility; 227 + break; 228 + 229 + case "app.bsky.actor.defs#mutedWordsPref": 230 + if (!isMutedWordsPref(pref)) break; 231 + for (const item of pref.items ?? []) { 232 + normalized.mutedWords.push(item.value); 233 + } 234 + break; 235 + 236 + case "app.bsky.actor.defs#feedViewPref": 237 + if (!isFeedViewPref(pref)) break; 238 + normalized.feedViewPrefs[pref.feed] = pref; 239 + break; 240 + 241 + case "app.bsky.actor.defs#labelersPref": 242 + if (!isLabelersPref(pref)) break; 243 + normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? [])); 244 + break; 245 + 246 + case "app.bsky.actor.defs#adultContentPref": 247 + if (!isAdultContentPref(pref)) break; 248 + normalized.adultContentEnabled = !!pref.enabled; 249 + break; 250 + 251 + case "app.bsky.actor.defs#savedFeedsPref": 252 + if (!isSavedFeedsPref(pref)) break; 253 + normalized.savedFeeds.pinned.push(...(pref.pinned ?? [])); 254 + normalized.savedFeeds.saved.push(...(pref.saved ?? [])); 255 + break; 256 + 257 + case "app.bsky.actor.defs#bskyAppStatePref": 258 + if (!isBskyAppStatePref(pref)) break; 259 + normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? [])); 260 + break; 261 + 262 + default: 263 + // unknown pref type โ€” just ignore for now 264 + break; 265 + } 266 + } 267 + 268 + return normalized; 269 + }
+642 -154
src/routes/notifications.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom } from "jotai"; 3 - import React, { useEffect, useRef,useState } from "react"; 4 5 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 - import { constellationURLAtom } from "~/utils/atoms"; 7 8 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 9 10 export const Route = createFileRoute("/notifications")({ 11 component: NotificationsComponent, 12 }); 13 14 - function NotificationsComponent() { 15 - // /*mass comment*/ console.log("NotificationsComponent render"); 16 - const { agent, status } = useAuth(); 17 - const authed = !!agent?.did; 18 - const authLoading = status === "loading"; 19 - const [did, setDid] = useState<string | null>(null); 20 - const [resolving, setResolving] = useState(false); 21 - const [error, setError] = useState<string | null>(null); 22 - const [responses, setResponses] = useState<any[]>([null, null, null]); 23 - const [loading, setLoading] = useState(false); 24 - const inputRef = useRef<HTMLInputElement>(null); 25 26 - useEffect(() => { 27 - if (authLoading) return; 28 - if (authed && agent && agent.assertDid) { 29 - setDid(agent.assertDid); 30 - } 31 - }, [authed, agent, authLoading]); 32 33 - async function handleSubmit() { 34 - // /*mass comment*/ console.log("handleSubmit called"); 35 - setError(null); 36 - setResponses([null, null, null]); 37 - const value = inputRef.current?.value?.trim() || ""; 38 - if (!value) return; 39 - if (value.startsWith("did:")) { 40 - setDid(value); 41 - setError(null); 42 - return; 43 - } 44 - setResolving(true); 45 - const cacheKey = `handleDid:${value}`; 46 - const now = Date.now(); 47 - const cached = undefined // await get(cacheKey); 48 - // if ( 49 - // cached && 50 - // cached.value && 51 - // cached.time && 52 - // now - cached.time < HANDLE_DID_CACHE_TIMEOUT 53 - // ) { 54 - // try { 55 - // const data = JSON.parse(cached.value); 56 - // setDid(data.did); 57 - // setResolving(false); 58 - // return; 59 - // } catch {} 60 - // } 61 - try { 62 - const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`; 63 - const res = await fetch(url); 64 - if (!res.ok) throw new Error("Failed to resolve handle"); 65 - const data = await res.json(); 66 - //set(cacheKey, JSON.stringify(data)); 67 - setDid(data.did); 68 - } catch (e: any) { 69 - setError("Failed to resolve handle: " + (e?.message || e)); 70 - } finally { 71 - setResolving(false); 72 - } 73 - } 74 75 - const [constellationURL] = useAtom(constellationURLAtom) 76 77 - useEffect(() => { 78 - if (!did) return; 79 - setLoading(true); 80 - setError(null); 81 - const urls = [ 82 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, 83 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, 84 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, 85 - ]; 86 - let ignore = false; 87 - Promise.all( 88 - urls.map(async (url) => { 89 - try { 90 - const r = await fetch(url); 91 - if (!r.ok) throw new Error("Failed to fetch"); 92 - const text = await r.text(); 93 - if (!text) return null; 94 - try { 95 - return JSON.parse(text); 96 - } catch { 97 - return null; 98 } 99 - } catch (e: any) { 100 - return { error: e?.message || String(e) }; 101 - } 102 - }) 103 - ) 104 - .then((results) => { 105 - if (!ignore) setResponses(results); 106 - }) 107 - .catch((e) => { 108 - if (!ignore) 109 - setError("Failed to fetch notifications: " + (e?.message || e)); 110 - }) 111 - .finally(() => { 112 - if (!ignore) setLoading(false); 113 }); 114 - return () => { 115 - ignore = true; 116 - }; 117 - }, [did]); 118 119 return ( 120 - <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 121 - <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-800"> 122 - <span className="text-xl font-bold ml-2">Notifications</span> 123 - {!authed && ( 124 - <div className="flex items-center gap-2"> 125 - <input 126 - type="text" 127 - placeholder="Enter handle or DID" 128 - ref={inputRef} 129 - className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100" 130 - style={{ minWidth: 220 }} 131 - disabled={resolving} 132 - /> 133 - <button 134 - type="button" 135 - className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50" 136 - disabled={resolving} 137 - onClick={handleSubmit} 138 - > 139 - {resolving ? "Resolving..." : "Submit"} 140 - </button> 141 - </div> 142 - )} 143 </div> 144 - {error && <div className="p-4 text-red-500">{error}</div>} 145 - {loading && ( 146 - <div className="p-4 text-gray-500">Loading notifications...</div> 147 )} 148 - {!loading && 149 - !error && 150 - responses.map((resp, i) => ( 151 - <div key={i} className="p-4"> 152 - <div className="font-bold mb-2">Query {i + 1}</div> 153 - {!resp || 154 - (typeof resp === "object" && Object.keys(resp).length === 0) || 155 - (Array.isArray(resp) && resp.length === 0) ? ( 156 - <div className="text-gray-500">No notifications found.</div> 157 - ) : ( 158 - <pre 159 - style={{ 160 - background: "#222", 161 - color: "#eee", 162 - borderRadius: 8, 163 - padding: 12, 164 - fontSize: 13, 165 - overflowX: "auto", 166 - }} 167 - > 168 - {JSON.stringify(resp, null, 2)} 169 - </pre> 170 - )} 171 - </div> 172 - ))} 173 - {/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */} 174 </div> 175 ); 176 }
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 import { useAtom } from "jotai"; 5 + import * as React from "react"; 6 7 + import defaultpfp from "~/../public/favicon.png"; 8 + import { Header } from "~/components/Header"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { 14 + MdiCardsHeartOutline, 15 + MdiCommentOutline, 16 + MdiRepeat, 17 + UniversalPostRendererATURILoader, 18 + } from "~/components/UniversalPostRenderer"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 + import { 21 + constellationURLAtom, 22 + enableBitesAtom, 23 + imgCDNAtom, 24 + postInteractionsFiltersAtom, 25 + } from "~/utils/atoms"; 26 + import { 27 + useInfiniteQueryAuthorFeed, 28 + useQueryConstellation, 29 + useQueryIdentity, 30 + useQueryProfile, 31 + yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 32 + } from "~/utils/useQuery"; 33 34 + import { FollowButton, Mutual } from "./profile.$did"; 35 + 36 + export function NotificationsComponent() { 37 + return ( 38 + <div className=""> 39 + <Header 40 + title={`Notifications`} 41 + backButtonCallback={() => { 42 + if (window.history.length > 1) { 43 + window.history.back(); 44 + } else { 45 + window.location.assign("/"); 46 + } 47 + }} 48 + bottomBorderDisabled={true} 49 + /> 50 + <NotificationsTabs /> 51 + </div> 52 + ); 53 + } 54 55 export const Route = createFileRoute("/notifications")({ 56 component: NotificationsComponent, 57 }); 58 59 + export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 61 + return ( 62 + <ReusableTabRoute 63 + route={`Notifications`} 64 + tabs={{ 65 + Mentions: <MentionsTab />, 66 + Follows: <FollowsTab />, 67 + "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 71 + }} 72 + /> 73 + ); 74 + } 75 + 76 + function MentionsTab() { 77 + const { agent } = useAuth(); 78 + const [constellationurl] = useAtom(constellationURLAtom); 79 + const infinitequeryresults = useInfiniteQuery({ 80 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 81 + { 82 + constellation: constellationurl, 83 + method: "/links", 84 + target: agent?.did, 85 + collection: "app.bsky.feed.post", 86 + path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 87 + } 88 + ), 89 + enabled: !!agent?.did, 90 + }); 91 + 92 + const { 93 + data: infiniteMentionsData, 94 + fetchNextPage, 95 + hasNextPage, 96 + isFetchingNextPage, 97 + isLoading, 98 + isError, 99 + error, 100 + } = infinitequeryresults; 101 + 102 + const mentionsAturis = React.useMemo(() => { 103 + // Get all replies from the standard infinite query 104 + return ( 105 + infiniteMentionsData?.pages.flatMap( 106 + (page) => 107 + page?.linking_records.map( 108 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 109 + ) ?? [] 110 + ) ?? [] 111 + ); 112 + }, [infiniteMentionsData]); 113 114 + useReusableTabScrollRestore("Notifications"); 115 116 + if (isLoading) return <LoadingState text="Loading mentions..." />; 117 + if (isError) return <ErrorState error={error} />; 118 + 119 + if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 120 121 + return ( 122 + <> 123 + {mentionsAturis.map((m) => ( 124 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 125 + ))} 126 127 + {hasNextPage && ( 128 + <button 129 + onClick={() => fetchNextPage()} 130 + disabled={isFetchingNextPage} 131 + 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 disabled:opacity-50" 132 + > 133 + {isFetchingNextPage ? "Loading..." : "Load More"} 134 + </button> 135 + )} 136 + </> 137 + ); 138 + } 139 + 140 + export function FollowsTab({did}:{did?:string}) { 141 + const { agent } = useAuth(); 142 + const userdidunsafe = did ?? agent?.did; 143 + const { data: identity} = useQueryIdentity(userdidunsafe); 144 + const userdid = identity?.did; 145 + 146 + const [constellationurl] = useAtom(constellationURLAtom); 147 + const infinitequeryresults = useInfiniteQuery({ 148 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 149 + { 150 + constellation: constellationurl, 151 + method: "/links", 152 + target: userdid, 153 + collection: "app.bsky.graph.follow", 154 + path: ".subject", 155 + } 156 + ), 157 + enabled: !!userdid, 158 + }); 159 + 160 + const { 161 + data: infiniteFollowsData, 162 + fetchNextPage, 163 + hasNextPage, 164 + isFetchingNextPage, 165 + isLoading, 166 + isError, 167 + error, 168 + } = infinitequeryresults; 169 + 170 + const followsAturis = React.useMemo(() => { 171 + // Get all replies from the standard infinite query 172 + return ( 173 + infiniteFollowsData?.pages.flatMap( 174 + (page) => 175 + page?.linking_records.map( 176 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 177 + ) ?? [] 178 + ) ?? [] 179 + ); 180 + }, [infiniteFollowsData]); 181 + 182 + useReusableTabScrollRestore("Notifications"); 183 + 184 + if (isLoading) return <LoadingState text="Loading follows..." />; 185 + if (isError) return <ErrorState error={error} />; 186 + 187 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 188 + 189 + return ( 190 + <> 191 + {followsAturis.map((m) => ( 192 + <NotificationItem key={m} notification={m} /> 193 + ))} 194 + 195 + {hasNextPage && ( 196 + <button 197 + onClick={() => fetchNextPage()} 198 + disabled={isFetchingNextPage} 199 + 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 disabled:opacity-50" 200 + > 201 + {isFetchingNextPage ? "Loading..." : "Load More"} 202 + </button> 203 + )} 204 + </> 205 + ); 206 + } 207 + 208 + 209 + export function BitesTab({did}:{did?:string}) { 210 + const { agent } = useAuth(); 211 + const userdidunsafe = did ?? agent?.did; 212 + const { data: identity} = useQueryIdentity(userdidunsafe); 213 + const userdid = identity?.did; 214 + 215 + const [constellationurl] = useAtom(constellationURLAtom); 216 + const infinitequeryresults = useInfiniteQuery({ 217 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 218 + { 219 + constellation: constellationurl, 220 + method: "/links", 221 + target: "at://"+userdid, 222 + collection: "net.wafrn.feed.bite", 223 + path: ".subject", 224 + staleMult: 0 // safe fun 225 + } 226 + ), 227 + enabled: !!userdid, 228 + }); 229 + 230 + const { 231 + data: infiniteFollowsData, 232 + fetchNextPage, 233 + hasNextPage, 234 + isFetchingNextPage, 235 + isLoading, 236 + isError, 237 + error, 238 + } = infinitequeryresults; 239 + 240 + const followsAturis = React.useMemo(() => { 241 + // Get all replies from the standard infinite query 242 + return ( 243 + infiniteFollowsData?.pages.flatMap( 244 + (page) => 245 + page?.linking_records.map( 246 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 247 + ) ?? [] 248 + ) ?? [] 249 + ); 250 + }, [infiniteFollowsData]); 251 + 252 + useReusableTabScrollRestore("Notifications"); 253 + 254 + if (isLoading) return <LoadingState text="Loading bites..." />; 255 + if (isError) return <ErrorState error={error} />; 256 + 257 + if (!followsAturis?.length) return <EmptyState text="No bites yet." />; 258 + 259 + return ( 260 + <> 261 + {followsAturis.map((m) => ( 262 + <NotificationItem key={m} notification={m} /> 263 + ))} 264 + 265 + {hasNextPage && ( 266 + <button 267 + onClick={() => fetchNextPage()} 268 + disabled={isFetchingNextPage} 269 + 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 disabled:opacity-50" 270 + > 271 + {isFetchingNextPage ? "Loading..." : "Load More"} 272 + </button> 273 + )} 274 + </> 275 + ); 276 + } 277 + 278 + function PostInteractionsTab() { 279 + const { agent } = useAuth(); 280 + const { data: identity } = useQueryIdentity(agent?.did); 281 + const queryClient = useQueryClient(); 282 + const { 283 + data: postsData, 284 + fetchNextPage, 285 + hasNextPage, 286 + isFetchingNextPage, 287 + isLoading: arePostsLoading, 288 + } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 289 + 290 + React.useEffect(() => { 291 + if (postsData) { 292 + postsData.pages.forEach((page) => { 293 + page.records.forEach((record) => { 294 + if (!queryClient.getQueryData(["post", record.uri])) { 295 + queryClient.setQueryData(["post", record.uri], record); 296 } 297 + }); 298 }); 299 + } 300 + }, [postsData, queryClient]); 301 + 302 + const posts = React.useMemo( 303 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 304 + [postsData] 305 + ); 306 + 307 + useReusableTabScrollRestore("Notifications"); 308 + 309 + const [filters] = useAtom(postInteractionsFiltersAtom); 310 + const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 311 312 return ( 313 + <> 314 + <PostInteractionsFilterChipBar /> 315 + {!empty && posts.map((m) => ( 316 + <PostInteractionsItem key={m.uri} uri={m.uri} /> 317 + ))} 318 + 319 + {hasNextPage && ( 320 + <button 321 + onClick={() => fetchNextPage()} 322 + disabled={isFetchingNextPage} 323 + 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 disabled:opacity-50" 324 + > 325 + {isFetchingNextPage ? "Loading..." : "Load More"} 326 + </button> 327 + )} 328 + </> 329 + ); 330 + } 331 + 332 + function PostInteractionsFilterChipBar() { 333 + const [filters, setFilters] = useAtom(postInteractionsFiltersAtom); 334 + // const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 335 + 336 + // useEffect(() => { 337 + // if (empty) { 338 + // setFilters((prev) => ({ 339 + // ...prev, 340 + // likes: true, 341 + // })); 342 + // } 343 + // }, [ 344 + // empty, 345 + // setFilters, 346 + // ]); 347 + 348 + const toggle = (key: keyof typeof filters) => { 349 + setFilters((prev) => ({ 350 + ...prev, 351 + [key]: !prev[key], 352 + })); 353 + }; 354 + 355 + return ( 356 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 357 + <Chip 358 + state={filters.likes} 359 + text="Likes" 360 + onClick={() => toggle("likes")} 361 + /> 362 + <Chip 363 + state={filters.reposts} 364 + text="Reposts" 365 + onClick={() => toggle("reposts")} 366 + /> 367 + <Chip 368 + state={filters.replies} 369 + text="Replies" 370 + onClick={() => toggle("replies")} 371 + /> 372 + <Chip 373 + state={filters.quotes} 374 + text="Quotes" 375 + onClick={() => toggle("quotes")} 376 + /> 377 + <Chip 378 + state={filters.showAll} 379 + text="Show All Metrics" 380 + onClick={() => toggle("showAll")} 381 + /> 382 + </div> 383 + ); 384 + } 385 + 386 + export function Chip({ 387 + state, 388 + text, 389 + onClick, 390 + }: { 391 + state: boolean; 392 + text: string; 393 + onClick: React.MouseEventHandler<HTMLButtonElement>; 394 + }) { 395 + return ( 396 + <button 397 + onClick={onClick} 398 + className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 399 + ${ 400 + state 401 + ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 402 + : "bg-surface-container-low text-on-surface-variant border border-outline" 403 + } 404 + hover:bg-primary/30 active:scale-[0.97] 405 + dark:border-outline-variant 406 + `} 407 + > 408 + {state && ( 409 + <IconMdiCheck 410 + className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary" 411 + aria-hidden 412 + /> 413 + )} 414 + {text} 415 + </button> 416 + ); 417 + } 418 + 419 + function PostInteractionsItem({ uri }: { uri: string }) { 420 + const [filters] = useAtom(postInteractionsFiltersAtom); 421 + const { data: links } = useQueryConstellation({ 422 + method: "/links/all", 423 + target: uri, 424 + }); 425 + 426 + const likes = 427 + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 428 + const replies = 429 + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 430 + const reposts = 431 + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 432 + const quotes1 = 433 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 434 + const quotes2 = 435 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 436 + ?.records || 0; 437 + const quotes = quotes1 + quotes2; 438 + 439 + const all = likes + replies + reposts + quotes; 440 + 441 + //const failLikes = filters.likes && likes < 1; 442 + //const failReposts = filters.reposts && reposts < 1; 443 + //const failReplies = filters.replies && replies < 1; 444 + //const failQuotes = filters.quotes && quotes < 1; 445 + 446 + const showLikes = filters.showAll || filters.likes 447 + const showReposts = filters.showAll || filters.reposts 448 + const showReplies = filters.showAll || filters.replies 449 + const showQuotes = filters.showAll || filters.quotes 450 + 451 + //const showNone = !showLikes && !showReposts && !showReplies && !showQuotes; 452 + 453 + //const fail = failLikes || failReposts || failReplies || failQuotes || showNone; 454 + 455 + const matchesLikes = filters.likes && likes > 0; 456 + const matchesReposts = filters.reposts && reposts > 0; 457 + const matchesReplies = filters.replies && replies > 0; 458 + const matchesQuotes = filters.quotes && quotes > 0; 459 + 460 + const matchesAnything = 461 + // filters.showAll || 462 + matchesLikes || 463 + matchesReposts || 464 + matchesReplies || 465 + matchesQuotes; 466 + 467 + if (!matchesAnything) return null; 468 + 469 + //if (fail) return; 470 + 471 + return ( 472 + <div className="flex flex-col"> 473 + {/* <span>fail likes {failLikes ? "true" : "false"}</span> 474 + <span>fail repost {failReposts ? "true" : "false"}</span> 475 + <span>fail reply {failReplies ? "true" : "false"}</span> 476 + <span>fail qupte {failQuotes ? "true" : "false"}</span> */} 477 + <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 478 + <UniversalPostRendererATURILoader 479 + isQuote 480 + key={uri} 481 + atUri={uri} 482 + nopics={true} 483 + concise={true} 484 + /> 485 + <div className="flex flex-col divide-x"> 486 + {showLikes &&(<InteractionsButton 487 + type={"like"} 488 + uri={uri} 489 + count={likes} 490 + />)} 491 + {showReposts && (<InteractionsButton 492 + type={"repost"} 493 + uri={uri} 494 + count={reposts} 495 + />)} 496 + {showReplies && (<InteractionsButton 497 + type={"reply"} 498 + uri={uri} 499 + count={replies} 500 + />)} 501 + {showQuotes && (<InteractionsButton 502 + type={"quote"} 503 + uri={uri} 504 + count={quotes} 505 + />)} 506 + {!all && ( 507 + <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 508 + No interactions yet. 509 + </div> 510 + )} 511 + </div> 512 </div> 513 + </div> 514 + ); 515 + } 516 + 517 + function InteractionsButton({ 518 + type, 519 + uri, 520 + count, 521 + }: { 522 + type: "reply" | "repost" | "like" | "quote"; 523 + uri: string; 524 + count: number; 525 + }) { 526 + if (!count) return <></>; 527 + const aturi = new AtUri(uri); 528 + return ( 529 + <Link 530 + to={ 531 + `/profile/$did/post/$rkey` + 532 + (type === "like" 533 + ? "/liked-by" 534 + : type === "repost" 535 + ? "/reposted-by" 536 + : type === "quote" 537 + ? "/quotes" 538 + : "") 539 + } 540 + params={{ 541 + did: aturi.host, 542 + rkey: aturi.rkey, 543 + }} 544 + className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 545 + > 546 + {type === "like" ? ( 547 + <MdiCardsHeartOutline height={22} width={22} /> 548 + ) : type === "repost" ? ( 549 + <MdiRepeat height={22} width={22} /> 550 + ) : type === "reply" ? ( 551 + <MdiCommentOutline height={22} width={22} /> 552 + ) : type === "quote" ? ( 553 + <IconMdiMessageReplyTextOutline 554 + height={22} 555 + width={22} 556 + className=" text-gray-400" 557 + /> 558 + ) : ( 559 + <></> 560 + )} 561 + {type === "like" 562 + ? "likes" 563 + : type === "reply" 564 + ? "replies" 565 + : type === "quote" 566 + ? "quotes" 567 + : type === "repost" 568 + ? "reposts" 569 + : ""} 570 + <div className="flex-1" /> {count} 571 + </Link> 572 + ); 573 + } 574 + 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 + const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 578 + const navigate = useNavigate(); 579 + const { data: identity } = useQueryIdentity(aturi.host); 580 + const resolvedDid = identity?.did; 581 + const profileUri = resolvedDid 582 + ? `at://${resolvedDid}/app.bsky.actor.profile/self` 583 + : undefined; 584 + const { data: profileRecord } = useQueryProfile(profileUri); 585 + const profile = profileRecord?.value; 586 + 587 + const [imgcdn] = useAtom(imgCDNAtom); 588 + 589 + function getAvatarUrl(p: typeof profile) { 590 + const link = p?.avatar?.ref?.["$link"]; 591 + if (!link || !resolvedDid) return null; 592 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 593 + } 594 + 595 + const avatar = getAvatarUrl(profile); 596 + 597 + return ( 598 + <div 599 + className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 600 + onClick={() => 601 + aturi && 602 + navigate({ 603 + to: "/profile/$did", 604 + params: { did: aturi.host }, 605 + }) 606 + } 607 + > 608 + {/* <div> 609 + {aturi.collection === "app.bsky.graph.follow" ? ( 610 + <IconMdiAccountPlus /> 611 + ) : aturi.collection === "app.bsky.feed.like" ? ( 612 + <MdiCardsHeart /> 613 + ) : ( 614 + <></> 615 + )} 616 + </div> */} 617 + {profile ? ( 618 + <img 619 + src={avatar || defaultpfp} 620 + alt={identity?.handle} 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 622 + /> 623 + ) : ( 624 + <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 625 )} 626 + <div className="flex flex-col min-w-0"> 627 + <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 628 + <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 629 + {profile?.displayName || identity?.handle || "Someone"} 630 + </span> 631 + <span className="text-gray-700 dark:text-gray-400 truncate"> 632 + @{identity?.handle} 633 + </span> 634 + </div> 635 + <div className="flex flex-row gap-2"> 636 + {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 637 + {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 638 + followed you 639 + </span> */} 640 + </div> 641 + </div> 642 + <div className="flex-1" /> 643 + {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 644 </div> 645 ); 646 } 647 + 648 + export const EmptyState = ({ text }: { text: string }) => ( 649 + <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 650 + {text} 651 + </div> 652 + ); 653 + 654 + export const LoadingState = ({ text }: { text: string }) => ( 655 + <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 656 + {text} 657 + </div> 658 + ); 659 + 660 + export const ErrorState = ({ error }: { error: unknown }) => ( 661 + <div className="py-10 text-center text-red-600 dark:text-red-400"> 662 + Error: {(error as Error)?.message || "Something went wrong."} 663 + </div> 664 + );
+91
src/routes/profile.$did/feed.$rkey.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { AtUri } from "@atproto/api"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + import { quickAuthAtom } from "~/utils/atoms"; 10 + import { useQueryArbitrary, useQueryIdentity } from "~/utils/useQuery"; 11 + 12 + export const Route = createFileRoute("/profile/$did/feed/$rkey")({ 13 + component: FeedRoute, 14 + }); 15 + 16 + // todo: scroll restoration 17 + function FeedRoute() { 18 + const { did, rkey } = Route.useParams(); 19 + const { agent, status } = useAuth(); 20 + const { data: identitydata } = useQueryIdentity(did); 21 + const { data: identity } = useQueryIdentity(agent?.did); 22 + const uri = `at://${identitydata?.did || did}/app.bsky.feed.generator/${rkey}`; 23 + const aturi = new AtUri(uri); 24 + const { data: feeddata } = useQueryArbitrary(uri); 25 + 26 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 27 + const isAuthRestoring = quickAuth ? status === "loading" : false; 28 + 29 + const authed = status === "signedIn"; 30 + 31 + const feedServiceDid = !isAuthRestoring 32 + ? ((feeddata?.value as any)?.did as string | undefined) 33 + : undefined; 34 + 35 + // const { 36 + // data: feedData, 37 + // isLoading: isFeedLoading, 38 + // error: feedError, 39 + // } = useQueryFeedSkeleton({ 40 + // feedUri: selectedFeed!, 41 + // agent: agent ?? undefined, 42 + // isAuthed: authed ?? false, 43 + // pdsUrl: identity?.pds, 44 + // feedServiceDid: feedServiceDid, 45 + // }); 46 + 47 + // const feed = feedData?.feed || []; 48 + 49 + const isReadyForAuthedFeed = 50 + !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 51 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed; 52 + 53 + const feed: ATPAPI.AppBskyFeedGenerator.Record | undefined = feeddata?.value; 54 + 55 + const web = feedServiceDid?.replace(/^did:web:/, "") || ""; 56 + 57 + return ( 58 + <> 59 + <Header 60 + title={feed?.displayName || aturi.rkey} 61 + backButtonCallback={() => { 62 + if (window.history.length > 1) { 63 + window.history.back(); 64 + } else { 65 + window.location.assign("/"); 66 + } 67 + }} 68 + /> 69 + 70 + {isAuthRestoring || 71 + (authed && (!identity?.pds || !feedServiceDid) && ( 72 + <div className="p-4 text-center text-gray-500"> 73 + Preparing your feed... 74 + </div> 75 + ))} 76 + 77 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 78 + <InfiniteCustomFeed 79 + key={uri} 80 + feedUri={uri} 81 + pdsUrl={identity?.pds} 82 + feedServiceDid={feedServiceDid} 83 + authedOverride={!authed && true || undefined} 84 + unauthedfeedurl={!authed && web || undefined} 85 + /> 86 + ) : ( 87 + <div className="p-4 text-center text-gray-500">Loading.......</div> 88 + )} 89 + </> 90 + ); 91 + }
+30
src/routes/profile.$did/followers.tsx
···
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + 3 + import { Header } from "~/components/Header"; 4 + 5 + import { FollowsTab } from "../notifications"; 6 + 7 + export const Route = createFileRoute("/profile/$did/followers")({ 8 + component: RouteComponent, 9 + }); 10 + 11 + // todo: scroll restoration 12 + function RouteComponent() { 13 + const params = Route.useParams(); 14 + 15 + return ( 16 + <div> 17 + <Header 18 + title={"Followers"} 19 + backButtonCallback={() => { 20 + if (window.history.length > 1) { 21 + window.history.back(); 22 + } else { 23 + window.location.assign("/"); 24 + } 25 + }} 26 + /> 27 + <FollowsTab did={params.did} /> 28 + </div> 29 + ); 30 + }
+79
src/routes/profile.$did/follows.tsx
···
··· 1 + import * as ATPAPI from "@atproto/api" 2 + import { createFileRoute } from '@tanstack/react-router' 3 + import React from 'react'; 4 + 5 + import { Header } from '~/components/Header'; 6 + import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute'; 7 + import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery'; 8 + 9 + import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications'; 10 + 11 + export const Route = createFileRoute('/profile/$did/follows')({ 12 + component: RouteComponent, 13 + }) 14 + 15 + // todo: scroll restoration 16 + function RouteComponent() { 17 + const params = Route.useParams(); 18 + return ( 19 + <div> 20 + <Header 21 + title={"Follows"} 22 + backButtonCallback={() => { 23 + if (window.history.length > 1) { 24 + window.history.back(); 25 + } else { 26 + window.location.assign("/"); 27 + } 28 + }} 29 + /> 30 + <Follows did={params.did}/> 31 + </div> 32 + ); 33 + } 34 + 35 + function Follows({did}:{did:string}) { 36 + const {data: identity} = useQueryIdentity(did); 37 + const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow"); 38 + 39 + const { 40 + data: infiniteFollowsData, 41 + fetchNextPage, 42 + hasNextPage, 43 + isFetchingNextPage, 44 + isLoading, 45 + isError, 46 + error, 47 + } = infinitequeryresults; 48 + 49 + const followsAturis = React.useMemo( 50 + () => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [], 51 + [infiniteFollowsData] 52 + ); 53 + 54 + useReusableTabScrollRestore("Notifications"); 55 + 56 + if (isLoading) return <LoadingState text="Loading follows..." />; 57 + if (isError) return <ErrorState error={error} />; 58 + 59 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 60 + 61 + return ( 62 + <> 63 + {followsAturis.map((m) => { 64 + const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record; 65 + return <NotificationItem key={record.subject} notification={record.subject} /> 66 + })} 67 + 68 + {hasNextPage && ( 69 + <button 70 + onClick={() => fetchNextPage()} 71 + disabled={isFetchingNextPage} 72 + 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 disabled:opacity-50" 73 + > 74 + {isFetchingNextPage ? "Loading..." : "Load More"} 75 + </button> 76 + )} 77 + </> 78 + ); 79 + }
+1005 -125
src/routes/profile.$did/index.tsx
··· 1 import { useQueryClient } from "@tanstack/react-query"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 - import React from "react"; 4 5 import { Header } from "~/components/Header"; 6 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 7 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 - import { toggleFollow, useGetFollowState } from "~/utils/followState"; 9 import { 10 useInfiniteQueryAuthorFeed, 11 useQueryIdentity, 12 useQueryProfile, 13 } from "~/utils/useQuery"; 14 15 export const Route = createFileRoute("/profile/$did/")({ 16 component: ProfileComponent, ··· 19 function ProfileComponent() { 20 // booo bad this is not always the did it might be a handle, use identity.did instead 21 const { did } = Route.useParams(); 22 const queryClient = useQueryClient(); 23 - const { agent } = useAuth(); 24 const { 25 data: identity, 26 isLoading: isIdentityLoading, 27 error: identityError, 28 } = useQueryIdentity(did); 29 30 - const followRecords = useGetFollowState({ 31 - target: identity?.did || did, 32 - user: agent?.did, 33 - }); 34 35 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 36 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 42 const { data: profileRecord } = useQueryProfile(profileUri); 43 const profile = profileRecord?.value; 44 45 - const { 46 - data: postsData, 47 - fetchNextPage, 48 - hasNextPage, 49 - isFetchingNextPage, 50 - isLoading: arePostsLoading, 51 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 52 - 53 - React.useEffect(() => { 54 - if (postsData) { 55 - postsData.pages.forEach((page) => { 56 - page.records.forEach((record) => { 57 - if (!queryClient.getQueryData(["post", record.uri])) { 58 - queryClient.setQueryData(["post", record.uri], record); 59 - } 60 - }); 61 - }); 62 - } 63 - }, [postsData, queryClient]); 64 - 65 - const posts = React.useMemo( 66 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 67 - [postsData] 68 - ); 69 70 function getAvatarUrl(p: typeof profile) { 71 const link = p?.avatar?.ref?.["$link"]; 72 if (!link || !resolvedDid) return null; 73 - return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 74 } 75 function getBannerUrl(p: typeof profile) { 76 const link = p?.banner?.ref?.["$link"]; 77 if (!link || !resolvedDid) return null; 78 - return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 79 } 80 81 const displayName = ··· 83 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 84 const description = profile?.description || ""; 85 86 - if (isIdentityLoading) { 87 - return ( 88 - <div className="p-4 text-center text-gray-500">Resolving profile...</div> 89 - ); 90 - } 91 92 - if (identityError) { 93 - return ( 94 - <div className="p-4 text-center text-red-500"> 95 - Error: {identityError.message} 96 - </div> 97 - ); 98 - } 99 100 - if (!resolvedDid) { 101 - return ( 102 - <div className="p-4 text-center text-gray-500">Profile not found.</div> 103 - ); 104 - } 105 106 return ( 107 - <> 108 <Header 109 title={`Profile`} 110 backButtonCallback={() => { ··· 114 window.location.assign("/"); 115 } 116 }} 117 /> 118 {/* <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"> 119 <Link ··· 148 149 {/* Avatar (PFP) */} 150 <div className="absolute left-[16px] top-[100px] "> 151 - <img 152 - src={getAvatarUrl(profile) || "/favicon.png"} 153 - alt="avatar" 154 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 155 - /> 156 </div> 157 158 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 159 {/* 160 todo: full follow and unfollow backfill (along with partial likes backfill, 161 just enough for it to be useful) 162 also delay the backfill to be on demand because it would be pretty intense 163 also save it persistently 164 */} 165 - {identity?.did !== agent?.did ? ( 166 - <> 167 - {!(followRecords?.length && followRecords?.length > 0) ? ( 168 - <button 169 - onClick={() => 170 - toggleFollow({ 171 - agent: agent || undefined, 172 - targetDid: identity?.did, 173 - followRecords: followRecords, 174 - queryClient: queryClient, 175 - }) 176 - } 177 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 178 - > 179 - Follow 180 - </button> 181 - ) : ( 182 - <button 183 - onClick={() => 184 - toggleFollow({ 185 - agent: agent || undefined, 186 - targetDid: identity?.did, 187 - followRecords: followRecords, 188 - queryClient: queryClient, 189 - }) 190 - } 191 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 192 - > 193 - Unfollow 194 - </button> 195 - )} 196 - </> 197 - ) : ( 198 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 199 - Edit Profile 200 - </button> 201 - )} 202 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 203 ... {/* todo: icon */} 204 </button> 205 </div> ··· 207 {/* Info Card */} 208 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 209 <div className="font-bold text-2xl">{displayName}</div> 210 - <div className="text-gray-500 dark:text-gray-400 text-base mb-3"> 211 {handle} 212 </div> 213 {description && ( 214 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 215 - {description} 216 </div> 217 )} 218 </div> 219 </div> 220 221 - {/* Posts Section */} 222 - <div className="max-w-2xl mx-auto"> 223 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 224 - Posts 225 </div> 226 - <div> 227 - {posts.map((post) => ( 228 <UniversalPostRendererATURILoader 229 - key={post.uri} 230 - atUri={post.uri} 231 feedviewpost={true} 232 /> 233 - ))} 234 - </div> 235 236 - {/* Loading and "Load More" states */} 237 - {arePostsLoading && posts.length === 0 && ( 238 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 239 - )} 240 - {isFetchingNextPage && ( 241 - <div className="p-4 text-center text-gray-500">Loading more...</div> 242 - )} 243 - {hasNextPage && !isFetchingNextPage && ( 244 - <button 245 - onClick={() => fetchNextPage()} 246 - 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" 247 > 248 - Load More Posts 249 - </button> 250 - )} 251 - {posts.length === 0 && !arePostsLoading && ( 252 - <div className="p-4 text-center text-gray-500">No posts found.</div> 253 - )} 254 </div> 255 </> 256 ); 257 }
··· 1 + import { Agent, RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 4 import { useQueryClient } from "@tanstack/react-query"; 5 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 6 + import { useAtom } from "jotai"; 7 + import React, { type ReactNode, useEffect, useState } from "react"; 8 9 + import defaultpfp from "~/../public/favicon.png"; 10 import { Header } from "~/components/Header"; 11 + import { 12 + ReusableTabRoute, 13 + useReusableTabScrollRestore, 14 + } from "~/components/ReusableTabRoute"; 15 + import { 16 + renderTextWithFacets, 17 + UniversalPostRendererATURILoader, 18 + } from "~/components/UniversalPostRenderer"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 + import { 22 + toggleFollow, 23 + useGetFollowState, 24 + useGetOneToOneState, 25 + } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 27 import { 28 useInfiniteQueryAuthorFeed, 29 + useQueryArbitrary, 30 + useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 32 useQueryIdentity, 33 useQueryProfile, 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 36 + 37 + import { renderSnack } from "../__root"; 38 + import { Chip } from "../notifications"; 39 40 export const Route = createFileRoute("/profile/$did/")({ 41 component: ProfileComponent, ··· 44 function ProfileComponent() { 45 // booo bad this is not always the did it might be a handle, use identity.did instead 46 const { did } = Route.useParams(); 47 + const { agent } = useAuth(); 48 + const navigate = useNavigate(); 49 const queryClient = useQueryClient(); 50 const { 51 data: identity, 52 isLoading: isIdentityLoading, 53 error: identityError, 54 } = useQueryIdentity(did); 55 56 + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) 57 + // so instead we should query the labeler profile 58 + 59 + const { data: labelerProfile } = useQueryArbitrary( 60 + identity?.did 61 + ? `at://${identity?.did}/app.bsky.labeler.service/self` 62 + : undefined 63 + ); 64 + 65 + const isLabeler = !!labelerProfile?.cid; 66 + const labelerRecord = isLabeler 67 + ? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record) 68 + : undefined; 69 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 77 const { data: profileRecord } = useQueryProfile(profileUri); 78 const profile = profileRecord?.value; 79 80 + const [imgcdn] = useAtom(imgCDNAtom); 81 82 function getAvatarUrl(p: typeof profile) { 83 const link = p?.avatar?.ref?.["$link"]; 84 if (!link || !resolvedDid) return null; 85 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 86 } 87 function getBannerUrl(p: typeof profile) { 88 const link = p?.banner?.ref?.["$link"]; 89 if (!link || !resolvedDid) return null; 90 + return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`; 91 } 92 93 const displayName = ··· 95 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 96 const description = profile?.description || ""; 97 98 + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 99 100 + const resultwhateversure = useQueryConstellationLinksCountDistinctDids( 101 + resolvedDid 102 + ? { 103 + method: "/links/count/distinct-dids", 104 + collection: "app.bsky.graph.follow", 105 + target: resolvedDid, 106 + path: ".subject", 107 + } 108 + : undefined 109 + ); 110 111 + const followercount = resultwhateversure?.data?.total; 112 113 return ( 114 + <div className=""> 115 <Header 116 title={`Profile`} 117 backButtonCallback={() => { ··· 121 window.location.assign("/"); 122 } 123 }} 124 + bottomBorderDisabled={true} 125 /> 126 {/* <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"> 127 <Link ··· 156 157 {/* Avatar (PFP) */} 158 <div className="absolute left-[16px] top-[100px] "> 159 + {!getAvatarUrl(profile) && isLabeler ? ( 160 + <div 161 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 162 + > 163 + <IconMdiShieldOutline className="w-20 h-20" /> 164 + </div> 165 + ) : ( 166 + <img 167 + src={getAvatarUrl(profile) || "/favicon.png"} 168 + alt="avatar" 169 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 170 + /> 171 + )} 172 </div> 173 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 175 + <BiteButton targetdidorhandle={did} /> 176 {/* 177 todo: full follow and unfollow backfill (along with partial likes backfill, 178 just enough for it to be useful) 179 also delay the backfill to be on demand because it would be pretty intense 180 also save it persistently 181 */} 182 + <FollowButton targetdidorhandle={did} /> 183 + <button 184 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 185 + onClick={(e) => { 186 + renderSnack({ 187 + title: "Not Implemented Yet", 188 + description: "Sorry...", 189 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 190 + }); 191 + }} 192 + > 193 ... {/* todo: icon */} 194 </button> 195 </div> ··· 197 {/* Info Card */} 198 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 199 <div className="font-bold text-2xl">{displayName}</div> 200 + <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 201 + <Mutual targetdidorhandle={did} /> 202 {handle} 203 </div> 204 + <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 205 + <Link to="/profile/$did/followers" params={{ did: did }}> 206 + {followercount && ( 207 + <span className="mr-1 text-gray-900 dark:text-gray-200 font-medium"> 208 + {followercount} 209 + </span> 210 + )} 211 + Followers 212 + </Link> 213 + - 214 + <Link to="/profile/$did/follows" params={{ did: did }}> 215 + Follows 216 + </Link> 217 + </div> 218 {description && ( 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 220 + {/* {description} */} 221 + <RichTextRenderer key={did} description={description} /> 222 </div> 223 )} 224 </div> 225 </div> 226 227 + {/* this should not be rendered until its ready (the top profile layout is stable) */} 228 + {isReady ? ( 229 + <ReusableTabRoute 230 + route={`Profile` + did} 231 + tabs={{ 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 243 + ...(identity?.did === agent?.did 244 + ? { Likes: <SelfLikesTab did={did} /> } 245 + : {}), 246 + }} 247 + /> 248 + ) : isIdentityLoading ? ( 249 + <div className="p-4 text-center text-gray-500"> 250 + Resolving profile... 251 + </div> 252 + ) : identityError ? ( 253 + <div className="p-4 text-center text-red-500"> 254 + Error: {identityError.message} 255 + </div> 256 + ) : !resolvedDid ? ( 257 + <div className="p-4 text-center text-gray-500">Profile not found.</div> 258 + ) : ( 259 + <div className="p-4 text-center text-gray-500"> 260 + Loading profile content... 261 </div> 262 + )} 263 + </div> 264 + ); 265 + } 266 + 267 + export type ProfilePostsFilter = { 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 272 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 273 + posts: true, 274 + replies: true, 275 + mediaOnly: false, 276 + }; 277 + 278 + function ProfilePostsFilterChipBar({ 279 + filters, 280 + toggle, 281 + }: { 282 + filters: ProfilePostsFilter | null; 283 + toggle: (key: keyof ProfilePostsFilter) => void; 284 + }) { 285 + const empty = !filters?.replies && !filters?.posts; 286 + const almostEmpty = !filters?.replies && filters?.posts; 287 + 288 + useEffect(() => { 289 + if (empty) { 290 + toggle("posts"); 291 + } 292 + }, [empty, toggle]); 293 + 294 + return ( 295 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 296 + <Chip 297 + state={filters?.posts ?? true} 298 + text="Posts" 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 300 + /> 301 + <Chip 302 + state={filters?.replies ?? true} 303 + text="Replies" 304 + onClick={() => toggle("replies")} 305 + /> 306 + <Chip 307 + state={filters?.mediaOnly ?? false} 308 + text="Media Only" 309 + onClick={() => toggle("mediaOnly")} 310 + /> 311 + </div> 312 + ); 313 + } 314 + 315 + function PostsTab({ did }: { did: string }) { 316 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 317 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 318 + const filters = filterses?.[did]; 319 + const setFilters = (obj: ProfilePostsFilter) => { 320 + setFilterses((prev) => { 321 + return { 322 + ...prev, 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 328 + if (!filters) { 329 + setFilters(defaultProfilePostsFilter); 330 + } 331 + }); 332 + useReusableTabScrollRestore(`Profile` + did); 333 + const queryClient = useQueryClient(); 334 + const { 335 + data: identity, 336 + isLoading: isIdentityLoading, 337 + error: identityError, 338 + } = useQueryIdentity(did); 339 + 340 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 341 + 342 + const { 343 + data: postsData, 344 + fetchNextPage, 345 + hasNextPage, 346 + isFetchingNextPage, 347 + isLoading: arePostsLoading, 348 + } = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds); 349 + 350 + React.useEffect(() => { 351 + if (postsData) { 352 + postsData.pages.forEach((page) => { 353 + page.records.forEach((record) => { 354 + if (!queryClient.getQueryData(["post", record.uri])) { 355 + queryClient.setQueryData(["post", record.uri], record); 356 + } 357 + }); 358 + }); 359 + } 360 + }, [postsData, queryClient]); 361 + 362 + const posts = React.useMemo( 363 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 364 + [postsData] 365 + ); 366 + 367 + const toggle = (key: keyof ProfilePostsFilter) => { 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 374 + 375 + return { 376 + ...prev, 377 + [did]: { 378 + ...existing, 379 + [key]: !existing[key], // safely negate 380 + }, 381 + }; 382 + }); 383 + }; 384 + 385 + return ( 386 + <> 387 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 388 + Posts 389 + </div> */} 390 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 391 + <div> 392 + {posts.map((post) => ( 393 + <UniversalPostRendererATURILoader 394 + key={post.uri} 395 + atUri={post.uri} 396 + feedviewpost={true} 397 + filterNoReplies={!filters?.replies} 398 + filterMustHaveMedia={filters?.mediaOnly} 399 + filterMustBeReply={!filters?.posts} 400 + /> 401 + ))} 402 + </div> 403 + 404 + {/* Loading and "Load More" states */} 405 + {arePostsLoading && posts.length === 0 && ( 406 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 407 + )} 408 + {isFetchingNextPage && ( 409 + <div className="p-4 text-center text-gray-500">Loading more...</div> 410 + )} 411 + {hasNextPage && !isFetchingNextPage && ( 412 + <button 413 + onClick={() => fetchNextPage()} 414 + 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" 415 + > 416 + Load More Posts 417 + </button> 418 + )} 419 + {posts.length === 0 && !arePostsLoading && ( 420 + <div className="p-4 text-center text-gray-500">No posts found.</div> 421 + )} 422 + </> 423 + ); 424 + } 425 + 426 + function RepostsTab({ did }: { did: string }) { 427 + useReusableTabScrollRestore(`Profile` + did); 428 + const { 429 + data: identity, 430 + isLoading: isIdentityLoading, 431 + error: identityError, 432 + } = useQueryIdentity(did); 433 + 434 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 435 + 436 + const { 437 + data: repostsData, 438 + fetchNextPage, 439 + hasNextPage, 440 + isFetchingNextPage, 441 + isLoading: arePostsLoading, 442 + } = useInfiniteQueryAuthorFeed( 443 + resolvedDid, 444 + identity?.pds, 445 + "app.bsky.feed.repost" 446 + ); 447 + 448 + const reposts = React.useMemo( 449 + () => repostsData?.pages.flatMap((page) => page.records) ?? [], 450 + [repostsData] 451 + ); 452 + 453 + return ( 454 + <> 455 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 456 + Reposts 457 + </div> 458 + <div> 459 + {reposts.map((repost) => { 460 + if ( 461 + !repost || 462 + !repost?.value || 463 + !repost?.value?.subject || 464 + // @ts-expect-error blehhhhh 465 + !repost?.value?.subject?.uri 466 + ) 467 + return; 468 + const repostRecord = 469 + repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record; 470 + return ( 471 <UniversalPostRendererATURILoader 472 + key={repostRecord.subject.uri} 473 + atUri={repostRecord.subject.uri} 474 feedviewpost={true} 475 + repostedby={repost.uri} 476 /> 477 + ); 478 + })} 479 + </div> 480 + 481 + {/* Loading and "Load More" states */} 482 + {arePostsLoading && reposts.length === 0 && ( 483 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 484 + )} 485 + {isFetchingNextPage && ( 486 + <div className="p-4 text-center text-gray-500">Loading more...</div> 487 + )} 488 + {hasNextPage && !isFetchingNextPage && ( 489 + <button 490 + onClick={() => fetchNextPage()} 491 + 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" 492 + > 493 + Load More Posts 494 + </button> 495 + )} 496 + {reposts.length === 0 && !arePostsLoading && ( 497 + <div className="p-4 text-center text-gray-500">No posts found.</div> 498 + )} 499 + </> 500 + ); 501 + } 502 + 503 + function FeedsTab({ did }: { did: string }) { 504 + useReusableTabScrollRestore(`Profile` + did); 505 + const { 506 + data: identity, 507 + isLoading: isIdentityLoading, 508 + error: identityError, 509 + } = useQueryIdentity(did); 510 + 511 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 512 + 513 + const { 514 + data: feedsData, 515 + fetchNextPage, 516 + hasNextPage, 517 + isFetchingNextPage, 518 + isLoading: arePostsLoading, 519 + } = useInfiniteQueryAuthorFeed( 520 + resolvedDid, 521 + identity?.pds, 522 + "app.bsky.feed.generator" 523 + ); 524 525 + const feeds = React.useMemo( 526 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 527 + [feedsData] 528 + ); 529 + 530 + return ( 531 + <> 532 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 533 + Feeds 534 + </div> 535 + <div> 536 + {feeds.map((feed) => { 537 + if (!feed || !feed?.value) return; 538 + const feedGenRecord = 539 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 540 + return <FeedItemRender feed={feed as any} key={feed.uri} />; 541 + })} 542 + </div> 543 + 544 + {/* Loading and "Load More" states */} 545 + {arePostsLoading && feeds.length === 0 && ( 546 + <div className="p-4 text-center text-gray-500">Loading feeds...</div> 547 + )} 548 + {isFetchingNextPage && ( 549 + <div className="p-4 text-center text-gray-500">Loading more...</div> 550 + )} 551 + {hasNextPage && !isFetchingNextPage && ( 552 + <button 553 + onClick={() => fetchNextPage()} 554 + 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" 555 + > 556 + Load More Feeds 557 + </button> 558 + )} 559 + {feeds.length === 0 && !arePostsLoading && ( 560 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 561 + )} 562 + </> 563 + ); 564 + } 565 + 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 + 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 610 > 611 + <div className="font-semibold text-lg">{item.name}</div> 612 + <div className="text-sm text-gray-500 dark:text-gray-400"> 613 + {item.description} 614 + </div> 615 + <div className="mt-1 text-xs text-gray-400"> 616 + {item.blur && <span>Blur: {item.blur} </span>} 617 + {item.severity && <span>โ€ข Severity: {item.severity} </span>} 618 + {item.adultOnly && <span>โ€ข 18+ only</span>} 619 + </div> 620 + </div> 621 + ))} 622 + </div> 623 + 624 + {/* Loading and "Load More" states */} 625 + {!labelerRecord && ( 626 + <div className="p-4 text-center text-gray-500">Loading labels...</div> 627 + )} 628 + {/* {!labelerRecord && ( 629 + <div className="p-4 text-center text-gray-500">Loading more...</div> 630 + )} */} 631 + {/* {hasNextPage && !isFetchingNextPage && ( 632 + <button 633 + onClick={() => fetchNextPage()} 634 + 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" 635 + > 636 + Load More Feeds 637 + </button> 638 + )} 639 + {feeds.length === 0 && !arePostsLoading && ( 640 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 641 + )} */} 642 + </> 643 + ); 644 + } 645 + 646 + export function FeedItemRenderAturiLoader({ 647 + aturi, 648 + listmode, 649 + disableBottomBorder, 650 + disablePropagation, 651 + }: { 652 + aturi: string; 653 + listmode?: boolean; 654 + disableBottomBorder?: boolean; 655 + disablePropagation?: boolean; 656 + }) { 657 + const { data: record } = useQueryArbitrary(aturi); 658 + 659 + if (!record) return; 660 + return ( 661 + <FeedItemRender 662 + listmode={listmode} 663 + feed={record} 664 + disableBottomBorder={disableBottomBorder} 665 + disablePropagation={disablePropagation} 666 + /> 667 + ); 668 + } 669 + 670 + export function FeedItemRender({ 671 + feed, 672 + listmode, 673 + disableBottomBorder, 674 + disablePropagation, 675 + }: { 676 + feed: { uri: string; cid: string; value: any }; 677 + listmode?: boolean; 678 + disableBottomBorder?: boolean; 679 + disablePropagation?: boolean; 680 + }) { 681 + const name = listmode 682 + ? (feed.value?.name as string) 683 + : (feed.value?.displayName as string); 684 + const aturi = new ATPAPI.AtUri(feed.uri); 685 + const { data: identity } = useQueryIdentity(aturi.host); 686 + const resolvedDid = identity?.did; 687 + const [imgcdn] = useAtom(imgCDNAtom); 688 + 689 + function getAvatarThumbnailUrl(f: typeof feed) { 690 + const link = f?.value.avatar?.ref?.["$link"]; 691 + if (!link || !resolvedDid) return null; 692 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 693 + } 694 + 695 + const { data: likes } = useQueryConstellation( 696 + // @ts-expect-error overloads sucks 697 + !listmode 698 + ? { 699 + target: feed.uri, 700 + method: "/links/count", 701 + collection: "app.bsky.feed.like", 702 + path: ".subject.uri", 703 + } 704 + : undefined 705 + ); 706 + 707 + return ( 708 + <Link 709 + className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} 710 + to="/profile/$did/feed/$rkey" 711 + params={{ did: aturi.host, rkey: aturi.rkey }} 712 + onClick={(e) => { 713 + e.stopPropagation(); 714 + }} 715 + > 716 + <div className="flex flex-row gap-3"> 717 + <div className="min-w-10 min-h-10"> 718 + <img 719 + src={getAvatarThumbnailUrl(feed) || defaultpfp} 720 + className="h-10 w-10 rounded border" 721 + /> 722 + </div> 723 + <div className="flex flex-col"> 724 + <span className="">{name}</span> 725 + <span className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 726 + {feed.value.did || aturi.rkey} 727 + </span> 728 + </div> 729 + <div className="flex-1" /> 730 + {/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */} 731 + </div> 732 + <span className=" text-sm">{feed.value?.description}</span> 733 + {!listmode && ( 734 + <span className=" text-sm dark:text-gray-400 text-gray-500"> 735 + Liked by {((likes as unknown as any)?.total as number) || 0} users 736 + </span> 737 + )} 738 + </Link> 739 + ); 740 + } 741 + 742 + function ListsTab({ did }: { did: string }) { 743 + useReusableTabScrollRestore(`Profile` + did); 744 + const { 745 + data: identity, 746 + isLoading: isIdentityLoading, 747 + error: identityError, 748 + } = useQueryIdentity(did); 749 + 750 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 751 + 752 + const { 753 + data: feedsData, 754 + fetchNextPage, 755 + hasNextPage, 756 + isFetchingNextPage, 757 + isLoading: arePostsLoading, 758 + } = useInfiniteQueryAuthorFeed( 759 + resolvedDid, 760 + identity?.pds, 761 + "app.bsky.graph.list" 762 + ); 763 + 764 + const feeds = React.useMemo( 765 + () => feedsData?.pages.flatMap((page) => page.records) ?? [], 766 + [feedsData] 767 + ); 768 + 769 + return ( 770 + <> 771 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 772 + Feeds 773 + </div> 774 + <div> 775 + {feeds.map((feed) => { 776 + if (!feed || !feed?.value) return; 777 + const feedGenRecord = 778 + feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record; 779 + return ( 780 + <FeedItemRender listmode={true} feed={feed as any} key={feed.uri} /> 781 + ); 782 + })} 783 + </div> 784 + 785 + {/* Loading and "Load More" states */} 786 + {arePostsLoading && feeds.length === 0 && ( 787 + <div className="p-4 text-center text-gray-500">Loading lists...</div> 788 + )} 789 + {isFetchingNextPage && ( 790 + <div className="p-4 text-center text-gray-500">Loading more...</div> 791 + )} 792 + {hasNextPage && !isFetchingNextPage && ( 793 + <button 794 + onClick={() => fetchNextPage()} 795 + 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" 796 + > 797 + Load More Lists 798 + </button> 799 + )} 800 + {feeds.length === 0 && !arePostsLoading && ( 801 + <div className="p-4 text-center text-gray-500">No lists found.</div> 802 + )} 803 + </> 804 + ); 805 + } 806 + 807 + function SelfLikesTab({ did }: { did: string }) { 808 + useReusableTabScrollRestore(`Profile` + did); 809 + const { 810 + data: identity, 811 + isLoading: isIdentityLoading, 812 + error: identityError, 813 + } = useQueryIdentity(did); 814 + 815 + const resolvedDid = did.startsWith("did:") ? did : identity?.did; 816 + 817 + const { 818 + data: likesData, 819 + fetchNextPage, 820 + hasNextPage, 821 + isFetchingNextPage, 822 + isLoading: arePostsLoading, 823 + } = useInfiniteQueryAuthorFeed( 824 + resolvedDid, 825 + identity?.pds, 826 + "app.bsky.feed.like" 827 + ); 828 + 829 + const likes = React.useMemo( 830 + () => likesData?.pages.flatMap((page) => page.records) ?? [], 831 + [likesData] 832 + ); 833 + 834 + const { setFastState } = useFastSetLikesFromFeed(); 835 + const seededRef = React.useRef(new Set<string>()); 836 + 837 + useEffect(() => { 838 + for (const like of likes) { 839 + if (!seededRef.current.has(like.uri)) { 840 + seededRef.current.add(like.uri); 841 + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 842 + setFastState(record.subject.uri, { 843 + target: record.subject.uri, 844 + uri: like.uri, 845 + cid: like.cid, 846 + }); 847 + } 848 + } 849 + }, [likes, setFastState]); 850 + 851 + return ( 852 + <> 853 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 854 + Likes 855 + </div> 856 + <div> 857 + {likes.map((like) => { 858 + if ( 859 + !like || 860 + !like?.value || 861 + !like?.value?.subject || 862 + // @ts-expect-error blehhhhh 863 + !like?.value?.subject?.uri 864 + ) 865 + return; 866 + const likeRecord = 867 + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 868 + return ( 869 + <UniversalPostRendererATURILoader 870 + key={likeRecord.subject.uri} 871 + atUri={likeRecord.subject.uri} 872 + feedviewpost={true} 873 + /> 874 + ); 875 + })} 876 </div> 877 + 878 + {/* Loading and "Load More" states */} 879 + {arePostsLoading && likes.length === 0 && ( 880 + <div className="p-4 text-center text-gray-500">Loading likes...</div> 881 + )} 882 + {isFetchingNextPage && ( 883 + <div className="p-4 text-center text-gray-500">Loading more...</div> 884 + )} 885 + {hasNextPage && !isFetchingNextPage && ( 886 + <button 887 + onClick={() => fetchNextPage()} 888 + 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" 889 + > 890 + Load More Likes 891 + </button> 892 + )} 893 + {likes.length === 0 && !arePostsLoading && ( 894 + <div className="p-4 text-center text-gray-500">No likes found.</div> 895 + )} 896 </> 897 ); 898 } 899 + 900 + export function FollowButton({ 901 + targetdidorhandle, 902 + }: { 903 + targetdidorhandle: string; 904 + }) { 905 + const { agent } = useAuth(); 906 + const { data: identity } = useQueryIdentity(targetdidorhandle); 907 + const queryClient = useQueryClient(); 908 + 909 + const followRecords = useGetFollowState({ 910 + target: identity?.did ?? targetdidorhandle, 911 + user: agent?.did, 912 + }); 913 + 914 + return ( 915 + <> 916 + {identity?.did !== agent?.did ? ( 917 + <> 918 + {!(followRecords?.length && followRecords?.length > 0) ? ( 919 + <button 920 + onClick={(e) => { 921 + e.stopPropagation(); 922 + toggleFollow({ 923 + agent: agent || undefined, 924 + targetDid: identity?.did, 925 + followRecords: followRecords, 926 + queryClient: queryClient, 927 + }); 928 + }} 929 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 930 + > 931 + Follow 932 + </button> 933 + ) : ( 934 + <button 935 + onClick={(e) => { 936 + e.stopPropagation(); 937 + toggleFollow({ 938 + agent: agent || undefined, 939 + targetDid: identity?.did, 940 + followRecords: followRecords, 941 + queryClient: queryClient, 942 + }); 943 + }} 944 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 945 + > 946 + Unfollow 947 + </button> 948 + )} 949 + </> 950 + ) : ( 951 + <button 952 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 953 + onClick={(e) => { 954 + renderSnack({ 955 + title: "Not Implemented Yet", 956 + description: "Sorry...", 957 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 958 + }); 959 + }} 960 + > 961 + Edit Profile 962 + </button> 963 + )} 964 + </> 965 + ); 966 + } 967 + 968 + export function BiteButton({ 969 + targetdidorhandle, 970 + }: { 971 + targetdidorhandle: string; 972 + }) { 973 + const { agent } = useAuth(); 974 + const { data: identity } = useQueryIdentity(targetdidorhandle); 975 + const [show] = useAtom(enableBitesAtom); 976 + 977 + if (!show) return; 978 + 979 + return ( 980 + <> 981 + <button 982 + onClick={async (e) => { 983 + e.stopPropagation(); 984 + await sendBite({ 985 + agent: agent || undefined, 986 + targetDid: identity?.did, 987 + }); 988 + }} 989 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 990 + > 991 + Bite 992 + </button> 993 + </> 994 + ); 995 + } 996 + 997 + async function sendBite({ 998 + agent, 999 + targetDid, 1000 + }: { 1001 + agent?: Agent; 1002 + targetDid?: string; 1003 + }) { 1004 + if (!agent?.did || !targetDid) { 1005 + renderSnack({ 1006 + title: "Bite Failed", 1007 + description: "You must be logged-in to bite someone.", 1008 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1009 + }); 1010 + return; 1011 + } 1012 + const newRecord = { 1013 + repo: agent.did, 1014 + collection: "net.wafrn.feed.bite", 1015 + rkey: TID.next().toString(), 1016 + record: { 1017 + $type: "net.wafrn.feed.bite", 1018 + subject: "at://" + targetDid, 1019 + createdAt: new Date().toISOString(), 1020 + }, 1021 + }; 1022 + 1023 + try { 1024 + await agent.com.atproto.repo.createRecord(newRecord); 1025 + renderSnack({ 1026 + title: "Bite Sent", 1027 + description: "Your bite was delivered.", 1028 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 1029 + }); 1030 + } catch (err) { 1031 + console.error("Bite failed:", err); 1032 + renderSnack({ 1033 + title: "Bite Failed", 1034 + description: "Your bite failed to be delivered.", 1035 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1036 + }); 1037 + } 1038 + } 1039 + 1040 + export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 1041 + const { agent } = useAuth(); 1042 + const { data: identity } = useQueryIdentity(targetdidorhandle); 1043 + 1044 + const theyFollowYouRes = useGetOneToOneState( 1045 + agent?.did 1046 + ? { 1047 + target: agent?.did, 1048 + user: identity?.did ?? targetdidorhandle, 1049 + collection: "app.bsky.graph.follow", 1050 + path: ".subject", 1051 + } 1052 + : undefined 1053 + ); 1054 + 1055 + const youFollowThemRes = useGetFollowState({ 1056 + target: identity?.did ?? targetdidorhandle, 1057 + user: agent?.did, 1058 + }); 1059 + 1060 + const theyFollowYou: boolean = 1061 + !!theyFollowYouRes?.length && theyFollowYouRes.length > 0; 1062 + const youFollowThem: boolean = 1063 + !!youFollowThemRes?.length && youFollowThemRes.length > 0; 1064 + 1065 + return ( 1066 + <> 1067 + {/* if not self */} 1068 + {identity?.did !== agent?.did ? ( 1069 + <> 1070 + {theyFollowYou ? ( 1071 + <> 1072 + {youFollowThem ? ( 1073 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 1074 + mutuals 1075 + </div> 1076 + ) : ( 1077 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 1078 + follows you 1079 + </div> 1080 + )} 1081 + </> 1082 + ) : ( 1083 + <></> 1084 + )} 1085 + </> 1086 + ) : ( 1087 + // lmao can someone be mutuals with themselves ?? 1088 + <></> 1089 + )} 1090 + </> 1091 + ); 1092 + } 1093 + 1094 + export function RichTextRenderer({ description }: { description: string }) { 1095 + const [richDescription, setRichDescription] = useState<string | ReactNode[]>( 1096 + description 1097 + ); 1098 + const { agent } = useAuth(); 1099 + const navigate = useNavigate(); 1100 + 1101 + useEffect(() => { 1102 + let mounted = true; 1103 + 1104 + // setRichDescription(description); 1105 + 1106 + async function processRichText() { 1107 + try { 1108 + if (!agent?.did) return; 1109 + const rt = new RichText({ text: description }); 1110 + await rt.detectFacets(agent); 1111 + 1112 + if (!mounted) return; 1113 + 1114 + if (rt.facets) { 1115 + setRichDescription( 1116 + renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate }) 1117 + ); 1118 + } else { 1119 + setRichDescription(rt.text); 1120 + } 1121 + } catch (error) { 1122 + console.error("Failed to detect facets:", error); 1123 + if (mounted) { 1124 + setRichDescription(description); 1125 + } 1126 + } 1127 + } 1128 + 1129 + processRichText(); 1130 + 1131 + return () => { 1132 + mounted = false; 1133 + }; 1134 + }, [description, agent, navigate]); 1135 + 1136 + return <>{richDescription}</>; 1137 + }
+1 -1
src/routes/profile.$did/post.$rkey.image.$i.tsx
··· 85 e.stopPropagation(); 86 e.nativeEvent.stopImmediatePropagation(); 87 }} 88 - className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 > 90 <ProfilePostComponent 91 key={`/profile/${did}/post/${rkey}`}
··· 85 e.stopPropagation(); 86 e.nativeEvent.stopImmediatePropagation(); 87 }} 88 + className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 > 90 <ProfilePostComponent 91 key={`/profile/${did}/post/${rkey}`}
+100
src/routes/profile.$did/post.$rkey.liked-by.tsx
···
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/liked-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.like", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteLikesData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const likesAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteLikesData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteLikesData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Liked By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading likes..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!likesAturis?.length) 81 + return <EmptyState text="No likes yet." />; 82 + })()} 83 + </> 84 + 85 + {likesAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + 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 disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+141
src/routes/profile.$did/post.$rkey.quotes.tsx
···
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 + import { constellationURLAtom } from "~/utils/atoms"; 9 + import { type linksRecord,useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 10 + 11 + import { 12 + EmptyState, 13 + ErrorState, 14 + LoadingState, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/quotes")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresultsWithoutMedia = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.post", 34 + path: ".embed.record.uri", // embed.record.record.uri and embed.record.uri 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + const infinitequeryresultsWithMedia = useInfiniteQuery({ 40 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 41 + { 42 + constellation: constellationurl, 43 + method: "/links", 44 + target: atUri, 45 + collection: "app.bsky.feed.post", 46 + path: ".embed.record.record.uri", // embed.record.record.uri and embed.record.uri 47 + } 48 + ), 49 + enabled: !!atUri, 50 + }); 51 + 52 + const { 53 + data: infiniteQuotesDataWithoutMedia, 54 + fetchNextPage: fetchNextPageWithoutMedia, 55 + hasNextPage: hasNextPageWithoutMedia, 56 + isFetchingNextPage: isFetchingNextPageWithoutMedia, 57 + isLoading: isLoadingWithoutMedia, 58 + isError: isErrorWithoutMedia, 59 + error: errorWithoutMedia, 60 + } = infinitequeryresultsWithoutMedia; 61 + const { 62 + data: infiniteQuotesDataWithMedia, 63 + fetchNextPage: fetchNextPageWithMedia, 64 + hasNextPage: hasNextPageWithMedia, 65 + isFetchingNextPage: isFetchingNextPageWithMedia, 66 + isLoading: isLoadingWithMedia, 67 + isError: isErrorWithMedia, 68 + error: errorWithMedia, 69 + } = infinitequeryresultsWithMedia; 70 + 71 + const fetchNextPage = async () => { 72 + await Promise.all([ 73 + hasNextPageWithMedia && fetchNextPageWithMedia(), 74 + hasNextPageWithoutMedia && fetchNextPageWithoutMedia(), 75 + ]); 76 + }; 77 + 78 + const hasNextPage = hasNextPageWithMedia || hasNextPageWithoutMedia; 79 + const isFetchingNextPage = isFetchingNextPageWithMedia || isFetchingNextPageWithoutMedia; 80 + const isLoading = isLoadingWithMedia || isLoadingWithoutMedia; 81 + 82 + const allQuotes = React.useMemo(() => { 83 + const withPages = infiniteQuotesDataWithMedia?.pages ?? []; 84 + const withoutPages = infiniteQuotesDataWithoutMedia?.pages ?? []; 85 + const maxLen = Math.max(withPages.length, withoutPages.length); 86 + const merged: linksRecord[] = []; 87 + 88 + for (let i = 0; i < maxLen; i++) { 89 + const a = withPages[i]?.linking_records ?? []; 90 + const b = withoutPages[i]?.linking_records ?? []; 91 + const mergedPage = [...a, ...b].sort((b, a) => a.rkey.localeCompare(b.rkey)); 92 + merged.push(...mergedPage); 93 + } 94 + 95 + return merged; 96 + }, [infiniteQuotesDataWithMedia?.pages, infiniteQuotesDataWithoutMedia?.pages]); 97 + 98 + const quotesAturis = React.useMemo(() => { 99 + return allQuotes.flatMap((r) => `at://${r.did}/${r.collection}/${r.rkey}`); 100 + }, [allQuotes]); 101 + 102 + return ( 103 + <> 104 + <Header 105 + title={`Quotes`} 106 + backButtonCallback={() => { 107 + if (window.history.length > 1) { 108 + window.history.back(); 109 + } else { 110 + window.location.assign("/"); 111 + } 112 + }} 113 + /> 114 + 115 + <> 116 + {(() => { 117 + if (isLoading) return <LoadingState text="Loading quotes..." />; 118 + if (isErrorWithMedia) return <ErrorState error={errorWithMedia} />; 119 + if (isErrorWithoutMedia) return <ErrorState error={errorWithoutMedia} />; 120 + 121 + if (!quotesAturis?.length) 122 + return <EmptyState text="No quotes yet." />; 123 + })()} 124 + </> 125 + 126 + {quotesAturis.map((m) => ( 127 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 128 + ))} 129 + 130 + {hasNextPage && ( 131 + <button 132 + onClick={() => fetchNextPage()} 133 + disabled={isFetchingNextPage} 134 + 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 disabled:opacity-50" 135 + > 136 + {isFetchingNextPage ? "Loading..." : "Load More"} 137 + </button> 138 + )} 139 + </> 140 + ); 141 + }
+100
src/routes/profile.$did/post.$rkey.reposted-by.tsx
···
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 4 + import React from "react"; 5 + 6 + import { Header } from "~/components/Header"; 7 + import { constellationURLAtom } from "~/utils/atoms"; 8 + import { useQueryIdentity, yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks } from "~/utils/useQuery"; 9 + 10 + import { 11 + EmptyState, 12 + ErrorState, 13 + LoadingState, 14 + NotificationItem, 15 + } from "../notifications"; 16 + 17 + export const Route = createFileRoute("/profile/$did/post/$rkey/reposted-by")({ 18 + component: RouteComponent, 19 + }); 20 + 21 + function RouteComponent() { 22 + const { did, rkey } = Route.useParams(); 23 + const { data: identity } = useQueryIdentity(did); 24 + const atUri = identity?.did && rkey ? `at://${decodeURIComponent(identity.did)}/app.bsky.feed.post/${rkey}` : ''; 25 + 26 + const [constellationurl] = useAtom(constellationURLAtom); 27 + const infinitequeryresults = useInfiniteQuery({ 28 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 29 + { 30 + constellation: constellationurl, 31 + method: "/links", 32 + target: atUri, 33 + collection: "app.bsky.feed.repost", 34 + path: ".subject.uri", 35 + } 36 + ), 37 + enabled: !!atUri, 38 + }); 39 + 40 + const { 41 + data: infiniteRepostsData, 42 + fetchNextPage, 43 + hasNextPage, 44 + isFetchingNextPage, 45 + isLoading, 46 + isError, 47 + error, 48 + } = infinitequeryresults; 49 + 50 + const repostsAturis = React.useMemo(() => { 51 + // Get all replies from the standard infinite query 52 + return ( 53 + infiniteRepostsData?.pages.flatMap( 54 + (page) => 55 + page?.linking_records.map( 56 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 57 + ) ?? [] 58 + ) ?? [] 59 + ); 60 + }, [infiniteRepostsData]); 61 + 62 + return ( 63 + <> 64 + <Header 65 + title={`Reposted By`} 66 + backButtonCallback={() => { 67 + if (window.history.length > 1) { 68 + window.history.back(); 69 + } else { 70 + window.location.assign("/"); 71 + } 72 + }} 73 + /> 74 + 75 + <> 76 + {(() => { 77 + if (isLoading) return <LoadingState text="Loading reposts..." />; 78 + if (isError) return <ErrorState error={error} />; 79 + 80 + if (!repostsAturis?.length) 81 + return <EmptyState text="No reposts yet." />; 82 + })()} 83 + </> 84 + 85 + {repostsAturis.map((m) => ( 86 + <NotificationItem key={m} notification={m} /> 87 + ))} 88 + 89 + {hasNextPage && ( 90 + <button 91 + onClick={() => fetchNextPage()} 92 + disabled={isFetchingNextPage} 93 + 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 disabled:opacity-50" 94 + > 95 + {isFetchingNextPage ? "Loading..." : "Load More"} 96 + </button> 97 + )} 98 + </> 99 + ); 100 + }
+107 -93
src/routes/profile.$did/post.$rkey.tsx
··· 1 import { AtUri } from "@atproto/api"; 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, Outlet } from "@tanstack/react-router"; 4 import React, { useLayoutEffect } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 //import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 9 import { 10 constructPostQuery, ··· 50 nopics?: boolean; 51 lightboxCallback?: (d: LightboxProps) => void; 52 }) { 53 //const { get, set } = usePersistentStore(); 54 const queryClient = useQueryClient(); 55 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 188 data: identity, 189 isLoading: isIdentityLoading, 190 error: identityError, 191 - } = useQueryIdentity(did); 192 193 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 194 195 const atUri = React.useMemo( 196 () => 197 - resolvedDid 198 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 199 : undefined, 200 - [resolvedDid, rkey] 201 ); 202 203 - const { data: mainPost } = useQueryPost(atUri); 204 205 console.log("atUri",atUri) 206 ··· 213 ); 214 215 // @ts-expect-error i hate overloads 216 - const { data: links } = useQueryConstellation(atUri?{ 217 method: "/links/all", 218 target: atUri, 219 } : { ··· 246 }, [links]); 247 248 const { data: opreplies } = useQueryConstellation( 249 - !!opdid && replyCount && replyCount >= 25 250 ? { 251 method: "/links", 252 target: atUri, ··· 275 // path: ".reply.parent.uri", 276 // }); 277 // const replies = repliesData?.linking_records.slice(0, 50) ?? []; 278 const infinitequeryresults = useInfiniteQuery({ 279 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 280 { 281 method: "/links", 282 target: atUri, 283 collection: "app.bsky.feed.post", 284 path: ".reply.parent.uri", 285 } 286 ), 287 - enabled: !!atUri, 288 }); 289 290 const { ··· 366 const [layoutReady, setLayoutReady] = React.useState(false); 367 368 useLayoutEffect(() => { 369 if (parents.length > 0 && !layoutReady && mainPostRef.current) { 370 const mainPostElement = mainPostRef.current; 371 ··· 384 // eslint-disable-next-line react-hooks/set-state-in-effect 385 setLayoutReady(true); 386 } 387 - }, [parents, layoutReady]); 388 389 React.useEffect(() => { 390 - if (parentsLoading) { 391 setLayoutReady(false); 392 } 393 ··· 395 setLayoutReady(true); 396 hasPerformedInitialLayout.current = true; 397 } 398 - }, [parentsLoading, mainPost]); 399 400 React.useEffect(() => { 401 if (!mainPost?.value?.reply?.parent?.uri) { ··· 414 while (currentParentUri && safetyCounter < MAX_PARENTS) { 415 try { 416 const parentPost = await queryClient.fetchQuery( 417 - constructPostQuery(currentParentUri) 418 ); 419 if (!parentPost) break; 420 parentChain.push(parentPost); ··· 436 return () => { 437 ignore = true; 438 }; 439 - }, [mainPost, queryClient]); 440 441 - if (!did || !rkey) return <div>Invalid post URI</div>; 442 - if (isIdentityLoading) return <div>Resolving handle...</div>; 443 - if (identityError) 444 return <div style={{ color: "red" }}>{identityError.message}</div>; 445 - if (!atUri) return <div>Could not construct post URI.</div>; 446 447 return ( 448 <> 449 <Outlet /> 450 - <Header 451 - title={`Post`} 452 - backButtonCallback={() => { 453 - if (window.history.length > 1) { 454 - window.history.back(); 455 - } else { 456 - window.location.assign("/"); 457 - } 458 - }} 459 - /> 460 461 - {parentsLoading && ( 462 - <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 463 - <div className="ml-4 w-[42px] flex justify-center"> 464 - <div 465 - style={{ width: 2, height: "100%", opacity: 0.5 }} 466 - className="bg-gray-500 dark:bg-gray-400" 467 - ></div> 468 </div> 469 - Loading conversation... 470 - </div> 471 - )} 472 473 - {/* we should use the reply lines here thats provided by UPR*/} 474 - <div style={{ maxWidth: 600, padding: 0 }}> 475 - {parents.map((parent, index) => ( 476 <UniversalPostRendererATURILoader 477 - key={parent.uri} 478 - atUri={parent.uri} 479 - topReplyLine={index > 0} 480 - bottomReplyLine={true} 481 - bottomBorder={false} 482 /> 483 - ))} 484 - </div> 485 - <div ref={mainPostRef}> 486 - <UniversalPostRendererATURILoader 487 - atUri={atUri} 488 - detailed={true} 489 - topReplyLine={parentsLoading || parents.length > 0} 490 - nopics={!!nopics} 491 - lightboxCallback={lightboxCallback} 492 - /> 493 - </div> 494 - <div 495 - style={{ 496 - maxWidth: 600, 497 - //margin: "0px auto 0", 498 - padding: 0, 499 - minHeight: "80dvh", 500 - paddingBottom: "20dvh", 501 - }} 502 - > 503 <div 504 - className="text-gray-500 dark:text-gray-400 text-sm font-bold" 505 style={{ 506 - fontSize: 18, 507 - margin: "12px 16px 12px 16px", 508 - fontWeight: 600, 509 }} 510 > 511 - Replies 512 - </div> 513 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 514 - {replyAturis.length > 0 && 515 - replyAturis.map((reply) => { 516 - //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 517 - return ( 518 - <UniversalPostRendererATURILoader 519 - key={reply} 520 - atUri={reply} 521 - maxReplies={4} 522 - /> 523 - ); 524 - })} 525 - {hasNextPage && ( 526 - <button 527 - onClick={() => fetchNextPage()} 528 - disabled={isFetchingNextPage} 529 - 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 disabled:opacity-50" 530 - > 531 - {isFetchingNextPage ? "Loading..." : "Load More"} 532 - </button> 533 - )} 534 </div> 535 - </div> 536 </> 537 ); 538 }
··· 1 import { AtUri } from "@atproto/api"; 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 import React, { useLayoutEffect } from "react"; 6 7 import { Header } from "~/components/Header"; 8 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 9 + import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms"; 10 //import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 11 import { 12 constructPostQuery, ··· 52 nopics?: boolean; 53 lightboxCallback?: (d: LightboxProps) => void; 54 }) { 55 + const matchRoute = useMatchRoute() 56 + const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' }) 57 + 58 //const { get, set } = usePersistentStore(); 59 const queryClient = useQueryClient(); 60 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 193 data: identity, 194 isLoading: isIdentityLoading, 195 error: identityError, 196 + } = useQueryIdentity(showMainPostRoute ? did : undefined); 197 198 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 199 200 const atUri = React.useMemo( 201 () => 202 + resolvedDid && showMainPostRoute 203 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 204 : undefined, 205 + [resolvedDid, rkey, showMainPostRoute] 206 ); 207 208 + const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined); 209 210 console.log("atUri",atUri) 211 ··· 218 ); 219 220 // @ts-expect-error i hate overloads 221 + const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{ 222 method: "/links/all", 223 target: atUri, 224 } : { ··· 251 }, [links]); 252 253 const { data: opreplies } = useQueryConstellation( 254 + showMainPostRoute && !!opdid && replyCount && replyCount >= 25 255 ? { 256 method: "/links", 257 target: atUri, ··· 280 // path: ".reply.parent.uri", 281 // }); 282 // const replies = repliesData?.linking_records.slice(0, 50) ?? []; 283 + const [constellationurl] = useAtom(constellationURLAtom) 284 + 285 const infinitequeryresults = useInfiniteQuery({ 286 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 287 { 288 + constellation: constellationurl, 289 method: "/links", 290 target: atUri, 291 collection: "app.bsky.feed.post", 292 path: ".reply.parent.uri", 293 } 294 ), 295 + enabled: !!atUri && showMainPostRoute, 296 }); 297 298 const { ··· 374 const [layoutReady, setLayoutReady] = React.useState(false); 375 376 useLayoutEffect(() => { 377 + if (!showMainPostRoute) return 378 if (parents.length > 0 && !layoutReady && mainPostRef.current) { 379 const mainPostElement = mainPostRef.current; 380 ··· 393 // eslint-disable-next-line react-hooks/set-state-in-effect 394 setLayoutReady(true); 395 } 396 + }, [parents, layoutReady, showMainPostRoute]); 397 398 + 399 + const [slingshoturl] = useAtom(slingshotURLAtom) 400 + 401 React.useEffect(() => { 402 + if (parentsLoading || !showMainPostRoute) { 403 setLayoutReady(false); 404 } 405 ··· 407 setLayoutReady(true); 408 hasPerformedInitialLayout.current = true; 409 } 410 + }, [parentsLoading, mainPost, showMainPostRoute]); 411 412 React.useEffect(() => { 413 if (!mainPost?.value?.reply?.parent?.uri) { ··· 426 while (currentParentUri && safetyCounter < MAX_PARENTS) { 427 try { 428 const parentPost = await queryClient.fetchQuery( 429 + constructPostQuery(currentParentUri, slingshoturl) 430 ); 431 if (!parentPost) break; 432 parentChain.push(parentPost); ··· 448 return () => { 449 ignore = true; 450 }; 451 + }, [mainPost, queryClient, slingshoturl]); 452 453 + if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>; 454 + if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>; 455 + if (identityError && showMainPostRoute) 456 return <div style={{ color: "red" }}>{identityError.message}</div>; 457 + if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>; 458 459 return ( 460 <> 461 <Outlet /> 462 + {showMainPostRoute && (<> 463 + <Header 464 + title={`Post`} 465 + backButtonCallback={() => { 466 + if (window.history.length > 1) { 467 + window.history.back(); 468 + } else { 469 + window.location.assign("/"); 470 + } 471 + }} 472 + /> 473 474 + {parentsLoading && ( 475 + <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 476 + <div className="ml-4 w-[42px] flex justify-center"> 477 + <div 478 + style={{ width: 2, height: "100%", opacity: 0.5 }} 479 + className="bg-gray-500 dark:bg-gray-400" 480 + ></div> 481 + </div> 482 + Loading conversation... 483 </div> 484 + )} 485 486 + {/* we should use the reply lines here thats provided by UPR*/} 487 + <div style={{ maxWidth: 600, padding: 0 }}> 488 + {parents.map((parent, index) => ( 489 + <UniversalPostRendererATURILoader 490 + key={parent.uri} 491 + atUri={parent.uri} 492 + topReplyLine={index > 0} 493 + bottomReplyLine={true} 494 + bottomBorder={false} 495 + /> 496 + ))} 497 + </div> 498 + <div ref={mainPostRef}> 499 <UniversalPostRendererATURILoader 500 + atUri={atUri!} 501 + detailed={true} 502 + topReplyLine={parentsLoading || parents.length > 0} 503 + nopics={!!nopics} 504 + lightboxCallback={lightboxCallback} 505 /> 506 + </div> 507 <div 508 style={{ 509 + maxWidth: 600, 510 + //margin: "0px auto 0", 511 + padding: 0, 512 + minHeight: "80dvh", 513 + paddingBottom: "20dvh", 514 }} 515 > 516 + <div 517 + className="text-gray-500 dark:text-gray-400 text-sm font-bold" 518 + style={{ 519 + fontSize: 18, 520 + margin: "12px 16px 12px 16px", 521 + fontWeight: 600, 522 + }} 523 + > 524 + Replies 525 + </div> 526 + <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 527 + {replyAturis.length > 0 && 528 + replyAturis.map((reply) => { 529 + //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 530 + return ( 531 + <UniversalPostRendererATURILoader 532 + key={reply} 533 + atUri={reply} 534 + maxReplies={4} 535 + /> 536 + ); 537 + })} 538 + {hasNextPage && ( 539 + <button 540 + onClick={() => fetchNextPage()} 541 + disabled={isFetchingNextPage} 542 + 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 disabled:opacity-50" 543 + > 544 + {isFetchingNextPage ? "Loading..." : "Load More"} 545 + </button> 546 + )} 547 + </div> 548 </div> 549 + </>)} 550 </> 551 ); 552 }
+259 -2
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 3 export const Route = createFileRoute("/search")({ 4 component: Search, 5 }); 6 7 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 9 }
··· 1 + import type { Agent } from "@atproto/api"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import { useEffect,useMemo } from "react"; 6 + 7 + import { Header } from "~/components/Header"; 8 + import { Import } from "~/components/Import"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 15 + import { lycanURLAtom } from "~/utils/atoms"; 16 + import { 17 + constructLycanRequestIndexQuery, 18 + useInfiniteQueryLycanSearch, 19 + useQueryIdentity, 20 + useQueryLycanStatus, 21 + } from "~/utils/useQuery"; 22 + 23 + import { renderSnack } from "./__root"; 24 + import { SliderPrimitive } from "./settings"; 25 26 export const Route = createFileRoute("/search")({ 27 component: Search, 28 }); 29 30 export function Search() { 31 + const queryClient = useQueryClient(); 32 + const { agent, status } = useAuth(); 33 + const { data: identity } = useQueryIdentity(agent?.did); 34 + const [lycandomain] = useAtom(lycanURLAtom); 35 + const lycanExists = lycandomain !== ""; 36 + const { data: lycanstatusdata, refetch } = useQueryLycanStatus(); 37 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 38 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 39 + const lycanIndexingProgress = lycanIndexing 40 + ? lycanstatusdata?.progress 41 + : undefined; 42 + 43 + const authed = status === "signedIn"; 44 + 45 + const lycanReady = lycanExists && lycanIndexed && authed; 46 + 47 + const { q }: { q: string } = useSearch({ from: "/search" }); 48 + 49 + // auto-refetch Lycan status until ready 50 + useEffect(() => { 51 + if (!lycanExists || !authed) return; 52 + if (lycanReady) return; 53 + 54 + const interval = setInterval(() => { 55 + refetch(); 56 + }, 3000); 57 + 58 + return () => clearInterval(interval); 59 + }, [lycanExists, authed, lycanReady, refetch]); 60 + 61 + const maintext = !lycanExists 62 + ? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:" 63 + : authed 64 + ? lycanReady 65 + ? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:" 66 + : "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:" 67 + : "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:"; 68 + 69 + async function index(opts: { 70 + agent?: Agent; 71 + isAuthed: boolean; 72 + pdsUrl?: string; 73 + feedServiceDid?: string; 74 + }) { 75 + renderSnack({ 76 + title: "Registering account...", 77 + }); 78 + try { 79 + const response = await queryClient.fetchQuery( 80 + constructLycanRequestIndexQuery(opts) 81 + ); 82 + if ( 83 + response?.message !== "Import has already started" && 84 + response?.message !== "Import has been scheduled" 85 + ) { 86 + renderSnack({ 87 + title: "Registration failed!", 88 + description: "Unknown server error (2)", 89 + }); 90 + } else { 91 + renderSnack({ 92 + title: "Succesfully sent registration request!", 93 + description: "Please wait for the server to index your account", 94 + }); 95 + refetch(); 96 + } 97 + } catch { 98 + renderSnack({ 99 + title: "Registration failed!", 100 + description: "Unknown server error (1)", 101 + }); 102 + } 103 + } 104 + 105 + return ( 106 + <> 107 + <Header 108 + title="Explore" 109 + backButtonCallback={() => { 110 + if (window.history.length > 1) { 111 + window.history.back(); 112 + } else { 113 + window.location.assign("/"); 114 + } 115 + }} 116 + /> 117 + <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 118 + <Import optionaltextstring={q} /> 119 + <div className="flex flex-col"> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 121 + <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 122 + <li> 123 + Bluesky URLs (from supported clients) (like{" "} 124 + <code className="text-sm">bsky.app</code> or{" "} 125 + <code className="text-sm">deer.social</code>). 126 + </li> 127 + <li> 128 + AT-URIs (e.g.,{" "} 129 + <code className="text-sm">at://did:example/collection/item</code> 130 + ). 131 + </li> 132 + <li> 133 + User Handles (like{" "} 134 + <code className="text-sm">@username.bsky.social</code>). 135 + </li> 136 + <li> 137 + DIDs (Decentralized Identifiers, starting with{" "} 138 + <code className="text-sm">did:</code>). 139 + </li> 140 + </ul> 141 + <p className="mt-2 text-gray-600 dark:text-gray-400"> 142 + Simply paste one of these into the import field above and press 143 + Enter to load the content. 144 + </p> 145 + 146 + {lycanExists && authed && !lycanReady ? ( 147 + !lycanIndexing ? ( 148 + <div className="mt-4 mx-auto"> 149 + <button 150 + onClick={() => 151 + index({ 152 + agent: agent || undefined, 153 + isAuthed: status === "signedIn", 154 + pdsUrl: identity?.pds, 155 + feedServiceDid: "did:web:" + lycandomain, 156 + }) 157 + } 158 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 159 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 160 + > 161 + Index my Account 162 + </button> 163 + </div> 164 + ) : ( 165 + <div className="mt-4 gap-2 flex flex-col"> 166 + <span>indexing...</span> 167 + <SliderPrimitive 168 + value={lycanIndexingProgress || 0} 169 + min={0} 170 + max={1} 171 + /> 172 + </div> 173 + ) 174 + ) : ( 175 + <></> 176 + )} 177 + </div> 178 + </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 180 + </> 181 + ); 182 + } 183 + 184 + function SearchTabs({ query }: { query: string }) { 185 + return ( 186 + <div> 187 + <ReusableTabRoute 188 + route={`search` + query} 189 + tabs={{ 190 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 191 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 192 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 193 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 194 + }} 195 + /> 196 + </div> 197 + ); 198 + } 199 + 200 + function LycanTab({ 201 + query, 202 + type, 203 + }: { 204 + query: string; 205 + type: "likes" | "pins" | "reposts" | "quotes"; 206 + }) { 207 + useReusableTabScrollRestore("search" + query); 208 + 209 + const { 210 + data: postsData, 211 + fetchNextPage, 212 + hasNextPage, 213 + isFetchingNextPage, 214 + isLoading: arePostsLoading, 215 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 216 + 217 + const posts = useMemo( 218 + () => 219 + postsData?.pages.flatMap((page) => { 220 + if (page) { 221 + return page.posts; 222 + } else { 223 + return []; 224 + } 225 + }) ?? [], 226 + [postsData] 227 + ); 228 + 229 + return ( 230 + <> 231 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 232 + Posts 233 + </div> */} 234 + <div> 235 + {posts.map((post) => ( 236 + <UniversalPostRendererATURILoader 237 + key={post} 238 + atUri={post} 239 + feedviewpost={true} 240 + /> 241 + ))} 242 + </div> 243 + 244 + {/* Loading and "Load More" states */} 245 + {arePostsLoading && posts.length === 0 && ( 246 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 247 + )} 248 + {isFetchingNextPage && ( 249 + <div className="p-4 text-center text-gray-500">Loading more...</div> 250 + )} 251 + {hasNextPage && !isFetchingNextPage && ( 252 + <button 253 + onClick={() => fetchNextPage()} 254 + 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" 255 + > 256 + Load More Posts 257 + </button> 258 + )} 259 + {posts.length === 0 && !arePostsLoading && ( 260 + <div className="p-4 text-center text-gray-500">No posts found.</div> 261 + )} 262 + </> 263 + ); 264 + 265 + return <></>; 266 }
+277 -13
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 4 import { Header } from "~/components/Header"; 5 import Login from "~/components/Login"; 6 import { 7 constellationURLAtom, 8 defaultconstellationURL, 9 defaultslingshotURL, 10 slingshotURLAtom, 11 } from "~/utils/atoms"; 12 13 export const Route = createFileRoute("/settings")({ 14 component: Settings, 15 }); 16 17 export function Settings() { 18 return ( 19 <> 20 <Header ··· 27 } 28 }} 29 /> 30 - <Login /> 31 <TextInputSetting 32 atom={constellationURLAtom} 33 - title={"Constellation URL"} 34 description={ 35 - "customize the Constellation instance to be used by Red Dwarf" 36 } 37 init={defaultconstellationURL} 38 /> 39 <TextInputSetting 40 atom={slingshotURLAtom} 41 - title={"Slingshot URL"} 42 - description={"customize the Slingshot instance to be used by Red Dwarf"} 43 init={defaultslingshotURL} 44 /> 45 </> 46 ); 47 } 48 49 export function TextInputSetting({ 50 atom, 51 title, ··· 59 }) { 60 const [value, setValue] = useAtom(atom); 61 return ( 62 - <div className="flex flex-col gap-2 p-4 rounded-2xl border border-gray-200 dark:border-gray-800 "> 63 - <div> 64 {title && ( 65 <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> 66 {title} ··· 71 {description} 72 </p> 73 )} 74 - </div> 75 76 <div className="flex flex-row gap-2 items-center"> 77 - <input 78 type="text" 79 value={value} 80 onChange={(e) => setValue(e.target.value)} ··· 82 text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 83 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600" 84 placeholder="Enter value..." 85 - /> 86 <button 87 onClick={() => setValue(init ?? "")} 88 - className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 89 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 90 > 91 Reset ··· 94 </div> 95 ); 96 }
··· 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect, useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import Login from "~/components/Login"; 8 import { 9 constellationURLAtom, 10 defaultconstellationURL, 11 + defaulthue, 12 + defaultImgCDN, 13 + defaultLycanURL, 14 defaultslingshotURL, 15 + defaultVideoCDN, 16 + enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 19 + hueAtom, 20 + imgCDNAtom, 21 + lycanURLAtom, 22 slingshotURLAtom, 23 + videoCDNAtom, 24 } from "~/utils/atoms"; 25 + 26 + import { MaterialNavItem } from "./__root"; 27 28 export const Route = createFileRoute("/settings")({ 29 component: Settings, 30 }); 31 32 export function Settings() { 33 + const navigate = useNavigate(); 34 return ( 35 <> 36 <Header ··· 43 } 44 }} 45 /> 46 + <div className="lg:hidden"> 47 + <Login /> 48 + </div> 49 + <div className="sm:hidden flex flex-col justify-around mt-4"> 50 + <SettingHeading title="Other Pages" top /> 51 + <MaterialNavItem 52 + InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 53 + ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 54 + active={false} 55 + onClickCallbback={() => 56 + navigate({ 57 + to: "/feeds", 58 + //params: { did: agent.assertDid }, 59 + }) 60 + } 61 + text="Feeds" 62 + /> 63 + <MaterialNavItem 64 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 65 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 66 + active={false} 67 + onClickCallbback={() => 68 + navigate({ 69 + to: "/moderation", 70 + //params: { did: agent.assertDid }, 71 + }) 72 + } 73 + text="Moderation" 74 + /> 75 + </div> 76 + <div className="h-4" /> 77 + 78 + <SettingHeading title="Personalization" top /> 79 + <Hue /> 80 + 81 + <SettingHeading title="Network Configuration" /> 82 + <div className="flex flex-col px-4 pb-2"> 83 + <span className="text-md">Service Endpoints</span> 84 + <span className="text-sm text-gray-500 dark:text-gray-400"> 85 + Customize the servers to be used by the app 86 + </span> 87 + </div> 88 <TextInputSetting 89 atom={constellationURLAtom} 90 + title={"Constellation"} 91 description={ 92 + "Customize the Constellation instance to be used by Red Dwarf" 93 } 94 init={defaultconstellationURL} 95 /> 96 <TextInputSetting 97 atom={slingshotURLAtom} 98 + title={"Slingshot"} 99 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 100 init={defaultslingshotURL} 101 /> 102 + <TextInputSetting 103 + atom={imgCDNAtom} 104 + title={"Image CDN"} 105 + description={ 106 + "Customize the Constellation instance to be used by Red Dwarf" 107 + } 108 + init={defaultImgCDN} 109 + /> 110 + <TextInputSetting 111 + atom={videoCDNAtom} 112 + title={"Video CDN"} 113 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 114 + init={defaultVideoCDN} 115 + /> 116 + <TextInputSetting 117 + atom={lycanURLAtom} 118 + title={"Lycan Search"} 119 + description={"Enable text search across posts you've interacted with"} 120 + init={defaultLycanURL} 121 + /> 122 + 123 + <SettingHeading title="Experimental" /> 124 + <SwitchSetting 125 + atom={enableBitesAtom} 126 + title={"Bites"} 127 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 128 + //init={false} 129 + /> 130 + <div className="h-4" /> 131 + <SwitchSetting 132 + atom={enableBridgyTextAtom} 133 + title={"Bridgy Text"} 134 + description={ 135 + "Show the original text of posts bridged from the Fediverse" 136 + } 137 + //init={false} 138 + /> 139 + <div className="h-4" /> 140 + <SwitchSetting 141 + atom={enableWafrnTextAtom} 142 + title={"Wafrn Text"} 143 + description={"Show the original text of posts from Wafrn instances"} 144 + //init={false} 145 + /> 146 + <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 147 + Notice: Please restart/refresh the app if changes arent applying 148 + correctly 149 + </p> 150 </> 151 ); 152 } 153 154 + export function SettingHeading({ 155 + title, 156 + top, 157 + }: { 158 + title: string; 159 + top?: boolean; 160 + }) { 161 + return ( 162 + <div 163 + className="px-4" 164 + style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }} 165 + > 166 + <span className=" text-sm font-medium text-gray-500 dark:text-gray-400"> 167 + {title} 168 + </span> 169 + </div> 170 + ); 171 + } 172 + 173 + export function SwitchSetting({ 174 + atom, 175 + title, 176 + description, 177 + }: { 178 + atom: typeof enableBitesAtom; 179 + title?: string; 180 + description?: string; 181 + }) { 182 + const value = useAtomValue(atom); 183 + const setValue = useSetAtom(atom); 184 + 185 + const [hydrated, setHydrated] = useState(false); 186 + // eslint-disable-next-line react-hooks/set-state-in-effect 187 + useEffect(() => setHydrated(true), []); 188 + 189 + if (!hydrated) { 190 + // Avoid rendering Switch until we know storage is loaded 191 + return null; 192 + } 193 + 194 + return ( 195 + <div className="flex items-center gap-4 px-4 "> 196 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 197 + <div className="flex flex-col"> 198 + <span className="text-md">{title}</span> 199 + <span className="text-sm text-gray-500 dark:text-gray-400"> 200 + {description} 201 + </span> 202 + </div> 203 + </label> 204 + 205 + <Switch.Root 206 + id={`switch-${title}`} 207 + checked={value} 208 + onCheckedChange={(v) => setValue(v)} 209 + className="m3switch root" 210 + > 211 + <Switch.Thumb className="m3switch thumb " /> 212 + </Switch.Root> 213 + </div> 214 + ); 215 + } 216 + 217 + function Hue() { 218 + const [hue, setHue] = useAtom(hueAtom); 219 + return ( 220 + <div className="flex flex-col px-4"> 221 + <span className="z-[2] text-md">Hue</span> 222 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 223 + Change the colors of the app 224 + </span> 225 + <div className="z-[1] flex flex-row items-center gap-4"> 226 + <SliderComponent atom={hueAtom} max={360} /> 227 + <button 228 + onClick={() => setHue(defaulthue ?? 28)} 229 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 230 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 231 + > 232 + Reset 233 + </button> 234 + </div> 235 + </div> 236 + ); 237 + } 238 + 239 export function TextInputSetting({ 240 atom, 241 title, ··· 249 }) { 250 const [value, setValue] = useAtom(atom); 251 return ( 252 + <div className="flex flex-col gap-2 px-4 py-2"> 253 + {/* <div> 254 {title && ( 255 <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> 256 {title} ··· 261 {description} 262 </p> 263 )} 264 + </div> */} 265 266 <div className="flex flex-row gap-2 items-center"> 267 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 268 + <input 269 + type="text" 270 + placeholder=" " 271 + value={value} 272 + onChange={(e) => setValue(e.target.value)} 273 + /> 274 + <label>{title}</label> 275 + </div> 276 + {/* <input 277 type="text" 278 value={value} 279 onChange={(e) => setValue(e.target.value)} ··· 281 text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 282 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600" 283 placeholder="Enter value..." 284 + /> */} 285 <button 286 onClick={() => setValue(init ?? "")} 287 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 288 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 289 > 290 Reset ··· 293 </div> 294 ); 295 } 296 + 297 + interface SliderProps { 298 + atom: typeof hueAtom; 299 + min?: number; 300 + max?: number; 301 + step?: number; 302 + } 303 + 304 + export const SliderComponent: React.FC<SliderProps> = ({ 305 + atom, 306 + min = 0, 307 + max = 100, 308 + step = 1, 309 + }) => { 310 + const [value, setValue] = useAtom(atom); 311 + 312 + return ( 313 + <Slider.Root 314 + className="relative flex items-center w-full h-4" 315 + value={[value]} 316 + min={min} 317 + max={max} 318 + step={step} 319 + onValueChange={(v: number[]) => setValue(v[0])} 320 + > 321 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 322 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 323 + </Slider.Track> 324 + <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 325 + </Slider.Root> 326 + ); 327 + }; 328 + 329 + 330 + interface SliderPProps { 331 + value: number; 332 + min?: number; 333 + max?: number; 334 + step?: number; 335 + } 336 + 337 + 338 + export const SliderPrimitive: React.FC<SliderPProps> = ({ 339 + value, 340 + min = 0, 341 + max = 100, 342 + step = 1, 343 + }) => { 344 + 345 + return ( 346 + <Slider.Root 347 + className="relative flex items-center w-full h-4" 348 + value={[value]} 349 + min={min} 350 + max={max} 351 + step={step} 352 + onValueChange={(v: number[]) => {}} 353 + > 354 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 355 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 356 + </Slider.Track> 357 + <Slider.Thumb className=" hidden shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 358 + </Slider.Root> 359 + ); 360 + };
+283 -14
src/styles/app.css
··· 15 --color-gray-950: oklch(0.129 0.050 222.000); 16 } */ 17 18 @theme { 19 - --color-gray-50: oklch(0.984 0.012 28); 20 - --color-gray-100: oklch(0.968 0.017 28); 21 - --color-gray-200: oklch(0.929 0.025 28); 22 - --color-gray-300: oklch(0.869 0.035 28); 23 - --color-gray-400: oklch(0.704 0.05 28); 24 - --color-gray-500: oklch(0.554 0.06 28); 25 - --color-gray-600: oklch(0.446 0.058 28); 26 - --color-gray-700: oklch(0.372 0.058 28); 27 - --color-gray-800: oklch(0.279 0.055 28); 28 - --color-gray-900: oklch(0.208 0.055 28); 29 - --color-gray-950: oklch(0.129 0.055 28); 30 } 31 32 @layer base { ··· 48 } 49 } 50 51 @media (width >= 64rem /* 1024px */) { 52 html:not(:has(.disablegutter)), 53 body:not(:has(.disablegutter)) { 54 scrollbar-gutter: stable both-edges !important; 55 } 56 - html:has(.disablegutter), 57 - body:has(.disablegutter) { 58 scrollbar-width: none; 59 overflow-y: hidden; 60 } ··· 76 .dangerousFediContent { 77 & a[href]{ 78 text-decoration: none; 79 - color: rgb(29, 122, 242); 80 word-break: break-all; 81 } 82 } ··· 105 :root { 106 --shadow-opacity: calc(1 - var(--is-top)); 107 --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 108 }
··· 15 --color-gray-950: oklch(0.129 0.050 222.000); 16 } */ 17 18 + :root { 19 + --safe-hue: var(--tw-gray-hue, 28) 20 + } 21 + 22 @theme { 23 + --color-gray-50: oklch(0.984 0.012 var(--safe-hue)); 24 + --color-gray-100: oklch(0.968 0.017 var(--safe-hue)); 25 + --color-gray-200: oklch(0.929 0.025 var(--safe-hue)); 26 + --color-gray-300: oklch(0.869 0.035 var(--safe-hue)); 27 + --color-gray-400: oklch(0.704 0.05 var(--safe-hue)); 28 + --color-gray-500: oklch(0.554 0.06 var(--safe-hue)); 29 + --color-gray-600: oklch(0.446 0.058 var(--safe-hue)); 30 + --color-gray-700: oklch(0.372 0.058 var(--safe-hue)); 31 + --color-gray-800: oklch(0.279 0.055 var(--safe-hue)); 32 + --color-gray-900: oklch(0.208 0.055 var(--safe-hue)); 33 + --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 + } 35 + 36 + :root { 37 + --link-text-color: oklch(0.5962 0.1987 var(--safe-hue)); 38 + /* max chroma!!! use fallback*/ 39 + /*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/ 40 } 41 42 @layer base { ··· 58 } 59 } 60 61 + .gutter{ 62 + scrollbar-gutter: stable both-edges; 63 + } 64 + 65 @media (width >= 64rem /* 1024px */) { 66 html:not(:has(.disablegutter)), 67 body:not(:has(.disablegutter)) { 68 scrollbar-gutter: stable both-edges !important; 69 } 70 + html:has(.disablescroll), 71 + body:has(.disablescroll) { 72 scrollbar-width: none; 73 overflow-y: hidden; 74 } ··· 90 .dangerousFediContent { 91 & a[href]{ 92 text-decoration: none; 93 + color: var(--link-text-color); 94 word-break: break-all; 95 } 96 } ··· 119 :root { 120 --shadow-opacity: calc(1 - var(--is-top)); 121 --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 122 + } 123 + 124 + 125 + /* m3 input */ 126 + :root { 127 + --m3input-radius: 6px; 128 + --m3input-border-width: .0625rem; 129 + --m3input-font-size: 16px; 130 + --m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1); 131 + /* light theme */ 132 + --m3input-bg: var(--color-gray-50); 133 + --m3input-border-color: var(--color-gray-400); 134 + --m3input-label-color: var(--color-gray-500); 135 + --m3input-text-color: var(--color-gray-900); 136 + --m3input-focus-color: var(--color-gray-600); 137 + } 138 + 139 + @media (prefers-color-scheme: dark) { 140 + :root { 141 + --m3input-bg: var(--color-gray-950); 142 + --m3input-border-color: var(--color-gray-700); 143 + --m3input-label-color: var(--color-gray-400); 144 + --m3input-text-color: var(--color-gray-50); 145 + --m3input-focus-color: var(--color-gray-400); 146 + } 147 + } 148 + 149 + /* reset page *//* 150 + html, 151 + body { 152 + background: var(--m3input-bg); 153 + margin: 0; 154 + padding: 1rem; 155 + color: var(--m3input-text-color); 156 + font-family: system-ui, sans-serif; 157 + font-size: var(--m3input-font-size); 158 + }*/ 159 + 160 + /* base wrapper */ 161 + .m3input-field.m3input-label.m3input-border { 162 + position: relative; 163 + display: inline-block; 164 + width: 100%; 165 + /*max-width: 400px;*/ 166 + } 167 + 168 + /* size variants */ 169 + .m3input-field.size-sm { 170 + --m3input-h: 40px; 171 + } 172 + 173 + .m3input-field.size-md { 174 + --m3input-h: 48px; 175 + } 176 + 177 + .m3input-field.size-lg { 178 + --m3input-h: 56px; 179 + } 180 + 181 + .m3input-field.size-xl { 182 + --m3input-h: 64px; 183 + } 184 + 185 + .m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) { 186 + --m3input-h: 48px; 187 + } 188 + 189 + /* outlined input */ 190 + .m3input-field.m3input-label.m3input-border input { 191 + width: 100%; 192 + height: var(--m3input-h); 193 + border: var(--m3input-border-width) solid var(--m3input-border-color); 194 + border-radius: var(--m3input-radius); 195 + background: var(--m3input-bg); 196 + color: var(--m3input-text-color); 197 + font-size: var(--m3input-font-size); 198 + padding: 0 12px; 199 + box-sizing: border-box; 200 + outline: none; 201 + transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition); 202 + } 203 + 204 + /* focus ring */ 205 + .m3input-field.m3input-label.m3input-border input:focus { 206 + /*border-color: var(--m3input-focus-color);*/ 207 + border-color: var(--m3input-focus-color); 208 + box-shadow: 0 0 0 1px var(--m3input-focus-color); 209 + /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 210 + } 211 + 212 + /* label */ 213 + .m3input-field.m3input-label.m3input-border label { 214 + position: absolute; 215 + left: 12px; 216 + top: 50%; 217 + transform: translateY(-50%); 218 + background: var(--m3input-bg); 219 + padding: 0 .25em; 220 + color: var(--m3input-label-color); 221 + pointer-events: none; 222 + transition: all var(--m3input-transition); 223 + } 224 + 225 + /* float on focus or when filled */ 226 + .m3input-field.m3input-label.m3input-border input:focus+label, 227 + .m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label { 228 + top: 0; 229 + transform: translateY(-50%) scale(.78); 230 + left: 0; 231 + color: var(--m3input-focus-color); 232 + } 233 + 234 + /* placeholder trick */ 235 + .m3input-field.m3input-label.m3input-border input::placeholder { 236 + color: transparent; 237 + } 238 + 239 + /* radix i love you but like cmon man */ 240 + body[data-scroll-locked]{ 241 + margin-left: var(--removed-body-scroll-bar-size) !important; 242 + } 243 + 244 + /* radix tabs */ 245 + 246 + .m3tab[data-radix-collection-item] { 247 + flex: 1; 248 + display: flex; 249 + padding: 12px 8px; 250 + align-items: center; 251 + justify-content: center; 252 + color: var(--color-gray-500); 253 + font-weight: 500; 254 + &:hover { 255 + background-color: var(--color-gray-100); 256 + cursor: pointer; 257 + } 258 + &[aria-selected="true"] { 259 + color: var(--color-gray-950); 260 + &::before{ 261 + content: ""; 262 + position: absolute; 263 + width: min(80px, 80%); 264 + border-radius: 99px 99px 0px 0px ; 265 + height: 3px; 266 + bottom: 0; 267 + background-color: var(--color-gray-400); 268 + } 269 + } 270 + } 271 + 272 + @media (prefers-color-scheme: dark) { 273 + .m3tab[data-radix-collection-item] { 274 + color: var(--color-gray-400); 275 + &:hover { 276 + background-color: var(--color-gray-900); 277 + cursor: pointer; 278 + } 279 + &[aria-selected="true"] { 280 + color: var(--color-gray-50); 281 + &::before{ 282 + background-color: var(--color-gray-500); 283 + } 284 + } 285 + } 286 + } 287 + 288 + :root{ 289 + --thumb-size: 2rem; 290 + --root-size: 3.25rem; 291 + 292 + --switch-off-border: var(--color-gray-400); 293 + --switch-off-bg: var(--color-gray-200); 294 + --switch-off-thumb: var(--color-gray-400); 295 + 296 + 297 + --switch-on-bg: var(--color-gray-500); 298 + --switch-on-thumb: var(--color-gray-50); 299 + 300 + } 301 + @media (prefers-color-scheme: dark) { 302 + :root { 303 + --switch-off-border: var(--color-gray-500); 304 + --switch-off-bg: var(--color-gray-800); 305 + --switch-off-thumb: var(--color-gray-500); 306 + 307 + 308 + --switch-on-bg: var(--color-gray-400); 309 + --switch-on-thumb: var(--color-gray-700); 310 + } 311 + } 312 + 313 + .m3switch.root{ 314 + /*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/ 315 + /*width: 40px; 316 + height: 24px;*/ 317 + 318 + inline-size: var(--root-size); 319 + block-size: 2rem; 320 + border-radius: 99999px; 321 + 322 + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 323 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 324 + transition-duration: var(--default-transition-duration); /* 150ms */ 325 + 326 + .m3switch.thumb{ 327 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 328 + 329 + height: var(--thumb-size); 330 + width: var(--thumb-size); 331 + display: inline-block; 332 + border-radius: 9999px; 333 + 334 + transform-origin: center; 335 + 336 + transition-property: transform, translate, scale, rotate; 337 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 338 + transition-duration: var(--default-transition-duration); /* 150ms */ 339 + 340 + } 341 + 342 + &[aria-checked="true"] { 343 + box-shadow: inset 0px 0px 0px 1.8px transparent; 344 + background-color: var(--switch-on-bg); 345 + 346 + .m3switch.thumb{ 347 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 348 + 349 + background-color: var(--switch-on-thumb); 350 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72); 351 + &:active { 352 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 353 + } 354 + 355 + } 356 + &:active .m3switch.thumb{ 357 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 358 + } 359 + } 360 + 361 + &[aria-checked="false"] { 362 + box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border); 363 + background-color: var(--switch-off-bg); 364 + .m3switch.thumb{ 365 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 366 + 367 + background-color: var(--switch-off-thumb); 368 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5); 369 + &:active { 370 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 371 + } 372 + } 373 + &:active .m3switch.thumb{ 374 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 375 + } 376 + } 377 }
+129 -19
src/utils/atoms.ts
··· 1 - import type Agent from "@atproto/api"; 2 - import { atom, createStore } from "jotai"; 3 - import { atomWithStorage } from 'jotai/utils'; 4 5 export const store = createStore(); 6 7 export const selectedFeedUriAtom = atomWithStorage<string | null>( 8 - 'selectedFeedUri', 9 null 10 ); 11 12 //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 13 14 export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 15 - 'feedscrollpositions', 16 {} 17 ); 18 19 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 20 - 'likedPosts', 21 {} 22 ); 23 24 - export const defaultconstellationURL = 'constellation.microcosm.blue' 25 export const constellationURLAtom = atomWithStorage<string>( 26 - 'constellationURL', 27 defaultconstellationURL 28 - ) 29 - export const defaultslingshotURL = 'slingshot.microcosm.blue' 30 export const slingshotURLAtom = atomWithStorage<string>( 31 - 'slingshotURL', 32 defaultslingshotURL 33 - ) 34 35 export const isAtTopAtom = atom<boolean>(true); 36 37 type ComposerState = 38 - | { kind: 'closed' } 39 - | { kind: 'root' } 40 - | { kind: 'reply'; parent: string } 41 - | { kind: 'quote'; subject: string }; 42 - export const composerAtom = atom<ComposerState>({ kind: 'closed' }); 43 44 - export const agentAtom = atom<Agent|null>(null); 45 - export const authedAtom = atom<boolean>(false);
··· 1 + import { atom, createStore, useAtomValue } from "jotai"; 2 + import { atomWithStorage } from "jotai/utils"; 3 + import { useEffect } from "react"; 4 + 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 7 export const store = createStore(); 8 9 + export const quickAuthAtom = atomWithStorage<string | null>( 10 + "quickAuth", 11 + null 12 + ); 13 + 14 export const selectedFeedUriAtom = atomWithStorage<string | null>( 15 + "selectedFeedUri", 16 null 17 ); 18 19 //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 20 21 export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 22 + "feedscrollpositions", 23 {} 24 ); 25 26 + type TabRouteScrollState = { 27 + activeTab: string; 28 + scrollPositions: Record<string, number>; 29 + }; 30 + /** 31 + * @deprecated should be safe to remove i think 32 + */ 33 + export const notificationsScrollAtom = atom<TabRouteScrollState>({ 34 + activeTab: "mentions", 35 + scrollPositions: {}, 36 + }); 37 + 38 + export type InteractionFilter = { 39 + likes: boolean; 40 + reposts: boolean; 41 + quotes: boolean; 42 + replies: boolean; 43 + showAll: boolean; 44 + }; 45 + const defaultFilters: InteractionFilter = { 46 + likes: true, 47 + reposts: true, 48 + quotes: true, 49 + replies: true, 50 + showAll: false, 51 + }; 52 + export const postInteractionsFiltersAtom = atomWithStorage<InteractionFilter>( 53 + "postInteractionsFilters", 54 + defaultFilters 55 + ); 56 + 57 + export const reusableTabRouteScrollAtom = atom<Record<string, TabRouteScrollState | undefined> | undefined>({}); 58 + 59 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 60 + "likedPosts", 61 + {} 62 + ); 63 + 64 + export type LikeRecord = { 65 + uri: string; // at://did/collection/rkey 66 + target: string; 67 + cid: string; 68 + }; 69 + 70 + export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>( 71 + "internal-liked-posts", 72 {} 73 ); 74 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 76 + 77 + export const defaultconstellationURL = "constellation.microcosm.blue"; 78 export const constellationURLAtom = atomWithStorage<string>( 79 + "constellationURL", 80 defaultconstellationURL 81 + ); 82 + export const defaultslingshotURL = "slingshot.microcosm.blue"; 83 export const slingshotURLAtom = atomWithStorage<string>( 84 + "slingshotURL", 85 defaultslingshotURL 86 + ); 87 + export const defaultImgCDN = "cdn.bsky.app"; 88 + export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 89 + export const defaultVideoCDN = "video.bsky.app"; 90 + export const videoCDNAtom = atomWithStorage<string>( 91 + "videocdnurl", 92 + defaultVideoCDN 93 + ); 94 + 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 101 + export const defaulthue = 28; 102 + export const hueAtom = atomWithStorage<number>("hue", defaulthue); 103 104 export const isAtTopAtom = atom<boolean>(true); 105 106 type ComposerState = 107 + | { kind: "closed" } 108 + | { kind: "root" } 109 + | { kind: "reply"; parent: string } 110 + | { kind: "quote"; subject: string }; 111 + export const composerAtom = atom<ComposerState>({ kind: "closed" }); 112 113 + //export const agentAtom = atom<Agent | null>(null); 114 + //export const authedAtom = atom<boolean>(false); 115 + 116 + export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 117 + const value = useAtomValue(atom); 118 + 119 + useEffect(() => { 120 + document.documentElement.style.setProperty(cssVar, value.toString()); 121 + }, [value, cssVar]); 122 + 123 + useEffect(() => { 124 + document.documentElement.style.setProperty(cssVar, value.toString()); 125 + }, []); 126 + } 127 + 128 + hueAtom.onMount = (setAtom) => { 129 + const stored = localStorage.getItem("hue"); 130 + if (stored != null) setAtom(Number(stored)); 131 + }; 132 + // export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) { 133 + // const initial = store.get(atom); 134 + // console.log("atom get ", initial); 135 + // document.documentElement.style.setProperty(cssVar, initial.toString()); 136 + // } 137 + 138 + 139 + 140 + // fun stuff 141 + 142 + export const enableBitesAtom = atomWithStorage<boolean>( 143 + "enableBitesAtom", 144 + false 145 + ); 146 + 147 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 148 + "enableBridgyTextAtom", 149 + false 150 + ); 151 + 152 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 153 + "enableWafrnTextAtom", 154 + false 155 + );
+33
src/utils/followState.ts
··· 128 }; 129 }); 130 }
··· 128 }; 129 }); 130 } 131 + 132 + 133 + 134 + export function useGetOneToOneState(params?: { 135 + target: string; 136 + user: string; 137 + collection: string; 138 + path: string; 139 + }): string[] | undefined { 140 + const { data: arbitrarydata } = useQueryConstellation( 141 + params && params.user 142 + ? { 143 + method: "/links", 144 + target: params.target, 145 + // @ts-expect-error overloading sucks so much 146 + collection: params.collection, 147 + path: params.path, 148 + dids: [params.user], 149 + } 150 + : { method: "undefined", target: "whatever" } 151 + // overloading sucks so much 152 + ) as { data: linksRecordsResponse | undefined }; 153 + if (!params || !params.user) return undefined; 154 + const data = arbitrarydata?.linking_records.slice(0, 50) ?? []; 155 + 156 + if (data.length > 0) { 157 + return data.map((linksRecord) => { 158 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 159 + }); 160 + } 161 + 162 + return undefined; 163 + }
+34
src/utils/likeMutationQueue.ts
···
··· 1 + import { useAtom } from "jotai"; 2 + import { useCallback } from "react"; 3 + 4 + import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider"; 5 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 + 7 + import { internalLikedPostsAtom } from "./atoms"; 8 + 9 + export function useFastLike(target: string, cid: string) { 10 + const { agent } = useAuth(); 11 + const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider(); 12 + 13 + const liked = fastState(target); 14 + const toggle = () => fastToggle(target, cid); 15 + /** 16 + * 17 + * @deprecated dont use it yet, will cause infinite rerenders 18 + */ 19 + const backfill = () => agent?.did && backfillState(target, agent.did); 20 + 21 + return { liked, toggle, backfill }; 22 + } 23 + 24 + export function useFastSetLikesFromFeed() { 25 + const [_, setLikedPosts] = useAtom(internalLikedPostsAtom); 26 + 27 + const setFastState = useCallback( 28 + (target: string, record: LikeRecord | null) => 29 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 30 + [setLikedPosts] 31 + ); 32 + 33 + return { setFastState }; 34 + }
+2 -2
src/utils/oauthClient.ts
··· 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 3 - // i tried making this https://pds-nd.whey.party but cors is annoying as fuck 4 - const handleResolverPDS = 'https://bsky.social'; 5 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
··· 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 3 + import resolvers from '../../public/resolvers.json' with { type: 'json' }; 4 + const handleResolverPDS = resolvers.resolver || 'https://bsky.social'; 5 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
+53 -23
src/utils/useHydrated.ts
··· 9 AppBskyFeedPost, 10 AtUri, 11 } from "@atproto/api"; 12 import { useMemo } from "react"; 13 14 - import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery"; 15 16 - type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends 17 - | { data: infer D } 18 - | undefined 19 - ? D 20 - : never; 21 22 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 23 return obj as $Typed<T>; ··· 26 export function hydrateEmbedImages( 27 embed: AppBskyEmbedImages.Main, 28 did: string, 29 ): $Typed<AppBskyEmbedImages.View> { 30 return asTyped({ 31 $type: "app.bsky.embed.images#view" as const, ··· 34 const link = img.image.ref?.["$link"]; 35 if (!link) return null; 36 return { 37 - thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 - fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 alt: img.alt || "", 40 aspectRatio: img.aspectRatio, 41 }; ··· 47 export function hydrateEmbedExternal( 48 embed: AppBskyEmbedExternal.Main, 49 did: string, 50 ): $Typed<AppBskyEmbedExternal.View> { 51 return asTyped({ 52 $type: "app.bsky.embed.external#view" as const, ··· 55 title: embed.external.title, 56 description: embed.external.description, 57 thumb: embed.external.thumb?.ref?.$link 58 - ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 : undefined, 60 }, 61 }); ··· 64 export function hydrateEmbedVideo( 65 embed: AppBskyEmbedVideo.Main, 66 did: string, 67 ): $Typed<AppBskyEmbedVideo.View> { 68 const videoLink = embed.video.ref.$link; 69 return asTyped({ 70 $type: "app.bsky.embed.video#view" as const, 71 - playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`, 72 - thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`, 73 aspectRatio: embed.aspectRatio, 74 cid: videoLink, 75 }); ··· 80 quotedPost: QueryResultData<typeof useQueryPost>, 81 quotedProfile: QueryResultData<typeof useQueryProfile>, 82 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 83 ): $Typed<AppBskyEmbedRecord.View> | undefined { 84 if (!quotedPost || !quotedProfile || !quotedIdentity) { 85 return undefined; ··· 91 handle: quotedIdentity.handle, 92 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 93 avatar: quotedProfile.value.avatar?.ref?.$link 94 - ? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 95 : undefined, 96 viewer: {}, 97 labels: [], ··· 122 quotedPost: QueryResultData<typeof useQueryPost>, 123 quotedProfile: QueryResultData<typeof useQueryProfile>, 124 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 125 ): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 126 const hydratedRecord = hydrateEmbedRecord( 127 embed.record, 128 quotedPost, 129 quotedProfile, 130 quotedIdentity, 131 ); 132 133 if (!hydratedRecord) return undefined; ··· 148 149 export function useHydratedEmbed( 150 embed: AppBskyFeedPost.Record["embed"], 151 - postAuthorDid: string | undefined, 152 ) { 153 const recordInfo = useMemo(() => { 154 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { ··· 181 error: profileError, 182 } = useQueryProfile(profileUri); 183 184 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 185 186 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 187 if (!embed || !postAuthorDid) return undefined; 188 189 - if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) { 190 return undefined; 191 } 192 193 try { 194 if (AppBskyEmbedImages.isMain(embed)) { 195 - return hydrateEmbedImages(embed, postAuthorDid); 196 } else if (AppBskyEmbedExternal.isMain(embed)) { 197 - return hydrateEmbedExternal(embed, postAuthorDid); 198 } else if (AppBskyEmbedVideo.isMain(embed)) { 199 - return hydrateEmbedVideo(embed, postAuthorDid); 200 } else if (AppBskyEmbedRecord.isMain(embed)) { 201 return hydrateEmbedRecord( 202 embed, 203 usequerypostresults?.data, 204 quotedProfile, 205 queryidentityresult?.data, 206 ); 207 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 208 let hydratedMedia: ··· 212 | undefined; 213 214 if (AppBskyEmbedImages.isMain(embed.media)) { 215 - hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid); 216 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 217 - hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid); 218 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 219 - hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid); 220 } 221 222 if (hydratedMedia) { ··· 226 usequerypostresults?.data, 227 quotedProfile, 228 queryidentityresult?.data, 229 ); 230 } 231 } ··· 236 })(); 237 238 const isLoading = isRecordType 239 - ? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading 240 : false; 241 242 - const error = usequerypostresults?.error || profileError || queryidentityresult?.error; 243 244 return { data: hydratedEmbed, isLoading, error }; 245 - }
··· 9 AppBskyFeedPost, 10 AtUri, 11 } from "@atproto/api"; 12 + import { useAtom } from "jotai"; 13 import { useMemo } from "react"; 14 15 + import { imgCDNAtom, videoCDNAtom } from "./atoms"; 16 + import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery"; 17 18 + type QueryResultData<T extends (...args: any) => any> = 19 + ReturnType<T> extends { data: infer D } | undefined ? D : never; 20 21 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 22 return obj as $Typed<T>; ··· 25 export function hydrateEmbedImages( 26 embed: AppBskyEmbedImages.Main, 27 did: string, 28 + cdn: string 29 ): $Typed<AppBskyEmbedImages.View> { 30 return asTyped({ 31 $type: "app.bsky.embed.images#view" as const, ··· 34 const link = img.image.ref?.["$link"]; 35 if (!link) return null; 36 return { 37 + thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 + fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 alt: img.alt || "", 40 aspectRatio: img.aspectRatio, 41 }; ··· 47 export function hydrateEmbedExternal( 48 embed: AppBskyEmbedExternal.Main, 49 did: string, 50 + cdn: string 51 ): $Typed<AppBskyEmbedExternal.View> { 52 return asTyped({ 53 $type: "app.bsky.embed.external#view" as const, ··· 56 title: embed.external.title, 57 description: embed.external.description, 58 thumb: embed.external.thumb?.ref?.$link 59 + ? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 60 : undefined, 61 }, 62 }); ··· 65 export function hydrateEmbedVideo( 66 embed: AppBskyEmbedVideo.Main, 67 did: string, 68 + videocdn: string 69 ): $Typed<AppBskyEmbedVideo.View> { 70 const videoLink = embed.video.ref.$link; 71 return asTyped({ 72 $type: "app.bsky.embed.video#view" as const, 73 + playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`, 74 + thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`, 75 aspectRatio: embed.aspectRatio, 76 cid: videoLink, 77 }); ··· 82 quotedPost: QueryResultData<typeof useQueryPost>, 83 quotedProfile: QueryResultData<typeof useQueryProfile>, 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 + cdn: string 86 ): $Typed<AppBskyEmbedRecord.View> | undefined { 87 if (!quotedPost || !quotedProfile || !quotedIdentity) { 88 return undefined; ··· 94 handle: quotedIdentity.handle, 95 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 96 avatar: quotedProfile.value.avatar?.ref?.$link 97 + ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 98 : undefined, 99 viewer: {}, 100 labels: [], ··· 125 quotedPost: QueryResultData<typeof useQueryPost>, 126 quotedProfile: QueryResultData<typeof useQueryProfile>, 127 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 128 + cdn: string 129 ): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 130 const hydratedRecord = hydrateEmbedRecord( 131 embed.record, 132 quotedPost, 133 quotedProfile, 134 quotedIdentity, 135 + cdn 136 ); 137 138 if (!hydratedRecord) return undefined; ··· 153 154 export function useHydratedEmbed( 155 embed: AppBskyFeedPost.Record["embed"], 156 + postAuthorDid: string | undefined 157 ) { 158 const recordInfo = useMemo(() => { 159 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { ··· 186 error: profileError, 187 } = useQueryProfile(profileUri); 188 189 + const [imgcdn] = useAtom(imgCDNAtom); 190 + const [videocdn] = useAtom(videoCDNAtom); 191 + 192 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 193 194 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 195 if (!embed || !postAuthorDid) return undefined; 196 197 + if ( 198 + isRecordType && 199 + (!usequerypostresults?.data || 200 + !quotedProfile || 201 + !queryidentityresult?.data) 202 + ) { 203 return undefined; 204 } 205 206 try { 207 if (AppBskyEmbedImages.isMain(embed)) { 208 + return hydrateEmbedImages(embed, postAuthorDid, imgcdn); 209 } else if (AppBskyEmbedExternal.isMain(embed)) { 210 + return hydrateEmbedExternal(embed, postAuthorDid, imgcdn); 211 } else if (AppBskyEmbedVideo.isMain(embed)) { 212 + return hydrateEmbedVideo(embed, postAuthorDid, videocdn); 213 } else if (AppBskyEmbedRecord.isMain(embed)) { 214 return hydrateEmbedRecord( 215 embed, 216 usequerypostresults?.data, 217 quotedProfile, 218 queryidentityresult?.data, 219 + imgcdn 220 ); 221 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 222 let hydratedMedia: ··· 226 | undefined; 227 228 if (AppBskyEmbedImages.isMain(embed.media)) { 229 + hydratedMedia = hydrateEmbedImages( 230 + embed.media, 231 + postAuthorDid, 232 + imgcdn 233 + ); 234 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 235 + hydratedMedia = hydrateEmbedExternal( 236 + embed.media, 237 + postAuthorDid, 238 + imgcdn 239 + ); 240 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 241 + hydratedMedia = hydrateEmbedVideo( 242 + embed.media, 243 + postAuthorDid, 244 + videocdn 245 + ); 246 } 247 248 if (hydratedMedia) { ··· 252 usequerypostresults?.data, 253 quotedProfile, 254 queryidentityresult?.data, 255 + imgcdn 256 ); 257 } 258 } ··· 263 })(); 264 265 const isLoading = isRecordType 266 + ? usequerypostresults?.isLoading || 267 + isLoadingProfile || 268 + queryidentityresult?.isLoading 269 : false; 270 271 + const error = 272 + usequerypostresults?.error || profileError || queryidentityresult?.error; 273 274 return { data: hydratedEmbed, isLoading, error }; 275 + }
+424 -171
src/utils/useQuery.ts
··· 5 queryOptions, 6 useInfiniteQuery, 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 9 10 - import { constellationURLAtom, slingshotURLAtom, store } from "./atoms"; 11 12 - export function constructIdentityQuery(didorhandle?: string) { 13 return queryOptions({ 14 queryKey: ["identity", didorhandle], 15 queryFn: async () => { 16 - if (!didorhandle) return undefined as undefined 17 - const slingshoturl = store.get(slingshotURLAtom) 18 const res = await fetch( 19 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 ); ··· 31 } 32 }, 33 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 35 }); 36 } 37 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 }, 44 Error 45 >; 46 - export function useQueryIdentity(): UseQueryResult< 47 - undefined, 48 - Error 49 - > 50 - export function useQueryIdentity(didorhandle?: string): 51 - UseQueryResult< 52 - { 53 - did: string; 54 - handle: string; 55 - pds: string; 56 - signing_key: string; 57 - } | undefined, 58 - Error 59 - > 60 export function useQueryIdentity(didorhandle?: string) { 61 - return useQuery(constructIdentityQuery(didorhandle)); 62 } 63 64 - export function constructPostQuery(uri?: string) { 65 return queryOptions({ 66 queryKey: ["post", uri], 67 queryFn: async () => { 68 - if (!uri) return undefined as undefined 69 - const slingshoturl = store.get(slingshotURLAtom) 70 const res = await fetch( 71 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 ); ··· 77 return undefined; 78 } 79 if (res.status === 400) return undefined; 80 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 81 return undefined; // cache โ€œnot foundโ€ 82 } 83 try { 84 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 86 uri: string; 87 cid: string; 88 value: any; ··· 97 return failureCount < 2; 98 }, 99 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 101 }); 102 } 103 export function useQueryPost(uri: string): UseQueryResult< ··· 108 }, 109 Error 110 >; 111 - export function useQueryPost(): UseQueryResult< 112 - undefined, 113 - Error 114 - > 115 - export function useQueryPost(uri?: string): 116 - UseQueryResult< 117 - { 118 - uri: string; 119 - cid: string; 120 - value: ATPAPI.AppBskyFeedPost.Record; 121 - } | undefined, 122 - Error 123 - > 124 export function useQueryPost(uri?: string) { 125 - return useQuery(constructPostQuery(uri)); 126 } 127 128 - export function constructProfileQuery(uri?: string) { 129 return queryOptions({ 130 queryKey: ["profile", uri], 131 queryFn: async () => { 132 - if (!uri) return undefined as undefined 133 - const slingshoturl = store.get(slingshotURLAtom) 134 const res = await fetch( 135 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 ); ··· 141 return undefined; 142 } 143 if (res.status === 400) return undefined; 144 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 145 return undefined; // cache โ€œnot foundโ€ 146 } 147 try { 148 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 150 uri: string; 151 cid: string; 152 value: any; ··· 161 return failureCount < 2; 162 }, 163 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 165 }); 166 } 167 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 }, 173 Error 174 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 182 uri: string; 183 cid: string; 184 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 188 export function useQueryProfile(uri?: string) { 189 - return useQuery(constructProfileQuery(uri)); 190 } 191 192 // export function constructConstellationQuery( ··· 221 // method: "/links/all", 222 // target: string 223 // ): QueryOptions<linksAllResponse, Error>; 224 - export function constructConstellationQuery(query?:{ 225 method: 226 | "/links" 227 | "/links/distinct-dids" 228 | "/links/count" 229 | "/links/count/distinct-dids" 230 | "/links/all" 231 - | "undefined", 232 - target: string, 233 - collection?: string, 234 - path?: string, 235 - cursor?: string, 236 - dids?: string[] 237 - } 238 - ) { 239 // : QueryOptions< 240 // | linksRecordsResponse 241 // | linksDidsResponse ··· 245 // Error 246 // > 247 return queryOptions({ 248 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 249 queryFn: async () => { 250 - if (!query || query.method === "undefined") return undefined as undefined 251 - const method = query.method 252 - const target = query.target 253 - const collection = query?.collection 254 - const path = query?.path 255 - const cursor = query.cursor 256 - const dids = query?.dids 257 - const constellation = store.get(constellationURLAtom); 258 const res = await fetch( 259 - `https://${constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 260 ); 261 if (!res.ok) throw new Error("Failed to fetch post"); 262 try { ··· 280 }, 281 // enforce short lifespan 282 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 283 - gcTime: /*0//*/5 * 60 * 1000, 284 }); 285 } 286 export function useQueryConstellation(query: { 287 method: "/links"; 288 target: string; ··· 345 > 346 | undefined { 347 //if (!query) return; 348 return useQuery( 349 - constructConstellationQuery(query) 350 ); 351 } 352 353 - type linksRecord = { 354 did: string; 355 collection: string; 356 rkey: string; ··· 390 }) { 391 return queryOptions({ 392 // The query key includes all dependencies to ensure it refetches when they change 393 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 394 queryFn: async () => { 395 - if (!options) return undefined as undefined 396 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 397 if (isAuthed) { 398 // Authenticated flow 399 if (!agent || !pdsUrl || !feedServiceDid) { 400 - throw new Error("Missing required info for authenticated feed fetch."); 401 } 402 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 403 const res = await agent.fetchHandler(url, { ··· 407 "Content-Type": "application/json", 408 }, 409 }); 410 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 411 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 412 } else { 413 // Unauthenticated flow (using a public PDS/AppView) 414 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 415 const res = await fetch(url); 416 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 417 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 418 } 419 }, ··· 431 return useQuery(constructFeedSkeletonQuery(options)); 432 } 433 434 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 435 return queryOptions({ 436 - queryKey: ['preferences', agent?.did], 437 queryFn: async () => { 438 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 439 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 444 }); 445 } 446 export function useQueryPreferences(options: { 447 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 448 }) { 449 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 450 } 451 452 - 453 - 454 - export function constructArbitraryQuery(uri?: string) { 455 return queryOptions({ 456 queryKey: ["arbitrary", uri], 457 queryFn: async () => { 458 - if (!uri) return undefined as undefined 459 - const slingshoturl = store.get(slingshotURLAtom) 460 const res = await fetch( 461 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 462 ); ··· 467 return undefined; 468 } 469 if (res.status === 400) return undefined; 470 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 471 return undefined; // cache โ€œnot foundโ€ 472 } 473 try { 474 if (!res.ok) throw new Error("Failed to fetch post"); 475 - return (data) as { 476 uri: string; 477 cid: string; 478 value: any; ··· 487 return failureCount < 2; 488 }, 489 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 490 - gcTime: /*0//*/5 * 60 * 1000, 491 }); 492 } 493 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 498 }, 499 Error 500 >; 501 - export function useQueryArbitrary(): UseQueryResult< 502 - undefined, 503 - Error 504 - >; 505 export function useQueryArbitrary(uri?: string): UseQueryResult< 506 - { 507 - uri: string; 508 - cid: string; 509 - value: any; 510 - } | undefined, 511 Error 512 >; 513 export function useQueryArbitrary(uri?: string) { 514 - return useQuery(constructArbitraryQuery(uri)); 515 } 516 517 - export function constructFallbackNothingQuery(){ 518 return queryOptions({ 519 queryKey: ["nothing"], 520 queryFn: async () => { 521 - return undefined 522 }, 523 }); 524 } ··· 532 }[]; 533 }; 534 535 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 536 return queryOptions({ 537 - queryKey: ['authorFeed', did], 538 queryFn: async ({ pageParam }: QueryFunctionContext) => { 539 const limit = 25; 540 - 541 const cursor = pageParam as string | undefined; 542 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 543 - 544 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 545 - 546 const res = await fetch(url); 547 if (!res.ok) throw new Error("Failed to fetch author's posts"); 548 - 549 return res.json() as Promise<ListRecordsResponse>; 550 }, 551 }); 552 } 553 554 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 555 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 556 - 557 return useInfiniteQuery({ 558 queryKey, 559 queryFn, ··· 571 isAuthed: boolean; 572 pdsUrl?: string; 573 feedServiceDid?: string; 574 }) { 575 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 576 - 577 return queryOptions({ 578 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 579 - 580 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 581 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 582 - 583 - if (isAuthed) { 584 if (!agent || !pdsUrl || !feedServiceDid) { 585 - throw new Error("Missing required info for authenticated feed fetch."); 586 } 587 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 588 const res = await agent.fetchHandler(url, { ··· 592 "Content-Type": "application/json", 593 }, 594 }); 595 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 596 return (await res.json()) as FeedSkeletonPage; 597 } else { 598 - const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 599 const res = await fetch(url); 600 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 601 return (await res.json()) as FeedSkeletonPage; 602 } 603 }, ··· 610 isAuthed: boolean; 611 pdsUrl?: string; 612 feedServiceDid?: string; 613 }) { 614 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 615 - 616 - return useInfiniteQuery({ 617 - queryKey, 618 - queryFn, 619 - initialPageParam: undefined as never, 620 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 621 - staleTime: Infinity, 622 - refetchOnWindowFocus: false, 623 - enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 624 - }); 625 } 626 627 - 628 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 629 - method: '/links' 630 - target?: string 631 - collection: string 632 - path: string 633 }) { 634 - const constellationHost = store.get(constellationURLAtom) 635 - console.log( 636 - 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 637 - query, 638 - ) 639 640 return infiniteQueryOptions({ 641 enabled: !!query?.target, 642 queryKey: [ 643 - 'reddwarf_constellation', 644 query?.method, 645 query?.target, 646 query?.collection, 647 query?.path, 648 ] as const, 649 650 - queryFn: async ({pageParam}: {pageParam?: string}) => { 651 - if (!query || !query?.target) return undefined 652 653 - const method = query.method 654 - const target = query.target 655 - const collection = query.collection 656 - const path = query.path 657 - const cursor = pageParam 658 659 const res = await fetch( 660 - `https://${constellationHost}${method}?target=${encodeURIComponent(target)}${ 661 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 662 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 663 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 664 - }`, 665 - ) 666 667 - if (!res.ok) throw new Error('Failed to fetch') 668 669 - return (await res.json()) as linksRecordsResponse 670 }, 671 672 - getNextPageParam: lastPage => { 673 - return (lastPage as any)?.cursor ?? undefined 674 }, 675 initialPageParam: undefined, 676 - staleTime: 5 * 60 * 1000, 677 - gcTime: 5 * 60 * 1000, 678 - }) 679 - }
··· 5 queryOptions, 6 useInfiniteQuery, 7 useQuery, 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 10 + import { useAtom } from "jotai"; 11 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 15 + 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 20 return queryOptions({ 21 queryKey: ["identity", didorhandle], 22 queryFn: async () => { 23 + if (!didorhandle) return undefined as undefined; 24 const res = await fetch( 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 26 ); ··· 37 } 38 }, 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 40 + gcTime: /*0//*/ 5 * 60 * 1000, 41 }); 42 } 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 49 }, 50 Error 51 >; 52 + export function useQueryIdentity(): UseQueryResult<undefined, Error>; 53 + export function useQueryIdentity(didorhandle?: string): UseQueryResult< 54 + | { 55 + did: string; 56 + handle: string; 57 + pds: string; 58 + signing_key: string; 59 + } 60 + | undefined, 61 + Error 62 + >; 63 export function useQueryIdentity(didorhandle?: string) { 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 65 + return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 66 } 67 68 + export function constructPostQuery(uri?: string, slingshoturl?: string) { 69 return queryOptions({ 70 queryKey: ["post", uri], 71 queryFn: async () => { 72 + if (!uri) return undefined as undefined; 73 const res = await fetch( 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 75 ); ··· 80 return undefined; 81 } 82 if (res.status === 400) return undefined; 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 87 return undefined; // cache โ€œnot foundโ€ 88 } 89 try { 90 if (!res.ok) throw new Error("Failed to fetch post"); 91 + return data as { 92 uri: string; 93 cid: string; 94 value: any; ··· 103 return failureCount < 2; 104 }, 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 106 + gcTime: /*0//*/ 5 * 60 * 1000, 107 }); 108 } 109 export function useQueryPost(uri: string): UseQueryResult< ··· 114 }, 115 Error 116 >; 117 + export function useQueryPost(): UseQueryResult<undefined, Error>; 118 + export function useQueryPost(uri?: string): UseQueryResult< 119 + | { 120 + uri: string; 121 + cid: string; 122 + value: ATPAPI.AppBskyFeedPost.Record; 123 + } 124 + | undefined, 125 + Error 126 + >; 127 export function useQueryPost(uri?: string) { 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 129 + return useQuery(constructPostQuery(uri, slingshoturl)); 130 } 131 132 + export function constructProfileQuery(uri?: string, slingshoturl?: string) { 133 return queryOptions({ 134 queryKey: ["profile", uri], 135 queryFn: async () => { 136 + if (!uri) return undefined as undefined; 137 const res = await fetch( 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 139 ); ··· 144 return undefined; 145 } 146 if (res.status === 400) return undefined; 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 151 return undefined; // cache โ€œnot foundโ€ 152 } 153 try { 154 if (!res.ok) throw new Error("Failed to fetch post"); 155 + return data as { 156 uri: string; 157 cid: string; 158 value: any; ··· 167 return failureCount < 2; 168 }, 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 170 + gcTime: /*0//*/ 5 * 60 * 1000, 171 }); 172 } 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 178 }, 179 Error 180 >; 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 184 uri: string; 185 cid: string; 186 value: ATPAPI.AppBskyActorProfile.Record; 187 + } 188 + | undefined, 189 + Error 190 + >; 191 export function useQueryProfile(uri?: string) { 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 193 + return useQuery(constructProfileQuery(uri, slingshoturl)); 194 } 195 196 // export function constructConstellationQuery( ··· 225 // method: "/links/all", 226 // target: string 227 // ): QueryOptions<linksAllResponse, Error>; 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 230 method: 231 | "/links" 232 | "/links/distinct-dids" 233 | "/links/count" 234 | "/links/count/distinct-dids" 235 | "/links/all" 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 243 // : QueryOptions< 244 // | linksRecordsResponse 245 // | linksDidsResponse ··· 249 // Error 250 // > 251 return queryOptions({ 252 + queryKey: [ 253 + "constellation", 254 + query?.method, 255 + query?.target, 256 + query?.collection, 257 + query?.path, 258 + query?.cursor, 259 + query?.dids, 260 + ] as const, 261 queryFn: async () => { 262 + if (!query || query.method === "undefined") return undefined as undefined; 263 + const method = query.method; 264 + const target = query.target; 265 + const collection = query?.collection; 266 + const path = query?.path; 267 + const cursor = query.cursor; 268 + const dids = query?.dids; 269 const res = await fetch( 270 + `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 271 ); 272 if (!res.ok) throw new Error("Failed to fetch post"); 273 try { ··· 291 }, 292 // enforce short lifespan 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 294 + gcTime: /*0//*/ 5 * 60 * 1000, 295 }); 296 } 297 + // todo do more of these instead of overloads since overloads sucks so much apparently 298 + export function useQueryConstellationLinksCountDistinctDids(query?: { 299 + method: "/links/count/distinct-dids"; 300 + target: string; 301 + collection: string; 302 + path: string; 303 + cursor?: string; 304 + }): UseQueryResult<linksCountResponse, Error> | undefined { 305 + //if (!query) return; 306 + const [constellationurl] = useAtom(constellationURLAtom); 307 + const queryres = useQuery( 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 311 + ) as unknown as UseQueryResult<linksCountResponse, Error>; 312 + if (!query) { 313 + return undefined as undefined; 314 + } 315 + return queryres as UseQueryResult<linksCountResponse, Error>; 316 + } 317 + 318 export function useQueryConstellation(query: { 319 method: "/links"; 320 target: string; ··· 377 > 378 | undefined { 379 //if (!query) return; 380 + const [constellationurl] = useAtom(constellationURLAtom); 381 return useQuery( 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 385 ); 386 } 387 388 + export type linksRecord = { 389 did: string; 390 collection: string; 391 rkey: string; ··· 425 }) { 426 return queryOptions({ 427 // The query key includes all dependencies to ensure it refetches when they change 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 433 queryFn: async () => { 434 + if (!options) return undefined as undefined; 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 436 if (isAuthed) { 437 // Authenticated flow 438 if (!agent || !pdsUrl || !feedServiceDid) { 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 442 } 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 444 const res = await agent.fetchHandler(url, { ··· 448 "Content-Type": "application/json", 449 }, 450 }); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 454 } else { 455 // Unauthenticated flow (using a public PDS/AppView) 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 457 const res = await fetch(url); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 461 } 462 }, ··· 474 return useQuery(constructFeedSkeletonQuery(options)); 475 } 476 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 481 return queryOptions({ 482 + queryKey: ["preferences", agent?.did], 483 queryFn: async () => { 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 490 }); 491 } 492 export function useQueryPreferences(options: { 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 495 }) { 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 497 } 498 499 + export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 500 return queryOptions({ 501 queryKey: ["arbitrary", uri], 502 queryFn: async () => { 503 + if (!uri) return undefined as undefined; 504 const res = await fetch( 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 506 ); ··· 511 return undefined; 512 } 513 if (res.status === 400) return undefined; 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 518 return undefined; // cache โ€œnot foundโ€ 519 } 520 try { 521 if (!res.ok) throw new Error("Failed to fetch post"); 522 + return data as { 523 uri: string; 524 cid: string; 525 value: any; ··· 534 return failureCount < 2; 535 }, 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 537 + gcTime: /*0//*/ 5 * 60 * 1000, 538 }); 539 } 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 545 }, 546 Error 547 >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 556 Error 557 >; 558 export function useQueryArbitrary(uri?: string) { 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 560 + return useQuery(constructArbitraryQuery(uri, slingshoturl)); 561 } 562 563 + export function constructFallbackNothingQuery() { 564 return queryOptions({ 565 queryKey: ["nothing"], 566 queryFn: async () => { 567 + return undefined; 568 }, 569 }); 570 } ··· 578 }[]; 579 }; 580 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 586 return queryOptions({ 587 + queryKey: ["authorFeed", did, collection], 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 589 const limit = 25; 590 + 591 const cursor = pageParam as string | undefined; 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 594 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 595 + 596 const res = await fetch(url); 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 598 + 599 return res.json() as Promise<ListRecordsResponse>; 600 }, 601 }); 602 } 603 604 + export function useInfiniteQueryAuthorFeed( 605 + did: string | undefined, 606 + pdsUrl: string | undefined, 607 + collection?: string 608 + ) { 609 + const { queryKey, queryFn } = constructAuthorFeedQuery( 610 + did!, 611 + pdsUrl!, 612 + collection 613 + ); 614 + 615 return useInfiniteQuery({ 616 queryKey, 617 queryFn, ··· 629 isAuthed: boolean; 630 pdsUrl?: string; 631 feedServiceDid?: string; 632 + // todo the hell is a unauthedfeedurl 633 + unauthedfeedurl?: string; 634 }) { 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 638 return queryOptions({ 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 645 + 646 + if (isAuthed && !unauthedfeedurl) { 647 if (!agent || !pdsUrl || !feedServiceDid) { 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 651 } 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 653 const res = await agent.fetchHandler(url, { ··· 657 "Content-Type": "application/json", 658 }, 659 }); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 662 return (await res.json()) as FeedSkeletonPage; 663 } else { 664 + const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 665 const res = await fetch(url); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 668 return (await res.json()) as FeedSkeletonPage; 669 } 670 }, ··· 677 isAuthed: boolean; 678 pdsUrl?: string; 679 feedServiceDid?: string; 680 + unauthedfeedurl?: string; 681 }) { 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 683 + 684 + return { 685 + ...useInfiniteQuery({ 686 + queryKey, 687 + queryFn, 688 + initialPageParam: undefined as never, 689 + getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 690 + staleTime: Infinity, 691 + refetchOnWindowFocus: false, 692 + enabled: 693 + !!options.feedUri && 694 + (options.isAuthed 695 + ? ((!!options.agent && !!options.pdsUrl) || 696 + !!options.unauthedfeedurl) && 697 + !!options.feedServiceDid 698 + : true), 699 + }), 700 + queryKey: queryKey, 701 + }; 702 } 703 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 711 }) { 712 + const safemult = query?.staleMult ?? 1; 713 + // console.log( 714 + // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 715 + // query, 716 + // ) 717 718 return infiniteQueryOptions({ 719 enabled: !!query?.target, 720 queryKey: [ 721 + "reddwarf_constellation", 722 query?.method, 723 query?.target, 724 query?.collection, 725 query?.path, 726 ] as const, 727 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 730 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 736 737 const res = await fetch( 738 + `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 744 745 + if (!res.ok) throw new Error("Failed to fetch"); 746 747 + return (await res.json()) as linksRecordsResponse; 748 }, 749 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 752 }, 753 initialPageParam: undefined, 754 + staleTime: 5 * 60 * 1000 * safemult, 755 + gcTime: 5 * 60 * 1000 * safemult, 756 + }); 757 + } 758 + 759 + export function useQueryLycanStatus() { 760 + const [lycanurl] = useAtom(lycanURLAtom); 761 + const { agent, status } = useAuth(); 762 + const { data: identity } = useQueryIdentity(agent?.did); 763 + return useQuery( 764 + constructLycanStatusCheckQuery({ 765 + agent: agent || undefined, 766 + isAuthed: status === "signedIn", 767 + pdsUrl: identity?.pds, 768 + feedServiceDid: "did:web:"+lycanurl, 769 + }) 770 + ); 771 + } 772 + 773 + export function constructLycanStatusCheckQuery(options: { 774 + agent?: ATPAPI.Agent; 775 + isAuthed: boolean; 776 + pdsUrl?: string; 777 + feedServiceDid?: string; 778 + }) { 779 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 780 + 781 + return queryOptions({ 782 + queryKey: ["lycanStatus", { isAuthed, did: agent?.did }], 783 + 784 + queryFn: async () => { 785 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 786 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`; 787 + const res = await agent.fetchHandler(url, { 788 + method: "GET", 789 + headers: { 790 + "atproto-proxy": `${feedServiceDid}#lycan`, 791 + "Content-Type": "application/json", 792 + }, 793 + }); 794 + if (!res.ok) 795 + throw new Error( 796 + `Authenticated lycan status fetch failed: ${res.statusText}` 797 + ); 798 + return (await res.json()) as statuschek; 799 + } 800 + return undefined; 801 + }, 802 + }); 803 + } 804 + 805 + type statuschek = { 806 + [key: string]: unknown; 807 + error?: "MethodNotImplemented"; 808 + message?: "Method Not Implemented"; 809 + status?: "finished" | "in_progress"; 810 + position?: string, 811 + progress?: number, 812 + 813 + }; 814 + 815 + //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 816 + type importtype = { 817 + message?: "Import has already started" | "Import has been scheduled" 818 + } 819 + 820 + export function constructLycanRequestIndexQuery(options: { 821 + agent?: ATPAPI.Agent; 822 + isAuthed: boolean; 823 + pdsUrl?: string; 824 + feedServiceDid?: string; 825 + }) { 826 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 827 + 828 + return queryOptions({ 829 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 830 + 831 + queryFn: async () => { 832 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 833 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 834 + const res = await agent.fetchHandler(url, { 835 + method: "POST", 836 + headers: { 837 + "atproto-proxy": `${feedServiceDid}#lycan`, 838 + "Content-Type": "application/json", 839 + }, 840 + }); 841 + if (!res.ok) 842 + throw new Error( 843 + `Authenticated lycan status fetch failed: ${res.statusText}` 844 + ); 845 + return await res.json() as importtype; 846 + } 847 + return undefined; 848 + }, 849 + }); 850 + } 851 + 852 + type LycanSearchPage = { 853 + terms: string[]; 854 + posts: string[]; 855 + cursor?: string; 856 + }; 857 + 858 + 859 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 860 + 861 + 862 + const [lycanurl] = useAtom(lycanURLAtom); 863 + const { agent, status } = useAuth(); 864 + const { data: identity } = useQueryIdentity(agent?.did); 865 + 866 + const { queryKey, queryFn } = constructLycanSearchQuery({ 867 + agent: agent || undefined, 868 + isAuthed: status === "signedIn", 869 + pdsUrl: identity?.pds, 870 + feedServiceDid: "did:web:"+lycanurl, 871 + query: options.query, 872 + type: options.type, 873 + }) 874 + 875 + return { 876 + ...useInfiniteQuery({ 877 + queryKey, 878 + queryFn, 879 + initialPageParam: undefined as never, 880 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 881 + //staleTime: Infinity, 882 + refetchOnWindowFocus: false, 883 + // enabled: 884 + // !!options.feedUri && 885 + // (options.isAuthed 886 + // ? ((!!options.agent && !!options.pdsUrl) || 887 + // !!options.unauthedfeedurl) && 888 + // !!options.feedServiceDid 889 + // : true), 890 + }), 891 + queryKey: queryKey, 892 + }; 893 + } 894 + 895 + 896 + export function constructLycanSearchQuery(options: { 897 + agent?: ATPAPI.Agent; 898 + isAuthed: boolean; 899 + pdsUrl?: string; 900 + feedServiceDid?: string; 901 + type: "likes" | "pins" | "reposts" | "quotes"; 902 + query: string; 903 + }) { 904 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 905 + 906 + return infiniteQueryOptions({ 907 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 908 + 909 + queryFn: async ({ 910 + pageParam, 911 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 912 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 913 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 914 + const res = await agent.fetchHandler(url, { 915 + method: "GET", 916 + headers: { 917 + "atproto-proxy": `${feedServiceDid}#lycan`, 918 + "Content-Type": "application/json", 919 + }, 920 + }); 921 + if (!res.ok) 922 + throw new Error( 923 + `Authenticated lycan status fetch failed: ${res.statusText}` 924 + ); 925 + return (await res.json()) as LycanSearchPage; 926 + } 927 + return undefined; 928 + }, 929 + initialPageParam: undefined as never, 930 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 931 + }); 932 + }
+6 -1
vite.config.ts
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 - const PROD_URL = "https://reddwarf.whey.party" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string { 17 return url.replace(/^https?:\/\//, ''); ··· 23 generateMetadataPlugin({ 24 prod: PROD_URL, 25 dev: DEV_URL, 26 }), 27 TanStackRouterVite({ autoCodeSplitting: true }), 28 viteReact({
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 + const PROD_URL = "https://reddwarf.app" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 + 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 19 function shp(url: string): string { 20 return url.replace(/^https?:\/\//, ''); ··· 26 generateMetadataPlugin({ 27 prod: PROD_URL, 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 31 }), 32 TanStackRouterVite({ autoCodeSplitting: true }), 33 viteReact({