Repository: Automattic/wp-calypso
Pull request #100999: Add: Support for bulk subscriber management
Author: login: jorgefilipecosta
Created: 2025-03-07T14:53:34Z | Merged: 2025-03-17T12:36:38Z

This PR adds support for bulk subscriber management.
For now, only the remove subscriber action has bulk support but we can expand that later.
This change makes the subscribers list more consistent with what happens with other data-views e.g.: the core ones, that support bulk actions.

It also opens the door for some nice user interactions, e.g.: I can easily apply a filter for example free subscribers, and then select all records to apply an action (remove) instead of going one by one.

## Screenshots

<img width="1382" alt="Screenshot 2025-03-07 at 14 48 17" src="https://github.com/user-attachments/assets/74fb76dd-755c-4f30-bcdc-448aff455a28" />
<img width="1376" alt="Screenshot 2025-03-07 at 14 48 08" src="https://github.com/user-attachments/assets/8a7833ea-ea29-4ce3-8c72-3739dbf09f75" />


## Testing Instructions

Go to http://calypso.localhost:3000/subscribers/site.
Select multiple subscribers and verify they can all be removed at once.
Use the select all checkbox and verify it works.
Verify it is still possible to switch to the details view and navigate the records there.
Verify removing subscribers from the details view still works as expected.

## Pre-merge Checklist


