an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at button 12 kB view raw
1import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2import { useAtom } from "jotai"; 3import { Dialog } from "radix-ui"; 4import { useEffect, useRef, useState } from "react"; 5 6import { useAuth } from "~/providers/UnifiedAuthProvider"; 7import { composerAtom } from "~/utils/atoms"; 8import { useQueryPost } from "~/utils/useQuery"; 9 10import { ProfileThing } from "./Login"; 11import { Button } from "./radix-m3-rd/Button"; 12import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 13 14const MAX_POST_LENGTH = 300; 15 16export function Composer() { 17 const [composerState, setComposerState] = useAtom(composerAtom); 18 const [closeConfirmState, setCloseConfirmState] = useState<boolean>(false); 19 const { agent } = useAuth(); 20 21 const [postText, setPostText] = useState(""); 22 const [posting, setPosting] = useState(false); 23 const [postSuccess, setPostSuccess] = useState(false); 24 const [postError, setPostError] = useState<string | null>(null); 25 26 useEffect(() => { 27 setPostText(""); 28 setPosting(false); 29 setPostSuccess(false); 30 setPostError(null); 31 }, [composerState.kind]); 32 33 const parentUri = 34 composerState.kind === "reply" 35 ? composerState.parent 36 : composerState.kind === "quote" 37 ? composerState.subject 38 : undefined; 39 40 const { data: parentPost, isLoading: isParentLoading } = 41 useQueryPost(parentUri); 42 43 async function handlePost() { 44 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; 45 46 setPosting(true); 47 setPostError(null); 48 49 try { 50 const rt = new RichText({ text: postText }); 51 await rt.detectFacets(agent); 52 53 if (rt.facets?.length) { 54 rt.facets = rt.facets.filter((item) => { 55 if (item.$type !== "app.bsky.richtext.facet") return true; 56 if (!item.features?.length) return true; 57 58 item.features = item.features.filter((feature) => { 59 if (feature.$type !== "app.bsky.richtext.facet#mention") return true; 60 const did = feature.$type === "app.bsky.richtext.facet#mention" ? (feature as AppBskyRichtextFacet.Mention)?.did : undefined; 61 return typeof did === "string" && did.startsWith("did:"); 62 }); 63 64 return item.features.length > 0; 65 }); 66 } 67 68 const record: Record<string, unknown> = { 69 $type: "app.bsky.feed.post", 70 text: rt.text, 71 facets: rt.facets, 72 createdAt: new Date().toISOString(), 73 }; 74 75 if (composerState.kind === "reply" && parentPost) { 76 record.reply = { 77 root: parentPost.value?.reply?.root ?? { 78 uri: parentPost.uri, 79 cid: parentPost.cid, 80 }, 81 parent: { 82 uri: parentPost.uri, 83 cid: parentPost.cid, 84 }, 85 }; 86 } 87 88 if (composerState.kind === "quote" && parentPost) { 89 record.embed = { 90 $type: "app.bsky.embed.record", 91 record: { 92 uri: parentPost.uri, 93 cid: parentPost.cid, 94 }, 95 }; 96 } 97 98 await agent.com.atproto.repo.createRecord({ 99 collection: "app.bsky.feed.post", 100 repo: agent.assertDid, 101 record, 102 }); 103 104 setPostSuccess(true); 105 setPostText(""); 106 107 setTimeout(() => { 108 setPostSuccess(false); 109 setComposerState({ kind: "closed" }); 110 }, 1500); 111 } catch (e: any) { 112 setPostError(e?.message || "Failed to post"); 113 } finally { 114 setPosting(false); 115 } 116 } 117 118 const getPlaceholder = () => { 119 switch (composerState.kind) { 120 case "reply": 121 return "Post your reply"; 122 case "quote": 123 return "Add a comment..."; 124 case "root": 125 default: 126 return "What's happening?!"; 127 } 128 }; 129 130 const charsLeft = MAX_POST_LENGTH - postText.length; 131 const isPostButtonDisabled = 132 posting || !postText.trim() || isParentLoading || charsLeft < 0; 133 134 function handleAttemptClose() { 135 if (postText.trim() && !posting) { 136 setCloseConfirmState(true); 137 } else { 138 setComposerState({ kind: "closed" }); 139 } 140 } 141 142 function handleConfirmClose() { 143 setComposerState({ kind: "closed" }); 144 setCloseConfirmState(false); 145 setPostText(""); 146 } 147 148 return ( 149 <> 150 <Dialog.Root 151 open={composerState.kind !== "closed"} 152 onOpenChange={(open) => { 153 if (!open) handleAttemptClose(); 154 }} 155 > 156 <Dialog.Portal> 157 <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 158 159 <Dialog.Content className="fixed overflow-y-auto gutter inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 160 <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-xl relative mx-4"> 161 <div className="flex flex-row justify-between p-2"> 162 <Dialog.Close asChild> 163 <button 164 className="h-8 w-8 flex items-center justify-center rounded-full text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" 165 disabled={posting} 166 aria-label="Close" 167 onClick={handleAttemptClose} 168 > 169 <svg 170 xmlns="http://www.w3.org/2000/svg" 171 width="20" 172 height="20" 173 viewBox="0 0 24 24" 174 fill="none" 175 stroke="currentColor" 176 strokeWidth="2.5" 177 strokeLinecap="round" 178 strokeLinejoin="round" 179 > 180 <line x1="18" y1="6" x2="6" y2="18"></line> 181 <line x1="6" y1="6" x2="18" y2="18"></line> 182 </svg> 183 </button> 184 </Dialog.Close> 185 186 <div className="flex-1" /> 187 <div className="flex items-center gap-4"> 188 <span 189 className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 190 > 191 {charsLeft} 192 </span> 193 <Button 194 onClick={handlePost} 195 disabled={isPostButtonDisabled} 196 > 197 {posting ? "Posting..." : "Post"} 198 </Button> 199 </div> 200 </div> 201 202 {postSuccess ? ( 203 <div className="flex flex-col items-center justify-center py-16"> 204 <span className="text-gray-500 text-6xl mb-4"></span> 205 <span className="text-xl font-bold text-black dark:text-white"> 206 Posted! 207 </span> 208 </div> 209 ) : ( 210 <div className="px-4"> 211 {composerState.kind === "reply" && ( 212 <div className="mb-1 -mx-4"> 213 {isParentLoading ? ( 214 <div className="text-sm text-gray-500 animate-pulse"> 215 Loading parent post... 216 </div> 217 ) : parentUri ? ( 218 <UniversalPostRendererATURILoader 219 atUri={parentUri} 220 bottomReplyLine 221 bottomBorder={false} 222 /> 223 ) : ( 224 <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 225 Could not load parent post. 226 </div> 227 )} 228 </div> 229 )} 230 231 <div className="flex w-full gap-1 flex-col"> 232 <ProfileThing agent={agent} large /> 233 <div className="flex pl-[50px]"> 234 <AutoGrowTextarea 235 className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 236 rows={5} 237 placeholder={getPlaceholder()} 238 value={postText} 239 onChange={(e) => setPostText(e.target.value)} 240 disabled={posting} 241 autoFocus 242 /> 243 </div> 244 </div> 245 246 {composerState.kind === "quote" && ( 247 <div className="mb-4 ml-[50px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 248 {isParentLoading ? ( 249 <div className="text-sm text-gray-500 animate-pulse"> 250 Loading parent post... 251 </div> 252 ) : parentUri ? ( 253 <UniversalPostRendererATURILoader 254 atUri={parentUri} 255 isQuote 256 /> 257 ) : ( 258 <div className="text-sm text-red-500 rounded-lg border border-red-500/50 p-3"> 259 Could not load parent post. 260 </div> 261 )} 262 </div> 263 )} 264 265 {postError && ( 266 <div className="text-red-500 text-sm my-2 text-center"> 267 {postError} 268 </div> 269 )} 270 </div> 271 )} 272 </div> 273 </Dialog.Content> 274 </Dialog.Portal> 275 </Dialog.Root> 276 277 {/* Close confirmation dialog */} 278 <Dialog.Root open={closeConfirmState} onOpenChange={setCloseConfirmState}> 279 <Dialog.Portal> 280 281 <Dialog.Overlay className="disablegutter fixed inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 282 283 <Dialog.Content className="fixed gutter inset-0 z-50 flex items-start justify-center pt-30 sm:pt-40"> 284 <div className="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 rounded-2xl shadow-xl w-full max-w-md relative mx-4 py-6"> 285 <div className="text-xl mb-4 text-center"> 286 Discard your post? 287 </div> 288 <div className="text-md mb-4 text-center"> 289 You will lose your draft 290 </div> 291 <div className="flex justify-end gap-2 px-6"> 292 <Button 293 onClick={handleConfirmClose} 294 > 295 Discard 296 </Button> 297 <Button 298 variant={"outlined"} 299 onClick={() => setCloseConfirmState(false)} 300 > 301 Cancel 302 </Button> 303 </div> 304 </div> 305 </Dialog.Content> 306 </Dialog.Portal> 307 </Dialog.Root> 308 </> 309 ); 310} 311 312function AutoGrowTextarea({ 313 value, 314 className, 315 onChange, 316 ...props 317}: React.DetailedHTMLProps< 318 React.TextareaHTMLAttributes<HTMLTextAreaElement>, 319 HTMLTextAreaElement 320>) { 321 const ref = useRef<HTMLTextAreaElement>(null); 322 323 useEffect(() => { 324 const el = ref.current; 325 if (!el) return; 326 el.style.height = "auto"; 327 el.style.height = el.scrollHeight + "px"; 328 }, [value]); 329 330 return ( 331 <textarea 332 ref={ref} 333 className={className} 334 value={value} 335 onChange={onChange} 336 {...props} 337 /> 338 ); 339}