From c2191c83fda36c65077e572a4ae57d889b068eea Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 27 Apr 2026 23:09:32 -0400 Subject: [PATCH 01/34] show attach account on attempted recommend --- components/LoginButton.tsx | 12 ++++- components/RecommendButton.tsx | 82 +++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx index a02c46be..3ad565bb 100644 --- a/components/LoginButton.tsx +++ b/components/LoginButton.tsx @@ -24,11 +24,19 @@ import { mutate } from "swr"; export const LoginModal = (props: { noEmailLogin?: boolean; - trigger: React.ReactNode; + trigger?: React.ReactNode; asChild?: boolean; redirectRoute?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) => { - let [open, setOpen] = useState(false); + let [internalOpen, setInternalOpen] = useState(false); + let isControlled = props.open !== undefined; + let open = props.open ?? internalOpen; + let setOpen = (o: boolean) => { + if (!isControlled) setInternalOpen(o); + props.onOpenChange?.(o); + }; return ( (null); + const [loginOpen, setLoginOpen] = useState(false); const toaster = useToaster(); const smoker = useSmoker(); @@ -75,6 +79,10 @@ export function RecommendButton(props: { const handleClick = async (e: React.MouseEvent) => { if (isPending || isLoading) return; + if (!identity?.atp_did) { + setLoginOpen(true); + return; + } const currentlyRecommended = displayRecommended; setIsPending(true); @@ -117,43 +125,34 @@ export function RecommendButton(props: { setIsPending(false); }; - if (props.expanded) - return ( - { - e.preventDefault(); - e.stopPropagation(); - handleClick(e); - }} - > - {displayRecommended ? ( - - ) : ( - - )} -
- {count > 0 && ( - <> - - {count} - - - - )} - {displayRecommended ? "Recommended!" : "Recommend"} -
-
- ); + const onClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + handleClick(e); + }; - return ( + const button = props.expanded ? ( + + {displayRecommended ? ( + + ) : ( + + )} +
+ {count > 0 && ( + <> + + {count} + + + + )} + {displayRecommended ? "Recommended!" : "Recommend"} +
+
+ ) : ( ); + + return ( + <> + {button} + {loginOpen && ( + + )} + + ); } -- 2.51.2 From 9f74038182310054ecc34f98e28f1dddb28e1780 Mon Sep 17 00:00:00 2001 From: celine Date: Tue, 28 Apr 2026 15:13:16 -0400 Subject: [PATCH 02/34] added text primary to modal --- components/Modal.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/Modal.tsx b/components/Modal.tsx index dee76cee..2c53df1d 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -28,9 +28,12 @@ export const Modal = ({ @@ -44,7 +47,7 @@ export const Modal = ({ > {title ? ( -

{title}

+

{title}

) : ( -- 2.51.2 From 0bc74a2e86379958fb2c10b7d569e6b2e23532fd Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 28 Apr 2026 23:02:06 -0400 Subject: [PATCH 03/34] add merge account flow if user subs with other method --- actions/mergeIdentity.ts | 150 ++------------------ actions/publications/subscribeEmail.tsx | 70 ++++++++- app/api/oauth/[route]/route.ts | 63 +++++++-- components/Subscribe/HandleSubscribe.tsx | 51 +++++-- components/Subscribe/LinkIdentityModal.tsx | 69 +++++++++ components/Subscribe/SubscribeButton.tsx | 79 ++++++++--- src/mergeIdentity.ts | 156 +++++++++++++++++++++ 7 files changed, 460 insertions(+), 178 deletions(-) create mode 100644 components/Subscribe/LinkIdentityModal.tsx create mode 100644 src/mergeIdentity.ts diff --git a/actions/mergeIdentity.ts b/actions/mergeIdentity.ts index 2bb76aa4..d8713fa0 100644 --- a/actions/mergeIdentity.ts +++ b/actions/mergeIdentity.ts @@ -1,18 +1,6 @@ "use server"; import { cookies } from "next/headers"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { eq, inArray, sql } from "drizzle-orm"; -import { - custom_domains, - email_auth_tokens, - identities, - permission_token_on_homepage, - publication_email_subscribers, - user_entitlements, - user_subscriptions, -} from "drizzle/schema"; -import { pool } from "supabase/pool"; import { supabaseServerClient } from "supabase/serverClient"; import { AUTH_TOKEN_COOKIE, @@ -22,13 +10,10 @@ import { setAuthToken, } from "src/auth"; import { Err, Ok, type Result } from "src/result"; - -type MergeError = - | "merge_not_pending" - | "invalid_source" - | "invalid_target" - | "same_identity" - | "database_error"; +import { + mergeEmailIdentityIntoAtpIdentity, + type MergeError, +} from "src/mergeIdentity"; export async function confirmIdentityMerge(): Promise> { const jar = await cookies(); @@ -48,128 +33,11 @@ export async function confirmIdentityMerge(): Promise> return Err("invalid_source"); if (!target.identity.atp_did) return Err("invalid_target"); - const sourceId = source.identity.id; - const targetId = target.identity.id; - const sourceEmail = source.identity.email; - - const client = await pool.connect(); - try { - const db = drizzle(client); - await db.transaction(async (tx) => { - // Re-verify invariants under a row lock. Protects against racing - // concurrent merges or an identity being mutated between cookie - // resolution and this transaction. - const locked = await tx - .select({ - id: identities.id, - email: identities.email, - atp_did: identities.atp_did, - }) - .from(identities) - .where(inArray(identities.id, [sourceId, targetId])) - .for("update"); - const lockedSource = locked.find((r) => r.id === sourceId); - const lockedTarget = locked.find((r) => r.id === targetId); - if (!lockedSource || !lockedTarget) - throw new Error("merge: identity disappeared under lock"); - if (lockedSource.atp_did !== null) - throw new Error("merge: source has atp_did"); - if (lockedTarget.atp_did === null) - throw new Error("merge: target missing atp_did"); - if (!lockedSource.email) throw new Error("merge: source missing email"); - - // email_auth_tokens: caller is about to swap auth_token to the pending - // token (which already points at target). Source's tokens are now stale. - await tx - .delete(email_auth_tokens) - .where(eq(email_auth_tokens.identity, sourceId)); - - // Target wins on (identity_id) PK collision. - await tx.execute(sql` - delete from user_subscriptions - where identity_id = ${sourceId} - and exists (select 1 from user_subscriptions where identity_id = ${targetId}) - `); - await tx - .update(user_subscriptions) - .set({ identity_id: targetId }) - .where(eq(user_subscriptions.identity_id, sourceId)); - - // Target wins on (identity_id, entitlement_key) PK collision. - await tx.execute(sql` - delete from user_entitlements - where identity_id = ${sourceId} - and entitlement_key in ( - select entitlement_key from user_entitlements where identity_id = ${targetId} - ) - `); - await tx - .update(user_entitlements) - .set({ identity_id: targetId }) - .where(eq(user_entitlements.identity_id, sourceId)); - - // Target wins on (token, identity) PK collision. - await tx.execute(sql` - delete from permission_token_on_homepage - where identity = ${sourceId} - and token in ( - select token from permission_token_on_homepage where identity = ${targetId} - ) - `); - await tx - .update(permission_token_on_homepage) - .set({ identity: targetId }) - .where(eq(permission_token_on_homepage.identity, sourceId)); - - // Target wins on unique (publication, email) collision. - await tx.execute(sql` - delete from ${publication_email_subscribers} - where ${publication_email_subscribers.identity_id} = ${sourceId} - and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( - select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} - from ${publication_email_subscribers} - where ${publication_email_subscribers.identity_id} = ${targetId} - ) - `); - await tx - .update(publication_email_subscribers) - .set({ identity_id: targetId }) - .where(eq(publication_email_subscribers.identity_id, sourceId)); - - await tx - .update(custom_domains) - .set({ identity_id: targetId }) - .where(eq(custom_domains.identity_id, sourceId)); - - // identities.email is unique — step via NULL so we don't collide - // mid-swap. custom_domains.identity is nullable and cascades on update; - // we re-set it explicitly after the final value lands because NULL→value - // cascades don't fire. - await tx - .update(identities) - .set({ email: null }) - .where(eq(identities.id, targetId)); - await tx - .update(identities) - .set({ email: null }) - .where(eq(identities.id, sourceId)); - await tx - .update(identities) - .set({ email: sourceEmail }) - .where(eq(identities.id, targetId)); - await tx - .update(custom_domains) - .set({ identity: sourceEmail }) - .where(eq(custom_domains.identity_id, targetId)); - - await tx.delete(identities).where(eq(identities.id, sourceId)); - }); - } catch (e) { - console.error("[mergeIdentity] transaction failed:", e); - return Err("database_error"); - } finally { - client.release(); - } + const result = await mergeEmailIdentityIntoAtpIdentity({ + sourceId: source.identity.id, + targetId: target.identity.id, + }); + if (!result.ok) return result; await setAuthToken(pendingTokenId!); await removePendingMergeToken(); diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index ee091346..158abc37 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -1,6 +1,7 @@ "use server"; import { getIdentityData } from "actions/getIdentityData"; +import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; import { supabaseServerClient } from "supabase/serverClient"; import { setAuthToken } from "src/auth"; import { PubConfirmEmail } from "emails/pubConfirmEmail"; @@ -29,7 +30,11 @@ type RequestError = | "suppression_delete_failed" | ConfirmationError; type RequestSuccess = { confirmed: boolean }; -type ConfirmError = "subscriber_not_found" | ConfirmationError; +type ConfirmError = + | "subscriber_not_found" + | "link_invalid_state" + | "email_belongs_to_other_account" + | ConfirmationError; type UnsubscribeError = "unauthorized" | "not_subscribed" | "database_error"; export async function requestPublicationEmailSubscription( @@ -174,6 +179,7 @@ export async function confirmPublicationEmailSubscription( publicationUri: string, emailRaw: string, code: string, + linkToCurrent: boolean = false, ): Promise> { const email = emailRaw.trim().toLowerCase(); @@ -192,8 +198,15 @@ export async function confirmPublicationEmailSubscription( // The confirmation code proves ownership of `email`. Issue (or look up) an // auth token for it so the subscriber is logged in and can one-click // subscribe to other publications from the same device. - const identityId = await ensureAuthTokenForEmail(email); - if (!identityId) return Err("database_error"); + let identityId: string | null; + if (linkToCurrent) { + const linkResult = await linkEmailToCurrentIdentity(email); + if (!linkResult.ok) return linkResult; + identityId = linkResult.value; + } else { + identityId = await ensureAuthTokenForEmail(email); + if (!identityId) return Err("database_error"); + } const [{ error: updateError }, { error: eventError }] = await Promise.all([ supabaseServerClient @@ -312,6 +325,57 @@ export async function unsubscribeFromPublication( return Ok(null); } +// Confirms an email subscription where the user has already chosen to link +// the email to their currently signed-in atp-only identity (via the +// LinkIdentityModal in the subscribe flow). Either attaches `email` to the +// current identity, or — if another email-only identity already owns it — +// merges that identity into the current one. +async function linkEmailToCurrentIdentity( + email: string, +): Promise> { + const [current, { data: existing }] = await Promise.all([ + getIdentityData(), + supabaseServerClient + .from("identities") + .select("id, atp_did") + .eq("email", email) + .maybeSingle(), + ]); + if (!current || !current.atp_did) return Err("link_invalid_state"); + + // Already linked — confirmation is a no-op for the identity row itself. + if (current.email && current.email.toLowerCase() === email) + return Ok(current.id); + + // Current identity already has a *different* email. Linking would clobber + // it, which the modal didn't promise — refuse. + if (current.email) return Err("link_invalid_state"); + + if (!existing || existing.id === current.id) { + const { error } = await supabaseServerClient + .from("identities") + .update({ email }) + .eq("id", current.id); + if (error) { + console.error("[subscribeEmail] attach email failed:", error); + return Err("database_error"); + } + return Ok(current.id); + } + + // Existing identity owns this email and isn't us. We can only merge if it's + // an unlinked email-only account; otherwise it has its own atp_did and + // merging would silently drop one of the two Bluesky links. + if (existing.atp_did) return Err("email_belongs_to_other_account"); + + const merged = await mergeEmailIdentityIntoAtpIdentity({ + sourceId: existing.id, + targetId: current.id, + }); + if (!merged.ok) return Err("database_error"); + return Ok(current.id); +} + async function ensureAuthTokenForEmail(email: string): Promise { const existingIdentity = await getIdentityData(); if (existingIdentity) return existingIdentity.id; diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index aa72fa0c..d5a8c95e 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -16,11 +16,16 @@ import { parseActionFromSearchParam, } from "./afterSignInActions"; import { inngest } from "app/api/inngest/client"; +import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; type OauthRequestClientState = { redirect: string | null; action: ActionAfterSignIn | null; link?: boolean; + // Auto-confirm a cross-identity merge instead of routing to /merge-accounts. + // Set when the caller already showed an in-context "link this account?" + // confirmation (e.g. the subscribe-flow LinkIdentityModal). + autoMerge?: boolean; }; export async function GET( @@ -39,11 +44,17 @@ export async function GET( const handle = searchParams.get("handle") as string; const signup = searchParams.get("signup") === "true"; const link = searchParams.get("link") === "true"; + const autoMerge = searchParams.get("autoMerge") === "true"; // Put originating page here! let redirect = searchParams.get("redirect_url"); if (redirect) redirect = decodeURIComponent(redirect); let action = parseActionFromSearchParam(searchParams.get("action")); - let state: OauthRequestClientState = { redirect, action, link }; + let state: OauthRequestClientState = { + redirect, + action, + link, + autoMerge, + }; // Revoke any pending authentication requests if the connection is closed (optional) const ac = new AbortController(); @@ -90,7 +101,8 @@ export async function GET( // Explicit link flow from the LoginModal for an email-only user. Never // fall through to a normal DID login — we must either attach the atp_did - // to the existing email identity or route to /merge-accounts. + // to the existing email identity or route to /merge-accounts (or merge + // inline if autoMerge is set). if ( s.link && currentIdentity && @@ -105,9 +117,27 @@ export async function GET( return handleAction(s.action, redirectPath); } if (identity.id !== currentIdentity.id) { - await stagePendingMerge(identity.id, redirectPath); - // Only reached if the token insert failed. Fall through to the - // normal sign-in flow rather than blocking the user. + if (s.autoMerge) { + const merged = await mergeEmailIdentityIntoAtpIdentity({ + sourceId: currentIdentity.id, + targetId: identity.id, + }); + if (merged.ok) { + // Source identity is gone; clear it so the cross-identity + // merge block below doesn't try to merge a deleted row. + currentIdentity = null; + } else { + console.error( + "[oauth/callback] autoMerge failed:", + merged.error, + ); + await stagePendingMerge(identity.id, redirectPath); + } + } else { + await stagePendingMerge(identity.id, redirectPath); + // Only reached if the token insert failed. Fall through to the + // normal sign-in flow rather than blocking the user. + } } // Same identity already linked — fall through to refresh session. } @@ -133,10 +163,25 @@ export async function GET( currentIdentity.email ) { // DID already has an identity row. Caller is currently signed in as a - // *different* email-only identity. Stage a pending merge and let the - // user confirm on /merge-accounts before we touch either account. - await stagePendingMerge(identity.id, redirectPath); - // Only reached if the token insert failed — fall through. + // *different* email-only identity. Either merge inline (autoMerge + // means the caller already collected the user's confirmation) or + // stage a pending merge for /merge-accounts. + if (s.autoMerge) { + const merged = await mergeEmailIdentityIntoAtpIdentity({ + sourceId: currentIdentity.id, + targetId: identity.id, + }); + if (!merged.ok) { + console.error( + "[oauth/callback] autoMerge failed:", + merged.error, + ); + await stagePendingMerge(identity.id, redirectPath); + } + } else { + await stagePendingMerge(identity.id, redirectPath); + // Only reached if the token insert failed — fall through. + } } // Trigger migration if identity needs it diff --git a/components/Subscribe/HandleSubscribe.tsx b/components/Subscribe/HandleSubscribe.tsx index cb11cca7..be798e08 100644 --- a/components/Subscribe/HandleSubscribe.tsx +++ b/components/Subscribe/HandleSubscribe.tsx @@ -13,6 +13,7 @@ import { HandleInput } from "./HandleInput"; import { Avatar } from "components/Avatar"; import { useIdentityData } from "components/IdentityProvider"; import { useRecordFromDid } from "src/utils/useRecordFromDid"; +import { LinkIdentityModal } from "./LinkIdentityModal"; const apps = [ { name: "Leaflet", logo: "/logos/leaflet.svg" }, { name: "Bluesky", logo: "/logos/bluesky.svg" }, @@ -54,6 +55,26 @@ export const SubscribeWithHandle = (props: { let [loading, setLoading] = useState(false); let [subscribing, setSubscribing] = useState(false); let [oauthError, setOauthError] = useState(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(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, + }); + let url = new URL(window.location.href); + url.searchParams.set("refreshAuth", ""); + let redirectUrl = encodeURIComponent(url.toString()); + let extra = link ? "&link=true&autoMerge=true" : ""; + window.location.href = `/api/oauth/login?handle=${encodeURIComponent(handle)}&redirect_url=${redirectUrl}&action=${action}${extra}`; + }; if (props.user.loggedIn && props.user.handle) { return ( @@ -119,15 +140,12 @@ export const SubscribeWithHandle = (props: { onSubmit={(handle) => { let trimmed = handle.trim(); if (!trimmed) return; + if (needsLinkConfirmation) { + setPendingLinkHandle(trimmed); + return; + } setLoading(true); - let action = encodeActionToSearchParam({ - action: "subscribe", - publication: props.publicationUri, - }); - let url = new URL(window.location.href); - url.searchParams.set("refreshAuth", ""); - let redirectUrl = encodeURIComponent(url.toString()); - window.location.href = `/api/oauth/login?handle=${encodeURIComponent(trimmed)}&redirect_url=${redirectUrl}&action=${action}`; + redirectToOauthForSubscribe(trimmed, false); }} action=
Subscribe @@ -136,6 +154,23 @@ export const SubscribeWithHandle = (props: {
+ {needsLinkConfirmation && ( + { + if (!open) setPendingLinkHandle(null); + }} + signedInAs={viewerEmail!} + linkingIdentity={`@${pendingLinkHandle ?? ""}`} + confirmButtonLabel="Link Bluesky" + confirming={loading} + onConfirm={() => { + if (!pendingLinkHandle) return; + setLoading(true); + redirectToOauthForSubscribe(pendingLinkHandle, true); + }} + /> + )}
); }; diff --git a/components/Subscribe/LinkIdentityModal.tsx b/components/Subscribe/LinkIdentityModal.tsx new file mode 100644 index 00000000..73a5923c --- /dev/null +++ b/components/Subscribe/LinkIdentityModal.tsx @@ -0,0 +1,69 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { mutate } from "swr"; +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; +import { Modal } from "components/Modal"; +import { DotLoader } from "components/utils/DotLoader"; + +export const LinkIdentityModal = (props: { + open: boolean; + onOpenChange: (open: boolean) => void; + signedInAs: string; + linkingIdentity: string; + confirmButtonLabel: string; + onConfirm: () => void | Promise; + confirming?: boolean; +}) => { + let router = useRouter(); + let [loggingOut, setLoggingOut] = useState(false); + + return ( + { + if (loggingOut || props.confirming) return; + props.onOpenChange(o); + }} + className="w-[400px] max-w-full" + > +
+

+ You're signed in as{" "} + {props.signedInAs} +

+

+ Do you want to link{" "} + {props.linkingIdentity} to this + account? +

+
+ { + if (loggingOut) return; + setLoggingOut(true); + try { + await fetch("/api/auth/logout"); + } finally { + mutate("identity", null); + router.refresh(); + } + }} + > + {loggingOut ? : "Log out"} + + { + if (props.confirming) return; + void props.onConfirm(); + }} + > + {props.confirming ? : props.confirmButtonLabel} + +
+
+
+ ); +}; diff --git a/components/Subscribe/SubscribeButton.tsx b/components/Subscribe/SubscribeButton.tsx index ce83f951..a4f81008 100644 --- a/components/Subscribe/SubscribeButton.tsx +++ b/components/Subscribe/SubscribeButton.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/navigation"; import { SubscribeWithHandle } from "./HandleSubscribe"; import { EmailInput, EmailConfirm } from "./EmailSubscribe"; import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess"; +import { LinkIdentityModal } from "./LinkIdentityModal"; import { Modal } from "components/Modal"; import { ButtonPrimary } from "components/Buttons"; import { ManageSubscription } from "./ManageSubscribe"; import { useToaster } from "components/Toast"; +import { useIdentityData } from "components/IdentityProvider"; import { requestPublicationEmailSubscription, confirmPublicationEmailSubscription, @@ -73,6 +75,7 @@ export const SubscribeInput = (props: SubscribeProps) => { let toaster = useToaster(); let router = useRouter(); const user = useViewerSubscription(props.publicationUri); + const { identity } = useIdentityData(); let [email, setEmail] = useState(user.email ?? ""); let [confirmState, setConfirmState] = useState<"confirm" | "success">( "confirm", @@ -81,6 +84,39 @@ export const SubscribeInput = (props: SubscribeProps) => { let [requesting, setRequesting] = useState(false); let [confirming, setConfirming] = useState(false); let [locallySubscribed, setLocallySubscribed] = useState(false); + let [linkModalOpen, setLinkModalOpen] = useState(false); + // Tracks that the user passed through LinkIdentityModal — when they enter + // the confirmation code we attach the email to their current atp identity + // (or merge from any existing email-only identity) instead of creating a + // disconnected email-only account. + let [linkToCurrent, setLinkToCurrent] = useState(false); + + const viewerHandle = identity?.bsky_profiles?.handle; + const viewerAtpDid = identity?.atp_did; + const viewerEmail = identity?.email; + // The atp-only-but-subscribing-via-email case: signed in as a Bluesky + // account with no email yet. The modal asks them to link the typed email + // (or log out) before we send a confirmation code. + const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email; + + const sendRequest = async (link: boolean) => { + setRequesting(true); + setLinkToCurrent(link); + let res = await requestPublicationEmailSubscription( + props.publicationUri, + email, + ); + setRequesting(false); + if (!res.ok) { + toaster({ type: "error", content: ERROR_MESSAGES[res.error] }); + return; + } + if (res.value.confirmed) { + setConfirmState("success"); + router.refresh(); + } + setConfirmOpen(true); + }; const isSubscribed = user.subscribed || locallySubscribed; return ( @@ -106,24 +142,11 @@ export const SubscribeInput = (props: SubscribeProps) => { disabled={requesting || !email} onClick={async () => { if (requesting) return; - setRequesting(true); - let res = await requestPublicationEmailSubscription( - props.publicationUri, - email, - ); - setRequesting(false); - if (!res.ok) { - toaster({ - type: "error", - content: ERROR_MESSAGES[res.error], - }); + if (needsLinkConfirmation) { + setLinkModalOpen(true); return; } - if (res.value.confirmed) { - setConfirmState("success"); - router.refresh(); - } - setConfirmOpen(true); + await sendRequest(false); }} > Subscribe @@ -138,6 +161,20 @@ export const SubscribeInput = (props: SubscribeProps) => { onSubscribed={() => setLocallySubscribed(true)} /> )} + {props.newsletterMode && needsLinkConfirmation && ( + { + setLinkModalOpen(false); + await sendRequest(true); + }} + /> + )} {props.newsletterMode && ( { if (!open) { if (confirmState === "success") setLocallySubscribed(true); setConfirmState("confirm"); + setLinkToCurrent(false); } }} > @@ -164,6 +202,7 @@ export const SubscribeInput = (props: SubscribeProps) => { props.publicationUri, email, code, + linkToCurrent, ); setConfirming(false); if (!res.ok) { @@ -192,7 +231,9 @@ type SubscribeError = | "invalid_code" | "database_error" | "suppressed_spam_complaint" - | "suppression_delete_failed"; + | "suppression_delete_failed" + | "link_invalid_state" + | "email_belongs_to_other_account"; const ERROR_MESSAGES: Record = { invalid_email: "Please enter a valid email address.", @@ -205,4 +246,8 @@ const ERROR_MESSAGES: Record = { "This address was previously marked as spam and can't be resubscribed. Contact the publication to resolve.", suppression_delete_failed: "We couldn't clear a prior delivery issue on this address. Try again later.", + link_invalid_state: + "Couldn't link this email to your account. Try logging out and subscribing again.", + email_belongs_to_other_account: + "This email is already linked to a different Bluesky account. Log out to use that account instead.", }; diff --git a/src/mergeIdentity.ts b/src/mergeIdentity.ts new file mode 100644 index 00000000..e941a3a9 --- /dev/null +++ b/src/mergeIdentity.ts @@ -0,0 +1,156 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq, inArray, sql } from "drizzle-orm"; +import { + custom_domains, + email_auth_tokens, + identities, + permission_token_on_homepage, + publication_email_subscribers, + user_entitlements, + user_subscriptions, +} from "drizzle/schema"; +import { pool } from "supabase/pool"; +import { Err, Ok, type Result } from "src/result"; + +export type MergeError = + | "merge_not_pending" + | "invalid_source" + | "invalid_target" + | "same_identity" + | "database_error"; + +// Merges an email-only identity (source) into an atp-linked identity (target). +// Re-verifies invariants under a row lock so concurrent mutations can't +// invalidate them mid-transaction. +// +// NOT a server action — kept in a plain module so it can't be invoked over +// the wire. Callers must validate that the requester is authorized to merge +// these specific identities before calling. +export async function mergeEmailIdentityIntoAtpIdentity(args: { + sourceId: string; + targetId: string; +}): Promise> { + const { sourceId, targetId } = args; + if (sourceId === targetId) return Err("same_identity"); + + const client = await pool.connect(); + try { + const db = drizzle(client); + await db.transaction(async (tx) => { + const locked = await tx + .select({ + id: identities.id, + email: identities.email, + atp_did: identities.atp_did, + }) + .from(identities) + .where(inArray(identities.id, [sourceId, targetId])) + .for("update"); + const lockedSource = locked.find((r) => r.id === sourceId); + const lockedTarget = locked.find((r) => r.id === targetId); + if (!lockedSource || !lockedTarget) + throw new Error("merge: identity disappeared under lock"); + if (lockedSource.atp_did !== null) + throw new Error("merge: source has atp_did"); + if (lockedTarget.atp_did === null) + throw new Error("merge: target missing atp_did"); + if (!lockedSource.email) throw new Error("merge: source missing email"); + + const sourceEmail = lockedSource.email; + + // email_auth_tokens: source's tokens are stale once source is gone. + // Caller is responsible for swapping any cookie-held auth_token to a + // target-pointing token before calling. + await tx + .delete(email_auth_tokens) + .where(eq(email_auth_tokens.identity, sourceId)); + + // Target wins on (identity_id) PK collision. + await tx.execute(sql` + delete from user_subscriptions + where identity_id = ${sourceId} + and exists (select 1 from user_subscriptions where identity_id = ${targetId}) + `); + await tx + .update(user_subscriptions) + .set({ identity_id: targetId }) + .where(eq(user_subscriptions.identity_id, sourceId)); + + // Target wins on (identity_id, entitlement_key) PK collision. + await tx.execute(sql` + delete from user_entitlements + where identity_id = ${sourceId} + and entitlement_key in ( + select entitlement_key from user_entitlements where identity_id = ${targetId} + ) + `); + await tx + .update(user_entitlements) + .set({ identity_id: targetId }) + .where(eq(user_entitlements.identity_id, sourceId)); + + // Target wins on (token, identity) PK collision. + await tx.execute(sql` + delete from permission_token_on_homepage + where identity = ${sourceId} + and token in ( + select token from permission_token_on_homepage where identity = ${targetId} + ) + `); + await tx + .update(permission_token_on_homepage) + .set({ identity: targetId }) + .where(eq(permission_token_on_homepage.identity, sourceId)); + + // Target wins on unique (publication, email) collision. + await tx.execute(sql` + delete from ${publication_email_subscribers} + where ${publication_email_subscribers.identity_id} = ${sourceId} + and (${publication_email_subscribers.publication}, ${publication_email_subscribers.email}) in ( + select ${publication_email_subscribers.publication}, ${publication_email_subscribers.email} + from ${publication_email_subscribers} + where ${publication_email_subscribers.identity_id} = ${targetId} + ) + `); + await tx + .update(publication_email_subscribers) + .set({ identity_id: targetId }) + .where(eq(publication_email_subscribers.identity_id, sourceId)); + + await tx + .update(custom_domains) + .set({ identity_id: targetId }) + .where(eq(custom_domains.identity_id, sourceId)); + + // identities.email is unique — step via NULL so we don't collide + // mid-swap. custom_domains.identity is nullable and cascades on update; + // we re-set it explicitly after the final value lands because NULL→value + // cascades don't fire. + await tx + .update(identities) + .set({ email: null }) + .where(eq(identities.id, targetId)); + await tx + .update(identities) + .set({ email: null }) + .where(eq(identities.id, sourceId)); + await tx + .update(identities) + .set({ email: sourceEmail }) + .where(eq(identities.id, targetId)); + await tx + .update(custom_domains) + .set({ identity: sourceEmail }) + .where(eq(custom_domains.identity_id, targetId)); + + await tx.delete(identities).where(eq(identities.id, sourceId)); + }); + } catch (e) { + console.error("[mergeIdentity] transaction failed:", e); + return Err("database_error"); + } finally { + client.release(); + } + + return Ok(null); +} -- 2.51.2 From f8c4c843c256db45cfe73b68aa1050768a62611f Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 28 Apr 2026 23:19:59 -0400 Subject: [PATCH 04/34] don't enable swipe gestures if not touch --- components/Blocks/Block.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 1b34aa9c..66606905 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -124,6 +124,12 @@ export const Block = memo(function Block( const bindSwipe = useDrag( ({ last, movement: [mx], event }) => { if (!last) return; + if ( + event && + "pointerType" in event && + event.pointerType !== "touch" + ) + return; if (!rep || !props.listData || !entity_set.permissions.write) return; if (Math.abs(mx) < SWIPE_THRESHOLD) return; event?.preventDefault(); -- 2.51.2 From e100d4ea6c370996d9bef9b27394f1777c7f04e1 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 28 Apr 2026 23:26:06 -0400 Subject: [PATCH 05/34] email template fixes --- actions/{emailAuth.ts => emailAuth.tsx} | 43 ++--- .../inngest/functions/send_post_broadcast.ts | 27 ++- emails/leafletConfirmEmail.tsx | 8 +- emails/post.tsx | 161 +++++++++++++++--- 4 files changed, 185 insertions(+), 54 deletions(-) rename actions/{emailAuth.ts => emailAuth.tsx} (79%) diff --git a/actions/emailAuth.ts b/actions/emailAuth.tsx similarity index 79% rename from actions/emailAuth.ts rename to actions/emailAuth.tsx index eb9d11a7..da48b984 100644 --- a/actions/emailAuth.ts +++ b/actions/emailAuth.tsx @@ -9,35 +9,24 @@ import { cookies } from "next/headers"; import { setAuthToken } from "src/auth"; import { pool } from "supabase/pool"; import { supabaseServerClient } from "supabase/serverClient"; +import { LeafletConfirmEmail } from "emails/leafletConfirmEmail"; +import { sendConfirmationEmail } from "src/utils/confirmationEmail"; async function sendAuthCode(email: string, code: string) { - if (process.env.NODE_ENV === "development") { - console.log("Auth code:", code); - return; - } - - let res = await fetch("https://api.postmarkapp.com/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, - }, - body: JSON.stringify({ - From: "Leaflet ", - Subject: `Your authentication code for Leaflet is ${code}`, - To: email, - TextBody: `Paste this code to login to Leaflet: - -${code} - `, - HtmlBody: ` - - -

Paste this code to login to Leaflet: ${code}

- - - `, - }), + await sendConfirmationEmail({ + to: email, + subject: `Your authentication code for Leaflet is ${code}`, + template: ( + + ), + text: `Paste this code to login to Leaflet:\n\n${code}\n`, + devLogTag: "auth code", + code, }); } diff --git a/app/api/inngest/functions/send_post_broadcast.ts b/app/api/inngest/functions/send_post_broadcast.ts index 4bef3841..ab3fa0a9 100644 --- a/app/api/inngest/functions/send_post_broadcast.ts +++ b/app/api/inngest/functions/send_post_broadcast.ts @@ -45,8 +45,10 @@ export const send_post_broadcast = inngest.createFunction( async ({ event, step }) => { const { publication_uri, document_uri } = event.data; + const authorDid = new AtUri(document_uri).host; + const loaded = await step.run("load-pub-and-doc", async () => { - const [pubRes, docRes] = await Promise.all([ + const [pubRes, docRes, profileRes] = await Promise.all([ supabaseServerClient .from("publications") .select( @@ -59,8 +61,17 @@ export const send_post_broadcast = inngest.createFunction( .select("data") .eq("uri", document_uri) .maybeSingle(), + supabaseServerClient + .from("bsky_profiles") + .select("handle") + .eq("did", authorDid) + .maybeSingle(), ]); - return { pub: pubRes.data, doc: docRes.data }; + return { + pub: pubRes.data, + doc: docRes.data, + profile: profileRes.data, + }; }); const settings = loaded.pub?.publication_newsletter_settings; @@ -108,7 +119,15 @@ export const send_post_broadcast = inngest.createFunction( } const fromHeader = buildFromHeader(pubRecord?.name, fromDomain); const replyToEmail = resolveReplyToEmail(settings); - const did = new AtUri(document_uri).host; + const did = authorDid; + const authorName = loaded.profile?.handle ?? undefined; + const publishedAtLabel = docRecord?.publishedAt + ? new Date(docRecord.publishedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : undefined; // The first page is the document body. Canvas pages don't map to a linear // email body — the email renders an empty postContent section and falls @@ -160,6 +179,8 @@ export const send_post_broadcast = inngest.createFunction( postTitle, postDescription, postUrl, + authorName, + publishedAtLabel, blocks, did, assetsBaseUrl: `${assetsBaseUrl}/`, diff --git a/emails/leafletConfirmEmail.tsx b/emails/leafletConfirmEmail.tsx index 3333abd3..08eba255 100644 --- a/emails/leafletConfirmEmail.tsx +++ b/emails/leafletConfirmEmail.tsx @@ -17,10 +17,14 @@ import { export const LeafletConfirmEmail = (props: { code?: string; assetsBaseUrl?: string; + title?: string; + message?: string; }) => { const leafletSrc = makeStaticUrl( props.assetsBaseUrl ?? "https://leaflet.pub", )("leaflet.png"); + const title = props.title ?? "Welcome to Leaflet!"; + const message = props.message ?? "Verify your email with this code"; return ( @@ -42,12 +46,12 @@ export const LeafletConfirmEmail = (props: { - Welcome to Leaflet!{" "} + {title}{" "} - Verify your email with this code + {message} diff --git a/emails/post.tsx b/emails/post.tsx index 03fe938f..79d0de5b 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -16,6 +16,7 @@ import type { PrismLanguage } from "@react-email/code-block"; import React, { type CSSProperties } from "react"; import { PubLeafletBlocksBlockquote, + PubLeafletBlocksButton, PubLeafletBlocksCode, PubLeafletBlocksHeader, PubLeafletBlocksHorizontalRule, @@ -159,6 +160,32 @@ const defaultProps: PostEmailProps = { description: "Description on the link", }, }, + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.button", + text: "Click me", + url: "https://leaflet.pub", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignLeft", + block: { + $type: "pub.leaflet.blocks.button", + text: "Aligned left", + url: "https://leaflet.pub", + }, + }, + { + $type: "pub.leaflet.pages.linearDocument#block", + alignment: "lex:pub.leaflet.pages.linearDocument#textAlignRight", + block: { + $type: "pub.leaflet.blocks.button", + text: "Aligned right", + url: "https://leaflet.pub", + }, + }, { $type: "pub.leaflet.pages.linearDocument#block", block: { $type: "pub.leaflet.blocks.horizontalRule" }, @@ -301,7 +328,16 @@ export const PostEmail = (props: Partial = {}) => { margin: "8px 0 0", }} > - {p.postTitle} + + {p.postTitle} + {p.postDescription ? ( @@ -365,17 +401,6 @@ export const PostEmail = (props: Partial = {}) => { /> - - - - Open post - - ) : null} @@ -384,6 +409,7 @@ export const PostEmail = (props: Partial = {}) => { = {}) => { lineHeight: "20px", }} > - See Full Post + Read in Browser @@ -450,27 +476,23 @@ export const PostEmail = (props: Partial = {}) => { )} - - - - {/* Spacer */}   - {/* Horizontal rule between card and watermark.
- margins are flaky in Gmail, so we use a 1px-tall - with border-top instead. */} + {/* Horizontal rule above watermark.
margins are + flaky in Gmail, so we use a 1px-tall with + border-top instead. */} = {}) => { + + + + @@ -516,14 +542,35 @@ export const PostEmail = (props: Partial = {}) => { }; export default PostEmail; +// Map the lexicon's alignment token to the simple left/center/right values +// usable as HTML `align` attributes. `justify` falls through to `left` to +// match published web behavior (`justify-start` flex). For buttons we +// default to `center` when alignment is unset, matching PostContent.tsx. +const resolveButtonAlignment = ( + alignment: string | undefined, +): "left" | "center" | "right" => { + switch (alignment) { + case "lex:pub.leaflet.pages.linearDocument#textAlignRight": + return "right"; + case "lex:pub.leaflet.pages.linearDocument#textAlignLeft": + case "lex:pub.leaflet.pages.linearDocument#textAlignJustify": + return "left"; + case "lex:pub.leaflet.pages.linearDocument#textAlignCenter": + default: + return "center"; + } +}; + const BlockRenderer = ({ block, + alignment, did, assetsBaseUrl, theme, colors, }: { block: PubLeafletPagesLinearDocument.Block["block"]; + alignment?: string; did: string; assetsBaseUrl: string; theme: EmailTheme; @@ -633,6 +680,16 @@ const BlockRenderer = ({ /> ); } + if (PubLeafletBlocksButton.isMain(block)) { + return ( + + ); + } if (PubLeafletBlocksHorizontalRule.isMain(block)) { return (
{ + // Bulletproof button: table-based so Outlook (which ignores padding on + // ) renders a real clickable button. The `` carries the bgcolor + // attribute and padding; the `` is `display: block` so the entire + // padded area is clickable. Alignment via the table's `align` HTML + // attribute — Gmail won't reliably cascade `text-align` from a wrapping + //
, so we anchor on the table itself. + return ( +
+ + + + + + +
+ + {text} + +
+
+ ); +}; + export const CodeBlock = ({ code, language, -- 2.51.2 From 9e90e8cc87d7669ade4e2fb948b2ef38bd4568b2 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 29 Apr 2026 11:10:15 -0400 Subject: [PATCH 06/34] restore archive on mailbox blocks --- components/Blocks/MailboxBlock.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/components/Blocks/MailboxBlock.tsx b/components/Blocks/MailboxBlock.tsx index 6262f923..7caf3be7 100644 --- a/components/Blocks/MailboxBlock.tsx +++ b/components/Blocks/MailboxBlock.tsx @@ -1,5 +1,7 @@ import { BlockProps, BlockLayout } from "./Block"; import { useUIState } from "src/useUIState"; +import { useEntity, useReplicache } from "src/replicache"; +import { focusPage } from "src/utils/focusPage"; export const MailboxBlock = ( props: BlockProps & { @@ -10,6 +12,8 @@ export const MailboxBlock = ( let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); + let archive = useEntity(props.entityID, "mailbox/archive"); + let { rep } = useReplicache(); return ( Email subscriptions have moved to publication newsletters. + {archive && ( + + )} ); }; -- 2.51.2 From 3315ea3d2c4e28a8a80281e3547871731b88f0ed Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 29 Apr 2026 11:20:43 -0400 Subject: [PATCH 07/34] get email subscribers for analytics --- .../get_publication_subscribers_timeseries.ts | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts index ac280b7c..4d2b2a58 100644 --- a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts +++ b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts @@ -34,38 +34,56 @@ export const get_publication_subscribers_timeseries = makeRoute({ return { error: "not_found" as const }; } - let query = supabase - .from("publication_subscriptions") - .select("created_at") + // Fetch all atproto subscriptions and confirmed email subscriptions in + // parallel. We dedupe in memory (mirroring PublicationSubscribers.tsx) so + // we need the full sets — date filtering is applied after. + const { data: newsletterSettings } = await supabase + .from("publication_newsletter_settings") + .select("enabled") .eq("publication", publication_uri) - .order("created_at", { ascending: true }); + .maybeSingle(); + const newsletterEnabled = !!newsletterSettings?.enabled; - if (from) { - query = query.gte("created_at", from); + const [{ data: atprotoSubs }, { data: emailSubs }] = await Promise.all([ + supabase + .from("publication_subscriptions") + .select("created_at, identities(bsky_profiles(did))") + .eq("publication", publication_uri), + newsletterEnabled + ? supabase + .from("publication_email_subscribers") + .select("id, created_at, identities(atp_did)") + .eq("publication", publication_uri) + .eq("state", "confirmed") + : Promise.resolve({ data: [] as const }), + ]); + + // Build dedup map keyed by DID (atproto identity) or email-sub id. + // Atproto sub's created_at wins when both channels exist for the same DID, + // matching the UI merge in PublicationSubscribers.tsx. + const subscribers = new Map(); + for (const s of atprotoSubs || []) { + const did = s.identities?.bsky_profiles?.did; + if (!did) continue; + subscribers.set(`did:${did}`, s.created_at); } - if (to) { - query = query.lte("created_at", to); + for (const s of emailSubs || []) { + const linkedDid = s.identities?.atp_did ?? undefined; + if (linkedDid && subscribers.has(`did:${linkedDid}`)) continue; + subscribers.set(`email:${s.id}`, s.created_at); } - const { data: subscriptions } = await query; - - // Bucket subscriptions by day and compute cumulative count + // Bucket the deduped subscribers' creation dates. const dailyCounts: Record = {}; - for (const sub of subscriptions || []) { - const day = sub.created_at.slice(0, 10); - dailyCounts[day] = (dailyCounts[day] || 0) + 1; - } - let cumulative = 0; - - // If we have a from filter, get the count of subscriptions before that date - if (from) { - const { count } = await supabase - .from("publication_subscriptions") - .select("*", { count: "exact", head: true }) - .eq("publication", publication_uri) - .lt("created_at", from); - cumulative = count || 0; + for (const createdAt of subscribers.values()) { + if (from && createdAt < from) { + cumulative += 1; + continue; + } + if (to && createdAt > to) continue; + const day = createdAt.slice(0, 10); + dailyCounts[day] = (dailyCounts[day] || 0) + 1; } // Build timeseries over the full date range, filling gaps with the -- 2.51.2 From ddee5d4baf0f5a711ce5080727eb6ef5e026c247 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 29 Apr 2026 11:21:08 -0400 Subject: [PATCH 08/34] fix post email width --- emails/post.tsx | 28 ++++++++++++++-------------- emails/shared.tsx | 35 ++++++----------------------------- 2 files changed, 20 insertions(+), 43 deletions(-) diff --git a/emails/post.tsx b/emails/post.tsx index 79d0de5b..51bec99f 100644 --- a/emails/post.tsx +++ b/emails/post.tsx @@ -233,13 +233,6 @@ export const PostEmail = (props: Partial = {}) => { @media only screen and (max-width: 480px) { .email-page-pad { padding: 12px 8px !important; } .email-card-pad { padding: 16px !important; } - /* The card table carries an HTML width attribute (e.g. 624) so - Gmail anchors on it; on mobile that pins it wider than the - viewport. Force it to fit. */ - .email-card-table { - width: 100% !important; - max-width: 100% !important; - } } `} @@ -279,20 +272,27 @@ export const PostEmail = (props: Partial = {}) => { padding: "24px 16px", }} > - {/* Both the HTML `width` attribute and CSS `width` are set: - Gmail strips/ignores `max-width` in some contexts but - always honors the HTML attribute, so it pins the box at - the publication's web page width on desktop. The - media-query rule above forces 100% on small screens. */} + {/* Responsive width: `width="100%"` lets the card fill its + container, and `max-width: pageWidth` caps it on wider + viewports. This keeps it readable at the publication's + page width on desktop while shrinking gracefully when + the email is viewed in a narrow window (e.g. Apple Mail + desktop resized below the page width). Outlook desktop + ignores `max-width` and will render full-width — that's + a known tradeoff for the simpler markup. */} diff --git a/emails/shared.tsx b/emails/shared.tsx index f795551f..1d4e78cd 100644 --- a/emails/shared.tsx +++ b/emails/shared.tsx @@ -1,9 +1,4 @@ -import { - Head, - Img, - Link, - pixelBasedPreset, -} from "@react-email/components"; +import { Head, Img, Link, pixelBasedPreset } from "@react-email/components"; import React from "react"; export type EmailTheme = { @@ -34,15 +29,9 @@ export const defaultEmailTheme: EmailTheme = { // Parse rgb()/rgba()/#hex into [r, g, b]. Returns black on parse failure — // theme colors come from a typed config so this is just defensive. const parseColor = (input: string): [number, number, number] => { - const rgbMatch = input.match( - /rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i, - ); + const rgbMatch = input.match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i); if (rgbMatch) - return [ - Number(rgbMatch[1]), - Number(rgbMatch[2]), - Number(rgbMatch[3]), - ]; + return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])]; const hexMatch = input.match(/^#([0-9a-f]{6})$/i); if (hexMatch) { const h = hexMatch[1]; @@ -69,11 +58,7 @@ const parseColor = (input: string): [number, number, number] => { // leaving borders invisible and tinted text falling back to defaults. // Linear mixing isn't perceptually identical to the oklab original, but // for the near-grayscale tints we use it's visually indistinguishable. -export const mixRgb = ( - a: string, - b: string, - bPercent: number, -): string => { +export const mixRgb = (a: string, b: string, bPercent: number): string => { const [ar, ag, ab] = parseColor(a); const [br, bg, bb] = parseColor(b); const t = bPercent / 100; @@ -179,16 +164,9 @@ export const confirmEmailTailwindConfig = { // makes every element render visually small). Templates can pass extra // children — usually a `