diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index 721c8773..fe1df0d1 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -16,6 +16,7 @@ import { getSuppression, deleteSuppression, } from "src/utils/postmarkSuppressions"; +import { unsubscribeToPublication } from "app/lish/subscribeToPublication"; type RequestError = | "invalid_email" @@ -199,45 +200,68 @@ export async function unsubscribeFromPublication( const identity = await getIdentityData(); if (!identity?.id) return Err("unauthorized"); + // A viewer is "subscribed" if they have EITHER an email subscription OR an + // atproto subscription (see useViewerSubscription). Unsubscribe must clear + // both — otherwise a user with only an atproto sub hits "not_subscribed" + // and a user with both ends up half-unsubscribed. + const [{ data: subscribers }, { data: atprotoSub }] = await Promise.all([ + supabaseServerClient + .from("publication_email_subscribers") + .select("id, state") + .eq("publication", publicationUri) + .eq("identity_id", identity.id), + identity.atp_did + ? supabaseServerClient + .from("publication_subscriptions") + .select("uri") + .eq("identity", identity.atp_did) + .eq("publication", publicationUri) + .maybeSingle() + : Promise.resolve({ data: null }), + ]); // Identity may have multiple matching subscriber rows historically // (different emails tied to the same identity, e.g. a linked address that // predates an email change). Unsubscribe all of them at once — a single // "Unsubscribe" click from this user should stop all future newsletter // mail from this publication. - const { data: subscribers } = await supabaseServerClient - .from("publication_email_subscribers") - .select("id, state") - .eq("publication", publicationUri) - .eq("identity_id", identity.id); const active = (subscribers ?? []).filter((s) => s.state !== "unsubscribed"); - if (!subscribers || subscribers.length === 0) return Err("not_subscribed"); - if (active.length === 0) return Ok(null); // already unsubscribed; idempotent - const ids = active.map((s) => s.id); - const nowIso = new Date().toISOString(); - const [{ error: updateError }, { error: eventError }] = await Promise.all([ - supabaseServerClient - .from("publication_email_subscribers") - .update({ - state: "unsubscribed", - unsubscribed_at: nowIso, - confirmation_code: null, - }) - .in("id", ids), - supabaseServerClient.from("publication_email_subscriber_events").insert( - active.map((s) => ({ - subscriber: s.id, - publication: publicationUri, - event_type: "unsubscribe_requested", - })), - ), - ]); - if (updateError || eventError) { - console.error( - "[subscribeEmail] unsubscribe update/event failed:", - updateError ?? eventError, - ); - return Err("database_error"); + if (active.length === 0 && !atprotoSub) { + if (subscribers && subscribers.length > 0) return Ok(null); // already unsubscribed; idempotent + return Err("not_subscribed"); + } + + if (active.length > 0) { + const ids = active.map((s) => s.id); + const nowIso = new Date().toISOString(); + const [{ error: updateError }, { error: eventError }] = await Promise.all([ + supabaseServerClient + .from("publication_email_subscribers") + .update({ + state: "unsubscribed", + unsubscribed_at: nowIso, + confirmation_code: null, + }) + .in("id", ids), + supabaseServerClient.from("publication_email_subscriber_events").insert( + active.map((s) => ({ + subscriber: s.id, + publication: publicationUri, + event_type: "unsubscribe_requested", + })), + ), + ]); + if (updateError || eventError) { + console.error( + "[subscribeEmail] unsubscribe update/event failed:", + updateError ?? eventError, + ); + return Err("database_error"); + } + } + + if (atprotoSub) { + await unsubscribeToPublication(publicationUri); } // NOTE: Postmark Suppressions API is deliberately NOT called here. Per spec, diff --git a/app/[leaflet_id]/publish/ShareOptions.tsx b/app/[leaflet_id]/publish/ShareOptions.tsx index 65aa0a8c..d62852ca 100644 --- a/app/[leaflet_id]/publish/ShareOptions.tsx +++ b/app/[leaflet_id]/publish/ShareOptions.tsx @@ -56,28 +56,32 @@ export function ShareOptions(props: Props) { return (
- handleChange("email", e.target.checked)} - > -
-
- Email{" "} - {props.subscriberCount !== undefined - ? `${props.subscriberCount} ` - : ""} - Subscribers -
-
-
- {props.newsletter_enabled && props.publication_uri ? ( - + {props.newsletter_enabled ? ( + <> + handleChange("email", e.target.checked)} + > +
+
+ Email{" "} + {props.subscriberCount !== undefined + ? `${props.subscriberCount} ` + : ""} + Subscribers +
+
+
+ {props.publication_uri ? ( + + ) : null} + ) : null} { - let { identity } = useIdentityData(); - let searchParams = useSearchParams(); - let [successModalOpen, setSuccessModalOpen] = useState( - !!searchParams.has("showSubscribeSuccess"), - ); - let [localSubscribeState, setLocalSubscribeState] = useState< - "subscribed" | "unsubscribed" - >("subscribed"); - let subscribed = - identity?.atp_did && - localSubscribeState !== "unsubscribed" && - props.subscribers.find((s) => s.identity === identity.atp_did); - - if (successModalOpen) - return ( - - ); - if (subscribed) { - return ( - setLocalSubscribeState("unsubscribed")} - /> - ); - } - return ( -
-
- setLocalSubscribeState("subscribed")} - compact={props.compact} - pub_uri={props.pub_uri} - setSuccessModalOpen={setSuccessModalOpen} - /> - - {props.compact ? ( - - ) : ( - - )} - -
-
- ); -}; - -export const ManageSubscription = (props: { - pub_uri: string; - subscribers: { identity: string }[]; - base_url: string; - compact?: boolean; - onUnsubscribe?: () => void; -}) => { - let toaster = useToaster(); - let [hasFeed] = useState(false); - let [, unsubscribe, unsubscribePending] = useActionState(async () => { - await unsubscribeToPublication(props.pub_uri); - toaster({ - content: "You unsubscribed.", - type: "success", - }); - props.onUnsubscribe?.(); - }, null); - return ( - - Manage Subscription -
- } - > -
-

Update Options

- - {!hasFeed && ( - - - Bluesky Custom Feed - - - )} - - - - Get RSS - - - -
- -
- -
-
- - ); -}; - -let BlueskySubscribeButton = (props: { - pub_uri: string; - setSuccessModalOpen: (open: boolean) => void; - compact?: boolean; - setLocalSubscribeState: () => void; -}) => { - let { identity } = useIdentityData(); - let toaster = useToaster(); - let [oauthError, setOauthError] = useState< - import("src/atproto-oauth").OAuthSessionError | null - >(null); - let [, subscribe, subscribePending] = useActionState(async () => { - setOauthError(null); - let result = await subscribeToPublication( - props.pub_uri, - window.location.href + "?refreshAuth", - ); - if (!result.success) { - if (isOAuthSessionError(result.error)) { - setOauthError(result.error); - } - return; - } - if (result.hasFeed === false) { - props.setSuccessModalOpen(true); - } - toaster({ content:
You're Subscribed!
, type: "success" }); - props.setLocalSubscribeState(); - }, null); - - let { data: profile } = useRecordFromDid(identity?.atp_did); - - if (!identity?.atp_did) { - return ( - Subscribe!} - noEmailLogin - /> - ); - } - return ( -
-
- - {subscribePending ? ( - - ) : ( - <> - Subscribe as - - - -
- {profile?.displayName ? profile.displayName : profile?.handle} -
-
- - )} -
-
- {oauthError && ( - - )} -
- ); -}; - -const SubscribeSuccessModal = ({ - open, - setOpen, -}: { - open: boolean; - setOpen: (open: boolean) => void; -}) => { - let searchParams = useSearchParams(); - let [loading, setLoading] = useState(false); - let toaster = useToaster(); - return ( - - - - - - -

Subscribed!

-
- - You'll get updates about this publication via a Feed just for you. - { - if (loading) return; - - setLoading(true); - let feedurl = - "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"; - await addFeed(); - toaster({ content: "Feed added!", type: "success" }); - setLoading(false); - window.open(feedurl, "_blank"); - }} - > - {loading ? : "Add Bluesky Feed"} - - - - -
-
-
- ); -}; - -export const SubscribeOnPost = () => { - return
; -}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index ddf76e03..7fb49df3 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -15,9 +15,9 @@ import { PubLeafletComment } from "lexicons/api"; import { type CommentOnDocument } from "contexts/DocumentContext"; import { prefetchQuotesData } from "./Quotes"; import { useIdentityData } from "components/IdentityProvider"; -import { ManageSubscription } from "app/lish/Subscribe"; +import { ManageSubscription } from "components/Subscribe/ManageSubscribe"; +import { useViewerSubscription } from "components/Subscribe/viewerSubscription"; import { EditTiny } from "components/Icons/EditTiny"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { RecommendButton } from "components/RecommendButton"; import { ButtonSecondary } from "components/Buttons"; import { Separator } from "components/Layout"; @@ -207,9 +207,9 @@ export const ExpandedInteractions = (props: { publication, leafletId, } = useDocument(); - let { identity } = useIdentityData(); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); + let viewer = useViewerSubscription(publication?.uri ?? ""); const handleQuotePrefetch = () => { if (quotesAndMentions) { @@ -223,13 +223,6 @@ export const ExpandedInteractions = (props: { let noInteractions = !props.showComments && !props.showMentions && !props.showRecommends; - let subscribed = - identity?.atp_did && - publication?.publication_subscriptions && - publication?.publication_subscriptions.find( - (s) => s.identity === identity.atp_did, - ); - return (
)}
- {subscribed && publication && ( + {viewer.subscribed && publication && ( )} diff --git a/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx b/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx deleted file mode 100644 index 6ffcd0be..00000000 --- a/app/lish/[did]/[publication]/[rkey]/PostSubscribe.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; -import { useDocumentOptional } from "contexts/DocumentContext"; -import { useIdentityData } from "components/IdentityProvider"; -import { SubscribeWithBluesky } from "app/lish/Subscribe"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; - -export const PostSubscribe = () => { - const data = useDocumentOptional(); - let { identity } = useIdentityData(); - - let publication = data?.publication; - let normalizedPublication = data?.normalizedPublication; - - let subscribed = - identity?.atp_did && - publication?.publication_subscriptions && - publication?.publication_subscriptions.find( - (s) => s.identity === identity.atp_did, - ); - - let isAuthor = - identity && - identity.atp_did === publication?.identity_did && - data?.leafletId; - - if (!subscribed && !isAuthor && publication && normalizedPublication) - return ( -
-
-
-
Subscribe to {publication.name}
to - get updates in Reader, RSS, or via Bluesky Feed -
- -
-
- ); - else return null; -}; diff --git a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx index f0a5f51d..88db647b 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx @@ -16,7 +16,11 @@ import { PostSettings } from "./PostSettings"; import { ThemeSettings } from "./ThemeSettings"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { ManageProSubscription, NewsletterSettings } from "./ProSettings"; -import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; +import { + useIsPro, + useCanSeePro, + useCanSeeNewsletterMode, +} from "src/hooks/useEntitlement"; import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; import { Modal } from "components/Modal"; import { Input } from "components/Input"; @@ -31,6 +35,7 @@ export function SettingsContent(props: { showPageBackground: boolean }) { let { publication: pubData } = data || {}; let isPro = useIsPro(); let canSeePro = useCanSeePro(); + let canSeeNewsletterMode = useCanSeeNewsletterMode(); let record = useNormalizedPublicationRecord(); let [loading, setLoading] = useState(false); let toast = useToaster(); @@ -199,7 +204,7 @@ export function SettingsContent(props: { showPageBackground: boolean }) { - + {canSeeNewsletterMode && } )}
diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx index 24e7eb2d..a2245089 100644 --- a/components/LoginButton.tsx +++ b/components/LoginButton.tsx @@ -147,7 +147,14 @@ export const LoginContent = (props: { loading={loading} onSubmit={(handle) => { setLoading(true); - const redirectUrl = props.redirectRoute || "/"; + let redirectUrl: string; + if (props.redirectRoute) { + redirectUrl = props.redirectRoute; + } else { + let url = new URL(window.location.href); + url.searchParams.set("refreshAuth", ""); + redirectUrl = url.toString(); + } window.location.href = `/api/oauth/login?handle=${encodeURIComponent(handle)}&redirect_url=${encodeURIComponent(redirectUrl)}`; }} /> diff --git a/components/Subscribe/HandleSubscribe.tsx b/components/Subscribe/HandleSubscribe.tsx index 9a882c48..1d96823f 100644 --- a/components/Subscribe/HandleSubscribe.tsx +++ b/components/Subscribe/HandleSubscribe.tsx @@ -2,6 +2,13 @@ import { ButtonPrimary } 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 "app/lish/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 { HandleInput } from "./HandleInput"; const apps = [ { name: "Leaflet", logo: "/logos/leaflet.svg" }, @@ -30,28 +37,89 @@ import { Separator } from "components/Layout"; export const SubscribeWithHandle = (props: { autoFocus?: boolean; + publicationUri: string; + onSubscribed?: () => void; user: { loggedIn: boolean; email: string | undefined; handle: string | undefined; }; }) => { + let toaster = useToaster(); + let [loading, setLoading] = useState(false); + let [subscribing, setSubscribing] = useState(false); + let [oauthError, setOauthError] = useState(null); + if (props.user.loggedIn && props.user.handle) { return ( - - Subscribe as - -
+
+ { + if (subscribing) return; + setSubscribing(true); + setOauthError(null); + let url = new URL(window.location.href); + url.searchParams.set("refreshAuth", ""); + let result = await subscribeToPublication( + props.publicationUri, + url.toString(), + ); + if (!result.success) { + if (isOAuthSessionError(result.error)) + setOauthError(result.error); + setSubscribing(false); + return; + } + toaster({ + content:
You're Subscribed!
, + type: "success", + }); + props.onSubscribed?.(); + setSubscribing(false); + }} + > + {subscribing ? ( + + ) : ( + <> + Subscribe as + +
-
{props.user.handle}
- - +
{props.user.handle}
+ + + )} + + {oauthError && ( + + )} +
); } else return (
{ + let trimmed = handle.trim(); + if (!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}`; + }} action=
Subscribe
@@ -64,6 +132,7 @@ export const SubscribeWithHandle = (props: { }; export const LinkHandle = (props: { compact?: boolean }) => { + let [loading, setLoading] = useState(false); return (
{
{ + let trimmed = handle.trim(); + if (!trimmed) return; + setLoading(true); + 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={
Link diff --git a/components/Subscribe/SubscribeButton.tsx b/components/Subscribe/SubscribeButton.tsx index c23d3c02..314c3b59 100644 --- a/components/Subscribe/SubscribeButton.tsx +++ b/components/Subscribe/SubscribeButton.tsx @@ -128,7 +128,12 @@ export const SubscribeInput = (props: SubscribeProps) => { } /> ) : ( - + setLocallySubscribed(true)} + /> )} {props.newsletterMode && (