diff --git a/.github/changelog/3044-from-description b/.github/changelog/3044-from-description new file mode 100644 index 0000000000..8d3cbfd485 --- /dev/null +++ b/.github/changelog/3044-from-description @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a Distribution Mode setting to control how quickly posts are delivered to followers. diff --git a/AGENTS.md b/AGENTS.md index 0092dcd454..6ac0449f68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ Text domain: always `'activitypub'`. **MUST** backslash-prefix all WordPress functions in namespaced code: `\get_option()`, `\add_action()`, `\apply_filters()`, `\__()`, `\_e()`, etc. PHP falls back to global scope, but backslashes are a project standard for consistency and to avoid accidentally shadowing globals. +**No inline namespaces.** Use `use` statements at the top of the file instead of inline fully-qualified class names (e.g., `use Activitypub\Options;` then `Options::method()`, not `\Activitypub\Options::method()`). + **For new or modified code**, MUST use `'unreleased'` for all `@since`, `@deprecated`, and deprecation function version strings so the release script can replace them. Do not introduce new hardcoded version numbers like `'5.1.0'`; existing versioned tags in the codebase are fine. ## Testing Conventions diff --git a/includes/class-options.php b/includes/class-options.php index 7fbe3a9162..dd7b4295c8 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -27,6 +27,10 @@ public static function init() { \add_filter( 'pre_option_activitypub_following_ui', array( self::class, 'pre_option_activitypub_following_ui' ) ); \add_filter( 'pre_option_activitypub_create_posts', array( self::class, 'pre_option_activitypub_create_posts' ) ); + \add_filter( 'pre_option_activitypub_distribution_mode', array( self::class, 'pre_option_activitypub_distribution_mode' ) ); + \add_filter( 'activitypub_dispatcher_batch_size', array( self::class, 'filter_dispatcher_batch_size' ) ); + \add_filter( 'activitypub_scheduler_async_batch_pause', array( self::class, 'filter_scheduler_batch_pause' ) ); + \add_filter( 'pre_option_activitypub_allow_likes', array( self::class, 'maybe_disable_interactions' ) ); \add_filter( 'pre_option_activitypub_allow_replies', array( self::class, 'maybe_disable_interactions' ) ); @@ -358,6 +362,46 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub_advanced', + 'activitypub_distribution_mode', + array( + 'type' => 'string', + 'description' => 'Distribution mode for federation delivery.', + 'default' => 'default', + 'sanitize_callback' => static function ( $value ) { + $allowed = array( 'default', 'balanced', 'eco', 'custom' ); + return \in_array( $value, $allowed, true ) ? $value : 'default'; + }, + ) + ); + + \register_setting( + 'activitypub_advanced', + 'activitypub_custom_batch_size', + array( + 'type' => 'integer', + 'description' => 'Custom batch size for federation delivery.', + 'default' => 100, + 'sanitize_callback' => static function ( $value ) { + return \min( 500, \max( 1, \absint( $value ) ) ); + }, + ) + ); + + \register_setting( + 'activitypub_advanced', + 'activitypub_custom_batch_pause', + array( + 'type' => 'integer', + 'description' => 'Custom pause in seconds between batches.', + 'default' => 30, + 'sanitize_callback' => static function ( $value ) { + return \min( 3600, \absint( $value ) ); + }, + ) + ); + /* * Options Group: activitypub_blog */ @@ -660,6 +704,140 @@ public static function default_object_type( $value ) { return $value; } + /** + * Pre-get option filter for the Distribution Mode. + * + * @since unreleased + * + * @param string|false $pre The pre-get option value. + * + * @return string|false The distribution mode or false if it should not be filtered. + */ + public static function pre_option_activitypub_distribution_mode( $pre ) { + if ( false !== ACTIVITYPUB_DISTRIBUTION_MODE ) { + /* + * Only preset modes are allowed via the constant. The 'custom' + * mode is excluded because its batch size and pause values are + * still read from the database, which defeats the purpose of + * locking the mode via wp-config.php. + */ + $allowed = array_keys( self::get_distribution_modes() ); + + if ( \in_array( ACTIVITYPUB_DISTRIBUTION_MODE, $allowed, true ) ) { + return ACTIVITYPUB_DISTRIBUTION_MODE; + } + + // Invalid or unsupported constant value, fall back to default. + return 'default'; + } + + return $pre; + } + + /** + * Get the available distribution mode presets. + * + * Centralized definition used by both the admin UI and the + * parameter resolution in get_distribution_params(). + * + * @since unreleased + * + * @return array Associative array of mode => { batch_size, pause, label, description }. + */ + public static function get_distribution_modes() { + return array( + 'default' => array( + 'batch_size' => 100, + 'pause' => 15, + 'label' => \__( 'Default', 'activitypub' ), + 'description' => \__( 'Deliver activities as fast as possible (100 per batch, 15s pause).', 'activitypub' ), + ), + 'balanced' => array( + 'batch_size' => 50, + 'pause' => 30, + 'label' => \__( 'Balanced', 'activitypub' ), + 'description' => \__( 'Moderate pace with reasonable pauses between batches (50 per batch, 30s pause).', 'activitypub' ), + ), + 'eco' => array( + 'batch_size' => 20, + 'pause' => 30, + 'label' => \__( 'Eco Mode', 'activitypub' ), + 'description' => \__( 'Gentle on server resources, ideal for shared hosting (20 per batch, 30s pause).', 'activitypub' ), + ), + ); + } + + /** + * Get distribution parameters for the current mode. + * + * @since unreleased + * + * @return array { batch_size: int, pause: int } + */ + public static function get_distribution_params() { + $mode = \get_option( 'activitypub_distribution_mode', 'default' ); + $modes = self::get_distribution_modes(); + + if ( isset( $modes[ $mode ] ) ) { + return array( + 'batch_size' => $modes[ $mode ]['batch_size'], + 'pause' => $modes[ $mode ]['pause'], + ); + } + + // Custom mode. + return array( + 'batch_size' => \max( 1, \absint( \get_option( 'activitypub_custom_batch_size', 100 ) ) ), + 'pause' => \absint( \get_option( 'activitypub_custom_batch_pause', 30 ) ), + ); + } + + /** + * Filter the dispatcher batch size based on distribution mode. + * + * Only overrides the value when a non-default mode is active, + * so other plugins or constants can still set the batch size. + * + * @since unreleased + * + * @param int $batch_size The default batch size. + * + * @return int The batch size for the current distribution mode. + */ + public static function filter_dispatcher_batch_size( $batch_size ) { + $mode = \get_option( 'activitypub_distribution_mode', 'default' ); + + if ( 'default' === $mode ) { + return $batch_size; + } + + $params = self::get_distribution_params(); + return $params['batch_size']; + } + + /** + * Filter the scheduler batch pause based on distribution mode. + * + * Only overrides the value when a non-default mode is active, + * so other plugins or constants can still set the pause. + * + * @since unreleased + * + * @param int $pause The default pause in seconds. + * + * @return int The pause for the current distribution mode. + */ + public static function filter_scheduler_batch_pause( $pause ) { + $mode = \get_option( 'activitypub_distribution_mode', 'default' ); + + if ( 'default' === $mode ) { + return $pause; + } + + $params = self::get_distribution_params(); + return $params['pause']; + } + /** * Handle relay mode option changes. * diff --git a/includes/constants.php b/includes/constants.php index 8c192d511b..2e14170c5d 100644 --- a/includes/constants.php +++ b/includes/constants.php @@ -20,6 +20,7 @@ defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false ); defined( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE' ) || define( 'ACTIVITYPUB_DEFAULT_OBJECT_TYPE', 'wordpress-post-format' ); defined( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE' ) || define( 'ACTIVITYPUB_OUTBOX_PROCESSING_BATCH_SIZE', 100 ); +defined( 'ACTIVITYPUB_DISTRIBUTION_MODE' ) || define( 'ACTIVITYPUB_DISTRIBUTION_MODE', false ); // Backwards compatibility: map old ACTIVITYPUB_DISABLE_SIDELOADING to ACTIVITYPUB_DISABLE_REMOTE_CACHE. if ( ! defined( 'ACTIVITYPUB_DISABLE_REMOTE_CACHE' ) && defined( 'ACTIVITYPUB_DISABLE_SIDELOADING' ) ) { define( 'ACTIVITYPUB_DISABLE_REMOTE_CACHE', ACTIVITYPUB_DISABLE_SIDELOADING ); diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php index 00fe100335..c42d0aa33d 100644 --- a/includes/wp-admin/class-advanced-settings-fields.php +++ b/includes/wp-admin/class-advanced-settings-fields.php @@ -7,6 +7,8 @@ namespace Activitypub\WP_Admin; +use Activitypub\Options; + /** * Advanced Settings Fields class. */ @@ -30,6 +32,16 @@ public static function register_advanced_fields() { 'activitypub_advanced_settings' ); + if ( false === ACTIVITYPUB_DISTRIBUTION_MODE ) { + \add_settings_field( + 'activitypub_distribution_mode', + \__( 'Distribution Mode', 'activitypub' ), + array( self::class, 'render_distribution_mode_field' ), + 'activitypub_advanced_settings', + 'activitypub_advanced_settings' + ); + } + if ( ! defined( 'ACTIVITYPUB_SEND_VARY_HEADER' ) ) { \add_settings_field( 'activitypub_vary_header', @@ -288,4 +300,77 @@ public static function render_object_type_field() {

