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.

+164 -117
src/components/Composer.tsx
··· 8 8 import { useQueryPost } from "~/utils/useQuery"; 9 9 10 10 import { ProfileThing } from "./Login"; 11 + import { Button } from "./radix-m3-rd/Button"; 11 12 import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 12 13 13 14 const MAX_POST_LENGTH = 300; 14 15 15 16 export function Composer() { 16 17 const [composerState, setComposerState] = useAtom(composerAtom); 18 + const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 17 19 const { agent } = useAuth(); 18 20 19 21 const [postText, setPostText] = useState(""); ··· 112 114 setPosting(false); 113 115 } 114 116 } 115 - // if (composerState.kind === "closed") { 116 - // return null; 117 - // } 118 117 119 118 const getPlaceholder = () => { 120 119 switch (composerState.kind) { ··· 132 131 const isPostButtonDisabled = 133 132 posting || !postText.trim() || isParentLoading || charsLeft < 0; 134 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 + 135 148 return ( 136 - <Dialog.Root 137 - open={composerState.kind !== "closed"} 138 - onOpenChange={(open) => { 139 - if (!open) setComposerState({ kind: "closed" }); 140 - }} 141 - > 142 - <Dialog.Portal> 143 - <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 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" /> 144 158 145 - <Dialog.Content className="fixed overflow-y-scroll inset-0 z-50 flex items-start justify-center py-10 sm:py-20"> 146 - <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 147 - <div className="flex flex-row justify-between p-2"> 148 - <Dialog.Close asChild> 149 - <button 150 - className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 151 - disabled={posting} 152 - aria-label="Close" 153 - > 154 - <svg 155 - xmlns="http://www.w3.org/2000/svg" 156 - width="20" 157 - height="20" 158 - viewBox="0 0 24 24" 159 - fill="none" 160 - stroke="currentColor" 161 - strokeWidth="2.5" 162 - strokeLinecap="round" 163 - strokeLinejoin="round" 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} 164 168 > 165 - <line x1="18" y1="6" x2="6" y2="18"></line> 166 - <line x1="6" y1="6" x2="18" y2="18"></line> 167 - </svg> 168 - </button> 169 - </Dialog.Close> 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> 170 185 171 - <div className="flex-1" /> 172 - <div className="flex items-center gap-4"> 173 - <span 174 - className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 175 - > 176 - {charsLeft} 177 - </span> 178 - <button 179 - className="bg-gray-600 hover:bg-gray-700 text-white font-bold py-1 px-4 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 180 - onClick={handlePost} 181 - disabled={isPostButtonDisabled} 182 - > 183 - {posting ? "Posting..." : "Post"} 184 - </button> 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> 185 200 </div> 186 - </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 + )} 187 230 188 - {postSuccess ? ( 189 - <div className="flex flex-col items-center justify-center py-16"> 190 - <span className="text-gray-500 text-6xl mb-4">โœ“</span> 191 - <span className="text-xl font-bold text-black dark:text-white"> 192 - Posted! 193 - </span> 194 - </div> 195 - ) : ( 196 - <div className="px-4"> 197 - {composerState.kind === "reply" && ( 198 - <div className="mb-1 -mx-4"> 199 - {isParentLoading ? ( 200 - <div className="text-sm text-gray-500 animate-pulse"> 201 - Loading parent post... 202 - </div> 203 - ) : parentUri ? ( 204 - <UniversalPostRendererATURILoader 205 - atUri={parentUri} 206 - bottomReplyLine 207 - bottomBorder={false} 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 208 242 /> 209 - ) : ( 210 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 211 - Could not load parent post. 212 - </div> 213 - )} 243 + </div> 214 244 </div> 215 - )} 216 245 217 - <div className="flex w-full gap-1 flex-col"> 218 - <ProfileThing agent={agent} large /> 219 - <div className="flex pl-[50px]"> 220 - <AutoGrowTextarea 221 - className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 222 - rows={5} 223 - placeholder={getPlaceholder()} 224 - value={postText} 225 - onChange={(e) => setPostText(e.target.value)} 226 - disabled={posting} 227 - autoFocus 228 - /> 229 - </div> 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 + )} 230 270 </div> 271 + )} 272 + </div> 273 + </Dialog.Content> 274 + </Dialog.Portal> 275 + </Dialog.Root> 231 276 232 - {composerState.kind === "quote" && ( 233 - <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 234 - {isParentLoading ? ( 235 - <div className="text-sm text-gray-500 animate-pulse"> 236 - Loading parent post... 237 - </div> 238 - ) : parentUri ? ( 239 - <UniversalPostRendererATURILoader 240 - atUri={parentUri} 241 - isQuote 242 - /> 243 - ) : ( 244 - <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 245 - Could not load parent post. 246 - </div> 247 - )} 248 - </div> 249 - )} 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" /> 250 282 251 - {postError && ( 252 - <div className="text-red-500 text-sm my-2 text-center"> 253 - {postError} 254 - </div> 255 - )} 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> 256 303 </div> 257 - )} 258 - </div> 259 - </Dialog.Content> 260 - </Dialog.Portal> 261 - </Dialog.Root> 304 + </div> 305 + </Dialog.Content> 306 + </Dialog.Portal> 307 + </Dialog.Root> 308 + </> 262 309 ); 263 310 } 264 311
+5 -3
src/components/Login.tsx
··· 7 7 import { imgCDNAtom } from "~/utils/atoms"; 8 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 9 9 10 + import { Button } from "./radix-m3-rd/Button"; 11 + 10 12 // --- 1. The Main Component (Orchestrator with `compact` prop) --- 11 13 export default function Login({ 12 14 compact = false, ··· 49 51 You are logged in! 50 52 </p> 51 53 <ProfileThing agent={agent} large /> 52 - <button 54 + <Button 53 55 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" 56 + className="mt-4" 55 57 > 56 58 Log out 57 - </button> 59 + </Button> 58 60 </div> 59 61 </div> 60 62 );
+2
src/components/UniversalPostRenderer.tsx
··· 1557 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 1558 /> 1559 1559 <div className=" flex-1 flex flex-row align-middle justify-end"> 1560 + <div className=" flex flex-col justify-start"> 1560 1561 <FollowButton targetdidorhandle={post.author.did} /> 1562 + </div> 1561 1563 </div> 1562 1564 </div> 1563 1565 <div className="flex flex-col gap-3">
+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 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
+39 -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 421 420 key={selectedFeed!} 422 421 feedUri={selectedFeed!} ··· 425 424 /> 426 425 ) : ( 427 426 <div className="p-4 text-center text-gray-500"> 428 - Select a feed to get started. 427 + Loading....... 429 428 </div> 430 429 )} 431 430 {/* {false && restoringScrollPosition && (
+15 -25
src/routes/profile.$did/index.tsx
··· 5 5 import React, { type ReactNode, useEffect, useState } from "react"; 6 6 7 7 import { Header } from "~/components/Header"; 8 + import { Button } from "~/components/radix-m3-rd/Button"; 8 9 import { 9 10 renderTextWithFacets, 10 11 UniversalPostRendererATURILoader, 11 12 } from "~/components/UniversalPostRenderer"; 12 13 import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 - import { aturiListServiceAtom, imgCDNAtom } from "~/utils/atoms"; 14 + import { imgCDNAtom } from "~/utils/atoms"; 14 15 import { 15 16 toggleFollow, 16 17 useGetFollowState, 17 18 useGetOneToOneState, 18 19 } from "~/utils/followState"; 19 20 import { 20 - useInfiniteQueryAturiList, 21 + useInfiniteQueryAuthorFeed, 21 22 useQueryIdentity, 22 23 useQueryProfile, 23 24 } from "~/utils/useQuery"; ··· 29 30 function ProfileComponent() { 30 31 // booo bad this is not always the did it might be a handle, use identity.did instead 31 32 const { did } = Route.useParams(); 32 - //const navigate = useNavigate(); 33 + const navigate = useNavigate(); 33 34 const queryClient = useQueryClient(); 34 35 const { 35 36 data: identity, ··· 39 40 40 41 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 41 42 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 42 - //const pdsUrl = identity?.pds; 43 + const pdsUrl = identity?.pds; 43 44 44 45 const profileUri = resolvedDid 45 46 ? `at://${resolvedDid}/app.bsky.actor.profile/self` ··· 47 48 const { data: profileRecord } = useQueryProfile(profileUri); 48 49 const profile = profileRecord?.value; 49 50 50 - const [aturilistservice] = useAtom(aturiListServiceAtom); 51 - 52 51 const { 53 52 data: postsData, 54 53 fetchNextPage, 55 54 hasNextPage, 56 55 isFetchingNextPage, 57 56 isLoading: arePostsLoading, 58 - } = useInfiniteQueryAturiList({ 59 - aturilistservice: aturilistservice, 60 - did: resolvedDid, 61 - collection: "app.bsky.feed.post", 62 - reverse: true 63 - }); 57 + } = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl); 64 58 65 59 React.useEffect(() => { 66 60 if (postsData) { 67 61 postsData.pages.forEach((page) => { 68 - page.forEach((record) => { 62 + page.records.forEach((record) => { 69 63 if (!queryClient.getQueryData(["post", record.uri])) { 70 64 queryClient.setQueryData(["post", record.uri], record); 71 65 } ··· 75 69 }, [postsData, queryClient]); 76 70 77 71 const posts = React.useMemo( 78 - () => postsData?.pages.flatMap((page) => page) ?? [], 72 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 79 73 [postsData] 80 74 ); 81 75 ··· 177 171 also save it persistently 178 172 */} 179 173 <FollowButton targetdidorhandle={did} /> 180 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 174 + <Button className="rounded-full" variant={"secondary"}> 181 175 ... {/* todo: icon */} 182 - </button> 176 + </Button> 183 177 </div> 184 178 185 179 {/* Info Card */} ··· 255 249 {identity?.did !== agent?.did ? ( 256 250 <> 257 251 {!(followRecords?.length && followRecords?.length > 0) ? ( 258 - <button 252 + <Button 259 253 onClick={(e) => { 260 254 e.stopPropagation(); 261 255 toggleFollow({ ··· 265 259 queryClient: queryClient, 266 260 }); 267 261 }} 268 - 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]" 269 262 > 270 263 Follow 271 - </button> 264 + </Button> 272 265 ) : ( 273 - <button 266 + <Button 274 267 onClick={(e) => { 275 268 e.stopPropagation(); 276 269 toggleFollow({ ··· 280 273 queryClient: queryClient, 281 274 }); 282 275 }} 283 - 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]" 284 276 > 285 277 Unfollow 286 - </button> 278 + </Button> 287 279 )} 288 280 </> 289 281 ) : ( 290 - <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]"> 291 - Edit Profile 292 - </button> 282 + <Button variant={"secondary"}>Edit Profile</Button> 293 283 )} 294 284 </> 295 285 );
+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}`}
-8
src/routes/settings.tsx
··· 5 5 import { Header } from "~/components/Header"; 6 6 import Login from "~/components/Login"; 7 7 import { 8 - aturiListServiceAtom, 9 8 constellationURLAtom, 10 - defaultaturilistservice, 11 9 defaultconstellationURL, 12 10 defaulthue, 13 11 defaultImgCDN, ··· 53 51 title={"Slingshot"} 54 52 description={"Customize the Slingshot instance to be used by Red Dwarf"} 55 53 init={defaultslingshotURL} 56 - /> 57 - <TextInputSetting 58 - atom={aturiListServiceAtom} 59 - title={"AtUriListService"} 60 - description={"Customize the AtUriListService instance to be used by Red Dwarf"} 61 - init={defaultaturilistservice} 62 54 /> 63 55 <TextInputSetting 64 56 atom={imgCDNAtom}
+6 -2
src/styles/app.css
··· 52 52 } 53 53 } 54 54 55 + .gutter{ 56 + scrollbar-gutter: stable both-edges; 57 + } 58 + 55 59 @media (width >= 64rem /* 1024px */) { 56 60 html:not(:has(.disablegutter)), 57 61 body:not(:has(.disablegutter)) { 58 62 scrollbar-gutter: stable both-edges !important; 59 63 } 60 - html:has(.disablegutter), 61 - body:has(.disablegutter) { 64 + html:has(.disablescroll), 65 + body:has(.disablescroll) { 62 66 scrollbar-width: none; 63 67 overflow-y: hidden; 64 68 }
+7 -8
src/utils/atoms.ts
··· 1 - import type Agent from "@atproto/api"; 2 1 import { atom, createStore, useAtomValue } from "jotai"; 3 2 import { atomWithStorage } from "jotai/utils"; 4 3 import { useEffect } from "react"; 5 4 6 5 export const store = createStore(); 6 + 7 + export const quickAuthAtom = atomWithStorage<string | null>( 8 + "quickAuth", 9 + null 10 + ); 7 11 8 12 export const selectedFeedUriAtom = atomWithStorage<string | null>( 9 13 "selectedFeedUri", ··· 32 36 "slingshotURL", 33 37 defaultslingshotURL 34 38 ); 35 - export const defaultaturilistservice = "aturilistservice.reddwarf.app"; 36 - export const aturiListServiceAtom = atomWithStorage<string>( 37 - "aturilistservice", 38 - defaultaturilistservice 39 - ); 40 39 export const defaultImgCDN = "cdn.bsky.app"; 41 40 export const imgCDNAtom = atomWithStorage<string>("imgcdnurl", defaultImgCDN); 42 41 export const defaultVideoCDN = "video.bsky.app"; ··· 57 56 | { kind: "quote"; subject: string }; 58 57 export const composerAtom = atom<ComposerState>({ kind: "closed" }); 59 58 60 - export const agentAtom = atom<Agent | null>(null); 61 - export const authedAtom = atom<boolean>(false); 59 + //export const agentAtom = atom<Agent | null>(null); 60 + //export const authedAtom = atom<boolean>(false); 62 61 63 62 export function useAtomCssVar(atom: typeof hueAtom, cssVar: string) { 64 63 const value = useAtomValue(atom);
-80
src/utils/useQuery.ts
··· 565 565 }); 566 566 } 567 567 568 - export const ATURI_PAGE_LIMIT = 100; 569 - 570 - export interface AturiDirectoryAturisItem { 571 - uri: string; 572 - cid: string; 573 - rkey: string; 574 - } 575 - 576 - export type AturiDirectoryAturis = AturiDirectoryAturisItem[]; 577 - 578 - export function constructAturiListQuery(aturilistservice: string, did: string, collection: string, reverse?: boolean) { 579 - return queryOptions({ 580 - // A unique key for this query, including all parameters that affect the data. 581 - queryKey: ["aturiList", did, collection, { reverse }], 582 - 583 - // The function that fetches the data. 584 - queryFn: async ({ pageParam }: QueryFunctionContext) => { 585 - const cursor = pageParam as string | undefined; 586 - 587 - // Use URLSearchParams for safe and clean URL construction. 588 - const params = new URLSearchParams({ 589 - did, 590 - collection, 591 - }); 592 - 593 - if (cursor) { 594 - params.set("cursor", cursor); 595 - } 596 - 597 - // Add the reverse parameter if it's true 598 - if (reverse) { 599 - params.set("reverse", "true"); 600 - } 601 - 602 - const url = `https://${aturilistservice}/aturis?${params.toString()}`; 603 - 604 - const res = await fetch(url); 605 - if (!res.ok) { 606 - // You can add more specific error handling here 607 - throw new Error(`Failed to fetch AT-URI list for ${did}`); 608 - } 609 - 610 - return res.json() as Promise<AturiDirectoryAturis>; 611 - }, 612 - }); 613 - } 614 - 615 - export function useInfiniteQueryAturiList({aturilistservice, did, collection, reverse}:{aturilistservice: string, did: string | undefined, collection: string | undefined, reverse?: boolean}) { 616 - // We only enable the query if both `did` and `collection` are provided. 617 - const isEnabled = !!did && !!collection; 618 - 619 - const { queryKey, queryFn } = constructAturiListQuery(aturilistservice, did!, collection!, reverse); 620 - 621 - return useInfiniteQuery({ 622 - queryKey, 623 - queryFn, 624 - initialPageParam: undefined as never, // ???? what is this shit 625 - 626 - // @ts-expect-error i wouldve used as null | undefined, anyways 627 - getNextPageParam: (lastPage: AturiDirectoryAturis) => { 628 - // If the last page returned no records, we're at the end. 629 - if (!lastPage || lastPage.length === 0) { 630 - return undefined; 631 - } 632 - 633 - // If the number of records is less than our page limit, it must be the last page. 634 - if (lastPage.length < ATURI_PAGE_LIMIT) { 635 - return undefined; 636 - } 637 - 638 - // The cursor for the next page is the `rkey` of the last item we received. 639 - const lastItem = lastPage[lastPage.length - 1]; 640 - return lastItem.rkey; 641 - }, 642 - 643 - enabled: isEnabled, 644 - }); 645 - } 646 - 647 - 648 568 type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 649 569 650 570 export function constructInfiniteFeedSkeletonQuery(options: {