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