PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
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
activitypub / includes / class-blocks.php

class-blocks.php in ActivityPub 9.0.1, at includes/class-blocks.php

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