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}