You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Port the Calypso Subscribers page (https://cloud.jetpack.com/subscribers/) into Jetpack's wp-admin so that Jetpack-connected sites can manage their subscribers without leaving wp-admin. The implementation should reach 1:1 parity with the Calypso UI before we consider any deviations.
The feature already has an empty home in the monorepo: projects/packages/subscribers-dashboard/. Today the package owns a top-level "Subscribers" wp-admin menu (gated behind the jetpack_wp_admin_subscriber_management_enabled filter, default false) and renders a <b>Hello world!</b> stub. The wiring is in place — menu registration, asset enqueue, build pipeline, and @wordpress/dataviews@14.1.0 is already a dep — we just need to fill it in.
When the page is available in wp-admin, the existing "Subscribers ↗" external link to cloud.jetpack.com (in projects/plugins/jetpack/modules/subscriptions.php) is suppressed by the same filter, so we replace the link with the in-admin page once the filter is flipped.
Filter that gates it: jetpack_wp_admin_subscriber_management_enabled
Sample API response shape — GET /wpcom/v2/sites/{site_id}/subscribers?per_page=10&page=1&sort=date_subscribed&sort_order=desc&use_new_helper=true&filters[]=all:
Where it lives — replace the stub inside projects/packages/subscribers-dashboard/_inc/. The package already owns the menu, asset wiring, and build pipeline. Newsletter settings stays as-is; we'll add a "Manage subscribers" link to this page from there.
Data source — add a Jetpack REST proxy endpoint (e.g. GET /wpcom/v2/jetpack-subscribers/list) that forwards to GET /wpcom/v2/sites/{site_id}/subscribers server-side using the blog token. Avoids exposing the user's WP.com token client-side and mirrors how other Jetpack features fetch from WP.com. Mutations (remove, cancel paid subscription, etc.) follow the same proxy pattern.
State / data layer — React Query (already used in Calypso); pagination, filters, search, sort all live in URL search params (mirroring the Forms package view-state pattern in this repo).
DataViews — @wordpress/dataviews server-driven mode (paginationInfo from API response, server-side sort and filters via query params).
Auth / target — Jetpack-connected self-hosted is the primary target. Atomic / Simple already get a Calypso-backed equivalent via nav-unification; for those hosts we keep the existing behavior unless the filter is explicitly flipped on.
Phases
Each phase ships as its own PR, stacked on a shared integration branch (try/newsletter-subscribers-dataviews).
Replace _inc/admin.jsx with a real React app shell: layout, header, container. (Note: apiFetch over a thin useState/useEffect hook for now; React Query lands in Phase 4 alongside mutations.)
Minimal DataViews wiring: 3 columns — avatar, name (display_name + email_address stacked), date subscribed. Server-side pagination via page/per_page. Server-side sort by date_subscribed. No filters, no row actions yet.
Loading and error states (empty-state polish moved to Phase 5 alongside Calypso parity copy).
TypeScript types for the API response (mirror the JSON shape above).
Subscriber totals summary in the page header via new GET /wpcom/v2/subscribers/totals proxy → /wpcom/v2/sites/{id}/subscribers/counts. (Filtered/searched variants land in Phase 3.)
Bundle @wordpress/dataviews stylesheet so the table chrome renders.
Filter chips for All / Paid / Comp / Free (subscription type field) and Subscribed / Not subscribed / Not confirmed / Not sending → email_subscriber / reader_subscriber / unconfirmed_subscriber / blocked_subscriber (subscription_status field). Each value maps to Calypso's filters[]=….
Free-text search via DataViews' built-in search input → API search param.
Persist view (page, perPage, sort, search, filters) in URL search params; ships as a small useViewState() hook. (We're not using the Forms-package context router because the subscribers-dashboard package is standalone — the hook reads/writes via URLSearchParams + history.replaceState directly. wp-admin's page=subscribers arg is preserved.)
Per-row + bulk Remove subscriber action (supportsBulk: true, capped at 100 like Calypso).
Confirmation modal explains the cascade (paid sub cancel + WPCOM follower delete + email follower delete).
Cascade runs server-side via new POST /wpcom/v2/subscribers/remove proxy that calls Client::wpcom_json_api_request_as_user for each underlying WP.com endpoint; collects per-step errors so partial failures surface.
React Query for queries (list + totals) + mutation. Cache invalidation on success via queryClient.invalidateQueries so the table and totals refetch.
Success / failure feedback via @wordpress/notices SnackbarList.
Add subscribers primary CTA in the header. Modal supports manual email entry (forgiving parser, inline invalid-entry warning); POSTs to new POST /wpcom/v2/subscribers/add proxy → /sites/{id}/invites/new.
More menu: Download as CSV (links to wpcom's existing export endpoint).
Empty state: icon + title + body + CTA when totalItems === 0 and no filters/search. Filtered-empty stays on DataViews' built-in empty UI.
Responsive header row collapses on narrow viewports.
RTL pass: verified in browser — sidebar mirrors, table columns reverse, inspector slot moves to the left, stat-card row reverses, pagination chevrons mirror. No code changes needed (all our padding is shorthand or symmetric, Stack flexbox direction reverses naturally).
Migrate subscribers from another WPCOM site — still deferred. Calypso's useMigrateSubscribersCallback POSTs to /jetpack-blogs/{target}/source/{source}/migrate, but the source-site picker needs /me/sites which currently returns "An active access token must be used to query information about the current user" when called via Client::wpcom_json_api_request_as_user. The in-modal flow was prototyped and reverted (commit faa3a11afe); we'll bring it back once we have a path to authenticate the /me/sites lookup from inside the dashboard.
Per-row Comp a subscription modal — plan picker (SelectControl) over the site's membership products + "Doesn't expire" checkbox, mirrors Calypso's <CompModal>.
Per-row Remove comp AlertDialog confirm; takes comp_id from the row's plans[] (is_comp && comp_id).
Three new REST proxies: GET /wpcom/v2/subscribers/products (memberships products feeding the picker), POST /wpcom/v2/subscribers/comp (calls /sites/{id}/memberships/comps/{user_id}/{plan_id} with optional no_expiration), POST /wpcom/v2/subscribers/remove-comp (forwards as DELETE to /sites/{id}/memberships/comp/{compId}).
Eligibility checks mirror Calypso: Comp shows when the row has a user_id; Remove comp shows when at least one plan is is_comp with a comp_id.
Two new REST proxies: GET /wpcom/v2/subscribers/individual → /sites/{id}/subscribers/individual; GET /wpcom/v2/subscribers/stats → /sites/{id}/individual-subscriber-stats. Both accept either subscription_id or user_id.
Deep-linkable URL: ?subscriber={subscription_id}&u={user_id} via a small useOpenSubscriber() hook that hydrates from the URL on mount and writes back via history.replaceState.
Skeleton spinner while details + stats are in flight.
add_question (Add Subscribers tab change, with method prop)
empty_view_displayed
Migrate-related events deferred along with the Migrate flow itself.
Out of scope (for now)
Switching the default value of jetpack_wp_admin_subscriber_management_enabled to true. We'll keep it false until the implementation is feature-complete and reviewed; flipping the default is a follow-up issue once Phases 1-5 are merged.
Replacing the existing Atomic / Simple "Subscribers" nav-unification entries (those continue to point at Calypso for now).
The dual identity model (user_id 0 means email-only, non-zero means WPCOM user) is the trickiest part. The remove flow has to call different endpoints based on which IDs are present. See getSubscriberDetailsType() and getSubscriptionIdFromSubscriber() in helpers/index.ts.
The API exposes both wpcom_date_subscribed and email_date_subscribed; Calypso uses wpcom_date_subscribed when present, otherwise falls back to email_date_subscribed. Same logic should be ported.
Keep the React component layout close to Calypso so future deltas can be diffed easily.
Goal
Port the Calypso Subscribers page (https://cloud.jetpack.com/subscribers/) into Jetpack's
wp-adminso that Jetpack-connected sites can manage their subscribers without leaving wp-admin. The implementation should reach 1:1 parity with the Calypso UI before we consider any deviations.The feature already has an empty home in the monorepo:
projects/packages/subscribers-dashboard/. Today the package owns a top-level "Subscribers" wp-admin menu (gated behind thejetpack_wp_admin_subscriber_management_enabledfilter, defaultfalse) and renders a<b>Hello world!</b>stub. The wiring is in place — menu registration, asset enqueue, build pipeline, and@wordpress/dataviews@14.1.0is already a dep — we just need to fill it in.When the page is available in wp-admin, the existing "Subscribers ↗" external link to
cloud.jetpack.com(inprojects/plugins/jetpack/modules/subscriptions.php) is suppressed by the same filter, so we replace the link with the in-admin page once the filter is flipped.Reference
client/my-sites/subscribers/projects/packages/subscribers-dashboard/jetpack_wp_admin_subscriber_management_enabledGET /wpcom/v2/sites/{site_id}/subscribers?per_page=10&page=1&sort=date_subscribed&sort_order=desc&use_new_helper=true&filters[]=all:{ "total": 545, "pages": 55, "page": 1, "per_page": 10, "subscribers": [ { "user_id": 41571258, "wpcom_subscription_id": 833368396, "wpcom_date_subscribed": "2026-03-10 10:56:44", "subscription_status": "Subscribed", "email_subscription_id": 937745224, "email_date_subscribed": "2026-03-10 10:56:44", "email_address": "redacted@example.com", "display_name": "Display Name", "avatar": "https://0.gravatar.com/avatar/…" } ], "is_owner_subscribed": true }Architecture decisions
projects/packages/subscribers-dashboard/_inc/. The package already owns the menu, asset wiring, and build pipeline. Newsletter settings stays as-is; we'll add a "Manage subscribers" link to this page from there.GET /wpcom/v2/jetpack-subscribers/list) that forwards toGET /wpcom/v2/sites/{site_id}/subscribersserver-side using the blog token. Avoids exposing the user's WP.com token client-side and mirrors how other Jetpack features fetch from WP.com. Mutations (remove, cancel paid subscription, etc.) follow the same proxy pattern.@wordpress/dataviewsserver-driven mode (paginationInfofrom API response, server-side sort and filters via query params).Phases
Each phase ships as its own PR, stacked on a shared integration branch (
try/newsletter-subscribers-dataviews).Phase 1 — Skeleton ✅ (commit 073a6a4)
_inc/admin.jsxwith a real React app shell: layout, header, container. (Note:apiFetchover a thinuseState/useEffecthook for now; React Query lands in Phase 4 alongside mutations.)GET /wpcom/v2/subscribers/list(forwardspage,per_page,sort,sort_order,filters[],search,use_new_helper=true).page/per_page. Server-side sort bydate_subscribed. No filters, no row actions yet.Phase 2 — Field parity ✅ (commit af34112)
name,plan,subscription_status,date_subscribed(matches CalypsoSubscribersSortByenum).date_subscribeddescending.GET /wpcom/v2/subscribers/totalsproxy →/wpcom/v2/sites/{id}/subscribers/counts. (Filtered/searched variants land in Phase 3.)@wordpress/dataviewsstylesheet so the table chrome renders.Phase 3 — Filters & search ✅ (commit f61c28c)
filters[]=….searchparam.useViewState()hook. (We're not using the Forms-package context router because the subscribers-dashboard package is standalone — the hook reads/writes viaURLSearchParams+history.replaceStatedirectly. wp-admin'spage=subscribersarg is preserved.)Phase 4 — Row + bulk actions ✅ (commit b390ca3)
supportsBulk: true, capped at 100 like Calypso).POST /wpcom/v2/subscribers/removeproxy that callsClient::wpcom_json_api_request_as_userfor each underlying WP.com endpoint; collects per-step errors so partial failures surface.queryClient.invalidateQueriesso the table and totals refetch.@wordpress/noticesSnackbarList.Phase 5 — Header CTAs + empty state ✅ (commit 958cd2d)
POST /wpcom/v2/subscribers/addproxy →/sites/{id}/invites/new.totalItems === 0and no filters/search. Filtered-empty stays on DataViews' built-in empty UI.Phase 5b — Importers + a11y/RTL ✅ (commits c330f92 + follow-ups)
/subscribers/addproxy. (Multipart pass-through to wpcom's/subscribers/importendpoint stays a follow-up; the JSON path covers the realistic single-column-of-emails case.)wordpress.com/import/newsletter/substack/{hostname}— same hand-off Calypso itself uses.useMigrateSubscribersCallbackPOSTs to/jetpack-blogs/{target}/source/{source}/migrate, but the source-site picker needs/me/siteswhich currently returns "An active access token must be used to query information about the current user" when called viaClient::wpcom_json_api_request_as_user. The in-modal flow was prototyped and reverted (commitfaa3a11afe); we'll bring it back once we have a path to authenticate the/me/siteslookup from inside the dashboard.Phase 5c — Comp / Remove comp ✅ (commit f0544b2)
SelectControl) over the site's membership products + "Doesn't expire" checkbox, mirrors Calypso's<CompModal>.comp_idfrom the row'splans[](is_comp && comp_id).GET /wpcom/v2/subscribers/products(memberships products feeding the picker),POST /wpcom/v2/subscribers/comp(calls/sites/{id}/memberships/comps/{user_id}/{plan_id}with optionalno_expiration),POST /wpcom/v2/subscribers/remove-comp(forwards as DELETE to/sites/{id}/memberships/comp/{compId}).user_id; Remove comp shows when at least one plan isis_compwith acomp_id.Phase 6 — Subscriber detail modal + stats ✅ (commit 24dd335)
useSubscriberDetails+useSubscriberStatsReact Query hooks.GET /wpcom/v2/subscribers/individual→/sites/{id}/subscribers/individual;GET /wpcom/v2/subscribers/stats→/sites/{id}/individual-subscriber-stats. Both accept eithersubscription_idoruser_id.?subscriber={subscription_id}&u={user_id}via a smalluseOpenSubscriber()hook that hydrates from the URL on mount and writes back viahistory.replaceState.Phase 7 — Telemetry ✅ (commit abd99cd)
jetpack_subscribers_*namespace via@automattic/jetpack-analytics:search_performed/filter_applied/sort_changed(DataViewsonChangeViewdiff)subscriber_row_clickedsubscriber_removed(per row, after API success)remove_{free|paid}_subscriber_modal_{showed,dismissed}comp_modal_open,comp_modal_confirm,remove_comp_confirmexport_downloadedadd_question(Add Subscribers tab change, withmethodprop)empty_view_displayedOut of scope (for now)
jetpack_wp_admin_subscriber_management_enabledtotrue. We'll keep itfalseuntil the implementation is feature-complete and reviewed; flipping the default is a follow-up issue once Phases 1-5 are merged.Calypso reference summary
client/my-sites/subscribers/main.tsxclient/my-sites/subscribers/components/subscriber-data-views.tsxclient/my-sites/subscribers/constants.ts(SubscribersSortBy)client/my-sites/subscribers/constants.ts(SubscribersFilterBy)client/my-sites/subscribers/queries/use-subscribers-query.tsxclient/my-sites/subscribers/queries/use-subscriber-count-query.tsclient/my-sites/subscribers/mutations/use-subscriber-remove-mutation.tsclient/my-sites/subscribers/tracks/client/my-sites/subscribers/helpers/index.tsNotes for reviewers / contributors
user_id0 means email-only, non-zero means WPCOM user) is the trickiest part. The remove flow has to call different endpoints based on which IDs are present. SeegetSubscriberDetailsType()andgetSubscriptionIdFromSubscriber()inhelpers/index.ts.wpcom_date_subscribedandemail_date_subscribed; Calypso useswpcom_date_subscribedwhen present, otherwise falls back toemail_date_subscribed. Same logic should be ported.