PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/class-blocks.php +46 -404 9.3.08.0.2 View file →
@@ -6,66 +6,15 @@
6 6 */
7 7
8 8 namespace Activitypub;
9 9
10 -use Activitypub\Cache\Stats_Image;
11 10 use Activitypub\Collection\Actors;
12 -use Activitypub\Collection\Followers;
13 -use Activitypub\Collection\Following;
14 -use Activitypub\Collection\Remote_Actors;
15 11
16 12 /**
17 13 * Block class.
18 14 */
19 15 class Blocks {
20 -
21 16 /**
22 - * HTML tags to skip during block conversion.
23 - *
24 - * @var array<string>
25 - */
26 - const SKIP_TAGS = array( 'BR', 'CITE', 'SOURCE' );
27 -
28 - /**
29 - * HTML void elements that have no closing tag.
30 - *
31 - * @var array<string>
32 - */
33 - const VOID_TAGS = array( 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR' );
34 -
35 - /**
36 - * Map of HTML tag names to WordPress block types.
37 - *
38 - * @var array<string, string>
39 - */
40 - const BLOCK_MAP = array(
41 - 'UL' => 'list',
42 - 'OL' => 'list',
43 - 'IMG' => 'image',
44 - 'BLOCKQUOTE' => 'quote',
45 - 'H1' => 'heading',
46 - 'H2' => 'heading',
47 - 'H3' => 'heading',
48 - 'H4' => 'heading',
49 - 'H5' => 'heading',
50 - 'H6' => 'heading',
51 - 'P' => 'paragraph',
52 - 'A' => 'paragraph',
53 - 'ABBR' => 'paragraph',
54 - 'B' => 'paragraph',
55 - 'CODE' => 'paragraph',
56 - 'EM' => 'paragraph',
57 - 'I' => 'paragraph',
58 - 'STRONG' => 'paragraph',
59 - 'SUB' => 'paragraph',
60 - 'SUP' => 'paragraph',
61 - 'SPAN' => 'paragraph',
62 - 'U' => 'paragraph',
63 - 'FIGURE' => 'image',
64 - 'HR' => 'separator',
65 - );
66 -
67 - /**
68 17 * Initialize the class, registering WordPress hooks.
69 18 */
70 19 public static function init() {
71 20 // This is already being called on the init hook, so just add it.
@@ -72,10 +21,8 @@
72 21 self::register_blocks();
73 22 self::register_patterns();
74 23 self::register_templates();
75 24
76 - \add_action( 'pre_get_posts', array( self::class, 'filter_query_loop_vars' ) );
77 -
78 25 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
79 26 // Add editor plugin.
80 27 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
81 28 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
@@ -80,9 +27,8 @@
80 27 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
81 28 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
82 29
83 30 \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 31
86 32 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
87 33 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
88 34 }
@@ -91,40 +37,39 @@
91 37 * Enqueue the block editor assets.
92 38 */
93 39 public static function enqueue_editor_assets() {
94 40 $data = array(
95 - 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
96 - 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
97 - 'enabled' => array(
41 + 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
42 + 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
43 + 'enabled' => array(
98 44 'blog' => ! is_user_type_disabled( 'blog' ),
99 45 'users' => ! is_user_type_disabled( 'user' ),
100 46 ),
101 - 'profileUrls' => array(
47 + 'profileUrls' => array(
102 48 'user' => \admin_url( 'profile.php#activitypub' ),
103 49 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
104 50 ),
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}' ) : '',
51 + 'showAvatars' => (bool) \get_option( 'show_avatars' ),
52 + 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
53 + 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
54 + 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
110 55 );
111 - \wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
56 + wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
112 57
113 58 // Check for our supported post types.
114 59 $current_screen = \get_current_screen();
115 60 $ap_post_types = \get_post_types_by_support( 'activitypub' );
116 - if ( ! $current_screen || ! \in_array( $current_screen->post_type, $ap_post_types, true ) ) {
61 + if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
117 62 return;
118 63 }
119 64
120 65 $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 );
66 + $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
67 + wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
123 68
124 69 $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 );
70 + $plugin_url = plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
71 + wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
127 72 }
128 73
129 74 /**
130 75 * Enqueue the reply handle script if the in_reply_to GET param is set.
@@ -136,10 +81,10 @@
136 81 return;
137 82 }
138 83
139 84 $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 );
85 + $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
86 + wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
142 87 }
143 88
144 89 /**
145 90 * Register the blocks.
@@ -147,10 +92,8 @@
147 92 public static function register_blocks() {
148 93 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
149 94 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
150 95 \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 96
154 97 // Only register the Following block if the Following feature is enabled.
155 98 if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
156 99 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/following' );
@@ -227,21 +170,9 @@
227 170 // Register each pattern.
228 171 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-header.php';
229 172 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-profile.php';
230 173 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/follow-page.php';
231 - require ACTIVITYPUB_PLUGIN_DIR . '/patterns/profile-page.php';
232 174 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 175 }
245 176
246 177 /**
247 178 * Register FSE templates for block themes.
@@ -264,9 +195,8 @@
264 195 <!-- wp:pattern {"slug":"activitypub/author-profile"} /-->
265 196 <!-- wp:spacer {"height":"32px"} -->
266 197 <div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>
267 198 <!-- /wp:spacer -->
268 - <!-- wp:activitypub/posts-and-replies /-->
269 199 <!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true}} -->
270 200 <div class="wp-block-query">
271 201 <!-- wp:post-template -->
272 202 <!-- wp:post-title {"isLink":true} /-->
@@ -291,9 +221,9 @@
291 221 * Register REST fields needed for blocks.
292 222 */
293 223 public static function register_rest_fields() {
294 224 // Register the post_count field for Follow Me block.
295 - \register_rest_field(
225 + register_rest_field(
296 226 'user',
297 227 'post_count',
298 228 array(
299 229 /**
@@ -304,9 +234,9 @@
304 234 * @param \WP_REST_Request $request The request object.
305 235 * @return int The number of published posts.
306 236 */
307 237 'get_callback' => static function ( $response, $field_name, $request ) {
308 - return (int) \count_user_posts( $request->get_param( 'id' ), 'post', true );
238 + return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
309 239 },
310 240 'schema' => array(
311 241 'description' => 'Number of published posts',
312 242 'type' => 'integer',
@@ -322,10 +252,10 @@
322 252 * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
323 253 * @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
324 254 */
325 255 public static function get_user_id( $user_string ) {
326 - if ( \is_numeric( $user_string ) ) {
327 - return \absint( $user_string );
256 + if ( is_numeric( $user_string ) ) {
257 + return absint( $user_string );
328 258 }
329 259
330 260 // If the user string is 'blog', return the Blog User ID.
331 261 if ( 'blog' === $user_string ) {
@@ -337,32 +267,32 @@
337 267 return null;
338 268 }
339 269
340 270 // 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' ) ) {
271 + if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
342 272 return Actors::BLOG_USER_ID;
343 273 }
344 274
345 275 // If we're in a loop, use the post author.
346 - $author_id = \get_the_author_meta( 'ID' );
276 + $author_id = get_the_author_meta( 'ID' );
347 277 if ( $author_id ) {
348 278 return $author_id;
349 279 }
350 280
351 281 // For other pages, the queried object will clue us in.
352 - $queried_object = \get_queried_object();
282 + $queried_object = get_queried_object();
353 283 if ( ! $queried_object ) {
354 284 return null;
355 285 }
356 286
357 287 // If we're on a user archive page, use that user's ID.
358 - if ( \is_a( $queried_object, 'WP_User' ) ) {
288 + if ( is_a( $queried_object, 'WP_User' ) ) {
359 289 return $queried_object->ID;
360 290 }
361 291
362 292 // 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' );
293 + if ( is_a( $queried_object, 'WP_Post' ) ) {
294 + return get_the_author_meta( 'ID' );
365 295 }
366 296
367 297 // We won't properly account for some conditions, like tag archives.
368 298 return null;
@@ -383,22 +313,18 @@
383 313 return '';
384 314 }
385 315
386 316 $attributes = \wp_parse_args( $attributes );
387 - $block_name = 'followers' === $endpoint ? \__( 'Followers', 'activitypub' ) : \__( 'Following', 'activitypub' );
317 + $block_name = 'followers' === $endpoint ? __( 'Followers', 'activitypub' ) : __( 'Following', 'activitypub' );
388 318
389 319 if ( empty( $content ) ) {
390 320 // Fallback for v1.0.0 blocks.
391 321 /* translators: %s: Block type (Followers or Following) */
392 - $_title = $attributes['title'] ?? \sprintf( \__( 'Fediverse %s', 'activitypub' ), $block_name );
322 + $_title = $attributes['title'] ?? \sprintf( __( 'Fediverse %s', 'activitypub' ), $block_name );
393 323 $content = '<h3 class="wp-block-heading">' . \esc_html( $_title ) . '</h3>';
394 324 unset( $attributes['title'], $attributes['className'] );
395 325 } else {
396 326 $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 327 }
402 328
403 329 $user_id = self::get_user_id( $attributes['selectedUser'] );
404 330 if ( \is_null( $user_id ) ) {
@@ -421,12 +347,12 @@
421 347 $_show_avatars = (bool) \get_option( 'show_avatars' );
422 348
423 349 // Query the appropriate collection.
424 350 if ( 'followers' === $endpoint ) {
425 - $data = Followers::query( $user_id, $_per_page );
351 + $data = \Activitypub\Collection\Followers::query( $user_id, $_per_page );
426 352 $items = $data['followers'];
427 353 } else {
428 - $data = Following::query( $user_id, $_per_page );
354 + $data = \Activitypub\Collection\Following::query( $user_id, $_per_page );
429 355 $items = $data['following'];
430 356 }
431 357
432 358 // Prepare items data for the Interactivity API context.
@@ -431,9 +357,9 @@
431 357
432 358 // Prepare items data for the Interactivity API context.
433 359 $prepared_items = \array_map(
434 360 static function ( $item ) {
435 - $actor = Remote_Actors::get_actor( $item );
361 + $actor = \Activitypub\Collection\Remote_Actors::get_actor( $item );
436 362
437 363 // Restrict URLs to http/https schemes to prevent XSS via javascript: URIs.
438 364 $url = object_to_uri( $actor->get_url() ) ?: $actor->get_id();
439 365
@@ -480,9 +406,9 @@
480 406 )
481 407 );
482 408
483 409 /* translators: %s: Block type (Followers or Following) */
484 - $nav_label = \sprintf( \__( '%s navigation', 'activitypub' ), $block_name );
410 + $nav_label = \sprintf( __( '%s navigation', 'activitypub' ), $block_name );
485 411
486 412 \ob_start();
487 413 ?>
488 414 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput ?>>
@@ -518,10 +444,10 @@
518 444 return $content;
519 445 }
520 446
521 447 $url = $attrs['url'];
522 - $shortcode = \trim( $content );
523 - $name = \trim( $shortcode, ':' );
448 + $shortcode = trim( $content );
449 + $name = trim( $shortcode, ':' );
524 450
525 451 /**
526 452 * Filters a remote media URL for caching.
527 453 *
@@ -680,28 +606,13 @@
680 606 if ( empty( $attrs['url'] ) ) {
681 607 return null;
682 608 }
683 609
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 610 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
700 611
701 - $wrapper_attrs = \get_block_wrapper_attributes(
612 + $wrapper_attrs = get_block_wrapper_attributes(
702 613 array(
703 - 'aria-label' => \__( 'Reply', 'activitypub' ),
614 + 'aria-label' => __( 'Reply', 'activitypub' ),
704 615 'class' => 'activitypub-reply-block',
705 616 'data-in-reply-to' => $attrs['url'],
706 617 )
707 618 );
@@ -712,9 +623,9 @@
712 623 $embed = null;
713 624 if ( $show_embed ) {
714 625 // Use the theme's content width or a reasonable default to avoid narrow embeds.
715 626 $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
716 - $embed = \wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
627 + $embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
717 628 if ( $embed ) {
718 629 $html .= $embed;
719 630 \wp_enqueue_script( 'wp-embed' );
720 631 }
@@ -721,14 +632,14 @@
721 632 }
722 633
723 634 // Show the link if embed is not requested or if embed failed.
724 635 if ( ! $show_embed || ! $embed ) {
725 - $html .= \sprintf(
636 + $html .= sprintf(
726 637 '<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' ),
638 + esc_url( $attrs['url'] ),
639 + esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
729 640 // 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'] ) ) )
641 + sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
731 642 );
732 643 }
733 644
734 645 $html .= '</div>';
@@ -838,9 +749,9 @@
838 749 'show_avatars' => true,
839 750 'show_pagination' => true,
840 751 'total' => 0,
841 752 'per_page' => 10,
842 - 'nav_label' => \__( 'Actor navigation', 'activitypub' ),
753 + 'nav_label' => __( 'Actor navigation', 'activitypub' ),
843 754 );
844 755
845 756 $args = \wp_parse_args( $args, $defaults );
846 757
@@ -891,9 +802,9 @@
891 802 <a
892 803 href="#"
893 804 role="button"
894 805 class="pagination-previous"
895 - data-wp-on--click="actions.previousPage"
806 + data-wp-on-async--click="actions.previousPage"
896 807 data-wp-bind--aria-disabled="state.disablePreviousLink"
897 808 aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
898 809 >
899 810 <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
@@ -904,9 +815,9 @@
904 815 <a
905 816 href="#"
906 817 role="button"
907 818 class="pagination-next"
908 - data-wp-on--click="actions.nextPage"
819 + data-wp-on-async--click="actions.nextPage"
909 820 data-wp-bind--aria-disabled="state.disableNextLink"
910 821 aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
911 822 >
912 823 <?php \esc_html_e( 'Next', 'activitypub' ); ?>
@@ -982,9 +893,8 @@
982 893 * @param object $post The post object.
983 894 */
984 895 public static function add_post_transformation_callbacks( $post ) {
985 896 \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 897
988 898 // Only transform reply link if it's the first block in the post.
989 899 $blocks = \parse_blocks( $post->post_content );
990 900 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
@@ -1001,9 +911,8 @@
1001 911 */
1002 912 public static function remove_post_transformation_callbacks( $content ) {
1003 913 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
1004 914 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
1005 - \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
1006 915
1007 916 return $content;
1008 917 }
1009 918
@@ -1061,98 +970,13 @@
1061 970 return \sprintf(
1062 971 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
1063 972 \esc_url( $url ),
1064 973 \esc_attr( $webfinger ),
1065 - \esc_html( '@' . \strtok( $webfinger, '@' ) )
974 + \esc_html( '@' . strtok( $webfinger, '@' ) )
1066 975 );
1067 976 }
1068 977
1069 978 /**
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 979 * Transform Embed blocks to block level link.
1156 980 *
1157 981 * Remote servers will simply drop iframe elements, rendering incomplete content.
1158 982 *
@@ -1167,189 +991,7 @@
1167 991 public static function revert_embed_links( $block_content, $block ) {
1168 992 if ( ! isset( $block['attrs']['url'] ) ) {
1169 993 return $block_content;
1170 994 }
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;
995 + return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1354 996 }
1355 997 }