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;