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 7 .env 8 8 .nitro 9 9 .tanstack 10 - public/client-metadata.json 10 + public/client-metadata.json 11 + public/resolvers.json
+9
README.md
··· 15 15 16 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 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 + 18 27 ## useQuery 19 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! 20 29
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 1 + import fs from "fs"; 2 + import path from "path"; 3 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 5 + const callbackPath = "/callback"; 6 6 7 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 - 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 + }; 24 26 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 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 + }) { 26 38 return { 27 - name: 'vite-plugin-generate-metadata', 39 + name: "vite-plugin-generate-metadata", 28 40 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.'); 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 + ); 34 50 } 35 51 } else { 36 52 appOrigin = dev; 53 + resolver = devResolver; 37 54 } 38 - 39 - 55 + 40 56 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 42 62 43 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 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 + 45 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 78 }, 47 79 }; 48 - } 80 + }
+10
package-lock.json
··· 29 29 "react": "^19.0.0", 30 30 "react-dom": "^19.0.0", 31 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 32 33 "tailwindcss": "^4.0.6", 33 34 "tanstack-router-keepalive": "^1.0.0" 34 35 }, ··· 12543 12544 "csstype": "^3.1.0", 12544 12545 "seroval": "~1.3.0", 12545 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" 12546 12556 } 12547 12557 }, 12548 12558 "node_modules/source-map": {
+1
package.json
··· 33 33 "react": "^19.0.0", 34 34 "react-dom": "^19.0.0", 35 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 36 37 "tailwindcss": "^4.0.6", 37 38 "tanstack-router-keepalive": "^1.0.0" 38 39 },
+6
src/auto-imports.d.ts
··· 18 18 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default 19 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 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 21 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 22 28 }
+117 -164
src/components/Composer.tsx
··· 8 8 import { useQueryPost } from "~/utils/useQuery"; 9 9 10 10 import { ProfileThing } from "./Login"; 11 - import { Button } from "./radix-m3-rd/Button"; 12 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 13 12 14 13 const MAX_POST_LENGTH = 300; 15 14 16 15 export function Composer() { 17 16 const [composerState, setComposerState] = useAtom(composerAtom); 18 - const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 19 17 const { agent } = useAuth(); 20 18 21 19 const [postText, setPostText] = useState(""); ··· 114 112 setPosting(false); 115 113 } 116 114 } 115 + // if (composerState.kind === "closed") { 116 + // return null; 117 + // } 117 118 118 119 const getPlaceholder = () => { 119 120 switch (composerState.kind) { ··· 131 132 const isPostButtonDisabled = 132 133 posting || !postText.trim() || isParentLoading || charsLeft < 0; 133 134 134 - function handleAttemptClose() { 135 - if (postText.trim() && !posting) { 136 - setCloseConfirmState(true); 137 - } else { 138 - setComposerState({ kind: "closed" }); 139 - } 140 - } 141 - 142 - function handleConfirmClose() { 143 - setComposerState({ kind: "closed" }); 144 - setCloseConfirmState(false); 145 - setPostText(""); 146 - } 147 - 148 135 return ( 149 - <> 150 - <Dialog.Root 151 - open={composerState.kind !== "closed"} 152 - onOpenChange={(open) => { 153 - if (!open) handleAttemptClose(); 154 - }} 155 - > 156 - <Dialog.Portal> 157 - <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 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" /> 158 144 159 - <Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 160 - <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"> 161 - <div className="flex flex-row justify-between p-2"> 162 - <Dialog.Close asChild> 163 - <button 164 - 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" 165 - disabled={posting} 166 - aria-label="Close" 167 - onClick={handleAttemptClose} 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" 168 164 > 169 - <svg 170 - xmlns="http://www.w3.org/2000/svg" 171 - width="20" 172 - height="20" 173 - viewBox="0 0 24 24" 174 - fill="none" 175 - stroke="currentColor" 176 - strokeWidth="2.5" 177 - strokeLinecap="round" 178 - strokeLinejoin="round" 179 - > 180 - <line x1="18" y1="6" x2="6" y2="18"></line> 181 - <line x1="6" y1="6" x2="18" y2="18"></line> 182 - </svg> 183 - </button> 184 - </Dialog.Close> 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> 185 170 186 - <div className="flex-1" /> 187 - <div className="flex items-center gap-4"> 188 - <span 189 - className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 190 - > 191 - {charsLeft} 192 - </span> 193 - <Button 194 - onClick={handlePost} 195 - disabled={isPostButtonDisabled} 196 - > 197 - {posting ? "Posting..." : "Post"} 198 - </Button> 199 - </div> 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> 200 185 </div> 186 + </div> 201 187 202 - {postSuccess ? ( 203 - <div className="flex flex-col items-center justify-center py-16"> 204 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 205 - <span className="text-xl font-bold text-black dark:text-white"> 206 - Posted! 207 - </span> 208 - </div> 209 - ) : ( 210 - <div className="px-4"> 211 - {composerState.kind === "reply" && ( 212 - <div className="mb-1 -mx-4"> 213 - {isParentLoading ? ( 214 - <div className="text-sm text-gray-500 animate-pulse"> 215 - Loading parent post... 216 - </div> 217 - ) : parentUri ? ( 218 - <UniversalPostRendererATURILoader 219 - atUri={parentUri} 220 - bottomReplyLine 221 - bottomBorder={false} 222 - /> 223 - ) : ( 224 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 225 - Could not load parent post. 226 - </div> 227 - )} 228 - </div> 229 - )} 230 - 231 - <div className="flex w-full gap-1 flex-col"> 232 - <ProfileThing agent={agent} large /> 233 - <div className="flex pl-[50px]"> 234 - <AutoGrowTextarea 235 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 236 - rows={5} 237 - placeholder={getPlaceholder()} 238 - value={postText} 239 - onChange={(e) => setPostText(e.target.value)} 240 - disabled={posting} 241 - autoFocus 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} 242 208 /> 243 - </div> 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 + )} 244 214 </div> 215 + )} 245 216 246 - {composerState.kind === "quote" && ( 247 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 248 - {isParentLoading ? ( 249 - <div className="text-sm text-gray-500 animate-pulse"> 250 - Loading parent post... 251 - </div> 252 - ) : parentUri ? ( 253 - <UniversalPostRendererATURILoader 254 - atUri={parentUri} 255 - isQuote 256 - /> 257 - ) : ( 258 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 259 - Could not load parent post. 260 - </div> 261 - )} 262 - </div> 263 - )} 264 - 265 - {postError && ( 266 - <div className="text-red-500 text-sm my-2 text-center"> 267 - {postError} 268 - </div> 269 - )} 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> 270 230 </div> 271 - )} 272 - </div> 273 - </Dialog.Content> 274 - </Dialog.Portal> 275 - </Dialog.Root> 276 231 277 - {/* Close confirmation dialog */} 278 - <Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}> 279 - <Dialog.Portal> 280 - 281 - <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 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 + )} 282 250 283 - <Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40"> 284 - <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-md relative mx-4 py-6"> 285 - <div className="text-xl mb-4 text-center"> 286 - Discard your post? 287 - </div> 288 - <div className="text-md mb-4 text-center"> 289 - You will lose your draft 251 + {postError && ( 252 + <div className="text-red-500 text-sm my-2 text-center"> 253 + {postError} 254 + </div> 255 + )} 290 256 </div> 291 - <div className="flex justify-end gap-2 px-6"> 292 - <Button 293 - onClick={handleConfirmClose} 294 - > 295 - Discard 296 - </Button> 297 - <Button 298 - variant={"outlined"} 299 - onClick={() => setCloseConfirmState(false)} 300 - > 301 - Cancel 302 - </Button> 303 - </div> 304 - </div> 305 - </Dialog.Content> 306 - </Dialog.Portal> 307 - </Dialog.Root> 308 - </> 257 + )} 258 + </div> 259 + </Dialog.Content> 260 + </Dialog.Portal> 261 + </Dialog.Root> 309 262 ); 310 263 } 311 264
+4 -2
src/components/Header.tsx
··· 5 5 6 6 export function Header({ 7 7 backButtonCallback, 8 - title 8 + title, 9 + bottomBorderDisabled, 9 10 }: { 10 11 backButtonCallback?: () => void; 11 12 title?: string; 13 + bottomBorderDisabled?: boolean; 12 14 }) { 13 15 const router = useRouter(); 14 16 const [isAtTop] = useAtom(isAtTopAtom); 15 17 //const what = router.history. 16 18 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`}> 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`}> 18 20 {backButtonCallback ? (<Link 19 21 to=".." 20 22 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+37 -3
src/components/Import.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 3 4 import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 4 9 5 10 /** 6 11 * Basically the best equivalent to Search that i can do 7 12 */ 8 - export function Import() { 9 - const [textInput, setTextInput] = useState<string | undefined>(); 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 10 21 const navigate = useNavigate(); 11 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 + 12 36 const handleEnter = () => { 13 37 if (!textInput) return; 14 38 handleImport({ 15 39 text: textInput, 16 40 navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 17 43 }); 18 44 }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 19 47 20 48 return ( 21 49 <div className="w-full relative"> ··· 23 51 24 52 <input 25 53 type="text" 26 - placeholder="Import..." 54 + placeholder={placeholder} 27 55 value={textInput} 28 56 onChange={(e) => setTextInput(e.target.value)} 29 57 onKeyDown={(e) => { ··· 38 66 function handleImport({ 39 67 text, 40 68 navigate, 69 + lycanReady, 41 70 }: { 42 71 text: string; 43 72 navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 44 74 }) { 45 75 const trimmed = text.trim(); 46 76 // parse text ··· 147 177 // } catch { 148 178 // // continue 149 179 // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 150 184 }
+6 -1
src/components/InfiniteCustomFeed.tsx
··· 14 14 feedUri: string; 15 15 pdsUrl?: string; 16 16 feedServiceDid?: string; 17 + authedOverride?: boolean; 18 + unauthedfeedurl?: string; 17 19 } 18 20 19 21 export function InfiniteCustomFeed({ 20 22 feedUri, 21 23 pdsUrl, 22 24 feedServiceDid, 25 + authedOverride, 26 + unauthedfeedurl, 23 27 }: InfiniteCustomFeedProps) { 24 28 const { agent } = useAuth(); 25 - const authed = !!agent?.did; 29 + const authed = authedOverride || !!agent?.did; 26 30 27 31 // const identityresultmaybe = useQueryIdentity(agent?.did); 28 32 // const identity = identityresultmaybe?.data; ··· 45 49 isAuthed: authed ?? false, 46 50 pdsUrl: pdsUrl, 47 51 feedServiceDid: feedServiceDid, 52 + unauthedfeedurl: unauthedfeedurl, 48 53 }); 49 54 const queryClient = useQueryClient(); 50 55
+3 -5
src/components/Login.tsx
··· 7 7 import { imgCDNAtom } from "~/utils/atoms"; 8 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 9 10 - import { Button } from "./radix-m3-rd/Button"; 11 - 12 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 13 11 export default function Login({ 14 12 compact = false, ··· 51 49 You are logged in! 52 50 </p> 53 51 <ProfileThing agent={agent} large /> 54 - <Button 52 + <button 55 53 onClick={logout} 56 - className="mt-4" 54 + className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 57 55 > 58 56 Log out 59 - </Button> 57 + </button> 60 58 </div> 61 59 </div> 62 60 );
+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 + */
+204 -60
src/components/UniversalPostRenderer.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 1 2 import { useNavigate } from "@tanstack/react-router"; 2 3 import DOMPurify from "dompurify"; 3 4 import { useAtom } from "jotai"; ··· 9 10 import { 10 11 composerAtom, 11 12 constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 12 15 imgCDNAtom, 13 - likedPostsAtom, 14 16 } from "~/utils/atoms"; 15 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 16 18 import { ··· 38 40 feedviewpost?: boolean; 39 41 repostedby?: string; 40 42 style?: React.CSSProperties; 41 - ref?: React.Ref<HTMLDivElement>; 43 + ref?: React.RefObject<HTMLDivElement>; 42 44 dataIndexPropPass?: number; 43 45 nopics?: boolean; 46 + concise?: boolean; 44 47 lightboxCallback?: (d: LightboxProps) => void; 45 48 maxReplies?: number; 46 49 isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 47 53 } 48 54 49 55 // export async function cachedGetRecord({ ··· 152 158 ref, 153 159 dataIndexPropPass, 154 160 nopics, 161 + concise, 155 162 lightboxCallback, 156 163 maxReplies, 157 164 isQuote, 165 + filterNoReplies, 166 + filterMustHaveMedia, 167 + filterMustBeReply, 158 168 }: UniversalPostRendererATURILoaderProps) { 159 169 // todo remove this once tree rendering is implemented, use a prop like isTree 160 170 const TEMPLINEAR = true; ··· 518 528 ? true 519 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 530 ? false 521 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 522 534 } 523 535 topReplyLine={topReplyLine} 524 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 536 548 ref={ref} 537 549 dataIndexPropPass={dataIndexPropPass} 538 550 nopics={nopics} 551 + concise={concise} 539 552 lightboxCallback={lightboxCallback} 540 553 maxReplies={maxReplies} 541 554 isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 557 + filterMustBeReply={filterMustBeReply} 542 558 /> 543 559 <> 544 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 545 561 <> 546 - {/* <div>hello</div> */} 547 - <MoreReplies atUri={atUri} /> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 548 564 </> 549 - ) : (<></>)} 565 + ) : ( 566 + <></> 567 + )} 550 568 </> 551 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 552 570 <> ··· 567 585 ref={ref} 568 586 dataIndexPropPass={dataIndexPropPass} 569 587 nopics={nopics} 588 + concise={concise} 570 589 lightboxCallback={lightboxCallback} 571 590 maxReplies={ 572 591 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined ··· 636 655 ref, 637 656 dataIndexPropPass, 638 657 nopics, 658 + concise, 639 659 lightboxCallback, 640 660 maxReplies, 641 661 isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 642 665 }: { 643 666 postRecord: any; 644 667 profileRecord: any; ··· 654 677 feedviewpost?: boolean; 655 678 repostedby?: string; 656 679 style?: React.CSSProperties; 657 - ref?: React.Ref<HTMLDivElement>; 680 + ref?: React.RefObject<HTMLDivElement>; 658 681 dataIndexPropPass?: number; 659 682 nopics?: boolean; 683 + concise?: boolean; 660 684 lightboxCallback?: (d: LightboxProps) => void; 661 685 maxReplies?: number; 662 686 isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 663 690 }) { 664 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 665 692 const navigate = useNavigate(); ··· 730 757 // run(); 731 758 // }, [postRecord, resolved?.did]); 732 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 + 733 777 const { 734 778 data: hydratedEmbed, 735 779 isLoading: isEmbedLoading, ··· 824 868 // }, [fakepost, get, set]); 825 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 826 870 ?.uri; 827 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 828 873 const replyhookvalue = useQueryIdentity( 829 874 feedviewpost ? feedviewpostreplydid : undefined 830 875 ); ··· 835 880 repostedby ? aturirepostbydid : undefined 836 881 ); 837 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 + 838 890 return ( 839 891 <> 840 892 {/* <p> 841 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 842 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 843 897 <UniversalPostRenderer 844 898 expanded={detailed} 845 899 onPostClick={() => ··· 874 928 ref={ref} 875 929 dataIndexPropPass={dataIndexPropPass} 876 930 nopics={nopics} 931 + concise={concise} 877 932 lightboxCallback={lightboxCallback} 878 933 maxReplies={maxReplies} 879 934 isQuote={isQuote} ··· 1197 1252 1198 1253 import defaultpfp from "~/../public/favicon.png"; 1199 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1200 - import { FollowButton, Mutual } from "~/routes/profile.$did"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1201 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262 + import { useFastLike } from "~/utils/likeMutationQueue"; 1202 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1203 1264 // import type { 1204 1265 // ViewRecord, ··· 1327 1388 ref, 1328 1389 dataIndexPropPass, 1329 1390 nopics, 1391 + concise, 1330 1392 lightboxCallback, 1331 1393 maxReplies, 1332 1394 }: { ··· 1350 1412 depth?: number; 1351 1413 repostedby?: string; 1352 1414 style?: React.CSSProperties; 1353 - ref?: React.Ref<HTMLDivElement>; 1415 + ref?: React.RefObject<HTMLDivElement>; 1354 1416 dataIndexPropPass?: number; 1355 1417 nopics?: boolean; 1418 + concise?: boolean; 1356 1419 lightboxCallback?: (d: LightboxProps) => void; 1357 1420 maxReplies?: number; 1358 1421 }) { 1359 1422 const parsed = new AtUri(post.uri); 1360 1423 const navigate = useNavigate(); 1361 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1362 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1363 1425 post.viewer?.repost ? true : false 1364 1426 ); 1365 - const [hasLiked, setHasLiked] = useState<boolean>( 1366 - post.uri in likedPosts || post.viewer?.like ? true : false 1367 - ); 1368 1427 const [, setComposerPost] = useAtom(composerAtom); 1369 1428 const { agent } = useAuth(); 1370 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1371 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1372 1430 post.viewer?.repost 1373 1431 ); 1374 - 1375 - const likeOrUnlikePost = async () => { 1376 - const newLikedPosts = { ...likedPosts }; 1377 - if (!agent) { 1378 - console.error("Agent is null or undefined"); 1379 - return; 1380 - } 1381 - if (hasLiked) { 1382 - if (post.uri in likedPosts) { 1383 - const likeUri = likedPosts[post.uri]; 1384 - setLikeUri(likeUri); 1385 - } 1386 - if (likeUri) { 1387 - await agent.deleteLike(likeUri); 1388 - setHasLiked(false); 1389 - delete newLikedPosts[post.uri]; 1390 - } 1391 - } else { 1392 - const { uri } = await agent.like(post.uri, post.cid); 1393 - setLikeUri(uri); 1394 - setHasLiked(true); 1395 - newLikedPosts[post.uri] = uri; 1396 - } 1397 - setLikedPosts(newLikedPosts); 1398 - }; 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]) 1399 1439 1400 1440 const repostOrUnrepostPost = async () => { 1401 1441 if (!agent) { ··· 1426 1466 : undefined; 1427 1467 1428 1468 const emergencySalt = randomString(); 1429 - const fedi = (post.record as { bridgyOriginalText?: string }) 1469 + 1470 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 + 1473 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1430 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); 1431 1507 1432 1508 /* fuck you */ 1433 1509 const isMainItem = false; 1434 1510 const setMainItem = (any: any) => {}; 1435 1511 // eslint-disable-next-line react-hooks/refs 1436 - console.log("Received ref in UniversalPostRenderer:", ref); 1512 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1437 1513 return ( 1438 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1439 1515 <div ··· 1557 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" 1558 1634 /> 1559 1635 <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 - <div className=" flex flex-col justify-start"> 1561 1636 <FollowButton targetdidorhandle={post.author.did} /> 1562 - </div> 1563 1637 </div> 1564 1638 </div> 1565 1639 <div className="flex flex-col gap-3"> ··· 1568 1642 {post.author.displayName || post.author.handle}{" "} 1569 1643 </div> 1570 1644 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1571 - <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1572 1647 </div> 1573 1648 </div> 1574 1649 {uprrrsauthor?.description && ( ··· 1761 1836 <div 1762 1837 style={{ 1763 1838 fontSize: 16, 1764 - marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1839 + marginBottom: !post.embed || concise ? 0 : 8, 1765 1840 whiteSpace: "pre-wrap", 1766 1841 textAlign: "left", 1767 1842 overflowWrap: "anywhere", 1768 1843 wordBreak: "break-word", 1769 - //color: theme.text, 1844 + ...(concise && { 1845 + display: "-webkit-box", 1846 + WebkitBoxOrient: "vertical", 1847 + WebkitLineClamp: 2, 1848 + overflow: "hidden", 1849 + }), 1770 1850 }} 1771 1851 className="text-gray-900 dark:text-gray-100" 1772 1852 > ··· 1789 1869 </> 1790 1870 )} 1791 1871 </div> 1792 - {post.embed && depth < 1 ? ( 1872 + {post.embed && depth < 1 && !concise ? ( 1793 1873 <PostEmbeds 1794 1874 embed={post.embed} 1795 1875 //moderation={moderation} ··· 1811 1891 </div> 1812 1892 </> 1813 1893 )} 1814 - <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1815 1899 <> 1816 1900 {expanded && ( 1817 1901 <div ··· 1907 1991 </DropdownMenu.Root> 1908 1992 <HitSlopButton 1909 1993 onClick={() => { 1910 - likeOrUnlikePost(); 1994 + toggle(); 1911 1995 }} 1912 1996 style={{ 1913 1997 ...btnstyle, 1914 - ...(hasLiked ? { color: "#EC4899" } : {}), 1998 + ...(liked ? { color: "#EC4899" } : {}), 1915 1999 }} 1916 2000 > 1917 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1918 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 2001 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 + {(post.likeCount || 0) + (liked ? 1 : 0)} 1919 2003 </HitSlopButton> 1920 2004 <div style={{ display: "flex", gap: 8 }}> 1921 2005 <HitSlopButton ··· 1929 2013 "/post/" + 1930 2014 post.uri.split("/").pop() 1931 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 1932 2019 } catch (_e) { 1933 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 1934 2024 } 1935 2025 }} 1936 2026 style={{ ··· 1939 2029 > 1940 2030 <MdiShareVariant /> 1941 2031 </HitSlopButton> 1942 - <span style={btnstyle}> 1943 - <MdiMoreHoriz /> 1944 - </span> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 1945 2043 </div> 1946 2044 </div> 1947 2045 )} ··· 2167 2265 } 2168 2266 2169 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 + 2170 2272 // custom feed embed (i.e. generator view) 2171 2273 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2172 2274 // stopgap sorry ··· 2176 2278 // <MaybeFeedCard view={embed.record} /> 2177 2279 // </div> 2178 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 + ); 2179 2291 } 2180 2292 2181 2293 // list embed ··· 2187 2299 // <MaybeListCard view={embed.record} /> 2188 2300 // </div> 2189 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 + ); 2190 2317 } 2191 2318 2192 2319 // starter pack embed ··· 2198 2325 // <StarterPackCard starterPack={embed.record} /> 2199 2326 // </div> 2200 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 + ); 2201 2343 } 2202 2344 2203 2345 // quote post ··· 2257 2399 </div> 2258 2400 ); 2259 2401 } else { 2402 + console.log("what the hell is a ", embed); 2260 2403 return <>sorry</>; 2261 2404 } 2262 2405 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; ··· 2566 2709 // = 2567 2710 if (AppBskyEmbedVideo.isView(embed)) { 2568 2711 // hls playlist 2712 + if (nopics) return; 2569 2713 const playlist = embed.playlist; 2570 2714 return ( 2571 2715 <SmartHLSPlayer ··· 2689 2833 className="link" 2690 2834 style={{ 2691 2835 textDecoration: "none", 2692 - color: "rgb(29, 122, 242)", 2836 + color: "var(--link-text-color)", 2693 2837 wordBreak: "break-all", 2694 2838 }} 2695 2839 target="_blank" ··· 2709 2853 result.push( 2710 2854 <span 2711 2855 key={start} 2712 - style={{ color: "rgb(29, 122, 242)" }} 2856 + style={{ color: "var(--link-text-color)" }} 2713 2857 className=" cursor-pointer" 2714 2858 onClick={(e) => { 2715 2859 e.stopPropagation(); ··· 2727 2871 result.push( 2728 2872 <span 2729 2873 key={start} 2730 - style={{ color: "rgb(29, 122, 242)" }} 2874 + style={{ color: "var(--link-text-color)" }} 2731 2875 onClick={(e) => { 2732 2876 e.stopPropagation(); 2733 2877 }}
-59
src/components/radix-m3-rd/Button.tsx
··· 1 - import { Slot } from "@radix-ui/react-slot"; 2 - import clsx from "clsx"; 3 - import * as React from "react"; 4 - 5 - export type ButtonVariant = "filled" | "outlined" | "text" | "secondary"; 6 - export type ButtonSize = "sm" | "md" | "lg"; 7 - 8 - const variantClasses: Record<ButtonVariant, string> = { 9 - filled: 10 - "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 11 - secondary: 12 - "bg-gray-300 text-gray-900 hover:bg-gray-400 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-500", 13 - outlined: 14 - "border border-gray-800 text-gray-800 hover:bg-gray-100 dark:border-gray-200 dark:text-gray-200 dark:hover:bg-gray-800/10", 15 - text: "bg-transparent text-gray-800 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800/10", 16 - }; 17 - 18 - const sizeClasses: Record<ButtonSize, string> = { 19 - sm: "px-3 py-1.5 text-sm", 20 - md: "px-4 py-2 text-base", 21 - lg: "px-6 py-3 text-lg", 22 - }; 23 - 24 - export function Button({ 25 - variant = "filled", 26 - size = "md", 27 - asChild = false, 28 - ref, 29 - className, 30 - children, 31 - ...props 32 - }: { 33 - variant?: ButtonVariant; 34 - size?: ButtonSize; 35 - asChild?: boolean; 36 - className?: string; 37 - children?: React.ReactNode; 38 - ref?: React.Ref<HTMLButtonElement>; 39 - } & React.ComponentPropsWithoutRef<"button">) { 40 - const Comp = asChild ? Slot : "button"; 41 - 42 - return ( 43 - <Comp 44 - ref={ref} 45 - className={clsx( 46 - //focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-gray-500 dark:focus:ring-gray-300 47 - "inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed", 48 - variantClasses[variant], 49 - sizeClasses[size], 50 - className 51 - )} 52 - {...props} 53 - > 54 - {children} 55 - </Comp> 56 - ); 57 - } 58 - 59 - Button.displayName = "Button";
+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 + }
+150
src/routeTree.gen.ts
··· 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 13 import { Route as SearchRouteImport } from './routes/search' 14 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 15 16 import { Route as FeedsRouteImport } from './routes/feeds' 16 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 18 import { Route as IndexRouteImport } from './routes/index' 18 19 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 20 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 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' 21 24 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 25 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 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' 24 31 import { Route as ProfileDidPostRkeyImageIRouteImport } from './routes/profile.$did/post.$rkey.image.$i' 25 32 26 33 const SettingsRoute = SettingsRouteImport.update({ ··· 38 45 path: '/notifications', 39 46 getParentRoute: () => rootRouteImport, 40 47 } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 51 + getParentRoute: () => rootRouteImport, 52 + } as any) 41 53 const FeedsRoute = FeedsRouteImport.update({ 42 54 id: '/feeds', 43 55 path: '/feeds', ··· 67 79 path: '/profile/$did/', 68 80 getParentRoute: () => rootRouteImport, 69 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) 70 92 const PathlessLayoutNestedLayoutRouteBRoute = 71 93 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 72 94 id: '/route-b', ··· 84 106 path: '/profile/$did/post/$rkey', 85 107 getParentRoute: () => rootRouteImport, 86 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) 87 132 const ProfileDidPostRkeyImageIRoute = 88 133 ProfileDidPostRkeyImageIRouteImport.update({ 89 134 id: '/image/$i', ··· 94 139 export interface FileRoutesByFullPath { 95 140 '/': typeof IndexRoute 96 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 97 143 '/notifications': typeof NotificationsRoute 98 144 '/search': typeof SearchRoute 99 145 '/settings': typeof SettingsRoute 100 146 '/callback': typeof CallbackIndexRoute 101 147 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 102 148 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 149 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 150 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 103 151 '/profile/$did': typeof ProfileDidIndexRoute 152 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 104 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 105 157 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 106 158 } 107 159 export interface FileRoutesByTo { 108 160 '/': typeof IndexRoute 109 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 110 163 '/notifications': typeof NotificationsRoute 111 164 '/search': typeof SearchRoute 112 165 '/settings': typeof SettingsRoute 113 166 '/callback': typeof CallbackIndexRoute 114 167 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 115 168 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 169 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 170 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 116 171 '/profile/$did': typeof ProfileDidIndexRoute 172 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 117 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 118 177 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 119 178 } 120 179 export interface FileRoutesById { ··· 122 181 '/': typeof IndexRoute 123 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 124 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 125 185 '/notifications': typeof NotificationsRoute 126 186 '/search': typeof SearchRoute 127 187 '/settings': typeof SettingsRoute ··· 129 189 '/callback/': typeof CallbackIndexRoute 130 190 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 131 191 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 192 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 193 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 132 194 '/profile/$did/': typeof ProfileDidIndexRoute 195 + '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 133 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 134 200 '/profile/$did/post/$rkey/image/$i': typeof ProfileDidPostRkeyImageIRoute 135 201 } 136 202 export interface FileRouteTypes { ··· 138 204 fullPaths: 139 205 | '/' 140 206 | '/feeds' 207 + | '/moderation' 141 208 | '/notifications' 142 209 | '/search' 143 210 | '/settings' 144 211 | '/callback' 145 212 | '/route-a' 146 213 | '/route-b' 214 + | '/profile/$did/followers' 215 + | '/profile/$did/follows' 147 216 | '/profile/$did' 217 + | '/profile/$did/feed/$rkey' 148 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' 149 222 | '/profile/$did/post/$rkey/image/$i' 150 223 fileRoutesByTo: FileRoutesByTo 151 224 to: 152 225 | '/' 153 226 | '/feeds' 227 + | '/moderation' 154 228 | '/notifications' 155 229 | '/search' 156 230 | '/settings' 157 231 | '/callback' 158 232 | '/route-a' 159 233 | '/route-b' 234 + | '/profile/$did/followers' 235 + | '/profile/$did/follows' 160 236 | '/profile/$did' 237 + | '/profile/$did/feed/$rkey' 161 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' 162 242 | '/profile/$did/post/$rkey/image/$i' 163 243 id: 164 244 | '__root__' 165 245 | '/' 166 246 | '/_pathlessLayout' 167 247 | '/feeds' 248 + | '/moderation' 168 249 | '/notifications' 169 250 | '/search' 170 251 | '/settings' ··· 172 253 | '/callback/' 173 254 | '/_pathlessLayout/_nested-layout/route-a' 174 255 | '/_pathlessLayout/_nested-layout/route-b' 256 + | '/profile/$did/followers' 257 + | '/profile/$did/follows' 175 258 | '/profile/$did/' 259 + | '/profile/$did/feed/$rkey' 176 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' 177 264 | '/profile/$did/post/$rkey/image/$i' 178 265 fileRoutesById: FileRoutesById 179 266 } ··· 181 268 IndexRoute: typeof IndexRoute 182 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 183 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 184 272 NotificationsRoute: typeof NotificationsRoute 185 273 SearchRoute: typeof SearchRoute 186 274 SettingsRoute: typeof SettingsRoute 187 275 CallbackIndexRoute: typeof CallbackIndexRoute 276 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 277 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 188 278 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 279 + ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 189 280 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren 190 281 } 191 282 ··· 212 303 preLoaderRoute: typeof NotificationsRouteImport 213 304 parentRoute: typeof rootRouteImport 214 305 } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 311 + parentRoute: typeof rootRouteImport 312 + } 215 313 '/feeds': { 216 314 id: '/feeds' 217 315 path: '/feeds' ··· 254 352 preLoaderRoute: typeof ProfileDidIndexRouteImport 255 353 parentRoute: typeof rootRouteImport 256 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 + } 257 369 '/_pathlessLayout/_nested-layout/route-b': { 258 370 id: '/_pathlessLayout/_nested-layout/route-b' 259 371 path: '/route-b' ··· 275 387 preLoaderRoute: typeof ProfileDidPostRkeyRouteImport 276 388 parentRoute: typeof rootRouteImport 277 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 + } 278 418 '/profile/$did/post/$rkey/image/$i': { 279 419 id: '/profile/$did/post/$rkey/image/$i' 280 420 path: '/image/$i' ··· 316 456 ) 317 457 318 458 interface ProfileDidPostRkeyRouteChildren { 459 + ProfileDidPostRkeyLikedByRoute: typeof ProfileDidPostRkeyLikedByRoute 460 + ProfileDidPostRkeyQuotesRoute: typeof ProfileDidPostRkeyQuotesRoute 461 + ProfileDidPostRkeyRepostedByRoute: typeof ProfileDidPostRkeyRepostedByRoute 319 462 ProfileDidPostRkeyImageIRoute: typeof ProfileDidPostRkeyImageIRoute 320 463 } 321 464 322 465 const ProfileDidPostRkeyRouteChildren: ProfileDidPostRkeyRouteChildren = { 466 + ProfileDidPostRkeyLikedByRoute: ProfileDidPostRkeyLikedByRoute, 467 + ProfileDidPostRkeyQuotesRoute: ProfileDidPostRkeyQuotesRoute, 468 + ProfileDidPostRkeyRepostedByRoute: ProfileDidPostRkeyRepostedByRoute, 323 469 ProfileDidPostRkeyImageIRoute: ProfileDidPostRkeyImageIRoute, 324 470 } 325 471 ··· 330 476 IndexRoute: IndexRoute, 331 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 332 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 333 480 NotificationsRoute: NotificationsRoute, 334 481 SearchRoute: SearchRoute, 335 482 SettingsRoute: SettingsRoute, 336 483 CallbackIndexRoute: CallbackIndexRoute, 484 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 485 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 337 486 ProfileDidIndexRoute: ProfileDidIndexRoute, 487 + ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 338 488 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren, 339 489 } 340 490 export const routeTree = rootRouteImport
+178 -16
src/routes/__root.tsx
··· 14 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 15 import { useAtom } from "jotai"; 16 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 17 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 20 19 21 import { Composer } from "~/components/Composer"; ··· 22 24 import Login from "~/components/Login"; 23 25 import { NotFound } from "~/components/NotFound"; 24 26 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 25 28 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 26 29 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 27 30 import { seo } from "~/utils/seo"; ··· 79 82 function RootComponent() { 80 83 return ( 81 84 <UnifiedAuthProvider> 82 - <RootDocument> 83 - <KeepAliveProvider> 84 - <KeepAliveOutlet /> 85 - </KeepAliveProvider> 86 - </RootDocument> 85 + <LikeMutationQueueProvider> 86 + <RootDocument> 87 + <KeepAliveProvider> 88 + <AppToaster /> 89 + <KeepAliveOutlet /> 90 + </KeepAliveProvider> 91 + </RootDocument> 92 + </LikeMutationQueueProvider> 87 93 </UnifiedAuthProvider> 88 94 ); 89 95 } 90 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 + 91 201 function RootDocument({ children }: { children: React.ReactNode }) { 92 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 93 203 const location = useLocation(); ··· 103 213 const isSettings = location.pathname.startsWith("/settings"); 104 214 const isSearch = location.pathname.startsWith("/search"); 105 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 106 217 107 218 const locationEnum: 108 219 | "feeds" ··· 110 221 | "settings" 111 222 | "notifications" 112 223 | "profile" 224 + | "moderation" 113 225 | "home" = isFeeds 114 226 ? "feeds" 115 227 : isSearch ··· 120 232 ? "notifications" 121 233 : isProfile 122 234 ? "profile" 123 - : "home"; 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 124 238 125 239 const [, setComposerPost] = useAtom(composerAtom); 126 240 ··· 131 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 132 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"> 133 247 <div className="flex items-center gap-3 mb-4"> 134 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 135 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 136 256 Red Dwarf{" "} 137 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 193 313 }) 194 314 } 195 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" 196 328 /> 197 329 <MaterialNavItem 198 330 InactiveIcon={ ··· 232 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 233 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 234 366 //active={true} 235 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 236 368 text="Post" 237 369 /> 238 370 </div> ··· 370 502 371 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"> 372 504 <div className="flex items-center gap-3 mb-4"> 373 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 374 512 </div> 375 513 <MaterialNavItem 376 514 small ··· 433 571 /> 434 572 <MaterialNavItem 435 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 436 587 InactiveIcon={ 437 588 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 438 589 } ··· 472 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 473 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 474 625 //active={true} 475 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 476 627 text="Post" 477 628 /> 478 629 </div> ··· 482 633 <button 483 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" 484 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 485 - onClick={() => setComposerPost({ kind: 'root' })} 636 + onClick={() => setComposerPost({ kind: "root" })} 486 637 type="button" 487 638 aria-label="Create Post" 488 639 > ··· 499 650 </main> 500 651 501 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 502 - <div className="px-4 pt-4"><Import /></div> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 503 656 <Login /> 504 657 505 658 <div className="flex-1"></div> 506 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 507 - 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) 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) 508 664 </p> 509 665 </aside> 510 666 </div> ··· 651 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 652 808 } 653 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 654 - active={locationEnum === "settings"} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 655 811 onClickCallbback={() => 656 812 navigate({ 657 813 to: "/settings", ··· 680 836 ) : ( 681 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"> 682 838 <div className="flex items-center gap-2"> 683 - <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 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 + /> 684 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 685 847 Red Dwarf{" "} 686 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 700 862 ); 701 863 } 702 864 703 - function MaterialNavItem({ 865 + export function MaterialNavItem({ 704 866 InactiveIcon, 705 867 ActiveIcon, 706 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + 3 5 export const Route = createFileRoute("/feeds")({ 4 6 component: Feeds, 5 7 }); 6 8 7 9 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 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 + ); 9 26 }
+44 -33
src/routes/index.tsx
··· 359 359 > 360 360 {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 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) => { 363 - const label = item.value.split("/").pop() || item.value; 364 - const isActive = selectedFeed === item.value; 365 - return ( 366 - <button 367 - key={item.value || idx} 368 - className={`px-3 py-1 rounded-full whitespace-nowrap font-medium transition-colors ${ 369 - isActive 370 - ? "text-gray-900 dark:text-gray-100 hover:bg-gray-300 dark:bg-gray-700 bg-gray-200 hover:dark:bg-gray-600" 371 - : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 hover:dark:bg-gray-800" 372 - // ? "bg-gray-500 text-white" 373 - // : item.pinned 374 - // ? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" 375 - // : "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200" 376 - }`} 377 - onClick={() => setSelectedFeed(item.value)} 378 - title={item.value} 379 - > 380 - {label} 381 - {item.pinned && ( 382 - <span 383 - className={`ml-1 text-xs ${ 384 - isActive 385 - ? "text-gray-900 dark:text-gray-100" 386 - : "text-gray-600 dark:text-gray-400" 387 - }`} 388 - > 389 - โ˜… 390 - </span> 391 - )} 392 - </button> 393 - ); 394 - })} 362 + {savedFeeds.map((item: any, idx: number) => {return <FeedTabOnTop key={item} item={item} idx={idx} />})} 395 363 </div> 396 364 ) : ( 397 365 // <span className="text-xl font-bold ml-2">Home</span> ··· 435 403 </div> 436 404 ); 437 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 + 438 449 // not even used lmaooo 439 450 440 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"; 1 + import { AtUri } from "@atproto/api"; 2 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 2 4 import { useAtom } from "jotai"; 3 - import React, { useEffect, useRef,useState } from "react"; 5 + import * as React from "react"; 4 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"; 5 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 - import { constellationURLAtom } from "~/utils/atoms"; 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"; 7 33 8 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 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 + } 9 54 10 55 export const Route = createFileRoute("/notifications")({ 11 56 component: NotificationsComponent, 12 57 }); 13 58 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); 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]); 25 113 26 - useEffect(() => { 27 - if (authLoading) return; 28 - if (authed && agent && agent.assertDid) { 29 - setDid(agent.assertDid); 30 - } 31 - }, [authed, agent, authLoading]); 114 + useReusableTabScrollRestore("Notifications"); 32 115 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 - } 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." />; 74 120 75 - const [constellationURL] = useAtom(constellationURLAtom) 121 + return ( 122 + <> 123 + {mentionsAturis.map((m) => ( 124 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 125 + ))} 76 126 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; 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); 98 296 } 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); 297 + }); 113 298 }); 114 - return () => { 115 - ignore = true; 116 - }; 117 - }, [did]); 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); 118 311 119 312 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 - )} 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> 143 512 </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> 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" /> 147 625 )} 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> */} 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} />} 174 644 </div> 175 645 ); 176 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 + }
+838 -86
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { Agent, RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 2 4 import { useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 5 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4 6 import { useAtom } from "jotai"; 5 7 import React, { type ReactNode, useEffect, useState } from "react"; 6 8 9 + import defaultpfp from "~/../public/favicon.png"; 7 10 import { Header } from "~/components/Header"; 8 - import { Button } from "~/components/radix-m3-rd/Button"; 11 + import { 12 + ReusableTabRoute, 13 + useReusableTabScrollRestore, 14 + } from "~/components/ReusableTabRoute"; 9 15 import { 10 16 renderTextWithFacets, 11 17 UniversalPostRendererATURILoader, 12 18 } from "~/components/UniversalPostRenderer"; 13 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 14 - import { imgCDNAtom } from "~/utils/atoms"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 15 21 import { 16 22 toggleFollow, 17 23 useGetFollowState, 18 24 useGetOneToOneState, 19 25 } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 20 27 import { 21 28 useInfiniteQueryAuthorFeed, 29 + useQueryArbitrary, 30 + useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 22 32 useQueryIdentity, 23 33 useQueryProfile, 24 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 36 + 37 + import { renderSnack } from "../__root"; 38 + import { Chip } from "../notifications"; 25 39 26 40 export const Route = createFileRoute("/profile/$did/")({ 27 41 component: ProfileComponent, ··· 30 44 function ProfileComponent() { 31 45 // booo bad this is not always the did it might be a handle, use identity.did instead 32 46 const { did } = Route.useParams(); 47 + const { agent } = useAuth(); 33 48 const navigate = useNavigate(); 34 49 const queryClient = useQueryClient(); 35 50 const { ··· 38 53 error: identityError, 39 54 } = useQueryIdentity(did); 40 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 + 41 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 42 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 43 72 const pdsUrl = identity?.pds; ··· 48 77 const { data: profileRecord } = useQueryProfile(profileUri); 49 78 const profile = profileRecord?.value; 50 79 51 - const { 52 - data: postsData, 53 - fetchNextPage, 54 - hasNextPage, 55 - isFetchingNextPage, 56 - isLoading: arePostsLoading, 57 - } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 58 - 59 - React.useEffect(() => { 60 - if (postsData) { 61 - postsData.pages.forEach((page) => { 62 - page.records.forEach((record) => { 63 - if (!queryClient.getQueryData(["post", record.uri])) { 64 - queryClient.setQueryData(["post", record.uri], record); 65 - } 66 - }); 67 - }); 68 - } 69 - }, [postsData, queryClient]); 70 - 71 - const posts = React.useMemo( 72 - () => postsData?.pages.flatMap((page) => page.records) ?? [], 73 - [postsData] 74 - ); 75 - 76 80 const [imgcdn] = useAtom(imgCDNAtom); 77 81 78 82 function getAvatarUrl(p: typeof profile) { ··· 91 95 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 92 96 const description = profile?.description || ""; 93 97 94 - if (isIdentityLoading) { 95 - return ( 96 - <div className="p-4 text-center text-gray-500">Resolving profile...</div> 97 - ); 98 - } 98 + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 99 99 100 - if (identityError) { 101 - return ( 102 - <div className="p-4 text-center text-red-500"> 103 - Error: {identityError.message} 104 - </div> 105 - ); 106 - } 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 + ); 107 110 108 - if (!resolvedDid) { 109 - return ( 110 - <div className="p-4 text-center text-gray-500">Profile not found.</div> 111 - ); 112 - } 111 + const followercount = resultwhateversure?.data?.total; 113 112 114 113 return ( 115 - <> 114 + <div className=""> 116 115 <Header 117 116 title={`Profile`} 118 117 backButtonCallback={() => { ··· 122 121 window.location.assign("/"); 123 122 } 124 123 }} 124 + bottomBorderDisabled={true} 125 125 /> 126 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 127 <Link ··· 156 156 157 157 {/* Avatar (PFP) */} 158 158 <div className="absolute left-[16px] top-[100px] "> 159 - <img 160 - src={getAvatarUrl(profile) || "/favicon.png"} 161 - alt="avatar" 162 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 163 - /> 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 + )} 164 172 </div> 165 173 166 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 175 + <BiteButton targetdidorhandle={did} /> 167 176 {/* 168 177 todo: full follow and unfollow backfill (along with partial likes backfill, 169 178 just enough for it to be useful) ··· 171 180 also save it persistently 172 181 */} 173 182 <FollowButton targetdidorhandle={did} /> 174 - <Button className="rounded-full" variant={"secondary"}> 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 + > 175 193 ... {/* todo: icon */} 176 - </Button> 194 + </button> 177 195 </div> 178 196 179 197 {/* Info Card */} ··· 183 201 <Mutual targetdidorhandle={did} /> 184 202 {handle} 185 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> 186 218 {description && ( 187 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 188 220 {/* {description} */} ··· 192 224 </div> 193 225 </div> 194 226 195 - {/* Posts Section */} 196 - <div className="max-w-2xl mx-auto"> 197 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 198 - Posts 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... 199 261 </div> 200 - <div> 201 - {posts.map((post) => ( 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 ( 202 471 <UniversalPostRendererATURILoader 203 - key={post.uri} 204 - atUri={post.uri} 472 + key={repostRecord.subject.uri} 473 + atUri={repostRecord.subject.uri} 205 474 feedviewpost={true} 475 + repostedby={repost.uri} 206 476 /> 207 - ))} 208 - </div> 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 + ); 209 524 210 - {/* Loading and "Load More" states */} 211 - {arePostsLoading && posts.length === 0 && ( 212 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 213 - )} 214 - {isFetchingNextPage && ( 215 - <div className="p-4 text-center text-gray-500">Loading more...</div> 216 - )} 217 - {hasNextPage && !isFetchingNextPage && ( 218 - <button 219 - onClick={() => fetchNextPage()} 220 - 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" 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" 221 610 > 222 - Load More Posts 223 - </button> 224 - )} 225 - {posts.length === 0 && !arePostsLoading && ( 226 - <div className="p-4 text-center text-gray-500">No posts found.</div> 227 - )} 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 + ))} 228 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 + )} 229 896 </> 230 897 ); 231 898 } ··· 249 916 {identity?.did !== agent?.did ? ( 250 917 <> 251 918 {!(followRecords?.length && followRecords?.length > 0) ? ( 252 - <Button 919 + <button 253 920 onClick={(e) => { 254 921 e.stopPropagation(); 255 922 toggleFollow({ ··· 259 926 queryClient: queryClient, 260 927 }); 261 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]" 262 930 > 263 931 Follow 264 - </Button> 932 + </button> 265 933 ) : ( 266 - <Button 934 + <button 267 935 onClick={(e) => { 268 936 e.stopPropagation(); 269 937 toggleFollow({ ··· 273 941 queryClient: queryClient, 274 942 }); 275 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]" 276 945 > 277 946 Unfollow 278 - </Button> 947 + </button> 279 948 )} 280 949 </> 281 950 ) : ( 282 - <Button variant={"secondary"}>Edit Profile</Button> 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> 283 963 )} 284 964 </> 285 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 + } 286 1038 } 287 1039 288 1040 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+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 + }
+98 -92
src/routes/profile.$did/post.$rkey.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 - import { createFileRoute, Outlet } from "@tanstack/react-router"; 3 + import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; 4 4 import { useAtom } from "jotai"; 5 5 import React, { useLayoutEffect } from "react"; 6 6 ··· 52 52 nopics?: boolean; 53 53 lightboxCallback?: (d: LightboxProps) => void; 54 54 }) { 55 + const matchRoute = useMatchRoute() 56 + const showMainPostRoute = !!matchRoute({ to: '/profile/$did/post/$rkey' }) || !!matchRoute({ to: '/profile/$did/post/$rkey/image/$i' }) 57 + 55 58 //const { get, set } = usePersistentStore(); 56 59 const queryClient = useQueryClient(); 57 60 // const [resolvedDid, setResolvedDid] = React.useState<string | null>(null); ··· 190 193 data: identity, 191 194 isLoading: isIdentityLoading, 192 195 error: identityError, 193 - } = useQueryIdentity(did); 196 + } = useQueryIdentity(showMainPostRoute ? did : undefined); 194 197 195 198 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 196 199 197 200 const atUri = React.useMemo( 198 201 () => 199 - resolvedDid 202 + resolvedDid && showMainPostRoute 200 203 ? `at://${decodeURIComponent(resolvedDid)}/app.bsky.feed.post/${rkey}` 201 204 : undefined, 202 - [resolvedDid, rkey] 205 + [resolvedDid, rkey, showMainPostRoute] 203 206 ); 204 207 205 - const { data: mainPost } = useQueryPost(atUri); 208 + const { data: mainPost } = useQueryPost(showMainPostRoute ? atUri : undefined); 206 209 207 210 console.log("atUri",atUri) 208 211 ··· 215 218 ); 216 219 217 220 // @ts-expect-error i hate overloads 218 - const { data: links } = useQueryConstellation(atUri?{ 221 + const { data: links } = useQueryConstellation(atUri&&showMainPostRoute?{ 219 222 method: "/links/all", 220 223 target: atUri, 221 224 } : { ··· 248 251 }, [links]); 249 252 250 253 const { data: opreplies } = useQueryConstellation( 251 - !!opdid && replyCount && replyCount >= 25 254 + showMainPostRoute && !!opdid && replyCount && replyCount >= 25 252 255 ? { 253 256 method: "/links", 254 257 target: atUri, ··· 289 292 path: ".reply.parent.uri", 290 293 } 291 294 ), 292 - enabled: !!atUri, 295 + enabled: !!atUri && showMainPostRoute, 293 296 }); 294 297 295 298 const { ··· 371 374 const [layoutReady, setLayoutReady] = React.useState(false); 372 375 373 376 useLayoutEffect(() => { 377 + if (!showMainPostRoute) return 374 378 if (parents.length > 0 && !layoutReady && mainPostRef.current) { 375 379 const mainPostElement = mainPostRef.current; 376 380 ··· 389 393 // eslint-disable-next-line react-hooks/set-state-in-effect 390 394 setLayoutReady(true); 391 395 } 392 - }, [parents, layoutReady]); 396 + }, [parents, layoutReady, showMainPostRoute]); 393 397 394 398 395 399 const [slingshoturl] = useAtom(slingshotURLAtom) 396 400 397 401 React.useEffect(() => { 398 - if (parentsLoading) { 402 + if (parentsLoading || !showMainPostRoute) { 399 403 setLayoutReady(false); 400 404 } 401 405 ··· 403 407 setLayoutReady(true); 404 408 hasPerformedInitialLayout.current = true; 405 409 } 406 - }, [parentsLoading, mainPost]); 410 + }, [parentsLoading, mainPost, showMainPostRoute]); 407 411 408 412 React.useEffect(() => { 409 413 if (!mainPost?.value?.reply?.parent?.uri) { ··· 444 448 return () => { 445 449 ignore = true; 446 450 }; 447 - }, [mainPost, queryClient]); 451 + }, [mainPost, queryClient, slingshoturl]); 448 452 449 - if (!did || !rkey) return <div>Invalid post URI</div>; 450 - if (isIdentityLoading) return <div>Resolving handle...</div>; 451 - if (identityError) 453 + if ((!did || !rkey) && showMainPostRoute) return <div>Invalid post URI</div>; 454 + if (isIdentityLoading && showMainPostRoute) return <div>Resolving handle...</div>; 455 + if (identityError && showMainPostRoute) 452 456 return <div style={{ color: "red" }}>{identityError.message}</div>; 453 - if (!atUri) return <div>Could not construct post URI.</div>; 457 + if (!atUri && showMainPostRoute) return <div>Could not construct post URI.</div>; 454 458 455 459 return ( 456 460 <> 457 461 <Outlet /> 458 - <Header 459 - title={`Post`} 460 - backButtonCallback={() => { 461 - if (window.history.length > 1) { 462 - window.history.back(); 463 - } else { 464 - window.location.assign("/"); 465 - } 466 - }} 467 - /> 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 + /> 468 473 469 - {parentsLoading && ( 470 - <div className="text-center text-gray-500 dark:text-gray-400 flex flex-row"> 471 - <div className="ml-4 w-[42px] flex justify-center"> 472 - <div 473 - style={{ width: 2, height: "100%", opacity: 0.5 }} 474 - className="bg-gray-500 dark:bg-gray-400" 475 - ></div> 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... 476 483 </div> 477 - Loading conversation... 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 + ))} 478 497 </div> 479 - )} 480 - 481 - {/* we should use the reply lines here thats provided by UPR*/} 482 - <div style={{ maxWidth: 600, padding: 0 }}> 483 - {parents.map((parent, index) => ( 498 + <div ref={mainPostRef}> 484 499 <UniversalPostRendererATURILoader 485 - key={parent.uri} 486 - atUri={parent.uri} 487 - topReplyLine={index > 0} 488 - bottomReplyLine={true} 489 - bottomBorder={false} 500 + atUri={atUri!} 501 + detailed={true} 502 + topReplyLine={parentsLoading || parents.length > 0} 503 + nopics={!!nopics} 504 + lightboxCallback={lightboxCallback} 490 505 /> 491 - ))} 492 - </div> 493 - <div ref={mainPostRef}> 494 - <UniversalPostRendererATURILoader 495 - atUri={atUri} 496 - detailed={true} 497 - topReplyLine={parentsLoading || parents.length > 0} 498 - nopics={!!nopics} 499 - lightboxCallback={lightboxCallback} 500 - /> 501 - </div> 502 - <div 503 - style={{ 504 - maxWidth: 600, 505 - //margin: "0px auto 0", 506 - padding: 0, 507 - minHeight: "80dvh", 508 - paddingBottom: "20dvh", 509 - }} 510 - > 506 + </div> 511 507 <div 512 - className="text-gray-500 dark:text-gray-400 text-sm font-bold" 513 508 style={{ 514 - fontSize: 18, 515 - margin: "12px 16px 12px 16px", 516 - fontWeight: 600, 509 + maxWidth: 600, 510 + //margin: "0px auto 0", 511 + padding: 0, 512 + minHeight: "80dvh", 513 + paddingBottom: "20dvh", 517 514 }} 518 515 > 519 - Replies 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> 520 548 </div> 521 - <div style={{ display: "flex", flexDirection: "column", gap: 0 }}> 522 - {replyAturis.length > 0 && 523 - replyAturis.map((reply) => { 524 - //const replyAtUri = `at://${reply.did}/app.bsky.feed.post/${reply.rkey}`; 525 - return ( 526 - <UniversalPostRendererATURILoader 527 - key={reply} 528 - atUri={reply} 529 - maxReplies={4} 530 - /> 531 - ); 532 - })} 533 - {hasNextPage && ( 534 - <button 535 - onClick={() => fetchNextPage()} 536 - disabled={isFetchingNextPage} 537 - 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" 538 - > 539 - {isFetchingNextPage ? "Loading..." : "Load More"} 540 - </button> 541 - )} 542 - </div> 543 - </div> 549 + </>)} 544 550 </> 545 551 ); 546 552 }
+217 -9
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 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"; 2 6 3 7 import { Header } from "~/components/Header"; 4 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"; 5 25 6 26 export const Route = createFileRoute("/search")({ 7 27 component: Search, 8 28 }); 9 29 10 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 + 11 105 return ( 12 106 <> 13 107 <Header ··· 21 115 }} 22 116 /> 23 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 118 + <Import optionaltextstring={q} /> 25 119 <div className="flex flex-col"> 26 - <p className="text-gray-600 dark:text-gray-400"> 27 - Sorry we dont have search. But instead, you can load some of these 28 - types of content into Red Dwarf: 29 - </p> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 30 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 122 <li> 32 - Bluesky URLs from supported clients (like{" "} 123 + Bluesky URLs (from supported clients) (like{" "} 33 124 <code className="text-sm">bsky.app</code> or{" "} 34 125 <code className="text-sm">deer.social</code>). 35 126 </li> ··· 39 130 ). 40 131 </li> 41 132 <li> 42 - Plain handles (like{" "} 133 + User Handles (like{" "} 43 134 <code className="text-sm">@username.bsky.social</code>). 44 135 </li> 45 136 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 137 + DIDs (Decentralized Identifiers, starting with{" "} 47 138 <code className="text-sm">did:</code>). 48 139 </li> 49 140 </ul> ··· 51 142 Simply paste one of these into the import field above and press 52 143 Enter to load the content. 53 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 + )} 54 177 </div> 55 178 </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 56 180 </> 57 181 ); 58 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 + }
+188 -17
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 - import { Slider } from "radix-ui"; 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"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import Login from "~/components/Login"; ··· 9 10 defaultconstellationURL, 10 11 defaulthue, 11 12 defaultImgCDN, 13 + defaultLycanURL, 12 14 defaultslingshotURL, 13 15 defaultVideoCDN, 16 + enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 14 19 hueAtom, 15 20 imgCDNAtom, 21 + lycanURLAtom, 16 22 slingshotURLAtom, 17 23 videoCDNAtom, 18 24 } from "~/utils/atoms"; 19 25 26 + import { MaterialNavItem } from "./__root"; 27 + 20 28 export const Route = createFileRoute("/settings")({ 21 29 component: Settings, 22 30 }); 23 31 24 32 export function Settings() { 33 + const navigate = useNavigate(); 25 34 return ( 26 35 <> 27 36 <Header ··· 37 46 <div className="lg:hidden"> 38 47 <Login /> 39 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> 40 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> 41 88 <TextInputSetting 42 89 atom={constellationURLAtom} 43 90 title={"Constellation"} ··· 66 113 description={"Customize the Slingshot instance to be used by Red Dwarf"} 67 114 init={defaultVideoCDN} 68 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 + /> 69 122 70 - <Hue /> 71 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 - please restart/refresh the app if changes arent applying correctly 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 73 149 </p> 74 150 </> 75 151 ); 76 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 + 77 217 function Hue() { 78 218 const [hue, setHue] = useAtom(hueAtom); 79 219 return ( 80 - <div className="flex flex-col px-4 mt-4 "> 81 - <span className="z-10">Hue</span> 82 - <div className="flex flex-row items-center gap-4"> 83 - <SliderComponent 84 - atom={hueAtom} 85 - max={360} 86 - /> 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} /> 87 227 <button 88 228 onClick={() => setHue(defaulthue ?? 28)} 89 229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 154 294 ); 155 295 } 156 296 157 - 158 297 interface SliderProps { 159 298 atom: typeof hueAtom; 160 299 min?: number; ··· 168 307 max = 100, 169 308 step = 1, 170 309 }) => { 171 - 172 - const [value, setValue] = useAtom(atom) 310 + const [value, setValue] = useAtom(atom); 173 311 174 312 return ( 175 313 <Slider.Root ··· 186 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" /> 187 325 </Slider.Root> 188 326 ); 189 - }; 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 + };
+144 -1
src/styles/app.css
··· 33 33 --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 34 } 35 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 + 36 42 @layer base { 37 43 html { 38 44 color-scheme: light dark; ··· 84 90 .dangerousFediContent { 85 91 & a[href]{ 86 92 text-decoration: none; 87 - color: rgb(29, 122, 242); 93 + color: var(--link-text-color); 88 94 word-break: break-all; 89 95 } 90 96 } ··· 197 203 198 204 /* focus ring */ 199 205 .m3input-field.m3input-label.m3input-border input:focus { 206 + /*border-color: var(--m3input-focus-color);*/ 200 207 border-color: var(--m3input-focus-color); 208 + box-shadow: 0 0 0 1px var(--m3input-focus-color); 201 209 /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 202 210 } 203 211 ··· 231 239 /* radix i love you but like cmon man */ 232 240 body[data-scroll-locked]{ 233 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 + } 234 377 }
+73
src/utils/atoms.ts
··· 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import { useEffect } from "react"; 4 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 5 7 export const store = createStore(); 6 8 7 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 21 23 {} 22 24 ); 23 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 + 24 59 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 25 60 "likedPosts", 26 61 {} 27 62 ); 28 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 + 29 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 30 78 export const constellationURLAtom = atomWithStorage<string>( 31 79 "constellationURL", ··· 42 90 export const videoCDNAtom = atomWithStorage<string>( 43 91 "videocdnurl", 44 92 defaultVideoCDN 93 + ); 94 + 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 45 99 ); 46 100 47 101 export const defaulthue = 28; ··· 80 134 // console.log("atom get ", initial); 81 135 // document.documentElement.style.setProperty(cssVar, initial.toString()); 82 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 + );
+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 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 2 3 - // i tried making this https://pds-nd.whey.party but cors is annoying as fuck 4 - const handleResolverPDS = 'https://bsky.social'; 3 + import resolvers from '../../public/resolvers.json' with { type: 'json' }; 4 + const handleResolverPDS = resolvers.resolver || 'https://bsky.social'; 5 5 6 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
+414 -163
src/utils/useQuery.ts
··· 5 5 queryOptions, 6 6 useInfiniteQuery, 7 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 9 10 import { useAtom } from "jotai"; 10 11 11 - import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 12 13 13 - export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 15 + 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 14 20 return queryOptions({ 15 21 queryKey: ["identity", didorhandle], 16 22 queryFn: async () => { 17 - if (!didorhandle) return undefined as undefined 23 + if (!didorhandle) return undefined as undefined; 18 24 const res = await fetch( 19 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 26 ); ··· 31 37 } 32 38 }, 33 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 40 + gcTime: /*0//*/ 5 * 60 * 1000, 35 41 }); 36 42 } 37 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 49 }, 44 50 Error 45 51 >; 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 - > 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 + >; 60 63 export function useQueryIdentity(didorhandle?: string) { 61 - const [slingshoturl] = useAtom(slingshotURLAtom) 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 62 65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63 66 } 64 67 ··· 66 69 return queryOptions({ 67 70 queryKey: ["post", uri], 68 71 queryFn: async () => { 69 - if (!uri) return undefined as undefined 72 + if (!uri) return undefined as undefined; 70 73 const res = await fetch( 71 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 75 ); ··· 77 80 return undefined; 78 81 } 79 82 if (res.status === 400) return undefined; 80 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 81 87 return undefined; // cache โ€œnot foundโ€ 82 88 } 83 89 try { 84 90 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 91 + return data as { 86 92 uri: string; 87 93 cid: string; 88 94 value: any; ··· 97 103 return failureCount < 2; 98 104 }, 99 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 106 + gcTime: /*0//*/ 5 * 60 * 1000, 101 107 }); 102 108 } 103 109 export function useQueryPost(uri: string): UseQueryResult< ··· 108 114 }, 109 115 Error 110 116 >; 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 - > 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 + >; 124 127 export function useQueryPost(uri?: string) { 125 - const [slingshoturl] = useAtom(slingshotURLAtom) 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 126 129 return useQuery(constructPostQuery(uri, slingshoturl)); 127 130 } 128 131 ··· 130 133 return queryOptions({ 131 134 queryKey: ["profile", uri], 132 135 queryFn: async () => { 133 - if (!uri) return undefined as undefined 136 + if (!uri) return undefined as undefined; 134 137 const res = await fetch( 135 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 139 ); ··· 141 144 return undefined; 142 145 } 143 146 if (res.status === 400) return undefined; 144 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 145 151 return undefined; // cache โ€œnot foundโ€ 146 152 } 147 153 try { 148 154 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 155 + return data as { 150 156 uri: string; 151 157 cid: string; 152 158 value: any; ··· 161 167 return failureCount < 2; 162 168 }, 163 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 170 + gcTime: /*0//*/ 5 * 60 * 1000, 165 171 }); 166 172 } 167 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 178 }, 173 179 Error 174 180 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 182 184 uri: string; 183 185 cid: string; 184 186 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 187 + } 188 + | undefined, 189 + Error 190 + >; 188 191 export function useQueryProfile(uri?: string) { 189 - const [slingshoturl] = useAtom(slingshotURLAtom) 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 190 193 return useQuery(constructProfileQuery(uri, slingshoturl)); 191 194 } 192 195 ··· 222 225 // method: "/links/all", 223 226 // target: string 224 227 // ): QueryOptions<linksAllResponse, Error>; 225 - export function constructConstellationQuery(query?:{ 226 - constellation: string, 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 227 230 method: 228 231 | "/links" 229 232 | "/links/distinct-dids" 230 233 | "/links/count" 231 234 | "/links/count/distinct-dids" 232 235 | "/links/all" 233 - | "undefined", 234 - target: string, 235 - collection?: string, 236 - path?: string, 237 - cursor?: string, 238 - dids?: string[] 239 - } 240 - ) { 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 241 243 // : QueryOptions< 242 244 // | linksRecordsResponse 243 245 // | linksDidsResponse ··· 247 249 // Error 248 250 // > 249 251 return queryOptions({ 250 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 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, 251 261 queryFn: async () => { 252 - if (!query || query.method === "undefined") return undefined as undefined 253 - const method = query.method 254 - const target = query.target 255 - const collection = query?.collection 256 - const path = query?.path 257 - const cursor = query.cursor 258 - const dids = query?.dids 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; 259 269 const res = await fetch( 260 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("") : ""}` 261 271 ); ··· 281 291 }, 282 292 // enforce short lifespan 283 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 - gcTime: /*0//*/5 * 60 * 1000, 294 + gcTime: /*0//*/ 5 * 60 * 1000, 285 295 }); 286 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 + 287 318 export function useQueryConstellation(query: { 288 319 method: "/links"; 289 320 target: string; ··· 346 377 > 347 378 | undefined { 348 379 //if (!query) return; 349 - const [constellationurl] = useAtom(constellationURLAtom) 380 + const [constellationurl] = useAtom(constellationURLAtom); 350 381 return useQuery( 351 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 352 385 ); 353 386 } 354 387 355 - type linksRecord = { 388 + export type linksRecord = { 356 389 did: string; 357 390 collection: string; 358 391 rkey: string; ··· 392 425 }) { 393 426 return queryOptions({ 394 427 // The query key includes all dependencies to ensure it refetches when they change 395 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 396 433 queryFn: async () => { 397 - if (!options) return undefined as undefined 434 + if (!options) return undefined as undefined; 398 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 399 436 if (isAuthed) { 400 437 // Authenticated flow 401 438 if (!agent || !pdsUrl || !feedServiceDid) { 402 - throw new Error("Missing required info for authenticated feed fetch."); 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 403 442 } 404 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 405 444 const res = await agent.fetchHandler(url, { ··· 409 448 "Content-Type": "application/json", 410 449 }, 411 450 }); 412 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 413 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 414 454 } else { 415 455 // Unauthenticated flow (using a public PDS/AppView) 416 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 417 457 const res = await fetch(url); 418 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 419 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 420 461 } 421 462 }, ··· 433 474 return useQuery(constructFeedSkeletonQuery(options)); 434 475 } 435 476 436 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 437 481 return queryOptions({ 438 - queryKey: ['preferences', agent?.did], 482 + queryKey: ["preferences", agent?.did], 439 483 queryFn: async () => { 440 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 441 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 446 490 }); 447 491 } 448 492 export function useQueryPreferences(options: { 449 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 450 495 }) { 451 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 452 497 } 453 - 454 - 455 498 456 499 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 457 500 return queryOptions({ 458 501 queryKey: ["arbitrary", uri], 459 502 queryFn: async () => { 460 - if (!uri) return undefined as undefined 503 + if (!uri) return undefined as undefined; 461 504 const res = await fetch( 462 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 463 506 ); ··· 468 511 return undefined; 469 512 } 470 513 if (res.status === 400) return undefined; 471 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 472 518 return undefined; // cache โ€œnot foundโ€ 473 519 } 474 520 try { 475 521 if (!res.ok) throw new Error("Failed to fetch post"); 476 - return (data) as { 522 + return data as { 477 523 uri: string; 478 524 cid: string; 479 525 value: any; ··· 488 534 return failureCount < 2; 489 535 }, 490 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 491 - gcTime: /*0//*/5 * 60 * 1000, 537 + gcTime: /*0//*/ 5 * 60 * 1000, 492 538 }); 493 539 } 494 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 499 545 }, 500 546 Error 501 547 >; 502 - export function useQueryArbitrary(): UseQueryResult< 503 - undefined, 504 - Error 505 - >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 506 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 507 - { 508 - uri: string; 509 - cid: string; 510 - value: any; 511 - } | undefined, 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 512 556 Error 513 557 >; 514 558 export function useQueryArbitrary(uri?: string) { 515 - const [slingshoturl] = useAtom(slingshotURLAtom) 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 516 560 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 517 561 } 518 562 519 - export function constructFallbackNothingQuery(){ 563 + export function constructFallbackNothingQuery() { 520 564 return queryOptions({ 521 565 queryKey: ["nothing"], 522 566 queryFn: async () => { 523 - return undefined 567 + return undefined; 524 568 }, 525 569 }); 526 570 } ··· 534 578 }[]; 535 579 }; 536 580 537 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 538 586 return queryOptions({ 539 - queryKey: ['authorFeed', did], 587 + queryKey: ["authorFeed", did, collection], 540 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 589 const limit = 25; 542 - 590 + 543 591 const cursor = pageParam as string | undefined; 544 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 - 546 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 547 - 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 594 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 595 + 548 596 const res = await fetch(url); 549 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 550 - 598 + 551 599 return res.json() as Promise<ListRecordsResponse>; 552 600 }, 553 601 }); 554 602 } 555 603 556 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 557 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 558 - 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 + 559 615 return useInfiniteQuery({ 560 616 queryKey, 561 617 queryFn, ··· 573 629 isAuthed: boolean; 574 630 pdsUrl?: string; 575 631 feedServiceDid?: string; 632 + // todo the hell is a unauthedfeedurl 633 + unauthedfeedurl?: string; 576 634 }) { 577 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 578 - 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 579 638 return queryOptions({ 580 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 581 - 582 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 583 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 584 - 585 - if (isAuthed) { 645 + 646 + if (isAuthed && !unauthedfeedurl) { 586 647 if (!agent || !pdsUrl || !feedServiceDid) { 587 - throw new Error("Missing required info for authenticated feed fetch."); 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 588 651 } 589 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 590 653 const res = await agent.fetchHandler(url, { ··· 594 657 "Content-Type": "application/json", 595 658 }, 596 659 }); 597 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 598 662 return (await res.json()) as FeedSkeletonPage; 599 663 } else { 600 - const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 664 + const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 601 665 const res = await fetch(url); 602 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 603 668 return (await res.json()) as FeedSkeletonPage; 604 669 } 605 670 }, ··· 612 677 isAuthed: boolean; 613 678 pdsUrl?: string; 614 679 feedServiceDid?: string; 680 + unauthedfeedurl?: string; 615 681 }) { 616 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 - 618 - return {...useInfiniteQuery({ 619 - queryKey, 620 - queryFn, 621 - initialPageParam: undefined as never, 622 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 623 - staleTime: Infinity, 624 - refetchOnWindowFocus: false, 625 - enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 - }), queryKey: queryKey}; 627 - } 628 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 + } 629 703 630 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 631 - constellation: string, 632 - method: '/links' 633 - target?: string 634 - collection: string 635 - path: string 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 636 711 }) { 637 - console.log( 638 - 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 - query, 640 - ) 712 + const safemult = query?.staleMult ?? 1; 713 + // console.log( 714 + // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 715 + // query, 716 + // ) 641 717 642 718 return infiniteQueryOptions({ 643 719 enabled: !!query?.target, 644 720 queryKey: [ 645 - 'reddwarf_constellation', 721 + "reddwarf_constellation", 646 722 query?.method, 647 723 query?.target, 648 724 query?.collection, 649 725 query?.path, 650 726 ] as const, 651 727 652 - queryFn: async ({pageParam}: {pageParam?: string}) => { 653 - if (!query || !query?.target) return undefined 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 654 730 655 - const method = query.method 656 - const target = query.target 657 - const collection = query.collection 658 - const path = query.path 659 - const cursor = pageParam 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 660 736 661 737 const res = await fetch( 662 738 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 663 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 664 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 665 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 666 - }`, 667 - ) 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 668 744 669 - if (!res.ok) throw new Error('Failed to fetch') 745 + if (!res.ok) throw new Error("Failed to fetch"); 670 746 671 - return (await res.json()) as linksRecordsResponse 747 + return (await res.json()) as linksRecordsResponse; 672 748 }, 673 749 674 - getNextPageParam: lastPage => { 675 - return (lastPage as any)?.cursor ?? undefined 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 676 752 }, 677 753 initialPageParam: undefined, 678 - staleTime: 5 * 60 * 1000, 679 - gcTime: 5 * 60 * 1000, 680 - }) 681 - } 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 + }
+5
vite.config.ts
··· 13 13 const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 + 16 19 function shp(url: string): string { 17 20 return url.replace(/^https?:\/\//, ''); 18 21 } ··· 23 26 generateMetadataPlugin({ 24 27 prod: PROD_URL, 25 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 26 31 }), 27 32 TanStackRouterVite({ autoCodeSplitting: true }), 28 33 viteReact({