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' => '
Hidden
Visible
', + ) + ); + $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 ); + } }