PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.0
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.1.0, at includes/class-blocks.php

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