Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
16 kB · 442 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443"use client";import { buildOauthLoginUrl } from "src/utils/customDomain";import { ButtonPrimary, ButtonSecondary } from "components/Buttons";import { Popover } from "components/Popover";import Link from "next/link";import { useState } from "react";import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions";import { subscribeToPublication } from "actions/publications/subscribeToPublication";import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError";import { useToaster } from "components/Toast";import { DotLoader } from "components/utils/DotLoader";import type { OAuthSessionError } from "src/atproto-oauth";import { HandleSearchInput } from "components/HandleSearchInput";import { Avatar } from "components/Avatar";import { useIdentityData, refreshIdentityData,} from "components/IdentityProvider";import { useRecordFromDid } from "src/utils/useRecordFromDid";import { LinkIdentityModal } from "./LinkIdentityModal";import { RSSTiny } from "components/Icons/RSSTiny";import { Tooltip } from "components/Tooltip";import { SubscribeButtonModeMenu } from "./SubscribeButton";import { RecommendedPublications } from "./RecommendedPublications";import { useSubscribeSuccessData } from "./useSubscribeSuccessData";import type { SubscriptionSource } from "src/subscriptionSource";const apps = [ { name: "Leaflet", logo: "https://leaflet.pub/logos/leaflet.svg" }, { name: "Bluesky", logo: "https://leaflet.pub/logos/bluesky.svg" }, { name: "Blacksky", logo: "https://leaflet.pub/logos/blacksky.svg" }, { name: "Eurosky", logo: "https://leaflet.pub/logos/eurosky.svg" }, { name: "Tangled", logo: "https://leaflet.pub/logos/tangled.svg" }, { name: "Semble", logo: "https://leaflet.pub/logos/semble.svg" }, { name: "Surf", logo: "https://leaflet.pub/logos/surf.svg" }, { name: "Spark", logo: "https://leaflet.pub/logos/spark.svg" }, { name: "Pckt", logo: "https://leaflet.pub/logos/pckt.svg" }, { name: "pdsls", logo: "https://leaflet.pub/logos/pdsls.svg" }, { name: "plyr.fm", logo: "https://leaflet.pub/logos/plyr.fm.svg" }, { name: "Popfeed", logo: "https://leaflet.pub/logos/popfeed.svg" }, { name: "Roomy", logo: "https://leaflet.pub/logos/roomy.svg" }, { name: "Sill", logo: "https://leaflet.pub/logos/sill.svg" }, { name: "Offprint", logo: "https://leaflet.pub/logos/offprint.svg" }, { name: "Margin", logo: "https://leaflet.pub/logos/margin.svg" }, { name: "Anisota", logo: "https://leaflet.pub/logos/anisota.svg" }, { name: "Blento", logo: "https://leaflet.pub/logos/blento.svg" }, { name: "Cartridge", logo: "https://leaflet.pub/logos/cartridge.svg" }, { name: "Graze", logo: "https://leaflet.pub/logos/graze.svg" },];
// True when this document is running inside an iframe. Reading `window.top`// across origins throws a SecurityError, which itself only happens when we're// framed by a different origin — so treat that as being framed too.const isInIframe = () => { if (typeof window === "undefined") return false; try { return window.self !== window.top; } catch { return true; }};
export const SubscribeWithHandle = (props: { autoFocus?: boolean; compact?: boolean; publicationUri: string; publicationUrl?: string; source?: SubscriptionSource; onSubscribed?: () => void; onAtSuccess?: () => void; leading?: React.ReactNode; user: { loggedIn: boolean; email: string | undefined; handle: string | undefined; };}) => { let toaster = useToaster(); let { identity } = useIdentityData(); let { data: record } = useRecordFromDid(identity?.atp_did); let [loading, setLoading] = useState(false); let [subscribing, setSubscribing] = useState(false); let [oauthError, setOauthError] = useState<OAuthSessionError | null>(null); // When an email-only user subscribes via the atproto flow, we surface a // confirmation modal first ("link Bluesky to your account?") so they can't // accidentally orphan their email account. let [pendingLinkHandle, setPendingLinkHandle] = useState<string | null>(null); const viewerEmail = identity?.email; const viewerAtpDid = identity?.atp_did; const needsLinkConfirmation = !!viewerEmail && !viewerAtpDid;
const redirectToOauthForSubscribe = (handle: string, link: boolean) => { let action = encodeActionToSearchParam({ action: "subscribe", publication: props.publicationUri, // The subscribe completes after the OAuth redirect, so stamp the // originating page into the source now. ...(props.source ? { source: { url: window.location.href, ...props.source } } : {}), }); let inIframe = isInIframe(); let url = new URL(window.location.href); if (inIframe) { url.searchParams.set("showSubscribeSuccess", "true"); url.searchParams.set("subscribed_pub", props.publicationUri); } let loginUrl = buildOauthLoginUrl({ handle, redirect: url.toString(), action, link, autoMerge: link, }); if (inIframe) { window.open(loginUrl, "_blank", "noopener,noreferrer"); // The iframe stays put while the user completes login in the new tab — // clear the pending spinner so the embedded form isn't stuck loading. setLoading(false); return; } window.location.href = loginUrl; };
// Without a handle there's no atproto identity to one-click subscribe with, so // fall through to the logged-out HandleSearchInput form (same as a logged-out user). if (props.user.loggedIn && props.user.handle) { let tooltipLabel = props.user.handle ? `@${props.user.handle}` : null; let avatar = ( <Avatar size="tiny" src={record?.avatar} displayName={record?.displayName || record?.handle} /> ); const subscribeAtproto = async () => { if (subscribing) return; setSubscribing(true); setOauthError(null); let result = await subscribeToPublication( props.publicationUri, window.location.href, props.source, ); if (!result.success) { if (isOAuthSessionError(result.error)) setOauthError(result.error); setSubscribing(false); return; } if (props.onAtSuccess) { props.onAtSuccess(); } else { toaster({ content: <div>You're Subscribed!</div>, type: "success", }); } props.onSubscribed?.(); // onSubscribed only flips this instance's local state; the subscription // now on the identity is what every other SubscribeButton on the page // reads, and on a published page nothing else will refetch it. refreshIdentityData(); setSubscribing(false); }; let subscribeButton = ( <ButtonPrimary compact={props.compact} className={` subscribeButton text-sm grow shrink! ${ props.compact ? "gap-1! min-w-0 flex items-center rounded-r-none! hover:outline-transparent! focus:outline-transparent!" : "" }`} disabled={subscribing} onClick={subscribeAtproto} > {subscribing ? ( <DotLoader /> ) : ( <> {avatar} <div className="flex grow min-w-0"> <div className="shrink-0 pr-[6px]">Subscribe</div> {!props.compact && ( <span className="grow truncate min-w-0"> as {props.user.handle} </span> )} </div> </> )} </ButtonPrimary> ); return ( <div className="flex flex-col gap-2 w-fit max-w-full min-w-0"> <div className="flex items-stretch gap-1 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.leading && ( <div className="shrink-0 flex items-center">{props.leading}</div> )} {props.compact && tooltipLabel ? ( <Tooltip asChild delayDuration={0} side="top" trigger={subscribeButton} className="text-sm p-1! text-tertiary" > {tooltipLabel} </Tooltip> ) : ( subscribeButton )} {props.compact && ( <SubscribeButtonModeMenu disabled={subscribing} publicationUrl={props.publicationUrl} accounts={[ { value: "atproto", label: `@${props.user.handle}`, icon: avatar, selected: true, onSelect: subscribeAtproto, }, ]} /> )} </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> {oauthError && ( <OAuthErrorMessage error={oauthError} className="text-center text-sm text-accent-1" /> )} </div> ); } else return ( <div className="subscribeHandleInputWrapper max-w-sm mx-auto w-full min-w-0"> <div className="flex gap-1 w-full"> <HandleSearchInput autoFocus={props.autoFocus} compact={props.compact} loading={loading} leading={props.leading} onSubmit={(handle) => { let trimmed = handle.trim(); if (!trimmed) return; if (needsLinkConfirmation) { setPendingLinkHandle(trimmed); return; } setLoading(true); redirectToOauthForSubscribe(trimmed, false); }} action=<div className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm min-w-20 shrink-0"> Subscribe </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> )} </div> <div className="pt-1 w-fit mx-auto"> <AtmosphericHandleInfo /> </div> {needsLinkConfirmation && ( <LinkIdentityModal open={pendingLinkHandle !== null} onOpenChange={(open) => { if (!open) setPendingLinkHandle(null); }} signedInAs={viewerEmail!} linkingIdentity={`@${pendingLinkHandle ?? ""}`} confirmButtonLabel="Link Bluesky" confirming={loading} onConfirm={() => { if (!pendingLinkHandle) return; setLoading(true); redirectToOauthForSubscribe(pendingLinkHandle, true); }} /> )} </div> );};
export const LinkHandle = (props: { compact?: boolean }) => { let [loading, setLoading] = useState(false); return ( <div className={`flex flex-col text-center justify-center ${props.compact ? "gap-3" : "gap-4"}`} > <div className={`text-secondary flex flex-col ${props.compact && "text-sm leading-snug"}`} > <h4 className={`${props.compact && "text-sm"}`}> Link your Atmosphere account </h4> <div className="text-tertiary"> to comment, recommend, and see what your friends are reading </div> <AtmosphericHandleInfo /> </div> <div className="text-base"> <HandleSearchInput loading={loading} onSubmit={(handle) => { let trimmed = handle.trim(); if (!trimmed) return; setLoading(true); window.location.href = buildOauthLoginUrl({ handle: trimmed, redirect: window.location.href, }); }} action={ <div className="bg-accent-1 rounded-md px-1 text-accent-2 font-bold text-sm"> Link </div> } /> </div> </div> );};
export const AtSubscribeSuccess = (props: { publicationUri?: string }) => { let { loading, publicationName, listings } = useSubscribeSuccessData( props.publicationUri, ); if (loading) return ( <div className="flex justify-center items-center py-8 text-secondary w-full max-w-full sm:min-w-md"> <DotLoader /> </div> ); return ( <div className="flex flex-col text-center justify-center p-4 text-secondary w-full max-w-full sm:w-auto sm:min-w-md sm:max-w-2xl"> <h2 className="text-primary pb-1"> {publicationName ? `You've subscribed to ${publicationName}!` : "You've Subscribed!"} </h2> You'll receive new posts in the <br /> <Link href={"https://leaflet.pub/reader"}>Leaflet Reader</Link> <br /> <span className="text-tertiary text-sm"> or any standard.site enabled reader! </span> <hr className="my-4 border-border-light" /> <div className="flex flex-col"> <h4>Other ways to follow</h4> <Link href="">Get the RSS Feed</Link> <Link href="">Pin Custom Feed in Bluesky</Link> </div> <RecommendedPublications publicationName={publicationName} recommendingPublicationUri={props.publicationUri} listings={listings} /> </div> );};
export const AtmosphericHandleInfo = (props: { trigger?: React.ReactNode }) => { return ( <Popover className="z-100! w-[min(24rem,var(--radix-popover-content-available-width))] flex flex-col" trigger={ props.trigger ? ( props.trigger ) : ( <div className="text-accent-contrast text-sm mx-auto"> What's the Atmosphere? </div> ) } > <div className="font-bold text-secondary pb-1 "> The Atmosphere is a growing ecosystem of social apps, like Leaflet and Bluesky. <br /> </div> <div className="pb-3 font-bold text-secondary"> One account gets you into <em>all</em> of them. </div>
<div className=" text-sm text-tertiary uppercase"> Apps on the Atmosphere! </div> <div className="opaque-container pt-3 pb-2 overflow-hidden"> <div className="logo-scroll-track flex w-max"> {[...apps, ...apps].map((app, i) => ( <AtApp key={i} logo={app.logo} name={app.name} /> ))} </div> </div>
<ButtonPrimary fullWidth className="mt-3 mx-auto mb-3"> Sign up via Bluesky! </ButtonPrimary> </Popover> );};
const AtApp = (props: { logo: string; name: string }) => { return ( <div className="w-20 flex-shrink-0 flex flex-col gap-2 justify-center text-tertiary font-bold text-sm text-center"> <img src={props.logo} alt={props.name} className="w-12 h-12 mx-auto" /> {props.name} </div> );};