PluginProbe
ActivityPub / 7.8.1
ActivityPub v7.8.1
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 7.8.1, at includes/class-blocks.php

454 lines 14.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
23 \add_action( 'load-post-new.php', array( self::class, 'handle_in_reply_to_get_param' ) );
24 // Add editor plugin.
25 \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
26 \add_action( 'rest_api_init', array( self::class, 'register_rest_fields' ) );
27
28 \add_filter( 'activitypub_import_mastodon_post_data', array( self::class, 'filter_import_mastodon_post_data' ), 10, 2 );
29
30 \add_action( 'activitypub_before_get_content', array( self::class, 'add_post_transformation_callbacks' ) );
31 \add_filter( 'activitypub_the_content', array( self::class, 'remove_post_transformation_callbacks' ) );
32 }
33
34 /**
35 * Enqueue the block editor assets.
36 */
37 public static function enqueue_editor_assets() {
38 $data = array(
39 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
40 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
41 'enabled' => array(
42 'blog' => ! is_user_type_disabled( 'blog' ),
43 'users' => ! is_user_type_disabled( 'user' ),
44 ),
45 'profileUrls' => array(
46 'user' => \admin_url( 'profile.php#activitypub' ),
47 'blog' => \admin_url( 'options-general.php?page=activitypub&tab=blog-profile' ),
48 ),
49 'showAvatars' => (bool) \get_option( 'show_avatars' ),
50 );
51 wp_localize_script( 'wp-editor', '_activityPubOptions', $data );
52
53 // Check for our supported post types.
54 $current_screen = \get_current_screen();
55 $ap_post_types = \get_post_types_by_support( 'activitypub' );
56 if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
57 return;
58 }
59
60 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
61 $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
62 wp_enqueue_script( 'activitypub-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
63 }
64
65 /**
66 * Enqueue the reply handle script if the in_reply_to GET param is set.
67 */
68 public static function handle_in_reply_to_get_param() {
69 // Only load the script if the in_reply_to GET param is set, action happens there, not here.
70 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
71 if ( ! isset( $_GET['in_reply_to'] ) ) {
72 return;
73 }
74
75 $asset_data = include ACTIVITYPUB_PLUGIN_DIR . 'build/reply-intent/plugin.asset.php';
76 $plugin_url = plugins_url( 'build/reply-intent/plugin.js', ACTIVITYPUB_PLUGIN_FILE );
77 wp_enqueue_script( 'activitypub-reply-intent', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
78 }
79
80 /**
81 * Register the blocks.
82 */
83 public static function register_blocks() {
84 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/extra-fields' );
85 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/follow-me' );
86 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/followers' );
87 // Register reactions block, conditionally removing facepile style if avatars are disabled.
88 $reactions_args = array();
89 if ( ! \get_option( 'show_avatars', true ) ) {
90 $reactions_args['styles'] = array();
91 }
92 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . '/build/reactions', $reactions_args );
93
94 \register_block_type_from_metadata(
95 ACTIVITYPUB_PLUGIN_DIR . '/build/reply',
96 array(
97 'render_callback' => array( self::class, 'render_reply_block' ),
98 )
99 );
100 }
101
102 /**
103 * Register REST fields needed for blocks.
104 */
105 public static function register_rest_fields() {
106 // Register the post_count field for Follow Me block.
107 register_rest_field(
108 'user',
109 'post_count',
110 array(
111 /**
112 * Get the number of published posts.
113 *
114 * @param array $response Prepared response array.
115 * @param string $field_name The field name.
116 * @param \WP_REST_Request $request The request object.
117 * @return int The number of published posts.
118 */
119 'get_callback' => static function ( $response, $field_name, $request ) {
120 return (int) count_user_posts( $request->get_param( 'id' ), 'post', true );
121 },
122 'schema' => array(
123 'description' => 'Number of published posts',
124 'type' => 'integer',
125 'context' => array( 'activitypub' ),
126 ),
127 )
128 );
129 }
130
131 /**
132 * Get the user ID from a user string.
133 *
134 * @param string $user_string The user string. Can be a user ID, 'blog', or 'inherit'.
135 * @return int|null The user ID, or null if the 'inherit' string is not supported in this context.
136 */
137 public static function get_user_id( $user_string ) {
138 if ( is_numeric( $user_string ) ) {
139 return absint( $user_string );
140 }
141
142 // If the user string is 'blog', return the Blog User ID.
143 if ( 'blog' === $user_string ) {
144 return Actors::BLOG_USER_ID;
145 }
146
147 // The only other value should be 'inherit', which means to use the query context to determine the User.
148 if ( 'inherit' !== $user_string ) {
149 return null;
150 }
151
152 // For a homepage/front page, if the Blog User is active, use it.
153 if ( ( is_front_page() || is_home() ) && ! is_user_type_disabled( 'blog' ) ) {
154 return Actors::BLOG_USER_ID;
155 }
156
157 // If we're in a loop, use the post author.
158 $author_id = get_the_author_meta( 'ID' );
159 if ( $author_id ) {
160 return $author_id;
161 }
162
163 // For other pages, the queried object will clue us in.
164 $queried_object = get_queried_object();
165 if ( ! $queried_object ) {
166 return null;
167 }
168
169 // If we're on a user archive page, use that user's ID.
170 if ( is_a( $queried_object, 'WP_User' ) ) {
171 return $queried_object->ID;
172 }
173
174 // For a single post, use the post author's ID.
175 if ( is_a( $queried_object, 'WP_Post' ) ) {
176 return get_the_author_meta( 'ID' );
177 }
178
179 // We won't properly account for some conditions, like tag archives.
180 return null;
181 }
182
183 /**
184 * Render the reply block.
185 *
186 * @param array $attrs The block attributes.
187 *
188 * @return string The HTML to render.
189 */
190 public static function render_reply_block( $attrs ) {
191 if ( is_activitypub_request() ) {
192 $attrs['embedPost'] = false;
193 }
194
195 // Return early if no URL is provided.
196 if ( empty( $attrs['url'] ) ) {
197 return null;
198 }
199
200 $show_embed = isset( $attrs['embedPost'] ) && $attrs['embedPost'];
201
202 $wrapper_attrs = get_block_wrapper_attributes(
203 array(
204 'aria-label' => __( 'Reply', 'activitypub' ),
205 'class' => 'activitypub-reply-block',
206 'data-in-reply-to' => $attrs['url'],
207 )
208 );
209
210 $html = '<div ' . $wrapper_attrs . '>';
211
212 // Try to get and append the embed if requested.
213 $embed = null;
214 if ( $show_embed ) {
215 $embed = wp_oembed_get( $attrs['url'] );
216 if ( $embed ) {
217 $html .= $embed;
218 \wp_enqueue_script( 'wp-embed' );
219 }
220 }
221
222 // Show the link if embed is not requested or if embed failed.
223 if ( ! $show_embed || ! $embed ) {
224 $html .= sprintf(
225 '<p><a title="%2$s" aria-label="%2$s" href="%1$s" class="u-in-reply-to" target="_blank">%3$s</a></p>',
226 esc_url( $attrs['url'] ),
227 esc_attr__( 'This post is a response to the referenced content.', 'activitypub' ),
228 // translators: %s is the URL of the post being replied to.
229 sprintf( __( '&#8620;%s', 'activitypub' ), \str_replace( array( 'https://', 'http://' ), '', esc_url( $attrs['url'] ) ) )
230 );
231 }
232
233 $html .= '</div>';
234
235 return $html;
236 }
237
238 /**
239 * Renders a modal component that can be used by different blocks.
240 *
241 * @param array $args Arguments for the modal.
242 */
243 public static function render_modal( $args = array() ) {
244 $defaults = array(
245 'content' => '',
246 'id' => '',
247 'is_compact' => false,
248 'title' => '',
249 );
250
251 $args = \wp_parse_args( $args, $defaults );
252 ?>
253
254 <div
255 class="activitypub-modal__overlay<?php echo \esc_attr( $args['is_compact'] ? ' compact' : '' ); ?>"
256 data-wp-bind--hidden="!context.modal.isOpen"
257 data-wp-watch="callbacks.handleModalEffects"
258 role="dialog"
259 aria-modal="true"
260 hidden
261 >
262 <div class="activitypub-modal__frame">
263 <?php if ( ! $args['is_compact'] || ! empty( $args['title'] ) ) : ?>
264 <div class="activitypub-modal__header">
265 <h2
266 class="activitypub-modal__title"
267 <?php if ( ! empty( $args['id'] ) ) : ?>
268 id="<?php echo \esc_attr( $args['id'] . '-title' ); ?>"
269 <?php endif; ?>
270 ><?php echo \esc_html( $args['title'] ); ?></h2>
271 <button
272 type="button"
273 class="activitypub-modal__close wp-element-button wp-block-button__link"
274 data-wp-on--click="actions.closeModal"
275 aria-label="<?php echo \esc_attr__( 'Close dialog', 'activitypub' ); ?>"
276 >
277 <svg fill="currentColor" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
278 <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>
279 </svg>
280 </button>
281 </div>
282 <?php endif; ?>
283 <div class="activitypub-modal__content">
284 <?php echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
285 </div>
286 </div>
287 </div>
288 <?php
289 }
290
291 /**
292 * Converts content to blocks before saving to the database.
293 *
294 * @param array $data The post data to be inserted.
295 * @param array $post The Mastodon Create activity.
296 *
297 * @return array
298 */
299 public static function filter_import_mastodon_post_data( $data, $post ) {
300 // Convert paragraphs to blocks.
301 \preg_match_all( '#<p>.*?</p>#is', $data['post_content'], $matches );
302 $blocks = \array_map(
303 static function ( $paragraph ) {
304 return '<!-- wp:paragraph -->' . PHP_EOL . $paragraph . PHP_EOL . '<!-- /wp:paragraph -->' . PHP_EOL;
305 },
306 $matches[0] ?? array()
307 );
308
309 $data['post_content'] = \rtrim( \implode( PHP_EOL, $blocks ), PHP_EOL );
310
311 // Add reply block if it's a reply.
312 if ( ! empty( $post['object']['inReplyTo'] ) ) {
313 $reply_block = \sprintf( '<!-- wp:activitypub/reply {"url":"%1$s","embedPost":true} /-->' . PHP_EOL, \esc_url( $post['object']['inReplyTo'] ) );
314 $data['post_content'] = $reply_block . $data['post_content'];
315 }
316
317 return $data;
318 }
319
320 /**
321 * Add Interactivity directions to the specified element.
322 *
323 * @param string $content The block content.
324 * @param string[] $selector The selector for the element to add directions to.
325 * @param string[] $attributes The attributes to add to the element.
326 *
327 * @return string The updated content.
328 */
329 public static function add_directions( $content, $selector, $attributes ) {
330 $tags = new \WP_HTML_Tag_Processor( $content );
331
332 while ( $tags->next_tag( $selector ) ) {
333 foreach ( $attributes as $key => $value ) {
334 if ( 'class' === $key ) {
335 $tags->add_class( $value );
336 continue;
337 }
338
339 $tags->set_attribute( $key, $value );
340 }
341 }
342
343 return $tags->get_updated_html();
344 }
345
346 /**
347 * Add post transformation callbacks.
348 *
349 * @param object $post The post object.
350 */
351 public static function add_post_transformation_callbacks( $post ) {
352 \add_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ), 10, 2 );
353
354 // Only transform reply link if it's the first block in the post.
355 $blocks = \parse_blocks( $post->post_content );
356 if ( ! empty( $blocks ) && 'activitypub/reply' === $blocks[0]['blockName'] ) {
357 \add_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ), 10, 2 );
358 }
359 }
360
361 /**
362 * Remove post transformation callbacks.
363 *
364 * @param string $content The post content.
365 *
366 * @return string The updated content.
367 */
368 public static function remove_post_transformation_callbacks( $content ) {
369 \remove_filter( 'render_block_core/embed', array( self::class, 'revert_embed_links' ) );
370 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
371
372 return $content;
373 }
374
375 /**
376 * Generate HTML @ link for reply block.
377 *
378 * @param string $block_content The block content.
379 * @param array $block The block data.
380 *
381 * @return string The HTML @ link.
382 */
383 public static function generate_reply_link( $block_content, $block ) {
384 // Unhook ourselves after first execution to ensure only the first reply block gets transformed.
385 \remove_filter( 'render_block_activitypub/reply', array( self::class, 'generate_reply_link' ) );
386
387 // Return empty string if no URL is provided.
388 if ( empty( $block['attrs']['url'] ) ) {
389 return '';
390 }
391
392 $url = $block['attrs']['url'];
393
394 // Try to get ActivityPub representation. Is likely already cached.
395 $object = Http::get_remote_object( $url );
396 if ( \is_wp_error( $object ) ) {
397 return '';
398 }
399
400 $author_url = $object['attributedTo'] ?? '';
401 if ( ! $author_url ) {
402 return '';
403 }
404
405 // Fetch author information.
406 $author = Http::get_remote_object( $author_url );
407 if ( \is_wp_error( $author ) ) {
408 return '';
409 }
410
411 // Get webfinger identifier.
412 $webfinger = '';
413 if ( ! empty( $author['webfinger'] ) ) {
414 $webfinger = \str_replace( 'acct:', '', $author['webfinger'] );
415 } elseif ( ! empty( $author['preferredUsername'] ) && ! empty( $author['url'] ) ) {
416 // Construct webfinger-style identifier from username and domain.
417 $domain = \wp_parse_url( $author['url'], PHP_URL_HOST );
418 $webfinger = '@' . $author['preferredUsername'] . '@' . $domain;
419 }
420
421 if ( ! $webfinger ) {
422 return '';
423 }
424
425 // Generate HTML @ link.
426 return \sprintf(
427 '<p class="ap-reply-mention"><a rel="mention ugc" href="%1$s" title="%2$s">%3$s</a></p>',
428 \esc_url( $url ),
429 \esc_attr( $webfinger ),
430 \esc_html( '@' . strtok( $webfinger, '@' ) )
431 );
432 }
433
434 /**
435 * Transform Embed blocks to block level link.
436 *
437 * Remote servers will simply drop iframe elements, rendering incomplete content.
438 *
439 * @see https://www.w3.org/TR/activitypub/#security-sanitizing-content
440 * @see https://www.w3.org/wiki/ActivityPub/Primer/HTML
441 *
442 * @param string $block_content The block content (html).
443 * @param object $block The block object.
444 *
445 * @return string A block level link
446 */
447 public static function revert_embed_links( $block_content, $block ) {
448 if ( ! isset( $block['attrs']['url'] ) ) {
449 return $block_content;
450 }
451 return '<p><a href="' . esc_url( $block['attrs']['url'] ) . '">' . $block['attrs']['url'] . '</a></p>';
452 }
453 }
454