diff --git a/.github/changelog/block-visibility-fediverse b/.github/changelog/block-visibility-fediverse
new file mode 100644
index 0000000000..472324c023
--- /dev/null
+++ b/.github/changelog/block-visibility-fediverse
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add per-block Fediverse visibility toggle to control which blocks are shared to Mastodon and other Fediverse platforms.
diff --git a/build/block-visibility/block.json b/build/block-visibility/block.json
new file mode 100644
index 0000000000..5647b2c590
--- /dev/null
+++ b/build/block-visibility/block.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "activitypub/block-visibility",
+ "title": "ActivityPub Block Visibility",
+ "category": "widgets",
+ "description": "Adds Fediverse visibility controls to all blocks.",
+ "icon": "screenoptions",
+ "textdomain": "activitypub",
+ "editorScript": "file:./plugin.js"
+}
\ No newline at end of file
diff --git a/build/block-visibility/plugin.asset.php b/build/block-visibility/plugin.asset.php
new file mode 100644
index 0000000000..72fdfd64cc
--- /dev/null
+++ b/build/block-visibility/plugin.asset.php
@@ -0,0 +1 @@
+ array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-compose', 'wp-hooks', 'wp-i18n'), 'version' => 'e0f5b9f469891ac4d2de');
diff --git a/build/block-visibility/plugin.js b/build/block-visibility/plugin.js
new file mode 100644
index 0000000000..600f22197f
--- /dev/null
+++ b/build/block-visibility/plugin.js
@@ -0,0 +1 @@
+(()=>{"use strict";const e=window.wp.hooks,i=window.wp.compose,t=window.wp.blockEditor,o=window.wp.components,s=window.wp.i18n,l=window.ReactJSXRuntime,n=(0,i.createHigherOrderComponent)(e=>i=>{const{attributes:n,setAttributes:d,isSelected:r}=i,c=n?.metadata||{},a=c?.blockVisibility||{},b=!1!==a?.fediverse;return(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(e,{...i}),r&&(0,l.jsx)(t.InspectorControls,{children:(0,l.jsx)(o.PanelBody,{title:(0,s.__)("Fediverse ⁂","activitypub"),initialOpen:!1,children:(0,l.jsx)(o.ToggleControl,{__nextHasNoMarginBottom:!0,label:(0,s.__)("Share to the Fediverse","activitypub"),help:b?(0,s.__)("This block will be shared to Mastodon and other Fediverse platforms.","activitypub"):(0,s.__)("This block will only be visible on your site.","activitypub"),checked:b,onChange:e=>{const i={...a};e?delete i.fediverse:i.fediverse=!1;const t={...c};0===Object.keys(i).length?delete t.blockVisibility:t.blockVisibility=i,0===Object.keys(t).length?d({metadata:void 0}):d({metadata:t})}})})})]})},"withFediverseVisibility");(0,e.addFilter)("editor.BlockEdit","activitypub/block-visibility",n)})();
\ No newline at end of file
diff --git a/build/reply/block.json b/build/reply/block.json
index becdc9cdc6..dacac0d026 100644
--- a/build/reply/block.json
+++ b/build/reply/block.json
@@ -18,6 +18,7 @@
"inserter": true,
"reusable": false,
"lock": false,
+ "visibility": false,
"innerBlocks": {
"allowedBlocks": [
"core/embed"
diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index 066210f2b0..60f327fa44 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -116,6 +116,10 @@ public static function enqueue_editor_assets() {
$asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/pre-publish-panel/plugin.asset.php';
$plugin_url = plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
+
+ $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/block-visibility/plugin.asset.php';
+ $plugin_url = plugins_url( 'build/block-visibility/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
+ wp_enqueue_script( 'activitypub-block-visibility', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
}
/**
@@ -946,6 +950,7 @@ public static function add_directions( $content, $selector, $attributes ) {
* @param object $post The post object.
*/
public static function add_post_transformation_callbacks( $post ) {
+ \add_filter( 'render_block', array( self::class, 'maybe_hide_block' ), 5, 2 );
\add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
// Only transform reply link if it's the first block in the post.
@@ -963,12 +968,48 @@ public static function add_post_transformation_callbacks( $post ) {
* @return string The updated content.
*/
public static function remove_post_transformation_callbacks( $content ) {
+ \remove_filter( 'render_block', array( self::class, 'maybe_hide_block' ), 5 );
\remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
\remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
return $content;
}
+ /**
+ * Strip blocks not marked for federation during content rendering.
+ *
+ * @since unreleased
+ *
+ * @param string $block_content The rendered block content.
+ * @param array $block The parsed block data.
+ *
+ * @return string The block content, or empty string if hidden.
+ */
+ public static function maybe_hide_block( $block_content, $block ) {
+ if ( ! self::is_federated( $block ) ) {
+ return '';
+ }
+
+ return $block_content;
+ }
+
+ /**
+ * Whether a block should be included in federated content.
+ *
+ * Checks `metadata.blockVisibility.fediverse`. Defaults to true.
+ *
+ * @since unreleased
+ *
+ * @param array $block The parsed block data.
+ *
+ * @return bool True if the block should be federated.
+ */
+ public static function is_federated( $block ) {
+ $visibility = $block['attrs']['metadata']['blockVisibility']['fediverse'] ?? true;
+
+ return false !== $visibility;
+ }
+
/**
* Generate HTML @ link for reply block.
*
diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php
index 4c83077da8..b5af1594bf 100644
--- a/includes/transformer/class-post.php
+++ b/includes/transformer/class-post.php
@@ -678,6 +678,10 @@ protected function get_in_reply_to() {
$blocks = \parse_blocks( $this->item->post_content );
foreach ( $blocks as $block ) {
+ if ( ! Blocks::is_federated( $block ) ) {
+ continue;
+ }
+
if ( 'activitypub/reply' === $block['blockName'] && isset( $block['attrs']['url'] ) ) {
// Check if the URL has been validated as ActivityPub. Default to true for backwards compatibility.
@@ -914,6 +918,11 @@ protected function get_block_attachments( $media, $max_media ) {
*/
protected function get_media_from_blocks( $blocks, $media ) {
foreach ( $blocks as $block ) {
+ // Skip blocks hidden from the Fediverse.
+ if ( ! Blocks::is_federated( $block ) ) {
+ continue;
+ }
+
// Recurse into inner blocks.
if ( ! empty( $block['innerBlocks'] ) ) {
$media = $this->get_media_from_blocks( $block['innerBlocks'], $media );
diff --git a/package-lock.json b/package-lock.json
index 8009a2c519..8931b6f785 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,7 @@
"@wordpress/editor": "^14.38.0",
"@wordpress/element": "^6.0.0",
"@wordpress/env": "^10.39.0",
+ "@wordpress/hooks": "^4.36.0",
"@wordpress/html-entities": "^4.36.0",
"@wordpress/i18n": "^6.0.0",
"@wordpress/icons": "^11.4.0",
diff --git a/package.json b/package.json
index 3e1fd2ae30..ae2d56eb75 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"@wordpress/editor": "^14.38.0",
"@wordpress/element": "^6.0.0",
"@wordpress/env": "^10.39.0",
+ "@wordpress/hooks": "^4.36.0",
"@wordpress/html-entities": "^4.36.0",
"@wordpress/i18n": "^6.0.0",
"@wordpress/icons": "^11.4.0",
diff --git a/src/block-visibility/block.json b/src/block-visibility/block.json
new file mode 100644
index 0000000000..c7b478d098
--- /dev/null
+++ b/src/block-visibility/block.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "activitypub/block-visibility",
+ "title": "ActivityPub Block Visibility",
+ "category": "widgets",
+ "description": "Adds Fediverse visibility controls to all blocks.",
+ "icon": "screenoptions",
+ "textdomain": "activitypub",
+ "editorScript": "file:./plugin.js"
+}
diff --git a/src/block-visibility/plugin.js b/src/block-visibility/plugin.js
new file mode 100644
index 0000000000..e9637de99a
--- /dev/null
+++ b/src/block-visibility/plugin.js
@@ -0,0 +1,79 @@
+import { addFilter } from '@wordpress/hooks';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { InspectorControls } from '@wordpress/block-editor';
+import { PanelBody, ToggleControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Adds a "Fediverse" panel to the block inspector for all blocks,
+ * allowing users to control whether a block is included in federated content.
+ */
+const withFediverseVisibility = createHigherOrderComponent( ( BlockEdit ) => {
+ return ( props ) => {
+ const { attributes, setAttributes, isSelected } = props;
+
+ // Get the current fediverse visibility state (default: true).
+ const metadata = attributes?.metadata || {};
+ const blockVisibility = metadata?.blockVisibility || {};
+ const isFediverseVisible = blockVisibility?.fediverse !== false;
+
+ /**
+ * Update the fediverse visibility in block metadata.
+ *
+ * @param {boolean} value Whether the block should be visible on the Fediverse.
+ */
+ const onChange = ( value ) => {
+ const updatedBlockVisibility = { ...blockVisibility };
+
+ if ( value ) {
+ // Remove the fediverse key when true (default state).
+ delete updatedBlockVisibility.fediverse;
+ } else {
+ updatedBlockVisibility.fediverse = false;
+ }
+
+ // Clean up: if blockVisibility is empty, remove it from metadata.
+ const updatedMetadata = { ...metadata };
+ if ( Object.keys( updatedBlockVisibility ).length === 0 ) {
+ delete updatedMetadata.blockVisibility;
+ } else {
+ updatedMetadata.blockVisibility = updatedBlockVisibility;
+ }
+
+ // Clean up: if metadata is empty, remove it entirely.
+ if ( Object.keys( updatedMetadata ).length === 0 ) {
+ setAttributes( { metadata: undefined } );
+ } else {
+ setAttributes( { metadata: updatedMetadata } );
+ }
+ };
+
+ return (
+ <>
+
+ { isSelected && (
+
+
+
+
+
+ ) }
+ >
+ );
+ };
+}, 'withFediverseVisibility' );
+
+addFilter( 'editor.BlockEdit', 'activitypub/block-visibility', withFediverseVisibility );
diff --git a/src/reply/block.json b/src/reply/block.json
index 4d4d9d2b70..b7b400db94 100644
--- a/src/reply/block.json
+++ b/src/reply/block.json
@@ -13,6 +13,7 @@
"inserter": true,
"reusable": false,
"lock": false,
+ "visibility": false,
"innerBlocks": {
"allowedBlocks": [ "core/embed" ]
}
diff --git a/tests/phpunit/tests/includes/class-test-blocks.php b/tests/phpunit/tests/includes/class-test-blocks.php
index 7a3c0afb24..54bac0602a 100644
--- a/tests/phpunit/tests/includes/class-test-blocks.php
+++ b/tests/phpunit/tests/includes/class-test-blocks.php
@@ -1027,4 +1027,82 @@ public function test_render_extra_fields_block_preserves_html() {
$this->assertStringContainsString( 'my site', $output );
$this->assertStringContainsString( ' array() );
+ $this->assertTrue( Blocks::is_federated( $block ) );
+ }
+
+ /**
+ * Test is_federated returns true when no metadata exists.
+ *
+ * @covers ::is_federated
+ */
+ public function test_is_federated_without_metadata() {
+ $block = array( 'attrs' => array( 'metadata' => array() ) );
+ $this->assertTrue( Blocks::is_federated( $block ) );
+ }
+
+ /**
+ * Test is_federated returns true when fediverse is explicitly true.
+ *
+ * @covers ::is_federated
+ */
+ public function test_is_federated_when_explicitly_true() {
+ $block = array(
+ 'attrs' => array(
+ 'metadata' => array(
+ 'blockVisibility' => array( 'fediverse' => true ),
+ ),
+ ),
+ );
+ $this->assertTrue( Blocks::is_federated( $block ) );
+ }
+
+ /**
+ * Test is_federated returns false when fediverse is false.
+ *
+ * @covers ::is_federated
+ */
+ public function test_is_federated_returns_false_when_hidden() {
+ $block = array(
+ 'attrs' => array(
+ 'metadata' => array(
+ 'blockVisibility' => array( 'fediverse' => false ),
+ ),
+ ),
+ );
+ $this->assertFalse( Blocks::is_federated( $block ) );
+ }
+
+ /**
+ * Test maybe_hide_block returns content for federated blocks.
+ *
+ * @covers ::maybe_hide_block
+ */
+ public function test_maybe_hide_block_keeps_visible_block() {
+ $block = array( 'attrs' => array() );
+ $this->assertSame( 'Hello
', Blocks::maybe_hide_block( 'Hello
', $block ) );
+ }
+
+ /**
+ * Test maybe_hide_block returns empty string for hidden blocks.
+ *
+ * @covers ::maybe_hide_block
+ */
+ public function test_maybe_hide_block_strips_hidden_block() {
+ $block = array(
+ 'attrs' => array(
+ 'metadata' => array(
+ 'blockVisibility' => array( 'fediverse' => false ),
+ ),
+ ),
+ );
+ $this->assertSame( '', Blocks::maybe_hide_block( 'Secret
', $block ) );
+ }
}
diff --git a/tests/phpunit/tests/includes/transformer/class-test-post.php b/tests/phpunit/tests/includes/transformer/class-test-post.php
index 1e35c55c09..0424f1393f 100644
--- a/tests/phpunit/tests/includes/transformer/class-test-post.php
+++ b/tests/phpunit/tests/includes/transformer/class-test-post.php
@@ -2110,4 +2110,62 @@ public function test_to_tombstone_to_array_without_deleted() {
$this->assertSame( 'Tombstone', $array['type'] );
$this->assertSame( 'Article', $array['formerType'] );
}
+
+ /**
+ * Test get_media_from_blocks skips blocks hidden from the Fediverse.
+ *
+ * @covers ::get_media_from_blocks
+ */
+ public function test_get_media_from_blocks_skips_hidden_blocks() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_content' => '
',
+ )
+ );
+ $post = get_post( $post_id );
+
+ $transformer = new Post( $post );
+ $media = array(
+ 'image' => array(),
+ 'audio' => array(),
+ 'video' => array(),
+ );
+
+ $reflection = new \ReflectionClass( Post::class );
+ $method = $reflection->getMethod( 'get_media_from_blocks' );
+ if ( \PHP_VERSION_ID < 80100 ) {
+ $method->setAccessible( true );
+ }
+
+ $blocks = parse_blocks( $post->post_content );
+ $result = $method->invoke( $transformer, $blocks, $media );
+
+ $this->assertCount( 1, $result['image'] );
+ $this->assertSame( 456, $result['image'][0]['id'] );
+ }
+
+ /**
+ * Test get_in_reply_to skips reply blocks hidden from the Fediverse.
+ *
+ * @covers ::get_in_reply_to
+ */
+ public function test_get_in_reply_to_skips_hidden_reply_block() {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_content' => 'Hello
',
+ )
+ );
+ $post = get_post( $post_id );
+
+ $transformer = new Post( $post );
+
+ $reflection = new \ReflectionClass( Post::class );
+ $method = $reflection->getMethod( 'get_in_reply_to' );
+ if ( \PHP_VERSION_ID < 80100 ) {
+ $method->setAccessible( true );
+ }
+
+ $result = $method->invoke( $transformer );
+ $this->assertNull( $result );
+ }
}