an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
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}