PluginProbe
ActivityPub / 9.3.0
ActivityPub v9.3.0
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 / build / reactions / render.php

render.php in ActivityPub 9.3.0, at build/reactions/render.php

423 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server-side rendering of the `activitypub/reactions` block.
4 *
5 * @package ActivityPub
6 */
7
8 use Activitypub\Blocks;
9 use Activitypub\Comment;
10
11 use function Activitypub\get_post_id;
12 use function Activitypub\get_reaction_author_name;
13 use function Activitypub\is_activitypub_request;
14 use function Activitypub\is_post_publicly_queryable;
15
16 if ( is_activitypub_request() || is_feed() ) {
17 return;
18 }
19
20 // Get the default display style based on WordPress avatar settings.
21 $default_display_style = get_option( 'show_avatars', true ) ? 'facepile' : 'compact';
22
23 /* @var array $attributes Block attributes. */
24 $attributes = wp_parse_args(
25 $attributes,
26 array(
27 'align' => null,
28 'displayStyle' => $default_display_style,
29 'showActions' => false,
30 )
31 );
32
33 /* @var \WP_Block $block Current block. */
34 $block = $block ?? '';
35
36 /* @var string $content Block content. */
37 $content = $content ?? '';
38
39 if ( empty( $content ) ) {
40 // Fallback for v1.0.0 blocks.
41 $_title = $attributes['title'] ?? __( 'Fediverse Reactions', 'activitypub' );
42 $content = '<h6 class="wp-block-heading">' . esc_html( $_title ) . '</h6>';
43 unset( $attributes['title'] );
44 } else {
45 $content = implode( PHP_EOL, wp_list_pluck( $block->parsed_block['innerBlocks'], 'innerHTML' ) );
46 // Hide empty headings.
47 if ( empty( wp_strip_all_tags( $content ) ) ) {
48 $content = '';
49 }
50 }
51
52 // Get the Post ID from attributes or use the current post.
53 $_post_id = $attributes['postId'] ?? get_the_ID();
54
55 // Don't leak reaction metadata for posts that are not currently publicly queryable.
56 if ( ! is_post_publicly_queryable( $_post_id ) ) {
57 return;
58 }
59
60 // Generate a unique ID for the block.
61 $block_id = 'activitypub-reactions-block-' . wp_unique_id();
62 $error_id = $block_id . '-remote-profile-error';
63
64 /*
65 * Determine display style - compact style hides avatars.
66 * For auto-hooked blocks without explicit style, use avatar setting to determine style.
67 */
68 $has_style_class = isset( $attributes['className'] ) && strpos( $attributes['className'], 'is-style-' ) !== false;
69 if ( ! $has_style_class ) {
70 $attributes['className'] = trim( ( $attributes['className'] ?? '' ) . ' is-style-' . $default_display_style );
71 $attributes['displayStyle'] = $default_display_style;
72 }
73
74 $show_avatars = 'facepile' === $attributes['displayStyle'];
75
76 // Fetch reactions.
77 $reactions = array();
78
79 foreach ( Comment::get_comment_types() as $_type => $type_object ) {
80 $_comments = get_comments(
81 array(
82 'post_id' => $_post_id,
83 'type' => $_type,
84 'status' => 'approve',
85 'parent' => 0,
86 )
87 );
88
89 if ( empty( $_comments ) ) {
90 continue;
91 }
92
93 $count = count( $_comments );
94 // phpcs:disable WordPress.WP.I18n
95 $label = sprintf(
96 _n(
97 $type_object['count_single'],
98 $type_object['count_plural'],
99 $count,
100 'activitypub'
101 ),
102 number_format_i18n( $count )
103 );
104 // phpcs:enable WordPress.WP.I18n
105
106 $reactions[ $_type ] = array(
107 'label' => $label,
108 'count' => $count,
109 'items' => array_map(
110 static function ( $comment ) {
111 return array(
112 'name' => get_reaction_author_name( $comment ),
113 'url' => $comment->comment_author_url,
114 'avatar' => get_avatar_url( $comment ),
115 );
116 },
117 $_comments
118 ),
119 );
120 }
121
122 if ( empty( $reactions ) && ! $attributes['showActions'] ) {
123 echo '<!-- Reactions block: No reactions found. -->';
124 return;
125 }
126
127 // Set up the Interactivity API config.
128 $config = array(
129 'defaultAvatarUrl' => ACTIVITYPUB_PLUGIN_URL . 'assets/img/mp.jpg',
130 'namespace' => ACTIVITYPUB_REST_NAMESPACE,
131 );
132
133 if ( $attributes['showActions'] ) {
134 $config['i18n'] = array(
135 'copied' => __( 'Copied!', 'activitypub' ),
136 'copy' => __( 'Copy', 'activitypub' ),
137 'emptyProfileError' => __( 'Please enter a profile URL or handle.', 'activitypub' ),
138 'genericError' => __( 'An error occurred. Please try again.', 'activitypub' ),
139 'intentLabelLike' => __( 'Like this post', 'activitypub' ),
140 'intentLabelAnnounce' => __( 'Boost this post', 'activitypub' ),
141 'invalidProfileError' => __( 'Please enter a valid profile URL or handle.', 'activitypub' ),
142 );
143 }
144
145 wp_interactivity_config( 'activitypub/reactions', $config );
146
147 // Set up the Interactivity API state.
148 wp_interactivity_state( 'activitypub/reactions', array( 'reactions' => array( $_post_id => $reactions ) ) );
149
150 // Render a subset of the most recent reactions for facepile.
151 $reactions = array_map(
152 static function ( $reaction ) use ( $attributes ) {
153 $count = 20;
154 if ( 'wide' === $attributes['align'] ) {
155 $count = 40;
156 } elseif ( 'full' === $attributes['align'] ) {
157 $count = 60;
158 }
159
160 $reaction['items'] = array_slice( array_reverse( $reaction['items'] ), 0, $count );
161
162 return $reaction;
163 },
164 $reactions
165 );
166
167 // Initialize the context for the block.
168 $context = array(
169 'blockId' => $block_id,
170 'modal' => array(
171 'isCompact' => true,
172 'isOpen' => false,
173 'items' => array(),
174 'title' => '',
175 ),
176 'postId' => $_post_id,
177 'reactions' => $reactions,
178 );
179
180 if ( $attributes['showActions'] ) {
181 $context['modal']['intent'] = '';
182 $context['copyButtonText'] = __( 'Copy', 'activitypub' );
183 $context['errorMessage'] = '';
184 $context['isError'] = false;
185 $context['isLoading'] = false;
186 $context['postUrl'] = get_post_id( $_post_id );
187 $context['remoteProfile'] = '';
188 $context['shouldSaveProfile'] = true;
189 }
190
191 // Map comment types to remote intent types.
192 $intent_map = array(
193 'like' => 'like',
194 'repost' => 'announce',
195 'quote' => 'announce',
196 );
197
198 // Build reactions content.
199 ob_start();
200 ?>
201 <div class="activitypub-reactions">
202 <?php
203 // First pass: render reaction types that have items (full treatment).
204 foreach ( $reactions as $_type => $reaction ) :
205 /* translators: %s: reaction type. */
206 $aria_label = sprintf( __( 'View all %s', 'activitypub' ), Comment::get_comment_type_attr( $_type, 'label' ) );
207 $intent = isset( $intent_map[ $_type ] ) ? $intent_map[ $_type ] : '';
208 ?>
209 <div class="reaction-group" data-reaction-type="<?php echo esc_attr( $_type ); ?>">
210 <?php if ( $attributes['showActions'] && $intent ) : ?>
211 <button
212 class="reaction-action-button has-text-color has-background"
213 data-intent="<?php echo esc_attr( $intent ); ?>"
214 data-wp-on--click="actions.openIntentModal"
215 type="button"
216 aria-label="<?php echo esc_attr( Comment::get_comment_type_attr( $_type, 'singular' ) ); ?>"
217 >
218 <?php echo esc_html( Comment::get_comment_type_attr( $_type, 'singular' ) ); ?>
219 </button>
220 <?php endif; ?>
221 <?php if ( $show_avatars ) : ?>
222 <ul class="reaction-avatars">
223 <template data-wp-each="context.reactions.<?php echo esc_attr( $_type ); ?>.items">
224 <li>
225 <a
226 data-wp-bind--href="context.item.url"
227 data-wp-bind--title="context.item.name"
228 target="_blank"
229 rel="noopener noreferrer"
230 >
231 <img
232 data-wp-bind--src="context.item.avatar"
233 data-wp-bind--alt="context.item.name"
234 data-wp-on--error="callbacks.setDefaultAvatar"
235 class="reaction-avatar"
236 height="32"
237 width="32"
238 src=""
239 alt=""
240 />
241 </a>
242 </li>
243 </template>
244 </ul>
245 <?php endif; ?>
246 <button
247 class="reaction-label has-text-color has-background"
248 data-reaction-type="<?php echo esc_attr( $_type ); ?>"
249 data-wp-on--click="actions.toggleModal"
250 type="button"
251 aria-label="<?php echo esc_attr( $aria_label ); ?>"
252 >
253 <?php echo esc_html( $reaction['label'] ); ?>
254 </button>
255 </div>
256 <?php endforeach; ?>
257 <?php
258 // Second pass: render action buttons for reaction types without items.
259 if ( $attributes['showActions'] ) :
260 $empty_types = array_diff_key( $intent_map, $reactions );
261
262 if ( ! empty( $empty_types ) ) :
263 ?>
264 <div class="reaction-actions-only" role="group" aria-label="<?php esc_attr_e( 'Reaction actions', 'activitypub' ); ?>">
265 <?php foreach ( $empty_types as $_type => $intent ) : ?>
266 <button
267 class="reaction-action-button has-text-color has-background"
268 data-intent="<?php echo esc_attr( $intent ); ?>"
269 data-wp-on--click="actions.openIntentModal"
270 type="button"
271 aria-label="<?php echo esc_attr( Comment::get_comment_type_attr( $_type, 'singular' ) ); ?>"
272 >
273 <?php echo esc_html( Comment::get_comment_type_attr( $_type, 'singular' ) ); ?>
274 </button>
275 <?php endforeach; ?>
276 </div>
277 <?php
278 endif;
279 endif;
280 ?>
281 </div>
282 <?php
283 $reactions_content = ob_get_clean();
284
285 // Build modal content: reactors list (compact) + intent dialog (full-size).
286 ob_start();
287 ?>
288 <div data-wp-bind--hidden="!context.modal.isCompact">
289 <ul class="reactions-list">
290 <template data-wp-each="context.modal.items">
291 <li class="reaction-item">
292 <a data-wp-bind--href="context.item.url" target="_blank" rel="noopener noreferrer">
293 <?php if ( $show_avatars ) : ?>
294 <img
295 alt=""
296 data-wp-bind--alt="context.item.name"
297 data-wp-bind--src="context.item.avatar"
298 data-wp-on--error="callbacks.setDefaultAvatar"
299 src=""
300 />
301 <?php endif; ?>
302 <span class="reaction-name" data-wp-text="context.item.name"></span>
303 </a>
304 </li>
305 </template>
306 </ul>
307 </div>
308 <?php if ( $attributes['showActions'] ) : ?>
309 <div class="activitypub-intent-dialog" data-wp-bind--hidden="context.modal.isCompact">
310 <div class="activitypub-dialog__section">
311 <h4><?php esc_html_e( 'Post URL', 'activitypub' ); ?></h4>
312 <div class="activitypub-dialog__description">
313 <?php esc_html_e( 'Paste the post URL into the search field of your favorite open social app or platform.', 'activitypub' ); ?>
314 </div>
315 <div class="activitypub-dialog__button-group">
316 <label for="<?php echo esc_attr( $block_id . '-post-url' ); ?>" class="screen-reader-text">
317 <?php esc_html_e( 'Post URL', 'activitypub' ); ?>
318 </label>
319 <input
320 aria-readonly="true"
321 class="wp-block-search__input"
322 id="<?php echo esc_attr( $block_id . '-post-url' ); ?>"
323 readonly
324 tabindex="-1"
325 type="text"
326 data-wp-bind--value="context.postUrl"
327 />
328 <button
329 aria-label="<?php esc_attr_e( 'Copy URL to clipboard', 'activitypub' ); ?>"
330 class="wp-element-button"
331 data-wp-on--click="actions.copyPostUrl"
332 type="button"
333 >
334 <span data-wp-text="context.copyButtonText"></span>
335 </button>
336 </div>
337 </div>
338 <div class="activitypub-dialog__section">
339 <h4><?php esc_html_e( 'Your Profile', 'activitypub' ); ?></h4>
340 <div class="activitypub-dialog__description">
341 <?php esc_html_e( 'Or, if you know your own profile, we can start things that way!', 'activitypub' ); ?>
342 <?php Blocks::render_modal_help(); ?>
343 </div>
344 <div class="activitypub-dialog__button-group">
345 <label for="<?php echo esc_attr( $block_id . '-remote-profile' ); ?>" class="screen-reader-text">
346 <?php esc_html_e( 'Your Fediverse profile', 'activitypub' ); ?>
347 </label>
348 <input
349 aria-describedby="<?php echo esc_attr( $error_id ); ?>"
350 class="wp-block-search__input"
351 data-wp-bind--aria-invalid="context.isError"
352 data-wp-bind--value="context.remoteProfile"
353 data-wp-on--input="actions.updateIntentProfile"
354 data-wp-on--keydown="actions.onIntentKeydown"
355 id="<?php echo esc_attr( $block_id . '-remote-profile' ); ?>"
356 placeholder="<?php esc_attr_e( '@username@example.com', 'activitypub' ); ?>"
357 type="text"
358 />
359 <button
360 class="wp-element-button"
361 data-wp-bind--disabled="context.isLoading"
362 data-wp-on--click="actions.submitIntent"
363 type="button"
364 >
365 <span data-wp-bind--hidden="context.isLoading"><?php esc_html_e( 'Go', 'activitypub' ); ?></span>
366 <span data-wp-bind--hidden="!context.isLoading"><?php esc_html_e( 'Loading…', 'activitypub' ); ?></span>
367 </button>
368 </div>
369 <div
370 class="activitypub-dialog__error"
371 data-wp-text="context.errorMessage"
372 id="<?php echo esc_attr( $error_id ); ?>"
373 role="alert"
374 ></div>
375 <div class="activitypub-dialog__remember">
376 <label>
377 <input
378 checked
379 data-wp-bind--checked="context.shouldSaveProfile"
380 data-wp-on--change="actions.toggleRememberProfile"
381 type="checkbox"
382 />
383 <?php esc_html_e( 'Remember my profile for future interactions.', 'activitypub' ); ?>
384 </label>
385 </div>
386 </div>
387 </div>
388 <?php endif; ?>
389 <?php
390 $modal_content = ob_get_clean();
391
392 // Render the shared modal with both contents.
393 $modal_args = array(
394 'content' => $modal_content,
395 );
396
397 if ( $attributes['showActions'] ) {
398 $modal_args['title_binding'] = 'context.modal.title';
399 } else {
400 $modal_args['is_compact'] = true;
401 }
402
403 ob_start();
404 Blocks::render_modal( $modal_args );
405 $inner_content = $reactions_content . ob_get_clean();
406
407 $wrapper_attrs = array(
408 'id' => $block_id,
409 'class' => $attributes['className'] ?? '',
410 'data-wp-interactive' => 'activitypub/reactions',
411 'data-wp-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
412 'data-wp-init' => 'callbacks.initReactions',
413 );
414
415 $wrapper_attributes = get_block_wrapper_attributes( $wrapper_attrs );
416
417 // Render the block with common wrapper.
418 ?>
419 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput ?>>
420 <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput ?>
421 <?php echo $inner_content; // phpcs:ignore WordPress.Security.EscapeOutput ?>
422 </div>
423