\__( 'Custom', 'activitypub' ), + 'description' => \__( 'Configure batch size and delay manually.', 'activitypub' ), + ); + + ?> +
+ +

+ +

+ $data ) { + ?> +

+ +
+ array() ) ); ?> +

+ + +

+ +

+
+ + assertEquals( ACTIVITYPUB_INTERACTION_POLICY_ME, \get_option( 'activitypub_default_quote_policy' ) ); } + /** + * Test distribution mode returns correct params for presets. + * + * @covers \Activitypub\Options::get_distribution_params + */ + public function test_distribution_params_presets() { + \update_option( 'activitypub_distribution_mode', 'eco' ); + + $params = Options::get_distribution_params(); + + $this->assertEquals( 20, $params['batch_size'] ); + $this->assertEquals( 30, $params['pause'] ); + } + + /** + * Test distribution mode custom params. + * + * @covers \Activitypub\Options::get_distribution_params + */ + public function test_distribution_params_custom() { + Options::register_settings(); + + \update_option( 'activitypub_distribution_mode', 'custom' ); + \update_option( 'activitypub_custom_batch_size', 42 ); + \update_option( 'activitypub_custom_batch_pause', 120 ); + + $params = Options::get_distribution_params(); + + $this->assertEquals( 42, $params['batch_size'] ); + $this->assertEquals( 120, $params['pause'] ); + } + + /** + * Test custom batch size cannot be zero. + * + * @covers \Activitypub\Options::register_settings + */ + public function test_custom_batch_size_minimum() { + Options::register_settings(); + + \update_option( 'activitypub_custom_batch_size', 0 ); + $this->assertGreaterThanOrEqual( 1, \get_option( 'activitypub_custom_batch_size' ) ); + } + + /** + * Test custom batch size is capped at 500. + * + * @covers \Activitypub\Options::register_settings + */ + public function test_custom_batch_size_maximum() { + Options::register_settings(); + + \update_option( 'activitypub_custom_batch_size', 100000 ); + $this->assertEquals( 500, \get_option( 'activitypub_custom_batch_size' ) ); + } + + /** + * Test custom batch pause is capped at 3600. + * + * @covers \Activitypub\Options::register_settings + */ + public function test_custom_batch_pause_maximum() { + Options::register_settings(); + + \update_option( 'activitypub_custom_batch_pause', 99999 ); + $this->assertEquals( 3600, \get_option( 'activitypub_custom_batch_pause' ) ); + } + + /** + * Test distribution mode sanitizes invalid values. + * + * @covers \Activitypub\Options::register_settings + */ + public function test_distribution_mode_sanitizes_invalid() { + Options::register_settings(); + + \update_option( 'activitypub_distribution_mode', 'turbo' ); + $this->assertEquals( 'default', \get_option( 'activitypub_distribution_mode' ) ); + } + + /** + * Test default mode does not override filter values. + * + * @covers \Activitypub\Options::filter_dispatcher_batch_size + */ + public function test_default_mode_preserves_filter_value() { + \update_option( 'activitypub_distribution_mode', 'default' ); + + $result = Options::filter_dispatcher_batch_size( 42 ); + $this->assertEquals( 42, $result ); + } + + /** + * Test non-default mode overrides filter values. + * + * @covers \Activitypub\Options::filter_dispatcher_batch_size + */ + public function test_non_default_mode_overrides_filter_value() { + \update_option( 'activitypub_distribution_mode', 'eco' ); + + $result = Options::filter_dispatcher_batch_size( 42 ); + $this->assertEquals( 20, $result ); + } + /** * Test default quote policy option sanitizes invalid values. *