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