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}