diff --git a/.github/changelog/2840-from-description b/.github/changelog/2840-from-description
new file mode 100644
index 0000000000..983fd648e1
--- /dev/null
+++ b/.github/changelog/2840-from-description
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Add bulk and row action to soft delete posts from the Fediverse.
diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php
index a04b0dd201..649fb53e58 100644
--- a/includes/wp-admin/class-admin.php
+++ b/includes/wp-admin/class-admin.php
@@ -15,8 +15,10 @@
use Activitypub\Moderation;
use Activitypub\Scheduler\Actor;
+use function Activitypub\add_to_outbox;
use function Activitypub\count_followers;
use function Activitypub\get_content_visibility;
+use function Activitypub\get_wp_object_state;
use function Activitypub\is_user_type_disabled;
use function Activitypub\site_supports_blocks;
use function Activitypub\user_can_activitypub;
@@ -57,6 +59,15 @@ public static function init() {
\add_action( 'admin_post_delete_actor_confirmed', array( self::class, 'handle_bulk_actor_delete_confirmation' ) );
\add_action( 'admin_action_activitypub_confirm_removal', array( self::class, 'handle_bulk_actor_delete_page' ) );
+ // Post bulk actions for federated content.
+ self::register_post_bulk_actions();
+ \add_action( 'admin_post_activitypub_delete_posts_confirmed', array( self::class, 'handle_bulk_post_delete_confirmation' ) );
+ \add_action( 'admin_action_activitypub_confirm_post_removal', array( self::class, 'handle_bulk_post_delete_page' ) );
+ \add_action( 'admin_post_activitypub_delete_post', array( self::class, 'handle_single_post_delete' ) );
+
+ // Register removable query args for one-time admin notices.
+ \add_filter( 'removable_query_args', array( self::class, 'add_removable_query_args' ) );
+
if ( user_can_activitypub( \get_current_user_id() ) ) {
\add_action( 'show_user_profile', array( self::class, 'add_profile' ) );
}
@@ -115,6 +126,98 @@ public static function admin_notices() {
0 ) {
+ ?>
+
+
+
+
+
+
+
+
+
+ $users,
- 'send_back' => $send_back,
+ 'type' => 'users',
+ 'items' => $users,
+ 'send_back' => $send_back,
+ 'checked' => false,
+ 'cancel_label' => \__( 'Skip', 'activitypub' ),
)
);
exit;
@@ -791,6 +897,245 @@ public static function process_capability_removal( $users, $remove_from_fedivers
return $send_back;
}
+ /**
+ * Register bulk actions for post types that support ActivityPub.
+ */
+ public static function register_post_bulk_actions() {
+ $post_types = \get_post_types_by_support( 'activitypub' );
+
+ foreach ( $post_types as $post_type ) {
+ \add_filter( "bulk_actions-edit-{$post_type}", array( self::class, 'post_bulk_options' ) );
+ \add_filter( "handle_bulk_actions-edit-{$post_type}", array( self::class, 'handle_post_bulk_request' ), 10, 3 );
+ }
+ }
+
+ /**
+ * Add options to the Bulk dropdown on the posts page.
+ *
+ * @param array $actions The existing bulk options.
+ *
+ * @return array The extended bulk options.
+ */
+ public static function post_bulk_options( $actions ) {
+ $actions['activitypub_delete'] = __( 'Soft Delete', 'activitypub' );
+
+ return $actions;
+ }
+
+ /**
+ * Handle bulk activitypub requests for posts.
+ *
+ * @param string $send_back The URL to send the user back to.
+ * @param string $action The requested action.
+ * @param array $post_ids The selected post IDs.
+ *
+ * @return string The URL to send the user back to.
+ */
+ public static function handle_post_bulk_request( $send_back, $action, $post_ids ) {
+ if ( 'activitypub_delete' !== $action ) {
+ return $send_back;
+ }
+
+ // Filter to only include federated posts.
+ $federated_posts = array();
+ foreach ( $post_ids as $post_id ) {
+ $post = \get_post( $post_id );
+ if ( ! $post ) {
+ continue;
+ }
+
+ $state = get_wp_object_state( $post );
+ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) {
+ $federated_posts[] = $post_id;
+ }
+ }
+
+ // If no federated posts, redirect back with a notice.
+ if ( empty( $federated_posts ) ) {
+ return \add_query_arg( 'activitypub_no_federated', '1', $send_back );
+ }
+
+ // Build the query args for the confirmation page.
+ $query_args = array(
+ 'action' => 'activitypub_confirm_post_removal',
+ 'send_back' => \rawurlencode( $send_back ),
+ );
+
+ // Add post IDs as separate parameters.
+ foreach ( $federated_posts as $index => $post_id ) {
+ $query_args[ sprintf( 'posts[%d]', $index ) ] = \absint( $post_id );
+ }
+
+ $confirmation_url = \add_query_arg( $query_args, \admin_url( 'edit.php' ) );
+
+ // Force redirect to confirmation page.
+ \wp_safe_redirect( $confirmation_url );
+ exit;
+ }
+
+ /**
+ * Handle the bulk post deletion page request directly.
+ */
+ public static function handle_bulk_post_delete_page() {
+ // Check permissions.
+ if ( ! \current_user_can( 'edit_posts' ) ) {
+ \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) );
+ }
+
+ // Get parameters.
+ // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
+ $posts = \wp_unslash( $_GET['posts'] ?? array() );
+ // phpcs:ignore WordPress.Security.NonceVerification
+ $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) );
+
+ // Sanitize post IDs.
+ $posts = \array_map( 'absint', (array) $posts );
+ $posts = \array_filter( $posts );
+
+ // Validate send_back URL.
+ if ( empty( $send_back ) && ! empty( $posts ) ) {
+ // Try to determine the post type from the first post to preserve context.
+ $first_post = \get_post( $posts[0] );
+ if ( $first_post ) {
+ $send_back = \admin_url( 'edit.php?post_type=' . $first_post->post_type );
+ } else {
+ $send_back = \admin_url( 'edit.php' );
+ }
+ } elseif ( empty( $send_back ) ) {
+ $send_back = \admin_url( 'edit.php' );
+ }
+
+ // Load template and exit to prevent WordPress from trying to load other admin pages.
+ \load_template(
+ ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-delete-confirmation.php',
+ false,
+ array(
+ 'type' => 'posts',
+ 'items' => $posts,
+ 'send_back' => $send_back,
+ )
+ );
+ exit;
+ }
+
+ /**
+ * Handle the bulk post deletion confirmation form submission.
+ */
+ public static function handle_bulk_post_delete_confirmation() {
+ // Verify nonce.
+ if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub-bulk-post-delete' ) ) {
+ \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) );
+ }
+
+ // Check permissions.
+ if ( ! \current_user_can( 'edit_posts' ) ) {
+ \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) );
+ }
+
+ // Get form data.
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+ $selected_posts = \wp_unslash( $_POST['selected_posts'] ?? array() );
+ $send_back = \esc_url_raw( \wp_unslash( $_POST['send_back'] ?? '' ) );
+
+ // Sanitize post IDs.
+ $selected_posts = \array_map( 'absint', (array) $selected_posts );
+ $selected_posts = \array_filter( $selected_posts );
+
+ if ( empty( $selected_posts ) ) {
+ \wp_safe_redirect( $send_back );
+ exit;
+ }
+
+ // Process deletion.
+ $deleted_count = 0;
+ foreach ( $selected_posts as $post_id ) {
+ $post = \get_post( $post_id );
+ if ( ! $post ) {
+ continue;
+ }
+
+ // Verify the post is still federated.
+ $state = get_wp_object_state( $post );
+ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) {
+ continue;
+ }
+
+ // Check user can edit this post.
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
+ continue;
+ }
+
+ // Send Delete activity.
+ $result = add_to_outbox( $post, 'Delete', $post->post_author );
+ if ( $result ) {
+ // Set visibility to private to prevent re-federation.
+ \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
+ ++$deleted_count;
+ }
+ }
+
+ // Add success count to redirect URL.
+ $send_back = \add_query_arg( 'activitypub_deleted', $deleted_count, $send_back );
+
+ // Redirect back.
+ \wp_safe_redirect( $send_back );
+ exit;
+ }
+
+ /**
+ * Handle single post deletion from Fediverse.
+ */
+ public static function handle_single_post_delete() {
+ // Get and sanitize post ID.
+ $post_id = \absint( $_GET['post_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification
+
+ // Verify nonce.
+ if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'activitypub-delete-post-' . $post_id ) ) {
+ \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) );
+ }
+
+ // Check post exists.
+ $post = \get_post( $post_id );
+ if ( ! $post ) {
+ \wp_die( \esc_html__( 'Post not found.', 'activitypub' ) );
+ }
+
+ // Check permissions.
+ if ( ! \current_user_can( 'edit_post', $post_id ) ) {
+ \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) );
+ }
+
+ // Verify the post is federated.
+ $state = get_wp_object_state( $post );
+ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) {
+ \wp_die( \esc_html__( 'This post has not been federated.', 'activitypub' ) );
+ }
+
+ // Send Delete activity.
+ $result = add_to_outbox( $post, 'Delete', $post->post_author );
+
+ // Set visibility to private to prevent re-federation.
+ if ( $result ) {
+ \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE );
+ }
+
+ // Build redirect URL.
+ $send_back = \admin_url( 'edit.php' );
+ if ( 'post' !== $post->post_type ) {
+ $send_back = \add_query_arg( 'post_type', $post->post_type, $send_back );
+ }
+
+ if ( $result ) {
+ $send_back = \add_query_arg( 'activitypub_deleted', 1, $send_back );
+ } else {
+ $send_back = \add_query_arg( 'activitypub_delete_failed', 1, $send_back );
+ }
+
+ // Redirect back.
+ \wp_safe_redirect( $send_back );
+ exit;
+ }
+
/**
* Add ActivityPub infos to the dashboard glance items.
*
@@ -853,24 +1198,51 @@ public static function dashboard_glance_items( $items ) {
* @return array The modified actions.
*/
public static function row_actions( $actions, $post ) {
- // check if the post is enabled for ActivityPub.
+ // Check if the post type supports ActivityPub.
if (
! \post_type_supports( \get_post_type( $post ), 'activitypub' ) ||
- ! in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) ||
- ! \current_user_can( 'edit_post', $post->ID ) ||
- ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === get_content_visibility( $post->ID ) ||
- ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) )
+ ! \current_user_can( 'edit_post', $post->ID )
) {
return $actions;
}
- $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) );
+ // Add preview link for non-local posts in block editor.
+ if (
+ in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) &&
+ ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL !== get_content_visibility( $post->ID ) &&
+ ! ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) )
+ ) {
+ $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) );
- $actions['activitypub'] = sprintf(
- '%s',
- \esc_url( $preview_url ),
- \esc_html__( 'Fediverse Preview ⁂', 'activitypub' )
- );
+ $actions['activitypub'] = sprintf(
+ '%s',
+ \esc_url( $preview_url ),
+ \esc_html__( 'Fediverse Preview ⁂', 'activitypub' )
+ );
+ }
+
+ // Add "Delete from Fediverse" link for federated posts.
+ $state = get_wp_object_state( $post );
+ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) {
+ $delete_url = \wp_nonce_url(
+ \add_query_arg(
+ array(
+ 'action' => 'activitypub_delete_post',
+ 'post_id' => $post->ID,
+ ),
+ \admin_url( 'admin-post.php' )
+ ),
+ 'activitypub-delete-post-' . $post->ID
+ );
+
+ $actions['activitypub_delete'] = sprintf(
+ '%s',
+ \esc_url( $delete_url ),
+ \esc_attr__( 'Send Delete activity to the Fediverse', 'activitypub' ),
+ \esc_js( __( 'Are you sure you want to delete this post from the Fediverse?', 'activitypub' ) ),
+ \esc_html__( 'Soft Delete', 'activitypub' )
+ );
+ }
return $actions;
}
diff --git a/templates/bulk-actor-delete-confirmation.php b/templates/bulk-actor-delete-confirmation.php
deleted file mode 100644
index 918b5c2d52..0000000000
--- a/templates/bulk-actor-delete-confirmation.php
+++ /dev/null
@@ -1,62 +0,0 @@
- true ) );
-}
-
-// Prepare user data for display.
-$users = get_users( array( 'include' => $users ) );
-
-// If no users with ActivityPub capability, redirect back.
-if ( ! $users ) {
- wp_safe_redirect( $send_back );
- exit;
-}
-
-$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-require_once ABSPATH . 'wp-admin/admin-header.php';
-?>
-
-
-
-
This action is irreversible.', 'activitypub' ), array( 'strong' => array() ) ); ?>
-
-
- 'posts',
+ 'items' => array(),
+ 'send_back' => '',
+ 'checked' => true,
+ 'cancel_label' => __( 'Cancel', 'activitypub' ),
+ )
+);
+
+$item_type = $args['type'];
+$item_ids = $args['items'];
+$send_back = $args['send_back'];
+$checked = $args['checked'];
+$cancel_label = $args['cancel_label'];
+
+// Validate items - redirect back with notice if empty.
+if ( empty( $item_ids ) ) {
+ $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts';
+ wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) );
+ exit;
+}
+
+// Get items based on type.
+$items = array();
+if ( 'users' === $item_type ) {
+ $items = get_users( array( 'include' => $item_ids ) );
+} else {
+ foreach ( $item_ids as $item_id ) {
+ $item = get_post( $item_id );
+ if ( $item && current_user_can( 'edit_post', $item_id ) ) {
+ $items[] = $item;
+ }
+ }
+}
+
+// If no valid items, redirect back with notice.
+if ( empty( $items ) ) {
+ $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts';
+ wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) );
+ exit;
+}
+
+// Set up type-specific variables.
+if ( 'users' === $item_type ) {
+ $page_title = __( 'Delete Users from Fediverse', 'activitypub' );
+ $description = __( 'You have removed the capability to publish to the Fediverse for the selected users. Do you also want to send a Delete activity to remove them from the Fediverse?', 'activitypub' );
+ $note = __( 'Note: This sends a Delete activity to notify remote servers that these profiles no longer exist.', 'activitypub' );
+ $nonce_action = 'bulk-users';
+ $form_action = 'delete_actor_confirmed';
+ $input_name = 'remove_from_fediverse[]';
+ $hidden_name = 'selected_users[]';
+ $columns = array(
+ 'name' => __( 'Name', 'activitypub' ),
+ );
+} else {
+ $page_title = __( 'Delete Posts from Fediverse', 'activitypub' );
+ $description = __( 'You are about to send Delete activities for the following posts. This will remove them from the Fediverse while keeping them on your site.', 'activitypub' );
+ $note = __( 'Note: This sends a Delete activity to notify remote servers. The posts will remain on your WordPress site.', 'activitypub' );
+ $nonce_action = 'activitypub-bulk-post-delete';
+ $form_action = 'activitypub_delete_posts_confirmed';
+ $input_name = 'selected_posts[]';
+ $hidden_name = '';
+ $columns = array(
+ 'title' => __( 'Title', 'activitypub' ),
+ 'author' => __( 'Author', 'activitypub' ),
+ 'date' => __( 'Date', 'activitypub' ),
+ );
+}
+
+$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+require_once ABSPATH . 'wp-admin/admin-header.php';
+?>
+
+
+
+
array() ) ); ?>
+
+
+
+
+user->create( array( 'role' => 'editor' ) );
+
+ // Add activitypub capability to the user.
+ \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' );
+ }
+
+ /**
+ * Clean up test resources.
+ */
+ public static function tear_down_after_class() {
+ \wp_delete_user( self::$user_id );
+
+ parent::tear_down_after_class();
+ }
+
+ /**
+ * Set up.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ \wp_set_current_user( self::$user_id );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tear_down() {
+ parent::tear_down();
+
+ _delete_all_posts();
+ }
+
+ /**
+ * Test post_bulk_options adds the Soft Delete option.
+ *
+ * @covers ::post_bulk_options
+ */
+ public function test_post_bulk_options() {
+ $actions = array(
+ 'edit' => 'Edit',
+ 'trash' => 'Move to Trash',
+ );
+
+ $result = Admin::post_bulk_options( $actions );
+
+ $this->assertArrayHasKey( 'activitypub_delete', $result );
+ $this->assertEquals( 'Soft Delete', $result['activitypub_delete'] );
+ // Ensure original actions are preserved.
+ $this->assertArrayHasKey( 'edit', $result );
+ $this->assertArrayHasKey( 'trash', $result );
+ }
+
+ /**
+ * Test handle_post_bulk_request returns early for non-activitypub actions.
+ *
+ * @covers ::handle_post_bulk_request
+ */
+ public function test_handle_post_bulk_request_wrong_action() {
+ $send_back = 'http://example.org/wp-admin/edit.php';
+ $post_ids = array( 1, 2, 3 );
+
+ $result = Admin::handle_post_bulk_request( $send_back, 'trash', $post_ids );
+
+ $this->assertEquals( $send_back, $result );
+ }
+
+ /**
+ * Test handle_post_bulk_request returns notice URL when no federated posts.
+ *
+ * @covers ::handle_post_bulk_request
+ */
+ public function test_handle_post_bulk_request_no_federated_posts() {
+ // Create a post and mark it as not federated (pending state).
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Explicitly set to pending state (not federated).
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING );
+
+ $send_back = 'http://example.org/wp-admin/edit.php';
+
+ $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( $post_id ) );
+
+ $this->assertStringContainsString( 'activitypub_no_federated=1', $result );
+ }
+
+ /**
+ * Test row_actions adds Soft Delete for federated posts.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_adds_soft_delete_for_federated_post() {
+ // Create a federated post.
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Mark as federated.
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'edit' => 'Edit',
+ 'trash' => 'Trash',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayHasKey( 'activitypub_delete', $result );
+ $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] );
+ $this->assertStringContainsString( 'activitypub_delete_post', $result['activitypub_delete'] );
+ }
+
+ /**
+ * Test row_actions does not add Soft Delete for non-federated posts.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_no_soft_delete_for_non_federated_post() {
+ // Create a post and mark as pending (not federated).
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Explicitly set to pending state (not federated).
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'edit' => 'Edit',
+ 'trash' => 'Trash',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayNotHasKey( 'activitypub_delete', $result );
+ }
+
+ /**
+ * Test row_actions returns unchanged for unsupported post types.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_unsupported_post_type() {
+ // Register an unsupported post type.
+ \register_post_type( 'unsupported_type', array( 'public' => true ) );
+
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_type' => 'unsupported_type',
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Even mark it as federated - shouldn't matter.
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'edit' => 'Edit',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayNotHasKey( 'activitypub_delete', $result );
+
+ \unregister_post_type( 'unsupported_type' );
+ }
+
+ /**
+ * Test add_removable_query_args adds the correct args.
+ *
+ * @covers ::add_removable_query_args
+ */
+ public function test_add_removable_query_args() {
+ $args = array( 'existing_arg' );
+
+ $result = Admin::add_removable_query_args( $args );
+
+ $this->assertContains( 'existing_arg', $result );
+ $this->assertContains( 'activitypub_deleted', $result );
+ $this->assertContains( 'activitypub_no_federated', $result );
+ $this->assertContains( 'activitypub_no_posts', $result );
+ $this->assertContains( 'activitypub_no_users', $result );
+ }
+
+ /**
+ * Test register_post_bulk_actions registers filters for supported post types.
+ *
+ * @covers ::register_post_bulk_actions
+ */
+ public function test_register_post_bulk_actions() {
+ // Clear existing filters.
+ \remove_all_filters( 'bulk_actions-edit-post' );
+ \remove_all_filters( 'handle_bulk_actions-edit-post' );
+
+ Admin::register_post_bulk_actions();
+
+ $this->assertTrue( \has_filter( 'bulk_actions-edit-post' ) !== false );
+ $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-post' ) !== false );
+ }
+
+ /**
+ * Test row_actions returns unchanged for users without edit capability.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_no_capability() {
+ // Create a subscriber user.
+ $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ \wp_set_current_user( $subscriber_id );
+
+ // Create a federated post by another user.
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'view' => 'View',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayNotHasKey( 'activitypub_delete', $result );
+
+ // Restore user.
+ \wp_set_current_user( self::$user_id );
+ \wp_delete_user( $subscriber_id );
+ }
+
+ /**
+ * Test handle_post_bulk_request filters out non-existent posts.
+ *
+ * @covers ::handle_post_bulk_request
+ */
+ public function test_handle_post_bulk_request_non_existent_posts() {
+ $send_back = 'http://example.org/wp-admin/edit.php';
+
+ // Use non-existent post IDs.
+ $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( 999999, 999998 ) );
+
+ $this->assertStringContainsString( 'activitypub_no_federated=1', $result );
+ }
+
+ /**
+ * Test handle_post_bulk_request with mixed federated and non-federated posts.
+ *
+ * This test verifies that when selecting multiple posts, only federated ones
+ * are included in the confirmation redirect. Since the method redirects when
+ * federated posts exist, we test the filtering logic indirectly.
+ *
+ * @covers ::handle_post_bulk_request
+ */
+ public function test_handle_post_bulk_request_mixed_posts() {
+ // Create a non-federated post.
+ $non_federated_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $non_federated_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING );
+
+ // Create a deleted post (should not be included).
+ $deleted_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $deleted_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED );
+
+ $send_back = 'http://example.org/wp-admin/edit.php';
+
+ // With only non-federated and deleted posts, should return notice.
+ $result = Admin::handle_post_bulk_request(
+ $send_back,
+ 'activitypub_delete',
+ array( $non_federated_id, $deleted_id )
+ );
+
+ $this->assertStringContainsString( 'activitypub_no_federated=1', $result );
+ }
+
+ /**
+ * Test row_actions does not add Soft Delete for already deleted posts.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_no_soft_delete_for_deleted_post() {
+ // Create a post marked as already deleted from Fediverse.
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Mark as deleted.
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'edit' => 'Edit',
+ 'trash' => 'Trash',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayNotHasKey( 'activitypub_delete', $result );
+ }
+
+ /**
+ * Test row_actions does not add Soft Delete for failed posts.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_no_soft_delete_for_failed_post() {
+ // Create a post marked as failed.
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+
+ // Mark as failed.
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FAILED );
+
+ $post = \get_post( $post_id );
+ $actions = array(
+ 'edit' => 'Edit',
+ );
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayNotHasKey( 'activitypub_delete', $result );
+ }
+
+ /**
+ * Test row_actions includes correct nonce in delete URL.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_delete_url_has_nonce() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array();
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertArrayHasKey( 'activitypub_delete', $result );
+ $this->assertStringContainsString( '_wpnonce', $result['activitypub_delete'] );
+ $this->assertStringContainsString( 'post_id=' . $post_id, $result['activitypub_delete'] );
+ }
+
+ /**
+ * Test row_actions includes confirmation dialog.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_delete_has_confirmation() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array();
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertStringContainsString( 'onclick', $result['activitypub_delete'] );
+ $this->assertStringContainsString( 'confirm', $result['activitypub_delete'] );
+ }
+
+ /**
+ * Test add_removable_query_args includes all ActivityPub query args.
+ *
+ * @covers ::add_removable_query_args
+ */
+ public function test_add_removable_query_args_complete() {
+ $result = Admin::add_removable_query_args( array() );
+
+ $expected_args = array(
+ 'activitypub_deleted',
+ 'activitypub_delete_failed',
+ 'activitypub_no_federated',
+ 'activitypub_no_users',
+ 'activitypub_no_posts',
+ );
+
+ foreach ( $expected_args as $arg ) {
+ $this->assertContains( $arg, $result, "Missing removable query arg: {$arg}" );
+ }
+ }
+
+ /**
+ * Test register_post_bulk_actions registers for page post type.
+ *
+ * @covers ::register_post_bulk_actions
+ */
+ public function test_register_post_bulk_actions_pages() {
+ // Ensure page post type supports activitypub.
+ \add_post_type_support( 'page', 'activitypub' );
+
+ // Clear existing filters.
+ \remove_all_filters( 'bulk_actions-edit-page' );
+ \remove_all_filters( 'handle_bulk_actions-edit-page' );
+
+ Admin::register_post_bulk_actions();
+
+ $this->assertTrue( \has_filter( 'bulk_actions-edit-page' ) !== false );
+ $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-page' ) !== false );
+ }
+
+ /**
+ * Test post_bulk_options preserves action order.
+ *
+ * @covers ::post_bulk_options
+ */
+ public function test_post_bulk_options_preserves_order() {
+ $actions = array(
+ 'edit' => 'Edit',
+ 'trash' => 'Move to Trash',
+ );
+
+ $result = Admin::post_bulk_options( $actions );
+
+ $keys = array_keys( $result );
+
+ // Original actions should come first.
+ $this->assertEquals( 'edit', $keys[0] );
+ $this->assertEquals( 'trash', $keys[1] );
+ // ActivityPub action added at end.
+ $this->assertEquals( 'activitypub_delete', $keys[2] );
+ }
+
+ /**
+ * Test handle_post_bulk_request with empty post array.
+ *
+ * @covers ::handle_post_bulk_request
+ */
+ public function test_handle_post_bulk_request_empty_array() {
+ $send_back = 'http://example.org/wp-admin/edit.php';
+
+ $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array() );
+
+ $this->assertStringContainsString( 'activitypub_no_federated=1', $result );
+ }
+
+ /**
+ * Test row_actions works with page post type.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_with_page() {
+ // Ensure page post type supports activitypub.
+ \add_post_type_support( 'page', 'activitypub' );
+
+ $page_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $page_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $page = \get_post( $page_id );
+ $actions = array();
+
+ $result = Admin::row_actions( $actions, $page );
+
+ $this->assertArrayHasKey( 'activitypub_delete', $result );
+ $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] );
+ }
+
+ /**
+ * Test row_actions with draft post does not add Soft Delete even if federated.
+ *
+ * Draft posts shouldn't normally be federated, but if somehow they are,
+ * the soft delete action should still be available since the state is federated.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_federated_draft() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'draft',
+ )
+ );
+ // Hypothetically federated draft.
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array();
+
+ $result = Admin::row_actions( $actions, $post );
+
+ // Should still show soft delete since it's marked as federated.
+ $this->assertArrayHasKey( 'activitypub_delete', $result );
+ }
+
+ /**
+ * Test row_actions delete link has proper title attribute.
+ *
+ * @covers ::row_actions
+ */
+ public function test_row_actions_delete_has_title() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_author' => self::$user_id,
+ 'post_status' => 'publish',
+ )
+ );
+ \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
+
+ $post = \get_post( $post_id );
+ $actions = array();
+
+ $result = Admin::row_actions( $actions, $post );
+
+ $this->assertStringContainsString( 'title=', $result['activitypub_delete'] );
+ $this->assertStringContainsString( 'Send Delete activity', $result['activitypub_delete'] );
+ }
+}