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.

+1 -1
README.md
··· 8 8 ## running dev and build 9 9 in the `vite.config.ts` file you should change these values 10 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 11 + const PROD_URL = "https://reddwarf.app" 12 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 13 ``` 14 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
+4
package-lock.json
··· 8 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 10 "@atproto/oauth-client-browser": "^0.3.33", 11 + "@radix-ui/react-dialog": "^1.1.15", 11 12 "@radix-ui/react-dropdown-menu": "^2.1.16", 13 + "@radix-ui/react-hover-card": "^1.1.15", 14 + "@radix-ui/react-slider": "^1.3.6", 12 15 "@tailwindcss/vite": "^4.0.6", 13 16 "@tanstack/query-sync-storage-persister": "^5.85.6", 14 17 "@tanstack/react-devtools": "^0.2.2", ··· 2400 2403 "version": "1.1.15", 2401 2404 "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", 2402 2405 "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", 2406 + "license": "MIT", 2403 2407 "dependencies": { 2404 2408 "@radix-ui/primitive": "1.1.3", 2405 2409 "@radix-ui/react-compose-refs": "1.1.2",
+3
package.json
··· 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 14 "@atproto/oauth-client-browser": "^0.3.33", 15 + "@radix-ui/react-dialog": "^1.1.15", 15 16 "@radix-ui/react-dropdown-menu": "^2.1.16", 17 + "@radix-ui/react-hover-card": "^1.1.15", 18 + "@radix-ui/react-slider": "^1.3.6", 16 19 "@tailwindcss/vite": "^4.0.6", 17 20 "@tanstack/query-sync-storage-persister": "^5.85.6", 18 21 "@tanstack/react-devtools": "^0.2.2",
+205 -112
src/components/Composer.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2 2 import { useAtom } from "jotai"; 3 + import { Dialog } from "radix-ui"; 3 4 import { useEffect, useRef, useState } from "react"; 4 5 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 7 8 import { useQueryPost } from "~/utils/useQuery"; 8 9 9 10 import { ProfileThing } from "./Login"; 11 + import { Button } from "./radix-m3-rd/Button"; 10 12 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 11 13 12 - const MAX_POST_LENGTH = 300 14 + const MAX_POST_LENGTH = 300; 13 15 14 16 export function Composer() { 15 17 const [composerState, setComposerState] = useAtom(composerAtom); 18 + const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 16 19 const { agent } = useAuth(); 17 20 18 21 const [postText, setPostText] = useState(""); ··· 31 34 composerState.kind === "reply" 32 35 ? composerState.parent 33 36 : composerState.kind === "quote" 34 - ? composerState.subject 35 - : undefined; 37 + ? composerState.subject 38 + : undefined; 36 39 37 - const { data: parentPost, isLoading: isParentLoading } = useQueryPost(parentUri); 40 + const { data: parentPost, isLoading: isParentLoading } = 41 + useQueryPost(parentUri); 38 42 39 43 async function handlePost() { 40 44 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; ··· 46 50 const rt = new RichText({ text: postText }); 47 51 await rt.detectFacets(agent); 48 52 53 + if (rt.facets?.length) { 54 + rt.facets = rt.facets.filter((item) => { 55 + if (item.$type !== "app.bsky.richtext.facet") return true; 56 + if (!item.features?.length) return true; 57 + 58 + item.features = item.features.filter((feature) => { 59 + if (feature.$type !== "app.bsky.richtext.facet#mention") return true; 60 + const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined; 61 + return typeof did === "string" && did.startsWith("did:"); 62 + }); 63 + 64 + return item.features.length > 0; 65 + }); 66 + } 67 + 49 68 const record: Record<string, unknown> = { 50 69 $type: "app.bsky.feed.post", 51 70 text: rt.text, ··· 96 115 } 97 116 } 98 117 99 - if (composerState.kind === "closed") { 100 - return null; 101 - } 102 - 103 118 const getPlaceholder = () => { 104 119 switch (composerState.kind) { 105 120 case "reply": ··· 111 126 return "What's happening?!"; 112 127 } 113 128 }; 114 - 129 + 115 130 const charsLeft = MAX_POST_LENGTH - postText.length; 116 131 const isPostButtonDisabled = 117 - posting || 118 - !postText.trim() || 119 - isParentLoading || 120 - charsLeft < 0; 132 + posting || !postText.trim() || isParentLoading || charsLeft < 0; 133 + 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 + } 121 147 122 148 return ( 123 - <div className="fixed inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 bg-black/40 dark:bg-black/50"> 124 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 125 - <div className="flex flex-row justify-between p-2"> 126 - <button 127 - className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 128 - onClick={() => !posting && setComposerState({ kind: "closed" })} 129 - disabled={posting} 130 - aria-label="Close" 131 - > 132 - <svg 133 - xmlns="http://www.w3.org/2000/svg" 134 - width="20" 135 - height="20" 136 - viewBox="0 0 24 24" 137 - fill="none" 138 - stroke="currentColor" 139 - strokeWidth="2.5" 140 - strokeLinecap="round" 141 - strokeLinejoin="round" 142 - > 143 - <line x1="18" y1="6" x2="6" y2="18"></line> 144 - <line x1="6" y1="6" x2="18" y2="18"></line> 145 - </svg> 146 - </button> 147 - <div className="flex-1" /> 148 - <div className="flex items-center gap-4"> 149 - <span className={`text-sm ${charsLeft < 0 ? 'text-red-500' : 'text-gray-500'}`}> 150 - {charsLeft} 151 - </span> 152 - 153 - <button 154 - className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 155 - onClick={handlePost} 156 - disabled={isPostButtonDisabled} 157 - > 158 - {posting ? "Posting..." : "Post"} 159 - </button> 160 - </div> 161 - </div> 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" /> 158 + 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} 168 + > 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> 185 + 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> 200 + </div> 201 + 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 + )} 162 230 163 - {postSuccess ? ( 164 - <div className="flex flex-col items-center justify-center py-16"> 165 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 166 - <span className="text-xl font-bold text-black dark:text-white">Posted!</span> 167 - </div> 168 - ) : ( 169 - <div className="px-4"> 170 - {(composerState.kind === "reply") && ( 171 - <div className="mb-1 -mx-4"> 172 - {isParentLoading ? ( 173 - <div className="text-sm text-gray-500 animate-pulse"> 174 - Loading parent post... 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 242 + /> 243 + </div> 175 244 </div> 176 - ) : parentUri ? ( 177 - <UniversalPostRendererATURILoader atUri={parentUri} bottomReplyLine bottomBorder={false} /> 178 - ) : ( 179 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 180 - Could not load parent post. 181 - </div> 182 - )} 245 + 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 + )} 270 + </div> 271 + )} 272 + </div> 273 + </Dialog.Content> 274 + </Dialog.Portal> 275 + </Dialog.Root> 276 + 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" /> 282 + 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? 183 287 </div> 184 - )} 185 - 186 - <div className="flex w-full gap-1 flex-col"> 187 - <ProfileThing agent={agent} large/> 188 - <div className="flex pl-[50px]"> 189 - <AutoGrowTextarea 190 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 191 - rows={5} 192 - placeholder={getPlaceholder()} 193 - value={postText} 194 - onChange={(e) => setPostText(e.target.value)} 195 - disabled={posting} 196 - autoFocus 197 - /> 288 + <div className="text-md mb-4 text-center"> 289 + You will lose your draft 290 + </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> 198 303 </div> 199 304 </div> 200 - {(composerState.kind === "quote") && ( 201 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 202 - {isParentLoading ? ( 203 - <div className="text-sm text-gray-500 animate-pulse"> 204 - Loading parent post... 205 - </div> 206 - ) : parentUri ? ( 207 - <UniversalPostRendererATURILoader atUri={parentUri} isQuote /> 208 - ) : ( 209 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 210 - Could not load parent post. 211 - </div> 212 - )} 213 - </div> 214 - )} 215 - 216 - {postError && ( 217 - <div className="text-red-500 text-sm my-2 text-center">{postError}</div> 218 - )} 219 - 220 - </div> 221 - )} 222 - </div> 223 - </div> 305 + </Dialog.Content> 306 + </Dialog.Portal> 307 + </Dialog.Root> 308 + </> 224 309 ); 225 310 } 226 311 227 - function AutoGrowTextarea({ value, className, onChange, ...props }: React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>) { 312 + function AutoGrowTextarea({ 313 + value, 314 + className, 315 + onChange, 316 + ...props 317 + }: React.DetailedHTMLProps< 318 + React.TextareaHTMLAttributes<HTMLTextAreaElement>, 319 + HTMLTextAreaElement 320 + >) { 228 321 const ref = useRef<HTMLTextAreaElement>(null); 229 322 230 323 useEffect(() => { ··· 243 336 {...props} 244 337 /> 245 338 ); 246 - } 339 + }
+150
src/components/Import.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useState } from "react"; 4 + 5 + /** 6 + * Basically the best equivalent to Search that i can do 7 + */ 8 + export function Import() { 9 + const [textInput, setTextInput] = useState<string | undefined>(); 10 + const navigate = useNavigate(); 11 + 12 + const handleEnter = () => { 13 + if (!textInput) return; 14 + handleImport({ 15 + text: textInput, 16 + navigate, 17 + }); 18 + }; 19 + 20 + return ( 21 + <div className="w-full relative"> 22 + <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 23 + 24 + <input 25 + type="text" 26 + placeholder="Import..." 27 + value={textInput} 28 + onChange={(e) => setTextInput(e.target.value)} 29 + onKeyDown={(e) => { 30 + if (e.key === "Enter") handleEnter(); 31 + }} 32 + className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition" 33 + /> 34 + </div> 35 + ); 36 + } 37 + 38 + function handleImport({ 39 + text, 40 + navigate, 41 + }: { 42 + text: string; 43 + navigate: UseNavigateResult<string>; 44 + }) { 45 + const trimmed = text.trim(); 46 + // parse text 47 + /** 48 + * text might be 49 + * 1. bsky dot app url (reddwarf link segments might be uri encoded,) 50 + * 2. aturi 51 + * 3. plain handle 52 + * 4. plain did 53 + */ 54 + 55 + // 1. Check if itโ€™s a URL 56 + try { 57 + const url = new URL(text); 58 + const knownHosts = [ 59 + "bsky.app", 60 + "social.daniela.lol", 61 + "deer.social", 62 + "reddwarf.whey.party", 63 + "reddwarf.app", 64 + "main.bsky.dev", 65 + "catsky.social", 66 + "blacksky.community", 67 + "red-dwarf-social-app.whey.party", 68 + "zeppelin.social", 69 + ]; 70 + if (knownHosts.includes(url.hostname)) { 71 + // parse path to get URI or handle 72 + const path = decodeURIComponent(url.pathname.slice(1)); // remove leading / 73 + console.log("BSky URL path:", path); 74 + navigate({ 75 + to: `/${path}`, 76 + }); 77 + return; 78 + } 79 + } catch { 80 + // not a URL, continue 81 + } 82 + 83 + // 2. Check if text looks like an at-uri 84 + try { 85 + if (text.startsWith("at://")) { 86 + console.log("AT URI detected:", text); 87 + const aturi = new AtUri(text); 88 + switch (aturi.collection) { 89 + case "app.bsky.feed.post": { 90 + navigate({ 91 + to: "/profile/$did/post/$rkey", 92 + params: { 93 + did: aturi.host, 94 + rkey: aturi.rkey, 95 + }, 96 + }); 97 + return; 98 + } 99 + case "app.bsky.actor.profile": { 100 + navigate({ 101 + to: "/profile/$did", 102 + params: { 103 + did: aturi.host, 104 + }, 105 + }); 106 + return; 107 + } 108 + // todo add more handlers as more routes are added. like feeds, lists, etc etc thanks! 109 + default: { 110 + // continue 111 + } 112 + } 113 + } 114 + } catch { 115 + // continue 116 + } 117 + 118 + // 3. Plain handle (starts with @) 119 + try { 120 + if (text.startsWith("@")) { 121 + const handle = text.slice(1); 122 + console.log("Handle detected:", handle); 123 + navigate({ to: "/profile/$did", params: { did: handle } }); 124 + return; 125 + } 126 + } catch { 127 + // continue 128 + } 129 + 130 + // 4. Plain DID (starts with did:) 131 + try { 132 + if (text.startsWith("did:")) { 133 + console.log("did detected:", text); 134 + navigate({ to: "/profile/$did", params: { did: text } }); 135 + return; 136 + } 137 + } catch { 138 + // continue 139 + } 140 + 141 + // if all else fails 142 + 143 + // try { 144 + // // probably a user? 145 + // navigate({ to: "/profile/$did", params: { did: text } }); 146 + // return; 147 + // } catch { 148 + // // continue 149 + // } 150 + }
+32 -6
src/components/InfiniteCustomFeed.tsx
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 1 2 import * as React from "react"; 2 3 3 4 //import { useInView } from "react-intersection-observer"; ··· 37 38 isFetchingNextPage, 38 39 refetch, 39 40 isRefetching, 41 + queryKey, 40 42 } = useInfiniteQueryFeedSkeleton({ 41 43 feedUri: feedUri, 42 44 agent: agent ?? undefined, ··· 44 46 pdsUrl: pdsUrl, 45 47 feedServiceDid: feedServiceDid, 46 48 }); 49 + const queryClient = useQueryClient(); 50 + 47 51 48 52 const handleRefresh = () => { 53 + queryClient.removeQueries({queryKey: queryKey}); 54 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 49 55 refetch(); 50 56 }; 51 57 58 + const allPosts = React.useMemo(() => { 59 + const flattenedPosts = data?.pages.flatMap((page) => page?.feed) ?? []; 60 + 61 + const seenUris = new Set<string>(); 62 + 63 + return flattenedPosts.filter((item) => { 64 + if (!item?.post) return false; 65 + 66 + if (seenUris.has(item.post)) { 67 + return false; 68 + } 69 + 70 + seenUris.add(item.post); 71 + 72 + return true; 73 + }); 74 + }, [data]); 75 + 52 76 //const { ref, inView } = useInView(); 53 77 54 78 // React.useEffect(() => { ··· 67 91 ); 68 92 } 69 93 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 94 + // const allPosts = 95 + // data?.pages.flatMap((page) => { 96 + // if (page) return page.feed; 97 + // }) ?? []; 74 98 75 99 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 100 return ( ··· 116 140 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 141 aria-label="Refresh feed" 118 142 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 143 + <RefreshIcon 144 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 145 + /> 120 146 </button> 121 147 </> 122 148 ); ··· 139 165 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 140 166 ></path> 141 167 </svg> 142 - ); 168 + );
+70 -25
src/components/Login.tsx
··· 1 1 // src/components/Login.tsx 2 2 import AtpAgent, { Agent } from "@atproto/api"; 3 + import { useAtom } from "jotai"; 3 4 import React, { useEffect, useRef, useState } from "react"; 4 5 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { imgCDNAtom } from "~/utils/atoms"; 6 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 + 10 + import { Button } from "./radix-m3-rd/Button"; 7 11 8 12 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 9 13 export default function Login({ ··· 22 26 className={ 23 27 compact 24 28 ? "flex items-center justify-center p-1" 25 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 29 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 26 30 } 27 31 > 28 32 <span ··· 41 45 // Large view 42 46 if (!compact) { 43 47 return ( 44 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 48 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 45 49 <div className="flex flex-col items-center justify-center text-center"> 46 50 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 47 51 You are logged in! 48 52 </p> 49 53 <ProfileThing agent={agent} large /> 50 - <button 54 + <Button 51 55 onClick={logout} 52 - className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 56 + className="mt-4" 53 57 > 54 58 Log out 55 - </button> 59 + </Button> 56 60 </div> 57 61 </div> 58 62 ); ··· 75 79 if (!compact) { 76 80 // Large view renders the form directly in the card 77 81 return ( 78 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 82 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 79 83 <UnifiedLoginForm /> 80 84 </div> 81 85 ); ··· 161 165 onClick={onClick} 162 166 className={`px-4 py-2 text-sm font-medium transition-colors rounded-full flex-1 ${ 163 167 active 164 - ? "text-gray-950 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 168 + ? "text-gray-50 dark:text-gray-200 border-gray-500 bg-gray-400 dark:bg-gray-500" 165 169 : "text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200" 166 170 }`} 167 171 > ··· 190 194 <p className="text-xs text-gray-500 dark:text-gray-400"> 191 195 Sign in with AT. Your password is never shared. 192 196 </p> 193 - <input 197 + {/* <input 194 198 type="text" 195 199 placeholder="handle.bsky.social" 196 200 value={handle} 197 201 onChange={(e) => setHandle(e.target.value)} 198 202 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 199 - /> 200 - <button 201 - type="submit" 202 - className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 203 - > 204 - Log in 205 - </button> 203 + /> */} 204 + <div className="flex flex-col gap-3"> 205 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 206 + <input 207 + type="text" 208 + placeholder=" " 209 + value={handle} 210 + onChange={(e) => setHandle(e.target.value)} 211 + /> 212 + <label>AT Handle</label> 213 + </div> 214 + <button 215 + type="submit" 216 + className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 217 + > 218 + Log in 219 + </button> 220 + </div> 206 221 </form> 207 222 ); 208 223 }; ··· 235 250 <p className="text-xs text-red-500 dark:text-red-400"> 236 251 Warning: Less secure. Use an App Password. 237 252 </p> 238 - <input 253 + {/* <input 239 254 type="text" 240 255 placeholder="handle.bsky.social" 241 256 value={user} ··· 257 272 value={serviceURL} 258 273 onChange={(e) => setServiceURL(e.target.value)} 259 274 className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" 260 - /> 275 + /> */} 276 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 277 + <input 278 + type="text" 279 + placeholder=" " 280 + value={user} 281 + onChange={(e) => setUser(e.target.value)} 282 + /> 283 + <label>AT Handle</label> 284 + </div> 285 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 286 + <input 287 + type="text" 288 + placeholder=" " 289 + value={password} 290 + onChange={(e) => setPassword(e.target.value)} 291 + /> 292 + <label>App Password</label> 293 + </div> 294 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 295 + <input 296 + type="text" 297 + placeholder=" " 298 + value={serviceURL} 299 + onChange={(e) => setServiceURL(e.target.value)} 300 + /> 301 + <label>PDS</label> 302 + </div> 261 303 {error && <p className="text-xs text-red-500">{error}</p>} 262 304 <button 263 305 type="submit" ··· 271 313 272 314 // --- Profile Component (now supports a `large` prop for styling) --- 273 315 export const ProfileThing = ({ 274 - agent: _unused, 316 + agent, 275 317 large = false, 276 318 }: { 277 - agent?: Agent | null; 319 + agent: Agent | null; 278 320 large?: boolean; 279 321 }) => { 280 - const { agent } = useAuth(); 281 - const did = ((agent as AtpAgent).session?.did ?? (agent as AtpAgent)?.assertDid ?? agent?.did) as 282 - | string 283 - | undefined; 322 + const did = ((agent as AtpAgent)?.session?.did ?? 323 + (agent as AtpAgent)?.assertDid ?? 324 + agent?.did) as string | undefined; 284 325 const { data: identity } = useQueryIdentity(did); 285 - const { data: profiledata } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`); 326 + const { data: profiledata } = useQueryProfile( 327 + `at://${did}/app.bsky.actor.profile/self` 328 + ); 286 329 const profile = profiledata?.value; 330 + 331 + const [imgcdn] = useAtom(imgCDNAtom) 287 332 288 333 function getAvatarUrl(p: typeof profile) { 289 334 const link = p?.avatar?.ref?.["$link"]; 290 335 if (!link || !did) return null; 291 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 336 + return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 292 337 } 293 338 294 339 if (!profiledata) {
+6
src/components/Star.tsx
··· 1 + import type { SVGProps } from 'react'; 2 + import React from 'react'; 3 + 4 + export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 5 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 6 + }
+154 -68
src/components/UniversalPostRenderer.tsx
··· 2 2 import DOMPurify from "dompurify"; 3 3 import { useAtom } from "jotai"; 4 4 import { DropdownMenu } from "radix-ui"; 5 + import { HoverCard } from "radix-ui"; 5 6 import * as React from "react"; 6 7 import { type SVGProps } from "react"; 7 8 8 - import { composerAtom, likedPostsAtom } from "~/utils/atoms"; 9 + import { 10 + composerAtom, 11 + constellationURLAtom, 12 + imgCDNAtom, 13 + likedPostsAtom, 14 + } from "~/utils/atoms"; 9 15 import { useHydratedEmbed } from "~/utils/useHydrated"; 10 16 import { 11 17 useQueryConstellation, ··· 150 156 maxReplies, 151 157 isQuote, 152 158 }: UniversalPostRendererATURILoaderProps) { 159 + // todo remove this once tree rendering is implemented, use a prop like isTree 160 + const TEMPLINEAR = true; 153 161 // /*mass comment*/ console.log("atUri", atUri); 154 162 //const { get, set } = usePersistentStore(); 155 163 //const [record, setRecord] = React.useState<any>(null); ··· 401 409 // path: ".reply.parent.uri", 402 410 // }); 403 411 412 + const [constellationurl] = useAtom(constellationURLAtom); 413 + 404 414 const infinitequeryresults = useInfiniteQuery({ 405 415 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 406 416 { 417 + constellation: constellationurl, 407 418 method: "/links", 408 419 target: atUri, 409 420 collection: "app.bsky.feed.post", ··· 422 433 423 434 // auto-fetch all pages 424 435 useEffect(() => { 425 - if (!maxReplies || isQuote) return; 436 + if (!maxReplies || isQuote || TEMPLINEAR) return; 426 437 if ( 427 438 infinitequeryresults.hasNextPage && 428 439 !infinitequeryresults.isFetchingNextPage ··· 430 441 console.log("Fetching the next page..."); 431 442 infinitequeryresults.fetchNextPage(); 432 443 } 433 - }, [infinitequeryresults]); 444 + }, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]); 434 445 435 446 const replyAturis = repliesData 436 447 ? repliesData.pages.flatMap((page) => ··· 507 518 ? true 508 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 509 520 ? false 510 - : bottomReplyLine 521 + : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 511 522 } 512 523 topReplyLine={topReplyLine} 513 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 529 540 maxReplies={maxReplies} 530 541 isQuote={isQuote} 531 542 /> 543 + <> 544 + {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 545 + <> 546 + {/* <div>hello</div> */} 547 + <MoreReplies atUri={atUri} /> 548 + </> 549 + ) : (<></>)} 550 + </> 532 551 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 533 552 <> 534 553 {/* <span>hello {maxReplies}</span> */} ··· 553 572 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 554 573 } 555 574 /> 556 - {maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && ( 557 - <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 558 - )} 559 575 </> 560 576 )} 561 577 </> ··· 596 612 ); 597 613 } 598 614 599 - function getAvatarUrl(opProfile: any, did: string) { 615 + function getAvatarUrl(opProfile: any, did: string, cdn: string) { 600 616 const link = opProfile?.value?.avatar?.ref?.["$link"]; 601 617 if (!link) return null; 602 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 618 + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 603 619 } 604 620 605 621 export function UniversalPostRendererRawRecordShim({ ··· 720 736 error: embedError, 721 737 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 722 738 739 + const [imgcdn] = useAtom(imgCDNAtom); 740 + 723 741 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 724 742 743 + const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 744 + () => ({ 745 + did: resolved?.did || "", 746 + handle: resolved?.handle || "", 747 + displayName: profileRecord?.value?.displayName || "", 748 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 749 + viewer: undefined, 750 + labels: profileRecord?.labels || undefined, 751 + verification: undefined, 752 + }), 753 + [imgcdn, profileRecord, resolved?.did, resolved?.handle] 754 + ); 755 + 756 + const fakeprofileviewdetailed = 757 + React.useMemo<AppBskyActorDefs.ProfileViewDetailed>( 758 + () => ({ 759 + ...fakeprofileviewbasic, 760 + $type: "app.bsky.actor.defs#profileViewDetailed", 761 + description: profileRecord?.value?.description || undefined, 762 + }), 763 + [fakeprofileviewbasic, profileRecord?.value?.description] 764 + ); 765 + 725 766 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( 726 767 () => ({ 727 768 $type: "app.bsky.feed.defs#postView", 728 769 uri: aturi, 729 770 cid: postRecord?.cid || "", 730 - author: { 731 - did: resolved?.did || "", 732 - handle: resolved?.handle || "", 733 - displayName: profileRecord?.value?.displayName || "", 734 - avatar: getAvatarUrl(profileRecord, resolved?.did) || "", 735 - viewer: undefined, 736 - labels: profileRecord?.labels || undefined, 737 - verification: undefined, 738 - }, 771 + author: fakeprofileviewbasic, 739 772 record: postRecord?.value || {}, 740 773 embed: hydratedEmbed ?? undefined, 741 774 replyCount: repliesCount ?? 0, ··· 752 785 postRecord?.cid, 753 786 postRecord?.value, 754 787 postRecord?.labels, 755 - resolved?.did, 756 - resolved?.handle, 757 - profileRecord, 788 + fakeprofileviewbasic, 758 789 hydratedEmbed, 759 790 repliesCount, 760 791 repostsCount, ··· 831 862 } 832 863 }} 833 864 post={fakepost} 865 + uprrrsauthor={fakeprofileviewdetailed} 834 866 salt={aturi} 835 867 bottomReplyLine={bottomReplyLine} 836 868 topReplyLine={topReplyLine} ··· 883 915 {...props} 884 916 > 885 917 <path 886 - fill="oklch(0.704 0.05 28)" 918 + fill="var(--color-gray-400)" 887 919 d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z" 888 920 ></path> 889 921 </svg> ··· 900 932 {...props} 901 933 > 902 934 <path 903 - fill="oklch(0.704 0.05 28)" 935 + fill="var(--color-gray-400)" 904 936 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 905 937 ></path> 906 938 </svg> ··· 951 983 {...props} 952 984 > 953 985 <path 954 - fill="oklch(0.704 0.05 28)" 986 + fill="var(--color-gray-400)" 955 987 d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3" 956 988 ></path> 957 989 </svg> ··· 968 1000 {...props} 969 1001 > 970 1002 <path 971 - fill="oklch(0.704 0.05 28)" 1003 + fill="var(--color-gray-400)" 972 1004 d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08" 973 1005 ></path> 974 1006 </svg> ··· 985 1017 {...props} 986 1018 > 987 1019 <path 988 - fill="oklch(0.704 0.05 28)" 1020 + fill="var(--color-gray-400)" 989 1021 d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2" 990 1022 ></path> 991 1023 </svg> ··· 1002 1034 {...props} 1003 1035 > 1004 1036 <path 1005 - fill="oklch(0.704 0.05 28)" 1037 + fill="var(--color-gray-400)" 1006 1038 d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" 1007 1039 ></path> 1008 1040 </svg> ··· 1036 1068 {...props} 1037 1069 > 1038 1070 <path 1039 - fill="oklch(0.704 0.05 28)" 1071 + fill="var(--color-gray-400)" 1040 1072 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1041 1073 ></path> 1042 1074 </svg> ··· 1090 1122 {...props} 1091 1123 > 1092 1124 <path 1093 - fill="oklch(0.704 0.05 28)" 1125 + fill="var(--color-gray-400)" 1094 1126 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1095 1127 ></path> 1096 1128 </svg> ··· 1107 1139 {...props} 1108 1140 > 1109 1141 <path 1110 - fill="oklch(0.704 0.05 28)" 1142 + fill="var(--color-gray-400)" 1111 1143 d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z" 1112 1144 ></path> 1113 1145 </svg> ··· 1135 1167 //import Masonry from "@mui/lab/Masonry"; 1136 1168 import { 1137 1169 type $Typed, 1170 + AppBskyActorDefs, 1138 1171 AppBskyEmbedDefs, 1139 1172 AppBskyEmbedExternal, 1140 1173 AppBskyEmbedImages, ··· 1164 1197 1165 1198 import defaultpfp from "~/../public/favicon.png"; 1166 1199 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1200 + import { FollowButton, Mutual } from "~/routes/profile.$did"; 1167 1201 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1168 1202 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1169 1203 // import type { ··· 1272 1306 1273 1307 function UniversalPostRenderer({ 1274 1308 post, 1309 + uprrrsauthor, 1275 1310 //setMainItem, 1276 1311 //isMainItem, 1277 1312 onPostClick, ··· 1296 1331 maxReplies, 1297 1332 }: { 1298 1333 post: PostView; 1334 + uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1299 1335 // optional for now because i havent ported every use to this yet 1300 1336 // setMainItem?: React.Dispatch< 1301 1337 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> ··· 1479 1515 className="bg-gray-500 dark:bg-gray-400" 1480 1516 /> 1481 1517 )} 1482 - <div 1483 - style={{ 1484 - position: "absolute", 1485 - //top: isRepost ? "calc(16px + 1rem)" : 16, 1486 - //left: 16, 1487 - zIndex: 1, 1488 - top: isRepost 1489 - ? "calc(16px + 1rem)" 1490 - : isQuote 1491 - ? 12 1492 - : topReplyLine 1493 - ? 8 1494 - : 16, 1495 - left: isQuote ? 12 : 16, 1496 - }} 1497 - onClick={onProfileClick} 1498 - > 1499 - <img 1500 - src={post.author.avatar || defaultpfp} 1501 - alt="avatar" 1502 - // transition={{ 1503 - // type: "spring", 1504 - // stiffness: 260, 1505 - // damping: 20, 1506 - // }} 1507 - style={{ 1508 - borderRadius: "50%", 1509 - marginRight: 12, 1510 - objectFit: "cover", 1511 - //background: theme.border, 1512 - //border: `1px solid ${theme.border}`, 1513 - width: isQuote ? 16 : 42, 1514 - height: isQuote ? 16 : 42, 1515 - }} 1516 - className="border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1517 - /> 1518 - </div> 1518 + <HoverCard.Root> 1519 + <HoverCard.Trigger asChild> 1520 + <div 1521 + className={`absolute`} 1522 + style={{ 1523 + top: isRepost 1524 + ? "calc(16px + 1rem)" 1525 + : isQuote 1526 + ? 12 1527 + : topReplyLine 1528 + ? 8 1529 + : 16, 1530 + left: isQuote ? 12 : 16, 1531 + }} 1532 + onClick={onProfileClick} 1533 + > 1534 + <img 1535 + src={post.author.avatar || defaultpfp} 1536 + alt="avatar" 1537 + className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1538 + style={{ 1539 + width: isQuote ? 16 : 42, 1540 + height: isQuote ? 16 : 42, 1541 + }} 1542 + /> 1543 + </div> 1544 + </HoverCard.Trigger> 1545 + <HoverCard.Portal> 1546 + <HoverCard.Content 1547 + className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50" 1548 + side={"bottom"} 1549 + sideOffset={5} 1550 + onClick={onProfileClick} 1551 + > 1552 + <div className="flex flex-col gap-2"> 1553 + <div className="flex flex-row"> 1554 + <img 1555 + src={post.author.avatar || defaultpfp} 1556 + alt="avatar" 1557 + 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 + /> 1559 + <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 + <div className=" flex flex-col justify-start"> 1561 + <FollowButton targetdidorhandle={post.author.did} /> 1562 + </div> 1563 + </div> 1564 + </div> 1565 + <div className="flex flex-col gap-3"> 1566 + <div> 1567 + <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1568 + {post.author.displayName || post.author.handle}{" "} 1569 + </div> 1570 + <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}{" "} 1572 + </div> 1573 + </div> 1574 + {uprrrsauthor?.description && ( 1575 + <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1576 + {uprrrsauthor.description} 1577 + </div> 1578 + )} 1579 + {/* <div className="flex gap-4"> 1580 + <div className="flex gap-1"> 1581 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1582 + 0 1583 + </div> 1584 + <div className="text-gray-500 dark:text-gray-400"> 1585 + Following 1586 + </div> 1587 + </div> 1588 + <div className="flex gap-1"> 1589 + <div className="font-medium text-gray-900 dark:text-gray-100"> 1590 + 2,900 1591 + </div> 1592 + <div className="text-gray-500 dark:text-gray-400"> 1593 + Followers 1594 + </div> 1595 + </div> 1596 + </div> */} 1597 + </div> 1598 + </div> 1599 + 1600 + {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1601 + </HoverCard.Content> 1602 + </HoverCard.Portal> 1603 + </HoverCard.Root> 1604 + 1519 1605 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1520 1606 <div 1521 1607 style={{ ··· 2571 2657 return { start, end, feature: f.features[0] }; 2572 2658 }); 2573 2659 } 2574 - function renderTextWithFacets({ 2660 + export function renderTextWithFacets({ 2575 2661 text, 2576 2662 facets, 2577 2663 navigate,
+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";
+2
src/main.tsx
··· 14 14 import { routeTree } from "./routeTree.gen"; 15 15 import { isAtTopAtom } from "./utils/atoms.ts"; 16 16 17 + //initAtomToCssVar(hueAtom, "--tw-gray-hue") 18 + 17 19 const queryClient = new QueryClient({ 18 20 defaultOptions: { 19 21 queries: {
+26 -23
src/providers/UnifiedAuthProvider.tsx
··· 1 - // src/providers/UnifiedAuthProvider.tsx 2 - // Import both Agent and the (soon to be deprecated) AtpAgent 3 1 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 4 2 import { 5 3 type OAuthSession, ··· 7 5 TokenRefreshError, 8 6 TokenRevokedError, 9 7 } from "@atproto/oauth-client-browser"; 8 + import { useAtom } from "jotai"; 10 9 import React, { 11 10 createContext, 12 11 use, ··· 15 14 useState, 16 15 } from "react"; 17 16 18 - import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + 19 + import { oauthClient } from "../utils/oauthClient"; 19 20 20 - // Define the unified status and authentication method 21 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 22 type AuthMethod = "password" | "oauth" | null; 23 23 24 24 interface AuthContextValue { 25 - agent: Agent | null; // The agent is typed as the base class `Agent` 25 + agent: Agent | null; 26 26 status: AuthStatus; 27 27 authMethod: AuthMethod; 28 28 loginWithPassword: ( ··· 41 41 }: { 42 42 children: React.ReactNode; 43 43 }) => { 44 - // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 45 44 const [agent, setAgent] = useState<Agent | null>(null); 46 45 const [status, setStatus] = useState<AuthStatus>("loading"); 47 46 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 48 47 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 49 49 50 - // Unified Initialization Logic 51 50 const initialize = useCallback(async () => { 52 - // --- 1. Try OAuth initialization first --- 53 51 try { 54 52 const oauthResult = await oauthClient.init(); 55 53 if (oauthResult) { 56 54 // /*mass comment*/ console.log("OAuth session restored."); 57 - const apiAgent = new Agent(oauthResult.session); // Standard Agent 55 + const apiAgent = new Agent(oauthResult.session); 58 56 setAgent(apiAgent); 59 57 setOauthSession(oauthResult.session); 60 58 setAuthMethod("oauth"); 61 59 setStatus("signedIn"); 62 - return; // Success 60 + setQuickAuth(apiAgent?.did || null); 61 + return; 63 62 } 64 63 } catch (e) { 65 64 console.error("OAuth init failed, checking password session.", e); 65 + if (!quickAuth) { 66 + // quickAuth restoration. if last used method is oauth we immediately call for oauth redo 67 + // (and set a persistent atom somewhere to not retry again if it failed) 68 + } 66 69 } 67 70 68 - // --- 2. If no OAuth, try password-based session using AtpAgent --- 69 71 try { 70 72 const service = localStorage.getItem("service"); 71 73 const sessionString = localStorage.getItem("sess"); 72 74 73 75 if (service && sessionString) { 74 76 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 75 - // Use the original, working AtpAgent logic 76 77 const apiAgent = new AtpAgent({ service }); 77 78 const session: AtpSessionData = JSON.parse(sessionString); 78 79 await apiAgent.resumeSession(session); 79 80 80 81 // /*mass comment*/ console.log("Password-based session resumed successfully."); 81 - setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 82 + setAgent(apiAgent); 82 83 setAuthMethod("password"); 83 84 setStatus("signedIn"); 84 - return; // Success 85 + setQuickAuth(apiAgent?.did || null); 86 + return; 85 87 } 86 88 } catch (e) { 87 89 console.error("Failed to resume password-based session.", e); ··· 89 91 localStorage.removeItem("service"); 90 92 } 91 93 92 - // --- 3. If neither worked, user is signed out --- 93 94 // /*mass comment*/ console.log("No active session found."); 94 95 setStatus("signedOut"); 95 96 setAgent(null); 96 97 setAuthMethod(null); 97 - }, []); 98 + // do we want to null it here? 99 + setQuickAuth(null); 100 + }, [quickAuth, setQuickAuth]); 98 101 99 102 useEffect(() => { 100 103 const handleOAuthSessionDeleted = ( ··· 105 108 setOauthSession(null); 106 109 setAuthMethod(null); 107 110 setStatus("signedOut"); 111 + setQuickAuth(null); 108 112 }; 109 113 110 114 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 113 117 return () => { 114 118 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 115 119 }; 116 - }, [initialize]); 120 + }, [initialize, setQuickAuth]); 117 121 118 - // --- Login Methods --- 119 122 const loginWithPassword = async ( 120 123 user: string, 121 124 password: string, ··· 125 128 setStatus("loading"); 126 129 try { 127 130 let sessionData: AtpSessionData | undefined; 128 - // Use the AtpAgent for its simple login and session persistence 129 131 const apiAgent = new AtpAgent({ 130 132 service, 131 133 persistSession: (_evt, sess) => { ··· 137 139 if (sessionData) { 138 140 localStorage.setItem("service", service); 139 141 localStorage.setItem("sess", JSON.stringify(sessionData)); 140 - setAgent(apiAgent); // Store the AtpAgent instance in our state 142 + setAgent(apiAgent); 141 143 setAuthMethod("password"); 142 144 setStatus("signedIn"); 145 + setQuickAuth(apiAgent?.did || null); 143 146 // /*mass comment*/ console.log("Successfully logged in with password."); 144 147 } else { 145 148 throw new Error("Session data not persisted after login."); ··· 147 150 } catch (e) { 148 151 console.error("Password login failed:", e); 149 152 setStatus("signedOut"); 153 + setQuickAuth(null); 150 154 throw e; 151 155 } 152 156 }; ··· 161 165 } 162 166 }, [status]); 163 167 164 - // --- Unified Logout --- 165 168 const logout = useCallback(async () => { 166 169 if (status !== "signedIn" || !agent) return; 167 170 setStatus("loading"); ··· 173 176 } else if (authMethod === "password") { 174 177 localStorage.removeItem("service"); 175 178 localStorage.removeItem("sess"); 176 - // AtpAgent has its own logout methods 177 179 await (agent as AtpAgent).com.atproto.server.deleteSession(); 178 180 // /*mass comment*/ console.log("Password-based session deleted."); 179 181 } ··· 184 186 setAuthMethod(null); 185 187 setOauthSession(null); 186 188 setStatus("signedOut"); 189 + setQuickAuth(null); 187 190 } 188 - }, [status, authMethod, agent, oauthSession]); 191 + }, [status, agent, authMethod, oauthSession, setQuickAuth]); 189 192 190 193 return ( 191 194 <AuthContext
+34 -30
src/routes/__root.tsx
··· 18 18 19 19 import { Composer } from "~/components/Composer"; 20 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 + import { Import } from "~/components/Import"; 21 22 import Login from "~/components/Login"; 22 23 import { NotFound } from "~/components/NotFound"; 24 + import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 23 25 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 24 - import { composerAtom } from "~/utils/atoms"; 26 + import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 25 27 import { seo } from "~/utils/seo"; 26 28 27 29 export const Route = createRootRouteWithContext<{ ··· 87 89 } 88 90 89 91 function RootDocument({ children }: { children: React.ReactNode }) { 92 + useAtomCssVar(hueAtom, "--tw-gray-hue"); 90 93 const location = useLocation(); 91 94 const navigate = useNavigate(); 92 95 const { agent } = useAuth(); ··· 128 131 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 129 132 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 130 133 <div className="flex items-center gap-3 mb-4"> 131 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 134 + <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 132 135 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 133 136 Red Dwarf{" "} 134 137 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 152 155 /> 153 156 154 157 <MaterialNavItem 158 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 159 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 160 + active={locationEnum === "search"} 161 + onClickCallbback={() => 162 + navigate({ 163 + to: "/search", 164 + //params: { did: agent.assertDid }, 165 + }) 166 + } 167 + text="Explore" 168 + /> 169 + <MaterialNavItem 155 170 InactiveIcon={ 156 171 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 157 172 } ··· 178 193 }) 179 194 } 180 195 text="Feeds" 181 - /> 182 - <MaterialNavItem 183 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 184 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 185 - active={locationEnum === "search"} 186 - onClickCallbback={() => 187 - navigate({ 188 - to: "/search", 189 - //params: { did: agent.assertDid }, 190 - }) 191 - } 192 - text="Search" 193 196 /> 194 197 <MaterialNavItem 195 198 InactiveIcon={ ··· 367 370 368 371 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 369 372 <div className="flex items-center gap-3 mb-4"> 370 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-8 h-8" /> 373 + <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 371 374 </div> 372 375 <MaterialNavItem 373 376 small ··· 385 388 text="Home" 386 389 /> 387 390 391 + <MaterialNavItem 392 + small 393 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 394 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 395 + active={locationEnum === "search"} 396 + onClickCallbback={() => 397 + navigate({ 398 + to: "/search", 399 + //params: { did: agent.assertDid }, 400 + }) 401 + } 402 + text="Explore" 403 + /> 388 404 <MaterialNavItem 389 405 small 390 406 InactiveIcon={ ··· 417 433 /> 418 434 <MaterialNavItem 419 435 small 420 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 421 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 422 - active={locationEnum === "search"} 423 - onClickCallbback={() => 424 - navigate({ 425 - to: "/search", 426 - //params: { did: agent.assertDid }, 427 - }) 428 - } 429 - text="Search" 430 - /> 431 - <MaterialNavItem 432 - small 433 436 InactiveIcon={ 434 437 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 435 438 } ··· 496 499 </main> 497 500 498 501 <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> 499 503 <Login /> 500 504 501 505 <div className="flex-1"></div> ··· 549 553 //params: { did: agent.assertDid }, 550 554 }) 551 555 } 552 - text="Search" 556 + text="Explore" 553 557 /> 554 558 {/* <Link 555 559 to="/search" ··· 676 680 ) : ( 677 681 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 678 682 <div className="flex items-center gap-2"> 679 - <img src="/redstar.png" alt="Red Dwarf Logo" className="w-6 h-6" /> 683 + <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 680 684 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 681 685 Red Dwarf{" "} 682 686 {/* <span className="text-gray-500 dark:text-gray-400 text-sm">
+40 -40
src/routes/index.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { useAtom } from "jotai"; 3 3 import * as React from "react"; 4 - import { useEffect, useLayoutEffect } from "react"; 4 + import { useLayoutEffect, useState } from "react"; 5 5 6 6 import { Header } from "~/components/Header"; 7 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 9 import { 10 - agentAtom, 11 - authedAtom, 12 10 feedScrollPositionsAtom, 13 11 isAtTopAtom, 12 + quickAuthAtom, 14 13 selectedFeedUriAtom, 15 - store, 16 14 } from "~/utils/atoms"; 17 15 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 18 16 import { ··· 107 105 } = useAuth(); 108 106 const authed = !!agent?.did; 109 107 110 - useEffect(() => { 111 - if (agent?.did) { 112 - store.set(authedAtom, true); 113 - } else { 114 - store.set(authedAtom, false); 115 - } 116 - }, [status, agent, authed]); 117 - useEffect(() => { 118 - if (agent) { 119 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment 120 - // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 121 - store.set(agentAtom, agent); 122 - } else { 123 - store.set(agentAtom, null); 124 - } 125 - }, [status, agent, authed]); 108 + // i dont remember why this is even here 109 + // useEffect(() => { 110 + // if (agent?.did) { 111 + // store.set(authedAtom, true); 112 + // } else { 113 + // store.set(authedAtom, false); 114 + // } 115 + // }, [status, agent, authed]); 116 + // useEffect(() => { 117 + // if (agent) { 118 + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 119 + // // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 120 + // store.set(agentAtom, agent); 121 + // } else { 122 + // store.set(agentAtom, null); 123 + // } 124 + // }, [status, agent, authed]); 126 125 127 126 //const { get, set } = usePersistentStore(); 128 127 // const [feed, setFeed] = React.useState<any[]>([]); ··· 162 161 163 162 // const savedFeeds = savedFeedsPref?.items || []; 164 163 165 - const identityresultmaybe = useQueryIdentity(agent?.did); 164 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 165 + const isAuthRestoring = quickAuth ? status === "loading" : false; 166 + 167 + const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined); 166 168 const identity = identityresultmaybe?.data; 167 169 168 170 const prefsresultmaybe = useQueryPreferences({ 169 - agent: agent ?? undefined, 170 - pdsUrl: identity?.pds, 171 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 172 + pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined, 171 173 }); 172 174 const prefs = prefsresultmaybe?.data; 173 175 ··· 178 180 return savedFeedsPref?.items || []; 179 181 }, [prefs]); 180 182 181 - const [persistentSelectedFeed, setPersistentSelectedFeed] = 182 - useAtom(selectedFeedUriAtom); // React.useState<string | null>(null); 183 - const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( 184 - persistentSelectedFeed 185 - ); // React.useState<string | null>(null); 183 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 184 + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed); 186 185 const selectedFeed = agent?.did 187 186 ? persistentSelectedFeed 188 187 : unauthedSelectedFeed; ··· 306 305 }, [scrollPositions]); 307 306 308 307 useLayoutEffect(() => { 308 + if (isAuthRestoring) return; 309 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 310 311 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 - }, [selectedFeed]); 313 + }, [selectedFeed, isAuthRestoring]); 314 314 315 315 useLayoutEffect(() => { 316 - if (!selectedFeed) return; 316 + if (!selectedFeed || isAuthRestoring) return; 317 317 318 318 const handleScroll = () => { 319 319 scrollPositionsRef.current = { ··· 328 328 329 329 setScrollPositions(scrollPositionsRef.current); 330 330 }; 331 - }, [selectedFeed, setScrollPositions]); 331 + }, [isAuthRestoring, selectedFeed, setScrollPositions]); 332 332 333 - const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); 334 - const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; 333 + const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined); 334 + const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined; 335 335 336 336 // const { 337 337 // data: feedData, ··· 347 347 348 348 // const feed = feedData?.feed || []; 349 349 350 - const isReadyForAuthedFeed = 351 - authed && agent && identity?.pds && feedServiceDid; 352 - const isReadyForUnauthedFeed = !authed && selectedFeed; 350 + const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 351 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed; 353 352 354 353 355 354 const [isAtTop] = useAtom(isAtTopAtom); ··· 358 357 <div 359 358 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 360 359 > 361 - {savedFeeds.length > 0 ? ( 360 + {!isAuthRestoring && savedFeeds.length > 0 ? ( 362 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`}> 363 362 {savedFeeds.map((item: any, idx: number) => { 364 363 const label = item.value.split("/").pop() || item.value; ··· 410 409 /> 411 410 ))} */} 412 411 413 - {authed && (!identity?.pds || !feedServiceDid) && ( 412 + {isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && ( 414 413 <div className="p-4 text-center text-gray-500"> 415 414 Preparing your feed... 416 415 </div> 417 416 )} 418 417 419 - {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 418 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 420 419 <InfiniteCustomFeed 420 + key={selectedFeed!} 421 421 feedUri={selectedFeed!} 422 422 pdsUrl={identity?.pds} 423 423 feedServiceDid={feedServiceDid} 424 424 /> 425 425 ) : ( 426 426 <div className="p-4 text-center text-gray-500"> 427 - Select a feed to get started. 427 + Loading....... 428 428 </div> 429 429 )} 430 430 {/* {false && restoringScrollPosition && (
+7 -3
src/routes/notifications.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useAtom } from "jotai"; 2 3 import React, { useEffect, useRef,useState } from "react"; 3 4 4 5 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 + import { constellationURLAtom } from "~/utils/atoms"; 5 7 6 8 const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 7 9 ··· 70 72 } 71 73 } 72 74 75 + const [constellationURL] = useAtom(constellationURLAtom) 76 + 73 77 useEffect(() => { 74 78 if (!did) return; 75 79 setLoading(true); 76 80 setError(null); 77 81 const urls = [ 78 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, 79 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, 80 - `https://constellation.microcosm.blue/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, 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`, 81 85 ]; 82 86 let ignore = false; 83 87 Promise.all(
+181 -53
src/routes/profile.$did/index.tsx
··· 1 + import { RichText } from "@atproto/api"; 1 2 import { useQueryClient } from "@tanstack/react-query"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 - import React from "react"; 3 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import React, { type ReactNode, useEffect, useState } from "react"; 4 6 5 7 import { Header } from "~/components/Header"; 6 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 + import { Button } from "~/components/radix-m3-rd/Button"; 9 + import { 10 + renderTextWithFacets, 11 + UniversalPostRendererATURILoader, 12 + } from "~/components/UniversalPostRenderer"; 7 13 import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 - import { toggleFollow, useGetFollowState } from "~/utils/followState"; 14 + import { imgCDNAtom } from "~/utils/atoms"; 15 + import { 16 + toggleFollow, 17 + useGetFollowState, 18 + useGetOneToOneState, 19 + } from "~/utils/followState"; 9 20 import { 10 21 useInfiniteQueryAuthorFeed, 11 22 useQueryIdentity, ··· 19 30 function ProfileComponent() { 20 31 // booo bad this is not always the did it might be a handle, use identity.did instead 21 32 const { did } = Route.useParams(); 33 + const navigate = useNavigate(); 22 34 const queryClient = useQueryClient(); 23 - const { agent } = useAuth(); 24 35 const { 25 36 data: identity, 26 37 isLoading: isIdentityLoading, 27 38 error: identityError, 28 39 } = useQueryIdentity(did); 29 - 30 - const followRecords = useGetFollowState({ 31 - target: identity?.did || did, 32 - user: agent?.did, 33 - }); 34 40 35 41 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 36 42 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 67 73 [postsData] 68 74 ); 69 75 76 + const [imgcdn] = useAtom(imgCDNAtom); 77 + 70 78 function getAvatarUrl(p: typeof profile) { 71 79 const link = p?.avatar?.ref?.["$link"]; 72 80 if (!link || !resolvedDid) return null; 73 - return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 81 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 74 82 } 75 83 function getBannerUrl(p: typeof profile) { 76 84 const link = p?.banner?.ref?.["$link"]; 77 85 if (!link || !resolvedDid) return null; 78 - return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 86 + return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`; 79 87 } 80 88 81 89 const displayName = ··· 162 170 also delay the backfill to be on demand because it would be pretty intense 163 171 also save it persistently 164 172 */} 165 - {identity?.did !== agent?.did ? ( 166 - <> 167 - {!(followRecords?.length && followRecords?.length > 0) ? ( 168 - <button 169 - onClick={() => 170 - toggleFollow({ 171 - agent: agent || undefined, 172 - targetDid: identity?.did, 173 - followRecords: followRecords, 174 - queryClient: queryClient, 175 - }) 176 - } 177 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 178 - > 179 - Follow 180 - </button> 181 - ) : ( 182 - <button 183 - onClick={() => 184 - toggleFollow({ 185 - agent: agent || undefined, 186 - targetDid: identity?.did, 187 - followRecords: followRecords, 188 - queryClient: queryClient, 189 - }) 190 - } 191 - className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 192 - > 193 - Unfollow 194 - </button> 195 - )} 196 - </> 197 - ) : ( 198 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 199 - Edit Profile 200 - </button> 201 - )} 202 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 173 + <FollowButton targetdidorhandle={did} /> 174 + <Button className="rounded-full" variant={"secondary"}> 203 175 ... {/* todo: icon */} 204 - </button> 176 + </Button> 205 177 </div> 206 178 207 179 {/* Info Card */} 208 180 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 209 181 <div className="font-bold text-2xl">{displayName}</div> 210 - <div className="text-gray-500 dark:text-gray-400 text-base mb-3"> 182 + <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 183 + <Mutual targetdidorhandle={did} /> 211 184 {handle} 212 185 </div> 213 186 {description && ( 214 187 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 215 - {description} 188 + {/* {description} */} 189 + <RichTextRenderer key={did} description={description} /> 216 190 </div> 217 191 )} 218 192 </div> ··· 255 229 </> 256 230 ); 257 231 } 232 + 233 + export function FollowButton({ 234 + targetdidorhandle, 235 + }: { 236 + targetdidorhandle: string; 237 + }) { 238 + const { agent } = useAuth(); 239 + const { data: identity } = useQueryIdentity(targetdidorhandle); 240 + const queryClient = useQueryClient(); 241 + 242 + const followRecords = useGetFollowState({ 243 + target: identity?.did ?? targetdidorhandle, 244 + user: agent?.did, 245 + }); 246 + 247 + return ( 248 + <> 249 + {identity?.did !== agent?.did ? ( 250 + <> 251 + {!(followRecords?.length && followRecords?.length > 0) ? ( 252 + <Button 253 + onClick={(e) => { 254 + e.stopPropagation(); 255 + toggleFollow({ 256 + agent: agent || undefined, 257 + targetDid: identity?.did, 258 + followRecords: followRecords, 259 + queryClient: queryClient, 260 + }); 261 + }} 262 + > 263 + Follow 264 + </Button> 265 + ) : ( 266 + <Button 267 + onClick={(e) => { 268 + e.stopPropagation(); 269 + toggleFollow({ 270 + agent: agent || undefined, 271 + targetDid: identity?.did, 272 + followRecords: followRecords, 273 + queryClient: queryClient, 274 + }); 275 + }} 276 + > 277 + Unfollow 278 + </Button> 279 + )} 280 + </> 281 + ) : ( 282 + <Button variant={"secondary"}>Edit Profile</Button> 283 + )} 284 + </> 285 + ); 286 + } 287 + 288 + export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { 289 + const { agent } = useAuth(); 290 + const { data: identity } = useQueryIdentity(targetdidorhandle); 291 + 292 + const theyFollowYouRes = useGetOneToOneState( 293 + agent?.did 294 + ? { 295 + target: agent?.did, 296 + user: identity?.did ?? targetdidorhandle, 297 + collection: "app.bsky.graph.follow", 298 + path: ".subject", 299 + } 300 + : undefined 301 + ); 302 + 303 + const youFollowThemRes = useGetFollowState({ 304 + target: identity?.did ?? targetdidorhandle, 305 + user: agent?.did, 306 + }); 307 + 308 + const theyFollowYou: boolean = 309 + !!theyFollowYouRes?.length && theyFollowYouRes.length > 0; 310 + const youFollowThem: boolean = 311 + !!youFollowThemRes?.length && youFollowThemRes.length > 0; 312 + 313 + return ( 314 + <> 315 + {/* if not self */} 316 + {identity?.did !== agent?.did ? ( 317 + <> 318 + {theyFollowYou ? ( 319 + <> 320 + {youFollowThem ? ( 321 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 322 + mutuals 323 + </div> 324 + ) : ( 325 + <div className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center"> 326 + follows you 327 + </div> 328 + )} 329 + </> 330 + ) : ( 331 + <></> 332 + )} 333 + </> 334 + ) : ( 335 + // lmao can someone be mutuals with themselves ?? 336 + <></> 337 + )} 338 + </> 339 + ); 340 + } 341 + 342 + export function RichTextRenderer({ description }: { description: string }) { 343 + const [richDescription, setRichDescription] = useState<string | ReactNode[]>( 344 + description 345 + ); 346 + const { agent } = useAuth(); 347 + const navigate = useNavigate(); 348 + 349 + useEffect(() => { 350 + let mounted = true; 351 + 352 + // setRichDescription(description); 353 + 354 + async function processRichText() { 355 + try { 356 + if (!agent?.did) return; 357 + const rt = new RichText({ text: description }); 358 + await rt.detectFacets(agent); 359 + 360 + if (!mounted) return; 361 + 362 + if (rt.facets) { 363 + setRichDescription( 364 + renderTextWithFacets({ text: rt.text, facets: rt.facets, navigate }) 365 + ); 366 + } else { 367 + setRichDescription(rt.text); 368 + } 369 + } catch (error) { 370 + console.error("Failed to detect facets:", error); 371 + if (mounted) { 372 + setRichDescription(description); 373 + } 374 + } 375 + } 376 + 377 + processRichText(); 378 + 379 + return () => { 380 + mounted = false; 381 + }; 382 + }, [description, agent, navigate]); 383 + 384 + return <>{richDescription}</>; 385 + }
+1 -1
src/routes/profile.$did/post.$rkey.image.$i.tsx
··· 85 85 e.stopPropagation(); 86 86 e.nativeEvent.stopImmediatePropagation(); 87 87 }} 88 - className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 88 + className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 89 > 90 90 <ProfilePostComponent 91 91 key={`/profile/${did}/post/${rkey}`}
+9 -1
src/routes/profile.$did/post.$rkey.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3 3 import { createFileRoute, Outlet } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 4 5 import React, { useLayoutEffect } from "react"; 5 6 6 7 import { Header } from "~/components/Header"; 7 8 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 9 + import { constellationURLAtom, slingshotURLAtom } from "~/utils/atoms"; 8 10 //import { usePersistentStore } from '~/providers/PersistentStoreProvider'; 9 11 import { 10 12 constructPostQuery, ··· 275 277 // path: ".reply.parent.uri", 276 278 // }); 277 279 // const replies = repliesData?.linking_records.slice(0, 50) ?? []; 280 + const [constellationurl] = useAtom(constellationURLAtom) 281 + 278 282 const infinitequeryresults = useInfiniteQuery({ 279 283 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 280 284 { 285 + constellation: constellationurl, 281 286 method: "/links", 282 287 target: atUri, 283 288 collection: "app.bsky.feed.post", ··· 386 391 } 387 392 }, [parents, layoutReady]); 388 393 394 + 395 + const [slingshoturl] = useAtom(slingshotURLAtom) 396 + 389 397 React.useEffect(() => { 390 398 if (parentsLoading) { 391 399 setLayoutReady(false); ··· 414 422 while (currentParentUri && safetyCounter < MAX_PARENTS) { 415 423 try { 416 424 const parentPost = await queryClient.fetchQuery( 417 - constructPostQuery(currentParentUri) 425 + constructPostQuery(currentParentUri, slingshoturl) 418 426 ); 419 427 if (!parentPost) break; 420 428 parentChain.push(parentPost);
+50 -1
src/routes/search.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + import { Import } from "~/components/Import"; 5 + 3 6 export const Route = createFileRoute("/search")({ 4 7 component: Search, 5 8 }); 6 9 7 10 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 11 + return ( 12 + <> 13 + <Header 14 + title="Explore" 15 + backButtonCallback={() => { 16 + if (window.history.length > 1) { 17 + window.history.back(); 18 + } else { 19 + window.location.assign("/"); 20 + } 21 + }} 22 + /> 23 + <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 + <Import /> 25 + <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> 30 + <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 + <li> 32 + Bluesky URLs from supported clients (like{" "} 33 + <code className="text-sm">bsky.app</code> or{" "} 34 + <code className="text-sm">deer.social</code>). 35 + </li> 36 + <li> 37 + AT-URIs (e.g.,{" "} 38 + <code className="text-sm">at://did:example/collection/item</code> 39 + ). 40 + </li> 41 + <li> 42 + Plain handles (like{" "} 43 + <code className="text-sm">@username.bsky.social</code>). 44 + </li> 45 + <li> 46 + Direct DIDs (Decentralized Identifiers, starting with{" "} 47 + <code className="text-sm">did:</code>). 48 + </li> 49 + </ul> 50 + <p className="mt-2 text-gray-600 dark:text-gray-400"> 51 + Simply paste one of these into the import field above and press 52 + Enter to load the content. 53 + </p> 54 + </div> 55 + </div> 56 + </> 57 + ); 9 58 }
+164 -1
src/routes/settings.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useAtom } from "jotai"; 3 + import { Slider } from "radix-ui"; 2 4 3 5 import { Header } from "~/components/Header"; 4 6 import Login from "~/components/Login"; 7 + import { 8 + constellationURLAtom, 9 + defaultconstellationURL, 10 + defaulthue, 11 + defaultImgCDN, 12 + defaultslingshotURL, 13 + defaultVideoCDN, 14 + hueAtom, 15 + imgCDNAtom, 16 + slingshotURLAtom, 17 + videoCDNAtom, 18 + } from "~/utils/atoms"; 5 19 6 20 export const Route = createFileRoute("/settings")({ 7 21 component: Settings, ··· 20 34 } 21 35 }} 22 36 /> 23 - <Login /> 37 + <div className="lg:hidden"> 38 + <Login /> 39 + </div> 40 + <div className="h-4" /> 41 + <TextInputSetting 42 + atom={constellationURLAtom} 43 + title={"Constellation"} 44 + description={ 45 + "Customize the Constellation instance to be used by Red Dwarf" 46 + } 47 + init={defaultconstellationURL} 48 + /> 49 + <TextInputSetting 50 + atom={slingshotURLAtom} 51 + title={"Slingshot"} 52 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 53 + init={defaultslingshotURL} 54 + /> 55 + <TextInputSetting 56 + atom={imgCDNAtom} 57 + title={"Image CDN"} 58 + description={ 59 + "Customize the Constellation instance to be used by Red Dwarf" 60 + } 61 + init={defaultImgCDN} 62 + /> 63 + <TextInputSetting 64 + atom={videoCDNAtom} 65 + title={"Video CDN"} 66 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 67 + init={defaultVideoCDN} 68 + /> 69 + 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 73 + </p> 24 74 </> 25 75 ); 26 76 } 77 + function Hue() { 78 + const [hue, setHue] = useAtom(hueAtom); 79 + 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 + /> 87 + <button 88 + onClick={() => setHue(defaulthue ?? 28)} 89 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 90 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 91 + > 92 + Reset 93 + </button> 94 + </div> 95 + </div> 96 + ); 97 + } 98 + 99 + export function TextInputSetting({ 100 + atom, 101 + title, 102 + description, 103 + init, 104 + }: { 105 + atom: typeof constellationURLAtom; 106 + title?: string; 107 + description?: string; 108 + init?: string; 109 + }) { 110 + const [value, setValue] = useAtom(atom); 111 + return ( 112 + <div className="flex flex-col gap-2 px-4 py-2"> 113 + {/* <div> 114 + {title && ( 115 + <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> 116 + {title} 117 + </h3> 118 + )} 119 + {description && ( 120 + <p className="text-sm text-gray-500 dark:text-gray-400"> 121 + {description} 122 + </p> 123 + )} 124 + </div> */} 125 + 126 + <div className="flex flex-row gap-2 items-center"> 127 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 128 + <input 129 + type="text" 130 + placeholder=" " 131 + value={value} 132 + onChange={(e) => setValue(e.target.value)} 133 + /> 134 + <label>{title}</label> 135 + </div> 136 + {/* <input 137 + type="text" 138 + value={value} 139 + onChange={(e) => setValue(e.target.value)} 140 + className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 141 + text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 142 + focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600" 143 + placeholder="Enter value..." 144 + /> */} 145 + <button 146 + onClick={() => setValue(init ?? "")} 147 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 148 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 149 + > 150 + Reset 151 + </button> 152 + </div> 153 + </div> 154 + ); 155 + } 156 + 157 + 158 + interface SliderProps { 159 + atom: typeof hueAtom; 160 + min?: number; 161 + max?: number; 162 + step?: number; 163 + } 164 + 165 + export const SliderComponent: React.FC<SliderProps> = ({ 166 + atom, 167 + min = 0, 168 + max = 100, 169 + step = 1, 170 + }) => { 171 + 172 + const [value, setValue] = useAtom(atom) 173 + 174 + return ( 175 + <Slider.Root 176 + className="relative flex items-center w-full h-4" 177 + value={[value]} 178 + min={min} 179 + max={max} 180 + step={step} 181 + onValueChange={(v: number[]) => setValue(v[0])} 182 + > 183 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 184 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 185 + </Slider.Track> 186 + <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 + </Slider.Root> 188 + ); 189 + };
+139 -13
src/styles/app.css
··· 15 15 --color-gray-950: oklch(0.129 0.050 222.000); 16 16 } */ 17 17 18 + :root { 19 + --safe-hue: var(--tw-gray-hue, 28) 20 + } 21 + 18 22 @theme { 19 - --color-gray-50: oklch(0.984 0.012 28); 20 - --color-gray-100: oklch(0.968 0.017 28); 21 - --color-gray-200: oklch(0.929 0.025 28); 22 - --color-gray-300: oklch(0.869 0.035 28); 23 - --color-gray-400: oklch(0.704 0.05 28); 24 - --color-gray-500: oklch(0.554 0.06 28); 25 - --color-gray-600: oklch(0.446 0.058 28); 26 - --color-gray-700: oklch(0.372 0.058 28); 27 - --color-gray-800: oklch(0.279 0.055 28); 28 - --color-gray-900: oklch(0.208 0.055 28); 29 - --color-gray-950: oklch(0.129 0.055 28); 23 + --color-gray-50: oklch(0.984 0.012 var(--safe-hue)); 24 + --color-gray-100: oklch(0.968 0.017 var(--safe-hue)); 25 + --color-gray-200: oklch(0.929 0.025 var(--safe-hue)); 26 + --color-gray-300: oklch(0.869 0.035 var(--safe-hue)); 27 + --color-gray-400: oklch(0.704 0.05 var(--safe-hue)); 28 + --color-gray-500: oklch(0.554 0.06 var(--safe-hue)); 29 + --color-gray-600: oklch(0.446 0.058 var(--safe-hue)); 30 + --color-gray-700: oklch(0.372 0.058 var(--safe-hue)); 31 + --color-gray-800: oklch(0.279 0.055 var(--safe-hue)); 32 + --color-gray-900: oklch(0.208 0.055 var(--safe-hue)); 33 + --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 30 34 } 31 35 32 36 @layer base { ··· 48 52 } 49 53 } 50 54 55 + .gutter{ 56 + scrollbar-gutter: stable both-edges; 57 + } 58 + 51 59 @media (width >= 64rem /* 1024px */) { 52 60 html:not(:has(.disablegutter)), 53 61 body:not(:has(.disablegutter)) { 54 62 scrollbar-gutter: stable both-edges !important; 55 63 } 56 - html:has(.disablegutter), 57 - body:has(.disablegutter) { 64 + html:has(.disablescroll), 65 + body:has(.disablescroll) { 58 66 scrollbar-width: none; 59 67 overflow-y: hidden; 60 68 } ··· 105 113 :root { 106 114 --shadow-opacity: calc(1 - var(--is-top)); 107 115 --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 116 + } 117 + 118 + 119 + /* m3 input */ 120 + :root { 121 + --m3input-radius: 6px; 122 + --m3input-border-width: .0625rem; 123 + --m3input-font-size: 16px; 124 + --m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1); 125 + /* light theme */ 126 + --m3input-bg: var(--color-gray-50); 127 + --m3input-border-color: var(--color-gray-400); 128 + --m3input-label-color: var(--color-gray-500); 129 + --m3input-text-color: var(--color-gray-900); 130 + --m3input-focus-color: var(--color-gray-600); 131 + } 132 + 133 + @media (prefers-color-scheme: dark) { 134 + :root { 135 + --m3input-bg: var(--color-gray-950); 136 + --m3input-border-color: var(--color-gray-700); 137 + --m3input-label-color: var(--color-gray-400); 138 + --m3input-text-color: var(--color-gray-50); 139 + --m3input-focus-color: var(--color-gray-400); 140 + } 141 + } 142 + 143 + /* reset page *//* 144 + html, 145 + body { 146 + background: var(--m3input-bg); 147 + margin: 0; 148 + padding: 1rem; 149 + color: var(--m3input-text-color); 150 + font-family: system-ui, sans-serif; 151 + font-size: var(--m3input-font-size); 152 + }*/ 153 + 154 + /* base wrapper */ 155 + .m3input-field.m3input-label.m3input-border { 156 + position: relative; 157 + display: inline-block; 158 + width: 100%; 159 + /*max-width: 400px;*/ 160 + } 161 + 162 + /* size variants */ 163 + .m3input-field.size-sm { 164 + --m3input-h: 40px; 165 + } 166 + 167 + .m3input-field.size-md { 168 + --m3input-h: 48px; 169 + } 170 + 171 + .m3input-field.size-lg { 172 + --m3input-h: 56px; 173 + } 174 + 175 + .m3input-field.size-xl { 176 + --m3input-h: 64px; 177 + } 178 + 179 + .m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) { 180 + --m3input-h: 48px; 181 + } 182 + 183 + /* outlined input */ 184 + .m3input-field.m3input-label.m3input-border input { 185 + width: 100%; 186 + height: var(--m3input-h); 187 + border: var(--m3input-border-width) solid var(--m3input-border-color); 188 + border-radius: var(--m3input-radius); 189 + background: var(--m3input-bg); 190 + color: var(--m3input-text-color); 191 + font-size: var(--m3input-font-size); 192 + padding: 0 12px; 193 + box-sizing: border-box; 194 + outline: none; 195 + transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition); 196 + } 197 + 198 + /* focus ring */ 199 + .m3input-field.m3input-label.m3input-border input:focus { 200 + border-color: var(--m3input-focus-color); 201 + /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 202 + } 203 + 204 + /* label */ 205 + .m3input-field.m3input-label.m3input-border label { 206 + position: absolute; 207 + left: 12px; 208 + top: 50%; 209 + transform: translateY(-50%); 210 + background: var(--m3input-bg); 211 + padding: 0 .25em; 212 + color: var(--m3input-label-color); 213 + pointer-events: none; 214 + transition: all var(--m3input-transition); 215 + } 216 + 217 + /* float on focus or when filled */ 218 + .m3input-field.m3input-label.m3input-border input:focus+label, 219 + .m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label { 220 + top: 0; 221 + transform: translateY(-50%) scale(.78); 222 + left: 0; 223 + color: var(--m3input-focus-color); 224 + } 225 + 226 + /* placeholder trick */ 227 + .m3input-field.m3input-label.m3input-border input::placeholder { 228 + color: transparent; 229 + } 230 + 231 + /* radix i love you but like cmon man */ 232 + body[data-scroll-locked]{ 233 + margin-left: var(--removed-body-scroll-bar-size) !important; 108 234 }
+61 -13
src/utils/atoms.ts
··· 1 - import type Agent from "@atproto/api"; 2 - import { atom, createStore } from "jotai"; 3 - import { atomWithStorage } from 'jotai/utils'; 1 + import { atom, createStore, useAtomValue } from "jotai"; 2 + import { atomWithStorage } from "jotai/utils"; 3 + import { useEffect } from "react"; 4 4 5 5 export const store = createStore(); 6 6 7 + export const quickAuthAtom = atomWithStorage<string | null>( 8 + "quickAuth", 9 + null 10 + ); 11 + 7 12 export const selectedFeedUriAtom = atomWithStorage<string | null>( 8 - 'selectedFeedUri', 13 + "selectedFeedUri", 9 14 null 10 15 ); 11 16 12 17 //export const feedScrollPositionsAtom = atom<Record<string, number>>({}); 13 18 14 19 export const feedScrollPositionsAtom = atomWithStorage<Record<string, number>>( 15 - 'feedscrollpositions', 20 + "feedscrollpositions", 16 21 {} 17 22 ); 18 23 19 24 export const likedPostsAtom = atomWithStorage<Record<string, string>>( 20 - 'likedPosts', 25 + "likedPosts", 21 26 {} 22 27 ); 23 28 29 + export const defaultconstellationURL = "constellation.microcosm.blue"; 30 + export const constellationURLAtom = atomWithStorage<string>( 31 + "constellationURL", 32 + defaultconstellationURL 33 + ); 34 + export const defaultslingshotURL = "slingshot.microcosm.blue"; 35 + export const slingshotURLAtom = atomWithStorage<string>( 36 + "slingshotURL", 37 + defaultslingshotURL 38 + ); 39 + export const defaultImgCDN = "cdn.bsky.app"; 40 + export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 41 + export const defaultVideoCDN = "video.bsky.app"; 42 + export const videoCDNAtom = atomWithStorage<string>( 43 + "videocdnurl", 44 + defaultVideoCDN 45 + ); 46 + 47 + export const defaulthue = 28; 48 + export const hueAtom = atomWithStorage<number>("hue", defaulthue); 49 + 24 50 export const isAtTopAtom = atom<boolean>(true); 25 51 26 52 type ComposerState = 27 - | { kind: 'closed' } 28 - | { kind: 'root' } 29 - | { kind: 'reply'; parent: string } 30 - | { kind: 'quote'; subject: string }; 31 - export const composerAtom = atom<ComposerState>({ kind: 'closed' }); 53 + | { kind: "closed" } 54 + | { kind: "root" } 55 + | { kind: "reply"; parent: string } 56 + | { kind: "quote"; subject: string }; 57 + export const composerAtom = atom<ComposerState>({ kind: "closed" }); 58 + 59 + //export const agentAtom = atom<Agent | null>(null); 60 + //export const authedAtom = atom<boolean>(false); 61 + 62 + export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 63 + const value = useAtomValue(atom); 64 + 65 + useEffect(() => { 66 + document.documentElement.style.setProperty(cssVar, value.toString()); 67 + }, [value, cssVar]); 68 + 69 + useEffect(() => { 70 + document.documentElement.style.setProperty(cssVar, value.toString()); 71 + }, []); 72 + } 32 73 33 - export const agentAtom = atom<Agent|null>(null); 34 - export const authedAtom = atom<boolean>(false); 74 + hueAtom.onMount = (setAtom) => { 75 + const stored = localStorage.getItem("hue"); 76 + if (stored != null) setAtom(Number(stored)); 77 + }; 78 + // export function initAtomToCssVar(atom: typeof hueAtom, cssVar: string) { 79 + // const initial = store.get(atom); 80 + // console.log("atom get ", initial); 81 + // document.documentElement.style.setProperty(cssVar, initial.toString()); 82 + // }
+33
src/utils/followState.ts
··· 128 128 }; 129 129 }); 130 130 } 131 + 132 + 133 + 134 + export function useGetOneToOneState(params?: { 135 + target: string; 136 + user: string; 137 + collection: string; 138 + path: string; 139 + }): string[] | undefined { 140 + const { data: arbitrarydata } = useQueryConstellation( 141 + params && params.user 142 + ? { 143 + method: "/links", 144 + target: params.target, 145 + // @ts-expect-error overloading sucks so much 146 + collection: params.collection, 147 + path: params.path, 148 + dids: [params.user], 149 + } 150 + : { method: "undefined", target: "whatever" } 151 + // overloading sucks so much 152 + ) as { data: linksRecordsResponse | undefined }; 153 + if (!params || !params.user) return undefined; 154 + const data = arbitrarydata?.linking_records.slice(0, 50) ?? []; 155 + 156 + if (data.length > 0) { 157 + return data.map((linksRecord) => { 158 + return `at://${linksRecord.did}/${linksRecord.collection}/${linksRecord.rkey}`; 159 + }); 160 + } 161 + 162 + return undefined; 163 + }
+53 -23
src/utils/useHydrated.ts
··· 9 9 AppBskyFeedPost, 10 10 AtUri, 11 11 } from "@atproto/api"; 12 + import { useAtom } from "jotai"; 12 13 import { useMemo } from "react"; 13 14 14 - import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery"; 15 + import { imgCDNAtom, videoCDNAtom } from "./atoms"; 16 + import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery"; 15 17 16 - type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends 17 - | { data: infer D } 18 - | undefined 19 - ? D 20 - : never; 18 + type QueryResultData<T extends (...args: any) => any> = 19 + ReturnType<T> extends { data: infer D } | undefined ? D : never; 21 20 22 21 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 23 22 return obj as $Typed<T>; ··· 26 25 export function hydrateEmbedImages( 27 26 embed: AppBskyEmbedImages.Main, 28 27 did: string, 28 + cdn: string 29 29 ): $Typed<AppBskyEmbedImages.View> { 30 30 return asTyped({ 31 31 $type: "app.bsky.embed.images#view" as const, ··· 34 34 const link = img.image.ref?.["$link"]; 35 35 if (!link) return null; 36 36 return { 37 - thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 - fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`, 37 + thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 + fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 39 alt: img.alt || "", 40 40 aspectRatio: img.aspectRatio, 41 41 }; ··· 47 47 export function hydrateEmbedExternal( 48 48 embed: AppBskyEmbedExternal.Main, 49 49 did: string, 50 + cdn: string 50 51 ): $Typed<AppBskyEmbedExternal.View> { 51 52 return asTyped({ 52 53 $type: "app.bsky.embed.external#view" as const, ··· 55 56 title: embed.external.title, 56 57 description: embed.external.description, 57 58 thumb: embed.external.thumb?.ref?.$link 58 - ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 + ? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 60 : undefined, 60 61 }, 61 62 }); ··· 64 65 export function hydrateEmbedVideo( 65 66 embed: AppBskyEmbedVideo.Main, 66 67 did: string, 68 + videocdn: string 67 69 ): $Typed<AppBskyEmbedVideo.View> { 68 70 const videoLink = embed.video.ref.$link; 69 71 return asTyped({ 70 72 $type: "app.bsky.embed.video#view" as const, 71 - playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`, 72 - thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`, 73 + playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`, 74 + thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`, 73 75 aspectRatio: embed.aspectRatio, 74 76 cid: videoLink, 75 77 }); ··· 80 82 quotedPost: QueryResultData<typeof useQueryPost>, 81 83 quotedProfile: QueryResultData<typeof useQueryProfile>, 82 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 + cdn: string 83 86 ): $Typed<AppBskyEmbedRecord.View> | undefined { 84 87 if (!quotedPost || !quotedProfile || !quotedIdentity) { 85 88 return undefined; ··· 91 94 handle: quotedIdentity.handle, 92 95 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 93 96 avatar: quotedProfile.value.avatar?.ref?.$link 94 - ? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 97 + ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 95 98 : undefined, 96 99 viewer: {}, 97 100 labels: [], ··· 122 125 quotedPost: QueryResultData<typeof useQueryPost>, 123 126 quotedProfile: QueryResultData<typeof useQueryProfile>, 124 127 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 128 + cdn: string 125 129 ): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 126 130 const hydratedRecord = hydrateEmbedRecord( 127 131 embed.record, 128 132 quotedPost, 129 133 quotedProfile, 130 134 quotedIdentity, 135 + cdn 131 136 ); 132 137 133 138 if (!hydratedRecord) return undefined; ··· 148 153 149 154 export function useHydratedEmbed( 150 155 embed: AppBskyFeedPost.Record["embed"], 151 - postAuthorDid: string | undefined, 156 + postAuthorDid: string | undefined 152 157 ) { 153 158 const recordInfo = useMemo(() => { 154 159 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { ··· 181 186 error: profileError, 182 187 } = useQueryProfile(profileUri); 183 188 189 + const [imgcdn] = useAtom(imgCDNAtom); 190 + const [videocdn] = useAtom(videoCDNAtom); 191 + 184 192 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 185 193 186 194 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 187 195 if (!embed || !postAuthorDid) return undefined; 188 196 189 - if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) { 197 + if ( 198 + isRecordType && 199 + (!usequerypostresults?.data || 200 + !quotedProfile || 201 + !queryidentityresult?.data) 202 + ) { 190 203 return undefined; 191 204 } 192 205 193 206 try { 194 207 if (AppBskyEmbedImages.isMain(embed)) { 195 - return hydrateEmbedImages(embed, postAuthorDid); 208 + return hydrateEmbedImages(embed, postAuthorDid, imgcdn); 196 209 } else if (AppBskyEmbedExternal.isMain(embed)) { 197 - return hydrateEmbedExternal(embed, postAuthorDid); 210 + return hydrateEmbedExternal(embed, postAuthorDid, imgcdn); 198 211 } else if (AppBskyEmbedVideo.isMain(embed)) { 199 - return hydrateEmbedVideo(embed, postAuthorDid); 212 + return hydrateEmbedVideo(embed, postAuthorDid, videocdn); 200 213 } else if (AppBskyEmbedRecord.isMain(embed)) { 201 214 return hydrateEmbedRecord( 202 215 embed, 203 216 usequerypostresults?.data, 204 217 quotedProfile, 205 218 queryidentityresult?.data, 219 + imgcdn 206 220 ); 207 221 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 208 222 let hydratedMedia: ··· 212 226 | undefined; 213 227 214 228 if (AppBskyEmbedImages.isMain(embed.media)) { 215 - hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid); 229 + hydratedMedia = hydrateEmbedImages( 230 + embed.media, 231 + postAuthorDid, 232 + imgcdn 233 + ); 216 234 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 217 - hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid); 235 + hydratedMedia = hydrateEmbedExternal( 236 + embed.media, 237 + postAuthorDid, 238 + imgcdn 239 + ); 218 240 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 219 - hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid); 241 + hydratedMedia = hydrateEmbedVideo( 242 + embed.media, 243 + postAuthorDid, 244 + videocdn 245 + ); 220 246 } 221 247 222 248 if (hydratedMedia) { ··· 226 252 usequerypostresults?.data, 227 253 quotedProfile, 228 254 queryidentityresult?.data, 255 + imgcdn 229 256 ); 230 257 } 231 258 } ··· 236 263 })(); 237 264 238 265 const isLoading = isRecordType 239 - ? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading 266 + ? usequerypostresults?.isLoading || 267 + isLoadingProfile || 268 + queryidentityresult?.isLoading 240 269 : false; 241 270 242 - const error = usequerypostresults?.error || profileError || queryidentityresult?.error; 271 + const error = 272 + usequerypostresults?.error || profileError || queryidentityresult?.error; 243 273 244 274 return { data: hydratedEmbed, isLoading, error }; 245 - } 275 + }
+27 -18
src/utils/useQuery.ts
··· 6 6 useInfiniteQuery, 7 7 useQuery, 8 8 type UseQueryResult} from "@tanstack/react-query"; 9 + import { useAtom } from "jotai"; 9 10 10 - export function constructIdentityQuery(didorhandle?: string) { 11 + import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 + 13 + export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 11 14 return queryOptions({ 12 15 queryKey: ["identity", didorhandle], 13 16 queryFn: async () => { 14 17 if (!didorhandle) return undefined as undefined 15 18 const res = await fetch( 16 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 19 + `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 17 20 ); 18 21 if (!res.ok) throw new Error("Failed to fetch post"); 19 22 try { ··· 55 58 Error 56 59 > 57 60 export function useQueryIdentity(didorhandle?: string) { 58 - return useQuery(constructIdentityQuery(didorhandle)); 61 + const [slingshoturl] = useAtom(slingshotURLAtom) 62 + return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 59 63 } 60 64 61 - export function constructPostQuery(uri?: string) { 65 + export function constructPostQuery(uri?: string, slingshoturl?: string) { 62 66 return queryOptions({ 63 67 queryKey: ["post", uri], 64 68 queryFn: async () => { 65 69 if (!uri) return undefined as undefined 66 70 const res = await fetch( 67 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 71 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 68 72 ); 69 73 let data: any; 70 74 try { ··· 118 122 Error 119 123 > 120 124 export function useQueryPost(uri?: string) { 121 - return useQuery(constructPostQuery(uri)); 125 + const [slingshoturl] = useAtom(slingshotURLAtom) 126 + return useQuery(constructPostQuery(uri, slingshoturl)); 122 127 } 123 128 124 - export function constructProfileQuery(uri?: string) { 129 + export function constructProfileQuery(uri?: string, slingshoturl?: string) { 125 130 return queryOptions({ 126 131 queryKey: ["profile", uri], 127 132 queryFn: async () => { 128 133 if (!uri) return undefined as undefined 129 134 const res = await fetch( 130 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 135 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 131 136 ); 132 137 let data: any; 133 138 try { ··· 181 186 Error 182 187 > 183 188 export function useQueryProfile(uri?: string) { 184 - return useQuery(constructProfileQuery(uri)); 189 + const [slingshoturl] = useAtom(slingshotURLAtom) 190 + return useQuery(constructProfileQuery(uri, slingshoturl)); 185 191 } 186 192 187 193 // export function constructConstellationQuery( ··· 217 223 // target: string 218 224 // ): QueryOptions<linksAllResponse, Error>; 219 225 export function constructConstellationQuery(query?:{ 226 + constellation: string, 220 227 method: 221 228 | "/links" 222 229 | "/links/distinct-dids" ··· 250 257 const cursor = query.cursor 251 258 const dids = query?.dids 252 259 const res = await fetch( 253 - `https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 260 + `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("") : ""}` 254 261 ); 255 262 if (!res.ok) throw new Error("Failed to fetch post"); 256 263 try { ··· 339 346 > 340 347 | undefined { 341 348 //if (!query) return; 349 + const [constellationurl] = useAtom(constellationURLAtom) 342 350 return useQuery( 343 - constructConstellationQuery(query) 351 + constructConstellationQuery(query && {constellation: constellationurl, ...query}) 344 352 ); 345 353 } 346 354 ··· 445 453 446 454 447 455 448 - export function constructArbitraryQuery(uri?: string) { 456 + export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 449 457 return queryOptions({ 450 458 queryKey: ["arbitrary", uri], 451 459 queryFn: async () => { 452 460 if (!uri) return undefined as undefined 453 461 const res = await fetch( 454 - `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 462 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 455 463 ); 456 464 let data: any; 457 465 try { ··· 504 512 Error 505 513 >; 506 514 export function useQueryArbitrary(uri?: string) { 507 - return useQuery(constructArbitraryQuery(uri)); 515 + const [slingshoturl] = useAtom(slingshotURLAtom) 516 + return useQuery(constructArbitraryQuery(uri, slingshoturl)); 508 517 } 509 518 510 519 export function constructFallbackNothingQuery(){ ··· 606 615 }) { 607 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 608 617 609 - return useInfiniteQuery({ 618 + return {...useInfiniteQuery({ 610 619 queryKey, 611 620 queryFn, 612 621 initialPageParam: undefined as never, ··· 614 623 staleTime: Infinity, 615 624 refetchOnWindowFocus: false, 616 625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 617 - }); 626 + }), queryKey: queryKey}; 618 627 } 619 628 620 629 621 630 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 631 + constellation: string, 622 632 method: '/links' 623 633 target?: string 624 634 collection: string 625 635 path: string 626 636 }) { 627 - const constellationHost = 'constellation.microcosm.blue' 628 637 console.log( 629 638 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 630 639 query, ··· 650 659 const cursor = pageParam 651 660 652 661 const res = await fetch( 653 - `https://${constellationHost}${method}?target=${encodeURIComponent(target)}${ 662 + `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 654 663 collection ? `&collection=${encodeURIComponent(collection)}` : '' 655 664 }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 656 665 cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
+1 -1
vite.config.ts
··· 10 10 11 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 12 13 - const PROD_URL = "https://reddwarf.whey.party" 13 + const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 16 function shp(url: string): string {