PluginProbe
ActivityPub / 9.2.0
ActivityPub v9.2.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.2.0, at build/reactions/render.php

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