a tool for shared writing and social publishing
at feature/reader 409 lines 12 kB view raw
1"use client"; 2import { ButtonPrimary } from "components/Buttons"; 3import { useActionState, useEffect, useState } from "react"; 4import { Input } from "components/Input"; 5import { useIdentityData } from "components/IdentityProvider"; 6import { 7 confirmEmailAuthToken, 8 requestAuthEmailToken, 9} from "actions/emailAuth"; 10import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; 11import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12import { ShareSmall } from "components/Icons/ShareSmall"; 13import { Popover } from "components/Popover"; 14import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 15import { useToaster } from "components/Toast"; 16import * as Dialog from "@radix-ui/react-dialog"; 17import { 18 subscribeToPublication, 19 unsubscribeToPublication, 20} from "./subscribeToPublication"; 21import { DotLoader } from "components/utils/DotLoader"; 22import { addFeed } from "./addFeed"; 23import { useSearchParams } from "next/navigation"; 24import LoginForm from "app/login/LoginForm"; 25import { RSSSmall } from "components/Icons/RSSSmall"; 26 27type State = 28 | { state: "email" } 29 | { state: "code"; token: string } 30 | { state: "success" }; 31export const SubscribeButton = (props: { 32 compact?: boolean; 33 publication: string; 34}) => { 35 let { identity, mutate } = useIdentityData(); 36 let [emailInputValue, setEmailInputValue] = useState(""); 37 let [codeInputValue, setCodeInputValue] = useState(""); 38 let [state, setState] = useState<State>({ state: "email" }); 39 40 if (state.state === "email") { 41 return ( 42 <div className="flex gap-2"> 43 <div className="flex relative w-full max-w-sm"> 44 <Input 45 type="email" 46 className="input-with-border pr-[104px]! py-1! grow w-full" 47 placeholder={ 48 props.compact ? "subscribe with email..." : "email here..." 49 } 50 disabled={!!identity?.email} 51 value={identity?.email ? identity.email : emailInputValue} 52 onChange={(e) => { 53 setEmailInputValue(e.currentTarget.value); 54 }} 55 /> 56 <ButtonPrimary 57 compact 58 className="absolute right-1 top-1 outline-0!" 59 onClick={async () => { 60 if (identity?.email) { 61 await subscribeToPublicationWithEmail(props.publication); 62 //optimistically could add! 63 await mutate(); 64 return; 65 } 66 let tokenID = await requestAuthEmailToken(emailInputValue); 67 setState({ state: "code", token: tokenID }); 68 }} 69 > 70 {props.compact ? ( 71 <ArrowRightTiny className="w-4 h-6" /> 72 ) : ( 73 "Subscribe" 74 )} 75 </ButtonPrimary> 76 </div> 77 {/* <ShareButton /> */} 78 </div> 79 ); 80 } 81 if (state.state === "code") { 82 return ( 83 <div 84 className="w-full flex flex-col justify-center place-items-center p-4 rounded-md" 85 style={{ 86 background: 87 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 88 }} 89 > 90 <div className="flex flex-col leading-snug text-secondary"> 91 <div>Please enter the code we sent to </div> 92 <div className="italic font-bold">{emailInputValue}</div> 93 </div> 94 95 <ConfirmCodeInput 96 publication={props.publication} 97 token={state.token} 98 codeInputValue={codeInputValue} 99 setCodeInputValue={setCodeInputValue} 100 setState={setState} 101 /> 102 103 <button 104 className="text-accent-contrast text-sm mt-1" 105 onClick={() => { 106 setState({ state: "email" }); 107 }} 108 > 109 Re-enter Email 110 </button> 111 </div> 112 ); 113 } 114 115 if (state.state === "success") { 116 return ( 117 <div 118 className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`} 119 style={{ 120 background: 121 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)", 122 }} 123 > 124 <div className="flex gap-2 leading-snug font-bold italic"> 125 <div>You're subscribed!</div> 126 {/* <ShareButton /> */} 127 </div> 128 </div> 129 ); 130 } 131}; 132 133export const ShareButton = () => { 134 return ( 135 <button className="text-accent-contrast"> 136 <ShareSmall /> 137 </button> 138 ); 139}; 140 141const ConfirmCodeInput = (props: { 142 codeInputValue: string; 143 token: string; 144 setCodeInputValue: (value: string) => void; 145 setState: (state: State) => void; 146 publication: string; 147}) => { 148 let { mutate } = useIdentityData(); 149 return ( 150 <div className="relative w-fit mt-2"> 151 <Input 152 type="text" 153 pattern="[0-9]" 154 className="input-with-border pr-[88px]! py-1! max-w-[156px]" 155 placeholder="000000" 156 value={props.codeInputValue} 157 onChange={(e) => { 158 props.setCodeInputValue(e.currentTarget.value); 159 }} 160 /> 161 <ButtonPrimary 162 compact 163 className="absolute right-1 top-1 outline-0!" 164 onClick={async () => { 165 console.log( 166 await confirmEmailAuthToken(props.token, props.codeInputValue), 167 ); 168 169 await subscribeToPublicationWithEmail(props.publication); 170 //optimistically could add! 171 await mutate(); 172 props.setState({ state: "success" }); 173 return; 174 }} 175 > 176 Confirm 177 </ButtonPrimary> 178 </div> 179 ); 180}; 181 182export const SubscribeWithBluesky = (props: { 183 isPost?: boolean; 184 pubName: string; 185 pub_uri: string; 186 base_url: string; 187 subscribers: { identity: string }[]; 188}) => { 189 let { identity } = useIdentityData(); 190 let searchParams = useSearchParams(); 191 let [successModalOpen, setSuccessModalOpen] = useState( 192 !!searchParams.has("showSubscribeSuccess"), 193 ); 194 let subscribed = 195 identity?.atp_did && 196 props.subscribers.find((s) => s.identity === identity.atp_did); 197 198 if (successModalOpen) 199 return ( 200 <SubscribeSuccessModal 201 open={successModalOpen} 202 setOpen={setSuccessModalOpen} 203 /> 204 ); 205 if (subscribed) { 206 return <ManageSubscription {...props} />; 207 } 208 return ( 209 <div className="flex flex-col gap-2 text-center justify-center"> 210 {props.isPost && ( 211 <div className="text-sm text-tertiary font-bold"> 212 Get updates from {props.pubName}! 213 </div> 214 )} 215 <div className="flex flex-row gap-2 place-self-center"> 216 <BlueskySubscribeButton 217 pub_uri={props.pub_uri} 218 setSuccessModalOpen={setSuccessModalOpen} 219 /> 220 <a href={`${props.base_url}/rss`} className="flex" target="_blank"> 221 <span className="sr-only">Subscribe to RSS</span> 222 <RSSSmall className="self-center" aria-hidden /> 223 </a> 224 </div> 225 </div> 226 ); 227}; 228 229const ManageSubscription = (props: { 230 isPost?: boolean; 231 pubName: string; 232 pub_uri: string; 233 subscribers: { identity: string }[]; 234}) => { 235 let toaster = useToaster(); 236 let [hasFeed] = useState(false); 237 let [, unsubscribe, unsubscribePending] = useActionState(async () => { 238 await unsubscribeToPublication(props.pub_uri); 239 toaster({ 240 content: "You unsubscribed.", 241 type: "success", 242 }); 243 }, null); 244 return ( 245 <div 246 className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 247 > 248 <div className="font-bold text-tertiary text-sm"> 249 You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 250 </div> 251 <Popover 252 trigger={<div className="text-accent-contrast text-sm">Manage</div>} 253 > 254 <div className="max-w-sm flex flex-col gap-3 justify-center text-center"> 255 {!hasFeed && ( 256 <> 257 <div className="flex flex-col gap-2 font-bold text-secondary w-full"> 258 Updates via Bluesky custom feed! 259 <a 260 href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 261 target="_blank" 262 className=" place-self-center" 263 > 264 <ButtonPrimary>View Feed</ButtonPrimary> 265 </a> 266 </div> 267 <hr className="border-border-light" /> 268 </> 269 )} 270 <form action={unsubscribe}> 271 <button className="font-bold text-accent-contrast w-max place-self-center"> 272 {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 273 </button> 274 </form> 275 </div>{" "} 276 </Popover> 277 </div> 278 ); 279}; 280 281let BlueskySubscribeButton = (props: { 282 pub_uri: string; 283 setSuccessModalOpen: (open: boolean) => void; 284}) => { 285 let { identity } = useIdentityData(); 286 let toaster = useToaster(); 287 let [, subscribe, subscribePending] = useActionState(async () => { 288 let result = await subscribeToPublication( 289 props.pub_uri, 290 window.location.href + "?refreshAuth", 291 ); 292 if (result.hasFeed === false) { 293 props.setSuccessModalOpen(true); 294 } 295 toaster({ content: <div>You're Subscribed!</div>, type: "success" }); 296 }, null); 297 298 let [isClient, setIsClient] = useState(false); 299 useEffect(() => { 300 setIsClient(true); 301 }, []); 302 303 if (!identity?.atp_did) { 304 return ( 305 <Popover 306 asChild 307 trigger={ 308 <ButtonPrimary className="place-self-center"> 309 <BlueskyTiny /> Subscribe with Bluesky 310 </ButtonPrimary> 311 } 312 > 313 {isClient && ( 314 <LoginForm 315 text="Log in to subscribe to this publication!" 316 noEmail 317 redirectRoute={window?.location.href + "?refreshAuth"} 318 action={{ action: "subscribe", publication: props.pub_uri }} 319 /> 320 )} 321 </Popover> 322 ); 323 } 324 325 return ( 326 <> 327 <form 328 action={subscribe} 329 className="place-self-center flex flex-row gap-1" 330 > 331 <ButtonPrimary> 332 {subscribePending ? ( 333 <DotLoader /> 334 ) : ( 335 <> 336 <BlueskyTiny /> Subscribe with Bluesky 337 </> 338 )} 339 </ButtonPrimary> 340 </form> 341 </> 342 ); 343}; 344 345const SubscribeSuccessModal = ({ 346 open, 347 setOpen, 348}: { 349 open: boolean; 350 setOpen: (open: boolean) => void; 351}) => { 352 let searchParams = useSearchParams(); 353 let [loading, setLoading] = useState(false); 354 let toaster = useToaster(); 355 return ( 356 <Dialog.Root open={open} onOpenChange={setOpen}> 357 <Dialog.Trigger asChild></Dialog.Trigger> 358 <Dialog.Portal> 359 <Dialog.Overlay className="fixed inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-xs" /> 360 <Dialog.Content 361 className={` 362 z-20 opaque-container 363 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 364 w-96 px-3 py-4 365 max-w-(--radix-popover-content-available-width) 366 max-h-(--radix-popover-content-available-height) 367 overflow-y-scroll no-scrollbar 368 flex flex-col gap-1 text-center justify-center 369 `} 370 > 371 <Dialog.Title asChild={true}> 372 <h3>Subscribed!</h3> 373 </Dialog.Title> 374 <Dialog.Description className="w-full flex flex-col"> 375 You'll get updates about this publication via a Feed just for you. 376 <ButtonPrimary 377 className="place-self-center mt-4" 378 onClick={async () => { 379 if (loading) return; 380 381 setLoading(true); 382 let feedurl = 383 "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"; 384 await addFeed(); 385 toaster({ content: "Feed added!", type: "success" }); 386 setLoading(false); 387 window.open(feedurl, "_blank"); 388 }} 389 > 390 {loading ? <DotLoader /> : "Add Bluesky Feed"} 391 </ButtonPrimary> 392 <button 393 className="text-accent-contrast mt-1" 394 onClick={() => { 395 const newUrl = new URL(window.location.href); 396 newUrl.searchParams.delete("showSubscribeSuccess"); 397 window.history.replaceState({}, "", newUrl.toString()); 398 setOpen(false); 399 }} 400 > 401 No thanks 402 </button> 403 </Dialog.Description> 404 <Dialog.Close /> 405 </Dialog.Content> 406 </Dialog.Portal> 407 </Dialog.Root> 408 ); 409};