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