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