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 (
-
- );
-};
-
-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
-
- }
- >
-
-
- );
-};
-
-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 (
-
-
- {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 && (