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 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 - const PROD_URL = "https://reddwarf.whey.party" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
··· 8 ## running dev and build 9 in the `vite.config.ts` file you should change these values 10 ```ts 11 + const PROD_URL = "https://reddwarf.app" 12 const DEV_URL = "https://local3768forumtest.whey.party" 13 ``` 14 the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work
+180 -118
src/components/Composer.tsx
··· 1 - import { RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 import { Dialog } from "radix-ui"; 4 import { useEffect, useRef, useState } from "react"; ··· 8 import { useQueryPost } from "~/utils/useQuery"; 9 10 import { ProfileThing } from "./Login"; 11 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 13 const MAX_POST_LENGTH = 300; 14 15 export function Composer() { 16 const [composerState, setComposerState] = useAtom(composerAtom); 17 const { agent } = useAuth(); 18 19 const [postText, setPostText] = useState(""); ··· 47 try { 48 const rt = new RichText({ text: postText }); 49 await rt.detectFacets(agent); 50 51 const record: Record<string, unknown> = { 52 $type: "app.bsky.feed.post", ··· 97 setPosting(false); 98 } 99 } 100 - // if (composerState.kind === "closed") { 101 - // return null; 102 - // } 103 104 const getPlaceholder = () => { 105 switch (composerState.kind) { ··· 117 const isPostButtonDisabled = 118 posting || !postText.trim() || isParentLoading || charsLeft < 0; 119 120 return ( 121 - <Dialog.Root 122 - open={composerState.kind !== "closed"} 123 - onOpenChange={(open) => { 124 - if (!open) setComposerState({ kind: "closed" }); 125 - }} 126 - > 127 - <Dialog.Portal> 128 - <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 129 130 - <Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20"> 131 - <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"> 132 - <div className="flex flex-row justify-between p-2"> 133 - <Dialog.Close asChild> 134 - <button 135 - 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" 136 - disabled={posting} 137 - aria-label="Close" 138 - > 139 - <svg 140 - xmlns="http://www.w3.org/2000/svg" 141 - width="20" 142 - height="20" 143 - viewBox="0 0 24 24" 144 - fill="none" 145 - stroke="currentColor" 146 - strokeWidth="2.5" 147 - strokeLinecap="round" 148 - strokeLinejoin="round" 149 > 150 - <line x1="18" y1="6" x2="6" y2="18"></line> 151 - <line x1="6" y1="6" x2="18" y2="18"></line> 152 - </svg> 153 - </button> 154 - </Dialog.Close> 155 156 - <div className="flex-1" /> 157 - <div className="flex items-center gap-4"> 158 - <span 159 - className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 160 - > 161 - {charsLeft} 162 - </span> 163 - <button 164 - 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" 165 - onClick={handlePost} 166 - disabled={isPostButtonDisabled} 167 - > 168 - {posting ? "Posting..." : "Post"} 169 - </button> 170 </div> 171 - </div> 172 173 - {postSuccess ? ( 174 - <div className="flex flex-col items-center justify-center py-16"> 175 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 176 - <span className="text-xl font-bold text-black dark:text-white"> 177 - Posted! 178 - </span> 179 - </div> 180 - ) : ( 181 - <div className="px-4"> 182 - {composerState.kind === "reply" && ( 183 - <div className="mb-1 -mx-4"> 184 - {isParentLoading ? ( 185 - <div className="text-sm text-gray-500 animate-pulse"> 186 - Loading parent post... 187 - </div> 188 - ) : parentUri ? ( 189 - <UniversalPostRendererATURILoader 190 - atUri={parentUri} 191 - bottomReplyLine 192 - bottomBorder={false} 193 /> 194 - ) : ( 195 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 196 - Could not load parent post. 197 - </div> 198 - )} 199 </div> 200 - )} 201 202 - <div className="flex w-full gap-1 flex-col"> 203 - <ProfileThing agent={agent} large /> 204 - <div className="flex pl-[50px]"> 205 - <AutoGrowTextarea 206 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 207 - rows={5} 208 - placeholder={getPlaceholder()} 209 - value={postText} 210 - onChange={(e) => setPostText(e.target.value)} 211 - disabled={posting} 212 - autoFocus 213 - /> 214 - </div> 215 </div> 216 217 - {composerState.kind === "quote" && ( 218 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 219 - {isParentLoading ? ( 220 - <div className="text-sm text-gray-500 animate-pulse"> 221 - Loading parent post... 222 - </div> 223 - ) : parentUri ? ( 224 - <UniversalPostRendererATURILoader 225 - atUri={parentUri} 226 - isQuote 227 - /> 228 - ) : ( 229 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 230 - Could not load parent post. 231 - </div> 232 - )} 233 - </div> 234 - )} 235 236 - {postError && ( 237 - <div className="text-red-500 text-sm my-2 text-center"> 238 - {postError} 239 - </div> 240 - )} 241 </div> 242 - )} 243 - </div> 244 - </Dialog.Content> 245 - </Dialog.Portal> 246 - </Dialog.Root> 247 ); 248 } 249
··· 1 + import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2 import { useAtom } from "jotai"; 3 import { Dialog } from "radix-ui"; 4 import { useEffect, useRef, useState } from "react"; ··· 8 import { useQueryPost } from "~/utils/useQuery"; 9 10 import { ProfileThing } from "./Login"; 11 + import { Button } from "./radix-m3-rd/Button"; 12 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 13 14 const MAX_POST_LENGTH = 300; 15 16 export function Composer() { 17 const [composerState, setComposerState] = useAtom(composerAtom); 18 + const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 19 const { agent } = useAuth(); 20 21 const [postText, setPostText] = useState(""); ··· 49 try { 50 const rt = new RichText({ text: postText }); 51 await rt.detectFacets(agent); 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 68 const record: Record<string, unknown> = { 69 $type: "app.bsky.feed.post", ··· 114 setPosting(false); 115 } 116 } 117 118 const getPlaceholder = () => { 119 switch (composerState.kind) { ··· 131 const isPostButtonDisabled = 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 + } 147 + 148 return ( 149 + <> 150 + <Dialog.Root 151 + open={composerState.kind !== "closed"} 152 + onOpenChange={(open) => { 153 + if (!open) handleAttemptClose(); 154 + }} 155 + > 156 + <Dialog.Portal> 157 + <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 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 + )} 230 + 231 + <div className="flex w-full gap-1 flex-col"> 232 + <ProfileThing agent={agent} large /> 233 + <div className="flex pl-[50px]"> 234 + <AutoGrowTextarea 235 + className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 236 + rows={5} 237 + placeholder={getPlaceholder()} 238 + value={postText} 239 + onChange={(e) => setPostText(e.target.value)} 240 + disabled={posting} 241 + autoFocus 242 /> 243 + </div> 244 </div> 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? 287 + </div> 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> 303 </div> 304 + </div> 305 + </Dialog.Content> 306 + </Dialog.Portal> 307 + </Dialog.Root> 308 + </> 309 ); 310 } 311
+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 * as React from "react"; 2 3 //import { useInView } from "react-intersection-observer"; ··· 37 isFetchingNextPage, 38 refetch, 39 isRefetching, 40 } = useInfiniteQueryFeedSkeleton({ 41 feedUri: feedUri, 42 agent: agent ?? undefined, ··· 44 pdsUrl: pdsUrl, 45 feedServiceDid: feedServiceDid, 46 }); 47 48 const handleRefresh = () => { 49 refetch(); 50 }; 51 52 //const { ref, inView } = useInView(); 53 54 // React.useEffect(() => { ··· 67 ); 68 } 69 70 - const allPosts = 71 - data?.pages.flatMap((page) => { 72 - if (page) return page.feed; 73 - }) ?? []; 74 75 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 76 return ( ··· 116 className="sticky lg:bottom-4 bottom-22 ml-4 w-[42px] h-[42px] z-10 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-50 p-[9px] rounded-full shadow-lg transition-transform duration-200 ease-in-out hover:scale-110 disabled:dark:bg-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed" 117 aria-label="Refresh feed" 118 > 119 - <RefreshIcon className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} /> 120 </button> 121 </> 122 ); ··· 139 d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" 140 ></path> 141 </svg> 142 - );
··· 1 + import { useQueryClient } from "@tanstack/react-query"; 2 import * as React from "react"; 3 4 //import { useInView } from "react-intersection-observer"; ··· 38 isFetchingNextPage, 39 refetch, 40 isRefetching, 41 + queryKey, 42 } = useInfiniteQueryFeedSkeleton({ 43 feedUri: feedUri, 44 agent: agent ?? undefined, ··· 46 pdsUrl: pdsUrl, 47 feedServiceDid: feedServiceDid, 48 }); 49 + const queryClient = useQueryClient(); 50 + 51 52 const handleRefresh = () => { 53 + queryClient.removeQueries({queryKey: queryKey}); 54 + //queryClient.invalidateQueries(["infinite-feed", feedUri] as const); 55 refetch(); 56 }; 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 + 76 //const { ref, inView } = useInView(); 77 78 // React.useEffect(() => { ··· 91 ); 92 } 93 94 + // const allPosts = 95 + // data?.pages.flatMap((page) => { 96 + // if (page) return page.feed; 97 + // }) ?? []; 98 99 if (!allPosts || typeof allPosts !== "object" || allPosts.length === 0) { 100 return ( ··· 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" 141 aria-label="Refresh feed" 142 > 143 + <RefreshIcon 144 + className={`h-6 w-6 text-gray-600 dark:text-gray-400 ${isRefetching && "animate-spin"}`} 145 + /> 146 </button> 147 </> 148 ); ··· 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" 166 ></path> 167 </svg> 168 + );
+8 -6
src/components/Login.tsx
··· 7 import { imgCDNAtom } from "~/utils/atoms"; 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 11 export default function Login({ 12 compact = false, ··· 24 className={ 25 compact 26 ? "flex items-center justify-center p-1" 27 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 28 } 29 > 30 <span ··· 43 // Large view 44 if (!compact) { 45 return ( 46 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 47 <div className="flex flex-col items-center justify-center text-center"> 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 49 You are logged in! 50 </p> 51 <ProfileThing agent={agent} large /> 52 - <button 53 onClick={logout} 54 - className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded-full px-6 py-2 font-semibold text-base transition-colors" 55 > 56 Log out 57 - </button> 58 </div> 59 </div> 60 ); ··· 77 if (!compact) { 78 // Large view renders the form directly in the card 79 return ( 80 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 81 <UnifiedLoginForm /> 82 </div> 83 );
··· 7 import { imgCDNAtom } from "~/utils/atoms"; 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 10 + import { Button } from "./radix-m3-rd/Button"; 11 + 12 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 13 export default function Login({ 14 compact = false, ··· 26 className={ 27 compact 28 ? "flex items-center justify-center p-1" 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]" 30 } 31 > 32 <span ··· 45 // Large view 46 if (!compact) { 47 return ( 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"> 49 <div className="flex flex-col items-center justify-center text-center"> 50 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 51 You are logged in! 52 </p> 53 <ProfileThing agent={agent} large /> 54 + <Button 55 onClick={logout} 56 + className="mt-4" 57 > 58 Log out 59 + </Button> 60 </div> 61 </div> 62 ); ··· 79 if (!compact) { 80 // Large view renders the form directly in the card 81 return ( 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"> 83 <UnifiedLoginForm /> 84 </div> 85 );
+13 -5
src/components/UniversalPostRenderer.tsx
··· 518 ? true 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 ? false 521 - : bottomReplyLine 522 } 523 topReplyLine={topReplyLine} 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 540 maxReplies={maxReplies} 541 isQuote={isQuote} 542 /> 543 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 544 <> 545 {/* <span>hello {maxReplies}</span> */} ··· 564 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 565 } 566 /> 567 - {maxReplies && maxReplies - 1 === 0 && replies && replies > 0 && ( 568 - <MoreReplies atUri={oldestOpsReplyElseNewestNonOpsReply} /> 569 - )} 570 </> 571 )} 572 </> ··· 1542 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" 1543 side={"bottom"} 1544 sideOffset={5} 1545 > 1546 <div className="flex flex-col gap-2"> 1547 <div className="flex flex-row"> ··· 1551 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1552 /> 1553 <div className=" flex-1 flex flex-row align-middle justify-end"> 1554 <FollowButton targetdidorhandle={post.author.did} /> 1555 </div> 1556 </div> 1557 <div className="flex flex-col gap-3"> ··· 2649 return { start, end, feature: f.features[0] }; 2650 }); 2651 } 2652 - function renderTextWithFacets({ 2653 text, 2654 facets, 2655 navigate,
··· 518 ? true 519 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 520 ? false 521 + : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 522 } 523 topReplyLine={topReplyLine} 524 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 540 maxReplies={maxReplies} 541 isQuote={isQuote} 542 /> 543 + <> 544 + {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 545 + <> 546 + {/* <div>hello</div> */} 547 + <MoreReplies atUri={atUri} /> 548 + </> 549 + ) : (<></>)} 550 + </> 551 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 552 <> 553 {/* <span>hello {maxReplies}</span> */} ··· 572 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined 573 } 574 /> 575 </> 576 )} 577 </> ··· 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"> ··· 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"> ··· 2657 return { start, end, feature: f.features[0] }; 2658 }); 2659 } 2660 + export function renderTextWithFacets({ 2661 text, 2662 facets, 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";
+26 -23
src/providers/UnifiedAuthProvider.tsx
··· 1 - // src/providers/UnifiedAuthProvider.tsx 2 - // Import both Agent and the (soon to be deprecated) AtpAgent 3 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 4 import { 5 type OAuthSession, ··· 7 TokenRefreshError, 8 TokenRevokedError, 9 } from "@atproto/oauth-client-browser"; 10 import React, { 11 createContext, 12 use, ··· 15 useState, 16 } from "react"; 17 18 - import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 19 20 - // Define the unified status and authentication method 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 type AuthMethod = "password" | "oauth" | null; 23 24 interface AuthContextValue { 25 - agent: Agent | null; // The agent is typed as the base class `Agent` 26 status: AuthStatus; 27 authMethod: AuthMethod; 28 loginWithPassword: ( ··· 41 }: { 42 children: React.ReactNode; 43 }) => { 44 - // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 45 const [agent, setAgent] = useState<Agent | null>(null); 46 const [status, setStatus] = useState<AuthStatus>("loading"); 47 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 48 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 49 50 - // Unified Initialization Logic 51 const initialize = useCallback(async () => { 52 - // --- 1. Try OAuth initialization first --- 53 try { 54 const oauthResult = await oauthClient.init(); 55 if (oauthResult) { 56 // /*mass comment*/ console.log("OAuth session restored."); 57 - const apiAgent = new Agent(oauthResult.session); // Standard Agent 58 setAgent(apiAgent); 59 setOauthSession(oauthResult.session); 60 setAuthMethod("oauth"); 61 setStatus("signedIn"); 62 - return; // Success 63 } 64 } catch (e) { 65 console.error("OAuth init failed, checking password session.", e); 66 } 67 68 - // --- 2. If no OAuth, try password-based session using AtpAgent --- 69 try { 70 const service = localStorage.getItem("service"); 71 const sessionString = localStorage.getItem("sess"); 72 73 if (service && sessionString) { 74 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 75 - // Use the original, working AtpAgent logic 76 const apiAgent = new AtpAgent({ service }); 77 const session: AtpSessionData = JSON.parse(sessionString); 78 await apiAgent.resumeSession(session); 79 80 // /*mass comment*/ console.log("Password-based session resumed successfully."); 81 - setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 82 setAuthMethod("password"); 83 setStatus("signedIn"); 84 - return; // Success 85 } 86 } catch (e) { 87 console.error("Failed to resume password-based session.", e); ··· 89 localStorage.removeItem("service"); 90 } 91 92 - // --- 3. If neither worked, user is signed out --- 93 // /*mass comment*/ console.log("No active session found."); 94 setStatus("signedOut"); 95 setAgent(null); 96 setAuthMethod(null); 97 - }, []); 98 99 useEffect(() => { 100 const handleOAuthSessionDeleted = ( ··· 105 setOauthSession(null); 106 setAuthMethod(null); 107 setStatus("signedOut"); 108 }; 109 110 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 113 return () => { 114 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 115 }; 116 - }, [initialize]); 117 118 - // --- Login Methods --- 119 const loginWithPassword = async ( 120 user: string, 121 password: string, ··· 125 setStatus("loading"); 126 try { 127 let sessionData: AtpSessionData | undefined; 128 - // Use the AtpAgent for its simple login and session persistence 129 const apiAgent = new AtpAgent({ 130 service, 131 persistSession: (_evt, sess) => { ··· 137 if (sessionData) { 138 localStorage.setItem("service", service); 139 localStorage.setItem("sess", JSON.stringify(sessionData)); 140 - setAgent(apiAgent); // Store the AtpAgent instance in our state 141 setAuthMethod("password"); 142 setStatus("signedIn"); 143 // /*mass comment*/ console.log("Successfully logged in with password."); 144 } else { 145 throw new Error("Session data not persisted after login."); ··· 147 } catch (e) { 148 console.error("Password login failed:", e); 149 setStatus("signedOut"); 150 throw e; 151 } 152 }; ··· 161 } 162 }, [status]); 163 164 - // --- Unified Logout --- 165 const logout = useCallback(async () => { 166 if (status !== "signedIn" || !agent) return; 167 setStatus("loading"); ··· 173 } else if (authMethod === "password") { 174 localStorage.removeItem("service"); 175 localStorage.removeItem("sess"); 176 - // AtpAgent has its own logout methods 177 await (agent as AtpAgent).com.atproto.server.deleteSession(); 178 // /*mass comment*/ console.log("Password-based session deleted."); 179 } ··· 184 setAuthMethod(null); 185 setOauthSession(null); 186 setStatus("signedOut"); 187 } 188 - }, [status, authMethod, agent, oauthSession]); 189 190 return ( 191 <AuthContext
··· 1 import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 2 import { 3 type OAuthSession, ··· 5 TokenRefreshError, 6 TokenRevokedError, 7 } from "@atproto/oauth-client-browser"; 8 + import { useAtom } from "jotai"; 9 import React, { 10 createContext, 11 use, ··· 14 useState, 15 } from "react"; 16 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + 19 + import { oauthClient } from "../utils/oauthClient"; 20 21 type AuthStatus = "loading" | "signedIn" | "signedOut"; 22 type AuthMethod = "password" | "oauth" | null; 23 24 interface AuthContextValue { 25 + agent: Agent | null; 26 status: AuthStatus; 27 authMethod: AuthMethod; 28 loginWithPassword: ( ··· 41 }: { 42 children: React.ReactNode; 43 }) => { 44 const [agent, setAgent] = useState<Agent | null>(null); 45 const [status, setStatus] = useState<AuthStatus>("loading"); 46 const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 47 const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 49 50 const initialize = useCallback(async () => { 51 try { 52 const oauthResult = await oauthClient.init(); 53 if (oauthResult) { 54 // /*mass comment*/ console.log("OAuth session restored."); 55 + const apiAgent = new Agent(oauthResult.session); 56 setAgent(apiAgent); 57 setOauthSession(oauthResult.session); 58 setAuthMethod("oauth"); 59 setStatus("signedIn"); 60 + setQuickAuth(apiAgent?.did || null); 61 + return; 62 } 63 } catch (e) { 64 console.error("OAuth init failed, checking password session.", e); 65 + if (!quickAuth) { 66 + // quickAuth restoration. if last used method is oauth we immediately call for oauth redo 67 + // (and set a persistent atom somewhere to not retry again if it failed) 68 + } 69 } 70 71 try { 72 const service = localStorage.getItem("service"); 73 const sessionString = localStorage.getItem("sess"); 74 75 if (service && sessionString) { 76 // /*mass comment*/ console.log("Resuming password-based session using AtpAgent..."); 77 const apiAgent = new AtpAgent({ service }); 78 const session: AtpSessionData = JSON.parse(sessionString); 79 await apiAgent.resumeSession(session); 80 81 // /*mass comment*/ console.log("Password-based session resumed successfully."); 82 + setAgent(apiAgent); 83 setAuthMethod("password"); 84 setStatus("signedIn"); 85 + setQuickAuth(apiAgent?.did || null); 86 + return; 87 } 88 } catch (e) { 89 console.error("Failed to resume password-based session.", e); ··· 91 localStorage.removeItem("service"); 92 } 93 94 // /*mass comment*/ console.log("No active session found."); 95 setStatus("signedOut"); 96 setAgent(null); 97 setAuthMethod(null); 98 + // do we want to null it here? 99 + setQuickAuth(null); 100 + }, [quickAuth, setQuickAuth]); 101 102 useEffect(() => { 103 const handleOAuthSessionDeleted = ( ··· 108 setOauthSession(null); 109 setAuthMethod(null); 110 setStatus("signedOut"); 111 + setQuickAuth(null); 112 }; 113 114 oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); ··· 117 return () => { 118 oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 119 }; 120 + }, [initialize, setQuickAuth]); 121 122 const loginWithPassword = async ( 123 user: string, 124 password: string, ··· 128 setStatus("loading"); 129 try { 130 let sessionData: AtpSessionData | undefined; 131 const apiAgent = new AtpAgent({ 132 service, 133 persistSession: (_evt, sess) => { ··· 139 if (sessionData) { 140 localStorage.setItem("service", service); 141 localStorage.setItem("sess", JSON.stringify(sessionData)); 142 + setAgent(apiAgent); 143 setAuthMethod("password"); 144 setStatus("signedIn"); 145 + setQuickAuth(apiAgent?.did || null); 146 // /*mass comment*/ console.log("Successfully logged in with password."); 147 } else { 148 throw new Error("Session data not persisted after login."); ··· 150 } catch (e) { 151 console.error("Password login failed:", e); 152 setStatus("signedOut"); 153 + setQuickAuth(null); 154 throw e; 155 } 156 }; ··· 165 } 166 }, [status]); 167 168 const logout = useCallback(async () => { 169 if (status !== "signedIn" || !agent) return; 170 setStatus("loading"); ··· 176 } else if (authMethod === "password") { 177 localStorage.removeItem("service"); 178 localStorage.removeItem("sess"); 179 await (agent as AtpAgent).com.atproto.server.deleteSession(); 180 // /*mass comment*/ console.log("Password-based session deleted."); 181 } ··· 186 setAuthMethod(null); 187 setOauthSession(null); 188 setStatus("signedOut"); 189 + setQuickAuth(null); 190 } 191 + }, [status, agent, authMethod, oauthSession, setQuickAuth]); 192 193 return ( 194 <AuthContext
+28 -26
src/routes/__root.tsx
··· 18 19 import { Composer } from "~/components/Composer"; 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 import Login from "~/components/Login"; 22 import { NotFound } from "~/components/NotFound"; 23 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; ··· 154 /> 155 156 <MaterialNavItem 157 InactiveIcon={ 158 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 159 } ··· 180 }) 181 } 182 text="Feeds" 183 - /> 184 - <MaterialNavItem 185 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 186 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 187 - active={locationEnum === "search"} 188 - onClickCallbback={() => 189 - navigate({ 190 - to: "/search", 191 - //params: { did: agent.assertDid }, 192 - }) 193 - } 194 - text="Search" 195 /> 196 <MaterialNavItem 197 InactiveIcon={ ··· 389 390 <MaterialNavItem 391 small 392 InactiveIcon={ 393 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 394 } ··· 419 /> 420 <MaterialNavItem 421 small 422 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 423 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 424 - active={locationEnum === "search"} 425 - onClickCallbback={() => 426 - navigate({ 427 - to: "/search", 428 - //params: { did: agent.assertDid }, 429 - }) 430 - } 431 - text="Search" 432 - /> 433 - <MaterialNavItem 434 - small 435 InactiveIcon={ 436 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 437 } ··· 498 </main> 499 500 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 501 <Login /> 502 503 <div className="flex-1"></div> ··· 551 //params: { did: agent.assertDid }, 552 }) 553 } 554 - text="Search" 555 /> 556 {/* <Link 557 to="/search"
··· 18 19 import { Composer } from "~/components/Composer"; 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 + import { Import } from "~/components/Import"; 22 import Login from "~/components/Login"; 23 import { NotFound } from "~/components/NotFound"; 24 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; ··· 155 /> 156 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 170 InactiveIcon={ 171 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 172 } ··· 193 }) 194 } 195 text="Feeds" 196 /> 197 <MaterialNavItem 198 InactiveIcon={ ··· 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 + /> 404 + <MaterialNavItem 405 + small 406 InactiveIcon={ 407 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 408 } ··· 433 /> 434 <MaterialNavItem 435 small 436 InactiveIcon={ 437 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 438 } ··· 499 </main> 500 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> 503 <Login /> 504 505 <div className="flex-1"></div> ··· 553 //params: { did: agent.assertDid }, 554 }) 555 } 556 + text="Explore" 557 /> 558 {/* <Link 559 to="/search"
+40 -40
src/routes/index.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom } from "jotai"; 3 import * as React from "react"; 4 - import { useEffect, useLayoutEffect } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 import { 10 - agentAtom, 11 - authedAtom, 12 feedScrollPositionsAtom, 13 isAtTopAtom, 14 selectedFeedUriAtom, 15 - store, 16 } from "~/utils/atoms"; 17 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 18 import { ··· 107 } = useAuth(); 108 const authed = !!agent?.did; 109 110 - useEffect(() => { 111 - if (agent?.did) { 112 - store.set(authedAtom, true); 113 - } else { 114 - store.set(authedAtom, false); 115 - } 116 - }, [status, agent, authed]); 117 - useEffect(() => { 118 - if (agent) { 119 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment 120 - // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 121 - store.set(agentAtom, agent); 122 - } else { 123 - store.set(agentAtom, null); 124 - } 125 - }, [status, agent, authed]); 126 127 //const { get, set } = usePersistentStore(); 128 // const [feed, setFeed] = React.useState<any[]>([]); ··· 162 163 // const savedFeeds = savedFeedsPref?.items || []; 164 165 - const identityresultmaybe = useQueryIdentity(agent?.did); 166 const identity = identityresultmaybe?.data; 167 168 const prefsresultmaybe = useQueryPreferences({ 169 - agent: agent ?? undefined, 170 - pdsUrl: identity?.pds, 171 }); 172 const prefs = prefsresultmaybe?.data; 173 ··· 178 return savedFeedsPref?.items || []; 179 }, [prefs]); 180 181 - const [persistentSelectedFeed, setPersistentSelectedFeed] = 182 - useAtom(selectedFeedUriAtom); // React.useState<string | null>(null); 183 - const [unauthedSelectedFeed, setUnauthedSelectedFeed] = React.useState( 184 - persistentSelectedFeed 185 - ); // React.useState<string | null>(null); 186 const selectedFeed = agent?.did 187 ? persistentSelectedFeed 188 : unauthedSelectedFeed; ··· 306 }, [scrollPositions]); 307 308 useLayoutEffect(() => { 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 - }, [selectedFeed]); 314 315 useLayoutEffect(() => { 316 - if (!selectedFeed) return; 317 318 const handleScroll = () => { 319 scrollPositionsRef.current = { ··· 328 329 setScrollPositions(scrollPositionsRef.current); 330 }; 331 - }, [selectedFeed, setScrollPositions]); 332 333 - const feedGengetrecordquery = useQueryArbitrary(selectedFeed ?? undefined); 334 - const feedServiceDid = (feedGengetrecordquery?.data?.value as any)?.did; 335 336 // const { 337 // data: feedData, ··· 347 348 // const feed = feedData?.feed || []; 349 350 - const isReadyForAuthedFeed = 351 - authed && agent && identity?.pds && feedServiceDid; 352 - const isReadyForUnauthedFeed = !authed && selectedFeed; 353 354 355 const [isAtTop] = useAtom(isAtTopAtom); ··· 358 <div 359 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 360 > 361 - {savedFeeds.length > 0 ? ( 362 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 363 {savedFeeds.map((item: any, idx: number) => { 364 const label = item.value.split("/").pop() || item.value; ··· 410 /> 411 ))} */} 412 413 - {authed && (!identity?.pds || !feedServiceDid) && ( 414 <div className="p-4 text-center text-gray-500"> 415 Preparing your feed... 416 </div> 417 )} 418 419 - {isReadyForAuthedFeed || isReadyForUnauthedFeed ? ( 420 <InfiniteCustomFeed 421 feedUri={selectedFeed!} 422 pdsUrl={identity?.pds} 423 feedServiceDid={feedServiceDid} 424 /> 425 ) : ( 426 <div className="p-4 text-center text-gray-500"> 427 - Select a feed to get started. 428 </div> 429 )} 430 {/* {false && restoringScrollPosition && (
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 import { useAtom } from "jotai"; 3 import * as React from "react"; 4 + import { useLayoutEffect, useState } from "react"; 5 6 import { Header } from "~/components/Header"; 7 import { InfiniteCustomFeed } from "~/components/InfiniteCustomFeed"; 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 import { 10 feedScrollPositionsAtom, 11 isAtTopAtom, 12 + quickAuthAtom, 13 selectedFeedUriAtom, 14 } from "~/utils/atoms"; 15 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 16 import { ··· 105 } = useAuth(); 106 const authed = !!agent?.did; 107 108 + // i dont remember why this is even here 109 + // useEffect(() => { 110 + // if (agent?.did) { 111 + // store.set(authedAtom, true); 112 + // } else { 113 + // store.set(authedAtom, false); 114 + // } 115 + // }, [status, agent, authed]); 116 + // useEffect(() => { 117 + // if (agent) { 118 + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment 119 + // // @ts-ignore is it just me or is the type really weird here it should be Agent not AtpAgent 120 + // store.set(agentAtom, agent); 121 + // } else { 122 + // store.set(agentAtom, null); 123 + // } 124 + // }, [status, agent, authed]); 125 126 //const { get, set } = usePersistentStore(); 127 // const [feed, setFeed] = React.useState<any[]>([]); ··· 161 162 // const savedFeeds = savedFeedsPref?.items || []; 163 164 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 165 + const isAuthRestoring = quickAuth ? status === "loading" : false; 166 + 167 + const identityresultmaybe = useQueryIdentity(!isAuthRestoring ? agent?.did : undefined); 168 const identity = identityresultmaybe?.data; 169 170 const prefsresultmaybe = useQueryPreferences({ 171 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 172 + pdsUrl: !isAuthRestoring ? (identity?.pds) : undefined, 173 }); 174 const prefs = prefsresultmaybe?.data; 175 ··· 180 return savedFeedsPref?.items || []; 181 }, [prefs]); 182 183 + const [persistentSelectedFeed, setPersistentSelectedFeed] = useAtom(selectedFeedUriAtom); 184 + const [unauthedSelectedFeed, setUnauthedSelectedFeed] = useState(persistentSelectedFeed); 185 const selectedFeed = agent?.did 186 ? persistentSelectedFeed 187 : unauthedSelectedFeed; ··· 305 }, [scrollPositions]); 306 307 useLayoutEffect(() => { 308 + if (isAuthRestoring) return; 309 const savedPosition = scrollPositions[selectedFeed ?? "null"] ?? 0; 310 311 window.scrollTo({ top: savedPosition, behavior: "instant" }); 312 // eslint-disable-next-line react-hooks/exhaustive-deps 313 + }, [selectedFeed, isAuthRestoring]); 314 315 useLayoutEffect(() => { 316 + if (!selectedFeed || isAuthRestoring) return; 317 318 const handleScroll = () => { 319 scrollPositionsRef.current = { ··· 328 329 setScrollPositions(scrollPositionsRef.current); 330 }; 331 + }, [isAuthRestoring, selectedFeed, setScrollPositions]); 332 333 + const feedGengetrecordquery = useQueryArbitrary(!isAuthRestoring ? selectedFeed ?? undefined : undefined); 334 + const feedServiceDid = !isAuthRestoring ? (feedGengetrecordquery?.data?.value as any)?.did as string | undefined : undefined; 335 336 // const { 337 // data: feedData, ··· 347 348 // const feed = feedData?.feed || []; 349 350 + const isReadyForAuthedFeed = !isAuthRestoring && authed && agent && identity?.pds && feedServiceDid; 351 + const isReadyForUnauthedFeed = !isAuthRestoring && !authed && selectedFeed; 352 353 354 const [isAtTop] = useAtom(isAtTopAtom); ··· 357 <div 358 className={`relative flex flex-col divide-y divide-gray-200 dark:divide-gray-800 ${hidden && "hidden"}`} 359 > 360 + {!isAuthRestoring && savedFeeds.length > 0 ? ( 361 <div className={`flex items-center px-4 py-2 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] ${!isAtTop && "shadow-sm"} sm:shadow-none sm:bg-white sm:dark:bg-gray-950 z-10 border-0 sm:border-b border-gray-200 dark:border-gray-700 overflow-x-auto overflow-y-hidden scroll-thin`}> 362 {savedFeeds.map((item: any, idx: number) => { 363 const label = item.value.split("/").pop() || item.value; ··· 409 /> 410 ))} */} 411 412 + {isAuthRestoring || authed && (!identity?.pds || !feedServiceDid) && ( 413 <div className="p-4 text-center text-gray-500"> 414 Preparing your feed... 415 </div> 416 )} 417 418 + {!isAuthRestoring && (isReadyForAuthedFeed || isReadyForUnauthedFeed) ? ( 419 <InfiniteCustomFeed 420 + key={selectedFeed!} 421 feedUri={selectedFeed!} 422 pdsUrl={identity?.pds} 423 feedServiceDid={feedServiceDid} 424 /> 425 ) : ( 426 <div className="p-4 text-center text-gray-500"> 427 + Loading....... 428 </div> 429 )} 430 {/* {false && restoringScrollPosition && (
+120 -46
src/routes/profile.$did/index.tsx
··· 1 import { useQueryClient } from "@tanstack/react-query"; 2 - import { createFileRoute } from "@tanstack/react-router"; 3 import { useAtom } from "jotai"; 4 - import React from "react"; 5 6 import { Header } from "~/components/Header"; 7 - import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 import { imgCDNAtom } from "~/utils/atoms"; 10 - import { toggleFollow, useGetFollowState, useGetOneToOneState } from "~/utils/followState"; 11 import { 12 useInfiniteQueryAuthorFeed, 13 useQueryIdentity, ··· 21 function ProfileComponent() { 22 // booo bad this is not always the did it might be a handle, use identity.did instead 23 const { did } = Route.useParams(); 24 const queryClient = useQueryClient(); 25 const { 26 data: identity, ··· 161 also save it persistently 162 */} 163 <FollowButton targetdidorhandle={did} /> 164 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 165 ... {/* todo: icon */} 166 - </button> 167 </div> 168 169 {/* Info Card */} 170 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 171 <div className="font-bold text-2xl">{displayName}</div> 172 <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 173 - <Mutual targetdidorhandle={did} /> 174 {handle} 175 </div> 176 {description && ( 177 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 178 - {description} 179 </div> 180 )} 181 </div> ··· 219 ); 220 } 221 222 - export function FollowButton({targetdidorhandle}:{targetdidorhandle: string}) { 223 - const {agent} = useAuth() 224 - const {data: identity} = useQueryIdentity(targetdidorhandle); 225 const queryClient = useQueryClient(); 226 227 const followRecords = useGetFollowState({ 228 target: identity?.did ?? targetdidorhandle, 229 user: agent?.did, 230 }); 231 - 232 return ( 233 <> 234 {identity?.did !== agent?.did ? ( 235 <> 236 {!(followRecords?.length && followRecords?.length > 0) ? ( 237 - <button 238 - onClick={(e) => 239 - { 240 e.stopPropagation(); 241 toggleFollow({ 242 agent: agent || undefined, 243 targetDid: identity?.did, 244 followRecords: followRecords, 245 queryClient: queryClient, 246 - }) 247 - } 248 - } 249 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 250 > 251 Follow 252 - </button> 253 ) : ( 254 - <button 255 - onClick={(e) => 256 - { 257 e.stopPropagation(); 258 toggleFollow({ 259 agent: agent || undefined, 260 targetDid: identity?.did, 261 followRecords: followRecords, 262 queryClient: queryClient, 263 - }) 264 - } 265 - } 266 - className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 267 > 268 Unfollow 269 - </button> 270 )} 271 </> 272 ) : ( 273 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 274 - Edit Profile 275 - </button> 276 )} 277 </> 278 ); 279 } 280 281 282 - export function Mutual({targetdidorhandle}:{targetdidorhandle: string}) { 283 - const {agent} = useAuth() 284 - const {data: identity} = useQueryIdentity(targetdidorhandle); 285 286 - const mutualfollows = useGetOneToOneState(agent?.did ? { 287 - target: agent?.did, 288 - user: identity?.did ?? targetdidorhandle, 289 - collection: "app.bsky.graph.follow", 290 - path: ".subject" 291 - }:undefined); 292 293 - const ismutual: boolean = (!!mutualfollows?.length && mutualfollows.length > 0) 294 - 295 return ( 296 <> 297 {identity?.did !== agent?.did ? ( 298 <> 299 - {!(ismutual) ? ( 300 - <></> 301 ) : ( 302 - <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">mutuals</div> 303 )} 304 </> 305 ) : ( ··· 308 )} 309 </> 310 ); 311 - }
··· 1 + import { RichText } from "@atproto/api"; 2 import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 import { useAtom } from "jotai"; 5 + import React, { type ReactNode, useEffect, useState } from "react"; 6 7 import { Header } from "~/components/Header"; 8 + import { Button } from "~/components/radix-m3-rd/Button"; 9 + import { 10 + renderTextWithFacets, 11 + UniversalPostRendererATURILoader, 12 + } from "~/components/UniversalPostRenderer"; 13 import { useAuth } from "~/providers/UnifiedAuthProvider"; 14 import { imgCDNAtom } from "~/utils/atoms"; 15 + import { 16 + toggleFollow, 17 + useGetFollowState, 18 + useGetOneToOneState, 19 + } from "~/utils/followState"; 20 import { 21 useInfiniteQueryAuthorFeed, 22 useQueryIdentity, ··· 30 function ProfileComponent() { 31 // booo bad this is not always the did it might be a handle, use identity.did instead 32 const { did } = Route.useParams(); 33 + const navigate = useNavigate(); 34 const queryClient = useQueryClient(); 35 const { 36 data: identity, ··· 171 also save it persistently 172 */} 173 <FollowButton targetdidorhandle={did} /> 174 + <Button className="rounded-full" variant={"secondary"}> 175 ... {/* todo: icon */} 176 + </Button> 177 </div> 178 179 {/* Info Card */} 180 <div className="mt-16 pb-2 px-4 text-gray-900 dark:text-gray-100"> 181 <div className="font-bold text-2xl">{displayName}</div> 182 <div className="text-gray-500 dark:text-gray-400 text-base mb-3 flex flex-row gap-1"> 183 + <Mutual targetdidorhandle={did} /> 184 {handle} 185 </div> 186 {description && ( 187 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 188 + {/* {description} */} 189 + <RichTextRenderer key={did} description={description} /> 190 </div> 191 )} 192 </div> ··· 230 ); 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 ) : ( ··· 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 e.stopPropagation(); 86 e.nativeEvent.stopImmediatePropagation(); 87 }} 88 - className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 > 90 <ProfilePostComponent 91 key={`/profile/${did}/post/${rkey}`}
··· 85 e.stopPropagation(); 86 e.nativeEvent.stopImmediatePropagation(); 87 }} 88 + className="lightbox-sidebar hidden lg:flex overscroll-none disablegutter disablescroll border-l dark:border-gray-800 was7 border-gray-300 fixed z-50 top-0 right-0 flex-col max-w-[350px] min-w-[350px] max-h-screen overflow-y-scroll dark:bg-gray-950 bg-white" 89 > 90 <ProfilePostComponent 91 key={`/profile/${did}/post/${rkey}`}
+50 -1
src/routes/search.tsx
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 export const Route = createFileRoute("/search")({ 4 component: Search, 5 }); 6 7 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 9 }
··· 1 import { createFileRoute } from "@tanstack/react-router"; 2 3 + import { Header } from "~/components/Header"; 4 + import { Import } from "~/components/Import"; 5 + 6 export const Route = createFileRoute("/search")({ 7 component: Search, 8 }); 9 10 export function Search() { 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 + ); 58 }
+6 -2
src/styles/app.css
··· 52 } 53 } 54 55 @media (width >= 64rem /* 1024px */) { 56 html:not(:has(.disablegutter)), 57 body:not(:has(.disablegutter)) { 58 scrollbar-gutter: stable both-edges !important; 59 } 60 - html:has(.disablegutter), 61 - body:has(.disablegutter) { 62 scrollbar-width: none; 63 overflow-y: hidden; 64 }
··· 52 } 53 } 54 55 + .gutter{ 56 + scrollbar-gutter: stable both-edges; 57 + } 58 + 59 @media (width >= 64rem /* 1024px */) { 60 html:not(:has(.disablegutter)), 61 body:not(:has(.disablegutter)) { 62 scrollbar-gutter: stable both-edges !important; 63 } 64 + html:has(.disablescroll), 65 + body:has(.disablescroll) { 66 scrollbar-width: none; 67 overflow-y: hidden; 68 }
+7 -3
src/utils/atoms.ts
··· 1 - import type Agent from "@atproto/api"; 2 import { atom, createStore, useAtomValue } from "jotai"; 3 import { atomWithStorage } from "jotai/utils"; 4 import { useEffect } from "react"; 5 6 export const store = createStore(); 7 8 export const selectedFeedUriAtom = atomWithStorage<string | null>( 9 "selectedFeedUri", ··· 52 | { kind: "quote"; subject: string }; 53 export const composerAtom = atom<ComposerState>({ kind: "closed" }); 54 55 - export const agentAtom = atom<Agent | null>(null); 56 - export const authedAtom = atom<boolean>(false); 57 58 export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 59 const value = useAtomValue(atom);
··· 1 import { atom, createStore, useAtomValue } from "jotai"; 2 import { atomWithStorage } from "jotai/utils"; 3 import { useEffect } from "react"; 4 5 export const store = createStore(); 6 + 7 + export const quickAuthAtom = atomWithStorage<string | null>( 8 + "quickAuth", 9 + null 10 + ); 11 12 export const selectedFeedUriAtom = atomWithStorage<string | null>( 13 "selectedFeedUri", ··· 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);
+2 -2
src/utils/useQuery.ts
··· 615 }) { 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 618 - return useInfiniteQuery({ 619 queryKey, 620 queryFn, 621 initialPageParam: undefined as never, ··· 623 staleTime: Infinity, 624 refetchOnWindowFocus: false, 625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 - }); 627 } 628 629
··· 615 }) { 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 618 + return {...useInfiniteQuery({ 619 queryKey, 620 queryFn, 621 initialPageParam: undefined as never, ··· 623 staleTime: Infinity, 624 refetchOnWindowFocus: false, 625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 + }), queryKey: queryKey}; 627 } 628 629
+1 -1
vite.config.ts
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 - const PROD_URL = "https://reddwarf.whey.party" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string {
··· 10 11 import { generateMetadataPlugin } from "./oauthdev.mts"; 12 13 + const PROD_URL = "https://reddwarf.app" 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 16 function shp(url: string): string {