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