PluginProbe
ActivityPub / 3.2.2
ActivityPub v3.2.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 +120 -1263 9.0.03.2.2 View file →
@@ -1,135 +1,27 @@
1 1 <?php
2 -/**
3 - * Blocks file.
4 - *
5 - * @package Activitypub
6 - */
2 +namespace Activitypub;
7 3
8 -namespace Activitypub;
4 +use Activitypub\Collection\Followers;
5 +use Activitypub\Collection\Users as User_Collection;
9 6
10 -use Activitypub\Cache\Stats_Image;
11 -use Activitypub\Collection\Actors;
7 +use function Activitypub\object_to_uri;
8 +use function Activitypub\is_user_type_disabled;
12 9
13 -/**
14 - * Block class.
15 - */
16 10 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 11 public static function init() {
68 - // This is already being called on the init hook, so just add it.
12 + // this is already being called on the init hook, so just add it.
69 13 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 -
14 + \add_action( 'wp_enqueue_scripts', array( self::class, 'add_data' ) );
15 + \add_action( 'enqueue_block_editor_assets', array( self::class, 'add_data' ) );
75 16 \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 17 }
86 18
87 19 /**
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 20 * Enqueue the reply handle script if the in_reply_to GET param is set.
128 21 */
129 22 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
23 + // only load the script if the in_reply_to GET param is set, action happens there, not here.
132 24 if ( ! isset( $_GET['in_reply_to'] ) ) {
133 25 return;
134 26 }
135 27
@@ -137,1208 +29,173 @@
137 29 $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
138 30 wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
139 31 }
140 32
141 - /**
142 - * Register the blocks.
143 - */
33 + public static function add_data() {
34 + $context = is_admin() ? 'editor' : 'view';
35 + $followers_handle = 'activitypub-followers-' . $context . '-script';
36 + $follow_me_handle = 'activitypub-follow-me-' . $context . '-script';
37 + $data = array(
38 + 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
39 + 'enabled' => array(
40 + 'site' => ! is_user_type_disabled( 'blog' ),
41 + 'users' => ! is_user_type_disabled( 'user' ),
42 + ),
43 + );
44 + $js = sprintf( 'var _activityPubOptions = %s;', wp_json_encode( $data ) );
45 + \wp_add_inline_script( $followers_handle, $js, 'before' );
46 + \wp_add_inline_script( $follow_me_handle, $js, 'before' );
47 + }
48 +
144 49 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 50 \register_block_type_from_metadata(
163 - ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
51 + ACTIVITYPUB_PLUGIN_DIR . '/build/followers',
164 52 array(
165 - 'render_callback' => array( self::class, 'render_reply_block' ),
53 + 'render_callback' => array( self::class, 'render_follower_block' ),
166 54 )
167 55 );
168 -
169 - // Register remote media blocks (server-side only, no editor UI).
170 - \register_block_type(
171 - 'activitypub/emoji',
56 + \register_block_type_from_metadata(
57 + ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me',
172 58 array(
173 - 'attributes' => array(
174 - 'url' => array( 'type' => 'string' ),
175 - 'updated' => array( 'type' => 'string' ),
176 - ),
177 - 'render_callback' => array( self::class, 'render_emoji_block' ),
59 + 'render_callback' => array( self::class, 'render_follow_me_block' ),
178 60 )
179 61 );
180 -
181 - \register_block_type(
182 - 'activitypub/image',
62 + \register_block_type_from_metadata(
63 + ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
183 64 array(
184 - 'attributes' => array(
185 - 'url' => array( 'type' => 'string' ),
186 - ),
187 - 'render_callback' => array( self::class, 'render_image_block' ),
65 + 'render_callback' => array( self::class, 'render_reply_block' ),
188 66 )
189 67 );
68 + }
190 69
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 - );
70 + private static function get_user_id( $user_string ) {
71 + if ( is_numeric( $user_string ) ) {
72 + return absint( $user_string );
73 + }
74 + // any other non-numeric falls back to 0, including the `site` string used in the UI
75 + return 0;
210 76 }
211 77
212 78 /**
213 - * Register block patterns for ActivityPub.
79 + * Filter an array by a list of keys.
80 + * @param array $array The array to filter.
81 + * @param array $keys The keys to keep.
82 + * @return array The filtered array.
214 83 */
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 - }
84 + protected static function filter_array_by_keys( $array, $keys ) {
85 + return array_intersect_key( $array, array_flip( $keys ) );
241 86 }
242 87
243 88 /**
244 - * Register FSE templates for block themes.
89 + * Render the follow me block.
90 + * @param array $attrs The block attributes.
91 + * @return string The HTML to render.
245 92 */
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;
93 + public static function render_follow_me_block( $attrs ) {
94 + $user_id = self::get_user_id( $attrs['selectedUser'] );
95 + $user = User_Collection::get_by_id( $user_id );
96 + if ( ! is_wp_error( $user ) ) {
97 + $attrs['profileData'] = self::filter_array_by_keys(
98 + $user->to_array(),
99 + array( 'icon', 'name', 'webfinger' )
100 + );
250 101 }
251 102
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 - }
103 + // add `@` prefix if it's missing
104 + if ( '@' !== substr( $attrs['profileData']['webfinger'], 0, 1 ) ) {
105 + $attrs['profileData']['webfinger'] = '@' . $attrs['profileData']['webfinger'];
106 + }
286 107
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',
108 + $wrapper_attributes = get_block_wrapper_attributes(
295 109 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 - ),
110 + 'aria-label' => __( 'Follow me on the Fediverse', 'activitypub' ),
111 + 'class' => 'activitypub-follow-me-block-wrapper',
112 + 'data-attrs' => wp_json_encode( $attrs ),
312 113 )
313 114 );
115 + // todo: render more than an empty div?
116 + return '<div ' . $wrapper_attributes . '></div>';
314 117 }
315 118
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 - }
119 + public static function render_follower_block( $attrs ) {
120 + $followee_user_id = self::get_user_id( $attrs['selectedUser'] );
121 + $per_page = absint( $attrs['per_page'] );
122 + $follower_data = Followers::get_followers_with_count( $followee_user_id, $per_page );
326 123
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' ) ),
124 + $attrs['followerData']['total'] = $follower_data['total'];
125 + $attrs['followerData']['followers'] = array_map(
126 + function ( $follower ) {
127 + return self::filter_array_by_keys(
128 + $follower->to_array(),
129 + array( 'icon', 'name', 'preferredUsername', 'url' )
438 130 );
439 131 },
440 - $items
132 + $follower_data['followers']
441 133 );
442 -
443 - $store_name = 'activitypub/' . $endpoint;
444 -
445 - // Set up the Interactivity API config.
446 - \wp_interactivity_config(
447 - $store_name,
134 + $wrapper_attributes = get_block_wrapper_attributes(
448 135 array(
449 - 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
450 - 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
136 + 'aria-label' => __( 'Fediverse Followers', 'activitypub' ),
137 + 'class' => 'activitypub-follower-block',
138 + 'data-attrs' => wp_json_encode( $attrs ),
451 139 )
452 140 );
453 141
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;
142 + $html = '<div ' . $wrapper_attributes . '>';
143 + if ( $attrs['title'] ) {
144 + $html .= '<h3>' . esc_html( $attrs['title'] ) . '</h3>';
512 145 }
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;
146 + $html .= '<ul>';
147 + foreach ( $follower_data['followers'] as $follower ) {
148 + $html .= '<li>' . self::render_follower( $follower ) . '</li>';
550 149 }
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;
150 + // We are only pagination on the JS side. Could be revisited but we gotta ship!
151 + $html .= '</ul></div>';
152 + return $html;
576 153 }
577 154
578 155 /**
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 156 * Render the reply block.
662 157 *
663 158 * @param array $attrs The block attributes.
664 159 *
665 - * @return string The HTML to render.
160 + * @return void
666 161 */
667 162 public static function render_reply_block( $attrs ) {
668 - if ( is_activitypub_request() ) {
669 - $attrs['embedPost'] = false;
670 - }
671 -
672 - // Return early if no URL is provided.
673 - if ( empty( $attrs['url'] ) ) {
674 - return null;
675 - }
676 -
677 - /*
678 - * In feed contexts (RSS, Atom, and anything else WordPress treats as a feed) the styled
679 - * embed card depends on plugin CSS that isn't loaded, so it degrades to an unreadable
680 - * wall of text. Substitute the same simplified mention link the federation path uses,
681 - * and if the remote lookup fails fall through to the plain `<a class="u-in-reply-to">`
682 - * link below so the feed item still surfaces *some* indication that it's a reply.
683 - */
684 - if ( \is_feed() ) {
685 - $mention = self::generate_reply_link( '', array( 'attrs' => $attrs ) );
686 - if ( ! empty( $mention ) ) {
687 - return $mention;
688 - }
689 - $attrs['embedPost'] = false;
690 - }
691 -
692 - $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
693 -
694 - $wrapper_attrs = get_block_wrapper_attributes(
695 - array(
696 - 'aria-label' => __( 'Reply', 'activitypub' ),
697 - 'class' => 'activitypub-reply-block',
698 - 'data-in-reply-to' => $attrs['url'],
699 - )
700 - );
701 -
702 - $html = '<div ' . $wrapper_attrs . '>';
703 -
704 - // Try to get and append the embed if requested.
705 - $embed = null;
706 - if ( $show_embed ) {
707 - // Use the theme's content width or a reasonable default to avoid narrow embeds.
708 - $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
709 - $embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
710 - if ( $embed ) {
711 - $html .= $embed;
712 - \wp_enqueue_script( 'wp-embed' );
713 - }
714 - }
715 -
716 - // Show the link if embed is not requested or if embed failed.
717 - if ( ! $show_embed || ! $embed ) {
718 - $html .= sprintf(
719 - '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
163 + return apply_filters(
164 + 'activitypub_reply_block',
165 + sprintf(
166 + '<p><a title="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
720 167 esc_url( $attrs['url'] ),
721 168 esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
722 169 // translators: %s is the URL of the post being replied to.
723 - sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
724 - );
725 - }
726 -
727 - $html .= '</div>';
728 -
729 - return $html;
730 - }
731 -
732 - /**
733 - * Renders a modal component that can be used by different blocks.
734 - *
735 - * @param array $args {
736 - * Arguments for the modal.
737 - *
738 - * @type string $content The modal content HTML.
739 - * @type string $id Optional ID prefix for the modal elements.
740 - * @type bool $is_compact Whether the modal is compact (popover-style). Default false.
741 - * @type string $title Static title text for the modal header.
742 - * @type string $title_binding Optional Interactivity API binding for a dynamic title
743 - * (e.g. 'context.modal.title'). When set, uses data-wp-text
744 - * on the title element and enables dynamic compact toggling.
745 - * }
746 - */
747 - public static function render_modal( $args = array() ) {
748 - $defaults = array(
749 - 'content' => '',
750 - 'id' => '',
751 - 'is_compact' => false,
752 - 'title' => '',
753 - 'title_binding' => '',
170 + sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', $attrs['url'] ) )
171 + ),
172 + $attrs
754 173 );
755 -
756 - $args = \wp_parse_args( $args, $defaults );
757 - ?>
758 -
759 - <div
760 - class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
761 - data-wp-bind--hidden="!context.modal.isOpen"
762 - data-wp-watch="callbacks.handleModalEffects"
763 - <?php if ( ! empty( $args['title_binding'] ) ) : ?>
764 - data-wp-class--compact="context.modal.isCompact"
765 - <?php endif; ?>
766 - role="dialog"
767 - aria-modal="true"
768 - hidden
769 - >
770 - <div class="activitypub-modal__frame">
771 - <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) || ! empty( $args['title_binding'] ) ) : ?>
772 - <div class="activitypub-modal__header">
773 - <h2
774 - class="activitypub-modal__title"
775 - <?php if ( ! empty( $args['id'] ) ) : ?>
776 - id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
777 - <?php endif; ?>
778 - <?php if ( ! empty( $args['title_binding'] ) ) : ?>
779 - data-wp-text="<?php echo \esc_attr( $args['title_binding'] ); ?>"
780 - <?php endif; ?>
781 - ><?php echo \esc_html( $args['title'] ); ?></h2>
782 - <button
783 - type="button"
784 - class="activitypub-modal__close wp-element-button"
785 - data-wp-on--click="actions.closeModal"
786 - aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
787 - >
788 - <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
789 - <path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z"></path>
790 - </svg>
791 - </button>
792 - </div>
793 - <?php endif; ?>
794 - <div class="activitypub-modal__content">
795 - <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
796 - </div>
797 - </div>
798 - </div>
799 - <?php
800 174 }
801 175
802 - /**
803 - * Renders a help section explaining the Fediverse inside modal dialogs.
804 - *
805 - * Outputs a collapsible `<details>` element that explains decentralized
806 - * interactions to users unfamiliar with the Fediverse.
807 - *
808 - * @since 8.0.0
809 - */
810 - public static function render_modal_help() {
811 - ?>
812 - <details class="activitypub-dialog__help">
813 - <summary><?php \esc_html_e( 'Why do I need to enter my profile?', 'activitypub' ); ?></summary>
814 - <p>
815 - <?php \esc_html_e( 'This site is part of the ⁂ open social web, a network of interconnected social platforms (like Mastodon, Pixelfed, Friendica, and others). Unlike centralized social media, your account lives on a platform of your choice, and you can interact with people across different platforms.', 'activitypub' ); ?>
816 - </p>
817 - <p>
818 - <?php \esc_html_e( 'By entering your profile, we can send you to your account where you can complete this action.', 'activitypub' ); ?>
819 - </p>
820 - </details>
821 - <?php
822 - }
176 + public static function render_follower( $follower ) {
177 + $external_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="components-external-link__icon css-rvs7bx esh4a730" aria-hidden="true" focusable="false"><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></svg>';
178 + $template =
179 + '<a href="%s" title="%s" class="components-external-link activitypub-link" target="_blank" rel="external noreferrer noopener">
180 + <img width="40" height="40" src="%s" class="avatar activitypub-avatar" />
181 + <span class="activitypub-actor">
182 + <strong class="activitypub-name">%s</strong>
183 + <span class="sep">/</span>
184 + <span class="activitypub-handle">@%s</span>
185 + </span>
186 + %s
187 + </a>';
823 188
824 - /**
825 - * Renders an actor list component that can be used by different blocks.
826 - *
827 - * @param array $args Arguments for the actor list.
828 - */
829 - public static function render_actor_list( $args = array() ) {
830 - $defaults = array(
831 - 'show_avatars' => true,
832 - 'show_pagination' => true,
833 - 'total' => 0,
834 - 'per_page' => 10,
835 - 'nav_label' => __( 'Actor navigation', 'activitypub' ),
836 - );
189 + $data = $follower->to_array();
837 190
838 - $args = \wp_parse_args( $args, $defaults );
839 -
840 - // Sanitize numeric values, ensuring per_page is at least 1 to avoid division by zero.
841 - $args['total'] = \absint( $args['total'] );
842 - $args['per_page'] = \max( 1, \absint( $args['per_page'] ) );
843 - ?>
844 -
845 - <div class="activitypub-actor-list-container">
846 - <ul class="activitypub-actor-list">
847 - <template data-wp-each="context.items">
848 - <li class="activitypub-actor-item">
849 - <a href="#"
850 - data-wp-bind--href="context.item.url"
851 - class="activitypub-actor-link"
852 - target="_blank"
853 - rel="external noreferrer noopener"
854 - data-wp-bind--title="context.item.handle">
855 -
856 - <?php if ( $args['show_avatars'] ) : ?>
857 - <img
858 - data-wp-bind--src="context.item.icon.url"
859 - data-wp-on--error="callbacks.setDefaultAvatar"
860 - src=""
861 - alt=""
862 - class="activitypub-actor-avatar"
863 - width="48"
864 - height="48"
865 - >
866 - <?php endif; ?>
867 -
868 - <div class="activitypub-actor-info">
869 - <span class="activitypub-actor-name" data-wp-text="context.item.name"></span>
870 - <span class="activitypub-actor-handle" data-wp-text="context.item.handle"></span>
871 - </div>
872 -
873 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="external-link-icon" aria-hidden="true" focusable="false" fill="currentColor">
874 - <path d="M18.2 17c0 .7-.6 1.2-1.2 1.2H7c-.7 0-1.2-.6-1.2-1.2V7c0-.7.6-1.2 1.2-1.2h3.2V4.2H7C5.5 4.2 4.2 5.5 4.2 7v10c0 1.5 1.2 2.8 2.8 2.8h10c1.5 0 2.8-1.2 2.8-2.8v-3.6h-1.5V17zM14.9 3v1.5h3.7l-6.4 6.4 1.1 1.1 6.4-6.4v3.7h1.5V3h-6.3z"></path>
875 - </svg>
876 - </a>
877 - </li>
878 - </template>
879 - </ul>
880 -
881 - <?php if ( $args['show_pagination'] && $args['total'] > $args['per_page'] ) : ?>
882 - <nav class="activitypub-actor-list-pagination" role="navigation">
883 - <h1 class="screen-reader-text"><?php echo \esc_html( $args['nav_label'] ); ?></h1>
884 - <a
885 - href="#"
886 - role="button"
887 - class="pagination-previous"
888 - data-wp-on--click="actions.previousPage"
889 - data-wp-bind--aria-disabled="state.disablePreviousLink"
890 - aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
891 - >
892 - <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
893 - </a>
894 -
895 - <div class="pagination-info" data-wp-text="state.paginationText"></div>
896 -
897 - <a
898 - href="#"
899 - role="button"
900 - class="pagination-next"
901 - data-wp-on--click="actions.nextPage"
902 - data-wp-bind--aria-disabled="state.disableNextLink"
903 - aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
904 - >
905 - <?php \esc_html_e( 'Next', 'activitypub' ); ?>
906 - </a>
907 - </nav>
908 -
909 - <div class="activitypub-actor-list-loading" data-wp-bind--aria-hidden="!context.isLoading">
910 - <div class="loading-spinner"></div>
911 - </div>
912 - <?php endif; ?>
913 - </div>
914 - <?php
915 - }
916 -
917 - /**
918 - * Converts content to blocks before saving to the database.
919 - *
920 - * @param array $data The post data to be inserted.
921 - * @param array $post The Mastodon Create activity.
922 - *
923 - * @return array
924 - */
925 - public static function filter_import_mastodon_post_data( $data, $post ) {
926 - // Convert paragraphs to blocks.
927 - \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
928 - $blocks = \array_map(
929 - static function ( $paragraph ) {
930 - return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
931 - },
932 - $matches[0] ?? array()
191 + return sprintf(
192 + $template,
193 + esc_url( object_to_uri( $data['url'] ) ),
194 + esc_attr( $data['name'] ),
195 + esc_attr( $data['icon']['url'] ),
196 + esc_html( $data['name'] ),
197 + esc_html( $data['preferredUsername'] ),
198 + $external_svg
933 199 );
934 -
935 - $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
936 -
937 - // Add reply block if it's a reply.
938 - if ( ! empty( $post['object']['inReplyTo'] ) ) {
939 - $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
940 - $data['post_content'] = $reply_block . $data['post_content'];
941 - }
942 -
943 - return $data;
944 - }
945 -
946 - /**
947 - * Add Interactivity directions to the specified element.
948 - *
949 - * @param string $content The block content.
950 - * @param string[] $selector The selector for the element to add directions to.
951 - * @param string[] $attributes The attributes to add to the element.
952 - *
953 - * @return string The updated content.
954 - */
955 - public static function add_directions( $content, $selector, $attributes ) {
956 - $tags = new \WP_HTML_Tag_Processor( $content );
957 -
958 - while ( $tags->next_tag( $selector ) ) {
959 - foreach ( $attributes as $key => $value ) {
960 - if ( 'class' === $key ) {
961 - $tags->add_class( $value );
962 - continue;
963 - }
964 -
965 - $tags->set_attribute( $key, $value );
966 - }
967 - }
968 -
969 - return $tags->get_updated_html();
970 - }
971 -
972 - /**
973 - * Add post transformation callbacks.
974 - *
975 - * @param object $post The post object.
976 - */
977 - public static function add_post_transformation_callbacks( $post ) {
978 - \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
979 - \add_filter( 'render_block_activitypub/stats', '__return_empty_string' );
980 -
981 - // Only transform reply link if it's the first block in the post.
982 - $blocks = \parse_blocks( $post->post_content );
983 - if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
984 - \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
985 - }
986 - }
987 -
988 - /**
989 - * Remove post transformation callbacks.
990 - *
991 - * @param string $content The post content.
992 - *
993 - * @return string The updated content.
994 - */
995 - public static function remove_post_transformation_callbacks( $content ) {
996 - \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
997 - \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
998 - \remove_filter( 'render_block_activitypub/stats', '__return_empty_string' );
999 -
1000 - return $content;
1001 - }
1002 -
1003 - /**
1004 - * Generate HTML @ link for reply block.
1005 - *
1006 - * @param string $block_content The block content.
1007 - * @param array $block The block data.
1008 - *
1009 - * @return string The HTML @ link.
1010 - */
1011 - public static function generate_reply_link( $block_content, $block ) {
1012 - // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
1013 - \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
1014 -
1015 - // Return empty string if no URL is provided.
1016 - if ( empty( $block['attrs']['url'] ) ) {
1017 - return '';
1018 - }
1019 -
1020 - $url = $block['attrs']['url'];
1021 -
1022 - // Try to get ActivityPub representation. Is likely already cached.
1023 - $object = Http::get_remote_object( $url );
1024 - if ( \is_wp_error( $object ) ) {
1025 - return '';
1026 - }
1027 -
1028 - $author_url = $object['attributedTo'] ?? '';
1029 - if ( ! $author_url ) {
1030 - return '';
1031 - }
1032 -
1033 - // Fetch author information.
1034 - $author = Http::get_remote_object( $author_url );
1035 - if ( \is_wp_error( $author ) ) {
1036 - return '';
1037 - }
1038 -
1039 - // Get webfinger identifier.
1040 - $webfinger = '';
1041 - if ( ! empty( $author['webfinger'] ) ) {
1042 - $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
1043 - } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
1044 - // Construct webfinger-style identifier from username and domain.
1045 - $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
1046 - $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
1047 - }
1048 -
1049 - if ( ! $webfinger ) {
1050 - return '';
1051 - }
1052 -
1053 - // Generate HTML @ link.
1054 - return \sprintf(
1055 - '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
1056 - \esc_url( $url ),
1057 - \esc_attr( $webfinger ),
1058 - \esc_html( '@' . strtok( $webfinger, '@' ) )
1059 - );
1060 - }
1061 -
1062 - /**
1063 - * Add the stats image as an attachment when a post contains the stats block.
1064 - *
1065 - * Parses the post content for activitypub/stats blocks and appends each
1066 - * as an Image attachment to the ActivityPub object.
1067 - *
1068 - * @since 8.1.0
1069 - *
1070 - * @param array $attachments The existing attachments.
1071 - * @param \WP_Post $post The post object.
1072 - *
1073 - * @return array The attachments with stats images appended.
1074 - */
1075 - public static function add_stats_image_attachment( $attachments, $post ) {
1076 - if ( ! Stats_Image::is_available() ) {
1077 - return $attachments;
1078 - }
1079 -
1080 - /*
1081 - * The stats image intentionally bypasses the `activitypub_max_image_attachments`
1082 - * limit because it replaces the block content rather than being an inline image
1083 - * extracted from the post. It is always appended so that the share-pic is
1084 - * included in the federated activity regardless of the attachment cap.
1085 - */
1086 - $blocks = \parse_blocks( $post->post_content );
1087 - $stats_blocks = self::find_blocks_recursive( $blocks, 'activitypub/stats' );
1088 -
1089 - foreach ( $stats_blocks as $block ) {
1090 - $user_id = self::get_user_id( $block['attrs']['selectedUser'] ?? 'blog' );
1091 -
1092 - if ( null === $user_id ) {
1093 - continue;
1094 - }
1095 -
1096 - $year = (int) ( $block['attrs']['year'] ?? (int) \gmdate( 'Y' ) - 1 );
1097 - $url = Stats_Image::get_url( $user_id, $year );
1098 -
1099 - if ( \is_wp_error( $url ) ) {
1100 - continue;
1101 - }
1102 -
1103 - // Determine mime type from URL extension.
1104 - $mime_type = \str_ends_with( $url, '.webp' ) ? 'image/webp' : 'image/png';
1105 -
1106 - $attachments[] = array(
1107 - 'type' => 'Image',
1108 - 'mediaType' => $mime_type,
1109 - 'url' => $url,
1110 - 'name' => \sprintf(
1111 - /* translators: %d: The year */
1112 - \__( 'Fediverse Stats %d', 'activitypub' ),
1113 - $year
1114 - ),
1115 - );
1116 - }
1117 -
1118 - return $attachments;
1119 - }
1120 -
1121 - /**
1122 - * Recursively find blocks of a given type in a block tree.
1123 - *
1124 - * @since 8.1.0
1125 - *
1126 - * @param array $blocks The parsed blocks.
1127 - * @param string $block_name The block name to search for.
1128 - *
1129 - * @return array The matching blocks.
1130 - */
1131 - private static function find_blocks_recursive( $blocks, $block_name ) {
1132 - $found = array();
1133 -
1134 - foreach ( $blocks as $block ) {
1135 - if ( $block_name === $block['blockName'] ) {
1136 - $found[] = $block;
1137 - }
1138 -
1139 - if ( ! empty( $block['innerBlocks'] ) ) {
1140 - $found = \array_merge( $found, self::find_blocks_recursive( $block['innerBlocks'], $block_name ) );
1141 - }
1142 - }
1143 -
1144 - return $found;
1145 - }
1146 -
1147 - /**
1148 - * Transform Embed blocks to block level link.
1149 - *
1150 - * Remote servers will simply drop iframe elements, rendering incomplete content.
1151 - *
1152 - * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
1153 - * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
1154 - *
1155 - * @param string $block_content The block content (html).
1156 - * @param object $block The block object.
1157 - *
1158 - * @return string A block level link
1159 - */
1160 - public static function revert_embed_links( $block_content, $block ) {
1161 - if ( ! isset( $block['attrs']['url'] ) ) {
1162 - return $block_content;
1163 - }
1164 - return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
1165 - }
1166 -
1167 - /**
1168 - * Convert HTML content to blocks.
1169 - *
1170 - * Tokenizes the content with wp_html_split(), tracks nesting depth,
1171 - * and wraps each top-level element in block comment delimiters.
1172 - *
1173 - * @since 8.1.0
1174 - *
1175 - * @param string $content The HTML content.
1176 - *
1177 - * @return string The content converted to blocks.
1178 - */
1179 - public static function convert_from_html( $content ) {
1180 - if ( empty( $content ) ) {
1181 - return '';
1182 - }
1183 -
1184 - $tokens = \wp_html_split( $content );
1185 - $_content = '';
1186 - $depth = 0;
1187 - $current_tag = '';
1188 - $current_html = '';
1189 -
1190 - foreach ( $tokens as $token ) {
1191 - if ( '' === $token ) {
1192 - continue;
1193 - }
1194 -
1195 - // Text content — accumulate only inside a top-level element.
1196 - if ( '<' !== $token[0] ) {
1197 - if ( $depth > 0 ) {
1198 - $current_html .= $token;
1199 - }
1200 - continue;
1201 - }
1202 -
1203 - // Closing tag.
1204 - if ( '/' === $token[1] ) {
1205 - $current_html .= $token;
1206 - --$depth;
1207 -
1208 - if ( 0 === $depth && '' !== $current_tag ) {
1209 - $_content .= self::to_block( $current_tag, $current_html );
1210 - $current_tag = '';
1211 - $current_html = '';
1212 - }
1213 - continue;
1214 - }
1215 -
1216 - // Extract the tag name from the opening tag.
1217 - if ( ! \preg_match( '/^<([a-zA-Z][a-zA-Z0-9]*)/', $token, $m ) ) {
1218 - if ( $depth > 0 ) {
1219 - $current_html .= $token;
1220 - }
1221 - continue;
1222 - }
1223 -
1224 - $tag = \strtoupper( $m[1] );
1225 -
1226 - // Start of a new top-level element.
1227 - if ( 0 === $depth ) {
1228 - $current_tag = $tag;
1229 - $current_html = $token;
1230 - } else {
1231 - $current_html .= $token;
1232 - }
1233 -
1234 - // Void elements don't increase depth — flush immediately at top level.
1235 - if ( \in_array( $tag, self::VOID_TAGS, true ) ) {
1236 - if ( 0 === $depth && '' !== $current_tag ) {
1237 - $_content .= self::to_block( $current_tag, $current_html );
1238 - $current_tag = '';
1239 - $current_html = '';
1240 - }
1241 - } else {
1242 - ++$depth;
1243 - }
1244 - }
1245 -
1246 - return $_content;
1247 - }
1248 -
1249 - /**
1250 - * Wrap an HTML element in block comment delimiters.
1251 - *
1252 - * @since 8.1.0
1253 - *
1254 - * @param string $tag The uppercase tag name.
1255 - * @param string $html The element HTML.
1256 - *
1257 - * @return string The block-wrapped HTML, or empty string for skipped tags.
1258 - */
1259 - private static function to_block( $tag, $html ) {
1260 - if ( \in_array( $tag, self::SKIP_TAGS, true ) ) {
1261 - return '';
1262 - }
1263 -
1264 - $block_type = self::BLOCK_MAP[ $tag ] ?? 'html';
1265 - $block_attrs = array();
1266 -
1267 - if ( 'OL' === $tag ) {
1268 - $block_attrs['ordered'] = true;
1269 - }
1270 -
1271 - return \get_comment_delimited_block_content( $block_type, $block_attrs, \trim( $html ) );
1272 - }
1273 -
1274 - /**
1275 - * Filter the main query to exclude replies.
1276 - *
1277 - * Adds a WHERE clause to exclude posts containing the `activitypub/reply`
1278 - * block when the visitor has explicitly requested the "Posts" tab via
1279 - * `?filter=posts`. This filters the main query so that Query Loop blocks
1280 - * with `inherit: true` also pick up the filter.
1281 - *
1282 - * The filter only attaches on that explicit opt-in. Admin, feed, and any
1283 - * regular frontend request (front page, archives, search…) are never
1284 - * touched, which is why no block-presence probing is needed: the only
1285 - * way `?filter=posts` appears in a URL is from a click on the
1286 - * `activitypub/posts-and-replies` tab block.
1287 - *
1288 - * @since 8.1.0
1289 - *
1290 - * @param WP_Query $query The WP_Query instance.
1291 - */
1292 - public static function filter_query_loop_vars( $query ) {
1293 - // Never touch admin or feed queries.
1294 - if ( \is_admin() || $query->is_feed() ) {
1295 - return;
1296 - }
1297 -
1298 - if ( ! $query->is_main_query() || $query->is_singular() ) {
1299 - return;
1300 - }
1301 -
1302 - // Skip the reply-exclusion filter for queries that only target
1303 - // non-ActivityPub post types to avoid a full table scan.
1304 - $query_post_type = $query->get( 'post_type' );
1305 - if ( ! empty( $query_post_type ) && 'any' !== $query_post_type ) {
1306 - $query_post_types = (array) $query_post_type;
1307 - if ( ! array_intersect( $query_post_types, \get_post_types_by_support( 'activitypub' ) ) ) {
1308 - return;
1309 - }
1310 - }
1311 -
1312 - // Only filter when the "Posts" tab has been explicitly selected.
1313 - // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1314 - if ( ! isset( $_GET['filter'] ) || 'posts' !== \sanitize_key( \wp_unslash( $_GET['filter'] ) ) ) {
1315 - return;
1316 - }
1317 -
1318 - \add_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1319 - }
1320 -
1321 - /**
1322 - * Exclude posts containing the activitypub/reply block.
1323 - *
1324 - * Removes itself after the first execution to avoid
1325 - * affecting secondary queries on the same page.
1326 - *
1327 - * @since 8.1.0
1328 - *
1329 - * @param string $where The WHERE clause.
1330 - * @return string Modified WHERE clause.
1331 - */
1332 - public static function exclude_replies_where( $where ) {
1333 - \remove_filter( 'posts_where', array( self::class, 'exclude_replies_where' ) );
1334 -
1335 - global $wpdb;
1336 -
1337 - $where .= $wpdb->prepare(
1338 - " AND {$wpdb->posts}.post_content NOT LIKE %s",
1339 - '%<!-- wp:activitypub/reply%'
1340 - );
1341 -
1342 - return $where;
1343 200 }
1344 201 }