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