diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
index 61510f54..db4eac7e 100644
--- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
@@ -9,6 +9,7 @@ import { PublicationSubscribers } from "./PublicationSubscribers";
import {
DashboardLayout,
PublicationDashboardControls,
+ SubscriberDashboardControls,
} from "components/PageLayouts/DashboardLayout";
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
import { type NormalizedPublication } from "src/utils/normalizeRecords";
@@ -75,7 +76,7 @@ export default function PublicationDashboard({
showPageBackground={!!record.theme?.showPageBackground}
/>
),
- controls: null,
+ controls: ,
},
...(canSeePro
? {
diff --git a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
index f827484f..30510348 100644
--- a/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
+++ b/app/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
@@ -7,15 +7,19 @@ import { Menu, MenuItem } from "components/Menu";
import { Separator } from "components/Layout";
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
+import { useDashboardState } from "components/PageLayouts/DashboardLayout";
type subscriber = { email: string | undefined; did: string | undefined };
+type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed";
+
type MergedSubscriber = {
key: string;
did: string | undefined;
handle: string | undefined;
email: string | undefined;
created_at: string;
+ status: SubscriberStatus;
};
export function PublicationSubscribers(props: {
@@ -23,14 +27,24 @@ export function PublicationSubscribers(props: {
}) {
let smoker = useSmoker();
let { data: publication } = usePublicationData();
+ let { subscriberStatus } = useDashboardState();
if (!publication) return
null
;
- let atprotoSubs = publication.publication?.publication_subscriptions || [];
+ // ATProto subscribers have no email lifecycle state — they're just present
+ // or absent, so they only count under the "subscribed" status filter.
+ let atprotoSubs = subscriberStatus.subscribed
+ ? publication.publication?.publication_subscriptions || []
+ : [];
let newsletterEnabled =
!!publication.publication?.publication_newsletter_settings?.enabled;
let emailSubs = newsletterEnabled
? (publication.publication?.publication_email_subscribers || []).filter(
- (s) => s.state === "confirmed",
+ (s) => {
+ if (s.state === "confirmed") return subscriberStatus.subscribed;
+ if (s.state === "pending") return subscriberStatus.unconfirmed;
+ if (s.state === "unsubscribed") return subscriberStatus.unsubscribed;
+ return false;
+ },
)
: [];
@@ -45,12 +59,19 @@ export function PublicationSubscribers(props: {
handle: s.identities?.bsky_profiles?.handle ?? undefined,
email: undefined,
created_at: s.created_at,
+ status: "subscribed",
});
}
for (let s of emailSubs) {
+ let status: SubscriberStatus =
+ s.state === "pending"
+ ? "unconfirmed"
+ : s.state === "unsubscribed"
+ ? "unsubscribed"
+ : "subscribed";
let linkedDid = s.identities?.atp_did ?? undefined;
let existing = linkedDid ? byDid.get(linkedDid) : undefined;
- if (existing) {
+ if (existing && status === "subscribed") {
existing.email = s.email;
continue;
}
@@ -60,6 +81,7 @@ export function PublicationSubscribers(props: {
handle: s.identities?.bsky_profiles?.handle ?? undefined,
email: s.email,
created_at: s.created_at,
+ status,
});
}
let subscribers: MergedSubscriber[] = [...byDid.values(), ...emailOnly];
@@ -80,7 +102,37 @@ export function PublicationSubscribers(props: {
// }
// }, [checkedSubscribers]);
- if (subscribers.length === 0)
+ let activeStatuses = (
+ Object.keys(subscriberStatus) as SubscriberStatus[]
+ ).filter((k) => subscriberStatus[k]);
+ let isDefaultStatusFilter =
+ activeStatuses.length === 1 && activeStatuses[0] === "subscribed";
+
+ if (subscribers.length === 0) {
+ if (!isDefaultStatusFilter) {
+ let label =
+ activeStatuses.length === 0
+ ? "any status"
+ : activeStatuses
+ .map((s) => (s === "unconfirmed" ? "unconfirmed" : s))
+ .join(", ");
+ return (
+
+
No subscribers match this filter
+
Showing: {label}
+
+ );
+ }
return (
);
+ }
return (
))}
@@ -178,6 +232,7 @@ const SubscriberListItem = (props: {
did: string | undefined;
email: string | undefined;
createdAt: string;
+ status: SubscriberStatus;
}) => {
return (
@@ -197,11 +252,16 @@ const SubscriberListItem = (props: {
{props.email}
)}
+ {props.status !== "subscribed" && (
+
+ {props.status === "unconfirmed" ? "unconfirmed" : "unsubscribed"}
+
+ )}
);
diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx
index a3df6c84..b95e7be9 100644
--- a/components/PageLayouts/DashboardLayout.tsx
+++ b/components/PageLayouts/DashboardLayout.tsx
@@ -38,6 +38,11 @@ export type DashboardState = {
docs: boolean;
archived: boolean;
};
+ subscriberStatus: {
+ unconfirmed: boolean;
+ subscribed: boolean;
+ unsubscribed: boolean;
+ };
};
type DashboardStore = {
@@ -54,8 +59,28 @@ const defaultDashboardState: DashboardState = {
docs: false,
archived: false,
},
+ subscriberStatus: {
+ unconfirmed: false,
+ subscribed: true,
+ unsubscribed: false,
+ },
};
+// Existing identities have stored interface_state without newer fields
+// (e.g. subscriberStatus). Merge so callers always see a complete shape.
+function withDefaults(stored: DashboardState | undefined): DashboardState {
+ if (!stored) return defaultDashboardState;
+ return {
+ ...defaultDashboardState,
+ ...stored,
+ filter: { ...defaultDashboardState.filter, ...stored.filter },
+ subscriberStatus: {
+ ...defaultDashboardState.subscriberStatus,
+ ...stored.subscriberStatus,
+ },
+ };
+}
+
export const useDashboardStore = create((set, get) => ({
dashboards: {},
setDashboard: (id: string, partial: Partial) => {
@@ -84,12 +109,10 @@ export const useDashboardId = () => {
export const useDashboardState = () => {
const id = useDashboardId();
let { identity } = useIdentityData();
- let localState = useDashboardStore(
- (state) => state.dashboards[id] || defaultDashboardState,
- );
- if (!identity) return localState;
+ let localState = useDashboardStore((state) => state.dashboards[id]);
+ if (!identity) return withDefaults(localState);
let metadata = identity.interface_state as InterfaceState;
- return metadata?.dashboards?.[id] || defaultDashboardState;
+ return withDefaults(metadata?.dashboards?.[id]);
};
export const useSetDashboardState = () => {
@@ -461,6 +484,72 @@ const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
);
};
+export const SubscriberStatusFilter = () => {
+ let { subscriberStatus } = useDashboardState();
+ let setState = useSetDashboardState();
+ let count = Object.values(subscriberStatus).filter(Boolean).length;
+
+ return (
+ Status {count > 0 && `(${count})`}}
+ >
+
+ setState({
+ subscriberStatus: {
+ ...subscriberStatus,
+ subscribed: !!e.target.checked,
+ },
+ })
+ }
+ >
+ Subscribed
+
+
+ setState({
+ subscriberStatus: {
+ ...subscriberStatus,
+ unconfirmed: !!e.target.checked,
+ },
+ })
+ }
+ >
+ Unconfirmed
+
+
+ setState({
+ subscriberStatus: {
+ ...subscriberStatus,
+ unsubscribed: !!e.target.checked,
+ },
+ })
+ }
+ >
+ Unsubscribed
+
+
+ );
+};
+
+export const SubscriberDashboardControls = () => {
+ return (
+
+ );
+};
+
const SearchInput = (props: {
searchValue: string;
setSearchValue: (searchValue: string) => void;