PluginProbe
Gutenberg / 24.1.0
Gutenberg v24.1.0
24.1.0 24.0.0 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 All 404 releases
gutenberg / lib / experimental / collaboration / collaboration.php

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

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