PluginProbe
Gutenberg / 23.7.2
Gutenberg v23.7.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / collaboration / collaboration.php

collaboration.php in Gutenberg 23.7.2, at lib/experimental/collaboration/collaboration.php

593 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bootstraps collaborative editing.
4 *
5 * @package gutenberg
6 */
7
8 require_once __DIR__ . '/class-wp-sync-config.php';
9 if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) {
10 require_once __DIR__ . '/interface-wp-sync-storage.php';
11 require_once __DIR__ . '/class-wp-sync-post-meta-storage.php';
12 require_once __DIR__ . '/class-wp-http-polling-sync-server.php';
13 }
14 require_once __DIR__ . '/class-wp-sync-save-server.php';
15
16 if ( ! function_exists( 'gutenberg_register_sync_storage_post_type' ) ) {
17 /**
18 * Registers the custom post type for sync storage.
19 */
20 function gutenberg_register_sync_storage_post_type() {
21 register_post_type(
22 'wp_sync_storage',
23 array(
24 'labels' => array(
25 'name' => __( 'Sync Updates', 'gutenberg' ),
26 'singular_name' => __( 'Sync Update', 'gutenberg' ),
27 ),
28 'public' => false,
29 'hierarchical' => false,
30 'capabilities' => array(
31 'read' => 'do_not_allow',
32 'read_private_posts' => 'do_not_allow',
33 'create_posts' => 'do_not_allow',
34 'publish_posts' => 'do_not_allow',
35 'edit_posts' => 'do_not_allow',
36 'edit_others_posts' => 'do_not_allow',
37 'edit_published_posts' => 'do_not_allow',
38 'delete_posts' => 'do_not_allow',
39 'delete_others_posts' => 'do_not_allow',
40 'delete_published_posts' => 'do_not_allow',
41 ),
42 'map_meta_cap' => false,
43 'publicly_queryable' => false,
44 'query_var' => false,
45 'rewrite' => false,
46 'show_in_menu' => false,
47 'show_in_rest' => false,
48 'show_ui' => false,
49 'supports' => array( 'custom-fields' ),
50 )
51 );
52 }
53 add_action( 'init', 'gutenberg_register_sync_storage_post_type' );
54 }
55
56 if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) {
57 /**
58 * Registers REST API routes for collaborative editing.
59 */
60 function gutenberg_register_collaboration_rest_routes(): void {
61 $sync_storage = new WP_Sync_Post_Meta_Storage();
62 $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
63 $sync_server->register_routes();
64
65 $sync_save_server = new WP_Sync_Save_Server();
66 $sync_save_server->register_routes();
67 }
68 add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' );
69 }
70
71 if ( ! function_exists( 'wp_collaboration_register_meta' ) ) {
72 /**
73 * Registers post meta for persisting CRDT documents.
74 */
75 function gutenberg_rest_api_crdt_post_meta() {
76 // This string must match POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/core-data.
77 $persisted_crdt_post_meta_key = '_crdt_document';
78
79 register_meta(
80 'post',
81 $persisted_crdt_post_meta_key,
82 array(
83 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
84 return user_can( $user_id, 'edit_post', $object_id );
85 },
86 /*
87 * Revisions must be disabled because we always want to preserve
88 * the latest persisted CRDT document, even when a revision is restored.
89 * This ensures that we can continue to apply updates to a shared document
90 * and peers can simply merge the restored revision like any other incoming
91 * update.
92 *
93 * If we want to persist CRDT documents alongside revisions in the
94 * future, we should do so in a separate meta key.
95 */
96 'revisions_enabled' => false,
97 'show_in_rest' => array(
98 'schema' => array(
99 'type' => 'string',
100 'context' => array( 'edit' ),
101 ),
102 ),
103 'single' => true,
104 'type' => 'string',
105 )
106 );
107 }
108 add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' );
109 }
110
111 if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) {
112 /**
113 * Registers the real-time collaboration setting.
114 */
115 function gutenberg_register_real_time_collaboration_setting() {
116 $option_name = 'wp_collaboration_enabled';
117
118 register_setting(
119 'writing',
120 $option_name,
121 array(
122 'type' => 'boolean',
123 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ),
124 'sanitize_callback' => 'rest_sanitize_boolean',
125 'default' => true,
126 'show_in_rest' => true,
127 )
128 );
129
130 add_settings_field(
131 $option_name,
132 __( 'Collaboration', 'gutenberg' ),
133 function () use ( $option_name ) {
134 $option_value = get_option( $option_name );
135
136 if ( wp_is_collaboration_allowed() ) :
137 ?>
138 <label for="wp_collaboration_enabled">
139 <input name="wp_collaboration_enabled" type="checkbox" id="wp_collaboration_enabled" value="1" <?php checked( '1', $option_value ); ?>/>
140 <?php _e( "Enable early access to real-time collaboration. Real-time collaboration may affect your website's performance.", 'gutenberg' ); ?>
141 </label>
142 <?php else : ?>
143 <div class="notice notice-warning inline">
144 <?php
145 printf(
146 /* translators: %s: Prefix "Note:". */
147 '<p>' . __( '%s Real-time collaboration has been disabled.', 'gutenberg' ) . '</p>',
148 '<strong>' . __( 'Note:', 'gutenberg' ) . '</strong>'
149 );
150 ?>
151 </div>
152 <?php
153 endif;
154 },
155 'writing'
156 );
157 }
158 add_action( 'admin_init', 'gutenberg_register_real_time_collaboration_setting' );
159 }
160
161 if ( ! function_exists( 'wp_is_collaboration_enabled' ) ) {
162 /**
163 * Determines whether real-time collaboration is enabled.
164 *
165 * If the WP_ALLOW_COLLABORATION constant is false,
166 * collaboration is always disabled regardless of the database option.
167 * Otherwise, falls back to the 'wp_collaboration_enabled' option.
168 *
169 * @since 7.0.0
170 *
171 * @return bool Whether real-time collaboration is enabled.
172 */
173 function wp_is_collaboration_enabled() {
174 return ( wp_is_collaboration_allowed() && (bool) get_option( 'wp_collaboration_enabled' ) );
175 }
176 }
177
178 if ( ! function_exists( 'wp_is_collaboration_allowed' ) ) {
179 /**
180 * Determines whether real-time collaboration is allowed.
181 *
182 * If the WP_ALLOW_COLLABORATION constant is false,
183 * collaboration is not allowed and cannot be enabled.
184 * The constant defaults to true, unless the WP_ALLOW_COLLABORATION
185 * environment variable is set to string "false".
186 *
187 * @since 7.0.0
188 *
189 * @return bool Whether real-time collaboration is allowed.
190 */
191 function wp_is_collaboration_allowed() {
192 if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) {
193 $env_value = getenv( 'WP_ALLOW_COLLABORATION' );
194 if ( false === $env_value ) {
195 // Environment variable is not defined, default to allowing collaboration.
196 define( 'WP_ALLOW_COLLABORATION', true );
197 } else {
198 /*
199 * Environment variable is defined, let's confirm it is actually set to
200 * "true" as it may still have a string value "false" – the preceding
201 * `if` branch only tests for the boolean `false`.
202 */
203 define( 'WP_ALLOW_COLLABORATION', 'true' === $env_value );
204 }
205 }
206
207 return WP_ALLOW_COLLABORATION;
208 }
209 }
210
211 if ( ! function_exists( 'wp_is_post_type_collaboration_disabled' ) ) {
212 /**
213 * Determines whether real-time collaboration is disabled for a post type.
214 *
215 * @since 7.1.0
216 *
217 * @param string $post_type Post type name.
218 * @return bool Whether real-time collaboration is disabled for the post type.
219 */
220 function wp_is_post_type_collaboration_disabled( $post_type ) {
221 if ( ! post_type_exists( $post_type ) ) {
222 return true;
223 }
224
225 /**
226 * Filters whether real-time collaboration is disabled for a post type.
227 *
228 * @since 7.1.0
229 *
230 * @param bool $disabled Whether real-time collaboration is disabled for the post type.
231 * @param string $post_type Post type name.
232 */
233 return (bool) apply_filters( 'wp_is_post_type_collaboration_disabled', false, $post_type );
234 }
235 }
236
237 if ( ! function_exists( 'gutenberg_get_active_edit_lock_user' ) ) {
238 /**
239 * Returns the user ID recorded in a fresh edit lock.
240 *
241 * Unlike wp_check_post_lock(), this includes locks owned by the current user.
242 *
243 * @since 7.1.0
244 *
245 * @param int $post_id Post ID.
246 * @return int User ID from a fresh lock, or 0 if none exists.
247 */
248 function gutenberg_get_active_edit_lock_user( $post_id ) {
249 $lock = get_post_meta( $post_id, '_edit_lock', true );
250 if ( ! $lock ) {
251 return 0;
252 }
253
254 $lock = explode( ':', $lock );
255 $time = (int) $lock[0];
256 $user = isset( $lock[1] ) ? (int) $lock[1] : (int) get_post_meta( $post_id, '_edit_last', true );
257
258 if ( ! $time || ! $user || ! get_userdata( $user ) ) {
259 return 0;
260 }
261
262 /** This filter is documented in wp-admin/includes/ajax-actions.php */
263 $time_window = apply_filters( 'wp_check_post_lock_window', 150 );
264
265 if ( $time > time() - $time_window ) {
266 return $user;
267 }
268
269 return 0;
270 }
271 }
272
273 /**
274 * Injects the real-time collaboration setting into a global variable.
275 *
276 * @global string $pagenow The filename of the current screen.
277 */
278 function gutenberg_inject_real_time_collaboration_setting() {
279 global $pagenow;
280
281 if ( ! wp_is_collaboration_enabled() ) {
282 return;
283 }
284
285 // Disable real-time collaboration on the site editor.
286 $enabled = true;
287 if (
288 'site-editor.php' === $pagenow ||
289 ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === $_GET['page'] )
290 ) {
291 $enabled = false;
292 }
293
294 $disabled_post_types = array_values(
295 array_filter(
296 get_post_types( array( 'show_in_rest' => true ) ),
297 'wp_is_post_type_collaboration_disabled'
298 )
299 );
300
301 wp_add_inline_script(
302 'wp-core-data',
303 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';' .
304 'window._wpCollaborationDisabledPostTypes = ' . wp_json_encode( $disabled_post_types ) . ';',
305 'after'
306 );
307 }
308 add_action( 'admin_init', 'gutenberg_inject_real_time_collaboration_setting' );
309
310 /**
311 * Core adds an option with the default value, so we need to set the option to
312 * our intended default when the Gutenberg plugin is activated, provided
313 * collaboration is allowed.
314 */
315 function gutenberg_set_collaboration_option_on_activation() {
316 if ( wp_is_collaboration_allowed() ) {
317 update_option( 'wp_collaboration_enabled', '1' );
318 }
319 }
320 add_action( 'activate_' . plugin_basename( dirname( __DIR__, 3 ) . '/gutenberg.php' ), 'gutenberg_set_collaboration_option_on_activation' );
321
322 /**
323 * Modifies the post list UI and heartbeat responses for real-time collaboration.
324 *
325 * When RTC is enabled, hides the lock icon and user avatar, replaces the
326 * user-specific lock text with "Currently being edited", changes the "Edit"
327 * row action to "Join", and re-enables bulk-edit checkboxes that core
328 * normally hides for locked posts (Quick Edit intentionally stays hidden,
329 * as it is not collaboration-aware).
330 *
331 * @global string $pagenow The filename of the current screen.
332 */
333 function gutenberg_post_list_collaboration_ui() {
334 global $pagenow;
335
336 if ( ! wp_is_collaboration_enabled() ) {
337 return;
338 }
339
340 // Heartbeat filter applies globally (not just edit.php) since the
341 // heartbeat API can fire from any admin page.
342 add_filter( 'heartbeat_received', 'gutenberg_filter_locked_posts_heartbeat_for_rtc', 20, 2 );
343
344 // Register globally because Quick Edit submits `action=inline-save` through admin-ajax.php.
345 add_action( 'wp_ajax_inline-save', 'gutenberg_block_quick_edit_for_active_lock', 0 );
346
347 // CSS, JS, and row action overrides only apply on the posts list page.
348 if ( 'edit.php' !== $pagenow ) {
349 return;
350 }
351
352 add_action( 'admin_head', 'gutenberg_post_list_collaboration_styles' );
353 add_filter( 'gettext', 'gutenberg_filter_locked_post_text_for_rtc', 10, 3 );
354 add_filter( 'post_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
355 add_filter( 'page_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
356 }
357 add_action( 'admin_init', 'gutenberg_post_list_collaboration_ui' );
358
359 /**
360 * Removes user-specific details from post lock heartbeat responses and adds
361 * fresh locks owned by the current user when collaboration is enabled.
362 *
363 * Core populates other-user lock data at priority 10 and excludes locks owned
364 * by the current user. This filter runs at priority 20 to replace those details
365 * with generic text and add the current user's own locks.
366 *
367 * @param array $response The heartbeat response.
368 * @param array $data The data sent by the client.
369 * @return array Modified heartbeat response.
370 */
371 function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response, $data = array() ) {
372 if ( ! empty( $response['wp-check-locked-posts'] ) ) {
373 foreach ( $response['wp-check-locked-posts'] as $key => $lock_data ) {
374 $response['wp-check-locked-posts'][ $key ]['text'] = __( 'Currently being edited', 'gutenberg' );
375 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src'] );
376 unset( $response['wp-check-locked-posts'][ $key ]['avatar_src_2x'] );
377 }
378 }
379
380 if ( ! empty( $data['wp-check-locked-posts'] ) && is_array( $data['wp-check-locked-posts'] ) ) {
381 foreach ( $data['wp-check-locked-posts'] as $key ) {
382 if ( isset( $response['wp-check-locked-posts'][ $key ] ) ) {
383 continue;
384 }
385
386 $post_id = absint( substr( $key, 5 ) );
387 if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
388 continue;
389 }
390
391 $post = get_post( $post_id );
392 if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) {
393 continue;
394 }
395
396 $lock_user = gutenberg_get_active_edit_lock_user( $post_id );
397 if ( $lock_user && get_current_user_id() === $lock_user ) {
398 $response['wp-check-locked-posts'][ $key ] = array(
399 'text' => __( 'Currently being edited', 'gutenberg' ),
400 );
401 }
402 }
403 }
404
405 return $response;
406 }
407
408 if ( ! function_exists( 'gutenberg_block_quick_edit_for_active_lock' ) ) {
409 /**
410 * Rejects Quick Edit while the current user holds a fresh edit lock.
411 *
412 * Core handles locks owned by other users but excludes the current user's
413 * locks. Rejecting them prevents Quick Edit changes from diverging from the
414 * editing session. The server check also covers post lists loaded before the
415 * lock was created.
416 *
417 * @since 7.1.0
418 */
419 function gutenberg_block_quick_edit_for_active_lock() {
420 check_ajax_referer( 'inlineeditnonce', '_inline_edit' );
421
422 $post_id = isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0;
423 if ( ! $post_id ) {
424 return;
425 }
426
427 $post = get_post( $post_id );
428 if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) {
429 return;
430 }
431
432 $lock_user = gutenberg_get_active_edit_lock_user( $post_id );
433 if ( ! $lock_user ) {
434 /*
435 * Core creates a lock during inline save. Prevent that specific write
436 * so a later Quick Edit is not mistaken for an active editor session.
437 */
438 add_filter(
439 'update_post_metadata',
440 static function ( $check, $object_id, $meta_key ) use ( $post_id ) {
441 if ( $post_id === (int) $object_id && '_edit_lock' === $meta_key ) {
442 return false;
443 }
444
445 return $check;
446 },
447 10,
448 3
449 );
450 return;
451 }
452
453 if ( get_current_user_id() !== $lock_user ) {
454 // Core handles locks owned by another user.
455 return;
456 }
457
458 wp_die( esc_html__( 'Quick Edit is disabled: You are currently editing this post in another tab or window.', 'gutenberg' ) );
459 }
460 }
461
462 /**
463 * Outputs CSS to hide the post lock icon and user avatar in the post list
464 * when real-time collaboration is enabled.
465 *
466 * Also re-enables checkboxes that WordPress core hides for locked posts, since
467 * collaborative editing means the post is not exclusively locked. It toggles
468 * "Edit" / "Join" action link text using the `.wp-locked` class managed by
469 * heartbeat.
470 */
471 function gutenberg_post_list_collaboration_styles() {
472 ?>
473 <style type="text/css">
474 /*
475 * Hide the lock indicator icon in the checkbox column.
476 * WordPress core shows it via .wp-locked .locked-indicator { display: block },
477 * so we match that specificity to override it.
478 */
479 .wp-locked .locked-indicator {
480 display: none;
481 }
482 /* Hide the user avatar in the locked info area. */
483 .wp-locked .locked-info .locked-avatar {
484 display: none;
485 }
486 /*
487 * Re-enable bulk-edit checkboxes that core hides for locked posts,
488 * since RTC allows collaborative editing.
489 * Must use `tr.wp-locked` to match core's specificity in
490 * list-tables.css and actually override its `display: none`.
491 * Quick Edit intentionally stays hidden: it is not collaboration-aware,
492 * so edits made through it diverge from the content in an active editor session.
493 */
494 tr.wp-locked .check-column label,
495 tr.wp-locked .check-column input[type="checkbox"] {
496 display: revert;
497 }
498 /*
499 * Toggle "Edit" / "Join" action link text based on lock state.
500 * The heartbeat adds/removes .wp-locked on locked rows. This
501 * CSS only runs when RTC is enabled, so .wp-locked here always
502 * means collaborative editing, not exclusive locking.
503 */
504 .join-action-text {
505 display: none;
506 }
507 .wp-locked .edit-action-text {
508 display: none;
509 }
510 .wp-locked .join-action-text {
511 display: inline;
512 }
513 </style>
514 <?php
515 }
516
517 /**
518 * Filters the translation of the lock text to replace user-specific
519 * "%s is currently editing" with a generic "Currently being edited"
520 * message on initial page render.
521 *
522 * WordPress core outputs this text server-side in WP_Posts_List_Table.
523 * Using a gettext filter replaces it before it reaches the browser,
524 * avoiding a flash of the original text.
525 *
526 * @param string $translation Translated text.
527 * @param string $text Original text to translate.
528 * @param string $domain Text domain.
529 * @return string Modified translation.
530 */
531 function gutenberg_filter_locked_post_text_for_rtc( $translation, $text, $domain ) {
532 if ( 'default' === $domain && '%s is currently editing' === $text ) {
533 return __( 'Currently being edited', 'gutenberg' );
534 }
535
536 return $translation;
537 }
538
539 /**
540 * Filters post row actions to render both "Edit" and "Join" link text
541 * when real-time collaboration is enabled.
542 *
543 * Both labels are always present in the markup; CSS toggles visibility using
544 * the `.wp-locked` class managed by heartbeat. This updates the link text when
545 * the lock state changes without requiring a page reload.
546 *
547 * @param string[] $actions An array of row action links.
548 * @param WP_Post $post The post object.
549 * @return string[] Modified row action links.
550 */
551 function gutenberg_post_list_collaboration_row_actions( $actions, $post ) {
552 if ( ! isset( $actions['edit'] ) ) {
553 return $actions;
554 }
555
556 if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) {
557 return $actions;
558 }
559
560 $title = _draft_or_post_title( $post->ID );
561
562 /*
563 * Each state is rendered as `<span class="…-action-text"><a>…</a></span>`.
564 * The toggle classes sit on the outer <span> rather than the <a> so they
565 * fall outside core's responsive selector `.row-actions span a` at
566 * <=782px, which otherwise outranks our class selectors and (a) leaves
567 * both labels visible on unlocked rows and (b) forces `display: inline`
568 * on the visible Join link to misalign with sibling row actions. The
569 * visible label is still a direct text child of <a>, so core's mobile
570 * font-size rule
571 * .row-actions span { font-size: 0; }
572 * .row-actions span a { font-size: 13px; }
573 * still reaches the visible label. CSS in
574 * gutenberg_post_list_collaboration_styles() flips visibility on the
575 * outer spans based on the row's `wp-locked` class, which core's
576 * inline-edit-post.js maintains in response to heartbeat ticks.
577 */
578 $actions['edit'] = sprintf(
579 '<span class="edit-action-text"><a href="%1$s" aria-label="%2$s">%3$s</a></span>'
580 . '<span class="join-action-text"><a href="%1$s" aria-label="%4$s">%5$s</a></span>',
581 esc_url( get_edit_post_link( $post->ID ) ),
582 /* translators: %s: Post title. */
583 esc_attr( sprintf( __( 'Edit &#8220;%s&#8221;', 'default' ), $title ) ),
584 __( 'Edit', 'default' ),
585 /* translators: %s: Post title. */
586 esc_attr( sprintf( __( 'Join editing &#8220;%s&#8221;', 'gutenberg' ), $title ) ),
587 /* translators: Action link text for a singular post in the post list. Can be any type of post. */
588 _x( 'Join', 'post list', 'gutenberg' )
589 );
590
591 return $actions;
592 }
593