- [ ] Has the general commit checklist been followed? (PCYsg-hS-p2)
- [ ] [Have you written new tests](https://wpcalypso.wordpress.com/devdocs/docs/testing/index.md) for your changes?
- [ ] Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)?
- [x] Have you checked for TypeScript, React or other console errors?
- [ ] Have you used memoizing on expensive computations? More info in [Memoizing with create-selector](https://github.com/Automattic/wp-calypso/blob/trunk/packages/state-utils/src/create-selector/README.md) and [Using memoizing selectors](https://react-redux.js.org/api/hooks#using-memoizing-selectors) and [Our Approach to Data](https://github.com/Automattic/wp-calypso/blob/trunk/docs/our-approach-to-data.md)
- [ ] Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    - [ ] For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages. 
- [ ] For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?



Diff:
diff --git a/client/my-sites/subscribers/components/subscriber-data-views/subscriber-data-views.tsx b/client/my-sites/subscribers/components/subscriber-data-views/subscriber-data-views.tsx
index 8f8b91418629..987eb05bc7c9 100644
--- a/client/my-sites/subscribers/components/subscriber-data-views/subscriber-data-views.tsx
+++ b/client/my-sites/subscribers/components/subscriber-data-views/subscriber-data-views.tsx
@@ -2,6 +2,7 @@ import { Gravatar, TimeSince } from '@automattic/components';
 import { useBreakpoint } from '@automattic/viewport-react';
 import { DataViews, type View, type Action, Operator } from '@wordpress/dataviews';
 import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
+import { trash } from '@wordpress/icons';
 import { translate } from 'i18n-calypso';
 import { useSubscribedNewsletterCategories } from 'calypso/data/newsletter-categories';
 import { useSelector } from 'calypso/state';
@@ -114,10 +115,10 @@ const SubscriberDataViews = ( {
 	} );
 
 	const {
-		currentSubscriber,
-		onClickUnsubscribe: handleUnsubscribe,
+		currentSubscribers,
+		onSetUnsubscribers: handleUnsubscribe,
 		onConfirmModal,
-		resetSubscriber,
+		resetSubscribers,
 	} = useUnsubscribeModal(
 		siteId ?? null,
 		{
@@ -294,8 +295,10 @@ const SubscriberDataViews = ( {
 			{
 				id: 'remove',
 				label: translate( 'Remove' ),
-				callback: ( items: Subscriber[] ) => handleUnsubscribe( items[ 0 ] ),
+				callback: handleUnsubscribe,
 				isPrimary: false,
+				supportsBulk: true,
+				icon: trash,
 			},
 		];
 
@@ -449,11 +452,14 @@ const SubscriberDataViews = ( {
 							fields={ fields }
 							view={ currentView }
 							onClickItem={ handleSubscriberSelection }
+							isItemClickable={ () => true }
 							onChangeView={ handleViewChange }
 							selection={
 								selectedSubscriber ? [ selectedSubscriber.subscription_id.toString() ] : undefined
 							}
-							onChangeSelection={ handleSubscriberSelection }
+							onChangeSelection={
+								currentView.type === 'list' ? handleSubscriberSelection : undefined
+							}
 							isLoading={ isLoading }
 							paginationInfo={ paginationInfo }
 							getItemId={ ( item: Subscriber ) => item.subscription_id.toString() }
@@ -476,15 +482,15 @@ const SubscriberDataViews = ( {
 							siteId={ siteId }
 							subscriptionId={ selectedSubscriber.subscription_id }
 							onClose={ () => setSelectedSubscriber( null ) }
-							onUnsubscribe={ handleUnsubscribe }
+							onUnsubscribe={ ( subscriber ) => handleUnsubscribe( [ subscriber ] ) }
 							newsletterCategoriesEnabled={ subscribedNewsletterCategoriesData?.enabled }
 							newsletterCategories={ subscribedNewsletterCategoriesData?.newsletterCategories }
 						/>
 					</section>
 				) }
 			<UnsubscribeModal
-				subscriber={ currentSubscriber }
-				onCancel={ resetSubscriber }
+				subscribers={ currentSubscribers }
+				onCancel={ resetSubscribers }
 				onConfirm={ onConfirmModal }
 			/>
 		</div>
diff --git a/client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx b/client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
index 53aab71a29e3..d0455cde1937 100644
--- a/client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
+++ b/client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
@@ -1,4 +1,4 @@
-import { useTranslate } from 'i18n-calypso';
+import { useTranslate, fixMe } from 'i18n-calypso';
 import { useCallback, useEffect } from 'react';
 import ConfirmModal from 'calypso/components/confirm-modal';
 import { useRecordRemoveModal } from '../../tracks';
@@ -10,65 +10,102 @@ export enum UnsubscribeActionType {
 }
 
 type UnsubscribeModalProps = {
-	subscriber?: Subscriber;
+	subscribers?: Subscriber[];
 	onCancel: () => void;
-	onConfirm: ( action: UnsubscribeActionType, subscriber?: Subscriber ) => void;
+	onConfirm: ( action: UnsubscribeActionType, subscribers: Subscriber[] | undefined ) => void;
 };
 
-const UnsubscribeModal = ( { subscriber, onCancel, onConfirm }: UnsubscribeModalProps ) => {
+const UnsubscribeModal = ( { subscribers, onCancel, onConfirm }: UnsubscribeModalProps ) => {
 	const translate = useTranslate();
-	const subscriberHasPlans = !! subscriber?.plans?.length;
+	const subscriber = subscribers?.[ 0 ];
+	const someSubscriberHasPlans = !! subscribers?.some( ( subscriber ) => subscriber.plans?.length );
 	const recordRemoveModal = useRecordRemoveModal();
 
+	useEffect( () => {
+		if ( subscriber ) {
+			recordRemoveModal( someSubscriberHasPlans, 'modal_showed' );
+		}
+	}, [ recordRemoveModal, someSubscriberHasPlans, subscriber ] );
+
+	const onCancelClick = useCallback( () => {
+		recordRemoveModal( someSubscriberHasPlans, 'modal_dismissed' );
+		onCancel();
+	}, [ someSubscriberHasPlans, onCancel ] );
+
+	if ( ! subscribers || ! subscribers.length ) {
+		return null;
+	}
+
 	const freeSubscriberProps = {
 		action: UnsubscribeActionType.Unsubscribe,
-		confirmButtonLabel: translate( 'Remove subscriber' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Remove subscribers',
+			newCopy: translate( 'Remove subscriber', 'Remove subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove subscriber' ),
+		} ),
 		text: translate(
-			'Are you sure you want to remove %s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(displayName)s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(numberOfSubscribers)d subscibers from your list? They will no longer receive new notifications from your site.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+					numberOfSubscribers: subscribers.length,
+				},
 			}
 		),
-		title: translate( 'Remove free subscriber' ),
+		title: fixMe( {
+			text: 'Remove free subscribers',
+			newCopy: translate( 'Remove free subscriber', 'Remove free subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove free subscriber' ),
+		} ),
 	};
 
 	const paidSubscriberProps = {
 		action: UnsubscribeActionType.Manage,
-		confirmButtonLabel: translate( 'Manage paid subscribers' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Manage paid subscribers',
+			newCopy: translate( 'Manage paid subscriber', 'Manage paid subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Manage paid subscriber' ),
+		} ),
+		// eslint-disable-next-line wpcalypso/i18n-mismatched-placeholders
 		text: translate(
-			'To remove %s from your list, you’ll need to cancel their paid subscription first.',
+			'To remove %(displayName)s from your list, you’ll need to cancel their paid subscription first.',
+			'Some subscribers have paid subscriptions. To remove them from your list, you’ll need to cancel their paid subscription first.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+				},
 			}
 		),
-		title: translate( 'Remove paid subscriber' ),
+		title: fixMe( {
+			text: 'Remove paid subscribers',
+			newCopy: translate( 'Remove paid subscriber', 'Remove paid subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove paid subscriber' ),
+		} ),
 	};
 
-	const { action, confirmButtonLabel, text, title } = subscriberHasPlans
+	const { action, confirmButtonLabel, text, title } = someSubscriberHasPlans
 		? paidSubscriberProps
 		: freeSubscriberProps;
 
-	useEffect( () => {
-		if ( subscriber ) {
-			recordRemoveModal( subscriberHasPlans, 'modal_showed' );
-		}
-	}, [ recordRemoveModal, subscriberHasPlans, subscriber ] );
-
-	const onCancelClick = useCallback( () => {
-		recordRemoveModal( subscriberHasPlans, 'modal_dismissed' );
-		onCancel();
-	}, [ subscriberHasPlans, onCancel ] );
-
 	return (
 		<ConfirmModal
 			isVisible={ !! subscriber }
-			confirmButtonLabel={ confirmButtonLabel }
+			confirmButtonLabel={ confirmButtonLabel || '' }
 			text={ text }
-			title={ title }
+			title={ ( title || '' ) as string }
 			onCancel={ onCancelClick }
-			onConfirm={ () => onConfirm( action, subscriber ) }
+			onConfirm={ () => onConfirm( action, subscribers ) }
 		/>
 	);
 };
diff --git a/client/my-sites/subscribers/hooks/use-unsubscribe-modal.ts b/client/my-sites/subscribers/hooks/use-unsubscribe-modal.ts
index 16da9d68fa42..30709da6d2bc 100644
--- a/client/my-sites/subscribers/hooks/use-unsubscribe-modal.ts
+++ b/client/my-sites/subscribers/hooks/use-unsubscribe-modal.ts
@@ -14,48 +14,52 @@ const useUnsubscribeModal = (
 	detailsView = false,
 	onSuccess?: () => void
 ) => {
-	const [ currentSubscriber, setCurrentSubscriber ] = useState< Subscriber >();
+	const [ currentSubscribers, setCurrentSubscribers ] = useState< Subscriber[] >();
 	const selectedSiteSlug = useSelector( getSelectedSiteSlug );
 	const recordRemoveModal = useRecordRemoveModal();
 	const { mutate } = useSubscriberRemoveMutation( siteId, subscriberQueryParams, detailsView );
 
-	const onClickUnsubscribe = ( subscriber: Subscriber ) => {
-		setCurrentSubscriber( subscriber );
+	const onSetUnsubscribers = ( subscribers: Subscriber[] ) => {
+		setCurrentSubscribers( subscribers );
 	};
 
-	const resetSubscriber = () => {
-		setCurrentSubscriber( undefined );
+	const resetSubscribers = () => {
+		setCurrentSubscribers( undefined );
 	};
 
-	const onConfirmModal = ( action: UnsubscribeActionType, subscriber?: Subscriber ) => {
+	const onConfirmModal = ( action: UnsubscribeActionType, subscribers?: Subscriber[] ) => {
 		if ( action === UnsubscribeActionType.Manage ) {
 			recordRemoveModal( true, 'manage_button_clicked' );
 			const link = isJetpackCloud()
 				? `/monetize/supporters/${ selectedSiteSlug }`
 				: `/earn/supporters/${ selectedSiteSlug }`;
 			navigate( link ?? '' );
-		} else if ( action === UnsubscribeActionType.Unsubscribe && subscriber ) {
-			mutate( subscriber, {
+		} else if (
+			action === UnsubscribeActionType.Unsubscribe &&
+			subscribers &&
+			subscribers.length
+		) {
+			mutate( subscribers, {
 				onSuccess: () => {
-					resetSubscriber();
+					resetSubscribers();
 					onSuccess?.();
 				},
 			} );
 		}
 
-		resetSubscriber();
+		resetSubscribers();
 	};
 
 	// Reset current subscriber on unmount
 	useEffect( () => {
-		return resetSubscriber;
+		return resetSubscribers;
 	}, [] );
 
 	return {
-		currentSubscriber,
-		onClickUnsubscribe,
+		currentSubscribers,
+		onSetUnsubscribers,
 		onConfirmModal,
-		resetSubscriber,
+		resetSubscribers,
 	};
 };
 
diff --git a/client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts b/client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts
index 603fd03191c2..359091ca5882 100644
--- a/client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts
+++ b/client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts
@@ -36,61 +36,88 @@ const useSubscriberRemoveMutation = (
 	} );
 
 	return useMutation( {
-		mutationFn: async ( subscriber: Subscriber ) => {
-			if ( ! siteId || ! subscriber ) {
+		mutationFn: async ( subscribers: Subscriber[] ) => {
+			if ( ! siteId || ! subscribers || ! subscribers.length ) {
 				throw new Error(
 					// reminder: translate this string when we add it to the UI
 					'Something went wrong while unsubscribing.'
 				);
 			}
 
-			if ( subscriber.plans?.length ) {
-				// unsubscribe this user from all plans
-				const promises = subscriber.plans.map( ( plan ) =>
-					wpcom.req.post(
-						`/sites/${ siteId }/memberships/subscriptions/${ plan.paid_subscription_id }/cancel`,
-						{
-							user_id: subscriber.user_id,
-						}
-					)
+			if ( subscribers.length > 100 ) {
+				throw new Error(
+					// reminder: translate this string when we add it to the UI
+					'The maximum number of subscribers you can remove at once is 100.'
 				);
-
-				await Promise.all( promises );
 			}
 
-			let wasRemoved = false;
-
-			// Remove the subscriber from the followers and email followers because they may be both of them.
-			if ( subscriber.user_id ) {
-				try {
-					await wpcom.req.post( `/sites/${ siteId }/followers/${ subscriber.user_id }/delete` );
-					wasRemoved = true;
-				} catch ( e ) {
-					// Only throw if subscription_id is empty.
-					if ( ( e as ApiResponseError )?.error === 'not_found' && ! subscriber.subscription_id ) {
-						throw new Error( ( e as ApiResponseError )?.message );
+			const subscriberPromises = subscribers.map( async ( subscriber ) => {
+				if ( subscriber.plans?.length ) {
+					// unsubscribe this user from all plans
+					const promises = subscriber.plans.map( ( plan ) =>
+						wpcom.req.post(
+							`/sites/${ siteId }/memberships/subscriptions/${ plan.paid_subscription_id }/cancel`,
+							{
+								user_id: subscriber.user_id,
+							}
+						)
+					);
+
+					await Promise.all( promises );
+				}
+
+				let wasRemoved = false;
+
+				// Remove the subscriber from the followers and email followers because they may be both of them.
+				if ( subscriber.user_id ) {
+					try {
+						await wpcom.req.post( `/sites/${ siteId }/followers/${ subscriber.user_id }/delete` );
+						wasRemoved = true;
+					} catch ( e ) {
+						// Only throw if subscription_id is empty.
+						if (
+							( e as ApiResponseError )?.error === 'not_found' &&
+							! subscriber.subscription_id
+						) {
+							throw new Error( ( e as ApiResponseError )?.message );
+						}
 					}
 				}
-			}
 
-			// Always try to remove as email follower if they have a subscription_id.
-			if ( subscriber.subscription_id ) {
-				try {
-					await wpcom.req.post(
-						`/sites/${ siteId }/email-followers/${ subscriber.subscription_id }/delete`
-					);
-					wasRemoved = true;
-				} catch ( e ) {
-					// Only throw if we haven't successfully removed them through any other method.
-					if ( ! wasRemoved ) {
-						throw new Error( ( e as ApiResponseError )?.message );
+				// Always try to remove as email follower if they have a subscription_id.
+				if ( subscriber.subscription_id ) {
+					try {
+						await wpcom.req.post(
+							`/sites/${ siteId }/email-followers/${ subscriber.subscription_id }/delete`
+						);
+						wasRemoved = true;
+					} catch ( e ) {
+						// Only throw if we haven't successfully removed them through any other method.
+						if ( ! wasRemoved ) {
+							throw new Error( ( e as ApiResponseError )?.message );
+						}
 					}
 				}
-			}
 
-			return wasRemoved;
+				return wasRemoved;
+			} );
+			const promiseResults = await Promise.allSettled( subscriberPromises );
+			if (
+				promiseResults.every( ( promiseResults ) => {
+					return promiseResults.status === 'fulfilled' && promiseResults.value === true;
+				} )
+			) {
+				return true;
+			}
+			const errorPromise = promiseResults.find( ( promiseResult ) => {
+				return promiseResult.status === 'rejected';
+			} );
+			if ( errorPromise ) {
+				throw new Error( errorPromise.reason );
+			}
+			return false;
 		},
-		onMutate: async ( subscriber ) => {
+		onMutate: async ( subscribers ) => {
 			// Cancel any outgoing refetches
 			await queryClient.cancelQueries( { queryKey: [ 'subscribers', siteId ] } );
 
@@ -102,9 +129,11 @@ const useSubscriberRemoveMutation = (
 				// Update the current page data
 				const updatedData = {
 					...previousData,
-					subscribers: previousData.subscribers.filter(
-						( s ) => s.subscription_id !== subscriber.subscription_id
-					),
+					subscribers: previousData.subscribers.filter( ( s ) => {
+						return ! subscribers.some(
+							( subscriber ) => s.subscription_id === subscriber.subscription_id
+						);
+					} ),
 					total: previousData.total - 1,
 					pages: Math.ceil( ( previousData.total - 1 ) / previousData.per_page ),
 				};
@@ -130,14 +159,16 @@ const useSubscriberRemoveMutation = (
 			// Handle subscriber details cache if needed
 			let previousDetailsData;
 			if ( invalidateDetailsCache ) {
-				const detailsCacheKey = getSubscriberDetailsCacheKey(
-					siteId,
-					subscriber.subscription_id,
-					subscriber.user_id,
-					getSubscriberDetailsType( subscriber.user_id )
-				);
-				await queryClient.cancelQueries( { queryKey: detailsCacheKey } );
-				previousDetailsData = queryClient.getQueryData< Subscriber >( detailsCacheKey );
+				for ( const subscriber of subscribers ) {
+					const detailsCacheKey = getSubscriberDetailsCacheKey(
+						siteId,
+						subscriber.subscription_id,
+						subscriber.user_id,
+						getSubscriberDetailsType( subscriber.user_id )
+					);
+					await queryClient.cancelQueries( { queryKey: detailsCacheKey } );
+					previousDetailsData = queryClient.getQueryData< Subscriber >( detailsCacheKey );
+				}
 			}
 
 			return {
@@ -161,25 +192,29 @@ const useSubscriberRemoveMutation = (
 				queryClient.setQueryData( detailsCacheKey, context.previousDetailsData );
 			}
 		},
-		onSuccess: ( data, subscriber ) => {
-			recordSubscriberRemoved( {
-				site_id: siteId,
-				subscription_id: subscriber.subscription_id,
-				user_id: subscriber.user_id,
-			} );
+		onSuccess: ( data, subscribers ) => {
+			for ( const subscriber of subscribers ) {
+				recordSubscriberRemoved( {
+					site_id: siteId,
+					subscription_id: subscriber.subscription_id,
+					user_id: subscriber.user_id,
+				} );
+			}
 		},
-		onSettled: ( data, error, subscriber ) => {
-			// Invalidate all subscriber queries to ensure consistency
-			queryClient.invalidateQueries( { queryKey: [ 'subscribers', siteId ] } );
+		onSettled: ( data, error, subscribers ) => {
+			for ( const subscriber of subscribers ) {
+				// Invalidate all subscriber queries to ensure consistency
+				queryClient.invalidateQueries( { queryKey: [ 'subscribers', siteId ] } );
 
-			if ( invalidateDetailsCache ) {
-				const detailsCacheKey = getSubscriberDetailsCacheKey(
-					siteId,
-					subscriber.subscription_id,
-					subscriber.user_id,
-					getSubscriberDetailsType( subscriber.user_id )
-				);
-				queryClient.invalidateQueries( { queryKey: detailsCacheKey } );
+				if ( invalidateDetailsCache ) {
+					const detailsCacheKey = getSubscriberDetailsCacheKey(
+						siteId,
+						subscriber.subscription_id,
+						subscriber.user_id,
+						getSubscriberDetailsType( subscriber.user_id )
+					);
+					queryClient.invalidateQueries( { queryKey: detailsCacheKey } );
+				}
 			}
 		},
 	} );


Reviews:
user: login: allilevine
body: This is working great! I had some suggestions for translations, and a question about max subscribers.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T14:40:59Z
user: login: allilevine
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T20:00:25Z
user: login: allilevine
body: I pushed a couple of small fixes to the translations, but it looks ready to ship!
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T20:01:31Z
user: login: jorgefilipecosta
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:00:32Z

Review comments:
diff_hunk: @@ -36,61 +36,81 @@ const useSubscriberRemoveMutation = (
 	} );
 
 	return useMutation( {
-		mutationFn: async ( subscriber: Subscriber ) => {
-			if ( ! siteId || ! subscriber ) {
+		mutationFn: async ( subscribers: Subscriber[] ) => {
+			if ( ! siteId || ! subscribers || ! subscribers.length ) {
path: client/my-sites/subscribers/mutations/use-subscriber-remove-mutation.ts
user: login: allilevine
body: Do we need a maximum length here? Or the most per page is 100 subscribers, so that's the highest number that could be deleted at once?
created_at: 2025-03-11T14:36:28Z
author_association: MEMBER
diff_hunk: @@ -10,56 +10,80 @@ export enum UnsubscribeActionType {
 }
 
 type UnsubscribeModalProps = {
-	subscriber?: Subscriber;
+	subscribers?: Subscriber[];
 	onCancel: () => void;
-	onConfirm: ( action: UnsubscribeActionType, subscriber?: Subscriber ) => void;
+	onConfirm: ( action: UnsubscribeActionType, subscribers: Subscriber[] | undefined ) => void;
 };
 
-const UnsubscribeModal = ( { subscriber, onCancel, onConfirm }: UnsubscribeModalProps ) => {
+const UnsubscribeModal = ( { subscribers, onCancel, onConfirm }: UnsubscribeModalProps ) => {
 	const translate = useTranslate();
-	const subscriberHasPlans = !! subscriber?.plans?.length;
+	const subscriber = subscribers?.[ 0 ];
+	const someSubscriberHasPlans = !! subscribers?.some( ( subscriber ) => subscriber.plans?.length );
+	const isSingleSubscriber = subscribers?.length === 1;
 	const recordRemoveModal = useRecordRemoveModal();
 
-	const freeSubscriberProps = {
-		action: UnsubscribeActionType.Unsubscribe,
-		confirmButtonLabel: translate( 'Remove subscriber' ),
-		text: translate(
-			'Are you sure you want to remove %s from your list? They will no longer receive new notifications from your site.',
-			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
-			}
-		),
-		title: translate( 'Remove free subscriber' ),
-	};
+	const freeSubscriberProps = isSingleSubscriber
+		? {
+				action: UnsubscribeActionType.Unsubscribe,
+				confirmButtonLabel: translate( 'Remove subscriber' ),
+				text: translate(
path: client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
user: login: allilevine
body: Is it possible to use `count` here for pluralization? https://github.com/Automattic/wp-calypso/tree/trunk/packages/i18n-calypso#pluralization

We can also use `fixMe` to show the old translation until the pluralized one is translated: https://github.com/Automattic/wp-calypso/tree/trunk/packages/i18n-calypso#fixme-method

Same for `paidSubscriberProps`.
created_at: 2025-03-11T14:40:01Z
author_association: MEMBER
diff_hunk: @@ -10,65 +10,103 @@ export enum UnsubscribeActionType {
 }
 
 type UnsubscribeModalProps = {
-	subscriber?: Subscriber;
+	subscribers?: Subscriber[];
 	onCancel: () => void;
-	onConfirm: ( action: UnsubscribeActionType, subscriber?: Subscriber ) => void;
+	onConfirm: ( action: UnsubscribeActionType, subscribers: Subscriber[] | undefined ) => void;
 };
 
-const UnsubscribeModal = ( { subscriber, onCancel, onConfirm }: UnsubscribeModalProps ) => {
+const UnsubscribeModal = ( { subscribers, onCancel, onConfirm }: UnsubscribeModalProps ) => {
 	const translate = useTranslate();
-	const subscriberHasPlans = !! subscriber?.plans?.length;
+	const subscriber = subscribers?.[ 0 ];
+	const someSubscriberHasPlans = !! subscribers?.some( ( subscriber ) => subscriber.plans?.length );
 	const recordRemoveModal = useRecordRemoveModal();
 
+	useEffect( () => {
+		if ( subscriber ) {
+			recordRemoveModal( someSubscriberHasPlans, 'modal_showed' );
+		}
+	}, [ recordRemoveModal, someSubscriberHasPlans, subscriber ] );
+
+	const onCancelClick = useCallback( () => {
+		recordRemoveModal( someSubscriberHasPlans, 'modal_dismissed' );
+		onCancel();
+	}, [ someSubscriberHasPlans, onCancel ] );
+
+	if ( ! subscribers || ! subscribers.length ) {
+		return null;
+	}
+
 	const freeSubscriberProps = {
 		action: UnsubscribeActionType.Unsubscribe,
-		confirmButtonLabel: translate( 'Remove subscriber' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Remove subscribers',
+			newCopy: translate( 'Remove subscriber', 'Remove subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove subscriber' ),
+		} ),
 		text: translate(
-			'Are you sure you want to remove %s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(displayName)s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(numberOfSubscribers)d subscribers from your list? They will no longer receive new notifications from your site.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+					numberOfSubscribers: subscribers.length,
+				},
 			}
 		),
-		title: translate( 'Remove free subscriber' ),
+		title: fixMe( {
+			text: 'Remove free subscribers',
+			newCopy: translate( 'Remove free subscriber', 'Remove free subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove free subscriber' ),
+		} ),
 	};
 
 	const paidSubscriberProps = {
 		action: UnsubscribeActionType.Manage,
-		confirmButtonLabel: translate( 'Manage paid subscribers' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Manage paid subscribers',
+			newCopy: translate( 'Manage paid subscriber', 'Manage paid subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Manage paid subscribers' ),
+		} ),
+		// eslint-disable-next-line wpcalypso/i18n-mismatched-placeholders
 		text: translate(
-			'To remove %s from your list, you’ll need to cancel their paid subscription first.',
+			'To remove %(displayName)s from your list, you’ll need to cancel their paid subscription first.',
+			'Some subscribers have paid subscriptions. To remove them from your list, you’ll need to cancel their paid subscription first.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+					numberOfSubscribers: subscribers.length,
path: client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
user: login: allilevine
body: Is this arg still needed?
created_at: 2025-03-13T20:00:25Z
author_association: MEMBER
diff_hunk: @@ -10,65 +10,103 @@ export enum UnsubscribeActionType {
 }
 
 type UnsubscribeModalProps = {
-	subscriber?: Subscriber;
+	subscribers?: Subscriber[];
 	onCancel: () => void;
-	onConfirm: ( action: UnsubscribeActionType, subscriber?: Subscriber ) => void;
+	onConfirm: ( action: UnsubscribeActionType, subscribers: Subscriber[] | undefined ) => void;
 };
 
-const UnsubscribeModal = ( { subscriber, onCancel, onConfirm }: UnsubscribeModalProps ) => {
+const UnsubscribeModal = ( { subscribers, onCancel, onConfirm }: UnsubscribeModalProps ) => {
 	const translate = useTranslate();
-	const subscriberHasPlans = !! subscriber?.plans?.length;
+	const subscriber = subscribers?.[ 0 ];
+	const someSubscriberHasPlans = !! subscribers?.some( ( subscriber ) => subscriber.plans?.length );
 	const recordRemoveModal = useRecordRemoveModal();
 
+	useEffect( () => {
+		if ( subscriber ) {
+			recordRemoveModal( someSubscriberHasPlans, 'modal_showed' );
+		}
+	}, [ recordRemoveModal, someSubscriberHasPlans, subscriber ] );
+
+	const onCancelClick = useCallback( () => {
+		recordRemoveModal( someSubscriberHasPlans, 'modal_dismissed' );
+		onCancel();
+	}, [ someSubscriberHasPlans, onCancel ] );
+
+	if ( ! subscribers || ! subscribers.length ) {
+		return null;
+	}
+
 	const freeSubscriberProps = {
 		action: UnsubscribeActionType.Unsubscribe,
-		confirmButtonLabel: translate( 'Remove subscriber' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Remove subscribers',
+			newCopy: translate( 'Remove subscriber', 'Remove subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove subscriber' ),
+		} ),
 		text: translate(
-			'Are you sure you want to remove %s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(displayName)s from your list? They will no longer receive new notifications from your site.',
+			'Are you sure you want to remove %(numberOfSubscribers)d subscribers from your list? They will no longer receive new notifications from your site.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+					numberOfSubscribers: subscribers.length,
+				},
 			}
 		),
-		title: translate( 'Remove free subscriber' ),
+		title: fixMe( {
+			text: 'Remove free subscribers',
+			newCopy: translate( 'Remove free subscriber', 'Remove free subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Remove free subscriber' ),
+		} ),
 	};
 
 	const paidSubscriberProps = {
 		action: UnsubscribeActionType.Manage,
-		confirmButtonLabel: translate( 'Manage paid subscribers' ),
+		confirmButtonLabel: fixMe( {
+			text: 'Manage paid subscribers',
+			newCopy: translate( 'Manage paid subscriber', 'Manage paid subscribers', {
+				count: subscribers.length,
+			} ),
+			oldCopy: translate( 'Manage paid subscribers' ),
+		} ),
+		// eslint-disable-next-line wpcalypso/i18n-mismatched-placeholders
 		text: translate(
-			'To remove %s from your list, you’ll need to cancel their paid subscription first.',
+			'To remove %(displayName)s from your list, you’ll need to cancel their paid subscription first.',
+			'Some subscribers have paid subscriptions. To remove them from your list, you’ll need to cancel their paid subscription first.',
 			{
-				args: [ subscriber?.display_name as string ],
-				comment: "%s is the subscriber's public display name",
+				count: subscribers.length,
+				args: {
+					displayName: subscriber?.display_name || '',
+					numberOfSubscribers: subscribers.length,
path: client/my-sites/subscribers/components/unsubscribe-modal/unsubscribe-modal.tsx
user: login: jorgefilipecosta
body: No, it is not needed. I removed it.
created_at: 2025-03-17T12:00:32Z
author_association: MEMBER

PR comments:
user: login: matticbot
created_at: 2025-03-07T14:54:07Z
body: <details>
<summary>Calypso Live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-137179%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179">https://calypso.live?image=registry.a8c.com/calypso/app:build-137179</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Jetpack Cloud live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=jetpack">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-137179%26env%3Djetpack%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=jetpack">https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=jetpack</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Automattic for Agencies live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=a8c-for-agencies">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-137179%26env%3Da8c-for-agencies%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=a8c-for-agencies">https://calypso.live?image=registry.a8c.com/calypso/app:build-137179&env=a8c-for-agencies</a>
</td>
</tr>
</table>
</details>
<!--calypso-live-watermark:apr@v1-->
author_association: CONTRIBUTOR
user: login: matticbot
created_at: 2025-03-07T14:56:33Z
body: <!-- icfy-watermark: c52822 -->
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

**Sections** (~549 bytes added 📈 [gzipped])
<details>

```
name         parsed_size           gzip_size
subscribers      +2115 B  (+0.2%)     +549 B  (+0.2%)
```

Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to.
</details>

**Async-loaded Components** (~327 bytes added 📈 [gzipped])
<details>

```
name                                                 parsed_size           gzip_size
async-load-calypso-my-sites-stats-pages-subscribers      +1265 B  (+0.7%)     +327 B  (+0.5%)
```

React components that are loaded lazily, when a certain part of UI is displayed for the first time.
</details>

**Legend**
<details>
<summary>What is parsed and gzip size?</summary>

**Parsed Size:** Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
**Gzip Size:** Compressed size of the JS and CSS files. This much data needs to be downloaded over network.
</details>

Generated by performance advisor bot at [iscalypsofastyet.com](http://iscalypsofastyet.com).
author_association: CONTRIBUTOR
user: login: matticbot
created_at: 2025-03-07T15:22:31Z
body: **This PR modifies the release build for the following Calypso Apps:**

_For info about this notification, see here: PCYsg-OT6-p2_

* notifications
* odyssey-stats

To test WordPress.com changes, run `install-plugin.sh $pluginSlug add/support-for-bulk-subscriber-management` on your sandbox.
<!--calypso-app-artifacts-watermark:apr@v1-->
author_association: CONTRIBUTOR
user: login: allilevine
created_at: 2025-03-07T16:02:17Z
body: @jorgefilipecosta This is a nice surprise! 🙌 cc'ing @davemart-in for awareness.

Looks like there's a type error, but the main functionality is working for me.

We also need to make sure Jetpack removal tests pass:
`export CALYPSO_BASE_URL=http://calypso.localhost:3000/`
`yarn workspace wp-e2e-tests build && VIEWPORT_NAME="desktop" ATOMIC_VARIATION="default" TEST_ON_ATOMIC="true" yarn workspace wp-e2e-tests test test/e2e/specs/users/newsletter__subscribe-remove.ts`
author_association: MEMBER
user: login: jorgefilipecosta
created_at: 2025-03-10T15:50:37Z
body: Hi @allilevine, thank you for having a look into this PR 😊

> Looks like there's a type error, but the main functionality is working for me.

The issue is fixed 👍 

> We also need to make sure Jetpack removal tests pass:
export CALYPSO_BASE_URL=http://calypso.localhost:3000/
yarn workspace wp-e2e-tests build && VIEWPORT_NAME="desktop" ATOMIC_VARIATION="default" TEST_ON_ATOMIC="true" yarn workspace wp-e2e-tests test test/e2e/specs/users/newsletter__subscribe-remove.ts

I'm having issues running this test locally, but the issue I have also happens on trunk:
<img width="1091" alt="Screenshot 2025-03-10 at 15 49 42" src="https://github.com/user-attachments/assets/1e392f14-2c09-405b-be53-e92fa9d6d9b6" />

The test stops here. It is probably some issue with my local setup.


Is this test also executed on the CI? If yes given that the CI is green could we assume the issue is fixed?



author_association: MEMBER
user: login: allilevine
created_at: 2025-03-11T14:04:14Z
body: > Is this test also executed on the CI? If yes given that the CI is green could we assume the issue is fixed?

It doesn't, I found that out when it broke Jetpack deploys. 😅 It has this group tag in the test file: `@group jetpack-wpcom-integration` Maybe we should change it to run here as well.

It's passing for me locally 🎉 

<img width="471" alt="Screen Shot 2025-03-11 at 10 00 56 AM" src="https://github.com/user-attachments/assets/c6d2391d-44fa-4113-886d-dcbb6a1cff6a" />


author_association: MEMBER
user: login: jorgefilipecosta
created_at: 2025-03-12T23:29:39Z
body: Thank you @allilevine, all your feedback was applied 😊
author_association: MEMBER