an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 665adb3f4041d747d2e43d21ab9e525b88b15510 339 lines 12 kB view raw
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}