PluginProbe
ActivityPub / 7.8.2
ActivityPub v7.8.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/class-blocks.php +37 -935 9.2.07.8.2 View file →
@@ -6,76 +6,21 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 -use Activitypub\Cache\Stats_Image;
11 10 use Activitypub\Collection\Actors;
12 -use Activitypub\Collection\Followers;
13 -use Activitypub\Collection\Following;
14 -use Activitypub\Collection\Remote_Actors;
15 11
16 12 /**
17 13 * Block class.
18 14 */
19 15 class Blocks {
20 -
21 16 /**
22 - * HTML tags to skip during block conversion.
23 - *
24 - * @var array<string>
25 - */
26 - const SKIP_TAGS = array( 'BR', 'CITE', 'SOURCE' );
27 -
28 - /**
29 - * HTML void elements that have no closing tag.
30 - *
31 - * @var array<string>
32 - */
33 - const VOID_TAGS = array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' );
34 -
35 - /**
36 - * Map of HTML tag names to WordPress block types.
37 - *
38 - * @var array<string, string>
39 - */
40 - const BLOCK_MAP = array(
41 - 'UL' => 'list',
42 - 'OL' => 'list',
43 - 'IMG' => 'image',
44 - 'BLOCKQUOTE' => 'quote',
45 - 'H1' => 'heading',
46 - 'H2' => 'heading',
47 - 'H3' => 'heading',
48 - 'H4' => 'heading',
49 - 'H5' => 'heading',
50 - 'H6' => 'heading',
51 - 'P' => 'paragraph',
52 - 'A' => 'paragraph',
53 - 'ABBR' => 'paragraph',
54 - 'B' => 'paragraph',
55 - 'CODE' => 'paragraph',
56 - 'EM' => 'paragraph',
57 - 'I' => 'paragraph',
58 - 'STRONG' => 'paragraph',
59 - 'SUB' => 'paragraph',
60 - 'SUP' => 'paragraph',
61 - 'SPAN' => 'paragraph',
62 - 'U' => 'paragraph',
63 - 'FIGURE' => 'image',
64 - 'HR' => 'separator',
65 - );
66 -
67 - /**
68 17 * Initialize the class, registering WordPress hooks.
69 18 */
70 19 public static function init() {
71 20 // This is already being called on the init hook, so just add it.
72 21 self::register_blocks();
73 - self::register_patterns();
74 - self::register_templates();
75 22
76 - \add_action( 'pre_get_posts', array( self::class, 'filter_query_loop_vars' ) );
77 -
78 23 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
79 24 // Add editor plugin.
80 25 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
81 26 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
@@ -80,9 +25,8 @@
80 25 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
81 26 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
82 27
83 28 \add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
84 - \add_filter( 'activitypub_attachments', array( self::class, 'add_stats_image_attachment' ), 10, 2 );
85 29
86 30 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
87 31 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
88 32 }
@@ -91,40 +35,32 @@
91 35 * Enqueue the block editor assets.
92 36 */
93 37 public static function enqueue_editor_assets() {
94 38 $data = array(
95 - 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
96 - 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
97 - 'enabled' => array(
39 + 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
40 + 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
41 + 'enabled' => array(
98 42 'blog' => ! is_user_type_disabled( 'blog' ),
99 43 'users' => ! is_user_type_disabled( 'user' ),
100 44 ),
101 - 'profileUrls' => array(
45 + 'profileUrls' => array(
102 46 'user' => \admin_url( 'profile.php#activitypub' ),
103 47 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
104 48 ),
105 - 'showAvatars' => (bool) \get_option( 'show_avatars' ),
106 - 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
107 - 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
108 - 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
109 - 'statsImageUrlEndpoint' => Stats_Image::is_available() ? \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image-url/{user_id}/{year}' ) : '',
49 + 'showAvatars' => (bool) \get_option( 'show_avatars' ),
110 50 );
111 - \wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
51 + wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
112 52
113 53 // Check for our supported post types.
114 54 $current_screen = \get_current_screen();
115 55 $ap_post_types = \get_post_types_by_support( 'activitypub' );
116 - if ( ! $current_screen || ! \in_array( $current_screen->post_type, $ap_post_types, true ) ) {
56 + if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
117 57 return;
118 58 }
119 59
120 60 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
121 - $plugin_url = \plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
122 - \wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
123 -
124 - $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/pre-publish-panel/plugin.asset.php';
125 - $plugin_url = \plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
126 - \wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
61 + $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
62 + wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
127 63 }
128 64
129 65 /**
130 66 * Enqueue the reply handle script if the in_reply_to GET param is set.
@@ -136,10 +72,10 @@
136 72 return;
137 73 }
138 74
139 75 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
140 - $plugin_url = \plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
141 - \wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
76 + $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
77 + wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
142 78 }
143 79
144 80 /**
145 81 * Register the blocks.
@@ -147,15 +83,8 @@
147 83 public static function register_blocks() {
148 84 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
149 85 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
150 86 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' );
151 - \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/posts-and-replies' );
152 - \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/stats' );
153 -
154 - // Only register the Following block if the Following feature is enabled.
155 - if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
156 - \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/following' );
157 - }
158 87 // Register reactions block, conditionally removing facepile style if avatars are disabled.
159 88 $reactions_args = array();
160 89 if ( ! \get_option( 'show_avatars', true ) ) {
161 90 $reactions_args['styles'] = array();
@@ -167,133 +96,16 @@
167 96 array(
168 97 'render_callback' => array( self::class, 'render_reply_block' ),
169 98 )
170 99 );
171 -
172 - // Register remote media blocks (server-side only, no editor UI).
173 - \register_block_type(
174 - 'activitypub/emoji',
175 - array(
176 - 'attributes' => array(
177 - 'url' => array( 'type' => 'string' ),
178 - 'updated' => array( 'type' => 'string' ),
179 - ),
180 - 'render_callback' => array( self::class, 'render_emoji_block' ),
181 - )
182 - );
183 -
184 - \register_block_type(
185 - 'activitypub/image',
186 - array(
187 - 'attributes' => array(
188 - 'url' => array( 'type' => 'string' ),
189 - ),
190 - 'render_callback' => array( self::class, 'render_image_block' ),
191 - )
192 - );
193 -
194 - \register_block_type(
195 - 'activitypub/audio',
196 - array(
197 - 'attributes' => array(
198 - 'url' => array( 'type' => 'string' ),
199 - ),
200 - 'render_callback' => array( self::class, 'render_audio_block' ),
201 - )
202 - );
203 -
204 - \register_block_type(
205 - 'activitypub/video',
206 - array(
207 - 'attributes' => array(
208 - 'url' => array( 'type' => 'string' ),
209 - ),
210 - 'render_callback' => array( self::class, 'render_video_block' ),
211 - )
212 - );
213 100 }
214 101
215 102 /**
216 - * Register block patterns for ActivityPub.
217 - */
218 - public static function register_patterns() {
219 - // Register the ActivityPub pattern category.
220 - \register_block_pattern_category(
221 - 'activitypub',
222 - array(
223 - 'label' => \__( 'Fediverse', 'activitypub' ),
224 - )
225 - );
226 -
227 - // Register each pattern.
228 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-header.php';
229 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-profile.php';
230 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/follow-page.php';
231 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/profile-page.php';
232 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/social-sidebar.php';
233 -
234 - // Only register the Following page pattern if the Following feature is enabled.
235 - if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
236 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/following-page.php';
237 - }
238 -
239 - // Only register the Stats post starter pattern in December and January.
240 - $month = (int) \gmdate( 'n' );
241 - if ( 12 === $month || 1 === $month ) {
242 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/stats-post.php';
243 - }
244 - }
245 -
246 - /**
247 - * Register FSE templates for block themes.
248 - */
249 - public static function register_templates() {
250 - // Only register templates for block themes on WP 6.7+.
251 - if ( ! \function_exists( 'register_block_template' ) || ! \wp_is_block_theme() ) {
252 - return;
253 - }
254 -
255 - // Use the core `author` hierarchy slug so WP can resolve this for author archives.
256 - \register_block_template(
257 - 'activitypub//author',
258 - array(
259 - 'title' => \__( 'Author Archive (Fediverse)', 'activitypub' ),
260 - 'description' => \__( 'Displays an author archive with Fediverse profile and follow options.', 'activitypub' ),
261 - 'content' => '<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
262 -<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
263 -<main class="wp-block-group">
264 - <!-- wp:pattern {"slug":"activitypub/author-profile"} /-->
265 - <!-- wp:spacer {"height":"32px"} -->
266 - <div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>
267 - <!-- /wp:spacer -->
268 - <!-- wp:activitypub/posts-and-replies /-->
269 - <!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true}} -->
270 - <div class="wp-block-query">
271 - <!-- wp:post-template -->
272 - <!-- wp:post-title {"isLink":true} /-->
273 - <!-- wp:post-excerpt /-->
274 - <!-- /wp:post-template -->
275 - <!-- wp:query-pagination -->
276 - <!-- wp:query-pagination-previous /-->
277 - <!-- wp:query-pagination-numbers /-->
278 - <!-- wp:query-pagination-next /-->
279 - <!-- /wp:query-pagination -->
280 - </div>
281 - <!-- /wp:query -->
282 -</main>
283 -<!-- /wp:group -->
284 -<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->',
285 - 'post_types' => array(),
286 - )
287 - );
288 - }
289 -
290 - /**
291 103 * Register REST fields needed for blocks.
292 104 */
293 105 public static function register_rest_fields() {
294 106 // Register the post_count field for Follow Me block.
295 - \register_rest_field(
107 + register_rest_field(
296 108 'user',
297 109 'post_count',
298 110 array(
299 111 /**
@@ -304,9 +116,9 @@
304 116 * @param \WP_REST_Request $request The request object.
305 117 * @return int The number of published posts.
306 118 */
307 119 'get_callback' => static function ( $response, $field_name, $request ) {
308 - return (int) \count_user_posts( $request->get_param( 'id' ), 'post', true );
120 + return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
309 121 },
310 122 'schema' => array(
311 123 'description' => 'Number of published posts',
312 124 'type' => 'integer',
@@ -322,10 +134,10 @@
322 134 * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
323 135 * @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
324 136 */
325 137 public static function get_user_id( $user_string ) {
326 - if ( \is_numeric( $user_string ) ) {
327 - return \absint( $user_string );
138 + if ( is_numeric( $user_string ) ) {
139 + return absint( $user_string );
328 140 }
329 141
330 142 // If the user string is 'blog', return the Blog User ID.
331 143 if ( 'blog' === $user_string ) {
@@ -337,32 +149,32 @@
337 149 return null;
338 150 }
339 151
340 152 // For a homepage/front page, if the Blog User is active, use it.
341 - if ( ( \is_front_page() || \is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
153 + if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
342 154 return Actors::BLOG_USER_ID;
343 155 }
344 156
345 157 // If we're in a loop, use the post author.
346 - $author_id = \get_the_author_meta( 'ID' );
158 + $author_id = get_the_author_meta( 'ID' );
347 159 if ( $author_id ) {
348 160 return $author_id;
349 161 }
350 162
351 163 // For other pages, the queried object will clue us in.
352 - $queried_object = \get_queried_object();
164 + $queried_object = get_queried_object();
353 165 if ( ! $queried_object ) {
354 166 return null;
355 167 }
356 168
357 169 // If we're on a user archive page, use that user's ID.
358 - if ( \is_a( $queried_object, 'WP_User' ) ) {
170 + if ( is_a( $queried_object, 'WP_User' ) ) {
359 171 return $queried_object->ID;
360 172 }
361 173
362 174 // For a single post, use the post author's ID.
363 - if ( \is_a( $queried_object, 'WP_Post' ) ) {
364 - return \get_the_author_meta( 'ID' );
175 + if ( is_a( $queried_object, 'WP_Post' ) ) {
176 + return get_the_author_meta( 'ID' );
365 177 }
366 178
367 179 // We won't properly account for some conditions, like tag archives.
368 180 return null;
@@ -368,304 +180,8 @@
368 180 return null;
369 181 }
370 182
371 183 /**
372 - * Render an actor list block (followers or following).
373 - *
374 - * @param string $endpoint The endpoint type ('followers' or 'following').
375 - * @param array $attributes Block attributes.
376 - * @param \WP_Block $block Block instance.
377 - * @param string $content Block content.
378 - *
379 - * @return string|void The HTML to render, or void to render nothing.
380 - */
381 - public static function render_actor_list_block( $endpoint, $attributes, $block, $content ) {
382 - if ( is_activitypub_request() || \is_feed() ) {
383 - return '';
384 - }
385 -
386 - $attributes = \wp_parse_args( $attributes );
387 - $block_name = 'followers' === $endpoint ? \__( 'Followers', 'activitypub' ) : \__( 'Following', 'activitypub' );
388 -
389 - if ( empty( $content ) ) {
390 - // Fallback for v1.0.0 blocks.
391 - /* translators: %s: Block type (Followers or Following) */
392 - $_title = $attributes['title'] ?? \sprintf( \__( 'Fediverse %s', 'activitypub' ), $block_name );
393 - $content = '<h3 class="wp-block-heading">' . \esc_html( $_title ) . '</h3>';
394 - unset( $attributes['title'], $attributes['className'] );
395 - } else {
396 - $content = \implode( PHP_EOL, \wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
397 - // Hide empty headings.
398 - if ( empty( \wp_strip_all_tags( $content ) ) ) {
399 - $content = '';
400 - }
401 - }
402 -
403 - $user_id = self::get_user_id( $attributes['selectedUser'] );
404 - if ( \is_null( $user_id ) ) {
405 - /* translators: %s: Block type (Followers or Following) */
406 - return \sprintf( '<!-- %s block: `inherit` mode does not display on this type of page -->', $block_name );
407 - }
408 -
409 - $user = Actors::get_by_id( $user_id );
410 - if ( \is_wp_error( $user ) ) {
411 - /* translators: 1: Block type (Followers or Following), 2: User ID */
412 - return \sprintf( '<!-- %1$s block: `%2$s` not an active ActivityPub user -->', $block_name, $user_id );
413 - }
414 -
415 - if ( ! Actors::show_social_graph( $user_id ) ) {
416 - /* translators: %s: Block type (Followers or Following) */
417 - return \sprintf( '<!-- %s block: social graph is hidden for this user -->', $block_name );
418 - }
419 -
420 - $_per_page = \max( 1, \absint( $attributes['per_page'] ) );
421 - $_show_avatars = (bool) \get_option( 'show_avatars' );
422 -
423 - // Query the appropriate collection.
424 - if ( 'followers' === $endpoint ) {
425 - $data = Followers::query( $user_id, $_per_page );
426 - $items = $data['followers'];
427 - } else {
428 - $data = Following::query( $user_id, $_per_page );
429 - $items = $data['following'];
430 - }
431 -
432 - // Prepare items data for the Interactivity API context.
433 - $prepared_items = \array_map(
434 - static function ( $item ) {
435 - $actor = Remote_Actors::get_actor( $item );
436 -
437 - // Restrict URLs to http/https schemes to prevent XSS via javascript: URIs.
438 - $url = object_to_uri( $actor->get_url() ) ?: $actor->get_id();
439 -
440 - return array(
441 - 'handle' => '@' . $actor->get_webfinger(),
442 - 'icon' => $actor->get_icon(),
443 - 'name' => $actor->get_name() ?: $actor->get_preferred_username(),
444 - 'url' => \esc_url( $url, array( 'http', 'https' ) ),
445 - );
446 - },
447 - $items
448 - );
449 -
450 - $store_name = 'activitypub/' . $endpoint;
451 -
452 - // Set up the Interactivity API config.
453 - \wp_interactivity_config(
454 - $store_name,
455 - array(
456 - 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
457 - 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
458 - )
459 - );
460 -
461 - // Set initial context data.
462 - $context = array(
463 - 'items' => $prepared_items,
464 - 'isLoading' => false,
465 - 'order' => $attributes['order'],
466 - 'page' => 1,
467 - 'pages' => \ceil( $data['total'] / $_per_page ),
468 - 'perPage' => $_per_page,
469 - 'total' => $data['total'],
470 - 'userId' => $user_id,
471 - 'endpoint' => $endpoint,
472 - );
473 -
474 - // Get block wrapper attributes with the data-wp-interactive attribute.
475 - $wrapper_attributes = \get_block_wrapper_attributes(
476 - array(
477 - 'id' => \wp_unique_id( 'activitypub-' . $endpoint . '-block-' ),
478 - 'data-wp-interactive' => $store_name,
479 - 'data-wp-context' => \wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
480 - )
481 - );
482 -
483 - /* translators: %s: Block type (Followers or Following) */
484 - $nav_label = \sprintf( \__( '%s navigation', 'activitypub' ), $block_name );
485 -
486 - \ob_start();
487 - ?>
488 - <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput ?>>
489 - <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput ?>
490 -
491 - <?php
492 - self::render_actor_list(
493 - array(
494 - 'show_avatars' => $_show_avatars,
495 - 'total' => $data['total'],
496 - 'per_page' => $_per_page,
497 - 'nav_label' => $nav_label,
498 - )
499 - );
500 - ?>
501 - </div>
502 - <?php
503 - return \ob_get_clean();
504 - }
505 -
506 - /**
507 - * Render the emoji block.
508 - *
509 - * Replaces emoji shortcode with cached img tag at runtime.
510 - *
511 - * @param array $attrs The block attributes.
512 - * @param string $content The block inner content (emoji shortcode).
513 - *
514 - * @return string The rendered emoji img tag.
515 - */
516 - public static function render_emoji_block( $attrs, $content ) {
517 - if ( empty( $attrs['url'] ) || empty( $content ) ) {
518 - return $content;
519 - }
520 -
521 - $url = $attrs['url'];
522 - $shortcode = \trim( $content );
523 - $name = \trim( $shortcode, ':' );
524 -
525 - /**
526 - * Filters a remote media URL for caching.
527 - *
528 - * @param string $url The remote media URL.
529 - * @param string $context The context ('emoji').
530 - * @param int|null $entity_id The entity ID.
531 - * @param array $options Additional options.
532 - */
533 - $cached_url = \apply_filters(
534 - 'activitypub_remote_media_url',
535 - $url,
536 - 'emoji',
537 - null,
538 - array( 'updated' => $attrs['updated'] ?? null )
539 - );
540 -
541 - return Emoji::get_img_tag( $cached_url ?: $url, $name );
542 - }
543 -
544 - /**
545 - * Render the image block.
546 - *
547 - * Replaces remote image URL with cached URL at runtime.
548 - *
549 - * @param array $attrs The block attributes.
550 - * @param string $content The block inner content (img tag).
551 - *
552 - * @return string The rendered content with cached URL.
553 - */
554 - public static function render_image_block( $attrs, $content ) {
555 - if ( empty( $attrs['url'] ) || empty( $content ) ) {
556 - return $content;
557 - }
558 -
559 - $url = $attrs['url'];
560 -
561 - // Get entity ID from context.
562 - $entity_id = null;
563 - $post = \get_post();
564 - if ( $post ) {
565 - $entity_id = $post->ID;
566 - }
567 -
568 - /**
569 - * Filters a remote image URL for caching.
570 - *
571 - * @param string $url The remote image URL.
572 - * @param string $context The context ('media').
573 - * @param int|null $entity_id The entity ID.
574 - * @param array $options Additional options.
575 - */
576 - $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'media', $entity_id, array() );
577 -
578 - if ( $cached_url && $cached_url !== $url ) {
579 - return \str_replace( $url, $cached_url, $content );
580 - }
581 -
582 - return $content;
583 - }
584 -
585 - /**
586 - * Render the audio block.
587 - *
588 - * Replaces remote audio URL with cached URL at runtime.
589 - *
590 - * @param array $attrs The block attributes.
591 - * @param string $content The block inner content (audio tag).
592 - *
593 - * @return string The rendered content with cached URL.
594 - */
595 - public static function render_audio_block( $attrs, $content ) {
596 - if ( empty( $attrs['url'] ) || empty( $content ) ) {
597 - return $content;
598 - }
599 -
600 - $url = $attrs['url'];
601 -
602 - // Get entity ID from context.
603 - $entity_id = null;
604 - $post = \get_post();
605 - if ( $post ) {
606 - $entity_id = $post->ID;
607 - }
608 -
609 - /**
610 - * Filters a remote audio URL for caching.
611 - *
612 - * @param string $url The remote audio URL.
613 - * @param string $context The context ('audio').
614 - * @param int|null $entity_id The entity ID.
615 - * @param array $options Additional options.
616 - */
617 - $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'audio', $entity_id, array() );
618 -
619 - if ( $cached_url && $cached_url !== $url ) {
620 - return \str_replace( $url, $cached_url, $content );
621 - }
622 -
623 - return $content;
624 - }
625 -
626 - /**
627 - * Render the video block.
628 - *
629 - * Replaces remote video URL with cached URL at runtime.
630 - *
631 - * @param array $attrs The block attributes.
632 - * @param string $content The block inner content (video tag).
633 - *
634 - * @return string The rendered content with cached URL.
635 - */
636 - public static function render_video_block( $attrs, $content ) {
637 - if ( empty( $attrs['url'] ) || empty( $content ) ) {
638 - return $content;
639 - }
640 -
641 - $url = $attrs['url'];
642 -
643 - // Get entity ID from context.
644 - $entity_id = null;
645 - $post = \get_post();
646 - if ( $post ) {
647 - $entity_id = $post->ID;
648 - }
649 -
650 - /**
651 - * Filters a remote video URL for caching.
652 - *
653 - * @param string $url The remote video URL.
654 - * @param string $context The context ('video').
655 - * @param int|null $entity_id The entity ID.
656 - * @param array $options Additional options.
657 - */
658 - $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'video', $entity_id, array() );
659 -
660 - if ( $cached_url && $cached_url !== $url ) {
661 - return \str_replace( $url, $cached_url, $content );
662 - }
663 -
664 - return $content;
665 - }
666 -
667 - /**
668 184 * Render the reply block.
669 185 *
670 186 * @param array $attrs The block attributes.
671 187 *
@@ -680,28 +196,13 @@
680 196 if ( empty( $attrs['url'] ) ) {
681 197 return null;
682 198 }
683 199
684 - /*
685 - * In feed contexts (RSS, Atom, and anything else WordPress treats as a feed) the styled
686 - * embed card depends on plugin CSS that isn't loaded, so it degrades to an unreadable
687 - * wall of text. Substitute the same simplified mention link the federation path uses,
688 - * and if the remote lookup fails fall through to the plain `<a class="u-in-reply-to">`
689 - * link below so the feed item still surfaces *some* indication that it's a reply.
690 - */
691 - if ( \is_feed() ) {
692 - $mention = self::generate_reply_link( '', array( 'attrs' => $attrs ) );
693 - if ( ! empty( $mention ) ) {
694 - return $mention;
695 - }
696 - $attrs['embedPost'] = false;
697 - }
698 -
699 200 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
700 201
701 - $wrapper_attrs = \get_block_wrapper_attributes(
202 + $wrapper_attrs = get_block_wrapper_attributes(
702 203 array(
703 - 'aria-label' => \__( 'Reply', 'activitypub' ),
204 + 'aria-label' => __( 'Reply', 'activitypub' ),
704 205 'class' => 'activitypub-reply-block',
705 206 'data-in-reply-to' => $attrs['url'],
706 207 )
707 208 );
@@ -710,11 +211,9 @@
710 211
711 212 // Try to get and append the embed if requested.
712 213 $embed = null;
713 214 if ( $show_embed ) {
714 - // Use the theme's content width or a reasonable default to avoid narrow embeds.
715 - $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
716 - $embed = \wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
215 + $embed = wp_oembed_get( $attrs['url'] );
717 216 if ( $embed ) {
718 217 $html .= $embed;
719 218 \wp_enqueue_script( 'wp-embed' );
720 219 }
@@ -721,14 +220,14 @@
721 220 }
722 221
723 222 // Show the link if embed is not requested or if embed failed.
724 223 if ( ! $show_embed || ! $embed ) {
725 - $html .= \sprintf(
224 + $html .= sprintf(
726 225 '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
727 - \esc_url( $attrs['url'] ),
728 - \esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
226 + esc_url( $attrs['url'] ),
227 + esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
729 228 // translators: %s is the URL of the post being replied to.
730 - \sprintf( \__( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', \esc_url( $attrs['url'] ) ) )
229 + sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
731 230 );
732 231 }
733 232
734 233 $html .= '</div>';
@@ -738,27 +237,16 @@
738 237
739 238 /**
740 239 * Renders a modal component that can be used by different blocks.
741 240 *
742 - * @param array $args {
743 - * Arguments for the modal.
744 - *
745 - * @type string $content The modal content HTML.
746 - * @type string $id Optional ID prefix for the modal elements.
747 - * @type bool $is_compact Whether the modal is compact (popover-style). Default false.
748 - * @type string $title Static title text for the modal header.
749 - * @type string $title_binding Optional Interactivity API binding for a dynamic title
750 - * (e.g. 'context.modal.title'). When set, uses data-wp-text
751 - * on the title element and enables dynamic compact toggling.
752 - * }
241 + * @param array $args Arguments for the modal.
753 242 */
754 243 public static function render_modal( $args = array() ) {
755 244 $defaults = array(
756 - 'content' => '',
757 - 'id' => '',
758 - 'is_compact' => false,
759 - 'title' => '',
760 - 'title_binding' => '',
245 + 'content' => '',
246 + 'id' => '',
247 + 'is_compact' => false,
248 + 'title' => '',
761 249 );
762 250
763 251 $args = \wp_parse_args( $args, $defaults );
764 252 ?>
@@ -766,17 +254,14 @@
766 254 <div
767 255 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
768 256 data-wp-bind--hidden="!context.modal.isOpen"
769 257 data-wp-watch="callbacks.handleModalEffects"
770 - <?php if ( ! empty( $args['title_binding'] ) ) : ?>
771 - data-wp-class--compact="context.modal.isCompact"
772 - <?php endif; ?>
773 258 role="dialog"
774 259 aria-modal="true"
775 260 hidden
776 261 >
777 262 <div class="activitypub-modal__frame">
778 - <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) || ! empty( $args['title_binding'] ) ) : ?>
263 + <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) ) : ?>
779 264 <div class="activitypub-modal__header">
780 265 <h2
781 266 class="activitypub-modal__title"
782 267 <?php if ( ! empty( $args['id'] ) ) : ?>
@@ -781,15 +266,12 @@
781 266 class="activitypub-modal__title"
782 267 <?php if ( ! empty( $args['id'] ) ) : ?>
783 268 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
784 269 <?php endif; ?>
785 - <?php if ( ! empty( $args['title_binding'] ) ) : ?>
786 - data-wp-text="<?php echo \esc_attr( $args['title_binding'] ); ?>"
787 - <?php endif; ?>
788 270 ><?php echo \esc_html( $args['title'] ); ?></h2>
789 271 <button
790 272 type="button"
791 - class="activitypub-modal__close wp-element-button"
273 + class="activitypub-modal__close wp-element-button wp-block-button__link"
792 274 data-wp-on--click="actions.closeModal"
793 275 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
794 276 >
795 277 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
@@ -806,123 +288,8 @@
806 288 <?php
807 289 }
808 290
809 291 /**
810 - * Renders a help section explaining the Fediverse inside modal dialogs.
811 - *
812 - * Outputs a collapsible `<details>` element that explains decentralized
813 - * interactions to users unfamiliar with the Fediverse.
814 - *
815 - * @since 8.0.0
816 - */
817 - public static function render_modal_help() {
818 - ?>
819 - <details class="activitypub-dialog__help">
820 - <summary><?php \esc_html_e( 'Why do I need to enter my profile?', 'activitypub' ); ?></summary>
821 - <p>
822 - <?php \esc_html_e( 'This site is part of the ⁂ open social web, a network of interconnected social platforms (like Mastodon, Pixelfed, Friendica, and others). Unlike centralized social media, your account lives on a platform of your choice, and you can interact with people across different platforms.', 'activitypub' ); ?>
823 - </p>
824 - <p>
825 - <?php \esc_html_e( 'By entering your profile, we can send you to your account where you can complete this action.', 'activitypub' ); ?>
826 - </p>
827 - </details>
828 - <?php
829 - }
830 -
831 - /**
832 - * Renders an actor list component that can be used by different blocks.
833 - *
834 - * @param array $args Arguments for the actor list.
835 - */
836 - public static function render_actor_list( $args = array() ) {
837 - $defaults = array(
838 - 'show_avatars' => true,
839 - 'show_pagination' => true,
840 - 'total' => 0,
841 - 'per_page' => 10,
842 - 'nav_label' => \__( 'Actor navigation', 'activitypub' ),
843 - );
844 -
845 - $args = \wp_parse_args( $args, $defaults );
846 -
847 - // Sanitize numeric values, ensuring per_page is at least 1 to avoid division by zero.
848 - $args['total'] = \absint( $args['total'] );
849 - $args['per_page'] = \max( 1, \absint( $args['per_page'] ) );
850 - ?>
851 -
852 - <div class="activitypub-actor-list-container">
853 - <ul class="activitypub-actor-list">
854 - <template data-wp-each="context.items">
855 - <li class="activitypub-actor-item">
856 - <a href="#"
857 - data-wp-bind--href="context.item.url"
858 - class="activitypub-actor-link"
859 - target="_blank"
860 - rel="external noreferrer noopener"
861 - data-wp-bind--title="context.item.handle">
862 -
863 - <?php if ( $args['show_avatars'] ) : ?>
864 - <img
865 - data-wp-bind--src="context.item.icon.url"
866 - data-wp-on--error="callbacks.setDefaultAvatar"
867 - src=""
868 - alt=""
869 - class="activitypub-actor-avatar"
870 - width="48"
871 - height="48"
872 - >
873 - <?php endif; ?>
874 -
875 - <div class="activitypub-actor-info">
876 - <span class="activitypub-actor-name" data-wp-text="context.item.name"></span>
877 - <span class="activitypub-actor-handle" data-wp-text="context.item.handle"></span>
878 - </div>
879 -
880 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="external-link-icon" aria-hidden="true" focusable="false" fill="currentColor">
881 - <path d="M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"></path>
882 - </svg>
883 - </a>
884 - </li>
885 - </template>
886 - </ul>
887 -
888 - <?php if ( $args['show_pagination'] && $args['total'] > $args['per_page'] ) : ?>
889 - <nav class="activitypub-actor-list-pagination" role="navigation">
890 - <h1 class="screen-reader-text"><?php echo \esc_html( $args['nav_label'] ); ?></h1>
891 - <a
892 - href="#"
893 - role="button"
894 - class="pagination-previous"
895 - data-wp-on--click="actions.previousPage"
896 - data-wp-bind--aria-disabled="state.disablePreviousLink"
897 - aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
898 - >
899 - <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
900 - </a>
901 -
902 - <div class="pagination-info" data-wp-text="state.paginationText"></div>
903 -
904 - <a
905 - href="#"
906 - role="button"
907 - class="pagination-next"
908 - data-wp-on--click="actions.nextPage"
909 - data-wp-bind--aria-disabled="state.disableNextLink"
910 - aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
911 - >
912 - <?php \esc_html_e( 'Next', 'activitypub' ); ?>
913 - </a>
914 - </nav>
915 -
916 - <div class="activitypub-actor-list-loading" data-wp-bind--aria-hidden="!context.isLoading">
917 - <div class="loading-spinner"></div>
918 - </div>
919 - <?php endif; ?>
920 - </div>
921 - <?php
922 - }
923 -
924 - /**
925 292 * Converts content to blocks before saving to the database.
926 293 *
927 294 * @param array $data The post data to be inserted.
928 295 * @param array $post The Mastodon Create activity.
@@ -982,9 +349,8 @@
982 349 * @param object $post The post object.
983 350 */
984 351 public static function add_post_transformation_callbacks( $post ) {
985 352 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
986 - \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
987 353
988 354 // Only transform reply link if it's the first block in the post.
989 355 $blocks = \parse_blocks( $post->post_content );
990 356 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
@@ -1001,9 +367,8 @@
1001 367 */
1002 368 public static function remove_post_transformation_callbacks( $content ) {
1003 369 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
1004 370 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
1005 - \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
1006 371
1007 372 return $content;
1008 373 }
1009 374
@@ -1061,98 +426,13 @@
1061 426 return \sprintf(
1062 427 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
1063 428 \esc_url( $url ),
1064 429 \esc_attr( $webfinger ),
1065 - \esc_html( '@' . \strtok( $webfinger, '@' ) )
430 + \esc_html( '@' . strtok( $webfinger, '@' ) )
1066 431 );
1067 432 }
1068 433
1069 434 /**
1070 - * Add the stats image as an attachment when a post contains the stats block.
1071 - *
1072 - * Parses the post content for activitypub/stats blocks and appends each
1073 - * as an Image attachment to the ActivityPub object.
1074 - *
1075 - * @since 8.1.0
1076 - *
1077 - * @param array $attachments The existing attachments.
1078 - * @param \WP_Post $post The post object.
1079 - *
1080 - * @return array The attachments with stats images appended.
1081 - */
1082 - public static function add_stats_image_attachment( $attachments, $post ) {
1083 - if ( ! Stats_Image::is_available() ) {
1084 - return $attachments;
1085 - }
1086 -
1087 - /*
1088 - * The stats image intentionally bypasses the `activitypub_max_image_attachments`
1089 - * limit because it replaces the block content rather than being an inline image
1090 - * extracted from the post. It is always appended so that the share-pic is
1091 - * included in the federated activity regardless of the attachment cap.
1092 - */
1093 - $blocks = \parse_blocks( $post->post_content );
1094 - $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
1095 -
1096 - foreach ( $stats_blocks as $block ) {
1097 - $user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
1098 -
1099 - if ( null === $user_id ) {
1100 - continue;
1101 - }
1102 -
1103 - $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
1104 - $url = Stats_Image::get_url( $user_id, $year );
1105 -
1106 - if ( \is_wp_error( $url ) ) {
1107 - continue;
1108 - }
1109 -
1110 - // Determine mime type from URL extension.
1111 - $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
1112 -
1113 - $attachments[] = array(
1114 - 'type' => 'Image',
1115 - 'mediaType' => $mime_type,
1116 - 'url' => $url,
1117 - 'name' => \sprintf(
1118 - /* translators: %d: The year */
1119 - \__( 'Fediverse Stats %d', 'activitypub' ),
1120 - $year
1121 - ),
1122 - );
1123 - }
1124 -
1125 - return $attachments;
1126 - }
1127 -
1128 - /**
1129 - * Recursively find blocks of a given type in a block tree.
1130 - *
1131 - * @since 8.1.0
1132 - *
1133 - * @param array $blocks The parsed blocks.
1134 - * @param string $block_name The block name to search for.
1135 - *
1136 - * @return array The matching blocks.
1137 - */
1138 - private static function find_blocks_recursive( $blocks, $block_name ) {
1139 - $found = array();
1140 -
1141 - foreach ( $blocks as $block ) {
1142 - if ( $block_name === $block['blockName'] ) {
1143 - $found[] = $block;
1144 - }
1145 -
1146 - if ( ! empty( $block['innerBlocks'] ) ) {
1147 - $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
1148 - }
1149 - }
1150 -
1151 - return $found;
1152 - }
1153 -
1154 - /**
1155 435 * Transform Embed blocks to block level link.
1156 436 *
1157 437 * Remote servers will simply drop iframe elements, rendering incomplete content.
1158 438 *
@@ -1167,185 +447,7 @@
1167 447 public static function revert_embed_links( $block_content, $block ) {
1168 448 if ( ! isset( $block['attrs']['url'] ) ) {
1169 449 return $block_content;
1170 450 }
1171 - return '<p><a href="' . \esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1172 - }
1173 -
1174 - /**
1175 - * Convert HTML content to blocks.
1176 - *
1177 - * Tokenizes the content with wp_html_split(), tracks nesting depth,
1178 - * and wraps each top-level element in block comment delimiters.
1179 - *
1180 - * @since 8.1.0
1181 - *
1182 - * @param string $content The HTML content.
1183 - *
1184 - * @return string The content converted to blocks.
1185 - */
1186 - public static function convert_from_html( $content ) {
1187 - if ( empty( $content ) ) {
1188 - return '';
1189 - }
1190 -
1191 - $tokens = \wp_html_split( $content );
1192 - $_content = '';
1193 - $depth = 0;
1194 - $current_tag = '';
1195 - $current_html = '';
1196 -
1197 - foreach ( $tokens as $token ) {
1198 - if ( '' === $token ) {
1199 - continue;
1200 - }
1201 -
1202 - // Text content — accumulate only inside a top-level element.
1203 - if ( '<' !== $token[0] ) {
1204 - if ( $depth > 0 ) {
1205 - $current_html .= $token;
1206 - }
1207 - continue;
1208 - }
1209 -
1210 - // Closing tag.
1211 - if ( '/' === $token[1] ) {
1212 - $current_html .= $token;
1213 - --$depth;
1214 -
1215 - if ( 0 === $depth && '' !== $current_tag ) {
1216 - $_content .= self::to_block( $current_tag, $current_html );
1217 - $current_tag = '';
1218 - $current_html = '';
1219 - }
1220 - continue;
1221 - }
1222 -
1223 - // Extract the tag name from the opening tag.
1224 - if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
1225 - if ( $depth > 0 ) {
1226 - $current_html .= $token;
1227 - }
1228 - continue;
1229 - }
1230 -
1231 - $tag = \strtoupper( $m[1] );
1232 -
1233 - // Start of a new top-level element.
1234 - if ( 0 === $depth ) {
1235 - $current_tag = $tag;
1236 - $current_html = $token;
1237 - } else {
1238 - $current_html .= $token;
1239 - }
1240 -
1241 - // Void elements don't increase depth — flush immediately at top level.
1242 - if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
1243 - if ( 0 === $depth && '' !== $current_tag ) {
1244 - $_content .= self::to_block( $current_tag, $current_html );
1245 - $current_tag = '';
1246 - $current_html = '';
1247 - }
1248 - } else {
1249 - ++$depth;
1250 - }
1251 - }
1252 -
1253 - return $_content;
1254 - }
1255 -
1256 - /**
1257 - * Wrap an HTML element in block comment delimiters.
1258 - *
1259 - * @since 8.1.0
1260 - *
1261 - * @param string $tag The uppercase tag name.
1262 - * @param string $html The element HTML.
1263 - *
1264 - * @return string The block-wrapped HTML, or empty string for skipped tags.
1265 - */
1266 - private static function to_block( $tag, $html ) {
1267 - if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
1268 - return '';
1269 - }
1270 -
1271 - $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
1272 - $block_attrs = array();
1273 -
1274 - if ( 'OL' === $tag ) {
1275 - $block_attrs['ordered'] = true;
1276 - }
1277 -
1278 - return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
1279 - }
1280 -
1281 - /**
1282 - * Filter the main query to exclude replies.
1283 - *
1284 - * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
1285 - * block when the visitor has explicitly requested the "Posts" tab via
1286 - * `?filter=posts`. This filters the main query so that Query Loop blocks
1287 - * with `inherit: true` also pick up the filter.
1288 - *
1289 - * The filter only attaches on that explicit opt-in. Admin, feed, and any
1290 - * regular frontend request (front page, archives, search…) are never
1291 - * touched, which is why no block-presence probing is needed: the only
1292 - * way `?filter=posts` appears in a URL is from a click on the
1293 - * `activitypub/posts-and-replies` tab block.
1294 - *
1295 - * @since 8.1.0
1296 - *
1297 - * @param WP_Query $query The WP_Query instance.
1298 - */
1299 - public static function filter_query_loop_vars( $query ) {
1300 - // Never touch admin or feed queries.
1301 - if ( \is_admin() || $query->is_feed() ) {
1302 - return;
1303 - }
1304 -
1305 - if ( ! $query->is_main_query() || $query->is_singular() ) {
1306 - return;
1307 - }
1308 -
1309 - // Skip the reply-exclusion filter for queries that only target
1310 - // non-ActivityPub post types to avoid a full table scan.
1311 - $query_post_type = $query->get( 'post_type' );
1312 - if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
1313 - $query_post_types = (array) $query_post_type;
1314 - if ( ! \array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
1315 - return;
1316 - }
1317 - }
1318 -
1319 - // Only filter when the "Posts" tab has been explicitly selected.
1320 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1321 - if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
1322 - return;
1323 - }
1324 -
1325 - \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1326 - }
1327 -
1328 - /**
1329 - * Exclude posts containing the activitypub/reply block.
1330 - *
1331 - * Removes itself after the first execution to avoid
1332 - * affecting secondary queries on the same page.
1333 - *
1334 - * @since 8.1.0
1335 - *
1336 - * @param string $where The WHERE clause.
1337 - * @return string Modified WHERE clause.
1338 - */
1339 - public static function exclude_replies_where( $where ) {
1340 - \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1341 -
1342 - global $wpdb;
1343 -
1344 - $where .= $wpdb->prepare(
1345 - " AND {$wpdb->posts}.post_content NOT LIKE %s",
1346 - '%<!-- wp:activitypub/reply%'
1347 - );
1348 -
1349 - return $where;
451 + return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1350 452 }
1351 453 }