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

Configure Feed

Select the types of activity you want to include in your feed.

oauth!!! (UnifiedAuthProvider)

rimar1337 9c34168f 395af63b

+765 -237
+1
.gitignore
··· 7 7 .env 8 8 .nitro 9 9 .tanstack 10 + public/client-metadata.json
+18 -2
README.md
··· 5 5 6 6 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 7 8 + ## running dev and build 9 + in the `vite.config.ts` file you should change these values 10 + ```ts 11 + const PROD_URL = "https://reddwarf.whey.party" 12 + const DEV_URL = "https://local3768forumtest.whey.party" 13 + ``` 14 + the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work 15 + 16 + run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 + 8 18 ## useQuery 9 19 Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! 10 20 ··· 22 32 ### Slingshot 23 33 though Red Dwarf was made before Microcosm [Slingshot](https://slingshot.microcosm.blue) existed, it now uses Slingshot to reduce load from each respective PDS server. Slignshot 24 34 25 - ## PassAuthProvider 26 - a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). in ForumTest, its been superseded by the [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx). i havent backported it here and maybe soon, although oauth makes it slightly more annoying to do development because it requires a tunnel so maybe someday if i managed to merge the password and oauth logins to provide both options 35 + ## UnifiedAuthProvider 36 + a merged auth provider with oauth and password based login. oauth makes it slightly more annoying to do development because it requires a tunnel, so so the password auth option is still here if you do prefer password login for whatever reason. 37 + 38 + ### Pass Auth 39 + a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). 40 + 41 + ### OAuth 42 + taken from ForumTest [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx) 27 43 28 44 ## Custom Feeds 29 45 they work, but i havent implemented a simple way of viewing arbitraty feeds. currently it either loads discover (logged out) or your saved feeds (logged in) and its not a technical limitation i just havent implemented it yet
-1
index.html
··· 11 11 /> 12 12 <link rel="apple-touch-icon" href="/redstar.png" /> 13 13 <link rel="manifest" href="/manifest.json" /> 14 - <link rel="stylesheet" href="/src/styles/app.css" /> 15 14 <title>Red Dwarf</title> 16 15 </head> 17 16 <body>
+48
oauthdev.mts
··· 1 + import fs from 'fs'; 2 + import path from 'path'; 3 + //import { generateClientMetadata } from './src/helpers/oauthClient' 4 + export const generateClientMetadata = (appOrigin: string) => { 5 + const callbackPath = '/callback'; 6 + 7 + return { 8 + "client_id": `${appOrigin}/client-metadata.json`, 9 + "client_name": "ForumTest", 10 + "client_uri": appOrigin, 11 + "logo_uri": `${appOrigin}/logo192.png`, 12 + "tos_uri": `${appOrigin}/terms-of-service`, 13 + "policy_uri": `${appOrigin}/privacy-policy`, 14 + "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + "scope": "atproto transition:generic", 16 + "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 + "response_types": ["code"] as ["code"], 18 + "token_endpoint_auth_method": "none" as "none", 19 + "application_type": "web" as "web", 20 + "dpop_bound_access_tokens": true 21 + }; 22 + } 23 + 24 + 25 + export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 26 + return { 27 + name: 'vite-plugin-generate-metadata', 28 + config(_config: any, { mode }: any) { 29 + let appOrigin; 30 + if (mode === 'production') { 31 + appOrigin = prod 32 + if (!appOrigin || !appOrigin.startsWith('https://')) { 33 + throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 34 + } 35 + } else { 36 + appOrigin = dev; 37 + } 38 + 39 + 40 + const metadata = generateClientMetadata(appOrigin); 41 + const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 42 + 43 + fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 + 45 + console.log(`✅ Generated client-metadata.json for ${appOrigin}`); 46 + }, 47 + }; 48 + }
+194 -11
package-lock.json
··· 7 7 "name": "red-dwarf-tanstack", 8 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 + "@atproto/oauth-client-browser": "^0.3.33", 10 11 "@tailwindcss/vite": "^4.0.6", 11 12 "@tanstack/query-sync-storage-persister": "^5.85.6", 12 13 "@tanstack/react-devtools": "^0.2.2", ··· 71 72 "dev": true, 72 73 "license": "ISC" 73 74 }, 75 + "node_modules/@atproto-labs/did-resolver": { 76 + "version": "0.2.2", 77 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.2.tgz", 78 + "integrity": "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ==", 79 + "license": "MIT", 80 + "dependencies": { 81 + "@atproto-labs/fetch": "0.2.3", 82 + "@atproto-labs/pipe": "0.1.1", 83 + "@atproto-labs/simple-store": "0.3.0", 84 + "@atproto-labs/simple-store-memory": "0.1.4", 85 + "@atproto/did": "0.2.1", 86 + "zod": "^3.23.8" 87 + } 88 + }, 89 + "node_modules/@atproto-labs/fetch": { 90 + "version": "0.2.3", 91 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", 92 + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", 93 + "license": "MIT", 94 + "dependencies": { 95 + "@atproto-labs/pipe": "0.1.1" 96 + } 97 + }, 98 + "node_modules/@atproto-labs/handle-resolver": { 99 + "version": "0.3.2", 100 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.2.tgz", 101 + "integrity": "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A==", 102 + "license": "MIT", 103 + "dependencies": { 104 + "@atproto-labs/simple-store": "0.3.0", 105 + "@atproto-labs/simple-store-memory": "0.1.4", 106 + "@atproto/did": "0.2.1", 107 + "zod": "^3.23.8" 108 + } 109 + }, 110 + "node_modules/@atproto-labs/identity-resolver": { 111 + "version": "0.3.2", 112 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.2.tgz", 113 + "integrity": "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw==", 114 + "license": "MIT", 115 + "dependencies": { 116 + "@atproto-labs/did-resolver": "0.2.2", 117 + "@atproto-labs/handle-resolver": "0.3.2" 118 + } 119 + }, 120 + "node_modules/@atproto-labs/pipe": { 121 + "version": "0.1.1", 122 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", 123 + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", 124 + "license": "MIT" 125 + }, 126 + "node_modules/@atproto-labs/simple-store": { 127 + "version": "0.3.0", 128 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", 129 + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", 130 + "license": "MIT" 131 + }, 132 + "node_modules/@atproto-labs/simple-store-memory": { 133 + "version": "0.1.4", 134 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", 135 + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", 136 + "license": "MIT", 137 + "dependencies": { 138 + "@atproto-labs/simple-store": "0.3.0", 139 + "lru-cache": "^10.2.0" 140 + } 141 + }, 142 + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { 143 + "version": "10.4.3", 144 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 145 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 146 + "license": "ISC" 147 + }, 74 148 "node_modules/@atproto/api": { 75 149 "version": "0.16.6", 76 150 "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.6.tgz", ··· 88 162 } 89 163 }, 90 164 "node_modules/@atproto/common-web": { 91 - "version": "0.4.2", 92 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz", 93 - "integrity": "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==", 165 + "version": "0.4.3", 166 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", 167 + "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 94 168 "license": "MIT", 95 169 "dependencies": { 96 170 "graphemer": "^1.4.0", ··· 99 173 "zod": "^3.23.8" 100 174 } 101 175 }, 176 + "node_modules/@atproto/did": { 177 + "version": "0.2.1", 178 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.1.tgz", 179 + "integrity": "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA==", 180 + "license": "MIT", 181 + "dependencies": { 182 + "zod": "^3.23.8" 183 + } 184 + }, 185 + "node_modules/@atproto/jwk": { 186 + "version": "0.6.0", 187 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 188 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 189 + "license": "MIT", 190 + "dependencies": { 191 + "multiformats": "^9.9.0", 192 + "zod": "^3.23.8" 193 + } 194 + }, 195 + "node_modules/@atproto/jwk-jose": { 196 + "version": "0.1.11", 197 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 198 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 199 + "license": "MIT", 200 + "dependencies": { 201 + "@atproto/jwk": "0.6.0", 202 + "jose": "^5.2.0" 203 + } 204 + }, 205 + "node_modules/@atproto/jwk-webcrypto": { 206 + "version": "0.2.0", 207 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 208 + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 209 + "license": "MIT", 210 + "dependencies": { 211 + "@atproto/jwk": "0.6.0", 212 + "@atproto/jwk-jose": "0.1.11", 213 + "zod": "^3.23.8" 214 + } 215 + }, 102 216 "node_modules/@atproto/lexicon": { 103 - "version": "0.5.0", 104 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.0.tgz", 105 - "integrity": "sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==", 217 + "version": "0.5.1", 218 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", 219 + "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==", 106 220 "license": "MIT", 107 221 "dependencies": { 108 - "@atproto/common-web": "^0.4.2", 222 + "@atproto/common-web": "^0.4.3", 109 223 "@atproto/syntax": "^0.4.1", 110 224 "iso-datestring-validator": "^2.2.2", 111 225 "multiformats": "^9.9.0", 112 226 "zod": "^3.23.8" 113 227 } 114 228 }, 229 + "node_modules/@atproto/oauth-client": { 230 + "version": "0.5.7", 231 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.7.tgz", 232 + "integrity": "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg==", 233 + "license": "MIT", 234 + "dependencies": { 235 + "@atproto-labs/did-resolver": "0.2.2", 236 + "@atproto-labs/fetch": "0.2.3", 237 + "@atproto-labs/handle-resolver": "0.3.2", 238 + "@atproto-labs/identity-resolver": "0.3.2", 239 + "@atproto-labs/simple-store": "0.3.0", 240 + "@atproto-labs/simple-store-memory": "0.1.4", 241 + "@atproto/did": "0.2.1", 242 + "@atproto/jwk": "0.6.0", 243 + "@atproto/oauth-types": "0.4.2", 244 + "@atproto/xrpc": "0.7.5", 245 + "core-js": "^3", 246 + "multiformats": "^9.9.0", 247 + "zod": "^3.23.8" 248 + } 249 + }, 250 + "node_modules/@atproto/oauth-client-browser": { 251 + "version": "0.3.33", 252 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.33.tgz", 253 + "integrity": "sha512-IvHn/5W3e9GXFUGXQ4MV19E4HXY4zJFgu+eZRWexIXnZl4GwgTH7op8J1SosczdOK1Ngu+LnHE6npcNhUGGd6Q==", 254 + "license": "MIT", 255 + "dependencies": { 256 + "@atproto-labs/did-resolver": "0.2.2", 257 + "@atproto-labs/handle-resolver": "0.3.2", 258 + "@atproto-labs/simple-store": "0.3.0", 259 + "@atproto/did": "0.2.1", 260 + "@atproto/jwk": "0.6.0", 261 + "@atproto/jwk-webcrypto": "0.2.0", 262 + "@atproto/oauth-client": "0.5.7", 263 + "@atproto/oauth-types": "0.4.2", 264 + "core-js": "^3" 265 + } 266 + }, 267 + "node_modules/@atproto/oauth-types": { 268 + "version": "0.4.2", 269 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.4.2.tgz", 270 + "integrity": "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ==", 271 + "license": "MIT", 272 + "dependencies": { 273 + "@atproto/did": "0.2.1", 274 + "@atproto/jwk": "0.6.0", 275 + "zod": "^3.23.8" 276 + } 277 + }, 115 278 "node_modules/@atproto/syntax": { 116 279 "version": "0.4.1", 117 280 "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", ··· 119 282 "license": "MIT" 120 283 }, 121 284 "node_modules/@atproto/xrpc": { 122 - "version": "0.7.4", 123 - "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.4.tgz", 124 - "integrity": "sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==", 285 + "version": "0.7.5", 286 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", 287 + "integrity": "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==", 125 288 "license": "MIT", 126 289 "dependencies": { 127 - "@atproto/lexicon": "^0.5.0", 290 + "@atproto/lexicon": "^0.5.1", 128 291 "zod": "^3.23.8" 129 292 } 130 293 }, ··· 2869 3032 "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", 2870 3033 "license": "MIT" 2871 3034 }, 3035 + "node_modules/core-js": { 3036 + "version": "3.46.0", 3037 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", 3038 + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", 3039 + "hasInstallScript": true, 3040 + "license": "MIT", 3041 + "funding": { 3042 + "type": "opencollective", 3043 + "url": "https://opencollective.com/core-js" 3044 + } 3045 + }, 2872 3046 "node_modules/cssstyle": { 2873 3047 "version": "4.6.0", 2874 3048 "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", ··· 3427 3601 "license": "MIT", 3428 3602 "bin": { 3429 3603 "jiti": "lib/jiti-cli.mjs" 3604 + } 3605 + }, 3606 + "node_modules/jose": { 3607 + "version": "5.10.0", 3608 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 3609 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 3610 + "license": "MIT", 3611 + "funding": { 3612 + "url": "https://github.com/sponsors/panva" 3430 3613 } 3431 3614 }, 3432 3615 "node_modules/jotai": {
+3 -2
package.json
··· 3 3 "private": true, 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "vite --port 3000", 7 - "start": "vite --port 3000", 6 + "dev": "vite --port 3768", 7 + "start": "vite --port 3768", 8 8 "build": "vite build && tsc", 9 9 "serve": "vite preview", 10 10 "test": "vitest run" 11 11 }, 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 + "@atproto/oauth-client-browser": "^0.3.33", 14 15 "@tailwindcss/vite": "^4.0.6", 15 16 "@tanstack/query-sync-storage-persister": "^5.85.6", 16 17 "@tanstack/react-devtools": "^0.2.2",
+3 -2
src/components/InfiniteCustomFeed.tsx
··· 1 1 import * as React from "react"; 2 2 //import { useInView } from "react-intersection-observer"; 3 3 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 4 - import { useAuth } from "~/providers/PassAuthProvider"; 4 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 5 5 import { 6 6 useQueryArbitrary, 7 7 useQueryIdentity, ··· 19 19 pdsUrl, 20 20 feedServiceDid, 21 21 }: InfiniteCustomFeedProps) { 22 - const { agent, authed } = useAuth(); 22 + const { agent } = useAuth(); 23 + const authed = !!agent?.did; 23 24 24 25 // const identityresultmaybe = useQueryIdentity(agent?.did); 25 26 // const identity = identityresultmaybe?.data;
+201 -197
src/components/Login.tsx
··· 1 + // src/components/Login.tsx 1 2 import React, { useEffect, useState, useRef } from "react"; 2 - import { useAuth } from "~/providers/PassAuthProvider"; 3 - 4 - interface LoginProps { 5 - compact?: boolean; 6 - } 7 - 8 - export default function Login({ compact = false }: LoginProps) { 9 - const { loginStatus, login, logout, loading, authed, agent } = useAuth(); 10 - const [user, setUser] = useState(""); 11 - const [password, setPassword] = useState(""); 12 - const [serviceURL, setServiceURL] = useState("bsky.social"); 13 - const [showLoginForm, setShowLoginForm] = useState(false); 14 - const formRef = useRef<HTMLDivElement>(null); 15 - 16 - useEffect(() => { 17 - function handleClickOutside(event: MouseEvent) { 18 - if (formRef.current && !formRef.current.contains(event.target as Node)) { 19 - setShowLoginForm(false); 20 - } 21 - } 22 - 23 - if (showLoginForm) { 24 - document.addEventListener("mousedown", handleClickOutside); 25 - } 3 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 4 + import { Agent } from "@atproto/api"; 26 5 27 - return () => { 28 - document.removeEventListener("mousedown", handleClickOutside); 29 - }; 30 - }, [showLoginForm]); 6 + // --- 1. The Main Component (Orchestrator with `compact` prop) --- 7 + export default function Login({ compact = false }: { compact?: boolean }) { 8 + const { status, agent, logout } = useAuth(); 31 9 32 - if (loading) { 10 + // Loading state can be styled differently based on the prop 11 + if (status === "loading") { 33 12 return ( 34 - <div className="flex items-center justify-center p-6 text-gray-500 dark:text-gray-400"> 35 - Loading... 13 + <div 14 + className={ 15 + compact 16 + ? "flex items-center justify-center p-1" 17 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 18 + } 19 + > 20 + <span 21 + className={`border-t-transparent rounded-full animate-spin ${ 22 + compact 23 + ? "w-5 h-5 border-2 border-gray-400" 24 + : "w-8 h-8 border-4 border-gray-400" 25 + }`} 26 + /> 36 27 </div> 37 28 ); 38 29 } 39 30 40 - if (compact) { 41 - if (authed) { 31 + // --- LOGGED IN STATE --- 32 + if (status === "signedIn") { 33 + // Large view 34 + if (!compact) { 42 35 return ( 36 + <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 37 + <div className="flex flex-col items-center justify-center text-center"> 38 + <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 39 + You are logged in! 40 + </p> 41 + <ProfileThing agent={agent} large /> 42 + <button 43 + onClick={logout} 44 + className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors" 45 + > 46 + Log out 47 + </button> 48 + </div> 49 + </div> 50 + ); 51 + } 52 + // Compact view 53 + return ( 54 + <div className="flex items-center gap-4"> 55 + <ProfileThing agent={agent} /> 43 56 <button 44 57 onClick={logout} 45 58 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 46 59 > 47 60 Log out 48 61 </button> 49 - ); 50 - } else { 51 - return ( 52 - <div className="relative" ref={formRef}> 53 - <button 54 - onClick={() => setShowLoginForm(!showLoginForm)} 55 - className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 56 - > 57 - Log in 58 - </button> 59 - {showLoginForm && ( 60 - <div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50"> 61 - <form 62 - onSubmit={(e) => { 63 - e.preventDefault(); 64 - login(user, password, `https://${serviceURL}`); 65 - setShowLoginForm(false); 66 - }} 67 - className="flex flex-col gap-3" 68 - > 69 - <p className="text-xs text-gray-500 dark:text-gray-400"> 70 - sorry for the temporary login, 71 - <br /> 72 - oauth will come soon enough i swear 73 - </p> 74 - <input 75 - type="text" 76 - placeholder="Username" 77 - value={user} 78 - onChange={(e) => setUser(e.target.value)} 79 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 80 - autoComplete="username" 81 - /> 82 - <input 83 - type="password" 84 - placeholder="Password" 85 - value={password} 86 - onChange={(e) => setPassword(e.target.value)} 87 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 88 - autoComplete="current-password" 89 - /> 90 - <input 91 - type="text" 92 - placeholder="bsky.social" 93 - value={serviceURL} 94 - onChange={(e) => setServiceURL(e.target.value)} 95 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 96 - /> 97 - <button 98 - type="submit" 99 - className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors" 100 - > 101 - Log in 102 - </button> 103 - </form> 104 - </div> 105 - )} 106 - </div> 107 - ); 108 - } 62 + </div> 63 + ); 64 + } 65 + 66 + // --- LOGGED OUT STATE --- 67 + if (!compact) { 68 + // Large view renders the form directly in the card 69 + return ( 70 + <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 71 + <UnifiedLoginForm /> 72 + </div> 73 + ); 109 74 } 110 75 76 + // Compact view renders a button that toggles the form in a dropdown 77 + return <CompactLoginButton />; 78 + } 79 + 80 + // --- 2. The Reusable, Self-Contained Login Form Component --- 81 + export function UnifiedLoginForm() { 82 + const [mode, setMode] = useState<"oauth" | "password">("oauth"); 83 + 111 84 return ( 112 - <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 113 - {authed ? ( 114 - <div className="flex flex-col items-center justify-center text-center"> 115 - <p className="text-lg font-semibold mb-2 text-gray-800 dark:text-gray-100"> 116 - You are logged in! 117 - </p> 118 - <ProfileThing /> 119 - <button 120 - onClick={logout} 121 - className="bg-gray-600 mt-2 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors" 122 - > 123 - Log out 124 - </button> 125 - </div> 126 - ) : ( 127 - <form 128 - onSubmit={(e) => { 129 - e.preventDefault(); 130 - login(user, password, `https://${serviceURL}`); 131 - }} 132 - className="flex flex-col gap-4" 133 - > 134 - <p className="text-sm text-gray-500 dark:text-gray-400 mb-2"> 135 - sorry for the temporary login, 136 - <br /> 137 - oauth will come soon enough i swear 138 - </p> 139 - <input 140 - type="text" 141 - placeholder="Username" 142 - value={user} 143 - onChange={(e) => setUser(e.target.value)} 144 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 145 - autoComplete="username" 146 - /> 147 - <input 148 - type="password" 149 - placeholder="Password" 150 - value={password} 151 - onChange={(e) => setPassword(e.target.value)} 152 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 153 - autoComplete="current-password" 154 - /> 155 - <input 156 - type="text" 157 - placeholder="bsky.social" 158 - value={serviceURL} 159 - onChange={(e) => setServiceURL(e.target.value)} 160 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 161 - /> 162 - <button 163 - type="submit" 164 - className="bg-gray-600 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors mt-2" 165 - > 166 - Log in 167 - </button> 168 - </form> 169 - )} 85 + <div> 86 + <div className="flex border-b border-gray-200 dark:border-gray-700 mb-4"> 87 + <TabButton 88 + label="OAuth" 89 + active={mode === "oauth"} 90 + onClick={() => setMode("oauth")} 91 + /> 92 + <TabButton 93 + label="Password" 94 + active={mode === "password"} 95 + onClick={() => setMode("password")} 96 + /> 97 + </div> 98 + {mode === "oauth" ? <OAuthForm /> : <PasswordForm />} 170 99 </div> 171 100 ); 172 101 } 173 102 174 - export const ProfileThing = () => { 175 - const { agent, loading, loginStatus, authed } = useAuth(); 176 - const [response, setResponse] = useState<any>(null); 103 + // --- 3. Helper components for layouts, forms, and UI --- 104 + 105 + // A new component to contain the logic for the compact dropdown 106 + const CompactLoginButton = () => { 107 + const [showForm, setShowForm] = useState(false); 108 + const formRef = useRef<HTMLDivElement>(null); 177 109 178 110 useEffect(() => { 179 - if (loginStatus && agent && !loading && authed) { 180 - fetchUser(); 111 + function handleClickOutside(event: MouseEvent) { 112 + if (formRef.current && !formRef.current.contains(event.target as Node)) { 113 + setShowForm(false); 114 + } 115 + } 116 + if (showForm) { 117 + document.addEventListener("mousedown", handleClickOutside); 181 118 } 182 - // eslint-disable-next-line 183 - }, [loginStatus, agent, loading, authed]); 119 + return () => { 120 + document.removeEventListener("mousedown", handleClickOutside); 121 + }; 122 + }, [showForm]); 123 + 124 + return ( 125 + <div className="relative" ref={formRef}> 126 + <button 127 + onClick={() => setShowForm(!showForm)} 128 + className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 129 + > 130 + Log in 131 + </button> 132 + {showForm && ( 133 + <div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50"> 134 + <UnifiedLoginForm /> 135 + </div> 136 + )} 137 + </div> 138 + ); 139 + }; 140 + 141 + const TabButton = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void; }) => ( 142 + <button 143 + onClick={onClick} 144 + className={`px-4 py-2 text-sm font-medium transition-colors ${ 145 + active 146 + ? "text-gray-200 border-b-2 border-gray-500" 147 + : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" 148 + }`} 149 + > 150 + {label} 151 + </button> 152 + ); 153 + 154 + const OAuthForm = () => { 155 + const { loginWithOAuth } = useAuth(); 156 + const [handle, setHandle] = useState(""); 157 + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (handle.trim()) loginWithOAuth(handle); }; 158 + return ( 159 + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 160 + <p className="text-xs text-gray-500 dark:text-gray-400">Sign in with AT. Your password is never shared.</p> 161 + <input type="text" placeholder="handle.bsky.social" value={handle} onChange={(e) => setHandle(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> 162 + <button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button> 163 + </form> 164 + ); 165 + }; 166 + 167 + const PasswordForm = () => { 168 + const { loginWithPassword } = useAuth(); 169 + const [user, setUser] = useState(""); 170 + const [password, setPassword] = useState(""); 171 + const [serviceURL, setServiceURL] = useState("bsky.social"); 172 + const [error, setError] = useState<string | null>(null); 184 173 185 - const fetchUser = async () => { 186 - if (!agent) { 187 - console.error("Agent is null or undefined"); 188 - return; 174 + const handleSubmit = async (e: React.FormEvent) => { 175 + e.preventDefault(); 176 + setError(null); 177 + try { 178 + await loginWithPassword(user, password, `https://${serviceURL}`); 179 + } catch (err) { 180 + setError("Login failed. Check your handle and App Password."); 189 181 } 190 - const res = await agent.app.bsky.actor.getProfile({ 191 - actor: agent.assertDid, 192 - }); 193 - setResponse(res.data); 194 182 }; 195 183 196 - if (!authed) { 197 - return 198 - return ( 199 - <div className="inline-block"> 200 - <span className="text-gray-100 text-base font-medium px-1.5"> 201 - Login 202 - </span> 203 - </div> 204 - ); 205 - } 206 - 207 - if (!response) { 208 - return ( 209 - <div className="flex flex-col items-start gap-1.5"> 210 - <span className="w-5 h-5 border-2 border-gray-200 dark:border-gray-600 border-t-transparent rounded-full animate-spin inline-block" /> 211 - <span className="text-gray-100">Loading... </span> 212 - </div> 213 - ); 214 - } 215 - 216 184 return ( 217 - <div className="flex flex-row items-start gap-1.5"> 218 - <img 219 - src={response?.avatar} 220 - alt="avatar" 221 - className="w-[30px] h-[30px] rounded-full object-cover" 222 - /> 223 - <div className="flex flex-col items-start"> 224 - <div className="text-gray-100 text-xs">{response?.displayName}</div> 225 - <div className="text-gray-100 text-xs">@{response?.handle}</div> 226 - </div> 227 - </div> 185 + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 186 + <p className="text-xs text-red-500 dark:text-red-400">Warning: Less secure. Use an App Password.</p> 187 + <input type="text" placeholder="handle.bsky.social" value={user} onChange={(e) => setUser(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="username" /> 188 + <input type="password" placeholder="App Password" value={password} onChange={(e) => setPassword(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="current-password" /> 189 + <input type="text" placeholder="PDS (e.g., bsky.social)" value={serviceURL} onChange={(e) => setServiceURL(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> 190 + {error && <p className="text-xs text-red-500">{error}</p>} 191 + <button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button> 192 + </form> 228 193 ); 229 194 }; 195 + 196 + // --- Profile Component (now supports a `large` prop for styling) --- 197 + export const ProfileThing = ({ agent, large = false }: { agent: Agent | null; large?: boolean }) => { 198 + const [profile, setProfile] = useState<any>(null); 199 + 200 + useEffect(() => { 201 + const fetchUser = async () => { 202 + const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid; 203 + if (!did) return; 204 + try { 205 + const res = await agent!.getProfile({ actor: did }); 206 + setProfile(res.data); 207 + } catch (e) { console.error("Failed to fetch profile", e); } 208 + }; 209 + if (agent) fetchUser(); 210 + }, [agent]); 211 + 212 + if (!profile) { 213 + return ( // Skeleton loader 214 + <div className={`flex items-center gap-2.5 animate-pulse ${large ? 'mb-2' : ''}`}> 215 + <div className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? 'w-12 h-12' : 'w-[30px] h-[30px]'}`} /> 216 + <div className="flex flex-col gap-2"> 217 + <div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-28' : 'h-3 w-20'}`} /> 218 + <div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-20' : 'h-3 w-16'}`} /> 219 + </div> 220 + </div> 221 + ); 222 + } 223 + 224 + return ( 225 + <div className={`flex flex-row items-center gap-2.5 ${large ? 'mb-2' : ''}`}> 226 + <img src={profile?.avatar} alt="avatar" className={`object-cover rounded-full ${large ? 'w-12 h-12' : 'w-[30px] h-[30px]'}`} /> 227 + <div className="flex flex-col items-start text-left"> 228 + <div className={`font-medium ${large ? 'text-gray-800 dark:text-gray-100 text-lg' : 'text-gray-800 dark:text-gray-100 text-sm'}`}>{profile?.displayName}</div> 229 + <div className={` ${large ? 'text-gray-500 dark:text-gray-400 text-sm' : 'text-gray-500 dark:text-gray-400 text-xs'}`}>@{profile?.handle}</div> 230 + </div> 231 + </div> 232 + ); 233 + };
+1 -1
src/components/UniversalPostRenderer.tsx
··· 954 954 } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 955 955 import { useEffect, useRef, useState } from "react"; 956 956 import ReactPlayer from "react-player"; 957 - import { useAuth } from "~/providers/PassAuthProvider"; 957 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 958 958 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 959 959 // import type { 960 960 // ViewRecord,
+205
src/providers/UnifiedAuthProvider.tsx
··· 1 + // src/providers/UnifiedAuthProvider.tsx 2 + import React, { 3 + createContext, 4 + useState, 5 + useEffect, 6 + useContext, 7 + useCallback, 8 + } from "react"; 9 + // Import both Agent and the (soon to be deprecated) AtpAgent 10 + import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 11 + import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 12 + import { 13 + type OAuthSession, 14 + TokenInvalidError, 15 + TokenRefreshError, 16 + TokenRevokedError, 17 + } from "@atproto/oauth-client-browser"; 18 + 19 + // Define the unified status and authentication method 20 + type AuthStatus = "loading" | "signedIn" | "signedOut"; 21 + type AuthMethod = "password" | "oauth" | null; 22 + 23 + interface AuthContextValue { 24 + agent: Agent | null; // The agent is typed as the base class `Agent` 25 + status: AuthStatus; 26 + authMethod: AuthMethod; 27 + loginWithPassword: ( 28 + user: string, 29 + password: string, 30 + service?: string, 31 + ) => Promise<void>; 32 + loginWithOAuth: (handleOrPdsUrl: string) => Promise<void>; 33 + logout: () => Promise<void>; 34 + } 35 + 36 + const AuthContext = createContext<AuthContextValue>({} as AuthContextValue); 37 + 38 + export const UnifiedAuthProvider = ({ 39 + children, 40 + }: { 41 + children: React.ReactNode; 42 + }) => { 43 + // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 44 + const [agent, setAgent] = useState<Agent | null>(null); 45 + const [status, setStatus] = useState<AuthStatus>("loading"); 46 + const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 47 + const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + 49 + // Unified Initialization Logic 50 + const initialize = useCallback(async () => { 51 + // --- 1. Try OAuth initialization first --- 52 + try { 53 + const oauthResult = await oauthClient.init(); 54 + if (oauthResult) { 55 + console.log("OAuth session restored."); 56 + const apiAgent = new Agent(oauthResult.session); // Standard Agent 57 + setAgent(apiAgent); 58 + setOauthSession(oauthResult.session); 59 + setAuthMethod("oauth"); 60 + setStatus("signedIn"); 61 + return; // Success 62 + } 63 + } catch (e) { 64 + console.error("OAuth init failed, checking password session.", e); 65 + } 66 + 67 + // --- 2. If no OAuth, try password-based session using AtpAgent --- 68 + try { 69 + const service = localStorage.getItem("service"); 70 + const sessionString = localStorage.getItem("sess"); 71 + 72 + if (service && sessionString) { 73 + console.log("Resuming password-based session using AtpAgent..."); 74 + // Use the original, working AtpAgent logic 75 + const apiAgent = new AtpAgent({ service }); 76 + const session: AtpSessionData = JSON.parse(sessionString); 77 + await apiAgent.resumeSession(session); 78 + 79 + console.log("Password-based session resumed successfully."); 80 + setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 81 + setAuthMethod("password"); 82 + setStatus("signedIn"); 83 + return; // Success 84 + } 85 + } catch (e) { 86 + console.error("Failed to resume password-based session.", e); 87 + localStorage.removeItem("sess"); 88 + localStorage.removeItem("service"); 89 + } 90 + 91 + // --- 3. If neither worked, user is signed out --- 92 + console.log("No active session found."); 93 + setStatus("signedOut"); 94 + setAgent(null); 95 + setAuthMethod(null); 96 + }, []); 97 + 98 + useEffect(() => { 99 + const handleOAuthSessionDeleted = ( 100 + event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }>, 101 + ) => { 102 + console.error(`OAuth Session for ${event.detail.sub} was deleted.`, event.detail.cause); 103 + setAgent(null); 104 + setOauthSession(null); 105 + setAuthMethod(null); 106 + setStatus("signedOut"); 107 + }; 108 + 109 + oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); 110 + initialize(); 111 + 112 + return () => { 113 + oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 114 + }; 115 + }, [initialize]); 116 + 117 + // --- Login Methods --- 118 + const loginWithPassword = async ( 119 + user: string, 120 + password: string, 121 + service: string = "https://bsky.social", 122 + ) => { 123 + if (status !== "signedOut") return; 124 + setStatus("loading"); 125 + try { 126 + let sessionData: AtpSessionData | undefined; 127 + // Use the AtpAgent for its simple login and session persistence 128 + const apiAgent = new AtpAgent({ 129 + service, 130 + persistSession: (_evt, sess) => { 131 + sessionData = sess; 132 + }, 133 + }); 134 + await apiAgent.login({ identifier: user, password }); 135 + 136 + if (sessionData) { 137 + localStorage.setItem("service", service); 138 + localStorage.setItem("sess", JSON.stringify(sessionData)); 139 + setAgent(apiAgent); // Store the AtpAgent instance in our state 140 + setAuthMethod("password"); 141 + setStatus("signedIn"); 142 + console.log("Successfully logged in with password."); 143 + } else { 144 + throw new Error("Session data not persisted after login."); 145 + } 146 + } catch (e) { 147 + console.error("Password login failed:", e); 148 + setStatus("signedOut"); 149 + throw e; 150 + } 151 + }; 152 + 153 + const loginWithOAuth = useCallback(async (handleOrPdsUrl: string) => { 154 + if (status !== "signedOut") return; 155 + try { 156 + sessionStorage.setItem("postLoginRedirect", window.location.pathname + window.location.search); 157 + await oauthClient.signIn(handleOrPdsUrl); 158 + } catch (err) { 159 + console.error("OAuth sign-in aborted or failed:", err); 160 + } 161 + }, [status]); 162 + 163 + // --- Unified Logout --- 164 + const logout = useCallback(async () => { 165 + if (status !== "signedIn" || !agent) return; 166 + setStatus("loading"); 167 + 168 + try { 169 + if (authMethod === "oauth" && oauthSession) { 170 + await oauthClient.revoke(oauthSession.sub); 171 + console.log("OAuth session revoked."); 172 + } else if (authMethod === "password") { 173 + localStorage.removeItem("service"); 174 + localStorage.removeItem("sess"); 175 + // AtpAgent has its own logout methods 176 + await (agent as AtpAgent).com.atproto.server.deleteSession(); 177 + console.log("Password-based session deleted."); 178 + } 179 + } catch (e) { 180 + console.error("Logout failed:", e); 181 + } finally { 182 + setAgent(null); 183 + setAuthMethod(null); 184 + setOauthSession(null); 185 + setStatus("signedOut"); 186 + } 187 + }, [status, authMethod, agent, oauthSession]); 188 + 189 + return ( 190 + <AuthContext.Provider 191 + value={{ 192 + agent, 193 + status, 194 + authMethod, 195 + loginWithPassword, 196 + loginWithOAuth, 197 + logout, 198 + }} 199 + > 200 + {children} 201 + </AuthContext.Provider> 202 + ); 203 + }; 204 + 205 + export const useAuth = () => useContext(AuthContext);
+21
src/routeTree.gen.ts
··· 15 15 import { Route as FeedsRouteImport } from './routes/feeds' 16 16 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 17 import { Route as IndexRouteImport } from './routes/index' 18 + import { Route as CallbackIndexRouteImport } from './routes/callback/index' 18 19 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 19 20 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 20 21 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' ··· 48 49 const IndexRoute = IndexRouteImport.update({ 49 50 id: '/', 50 51 path: '/', 52 + getParentRoute: () => rootRouteImport, 53 + } as any) 54 + const CallbackIndexRoute = CallbackIndexRouteImport.update({ 55 + id: '/callback/', 56 + path: '/callback/', 51 57 getParentRoute: () => rootRouteImport, 52 58 } as any) 53 59 const PathlessLayoutNestedLayoutRoute = ··· 84 90 '/notifications': typeof NotificationsRoute 85 91 '/search': typeof SearchRoute 86 92 '/settings': typeof SettingsRoute 93 + '/callback': typeof CallbackIndexRoute 87 94 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 88 95 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 89 96 '/profile/$did': typeof ProfileDidIndexRoute ··· 95 102 '/notifications': typeof NotificationsRoute 96 103 '/search': typeof SearchRoute 97 104 '/settings': typeof SettingsRoute 105 + '/callback': typeof CallbackIndexRoute 98 106 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 99 107 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 100 108 '/profile/$did': typeof ProfileDidIndexRoute ··· 109 117 '/search': typeof SearchRoute 110 118 '/settings': typeof SettingsRoute 111 119 '/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren 120 + '/callback/': typeof CallbackIndexRoute 112 121 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 113 122 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 114 123 '/profile/$did/': typeof ProfileDidIndexRoute ··· 122 131 | '/notifications' 123 132 | '/search' 124 133 | '/settings' 134 + | '/callback' 125 135 | '/route-a' 126 136 | '/route-b' 127 137 | '/profile/$did' ··· 133 143 | '/notifications' 134 144 | '/search' 135 145 | '/settings' 146 + | '/callback' 136 147 | '/route-a' 137 148 | '/route-b' 138 149 | '/profile/$did' ··· 146 157 | '/search' 147 158 | '/settings' 148 159 | '/_pathlessLayout/_nested-layout' 160 + | '/callback/' 149 161 | '/_pathlessLayout/_nested-layout/route-a' 150 162 | '/_pathlessLayout/_nested-layout/route-b' 151 163 | '/profile/$did/' ··· 159 171 NotificationsRoute: typeof NotificationsRoute 160 172 SearchRoute: typeof SearchRoute 161 173 SettingsRoute: typeof SettingsRoute 174 + CallbackIndexRoute: typeof CallbackIndexRoute 162 175 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 163 176 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRoute 164 177 } ··· 205 218 path: '/' 206 219 fullPath: '/' 207 220 preLoaderRoute: typeof IndexRouteImport 221 + parentRoute: typeof rootRouteImport 222 + } 223 + '/callback/': { 224 + id: '/callback/' 225 + path: '/callback' 226 + fullPath: '/callback' 227 + preLoaderRoute: typeof CallbackIndexRouteImport 208 228 parentRoute: typeof rootRouteImport 209 229 } 210 230 '/_pathlessLayout/_nested-layout': { ··· 282 302 NotificationsRoute: NotificationsRoute, 283 303 SearchRoute: SearchRoute, 284 304 SettingsRoute: SettingsRoute, 305 + CallbackIndexRoute: CallbackIndexRoute, 285 306 ProfileDidIndexRoute: ProfileDidIndexRoute, 286 307 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRoute, 287 308 }
+6 -6
src/routes/__root.tsx
··· 21 21 import { NotFound } from "~/components/NotFound"; 22 22 import appCss from "~/styles/app.css?url"; 23 23 import { seo } from "~/utils/seo"; 24 - import { AuthProvider, useAuth } from "~/providers/PassAuthProvider"; 24 + import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 25 25 import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider"; 26 - import type AtpAgent from "@atproto/api"; 26 + import type Agent from "@atproto/api"; 27 27 import type { QueryClient } from "@tanstack/react-query"; 28 28 29 29 export const Route = createRootRouteWithContext<{ ··· 44 44 }), 45 45 ], 46 46 links: [ 47 - { rel: "stylesheet", href: appCss }, 48 47 { 49 48 rel: "apple-touch-icon", 50 49 sizes: "180x180", ··· 79 78 80 79 function RootComponent() { 81 80 return ( 82 - <AuthProvider> 81 + <UnifiedAuthProvider> 83 82 <PersistentStoreProvider> 84 83 <RootDocument> 85 84 <Outlet /> 86 85 </RootDocument> 87 86 </PersistentStoreProvider> 88 - </AuthProvider> 87 + </UnifiedAuthProvider> 89 88 ); 90 89 } 91 90 92 91 function RootDocument({ children }: { children: React.ReactNode }) { 93 92 const location = useLocation(); 94 93 const navigate = useNavigate(); 95 - const { agent, authed } = useAuth(); 94 + const { agent } = useAuth(); 95 + const authed = !!agent?.did; 96 96 const isHome = location.pathname === "/"; 97 97 const isNotifications = location.pathname.startsWith("/notifications"); 98 98 const isProfile = agent && ((location.pathname === (`/profile/${agent?.did}`)) || (location.pathname === (`/profile/${encodeURIComponent(agent?.did??"")}`)));
+13
src/routes/callback/index.tsx
··· 1 + import { createFileRoute, useNavigate } from '@tanstack/react-router' 2 + 3 + export const Route = createFileRoute('/callback/')({ 4 + component: RouteComponent, 5 + }) 6 + 7 + function RouteComponent() { 8 + const navigate = useNavigate() 9 + const redirectPath = sessionStorage.getItem('postLoginRedirect') || '/'; 10 + navigate({to:redirectPath}) 11 + sessionStorage.removeItem('postLoginRedirect'); 12 + return <div>Hello "/callback/"!</div> 13 + }
+9 -7
src/routes/index.tsx
··· 6 6 UniversalPostRendererATURILoader, 7 7 } from "~/components/UniversalPostRenderer"; 8 8 import * as React from "react"; 9 - import { useAuth } from "~/providers/PassAuthProvider"; 9 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 10 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 11 11 import { 12 12 useQueryIdentity, ··· 99 99 function Home() { 100 100 const { 101 101 agent, 102 - loginStatus, 103 - login, 102 + status, 103 + authMethod, 104 + loginWithPassword, 105 + loginWithOAuth, 104 106 logout, 105 - loading: loadering, 106 - authed, 107 107 } = useAuth(); 108 + const authed = !!agent?.did; 108 109 109 110 useEffect(() => { 110 111 if (agent?.did) { ··· 112 113 } else { 113 114 store.set(authedAtom, false); 114 115 } 115 - }, [loginStatus, agent, authed]); 116 + }, [status, agent, authed]); 116 117 useEffect(() => { 117 118 if (agent) { 119 + // is it just me or is the type really weird here it should be Agent not AtpAgent 118 120 store.set(agentAtom, agent); 119 121 } else { 120 122 store.set(agentAtom, null); 121 123 } 122 - }, [loginStatus, agent, authed]); 124 + }, [status, agent, authed]); 123 125 124 126 //const { get, set } = usePersistentStore(); 125 127 // const [feed, setFeed] = React.useState<any[]>([]);
+2 -2
src/utils/atoms.ts
··· 1 - import type AtpAgent from "@atproto/api"; 1 + import type Agent from "@atproto/api"; 2 2 import { atom, createStore } from "jotai"; 3 3 import { atomWithStorage } from 'jotai/utils'; 4 4 ··· 21 21 {} 22 22 ); 23 23 24 - export const agentAtom = atom<AtpAgent|null>(null); 24 + export const agentAtom = atom<Agent|null>(null); 25 25 export const authedAtom = atom<boolean>(false);
+16
src/utils/oauthClient.ts
··· 1 + // src/helpers/oauthClient.ts 2 + import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 3 + 4 + // This is your app's PDS for resolving handles if not provided. 5 + // You might need to host your own or use a public one. 6 + const handleResolverPDS = 'https://bsky.social'; 7 + 8 + // This assumes your client-metadata.json is in the /public folder 9 + // and will be served at the root of your domain. 10 + import clientMetadata from '../../public/client-metadata.json' assert { type: 'json' }; 11 + 12 + export const oauthClient = new BrowserOAuthClient({ 13 + // The type assertion is needed because the static import isn't strictly typed 14 + clientMetadata: clientMetadata as ClientMetadata, 15 + handleResolver: handleResolverPDS, 16 + });
+6 -6
src/utils/useQuery.ts
··· 332 332 333 333 export function constructFeedSkeletonQuery(options?: { 334 334 feedUri: string; 335 - agent?: ATPAPI.AtpAgent; 335 + agent?: ATPAPI.Agent; 336 336 isAuthed: boolean; 337 337 pdsUrl?: string; 338 338 feedServiceDid?: string; ··· 372 372 373 373 export function useQueryFeedSkeleton(options?: { 374 374 feedUri: string; 375 - agent?: ATPAPI.AtpAgent; 375 + agent?: ATPAPI.Agent; 376 376 isAuthed: boolean; 377 377 pdsUrl?: string; 378 378 feedServiceDid?: string; ··· 380 380 return useQuery(constructFeedSkeletonQuery(options)); 381 381 } 382 382 383 - export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined) { 383 + export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 384 384 return queryOptions({ 385 385 queryKey: ['preferences', agent?.did], 386 386 queryFn: async () => { ··· 393 393 }); 394 394 } 395 395 export function useQueryPreferences(options: { 396 - agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined 396 + agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 397 397 }) { 398 398 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 399 399 } ··· 498 498 499 499 export function constructInfiniteFeedSkeletonQuery(options: { 500 500 feedUri: string; 501 - agent?: ATPAPI.AtpAgent; 501 + agent?: ATPAPI.Agent; 502 502 isAuthed: boolean; 503 503 pdsUrl?: string; 504 504 feedServiceDid?: string; ··· 537 537 538 538 export function useInfiniteQueryFeedSkeleton(options: { 539 539 feedUri: string; 540 - agent?: ATPAPI.AtpAgent; 540 + agent?: ATPAPI.Agent; 541 541 isAuthed: boolean; 542 542 pdsUrl?: string; 543 543 feedServiceDid?: string;
+18
vite.config.ts
··· 1 1 import { defineConfig } from "vite"; 2 2 import viteReact from "@vitejs/plugin-react"; 3 3 import tailwindcss from "@tailwindcss/vite"; 4 + import { generateMetadataPlugin } from "./oauthdev.mts"; 5 + 6 + const PROD_URL = "https://reddwarf.whey.party" 7 + const DEV_URL = "https://local3768forumtest.whey.party" 8 + 9 + function shp(url: string): string { 10 + return url.replace(/^https?:\/\//, ''); 11 + } 4 12 5 13 import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 6 14 import { resolve } from "node:path"; ··· 8 16 // https://vitejs.dev/config/ 9 17 export default defineConfig({ 10 18 plugins: [ 19 + generateMetadataPlugin({ 20 + prod: PROD_URL, 21 + dev: DEV_URL, 22 + }), 11 23 TanStackRouterVite({ autoCodeSplitting: true }), 12 24 viteReact(), 13 25 tailwindcss(), ··· 21 33 "@": resolve(__dirname, "./src"), 22 34 "~": resolve(__dirname, "./src"), 23 35 }, 36 + }, 37 + server: { 38 + allowedHosts: [shp(PROD_URL),shp(DEV_URL)], 39 + }, 40 + css: { 41 + devSourcemap: true, 24 42 }, 25 43 });