an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 585 lines 22 kB view raw
1import { AppBskyRichtextFacet, RichText } from "@atproto/api"; 2import { TID } from "@atproto/common-web"; 3import { useAtom } from "jotai"; 4import { Dialog, Switch } from "radix-ui"; 5import { useEffect, useRef, useState } from "react"; 6 7import { useAuth } from "~/providers/UnifiedAuthProvider"; 8import { composerAtom } from "~/utils/atoms"; 9import { useQueryPost } from "~/utils/useQuery"; 10 11import { ProfileThing } from "./Login"; 12import { useOGGenerator } from "./OGPoll"; 13import { UniversalPostRendererATURILoader } from "./UniversalPostRenderer"; 14 15const MAX_POST_LENGTH = 300; 16 17// Helper to calculate expiry dates 18const addHours = (date: Date, h: number) => { 19 const newDate = new Date(date); 20 newDate.setTime(newDate.getTime() + h * 60 * 60 * 1000); 21 return newDate; 22}; 23 24export function Composer() { 25 const { generate, element: generatorElement } = useOGGenerator(); 26 27 const [composerState, setComposerState] = useAtom(composerAtom); 28 const { agent } = useAuth(); 29 30 const [postText, setPostText] = useState(""); 31 const [posting, setPosting] = useState(false); 32 const [postSuccess, setPostSuccess] = useState(false); 33 const [postError, setPostError] = useState<string | null>(null); 34 35 // Poll State 36 const [showPoll, setShowPoll] = useState(false); 37 const [pollData, setPollData] = useState({ 38 a: "", 39 b: "", 40 c: "", 41 d: "", 42 duration: "24", 43 expiry: addHours(new Date(), 24), 44 }); 45 46 useEffect(() => { 47 // Reset Everything on Open/Close 48 setPostText(""); 49 setPosting(false); 50 setPostSuccess(false); 51 setPostError(null); 52 setShowPoll(false); 53 setPollData({ 54 a: "", 55 b: "", 56 c: "", 57 d: "", 58 duration: "24", 59 expiry: addHours(new Date(), 24), 60 }); 61 }, [composerState.kind]); 62 63 const parentUri = 64 composerState.kind === "reply" 65 ? composerState.parent 66 : composerState.kind === "quote" 67 ? composerState.subject 68 : undefined; 69 70 const { data: parentPost, isLoading: isParentLoading } = 71 useQueryPost(parentUri); 72 73 async function handlePost() { 74 if (!agent || !postText.trim() || postText.length > MAX_POST_LENGTH) return; 75 76 setPosting(true); 77 setPostError(null); 78 79 try { 80 const rkey = TID.nextStr(); 81 const rt = new RichText({ text: postText }); 82 await rt.detectFacets(agent); 83 84 if (rt.facets?.length) { 85 rt.facets = rt.facets.filter((item) => { 86 if (item.$type !== "app.bsky.richtext.facet") return true; 87 if (!item.features?.length) return true; 88 89 item.features = item.features.filter((feature) => { 90 if (feature.$type !== "app.bsky.richtext.facet#mention") 91 return true; 92 const did = 93 feature.$type === "app.bsky.richtext.facet#mention" 94 ? (feature as AppBskyRichtextFacet.Mention)?.did 95 : undefined; 96 return typeof did === "string" && did.startsWith("did:"); 97 }); 98 99 return item.features.length > 0; 100 }); 101 } 102 103 let uploadedPollImageBlob = null; 104 105 // Only generate if we actually have poll data AND the user wants a poll 106 if (showPoll && pollData.a && pollData.b) { 107 // A. Generate the Base64 Data URL using the Client-Side Generator 108 const dataUrl = await generate({ 109 a: pollData.a, 110 b: pollData.b, 111 c: pollData.c || undefined, 112 d: pollData.d || undefined, 113 expiry: pollData.expiry, 114 multiple: true, 115 }); 116 117 if (dataUrl) { 118 // B. Convert DataURL to Blob 119 const blob = await fetch(dataUrl).then((res) => res.blob()); 120 121 // C. Upload Blob to Bluesky/ATProto PDS 122 const { data } = await agent.uploadBlob(blob, { 123 encoding: "image/png", 124 }); 125 126 uploadedPollImageBlob = data.blob; 127 } 128 } 129 130 const record: Record<string, unknown> = { 131 $type: "app.bsky.feed.post", 132 text: rt.text, 133 facets: rt.facets, 134 createdAt: new Date().toISOString(), 135 }; 136 137 let externalEmbed = null; 138 139 // todo get real way of doing this better getting domain 140 const domain = window.location.hostname; 141 if (uploadedPollImageBlob) { 142 externalEmbed = { 143 $type: "app.bsky.embed.external", 144 external: { 145 uri: `https://${domain}/profile/${agent.did}/post/${rkey}`, // Todo: update to your actual poll viewer URL 146 title: "Poll created by " + agent.did, 147 description: "Click to participate in this poll", 148 thumb: uploadedPollImageBlob, 149 }, 150 }; 151 } 152 153 // Handle Replies 154 if (composerState.kind === "reply" && parentPost) { 155 record.reply = { 156 root: parentPost.value?.reply?.root ?? { 157 uri: parentPost.uri, 158 cid: parentPost.cid, 159 }, 160 parent: { 161 uri: parentPost.uri, 162 cid: parentPost.cid, 163 }, 164 }; 165 } 166 167 // Handle Quotes + Embeds 168 if (composerState.kind === "quote" && parentPost) { 169 const quoteEmbed = { 170 $type: "app.bsky.embed.record", 171 record: { uri: parentPost.uri, cid: parentPost.cid }, 172 }; 173 174 if (externalEmbed) { 175 record.embed = { 176 $type: "app.bsky.embed.recordWithMedia", 177 media: externalEmbed, 178 record: quoteEmbed, 179 }; 180 } else { 181 record.embed = quoteEmbed; 182 } 183 } else if (externalEmbed) { 184 record.embed = externalEmbed; 185 } 186 187 const postResponse = await agent.com.atproto.repo.createRecord({ 188 collection: "app.bsky.feed.post", 189 repo: agent.assertDid, 190 record, 191 rkey: rkey, 192 }); 193 194 // Create poll embed record if poll data exists 195 if (showPoll && pollData.a && pollData.b) { 196 const pollRecord = { 197 $type: "app.reddwarf.embed.poll", 198 subject: { 199 $type: "com.atproto.repo.strongRef", 200 uri: `at://${agent.assertDid}/app.bsky.feed.post/${rkey}`, 201 cid: postResponse.data.cid, 202 }, 203 a: pollData.a, 204 b: pollData.b, 205 c: pollData.c || undefined, 206 d: pollData.d || undefined, 207 multiple: true, 208 createdAt: new Date().toISOString(), 209 }; 210 211 try { 212 await agent.com.atproto.repo.createRecord({ 213 collection: "app.reddwarf.embed.poll", 214 repo: agent.assertDid, 215 record: pollRecord, 216 rkey: rkey, 217 }); 218 } catch (pollError) { 219 console.error("Failed to create poll embed record:", pollError); 220 // Don't fail the entire post if poll record creation fails 221 } 222 } 223 224 setPostSuccess(true); 225 setPostText(""); 226 227 setTimeout(() => { 228 setPostSuccess(false); 229 setComposerState({ kind: "closed" }); 230 }, 1500); 231 } catch (e: any) { 232 setPostError(e?.message || "Failed to post"); 233 } finally { 234 setPosting(false); 235 } 236 } 237 238 const getPlaceholder = () => { 239 switch (composerState.kind) { 240 case "reply": 241 return "Post your reply"; 242 case "quote": 243 return "Add a comment..."; 244 case "root": 245 default: 246 return "What's happening?!"; 247 } 248 }; 249 250 const charsLeft = MAX_POST_LENGTH - postText.length; 251 // Disable if empty text OR if poll is active but only 1 option is filled 252 const isPollInvalid = showPoll && (!pollData.a || !pollData.b); 253 const isPostButtonDisabled = 254 posting || 255 !postText.trim() || 256 isParentLoading || 257 charsLeft < 0 || 258 isPollInvalid; 259 260 return ( 261 <> 262 <Dialog.Root 263 open={composerState.kind !== "closed"} 264 onOpenChange={(open) => { 265 if (!open) setComposerState({ kind: "closed" }); 266 }} 267 > 268 <Dialog.Portal> 269 <Dialog.Overlay className="fixed disablegutter inset-0 z-50 bg-black/40 dark:bg-black/50 data-[state=open]:animate-fadeIn" /> 270 271 <Dialog.Content className="fixed gutter overflow-y-scroll inset-0 z-50 flex items-start justify-center pt-10 sm:pt-20 pb-[50dvh] sm:pb-[50dvh]"> 272 <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"> 273 {/* HEADER */} 274 <div className="flex flex-row justify-between p-2 items-center"> 275 <Dialog.Close asChild> 276 <button 277 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 transition-colors" 278 disabled={posting} 279 aria-label="Close" 280 > 281 <svg 282 xmlns="http://www.w3.org/2000/svg" 283 width="20" 284 height="20" 285 viewBox="0 0 24 24" 286 fill="none" 287 stroke="currentColor" 288 strokeWidth="2.5" 289 strokeLinecap="round" 290 strokeLinejoin="round" 291 > 292 <line x1="18" y1="6" x2="6" y2="18"></line> 293 <line x1="6" y1="6" x2="18" y2="18"></line> 294 </svg> 295 </button> 296 </Dialog.Close> 297 298 <div className="flex-1" /> 299 <div className="flex items-center gap-4"> 300 <span 301 className={`text-sm ${charsLeft < 0 ? "text-red-500" : "text-gray-500"}`} 302 > 303 {charsLeft} 304 </span> 305 <button 306 className="bg-gray-600 hover:bg-gray-700 text-white font-medium text-sm py-1.5 px-5 rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-95" 307 onClick={handlePost} 308 disabled={isPostButtonDisabled} 309 > 310 {posting ? "Posting..." : "Post"} 311 </button> 312 </div> 313 </div> 314 315 {/* BODY */} 316 {postSuccess ? ( 317 <div className="flex flex-col items-center justify-center py-16 animate-in fade-in zoom-in duration-300"> 318 <span className="text-gray-500 text-6xl mb-4"></span> 319 <span className="text-xl font-bold text-black dark:text-white"> 320 Posted! 321 </span> 322 </div> 323 ) : ( 324 <div className="px-4 pb-4"> 325 {/* REPLY CONTEXT */} 326 {composerState.kind === "reply" && ( 327 <div className="mb-1 -mx-4"> 328 {isParentLoading ? ( 329 <div className="text-sm text-gray-500 animate-pulse px-4"> 330 Loading parent post... 331 </div> 332 ) : parentUri ? ( 333 <UniversalPostRendererATURILoader 334 atUri={parentUri} 335 bottomReplyLine 336 bottomBorder={false} 337 /> 338 ) : null} 339 </div> 340 )} 341 342 <div className="flex w-full gap-3 flex-col"> 343 <div className="flex flex-col gap-1"> 344 <ProfileThing agent={agent} large /> 345 <div className="flex pl-[50px]"> 346 <AutoGrowTextarea 347 className="w-full text-lg bg-transparent focus:outline-none resize-none placeholder:text-gray-500 text-black dark:text-white pb-2" 348 rows={5} 349 placeholder={getPlaceholder()} 350 value={postText} 351 onChange={(e) => setPostText(e.target.value)} 352 disabled={posting} 353 autoFocus 354 /> 355 </div> 356 </div> 357 358 {/* QUOTE CONTEXT */} 359 {composerState.kind === "quote" && ( 360 <div className="ml-[52px] mb-4 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"> 361 {isParentLoading ? ( 362 <div className="p-4 text-sm text-gray-500 animate-pulse"> 363 Loading parent post... 364 </div> 365 ) : parentUri ? ( 366 <UniversalPostRendererATURILoader 367 atUri={parentUri} 368 isQuote 369 /> 370 ) : null} 371 </div> 372 )} 373 374 {/* POLL FORM */} 375 <div className="pl-[52px] transition-all duration-300 ease-in-out"> 376 {showPoll && ( 377 <PollCreator 378 data={pollData} 379 onChange={setPollData} 380 disabled={posting} 381 /> 382 )} 383 </div> 384 385 {/* TOOLS BAR (Switch) */} 386 <div className="pl-[52px] pt-2 flex items-center justify-between border-t border-gray-100 dark:border-gray-800 mt-2"> 387 <div className="flex items-center gap-2"> 388 <div className="text-gray-500 dark:text-gray-400"> 389 <svg 390 xmlns="http://www.w3.org/2000/svg" 391 width="20" 392 height="20" 393 viewBox="0 0 24 24" 394 fill="none" 395 stroke="currentColor" 396 strokeWidth="2" 397 strokeLinecap="round" 398 strokeLinejoin="round" 399 > 400 <rect width="18" height="18" x="3" y="3" rx="2" /> 401 <path d="M8 17h8" /> 402 <path d="M8 12h8" /> 403 <path d="M8 7h4" /> 404 </svg> 405 </div> 406 <span className="text-sm font-medium text-gray-500 dark:text-gray-400 select-none"> 407 Create a Poll 408 </span> 409 </div> 410 <Switch.Root 411 checked={showPoll} 412 onCheckedChange={setShowPoll} 413 disabled={posting} 414 className="m3switch root" 415 > 416 <Switch.Thumb className="m3switch thumb" /> 417 </Switch.Root> 418 </div> 419 </div> 420 421 {postError && ( 422 <div className="text-red-500 bg-red-50 dark:bg-red-900/10 p-2 rounded-lg text-sm mt-4 text-center"> 423 {postError} 424 </div> 425 )} 426 </div> 427 )} 428 </div> 429 </Dialog.Content> 430 </Dialog.Portal> 431 </Dialog.Root> 432 {generatorElement} 433 </> 434 ); 435} 436 437/** 438 * Poll Creation Form 439 * Follows Material Design 3 spacing and filled input styles 440 */ 441function PollCreator({ 442 data, 443 onChange, 444 disabled, 445}: { 446 data: any; 447 onChange: any; 448 disabled: boolean; 449}) { 450 const handleChange = (field: string, val: string) => { 451 onChange((prev: any) => ({ ...prev, [field]: val })); 452 }; 453 454 // const handleDuration = (val: string) => { 455 // const hours = parseInt(val, 10); 456 // onChange((prev: any) => ({ 457 // ...prev, 458 // duration: val, 459 // expiry: addHours(new Date(), hours), 460 // })); 461 // }; 462 463 return ( 464 <div className="mt-2 p-4 bg-gray-100 dark:bg-gray-900/50 rounded-xl border border-gray-200 dark:border-gray-800 space-y-3"> 465 {/* Option A */} 466 <div className="relative group"> 467 <input 468 type="text" 469 placeholder="Option 1" 470 className="block w-full px-3 pt-5 pb-2 text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 border-b-2 border-gray-300 dark:border-gray-600 rounded-t-lg focus:border-gray-600 dark:focus:border-gray-400 focus:outline-none peer placeholder-transparent" 471 value={data.a} 472 onChange={(e) => handleChange("a", e.target.value)} 473 disabled={disabled} 474 /> 475 <label className="absolute text-xs text-gray-500 dark:text-gray-400 duration-300 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-3 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3"> 476 Option 1 (Required) 477 </label> 478 </div> 479 480 {/* Option B */} 481 <div className="relative group"> 482 <input 483 type="text" 484 placeholder="Option 2" 485 className="block w-full px-3 pt-5 pb-2 text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 border-b-2 border-gray-300 dark:border-gray-600 rounded-t-lg focus:border-gray-600 dark:focus:border-gray-400 focus:outline-none peer placeholder-transparent" 486 value={data.b} 487 onChange={(e) => handleChange("b", e.target.value)} 488 disabled={disabled} 489 /> 490 <label className="absolute text-xs text-gray-500 dark:text-gray-400 duration-300 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-3 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3"> 491 Option 2 (Required) 492 </label> 493 </div> 494 495 {/* Option C */} 496 <div className="relative group"> 497 <input 498 type="text" 499 placeholder="Option 3" 500 className="block w-full px-3 pt-5 pb-2 text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 border-b-2 border-gray-300 dark:border-gray-600 rounded-t-lg focus:border-gray-600 dark:focus:border-gray-400 focus:outline-none peer placeholder-transparent" 501 value={data.c} 502 onChange={(e) => handleChange("c", e.target.value)} 503 disabled={disabled} 504 /> 505 <label className="absolute text-xs text-gray-500 dark:text-gray-400 duration-300 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-3 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3"> 506 Option 3 (Optional) 507 </label> 508 </div> 509 510 {/* Option D */} 511 <div className="relative group"> 512 <input 513 type="text" 514 placeholder="Option 4" 515 className="block w-full px-3 pt-5 pb-2 text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800 border-b-2 border-gray-300 dark:border-gray-600 rounded-t-lg focus:border-gray-600 dark:focus:border-gray-400 focus:outline-none peer placeholder-transparent" 516 value={data.d} 517 onChange={(e) => handleChange("d", e.target.value)} 518 disabled={disabled} 519 /> 520 <label className="absolute text-xs text-gray-500 dark:text-gray-400 duration-300 transform -translate-y-3 scale-75 top-4 z-10 origin-[0] left-3 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-3"> 521 Option 4 (Optional) 522 </label> 523 </div> 524 525 {/* <div className="flex flex-col gap-1 pt-2"> 526 <label className="text-xs font-semibold text-gray-500 uppercase tracking-wider pl-1"> 527 Poll Duration 528 </label> 529 <div className="relative"> 530 <select 531 value={data.duration} 532 onChange={(e) => handleDuration(e.target.value)} 533 disabled={disabled} 534 className="appearance-none block w-full px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200" 535 > 536 <option value="1">1 Hour</option> 537 <option value="6">6 Hours</option> 538 <option value="12">12 Hours</option> 539 <option value="24">1 Day</option> 540 <option value="72">3 Days</option> 541 <option value="168">7 Days</option> 542 </select> 543 <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"> 544 <svg className="h-4 w-4 fill-current" viewBox="0 0 20 20"> 545 <path 546 d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" 547 clipRule="evenodd" 548 fillRule="evenodd" 549 ></path> 550 </svg> 551 </div> 552 </div> 553 </div> */} 554 </div> 555 ); 556} 557 558function AutoGrowTextarea({ 559 value, 560 className, 561 onChange, 562 ...props 563}: React.DetailedHTMLProps< 564 React.TextareaHTMLAttributes<HTMLTextAreaElement>, 565 HTMLTextAreaElement 566>) { 567 const ref = useRef<HTMLTextAreaElement>(null); 568 569 useEffect(() => { 570 const el = ref.current; 571 if (!el) return; 572 el.style.height = "auto"; 573 el.style.height = el.scrollHeight + "px"; 574 }, [value]); 575 576 return ( 577 <textarea 578 ref={ref} 579 className={className} 580 value={value} 581 onChange={onChange} 582 {...props} 583 /> 584 ); 585}