PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-blocks.php

class-blocks.php in ActivityPub 8.0.2, at includes/class-blocks.php

998 lines 32.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Blocks file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11
12 /**
13 * Block class.
14 */
15 class Blocks {
16 /**
17 * Initialize the class, registering WordPress hooks.
18 */
19 public static function init() {
20 // This is already being called on the init hook, so just add it.
21 self::register_blocks();
22 self::register_patterns();
23 self::register_templates();
24
25 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
26 // Add editor plugin.
27 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
28 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
29
30 \add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
31
32 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
33 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
34 }
35
36 /**
37 * Enqueue the block editor assets.
38 */
39 public static function enqueue_editor_assets() {
40 $data = array(
41 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
42 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
43 'enabled' => array(
44 'blog' => ! is_user_type_disabled( 'blog' ),
45 'users' => ! is_user_type_disabled( 'user' ),
46 ),
47 'profileUrls' => array(
48 'user' => \admin_url( 'profile.php#activitypub' ),
49 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
50 ),
51 'showAvatars' => (bool) \get_option( 'show_avatars' ),
52 'defaultQuotePolicy' => \get_option( 'activitypub_default_quote_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ),
53 'objectType' => \get_option( 'activitypub_object_type', ACTIVITYPUB_DEFAULT_OBJECT_TYPE ),
54 'noteLength' => ACTIVITYPUB_NOTE_LENGTH,
55 );
56 wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
57
58 // Check for our supported post types.
59 $current_screen = \get_current_screen();
60 $ap_post_types = \get_post_types_by_support( 'activitypub' );
61 if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
62 return;
63 }
64
65 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
66 $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
67 wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
68
69 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/pre-publish-panel/plugin.asset.php';
70 $plugin_url = plugins_url( 'build/pre-publish-panel/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
71 wp_enqueue_script( 'activitypub-pre-publish-panel', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
72 }
73
74 /**
75 * Enqueue the reply handle script if the in_reply_to GET param is set.
76 */
77 public static function handle_in_reply_to_get_param() {
78 // Only load the script if the in_reply_to GET param is set, action happens there, not here.
79 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
80 if ( ! isset( $_GET['in_reply_to'] ) ) {
81 return;
82 }
83
84 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
85 $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
86 wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
87 }
88
89 /**
90 * Register the blocks.
91 */
92 public static function register_blocks() {
93 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
94 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
95 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' );
96
97 // Only register the Following block if the Following feature is enabled.
98 if ( '1' === \get_option( 'activitypub_following_ui', '0' ) ) {
99 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/following' );
100 }
101 // Register reactions block, conditionally removing facepile style if avatars are disabled.
102 $reactions_args = array();
103 if ( ! \get_option( 'show_avatars', true ) ) {
104 $reactions_args['styles'] = array();
105 }
106 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/reactions', $reactions_args );
107
108 \register_block_type_from_metadata(
109 ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
110 array(
111 'render_callback' => array( self::class, 'render_reply_block' ),
112 )
113 );
114
115 // Register remote media blocks (server-side only, no editor UI).
116 \register_block_type(
117 'activitypub/emoji',
118 array(
119 'attributes' => array(
120 'url' => array( 'type' => 'string' ),
121 'updated' => array( 'type' => 'string' ),
122 ),
123 'render_callback' => array( self::class, 'render_emoji_block' ),
124 )
125 );
126
127 \register_block_type(
128 'activitypub/image',
129 array(
130 'attributes' => array(
131 'url' => array( 'type' => 'string' ),
132 ),
133 'render_callback' => array( self::class, 'render_image_block' ),
134 )
135 );
136
137 \register_block_type(
138 'activitypub/audio',
139 array(
140 'attributes' => array(
141 'url' => array( 'type' => 'string' ),
142 ),
143 'render_callback' => array( self::class, 'render_audio_block' ),
144 )
145 );
146
147 \register_block_type(
148 'activitypub/video',
149 array(
150 'attributes' => array(
151 'url' => array( 'type' => 'string' ),
152 ),
153 'render_callback' => array( self::class, 'render_video_block' ),
154 )
155 );
156 }
157
158 /**
159 * Register block patterns for ActivityPub.
160 */
161 public static function register_patterns() {
162 // Register the ActivityPub pattern category.
163 \register_block_pattern_category(
164 'activitypub',
165 array(
166 'label' => \__( 'Fediverse', 'activitypub' ),
167 )
168 );
169
170 // Register each pattern.
171 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-header.php';
172 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/author-profile.php';
173 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/follow-page.php';
174 require ACTIVITYPUB_PLUGIN_DIR . '/patterns/social-sidebar.php';
175 }
176
177 /**
178 * Register FSE templates for block themes.
179 */
180 public static function register_templates() {
181 // Only register templates for block themes on WP 6.7+.
182 if ( ! \function_exists( 'register_block_template' ) || ! \wp_is_block_theme() ) {
183 return;
184 }
185
186 // Use the core `author` hierarchy slug so WP can resolve this for author archives.
187 \register_block_template(
188 'activitypub//author',
189 array(
190 'title' => \__( 'Author Archive (Fediverse)', 'activitypub' ),
191 'description' => \__( 'Displays an author archive with Fediverse profile and follow options.', 'activitypub' ),
192 'content' => '<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
193 <!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
194 <main class="wp-block-group">
195 <!-- wp:pattern {"slug":"activitypub/author-profile"} /-->
196 <!-- wp:spacer {"height":"32px"} -->
197 <div style="height:32px" aria-hidden="true" class="wp-block-spacer"></div>
198 <!-- /wp:spacer -->
199 <!-- wp:query {"queryId":0,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","author":"","search":"","exclude":[],"sticky":"","inherit":true}} -->
200 <div class="wp-block-query">
201 <!-- wp:post-template -->
202 <!-- wp:post-title {"isLink":true} /-->
203 <!-- wp:post-excerpt /-->
204 <!-- /wp:post-template -->
205 <!-- wp:query-pagination -->
206 <!-- wp:query-pagination-previous /-->
207 <!-- wp:query-pagination-numbers /-->
208 <!-- wp:query-pagination-next /-->
209 <!-- /wp:query-pagination -->
210 </div>
211 <!-- /wp:query -->
212 </main>
213 <!-- /wp:group -->
214 <!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->',
215 'post_types' => array(),
216 )
217 );
218 }
219
220 /**
221 * Register REST fields needed for blocks.
222 */
223 public static function register_rest_fields() {
224 // Register the post_count field for Follow Me block.
225 register_rest_field(
226 'user',
227 'post_count',
228 array(
229 /**
230 * Get the number of published posts.
231 *
232 * @param array $response Prepared response array.
233 * @param string $field_name The field name.
234 * @param \WP_REST_Request $request The request object.
235 * @return int The number of published posts.
236 */
237 'get_callback' => static function ( $response, $field_name, $request ) {
238 return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
239 },
240 'schema' => array(
241 'description' => 'Number of published posts',
242 'type' => 'integer',
243 'context' => array( 'activitypub' ),
244 ),
245 )
246 );
247 }
248
249 /**
250 * Get the user ID from a user string.
251 *
252 * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
253 * @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
254 */
255 public static function get_user_id( $user_string ) {
256 if ( is_numeric( $user_string ) ) {
257 return absint( $user_string );
258 }
259
260 // If the user string is 'blog', return the Blog User ID.
261 if ( 'blog' === $user_string ) {
262 return Actors::BLOG_USER_ID;
263 }
264
265 // The only other value should be 'inherit', which means to use the query context to determine the User.
266 if ( 'inherit' !== $user_string ) {
267 return null;
268 }
269
270 // For a homepage/front page, if the Blog User is active, use it.
271 if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
272 return Actors::BLOG_USER_ID;
273 }
274
275 // If we're in a loop, use the post author.
276 $author_id = get_the_author_meta( 'ID' );
277 if ( $author_id ) {
278 return $author_id;
279 }
280
281 // For other pages, the queried object will clue us in.
282 $queried_object = get_queried_object();
283 if ( ! $queried_object ) {
284 return null;
285 }
286
287 // If we're on a user archive page, use that user's ID.
288 if ( is_a( $queried_object, 'WP_User' ) ) {
289 return $queried_object->ID;
290 }
291
292 // For a single post, use the post author's ID.
293 if ( is_a( $queried_object, 'WP_Post' ) ) {
294 return get_the_author_meta( 'ID' );
295 }
296
297 // We won't properly account for some conditions, like tag archives.
298 return null;
299 }
300
301 /**
302 * Render an actor list block (followers or following).
303 *
304 * @param string $endpoint The endpoint type ('followers' or 'following').
305 * @param array $attributes Block attributes.
306 * @param \WP_Block $block Block instance.
307 * @param string $content Block content.
308 *
309 * @return string|void The HTML to render, or void to render nothing.
310 */
311 public static function render_actor_list_block( $endpoint, $attributes, $block, $content ) {
312 if ( is_activitypub_request() || \is_feed() ) {
313 return '';
314 }
315
316 $attributes = \wp_parse_args( $attributes );
317 $block_name = 'followers' === $endpoint ? __( 'Followers', 'activitypub' ) : __( 'Following', 'activitypub' );
318
319 if ( empty( $content ) ) {
320 // Fallback for v1.0.0 blocks.
321 /* translators: %s: Block type (Followers or Following) */
322 $_title = $attributes['title'] ?? \sprintf( __( 'Fediverse %s', 'activitypub' ), $block_name );
323 $content = '<h3 class="wp-block-heading">' . \esc_html( $_title ) . '</h3>';
324 unset( $attributes['title'], $attributes['className'] );
325 } else {
326 $content = \implode( PHP_EOL, \wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
327 }
328
329 $user_id = self::get_user_id( $attributes['selectedUser'] );
330 if ( \is_null( $user_id ) ) {
331 /* translators: %s: Block type (Followers or Following) */
332 return \sprintf( '<!-- %s block: `inherit` mode does not display on this type of page -->', $block_name );
333 }
334
335 $user = Actors::get_by_id( $user_id );
336 if ( \is_wp_error( $user ) ) {
337 /* translators: 1: Block type (Followers or Following), 2: User ID */
338 return \sprintf( '<!-- %1$s block: `%2$s` not an active ActivityPub user -->', $block_name, $user_id );
339 }
340
341 if ( ! Actors::show_social_graph( $user_id ) ) {
342 /* translators: %s: Block type (Followers or Following) */
343 return \sprintf( '<!-- %s block: social graph is hidden for this user -->', $block_name );
344 }
345
346 $_per_page = \max( 1, \absint( $attributes['per_page'] ) );
347 $_show_avatars = (bool) \get_option( 'show_avatars' );
348
349 // Query the appropriate collection.
350 if ( 'followers' === $endpoint ) {
351 $data = \Activitypub\Collection\Followers::query( $user_id, $_per_page );
352 $items = $data['followers'];
353 } else {
354 $data = \Activitypub\Collection\Following::query( $user_id, $_per_page );
355 $items = $data['following'];
356 }
357
358 // Prepare items data for the Interactivity API context.
359 $prepared_items = \array_map(
360 static function ( $item ) {
361 $actor = \Activitypub\Collection\Remote_Actors::get_actor( $item );
362
363 // Restrict URLs to http/https schemes to prevent XSS via javascript: URIs.
364 $url = object_to_uri( $actor->get_url() ) ?: $actor->get_id();
365
366 return array(
367 'handle' => '@' . $actor->get_webfinger(),
368 'icon' => $actor->get_icon(),
369 'name' => $actor->get_name() ?: $actor->get_preferred_username(),
370 'url' => \esc_url( $url, array( 'http', 'https' ) ),
371 );
372 },
373 $items
374 );
375
376 $store_name = 'activitypub/' . $endpoint;
377
378 // Set up the Interactivity API config.
379 \wp_interactivity_config(
380 $store_name,
381 array(
382 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
383 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
384 )
385 );
386
387 // Set initial context data.
388 $context = array(
389 'items' => $prepared_items,
390 'isLoading' => false,
391 'order' => $attributes['order'],
392 'page' => 1,
393 'pages' => \ceil( $data['total'] / $_per_page ),
394 'perPage' => $_per_page,
395 'total' => $data['total'],
396 'userId' => $user_id,
397 'endpoint' => $endpoint,
398 );
399
400 // Get block wrapper attributes with the data-wp-interactive attribute.
401 $wrapper_attributes = \get_block_wrapper_attributes(
402 array(
403 'id' => \wp_unique_id( 'activitypub-' . $endpoint . '-block-' ),
404 'data-wp-interactive' => $store_name,
405 'data-wp-context' => \wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
406 )
407 );
408
409 /* translators: %s: Block type (Followers or Following) */
410 $nav_label = \sprintf( __( '%s navigation', 'activitypub' ), $block_name );
411
412 \ob_start();
413 ?>
414 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput ?>>
415 <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput ?>
416
417 <?php
418 self::render_actor_list(
419 array(
420 'show_avatars' => $_show_avatars,
421 'total' => $data['total'],
422 'per_page' => $_per_page,
423 'nav_label' => $nav_label,
424 )
425 );
426 ?>
427 </div>
428 <?php
429 return \ob_get_clean();
430 }
431
432 /**
433 * Render the emoji block.
434 *
435 * Replaces emoji shortcode with cached img tag at runtime.
436 *
437 * @param array $attrs The block attributes.
438 * @param string $content The block inner content (emoji shortcode).
439 *
440 * @return string The rendered emoji img tag.
441 */
442 public static function render_emoji_block( $attrs, $content ) {
443 if ( empty( $attrs['url'] ) || empty( $content ) ) {
444 return $content;
445 }
446
447 $url = $attrs['url'];
448 $shortcode = trim( $content );
449 $name = trim( $shortcode, ':' );
450
451 /**
452 * Filters a remote media URL for caching.
453 *
454 * @param string $url The remote media URL.
455 * @param string $context The context ('emoji').
456 * @param int|null $entity_id The entity ID.
457 * @param array $options Additional options.
458 */
459 $cached_url = \apply_filters(
460 'activitypub_remote_media_url',
461 $url,
462 'emoji',
463 null,
464 array( 'updated' => $attrs['updated'] ?? null )
465 );
466
467 return Emoji::get_img_tag( $cached_url ?: $url, $name );
468 }
469
470 /**
471 * Render the image block.
472 *
473 * Replaces remote image URL with cached URL at runtime.
474 *
475 * @param array $attrs The block attributes.
476 * @param string $content The block inner content (img tag).
477 *
478 * @return string The rendered content with cached URL.
479 */
480 public static function render_image_block( $attrs, $content ) {
481 if ( empty( $attrs['url'] ) || empty( $content ) ) {
482 return $content;
483 }
484
485 $url = $attrs['url'];
486
487 // Get entity ID from context.
488 $entity_id = null;
489 $post = \get_post();
490 if ( $post ) {
491 $entity_id = $post->ID;
492 }
493
494 /**
495 * Filters a remote image URL for caching.
496 *
497 * @param string $url The remote image URL.
498 * @param string $context The context ('media').
499 * @param int|null $entity_id The entity ID.
500 * @param array $options Additional options.
501 */
502 $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'media', $entity_id, array() );
503
504 if ( $cached_url && $cached_url !== $url ) {
505 return \str_replace( $url, $cached_url, $content );
506 }
507
508 return $content;
509 }
510
511 /**
512 * Render the audio block.
513 *
514 * Replaces remote audio URL with cached URL at runtime.
515 *
516 * @param array $attrs The block attributes.
517 * @param string $content The block inner content (audio tag).
518 *
519 * @return string The rendered content with cached URL.
520 */
521 public static function render_audio_block( $attrs, $content ) {
522 if ( empty( $attrs['url'] ) || empty( $content ) ) {
523 return $content;
524 }
525
526 $url = $attrs['url'];
527
528 // Get entity ID from context.
529 $entity_id = null;
530 $post = \get_post();
531 if ( $post ) {
532 $entity_id = $post->ID;
533 }
534
535 /**
536 * Filters a remote audio URL for caching.
537 *
538 * @param string $url The remote audio URL.
539 * @param string $context The context ('audio').
540 * @param int|null $entity_id The entity ID.
541 * @param array $options Additional options.
542 */
543 $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'audio', $entity_id, array() );
544
545 if ( $cached_url && $cached_url !== $url ) {
546 return \str_replace( $url, $cached_url, $content );
547 }
548
549 return $content;
550 }
551
552 /**
553 * Render the video block.
554 *
555 * Replaces remote video URL with cached URL at runtime.
556 *
557 * @param array $attrs The block attributes.
558 * @param string $content The block inner content (video tag).
559 *
560 * @return string The rendered content with cached URL.
561 */
562 public static function render_video_block( $attrs, $content ) {
563 if ( empty( $attrs['url'] ) || empty( $content ) ) {
564 return $content;
565 }
566
567 $url = $attrs['url'];
568
569 // Get entity ID from context.
570 $entity_id = null;
571 $post = \get_post();
572 if ( $post ) {
573 $entity_id = $post->ID;
574 }
575
576 /**
577 * Filters a remote video URL for caching.
578 *
579 * @param string $url The remote video URL.
580 * @param string $context The context ('video').
581 * @param int|null $entity_id The entity ID.
582 * @param array $options Additional options.
583 */
584 $cached_url = \apply_filters( 'activitypub_remote_media_url', $url, 'video', $entity_id, array() );
585
586 if ( $cached_url && $cached_url !== $url ) {
587 return \str_replace( $url, $cached_url, $content );
588 }
589
590 return $content;
591 }
592
593 /**
594 * Render the reply block.
595 *
596 * @param array $attrs The block attributes.
597 *
598 * @return string The HTML to render.
599 */
600 public static function render_reply_block( $attrs ) {
601 if ( is_activitypub_request() ) {
602 $attrs['embedPost'] = false;
603 }
604
605 // Return early if no URL is provided.
606 if ( empty( $attrs['url'] ) ) {
607 return null;
608 }
609
610 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
611
612 $wrapper_attrs = get_block_wrapper_attributes(
613 array(
614 'aria-label' => __( 'Reply', 'activitypub' ),
615 'class' => 'activitypub-reply-block',
616 'data-in-reply-to' => $attrs['url'],
617 )
618 );
619
620 $html = '<div ' . $wrapper_attrs . '>';
621
622 // Try to get and append the embed if requested.
623 $embed = null;
624 if ( $show_embed ) {
625 // Use the theme's content width or a reasonable default to avoid narrow embeds.
626 $embed_width = ! empty( $GLOBALS['content_width'] ) ? $GLOBALS['content_width'] : 600;
627 $embed = wp_oembed_get( $attrs['url'], array( 'width' => $embed_width ) );
628 if ( $embed ) {
629 $html .= $embed;
630 \wp_enqueue_script( 'wp-embed' );
631 }
632 }
633
634 // Show the link if embed is not requested or if embed failed.
635 if ( ! $show_embed || ! $embed ) {
636 $html .= sprintf(
637 '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
638 esc_url( $attrs['url'] ),
639 esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
640 // translators: %s is the URL of the post being replied to.
641 sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
642 );
643 }
644
645 $html .= '</div>';
646
647 return $html;
648 }
649
650 /**
651 * Renders a modal component that can be used by different blocks.
652 *
653 * @param array $args {
654 * Arguments for the modal.
655 *
656 * @type string $content The modal content HTML.
657 * @type string $id Optional ID prefix for the modal elements.
658 * @type bool $is_compact Whether the modal is compact (popover-style). Default false.
659 * @type string $title Static title text for the modal header.
660 * @type string $title_binding Optional Interactivity API binding for a dynamic title
661 * (e.g. 'context.modal.title'). When set, uses data-wp-text
662 * on the title element and enables dynamic compact toggling.
663 * }
664 */
665 public static function render_modal( $args = array() ) {
666 $defaults = array(
667 'content' => '',
668 'id' => '',
669 'is_compact' => false,
670 'title' => '',
671 'title_binding' => '',
672 );
673
674 $args = \wp_parse_args( $args, $defaults );
675 ?>
676
677 <div
678 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
679 data-wp-bind--hidden="!context.modal.isOpen"
680 data-wp-watch="callbacks.handleModalEffects"
681 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
682 data-wp-class--compact="context.modal.isCompact"
683 <?php endif; ?>
684 role="dialog"
685 aria-modal="true"
686 hidden
687 >
688 <div class="activitypub-modal__frame">
689 <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) || ! empty( $args['title_binding'] ) ) : ?>
690 <div class="activitypub-modal__header">
691 <h2
692 class="activitypub-modal__title"
693 <?php if ( ! empty( $args['id'] ) ) : ?>
694 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
695 <?php endif; ?>
696 <?php if ( ! empty( $args['title_binding'] ) ) : ?>
697 data-wp-text="<?php echo \esc_attr( $args['title_binding'] ); ?>"
698 <?php endif; ?>
699 ><?php echo \esc_html( $args['title'] ); ?></h2>
700 <button
701 type="button"
702 class="activitypub-modal__close wp-element-button"
703 data-wp-on--click="actions.closeModal"
704 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
705 >
706 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
707 <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>
708 </svg>
709 </button>
710 </div>
711 <?php endif; ?>
712 <div class="activitypub-modal__content">
713 <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
714 </div>
715 </div>
716 </div>
717 <?php
718 }
719
720 /**
721 * Renders a help section explaining the Fediverse inside modal dialogs.
722 *
723 * Outputs a collapsible `<details>` element that explains decentralized
724 * interactions to users unfamiliar with the Fediverse.
725 *
726 * @since 8.0.0
727 */
728 public static function render_modal_help() {
729 ?>
730 <details class="activitypub-dialog__help">
731 <summary><?php \esc_html_e( 'Why do I need to enter my profile?', 'activitypub' ); ?></summary>
732 <p>
733 <?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' ); ?>
734 </p>
735 <p>
736 <?php \esc_html_e( 'By entering your profile, we can send you to your account where you can complete this action.', 'activitypub' ); ?>
737 </p>
738 </details>
739 <?php
740 }
741
742 /**
743 * Renders an actor list component that can be used by different blocks.
744 *
745 * @param array $args Arguments for the actor list.
746 */
747 public static function render_actor_list( $args = array() ) {
748 $defaults = array(
749 'show_avatars' => true,
750 'show_pagination' => true,
751 'total' => 0,
752 'per_page' => 10,
753 'nav_label' => __( 'Actor navigation', 'activitypub' ),
754 );
755
756 $args = \wp_parse_args( $args, $defaults );
757
758 // Sanitize numeric values, ensuring per_page is at least 1 to avoid division by zero.
759 $args['total'] = \absint( $args['total'] );
760 $args['per_page'] = \max( 1, \absint( $args['per_page'] ) );
761 ?>
762
763 <div class="activitypub-actor-list-container">
764 <ul class="activitypub-actor-list">
765 <template data-wp-each="context.items">
766 <li class="activitypub-actor-item">
767 <a href="#"
768 data-wp-bind--href="context.item.url"
769 class="activitypub-actor-link"
770 target="_blank"
771 rel="external noreferrer noopener"
772 data-wp-bind--title="context.item.handle">
773
774 <?php if ( $args['show_avatars'] ) : ?>
775 <img
776 data-wp-bind--src="context.item.icon.url"
777 data-wp-on--error="callbacks.setDefaultAvatar"
778 src=""
779 alt=""
780 class="activitypub-actor-avatar"
781 width="48"
782 height="48"
783 >
784 <?php endif; ?>
785
786 <div class="activitypub-actor-info">
787 <span class="activitypub-actor-name" data-wp-text="context.item.name"></span>
788 <span class="activitypub-actor-handle" data-wp-text="context.item.handle"></span>
789 </div>
790
791 <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">
792 <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>
793 </svg>
794 </a>
795 </li>
796 </template>
797 </ul>
798
799 <?php if ( $args['show_pagination'] && $args['total'] > $args['per_page'] ) : ?>
800 <nav class="activitypub-actor-list-pagination" role="navigation">
801 <h1 class="screen-reader-text"><?php echo \esc_html( $args['nav_label'] ); ?></h1>
802 <a
803 href="#"
804 role="button"
805 class="pagination-previous"
806 data-wp-on-async--click="actions.previousPage"
807 data-wp-bind--aria-disabled="state.disablePreviousLink"
808 aria-label="<?php \esc_attr_e( 'Previous page', 'activitypub' ); ?>"
809 >
810 <?php \esc_html_e( 'Previous', 'activitypub' ); ?>
811 </a>
812
813 <div class="pagination-info" data-wp-text="state.paginationText"></div>
814
815 <a
816 href="#"
817 role="button"
818 class="pagination-next"
819 data-wp-on-async--click="actions.nextPage"
820 data-wp-bind--aria-disabled="state.disableNextLink"
821 aria-label="<?php \esc_attr_e( 'Next page', 'activitypub' ); ?>"
822 >
823 <?php \esc_html_e( 'Next', 'activitypub' ); ?>
824 </a>
825 </nav>
826
827 <div class="activitypub-actor-list-loading" data-wp-bind--aria-hidden="!context.isLoading">
828 <div class="loading-spinner"></div>
829 </div>
830 <?php endif; ?>
831 </div>
832 <?php
833 }
834
835 /**
836 * Converts content to blocks before saving to the database.
837 *
838 * @param array $data The post data to be inserted.
839 * @param array $post The Mastodon Create activity.
840 *
841 * @return array
842 */
843 public static function filter_import_mastodon_post_data( $data, $post ) {
844 // Convert paragraphs to blocks.
845 \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
846 $blocks = \array_map(
847 static function ( $paragraph ) {
848 return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
849 },
850 $matches[0] ?? array()
851 );
852
853 $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
854
855 // Add reply block if it's a reply.
856 if ( ! empty( $post['object']['inReplyTo'] ) ) {
857 $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
858 $data['post_content'] = $reply_block . $data['post_content'];
859 }
860
861 return $data;
862 }
863
864 /**
865 * Add Interactivity directions to the specified element.
866 *
867 * @param string $content The block content.
868 * @param string[] $selector The selector for the element to add directions to.
869 * @param string[] $attributes The attributes to add to the element.
870 *
871 * @return string The updated content.
872 */
873 public static function add_directions( $content, $selector, $attributes ) {
874 $tags = new \WP_HTML_Tag_Processor( $content );
875
876 while ( $tags->next_tag( $selector ) ) {
877 foreach ( $attributes as $key => $value ) {
878 if ( 'class' === $key ) {
879 $tags->add_class( $value );
880 continue;
881 }
882
883 $tags->set_attribute( $key, $value );
884 }
885 }
886
887 return $tags->get_updated_html();
888 }
889
890 /**
891 * Add post transformation callbacks.
892 *
893 * @param object $post The post object.
894 */
895 public static function add_post_transformation_callbacks( $post ) {
896 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
897
898 // Only transform reply link if it's the first block in the post.
899 $blocks = \parse_blocks( $post->post_content );
900 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
901 \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
902 }
903 }
904
905 /**
906 * Remove post transformation callbacks.
907 *
908 * @param string $content The post content.
909 *
910 * @return string The updated content.
911 */
912 public static function remove_post_transformation_callbacks( $content ) {
913 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
914 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
915
916 return $content;
917 }
918
919 /**
920 * Generate HTML @ link for reply block.
921 *
922 * @param string $block_content The block content.
923 * @param array $block The block data.
924 *
925 * @return string The HTML @ link.
926 */
927 public static function generate_reply_link( $block_content, $block ) {
928 // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
929 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
930
931 // Return empty string if no URL is provided.
932 if ( empty( $block['attrs']['url'] ) ) {
933 return '';
934 }
935
936 $url = $block['attrs']['url'];
937
938 // Try to get ActivityPub representation. Is likely already cached.
939 $object = Http::get_remote_object( $url );
940 if ( \is_wp_error( $object ) ) {
941 return '';
942 }
943
944 $author_url = $object['attributedTo'] ?? '';
945 if ( ! $author_url ) {
946 return '';
947 }
948
949 // Fetch author information.
950 $author = Http::get_remote_object( $author_url );
951 if ( \is_wp_error( $author ) ) {
952 return '';
953 }
954
955 // Get webfinger identifier.
956 $webfinger = '';
957 if ( ! empty( $author['webfinger'] ) ) {
958 $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
959 } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
960 // Construct webfinger-style identifier from username and domain.
961 $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
962 $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
963 }
964
965 if ( ! $webfinger ) {
966 return '';
967 }
968
969 // Generate HTML @ link.
970 return \sprintf(
971 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
972 \esc_url( $url ),
973 \esc_attr( $webfinger ),
974 \esc_html( '@' . strtok( $webfinger, '@' ) )
975 );
976 }
977
978 /**
979 * Transform Embed blocks to block level link.
980 *
981 * Remote servers will simply drop iframe elements, rendering incomplete content.
982 *
983 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
984 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
985 *
986 * @param string $block_content The block content (html).
987 * @param object $block The block object.
988 *
989 * @return string A block level link
990 */
991 public static function revert_embed_links( $block_content, $block ) {
992 if ( ! isset( $block['attrs']['url'] ) ) {
993 return $block_content;
994 }
995 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
996 }
997 }
998