an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

settings revamp

rimar1337 074047b5 fe5744ad

Changed files
+289 -62
src
+59 -15
src/components/Login.tsx
··· 1 1 // src/components/Login.tsx 2 2 import AtpAgent, { Agent } from "@atproto/api"; 3 + import { useAtom } from "jotai"; 3 4 import React, { useEffect, useRef, useState } from "react"; 4 5 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { imgCDNAtom } from "~/utils/atoms"; 6 8 import { useQueryIdentity, useQueryProfile } from "~/utils/useQuery"; 7 9 8 10 // --- 1. The Main Component (Orchestrator with `compact` prop) --- ··· 190 192 <p className="text-xs text-gray-500 dark:text-gray-400"> 191 193 Sign in with AT. Your password is never shared. 192 194 </p> 193 - <input 195 + {/* <input 194 196 type="text" 195 197 placeholder="handle.bsky.social" 196 198 value={handle} 197 199 onChange={(e) => setHandle(e.target.value)} 198 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" 199 - /> 200 - <button 201 - type="submit" 202 - className="bg-gray-600 hover:bg-gray-700 text-white rounded-full px-4 py-2 font-medium text-sm transition-colors" 203 - > 204 - Log in 205 - </button> 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> 206 219 </form> 207 220 ); 208 221 }; ··· 235 248 <p className="text-xs text-red-500 dark:text-red-400"> 236 249 Warning: Less secure. Use an App Password. 237 250 </p> 238 - <input 251 + {/* <input 239 252 type="text" 240 253 placeholder="handle.bsky.social" 241 254 value={user} ··· 257 270 value={serviceURL} 258 271 onChange={(e) => setServiceURL(e.target.value)} 259 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" 260 - /> 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> 261 301 {error && <p className="text-xs text-red-500">{error}</p>} 262 302 <button 263 303 type="submit" ··· 278 318 large?: boolean; 279 319 }) => { 280 320 const { agent } = useAuth(); 281 - const did = ((agent as AtpAgent).session?.did ?? (agent as AtpAgent)?.assertDid ?? agent?.did) as 282 - | string 283 - | undefined; 321 + const did = ((agent as AtpAgent).session?.did ?? 322 + (agent as AtpAgent)?.assertDid ?? 323 + agent?.did) as string | undefined; 284 324 const { data: identity } = useQueryIdentity(did); 285 - const { data: profiledata } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`); 325 + const { data: profiledata } = useQueryProfile( 326 + `at://${did}/app.bsky.actor.profile/self` 327 + ); 286 328 const profile = profiledata?.value; 287 329 330 + const [imgcdn] = useAtom(imgCDNAtom) 331 + 288 332 function getAvatarUrl(p: typeof profile) { 289 333 const link = p?.avatar?.ref?.["$link"]; 290 334 if (!link || !did) return null; 291 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 335 + return `https://${imgcdn}/img/avatar/plain/${did}/${link}@jpeg`; 292 336 } 293 337 294 338 if (!profiledata) {
+16 -13
src/components/UniversalPostRenderer.tsx
··· 5 5 import * as React from "react"; 6 6 import { type SVGProps } from "react"; 7 7 8 - import { composerAtom, constellationURLAtom, likedPostsAtom } from "~/utils/atoms"; 8 + import { composerAtom, constellationURLAtom, imgCDNAtom, likedPostsAtom } from "~/utils/atoms"; 9 9 import { useHydratedEmbed } from "~/utils/useHydrated"; 10 10 import { 11 11 useQueryConstellation, ··· 599 599 ); 600 600 } 601 601 602 - function getAvatarUrl(opProfile: any, did: string) { 602 + function getAvatarUrl(opProfile: any, did: string, cdn: string) { 603 603 const link = opProfile?.value?.avatar?.ref?.["$link"]; 604 604 if (!link) return null; 605 - return `https://cdn.bsky.app/img/avatar/plain/${did}/${link}@jpeg`; 605 + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 606 606 } 607 607 608 608 export function UniversalPostRendererRawRecordShim({ ··· 723 723 error: embedError, 724 724 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did); 725 725 726 + const [imgcdn] = useAtom(imgCDNAtom) 727 + 726 728 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 727 729 728 730 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>( ··· 734 736 did: resolved?.did || "", 735 737 handle: resolved?.handle || "", 736 738 displayName: profileRecord?.value?.displayName || "", 737 - avatar: getAvatarUrl(profileRecord, resolved?.did) || "", 739 + avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "", 738 740 viewer: undefined, 739 741 labels: profileRecord?.labels || undefined, 740 742 verification: undefined, ··· 762 764 repliesCount, 763 765 repostsCount, 764 766 likesCount, 767 + imgcdn 765 768 ] 766 769 ); 767 770 ··· 886 889 {...props} 887 890 > 888 891 <path 889 - fill="oklch(0.704 0.05 28)" 892 + fill="var(--color-gray-400)" 890 893 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" 891 894 ></path> 892 895 </svg> ··· 903 906 {...props} 904 907 > 905 908 <path 906 - fill="oklch(0.704 0.05 28)" 909 + fill="var(--color-gray-400)" 907 910 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 908 911 ></path> 909 912 </svg> ··· 954 957 {...props} 955 958 > 956 959 <path 957 - fill="oklch(0.704 0.05 28)" 960 + fill="var(--color-gray-400)" 958 961 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" 959 962 ></path> 960 963 </svg> ··· 971 974 {...props} 972 975 > 973 976 <path 974 - fill="oklch(0.704 0.05 28)" 977 + fill="var(--color-gray-400)" 975 978 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" 976 979 ></path> 977 980 </svg> ··· 988 991 {...props} 989 992 > 990 993 <path 991 - fill="oklch(0.704 0.05 28)" 994 + fill="var(--color-gray-400)" 992 995 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" 993 996 ></path> 994 997 </svg> ··· 1005 1008 {...props} 1006 1009 > 1007 1010 <path 1008 - fill="oklch(0.704 0.05 28)" 1011 + fill="var(--color-gray-400)" 1009 1012 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" 1010 1013 ></path> 1011 1014 </svg> ··· 1039 1042 {...props} 1040 1043 > 1041 1044 <path 1042 - fill="oklch(0.704 0.05 28)" 1045 + fill="var(--color-gray-400)" 1043 1046 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1044 1047 ></path> 1045 1048 </svg> ··· 1093 1096 {...props} 1094 1097 > 1095 1098 <path 1096 - fill="oklch(0.704 0.05 28)" 1099 + fill="var(--color-gray-400)" 1097 1100 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1098 1101 ></path> 1099 1102 </svg> ··· 1110 1113 {...props} 1111 1114 > 1112 1115 <path 1113 - fill="oklch(0.704 0.05 28)" 1116 + fill="var(--color-gray-400)" 1114 1117 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" 1115 1118 ></path> 1116 1119 </svg>
+6 -2
src/routes/profile.$did/index.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { createFileRoute } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 3 4 import React from "react"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 7 8 import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + import { imgCDNAtom } from "~/utils/atoms"; 8 10 import { toggleFollow, useGetFollowState } from "~/utils/followState"; 9 11 import { 10 12 useInfiniteQueryAuthorFeed, ··· 66 68 () => postsData?.pages.flatMap((page) => page.records) ?? [], 67 69 [postsData] 68 70 ); 71 + 72 + const [imgcdn] = useAtom(imgCDNAtom) 69 73 70 74 function getAvatarUrl(p: typeof profile) { 71 75 const link = p?.avatar?.ref?.["$link"]; 72 76 if (!link || !resolvedDid) return null; 73 - return `https://cdn.bsky.app/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 77 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 74 78 } 75 79 function getBannerUrl(p: typeof profile) { 76 80 const link = p?.banner?.ref?.["$link"]; 77 81 if (!link || !resolvedDid) return null; 78 - return `https://cdn.bsky.app/img/banner/plain/${resolvedDid}/${link}@jpeg`; 82 + return `https://${imgcdn}/img/banner/plain/${resolvedDid}/${link}@jpeg`; 79 83 } 80 84 81 85 const displayName =
+32 -9
src/routes/settings.tsx
··· 6 6 import { 7 7 constellationURLAtom, 8 8 defaultconstellationURL, 9 + defaultImgCDN, 9 10 defaultslingshotURL, 11 + defaultVideoCDN, 12 + imgCDNAtom, 10 13 slingshotURLAtom, 14 + videoCDNAtom, 11 15 } from "~/utils/atoms"; 12 16 13 17 export const Route = createFileRoute("/settings")({ ··· 27 31 } 28 32 }} 29 33 /> 30 - <Login /> 34 + <div className="lg:hidden"><Login /></div> 35 + <div className="h-4" /> 31 36 <TextInputSetting 32 37 atom={constellationURLAtom} 33 38 title={"Constellation"} ··· 42 47 description={"Customize the Slingshot instance to be used by Red Dwarf"} 43 48 init={defaultslingshotURL} 44 49 /> 45 - <span className="text-gray-500 dark:text-gray-400 py-4 px-6">please restart/refresh the app if changes arent applying correctly</span> 50 + <TextInputSetting 51 + atom={imgCDNAtom} 52 + title={"Image CDN"} 53 + description={ 54 + "Customize the Constellation instance to be used by Red Dwarf" 55 + } 56 + init={defaultImgCDN} 57 + /> 58 + <TextInputSetting 59 + atom={videoCDNAtom} 60 + title={"Video CDN"} 61 + description={"Customize the Slingshot instance to be used by Red Dwarf"} 62 + init={defaultVideoCDN} 63 + /> 64 + <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm">please restart/refresh the app if changes arent applying correctly</p> 46 65 </> 47 66 ); 48 67 } ··· 60 79 }) { 61 80 const [value, setValue] = useAtom(atom); 62 81 return ( 63 - <div className="flex flex-col gap-2 p-4 rounded-2xl border border-gray-200 dark:border-gray-800 "> 64 - <div> 82 + <div className="flex flex-col gap-2 px-4 py-2"> 83 + {/* <div> 65 84 {title && ( 66 85 <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"> 67 86 {title} ··· 72 91 {description} 73 92 </p> 74 93 )} 75 - </div> 94 + </div> */} 76 95 77 96 <div className="flex flex-row gap-2 items-center"> 78 - <input 97 + <div className="m3input-field m3input-label m3input-border size-md flex-1"> 98 + <input type="text" placeholder=" " value={value} onChange={(e) => setValue(e.target.value)}/> 99 + <label>{title}</label> 100 + </div> 101 + {/* <input 79 102 type="text" 80 103 value={value} 81 104 onChange={(e) => setValue(e.target.value)} ··· 83 106 text-gray-900 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 84 107 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600" 85 108 placeholder="Enter value..." 86 - /> 109 + /> */} 87 110 <button 88 111 onClick={() => setValue(init ?? "")} 89 - className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 112 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 90 113 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 91 114 > 92 115 Reset ··· 94 117 </div> 95 118 </div> 96 119 ); 97 - } 120 + }
+113
src/styles/app.css
··· 105 105 :root { 106 106 --shadow-opacity: calc(1 - var(--is-top)); 107 107 --tw-shadow-header: 0 2px 8px hsl(0 0% 0% / calc(var(--shadow-opacity) * 0.15)); 108 + } 109 + 110 + 111 + /* m3 input */ 112 + :root { 113 + --m3input-radius: 6px; 114 + --m3input-border-width: .0625rem; 115 + --m3input-font-size: 16px; 116 + --m3input-transition: 150ms cubic-bezier(.2, .8, .2, 1); 117 + /* light theme */ 118 + --m3input-bg: var(--color-gray-50); 119 + --m3input-border-color: var(--color-gray-400); 120 + --m3input-label-color: var(--color-gray-500); 121 + --m3input-text-color: var(--color-gray-900); 122 + --m3input-focus-color: var(--color-gray-600); 123 + } 124 + 125 + @media (prefers-color-scheme: dark) { 126 + :root { 127 + --m3input-bg: var(--color-gray-950); 128 + --m3input-border-color: var(--color-gray-700); 129 + --m3input-label-color: var(--color-gray-400); 130 + --m3input-text-color: var(--color-gray-50); 131 + --m3input-focus-color: var(--color-gray-400); 132 + } 133 + } 134 + 135 + /* reset page *//* 136 + html, 137 + body { 138 + background: var(--m3input-bg); 139 + margin: 0; 140 + padding: 1rem; 141 + color: var(--m3input-text-color); 142 + font-family: system-ui, sans-serif; 143 + font-size: var(--m3input-font-size); 144 + }*/ 145 + 146 + /* base wrapper */ 147 + .m3input-field.m3input-label.m3input-border { 148 + position: relative; 149 + display: inline-block; 150 + width: 100%; 151 + /*max-width: 400px;*/ 152 + } 153 + 154 + /* size variants */ 155 + .m3input-field.size-sm { 156 + --m3input-h: 40px; 157 + } 158 + 159 + .m3input-field.size-md { 160 + --m3input-h: 48px; 161 + } 162 + 163 + .m3input-field.size-lg { 164 + --m3input-h: 56px; 165 + } 166 + 167 + .m3input-field.size-xl { 168 + --m3input-h: 64px; 169 + } 170 + 171 + .m3input-field.m3input-label.m3input-border:not(.size-sm):not(.size-md):not(.size-lg):not(.size-xl) { 172 + --m3input-h: 48px; 173 + } 174 + 175 + /* outlined input */ 176 + .m3input-field.m3input-label.m3input-border input { 177 + width: 100%; 178 + height: var(--m3input-h); 179 + border: var(--m3input-border-width) solid var(--m3input-border-color); 180 + border-radius: var(--m3input-radius); 181 + background: var(--m3input-bg); 182 + color: var(--m3input-text-color); 183 + font-size: var(--m3input-font-size); 184 + padding: 0 12px; 185 + box-sizing: border-box; 186 + outline: none; 187 + transition: border-color var(--m3input-transition), box-shadow var(--m3input-transition); 188 + } 189 + 190 + /* focus ring */ 191 + .m3input-field.m3input-label.m3input-border input:focus { 192 + border-color: var(--m3input-focus-color); 193 + /*box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-color) 20%, transparent);*/ 194 + } 195 + 196 + /* label */ 197 + .m3input-field.m3input-label.m3input-border label { 198 + position: absolute; 199 + left: 12px; 200 + top: 50%; 201 + transform: translateY(-50%); 202 + background: var(--m3input-bg); 203 + padding: 0 .25em; 204 + color: var(--m3input-label-color); 205 + pointer-events: none; 206 + transition: all var(--m3input-transition); 207 + } 208 + 209 + /* float on focus or when filled */ 210 + .m3input-field.m3input-label.m3input-border input:focus+label, 211 + .m3input-field.m3input-label.m3input-border input:not(:placeholder-shown)+label { 212 + top: 0; 213 + transform: translateY(-50%) scale(.78); 214 + left: 0; 215 + color: var(--m3input-focus-color); 216 + } 217 + 218 + /* placeholder trick */ 219 + .m3input-field.m3input-label.m3input-border input::placeholder { 220 + color: transparent; 108 221 }
+10
src/utils/atoms.ts
··· 31 31 'slingshotURL', 32 32 defaultslingshotURL 33 33 ) 34 + export const defaultImgCDN = 'cdn.bsky.app' 35 + export const imgCDNAtom = atomWithStorage<string>( 36 + 'imgcdnurl', 37 + defaultImgCDN 38 + ) 39 + export const defaultVideoCDN = 'video.bsky.app' 40 + export const videoCDNAtom = atomWithStorage<string>( 41 + 'videocdnurl', 42 + defaultVideoCDN 43 + ) 34 44 35 45 export const isAtTopAtom = atom<boolean>(true); 36 46
+53 -23
src/utils/useHydrated.ts
··· 9 9 AppBskyFeedPost, 10 10 AtUri, 11 11 } from "@atproto/api"; 12 + import { useAtom } from "jotai"; 12 13 import { useMemo } from "react"; 13 14 14 - import { useQueryIdentity,useQueryPost, useQueryProfile } from "./useQuery"; 15 + import { imgCDNAtom, videoCDNAtom } from "./atoms"; 16 + import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery"; 15 17 16 - type QueryResultData<T extends (...args: any) => any> = ReturnType<T> extends 17 - | { data: infer D } 18 - | undefined 19 - ? D 20 - : never; 18 + type QueryResultData<T extends (...args: any) => any> = 19 + ReturnType<T> extends { data: infer D } | undefined ? D : never; 21 20 22 21 function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 23 22 return obj as $Typed<T>; ··· 26 25 export function hydrateEmbedImages( 27 26 embed: AppBskyEmbedImages.Main, 28 27 did: string, 28 + cdn: string 29 29 ): $Typed<AppBskyEmbedImages.View> { 30 30 return asTyped({ 31 31 $type: "app.bsky.embed.images#view" as const, ··· 34 34 const link = img.image.ref?.["$link"]; 35 35 if (!link) return null; 36 36 return { 37 - thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 - fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${link}@jpeg`, 37 + thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 + fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 39 alt: img.alt || "", 40 40 aspectRatio: img.aspectRatio, 41 41 }; ··· 47 47 export function hydrateEmbedExternal( 48 48 embed: AppBskyEmbedExternal.Main, 49 49 did: string, 50 + cdn: string 50 51 ): $Typed<AppBskyEmbedExternal.View> { 51 52 return asTyped({ 52 53 $type: "app.bsky.embed.external#view" as const, ··· 55 56 title: embed.external.title, 56 57 description: embed.external.description, 57 58 thumb: embed.external.thumb?.ref?.$link 58 - ? `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 + ? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 59 60 : undefined, 60 61 }, 61 62 }); ··· 64 65 export function hydrateEmbedVideo( 65 66 embed: AppBskyEmbedVideo.Main, 66 67 did: string, 68 + videocdn: string 67 69 ): $Typed<AppBskyEmbedVideo.View> { 68 70 const videoLink = embed.video.ref.$link; 69 71 return asTyped({ 70 72 $type: "app.bsky.embed.video#view" as const, 71 - playlist: `https://video.bsky.app/watch/${did}/${videoLink}/playlist.m3u8`, 72 - thumbnail: `https://video.bsky.app/watch/${did}/${videoLink}/thumbnail.jpg`, 73 + playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`, 74 + thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`, 73 75 aspectRatio: embed.aspectRatio, 74 76 cid: videoLink, 75 77 }); ··· 80 82 quotedPost: QueryResultData<typeof useQueryPost>, 81 83 quotedProfile: QueryResultData<typeof useQueryProfile>, 82 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 + cdn: string 83 86 ): $Typed<AppBskyEmbedRecord.View> | undefined { 84 87 if (!quotedPost || !quotedProfile || !quotedIdentity) { 85 88 return undefined; ··· 91 94 handle: quotedIdentity.handle, 92 95 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 93 96 avatar: quotedProfile.value.avatar?.ref?.$link 94 - ? `https://cdn.bsky.app/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 97 + ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 95 98 : undefined, 96 99 viewer: {}, 97 100 labels: [], ··· 122 125 quotedPost: QueryResultData<typeof useQueryPost>, 123 126 quotedProfile: QueryResultData<typeof useQueryProfile>, 124 127 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 128 + cdn: string 125 129 ): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 126 130 const hydratedRecord = hydrateEmbedRecord( 127 131 embed.record, 128 132 quotedPost, 129 133 quotedProfile, 130 134 quotedIdentity, 135 + cdn 131 136 ); 132 137 133 138 if (!hydratedRecord) return undefined; ··· 148 153 149 154 export function useHydratedEmbed( 150 155 embed: AppBskyFeedPost.Record["embed"], 151 - postAuthorDid: string | undefined, 156 + postAuthorDid: string | undefined 152 157 ) { 153 158 const recordInfo = useMemo(() => { 154 159 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { ··· 181 186 error: profileError, 182 187 } = useQueryProfile(profileUri); 183 188 189 + const [imgcdn] = useAtom(imgCDNAtom); 190 + const [videocdn] = useAtom(videoCDNAtom); 191 + 184 192 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 185 193 186 194 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 187 195 if (!embed || !postAuthorDid) return undefined; 188 196 189 - if (isRecordType && (!usequerypostresults?.data || !quotedProfile || !queryidentityresult?.data)) { 197 + if ( 198 + isRecordType && 199 + (!usequerypostresults?.data || 200 + !quotedProfile || 201 + !queryidentityresult?.data) 202 + ) { 190 203 return undefined; 191 204 } 192 205 193 206 try { 194 207 if (AppBskyEmbedImages.isMain(embed)) { 195 - return hydrateEmbedImages(embed, postAuthorDid); 208 + return hydrateEmbedImages(embed, postAuthorDid, imgcdn); 196 209 } else if (AppBskyEmbedExternal.isMain(embed)) { 197 - return hydrateEmbedExternal(embed, postAuthorDid); 210 + return hydrateEmbedExternal(embed, postAuthorDid, imgcdn); 198 211 } else if (AppBskyEmbedVideo.isMain(embed)) { 199 - return hydrateEmbedVideo(embed, postAuthorDid); 212 + return hydrateEmbedVideo(embed, postAuthorDid, videocdn); 200 213 } else if (AppBskyEmbedRecord.isMain(embed)) { 201 214 return hydrateEmbedRecord( 202 215 embed, 203 216 usequerypostresults?.data, 204 217 quotedProfile, 205 218 queryidentityresult?.data, 219 + imgcdn 206 220 ); 207 221 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 208 222 let hydratedMedia: ··· 212 226 | undefined; 213 227 214 228 if (AppBskyEmbedImages.isMain(embed.media)) { 215 - hydratedMedia = hydrateEmbedImages(embed.media, postAuthorDid); 229 + hydratedMedia = hydrateEmbedImages( 230 + embed.media, 231 + postAuthorDid, 232 + imgcdn 233 + ); 216 234 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 217 - hydratedMedia = hydrateEmbedExternal(embed.media, postAuthorDid); 235 + hydratedMedia = hydrateEmbedExternal( 236 + embed.media, 237 + postAuthorDid, 238 + imgcdn 239 + ); 218 240 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 219 - hydratedMedia = hydrateEmbedVideo(embed.media, postAuthorDid); 241 + hydratedMedia = hydrateEmbedVideo( 242 + embed.media, 243 + postAuthorDid, 244 + videocdn 245 + ); 220 246 } 221 247 222 248 if (hydratedMedia) { ··· 226 252 usequerypostresults?.data, 227 253 quotedProfile, 228 254 queryidentityresult?.data, 255 + imgcdn 229 256 ); 230 257 } 231 258 } ··· 236 263 })(); 237 264 238 265 const isLoading = isRecordType 239 - ? usequerypostresults?.isLoading || isLoadingProfile || queryidentityresult?.isLoading 266 + ? usequerypostresults?.isLoading || 267 + isLoadingProfile || 268 + queryidentityresult?.isLoading 240 269 : false; 241 270 242 - const error = usequerypostresults?.error || profileError || queryidentityresult?.error; 271 + const error = 272 + usequerypostresults?.error || profileError || queryidentityresult?.error; 243 273 244 274 return { data: hydratedEmbed, isLoading, error }; 245 - } 275 + }