PluginProbe
ActivityPub / 8.2.0
ActivityPub v8.2.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 8.2.0, at includes/class-blocks.php

1,330 lines 41.6 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 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
678
679 $wrapper_attrs = get_block_wrapper_attributes(
680 array(
681 'aria-label' => __( 'Reply', 'activitypub' ),
682 'class' => 'activitypub-reply-block',
683 'data-in-reply-to' => $attrs['url'],
684 )
685 );
686
687 $html = '<div ' . $wrapper_attrs . '>';
688
689 // Try to get and append the embed if requested.
690 $embed = null;
691 if ( $show_embed ) {
692 // Use the theme's content width or a reasonable default to avoid narrow embeds.
693 $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
694 $embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
695 if ( $embed ) {
696 $html .= $embed;
697 \wp_enqueue_script( 'wp-embed' );
698 }
699 }
700
701 // Show the link if embed is not requested or if embed failed.
702 if ( ! $show_embed || ! $embed ) {
703 $html .= sprintf(
704 '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
705 esc_url( $attrs['url'] ),
706 esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
707 // translators: %s is the URL of the post being replied to.
708 sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
709 );
710 }
711
712 $html .= '</div>';
713
714 return $html;
715 }
716
717 /**
718 * Renders a modal component that can be used by different blocks.
719 *
720 * @param array $args {
721 * Arguments for the modal.
722 *
723 * @type string $content The modal content HTML.
724 * @type string $id Optional ID prefix for the modal elements.
725 * @type bool $is_compact Whether the modal is compact (popover-style). Default false.
726 * @type string $title Static title text for the modal header.
727 * @type string $title_binding Optional Interactivity API binding for a dynamic title
728 * (e.g. 'context.modal.title'). When set, uses data-wp-text
729 * on the title element and enables dynamic compact toggling.
730 * }
731 */
732 public static function render_modal( $args = array() ) {
733 $defaults = array(
734 'content' => '',
735 'id' => '',
736 'is_compact' => false,
737 'title' => '',
738 'title_binding' => '',
739 );
740
741 $args = \wp_parse_args( $args, $defaults );
742 ?>
743
744 <div
745 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
746 data-wp-bind--hidden="!context.modal.isOpen"
747 data-wp-watch="callbacks.handleModalEffects"
748 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
749 data-wp-class--compact="context.modal.isCompact"
750 <?php endif; ?>
751 role="dialog"
752 aria-modal="true"
753 hidden
754 >
755 <div class="activitypub-modal__frame">
756 <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) || ! empty( $args['title_binding'] ) ) : ?>
757 <div class="activitypub-modal__header">
758 <h2
759 class="activitypub-modal__title"
760 <?php if ( ! empty( $args['id'] ) ) : ?>
761 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
762 <?php endif; ?>
763 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
764 data-wp-text="<?php echo \esc_attr( $args['title_binding'] ); ?>"
765 <?php endif; ?>
766 ><?php echo \esc_html( $args['title'] ); ?></h2>
767 <button
768 type="button"
769 class="activitypub-modal__close wp-element-button"
770 data-wp-on--click="actions.closeModal"
771 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
772 >
773 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
774 <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>
775 </svg>
776 </button>
777 </div>
778 <?php endif; ?>
779 <div class="activitypub-modal__content">
780 <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
781 </div>
782 </div>
783 </div>
784 <?php
785 }
786
787 /**
788 * Renders a help section explaining the Fediverse inside modal dialogs.
789 *
790 * Outputs a collapsible `<details>` element that explains decentralized
791 * interactions to users unfamiliar with the Fediverse.
792 *
793 * @since 8.0.0
794 */
795 public static function render_modal_help() {
796 ?>
797 <details class="activitypub-dialog__help">
798 <summary><?php \esc_html_e( 'Why do I need to enter my profile?', 'activitypub' ); ?></summary>
799 <p>
800 <?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' ); ?>
801 </p>
802 <p>
803 <?php \esc_html_e( 'By entering your profile, we can send you to your account where you can complete this action.', 'activitypub' ); ?>
804 </p>
805 </details>
806 <?php
807 }
808
809 /**
810 * Renders an actor list component that can be used by different blocks.
811 *
812 * @param array $args Arguments for the actor list.
813 */
814 public static function render_actor_list( $args = array() ) {
815 $defaults = array(
816 'show_avatars' => true,
817 'show_pagination' => true,
818 'total' => 0,
819 'per_page' => 10,
820 'nav_label' => __( 'Actor navigation', 'activitypub' ),
821 );
822
823 $args = \wp_parse_args( $args, $defaults );
824
825 // Sanitize numeric values, ensuring per_page is at least 1 to avoid division by zero.
826 $args['total'] = \absint( $args['total'] );
827 $args['per_page'] = \max( 1, \absint( $args['per_page'] ) );
828 ?>
829
830 <div class="activitypub-actor-list-container">
831 <ul class="activitypub-actor-list">
832 <template data-wp-each="context.items">
833 <li class="activitypub-actor-item">
834 <a href="#"
835 data-wp-bind--href="context.item.url"
836 class="activitypub-actor-link"
837 target="_blank"
838 rel="external noreferrer noopener"
839 data-wp-bind--title="context.item.handle">
840
841 <?php if ( $args['show_avatars'] ) : ?>
842 <img
843 data-wp-bind--src="context.item.icon.url"
844 data-wp-on--error="callbacks.setDefaultAvatar"
845 src=""
846 alt=""
847 class="activitypub-actor-avatar"
848 width="48"
849 height="48"
850 >
851 <?php endif; ?>
852
853 <div class="activitypub-actor-info">
854 <span class="activitypub-actor-name" data-wp-text="context.item.name"></span>
855 <span class="activitypub-actor-handle" data-wp-text="context.item.handle"></span>
856 </div>
857
858 <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">
859 <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>
860 </svg>
861 </a>
862 </li>
863 </template>
864 </ul>
865
866 <?php if ( $args['show_pagination'] && $args['total'] > $args['per_page'] ) : ?>
867 <nav class="activitypub-actor-list-pagination" role="navigation">
868 <h1 class="screen-reader-text"><?php echo \esc_html( $args['nav_label'] ); ?></h1>
869 <a
870 href="#"
871 role="button"
872 class="pagination-previous"
873 data-wp-on-async--click="actions.previousPage"
874 data-wp-bind--aria-disabled="state.disablePreviousLink"
875 aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
876 >
877 <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
878 </a>
879
880 <div class="pagination-info" data-wp-text="state.paginationText"></div>
881
882 <a
883 href="#"
884 role="button"
885 class="pagination-next"
886 data-wp-on-async--click="actions.nextPage"
887 data-wp-bind--aria-disabled="state.disableNextLink"
888 aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
889 >
890 <?php \esc_html_e( 'Next', 'activitypub' ); ?>
891 </a>
892 </nav>
893
894 <div class="activitypub-actor-list-loading" data-wp-bind--aria-hidden="!context.isLoading">
895 <div class="loading-spinner"></div>
896 </div>
897 <?php endif; ?>
898 </div>
899 <?php
900 }
901
902 /**
903 * Converts content to blocks before saving to the database.
904 *
905 * @param array $data The post data to be inserted.
906 * @param array $post The Mastodon Create activity.
907 *
908 * @return array
909 */
910 public static function filter_import_mastodon_post_data( $data, $post ) {
911 // Convert paragraphs to blocks.
912 \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
913 $blocks = \array_map(
914 static function ( $paragraph ) {
915 return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
916 },
917 $matches[0] ?? array()
918 );
919
920 $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
921
922 // Add reply block if it's a reply.
923 if ( ! empty( $post['object']['inReplyTo'] ) ) {
924 $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
925 $data['post_content'] = $reply_block . $data['post_content'];
926 }
927
928 return $data;
929 }
930
931 /**
932 * Add Interactivity directions to the specified element.
933 *
934 * @param string $content The block content.
935 * @param string[] $selector The selector for the element to add directions to.
936 * @param string[] $attributes The attributes to add to the element.
937 *
938 * @return string The updated content.
939 */
940 public static function add_directions( $content, $selector, $attributes ) {
941 $tags = new \WP_HTML_Tag_Processor( $content );
942
943 while ( $tags->next_tag( $selector ) ) {
944 foreach ( $attributes as $key => $value ) {
945 if ( 'class' === $key ) {
946 $tags->add_class( $value );
947 continue;
948 }
949
950 $tags->set_attribute( $key, $value );
951 }
952 }
953
954 return $tags->get_updated_html();
955 }
956
957 /**
958 * Add post transformation callbacks.
959 *
960 * @param object $post The post object.
961 */
962 public static function add_post_transformation_callbacks( $post ) {
963 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
964 \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
965
966 // Only transform reply link if it's the first block in the post.
967 $blocks = \parse_blocks( $post->post_content );
968 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
969 \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
970 }
971 }
972
973 /**
974 * Remove post transformation callbacks.
975 *
976 * @param string $content The post content.
977 *
978 * @return string The updated content.
979 */
980 public static function remove_post_transformation_callbacks( $content ) {
981 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
982 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
983 \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
984
985 return $content;
986 }
987
988 /**
989 * Generate HTML @ link for reply block.
990 *
991 * @param string $block_content The block content.
992 * @param array $block The block data.
993 *
994 * @return string The HTML @ link.
995 */
996 public static function generate_reply_link( $block_content, $block ) {
997 // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
998 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
999
1000 // Return empty string if no URL is provided.
1001 if ( empty( $block['attrs']['url'] ) ) {
1002 return '';
1003 }
1004
1005 $url = $block['attrs']['url'];
1006
1007 // Try to get ActivityPub representation. Is likely already cached.
1008 $object = Http::get_remote_object( $url );
1009 if ( \is_wp_error( $object ) ) {
1010 return '';
1011 }
1012
1013 $author_url = $object['attributedTo'] ?? '';
1014 if ( ! $author_url ) {
1015 return '';
1016 }
1017
1018 // Fetch author information.
1019 $author = Http::get_remote_object( $author_url );
1020 if ( \is_wp_error( $author ) ) {
1021 return '';
1022 }
1023
1024 // Get webfinger identifier.
1025 $webfinger = '';
1026 if ( ! empty( $author['webfinger'] ) ) {
1027 $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
1028 } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
1029 // Construct webfinger-style identifier from username and domain.
1030 $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
1031 $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
1032 }
1033
1034 if ( ! $webfinger ) {
1035 return '';
1036 }
1037
1038 // Generate HTML @ link.
1039 return \sprintf(
1040 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
1041 \esc_url( $url ),
1042 \esc_attr( $webfinger ),
1043 \esc_html( '@' . strtok( $webfinger, '@' ) )
1044 );
1045 }
1046
1047 /**
1048 * Add the stats image as an attachment when a post contains the stats block.
1049 *
1050 * Parses the post content for activitypub/stats blocks and appends each
1051 * as an Image attachment to the ActivityPub object.
1052 *
1053 * @since 8.1.0
1054 *
1055 * @param array $attachments The existing attachments.
1056 * @param \WP_Post $post The post object.
1057 *
1058 * @return array The attachments with stats images appended.
1059 */
1060 public static function add_stats_image_attachment( $attachments, $post ) {
1061 if ( ! Stats_Image::is_available() ) {
1062 return $attachments;
1063 }
1064
1065 /*
1066 * The stats image intentionally bypasses the `activitypub_max_image_attachments`
1067 * limit because it replaces the block content rather than being an inline image
1068 * extracted from the post. It is always appended so that the share-pic is
1069 * included in the federated activity regardless of the attachment cap.
1070 */
1071 $blocks = \parse_blocks( $post->post_content );
1072 $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
1073
1074 foreach ( $stats_blocks as $block ) {
1075 $user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
1076
1077 if ( null === $user_id ) {
1078 continue;
1079 }
1080
1081 $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
1082 $url = Stats_Image::get_url( $user_id, $year );
1083
1084 if ( \is_wp_error( $url ) ) {
1085 continue;
1086 }
1087
1088 // Determine mime type from URL extension.
1089 $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
1090
1091 $attachments[] = array(
1092 'type' => 'Image',
1093 'mediaType' => $mime_type,
1094 'url' => $url,
1095 'name' => \sprintf(
1096 /* translators: %d: The year */
1097 \__( 'Fediverse Stats %d', 'activitypub' ),
1098 $year
1099 ),
1100 );
1101 }
1102
1103 return $attachments;
1104 }
1105
1106 /**
1107 * Recursively find blocks of a given type in a block tree.
1108 *
1109 * @since 8.1.0
1110 *
1111 * @param array $blocks The parsed blocks.
1112 * @param string $block_name The block name to search for.
1113 *
1114 * @return array The matching blocks.
1115 */
1116 private static function find_blocks_recursive( $blocks, $block_name ) {
1117 $found = array();
1118
1119 foreach ( $blocks as $block ) {
1120 if ( $block_name === $block['blockName'] ) {
1121 $found[] = $block;
1122 }
1123
1124 if ( ! empty( $block['innerBlocks'] ) ) {
1125 $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
1126 }
1127 }
1128
1129 return $found;
1130 }
1131
1132 /**
1133 * Transform Embed blocks to block level link.
1134 *
1135 * Remote servers will simply drop iframe elements, rendering incomplete content.
1136 *
1137 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
1138 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
1139 *
1140 * @param string $block_content The block content (html).
1141 * @param object $block The block object.
1142 *
1143 * @return string A block level link
1144 */
1145 public static function revert_embed_links( $block_content, $block ) {
1146 if ( ! isset( $block['attrs']['url'] ) ) {
1147 return $block_content;
1148 }
1149 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1150 }
1151
1152 /**
1153 * Convert HTML content to blocks.
1154 *
1155 * Tokenizes the content with wp_html_split(), tracks nesting depth,
1156 * and wraps each top-level element in block comment delimiters.
1157 *
1158 * @since 8.1.0
1159 *
1160 * @param string $content The HTML content.
1161 *
1162 * @return string The content converted to blocks.
1163 */
1164 public static function convert_from_html( $content ) {
1165 if ( empty( $content ) ) {
1166 return '';
1167 }
1168
1169 $tokens = \wp_html_split( $content );
1170 $_content = '';
1171 $depth = 0;
1172 $current_tag = '';
1173 $current_html = '';
1174
1175 foreach ( $tokens as $token ) {
1176 if ( '' === $token ) {
1177 continue;
1178 }
1179
1180 // Text content — accumulate only inside a top-level element.
1181 if ( '<' !== $token[0] ) {
1182 if ( $depth > 0 ) {
1183 $current_html .= $token;
1184 }
1185 continue;
1186 }
1187
1188 // Closing tag.
1189 if ( '/' === $token[1] ) {
1190 $current_html .= $token;
1191 --$depth;
1192
1193 if ( 0 === $depth && '' !== $current_tag ) {
1194 $_content .= self::to_block( $current_tag, $current_html );
1195 $current_tag = '';
1196 $current_html = '';
1197 }
1198 continue;
1199 }
1200
1201 // Extract the tag name from the opening tag.
1202 if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
1203 if ( $depth > 0 ) {
1204 $current_html .= $token;
1205 }
1206 continue;
1207 }
1208
1209 $tag = \strtoupper( $m[1] );
1210
1211 // Start of a new top-level element.
1212 if ( 0 === $depth ) {
1213 $current_tag = $tag;
1214 $current_html = $token;
1215 } else {
1216 $current_html .= $token;
1217 }
1218
1219 // Void elements don't increase depth — flush immediately at top level.
1220 if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
1221 if ( 0 === $depth && '' !== $current_tag ) {
1222 $_content .= self::to_block( $current_tag, $current_html );
1223 $current_tag = '';
1224 $current_html = '';
1225 }
1226 } else {
1227 ++$depth;
1228 }
1229 }
1230
1231 return $_content;
1232 }
1233
1234 /**
1235 * Wrap an HTML element in block comment delimiters.
1236 *
1237 * @since 8.1.0
1238 *
1239 * @param string $tag The uppercase tag name.
1240 * @param string $html The element HTML.
1241 *
1242 * @return string The block-wrapped HTML, or empty string for skipped tags.
1243 */
1244 private static function to_block( $tag, $html ) {
1245 if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
1246 return '';
1247 }
1248
1249 $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
1250 $block_attrs = array();
1251
1252 if ( 'OL' === $tag ) {
1253 $block_attrs['ordered'] = true;
1254 }
1255
1256 return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
1257 }
1258
1259 /**
1260 * Filter the main query to exclude replies.
1261 *
1262 * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
1263 * block when the visitor has explicitly requested the "Posts" tab via
1264 * `?filter=posts`. This filters the main query so that Query Loop blocks
1265 * with `inherit: true` also pick up the filter.
1266 *
1267 * The filter only attaches on that explicit opt-in. Admin, feed, and any
1268 * regular frontend request (front page, archives, search…) are never
1269 * touched, which is why no block-presence probing is needed: the only
1270 * way `?filter=posts` appears in a URL is from a click on the
1271 * `activitypub/posts-and-replies` tab block.
1272 *
1273 * @since 8.1.0
1274 *
1275 * @param WP_Query $query The WP_Query instance.
1276 */
1277 public static function filter_query_loop_vars( $query ) {
1278 // Never touch admin or feed queries.
1279 if ( \is_admin() || $query->is_feed() ) {
1280 return;
1281 }
1282
1283 if ( ! $query->is_main_query() || $query->is_singular() ) {
1284 return;
1285 }
1286
1287 // Skip the reply-exclusion filter for queries that only target
1288 // non-ActivityPub post types to avoid a full table scan.
1289 $query_post_type = $query->get( 'post_type' );
1290 if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
1291 $query_post_types = (array) $query_post_type;
1292 if ( ! array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
1293 return;
1294 }
1295 }
1296
1297 // Only filter when the "Posts" tab has been explicitly selected.
1298 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1299 if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
1300 return;
1301 }
1302
1303 \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1304 }
1305
1306 /**
1307 * Exclude posts containing the activitypub/reply block.
1308 *
1309 * Removes itself after the first execution to avoid
1310 * affecting secondary queries on the same page.
1311 *
1312 * @since 8.1.0
1313 *
1314 * @param string $where The WHERE clause.
1315 * @return string Modified WHERE clause.
1316 */
1317 public static function exclude_replies_where( $where ) {
1318 \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1319
1320 global $wpdb;
1321
1322 $where .= $wpdb->prepare(
1323 " AND {$wpdb->posts}.post_content NOT LIKE %s",
1324 '%<!-- wp:activitypub/reply%'
1325 );
1326
1327 return $where;
1328 }
1329 }
1330