PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/class-blocks.php

1,356 lines 42.5 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 // 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 * Render the reply block.
669 *
670 * @param array $attrs The block attributes.
671 *
672 * @return string The HTML to render.
673 */
674 public static function render_reply_block( $attrs ) {
675 if ( is_activitypub_request() ) {
676 $attrs['embedPost'] = false;
677 }
678
679 // Return early if no URL is provided.
680 if ( empty( $attrs['url'] ) ) {
681 return null;
682 }
683
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 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
700
701 $wrapper_attrs = \get_block_wrapper_attributes(
702 array(
703 'aria-label' => \__( 'Reply', 'activitypub' ),
704 'class' => 'activitypub-reply-block',
705 'data-in-reply-to' => $attrs['url'],
706 )
707 );
708
709 $html = '<div ' . $wrapper_attrs . '>';
710
711 // Try to get and append the embed if requested.
712 $embed = null;
713 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 ) );
717 if ( $embed ) {
718 $html .= $embed;
719 \wp_enqueue_script( 'wp-embed' );
720 }
721 }
722
723 // Show the link if embed is not requested or if embed failed.
724 if ( ! $show_embed || ! $embed ) {
725 $html .= \sprintf(
726 '<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' ),
729 // 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'] ) ) )
731 );
732 }
733
734 $html .= '</div>';
735
736 return $html;
737 }
738
739 /**
740 * Renders a modal component that can be used by different blocks.
741 *
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 * }
753 */
754 public static function render_modal( $args = array() ) {
755 $defaults = array(
756 'content' => '',
757 'id' => '',
758 'is_compact' => false,
759 'title' => '',
760 'title_binding' => '',
761 );
762
763 $args = \wp_parse_args( $args, $defaults );
764 ?>
765
766 <div
767 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
768 data-wp-bind--hidden="!context.modal.isOpen"
769 data-wp-watch="callbacks.handleModalEffects"
770 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
771 data-wp-class--compact="context.modal.isCompact"
772 <?php endif; ?>
773 role="dialog"
774 aria-modal="true"
775 hidden
776 >
777 <div class="activitypub-modal__frame">
778 <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) || ! empty( $args['title_binding'] ) ) : ?>
779 <div class="activitypub-modal__header">
780 <h2
781 class="activitypub-modal__title"
782 <?php if ( ! empty( $args['id'] ) ) : ?>
783 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
784 <?php endif; ?>
785 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
786 data-wp-text="<?php echo \esc_attr( $args['title_binding'] ); ?>"
787 <?php endif; ?>
788 ><?php echo \esc_html( $args['title'] ); ?></h2>
789 <button
790 type="button"
791 class="activitypub-modal__close wp-element-button"
792 data-wp-on--click="actions.closeModal"
793 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
794 >
795 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
796 <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>
797 </svg>
798 </button>
799 </div>
800 <?php endif; ?>
801 <div class="activitypub-modal__content">
802 <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
803 </div>
804 </div>
805 </div>
806 <?php
807 }
808
809 /**
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 * Converts content to blocks before saving to the database.
926 *
927 * @param array $data The post data to be inserted.
928 * @param array $post The Mastodon Create activity.
929 *
930 * @return array
931 */
932 public static function filter_import_mastodon_post_data( $data, $post ) {
933 // Convert paragraphs to blocks.
934 \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
935 $blocks = \array_map(
936 static function ( $paragraph ) {
937 return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
938 },
939 $matches[0] ?? array()
940 );
941
942 $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
943
944 // Add reply block if it's a reply.
945 if ( ! empty( $post['object']['inReplyTo'] ) ) {
946 $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
947 $data['post_content'] = $reply_block . $data['post_content'];
948 }
949
950 return $data;
951 }
952
953 /**
954 * Add Interactivity directions to the specified element.
955 *
956 * @param string $content The block content.
957 * @param string[] $selector The selector for the element to add directions to.
958 * @param string[] $attributes The attributes to add to the element.
959 *
960 * @return string The updated content.
961 */
962 public static function add_directions( $content, $selector, $attributes ) {
963 $tags = new \WP_HTML_Tag_Processor( $content );
964
965 while ( $tags->next_tag( $selector ) ) {
966 foreach ( $attributes as $key => $value ) {
967 if ( 'class' === $key ) {
968 $tags->add_class( $value );
969 continue;
970 }
971
972 $tags->set_attribute( $key, $value );
973 }
974 }
975
976 return $tags->get_updated_html();
977 }
978
979 /**
980 * Add post transformation callbacks.
981 *
982 * @param object $post The post object.
983 */
984 public static function add_post_transformation_callbacks( $post ) {
985 \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
988 // Only transform reply link if it's the first block in the post.
989 $blocks = \parse_blocks( $post->post_content );
990 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
991 \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
992 }
993 }
994
995 /**
996 * Remove post transformation callbacks.
997 *
998 * @param string $content The post content.
999 *
1000 * @return string The updated content.
1001 */
1002 public static function remove_post_transformation_callbacks( $content ) {
1003 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
1004 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
1005 \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
1006
1007 return $content;
1008 }
1009
1010 /**
1011 * Generate HTML @ link for reply block.
1012 *
1013 * @param string $block_content The block content.
1014 * @param array $block The block data.
1015 *
1016 * @return string The HTML @ link.
1017 */
1018 public static function generate_reply_link( $block_content, $block ) {
1019 // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
1020 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
1021
1022 // Return empty string if no URL is provided.
1023 if ( empty( $block['attrs']['url'] ) ) {
1024 return '';
1025 }
1026
1027 $url = $block['attrs']['url'];
1028
1029 // Try to get ActivityPub representation. Is likely already cached.
1030 $object = Http::get_remote_object( $url );
1031 if ( \is_wp_error( $object ) ) {
1032 return '';
1033 }
1034
1035 $author_url = $object['attributedTo'] ?? '';
1036 if ( ! $author_url ) {
1037 return '';
1038 }
1039
1040 // Fetch author information.
1041 $author = Http::get_remote_object( $author_url );
1042 if ( \is_wp_error( $author ) ) {
1043 return '';
1044 }
1045
1046 // Get webfinger identifier.
1047 $webfinger = '';
1048 if ( ! empty( $author['webfinger'] ) ) {
1049 $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
1050 } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
1051 // Construct webfinger-style identifier from username and domain.
1052 $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
1053 $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
1054 }
1055
1056 if ( ! $webfinger ) {
1057 return '';
1058 }
1059
1060 // Generate HTML @ link.
1061 return \sprintf(
1062 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
1063 \esc_url( $url ),
1064 \esc_attr( $webfinger ),
1065 \esc_html( '@' . \strtok( $webfinger, '@' ) )
1066 );
1067 }
1068
1069 /**
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 * Transform Embed blocks to block level link.
1156 *
1157 * Remote servers will simply drop iframe elements, rendering incomplete content.
1158 *
1159 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
1160 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
1161 *
1162 * @param string $block_content The block content (html).
1163 * @param object $block The block object.
1164 *
1165 * @return string A block level link
1166 */
1167 public static function revert_embed_links( $block_content, $block ) {
1168 if ( ! isset( $block['attrs']['url'] ) ) {
1169 return $block_content;
1170 }
1171
1172 // Escape once and reuse: the URL is also the visible link text, so it must be safe there too.
1173 $url = \esc_url( $block['attrs']['url'] );
1174
1175 return '<p><a href="' . $url . '">' . $url . '</a></p>';
1176 }
1177
1178 /**
1179 * Convert HTML content to blocks.
1180 *
1181 * Tokenizes the content with wp_html_split(), tracks nesting depth,
1182 * and wraps each top-level element in block comment delimiters.
1183 *
1184 * @since 8.1.0
1185 *
1186 * @param string $content The HTML content.
1187 *
1188 * @return string The content converted to blocks.
1189 */
1190 public static function convert_from_html( $content ) {
1191 if ( empty( $content ) ) {
1192 return '';
1193 }
1194
1195 $tokens = \wp_html_split( $content );
1196 $_content = '';
1197 $depth = 0;
1198 $current_tag = '';
1199 $current_html = '';
1200
1201 foreach ( $tokens as $token ) {
1202 if ( '' === $token ) {
1203 continue;
1204 }
1205
1206 // Text content — accumulate only inside a top-level element.
1207 if ( '<' !== $token[0] ) {
1208 if ( $depth > 0 ) {
1209 $current_html .= $token;
1210 }
1211 continue;
1212 }
1213
1214 // Closing tag.
1215 if ( '/' === $token[1] ) {
1216 $current_html .= $token;
1217 --$depth;
1218
1219 if ( 0 === $depth && '' !== $current_tag ) {
1220 $_content .= self::to_block( $current_tag, $current_html );
1221 $current_tag = '';
1222 $current_html = '';
1223 }
1224 continue;
1225 }
1226
1227 // Extract the tag name from the opening tag.
1228 if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
1229 if ( $depth > 0 ) {
1230 $current_html .= $token;
1231 }
1232 continue;
1233 }
1234
1235 $tag = \strtoupper( $m[1] );
1236
1237 // Start of a new top-level element.
1238 if ( 0 === $depth ) {
1239 $current_tag = $tag;
1240 $current_html = $token;
1241 } else {
1242 $current_html .= $token;
1243 }
1244
1245 // Void elements don't increase depth — flush immediately at top level.
1246 if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
1247 if ( 0 === $depth && '' !== $current_tag ) {
1248 $_content .= self::to_block( $current_tag, $current_html );
1249 $current_tag = '';
1250 $current_html = '';
1251 }
1252 } else {
1253 ++$depth;
1254 }
1255 }
1256
1257 return $_content;
1258 }
1259
1260 /**
1261 * Wrap an HTML element in block comment delimiters.
1262 *
1263 * @since 8.1.0
1264 *
1265 * @param string $tag The uppercase tag name.
1266 * @param string $html The element HTML.
1267 *
1268 * @return string The block-wrapped HTML, or empty string for skipped tags.
1269 */
1270 private static function to_block( $tag, $html ) {
1271 if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
1272 return '';
1273 }
1274
1275 $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
1276 $block_attrs = array();
1277
1278 if ( 'OL' === $tag ) {
1279 $block_attrs['ordered'] = true;
1280 }
1281
1282 return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
1283 }
1284
1285 /**
1286 * Filter the main query to exclude replies.
1287 *
1288 * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
1289 * block when the visitor has explicitly requested the "Posts" tab via
1290 * `?filter=posts`. This filters the main query so that Query Loop blocks
1291 * with `inherit: true` also pick up the filter.
1292 *
1293 * The filter only attaches on that explicit opt-in. Admin, feed, and any
1294 * regular frontend request (front page, archives, search…) are never
1295 * touched, which is why no block-presence probing is needed: the only
1296 * way `?filter=posts` appears in a URL is from a click on the
1297 * `activitypub/posts-and-replies` tab block.
1298 *
1299 * @since 8.1.0
1300 *
1301 * @param WP_Query $query The WP_Query instance.
1302 */
1303 public static function filter_query_loop_vars( $query ) {
1304 // Never touch admin or feed queries.
1305 if ( \is_admin() || $query->is_feed() ) {
1306 return;
1307 }
1308
1309 if ( ! $query->is_main_query() || $query->is_singular() ) {
1310 return;
1311 }
1312
1313 // Skip the reply-exclusion filter for queries that only target
1314 // non-ActivityPub post types to avoid a full table scan.
1315 $query_post_type = $query->get( 'post_type' );
1316 if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
1317 $query_post_types = (array) $query_post_type;
1318 if ( ! \array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
1319 return;
1320 }
1321 }
1322
1323 // Only filter when the "Posts" tab has been explicitly selected.
1324 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1325 if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
1326 return;
1327 }
1328
1329 \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1330 }
1331
1332 /**
1333 * Exclude posts containing the activitypub/reply block.
1334 *
1335 * Removes itself after the first execution to avoid
1336 * affecting secondary queries on the same page.
1337 *
1338 * @since 8.1.0
1339 *
1340 * @param string $where The WHERE clause.
1341 * @return string Modified WHERE clause.
1342 */
1343 public static function exclude_replies_where( $where ) {
1344 \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1345
1346 global $wpdb;
1347
1348 $where .= $wpdb->prepare(
1349 " AND {$wpdb->posts}.post_content NOT LIKE %s",
1350 '%<!-- wp:activitypub/reply%'
1351 );
1352
1353 return $where;
1354 }
1355 }
1356