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() {