Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
11 kB · 333 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334"use client";import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field";import { useState } from "react";import { useRouter } from "next/navigation";import { ButtonPrimary, ButtonSecondary } from "components/Buttons";import { RSSTiny } from "components/Icons/RSSTiny";import { Input } from "components/Input";import { DotLoader } from "components/utils/DotLoader";import { useToaster } from "components/Toast";import { requestPublicationEmailSubscription } from "actions/publications/subscribeEmail";import { subscribeToPublication } from "actions/publications/subscribeToPublication";import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError";import { SUBSCRIBE_ERROR_MESSAGES } from "./subscribeErrors";import { onMouseDown as iosOnPointerDown } from "src/utils/iosInputMouseDown";import { theme } from "tailwind.config";import { EmailTiny } from "components/Icons/EmailTiny";import { Avatar } from "components/Avatar";import { refreshIdentityData, useIdentityData,} from "components/IdentityProvider";import { useRecordFromDid } from "src/utils/useRecordFromDid";import { Tooltip } from "components/Tooltip";import { SubscribeButtonModeMenu } from "./SubscribeButton";import { INPUT_HIGHLIGHT_CLASS } from "./inputHighlight";import type { SubscriptionSource } from "src/subscriptionSource";
export const EmailInput = (props: { // Omitted when the input only collects the address (e.g. the paid join // modal, where the tier buttons submit). action?: React.ReactNode; leading?: React.ReactNode; autoFocus?: boolean; large?: boolean; compact?: boolean; value: string; onChange: (val: string) => void; disabled?: boolean; loading?: boolean; publicationUrl?: string; // Flags the input as needing attention (e.g. the reader picked a tier before // entering their email); cleared on focus via onFocus. highlight?: boolean; onFocus?: () => void; // When set, the input is wrapped in a form so Enter submits. Callers that // provide their own outer form (e.g. LoginButton) should omit this. onSubmit?: () => void;}) => { let content = ( <> <div className={` input-with-border flex gap-2 w-full items-center mx-auto py-0! min-w-0 ${props.large && "px-2!"} ${props.highlight ? INPUT_HIGHLIGHT_CLASS : ""} `} style={ props.loading ? { backgroundColor: theme.colors["border-light"], color: theme.colors.tertiary, } : { backgroundColor: theme.colors["bg-page"], color: theme.colors.primary, } } > {props.leading && ( <div className="text-tertiary shrink-0 flex items-center"> {props.leading} </div> )} <Input type="email" autoFocus={props.autoFocus} className={`appearance-none! outline-none! grow min-w-0 ${props.large ? "py-1!" : `${props.compact ? "py-0!" : "py-0.5"} disabled:text-tertiary disabled:italic disabled:border-border-light`}`} disabled={props.disabled || props.loading} placeholder="email@example.com" size={0} value={props.value} onChange={(e) => props.onChange(e.target.value)} onFocus={props.onFocus} /> <div className={` text-accent-contrast flex items-center shrink-0 `}> {props.loading ? <DotLoader /> : props.action} </div> </div> {props.publicationUrl && ( <a href={`${props.publicationUrl}/rss`} target="_blank" rel="noopener noreferrer" className={`no-underlinetext-accent-contrast`} > <ButtonSecondary className={`${props.compact ? "p-[3px]!" : "p-[6px]!"} border-border!`} > <RSSTiny /> </ButtonSecondary> </a> )} </> ); return props.onSubmit ? ( <form className="flex gap-1 w-full min-w-0" onSubmit={(e) => { e.preventDefault(); props.onSubmit?.(); }} > {content} </form> ) : ( <div className="flex gap-1 w-full min-w-0">{content}</div> );};
export const EmailButton = (props: { publicationUri: string; publicationUrl?: string; source?: SubscriptionSource; email: string; handle?: string; compact?: boolean; onSubscribed?: () => void; // The dropdown can also subscribe via the linked atproto handle, so the // caller needs the mode to open the matching success modal. Falls back to a // toast when omitted. onSuccess?: (mode: "email" | "atproto") => void;}) => { let toaster = useToaster(); let router = useRouter(); let { identity } = useIdentityData(); let { data: record } = useRecordFromDid(identity?.atp_did); let [loading, setLoading] = useState(false); let hasModeMenu = props.compact || props.handle; // The main button always subscribes via email; the atproto handle is offered // as a one-click alternate in the dropdown. let tooltipLabel = props.email || null;
const avatar = ( <Avatar size="tiny" src={record?.avatar} displayName={record?.displayName || record?.handle} /> );
const subscribeEmail = async () => { let res = await requestPublicationEmailSubscription( props.publicationUri, props.email, props.source, ); if (!res.ok) { toaster({ type: "error", content: SUBSCRIBE_ERROR_MESSAGES[res.error] }); return false; } return true; };
const subscribeAtproto = async () => { let res = await subscribeToPublication( props.publicationUri, window.location.href, props.source, ); if (!res.success) { toaster({ type: "error", content: isOAuthSessionError(res.error) ? ( <OAuthErrorMessage error={res.error} /> ) : ( "We couldn't subscribe you. Try again." ), }); return false; } return true; };
const subscribe = async (mode: "email" | "atproto") => { if (loading) return; setLoading(true); let ok = mode === "email" ? await subscribeEmail() : await subscribeAtproto(); setLoading(false); if (!ok) return; if (props.onSuccess) props.onSuccess(mode); else toaster({ content: <div>You're Subscribed!</div>, type: "success" }); props.onSubscribed?.(); refreshIdentityData(); router.refresh(); };
const subscribeButton = ( <ButtonPrimary compact={props.compact} disabled={loading} onClick={() => subscribe("email")} className={`text-sm grow shrink! min-w-0 flex gap-2 items-center ${hasModeMenu ? "gap-1! rounded-r-none! hover:outline-transparent! focus:outline-transparent!" : ""} `} > {loading ? ( <DotLoader /> ) : ( <> <EmailTiny className="shrink-0" /> <span className={`shrink-0`}> Subscribe</span>{" "} <span className={`truncate min-w-0 ${props.compact && "hidden"}`}> with {props.email} </span> </> )} </ButtonPrimary> );
return ( <div className="flex gap-1 max-w-full w-fit min-w-0"> <div className={`flex grow min-w-0 ${props.compact ? "group rounded-md outline-2 outline-transparent outline-offset-1 hover:outline-accent-1 focus-within:outline-accent-1 shrink-0" : ""}`} > {props.compact && tooltipLabel ? ( <Tooltip asChild delayDuration={0} side="top" trigger={subscribeButton} className="text-sm p-1! text-tertiary" > {tooltipLabel} </Tooltip> ) : ( subscribeButton )} {hasModeMenu ? ( <SubscribeButtonModeMenu disabled={loading} publicationUrl={props.publicationUrl} accounts={[ { value: "email", label: props.email, icon: <EmailTiny />, selected: true, onSelect: () => subscribe("email"), }, ...(props.handle ? [ { value: "atproto" as const, label: `@${props.handle}`, icon: avatar, selected: false, onSelect: () => subscribe("atproto"), }, ] : []), ]} /> ) : null} </div> {!props.compact && ( <a href={`${props.publicationUrl}/rss`} target="_blank" rel="noopener noreferrer" className={`no-underline shrink-0 ${props.compact ? "w-6" : "w-7"}`} > <ButtonPrimary className="h-full! w-auto! py-0! px-0! aspect-square"> <RSSTiny /> </ButtonPrimary> </a> )} </div> );};
export const EmailConfirm = (props: { emailValue: string; autoFocus?: boolean; loading?: boolean; onSubmit: (code: string) => void; onBack: () => void;}) => { let inputClassName = `input-with-border text-2xl w-8 h-12 text-center ${props.loading ? "bg-border-light! text-tertiary!" : ""}`; return ( <div className="flex flex-col text-center max-w-sm pb-2 text-secondary leading-snug"> <h3>Confirm your email</h3> Enter the code sent to <br /> <div className="italic min-w-0 truncate">{props.emailValue}</div> <OneTimePasswordField.Root autoSubmit autoFocus={props.autoFocus} validationType="alphanumeric" onAutoSubmit={(value) => { props.onSubmit(value); }} > <div className="flex gap-1 pt-4 w-full justify-center"> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> <OneTimePasswordField.Input className={inputClassName} onPointerDown={iosOnPointerDown} /> </div> <OneTimePasswordField.HiddenInput /> </OneTimePasswordField.Root> <button className="text-sm text-accent-contrast mt-1" onClick={() => props.onBack()} > Use a different email? </button> </div> );};