array( 'name' => __( 'Sync Updates', 'gutenberg' ), 'singular_name' => __( 'Sync Update', 'gutenberg' ), ), 'public' => false, 'hierarchical' => false, 'capabilities' => array( 'read' => 'do_not_allow', 'read_private_posts' => 'do_not_allow', 'create_posts' => 'do_not_allow', 'publish_posts' => 'do_not_allow', 'edit_posts' => 'do_not_allow', 'edit_others_posts' => 'do_not_allow', 'edit_published_posts' => 'do_not_allow', 'delete_posts' => 'do_not_allow', 'delete_others_posts' => 'do_not_allow', 'delete_published_posts' => 'do_not_allow', ), 'map_meta_cap' => false, 'publicly_queryable' => false, 'query_var' => false, 'rewrite' => false, 'show_in_menu' => false, 'show_in_rest' => false, 'show_ui' => false, 'supports' => array( 'custom-fields' ), ) ); } add_action( 'init', 'gutenberg_register_sync_storage_post_type' ); } if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) { /** * Registers REST API routes for collaborative editing. */ function gutenberg_register_collaboration_rest_routes(): void { if ( ! wp_is_collaboration_enabled() ) { return; } /** * Filters the sync storage implementation for collaborative editing. * * Allows plugins to replace the default post meta storage with alternative * backends. The primary use case is the realtime-collaboration plugin, * which uses Presence API for awareness and a dedicated wp_collaboration * table for CRDT updates, eliminating cache side effects. * * This filter is unstable and may change as RTC explores fundamental changes * to how syncing works. The current interface assumes a pure naïve relay, * which could change. * * @since Gutenberg 21.x * * @param WP_Sync_Storage $sync_storage Storage implementation. Must implement * the WP_Sync_Storage interface. */ $sync_storage = apply_filters( '__unstable_wp_sync_storage', new WP_Sync_Post_Meta_Storage() ); if ( ! $sync_storage instanceof WP_Sync_Storage ) { $sync_storage = new WP_Sync_Post_Meta_Storage(); } $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); $sync_server->register_routes(); $sync_save_server = new WP_Sync_Save_Server(); $sync_save_server->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' ); } if ( ! function_exists( 'wp_collaboration_register_meta' ) ) { /** * Registers post meta for persisting CRDT documents. */ function gutenberg_rest_api_crdt_post_meta() { if ( ! wp_is_collaboration_enabled() ) { return; } // This string must match POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/core-data. $persisted_crdt_post_meta_key = '_crdt_document'; register_meta( 'post', $persisted_crdt_post_meta_key, array( 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { return user_can( $user_id, 'edit_post', $object_id ); }, /* * Revisions must be disabled because we always want to preserve * the latest persisted CRDT document, even when a revision is restored. * This ensures that we can continue to apply updates to a shared document * and peers can simply merge the restored revision like any other incoming * update. * * If we want to persist CRDT documents alongside revisions in the * future, we should do so in a separate meta key. */ 'revisions_enabled' => false, 'show_in_rest' => array( 'schema' => array( 'type' => 'string', 'context' => array( 'edit' ), ), ), 'single' => true, 'type' => 'string', ) ); } add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); } if ( ! function_exists( 'wp_is_collaboration_enabled' ) ) { /** * Determines whether real-time collaboration is enabled. * * @since 7.0.0 * * @return bool Whether real-time collaboration is enabled. */ function wp_is_collaboration_enabled() { return gutenberg_is_experiment_enabled( 'gutenberg-real-time-collaboration' ); } } if ( ! function_exists( 'wp_is_post_type_collaboration_disabled' ) ) { /** * Determines whether real-time collaboration is disabled for a post type. * * @since 7.1.0 * * @param string $post_type Post type name. * @return bool Whether real-time collaboration is disabled for the post type. */ function wp_is_post_type_collaboration_disabled( $post_type ) { if ( ! post_type_exists( $post_type ) ) { return true; } /** * Filters whether real-time collaboration is disabled for a post type. * * @since 7.1.0 * * @param bool $disabled Whether real-time collaboration is disabled for the post type. * @param string $post_type Post type name. */ return (bool) apply_filters( 'wp_is_post_type_collaboration_disabled', false, $post_type ); } } /** * Disables real-time collaboration for post types that cannot persist the * CRDT document. * * Collaboration stores its CRDT document in post meta. The REST API only * exposes post meta for post types that support custom fields, so enabling * collaboration for other post types can cause stale sync updates to replace * newer entity content. * * @param bool $disabled Whether real-time collaboration is disabled for the post type. * @param string $post_type Post type name. * @return bool Whether real-time collaboration is disabled for the post type. */ function gutenberg_disable_collaboration_for_post_types_without_custom_fields( $disabled, $post_type ) { if ( $disabled ) { return $disabled; } /* * The attachments REST controller always exposes meta, regardless of * whether the attachment post type supports custom fields. */ if ( 'attachment' === $post_type ) { return false; } return ! post_type_supports( $post_type, 'custom-fields' ); } add_filter( 'wp_is_post_type_collaboration_disabled', 'gutenberg_disable_collaboration_for_post_types_without_custom_fields', 10, 2 ); if ( ! function_exists( 'gutenberg_get_active_edit_lock_user' ) ) { /** * Returns the user ID recorded in a fresh edit lock. * * Unlike wp_check_post_lock(), this includes locks owned by the current user. * * @since 7.1.0 * * @param int $post_id Post ID. * @return int User ID from a fresh lock, or 0 if none exists. */ function gutenberg_get_active_edit_lock_user( $post_id ) { $lock = get_post_meta( $post_id, '_edit_lock', true ); if ( ! $lock ) { return 0; } $lock = explode( ':', $lock ); $time = (int) $lock[0]; $user = isset( $lock[1] ) ? (int) $lock[1] : (int) get_post_meta( $post_id, '_edit_last', true ); if ( ! $time || ! $user || ! get_userdata( $user ) ) { return 0; } /** This filter is documented in wp-admin/includes/ajax-actions.php */ $time_window = apply_filters( 'wp_check_post_lock_window', 150 ); if ( $time > time() - $time_window ) { return $user; } return 0; } } /** * Injects the post types for which real-time collaboration is disabled. */ function gutenberg_inject_collaboration_disabled_post_types() { if ( ! wp_is_collaboration_enabled() ) { return; } $disabled_post_types = array_values( array_filter( get_post_types( array( 'show_in_rest' => true ) ), 'wp_is_post_type_collaboration_disabled' ) ); wp_add_inline_script( 'wp-core-data', 'window._wpCollaborationDisabledPostTypes = ' . wp_json_encode( $disabled_post_types ) . ';', 'after' ); } add_action( 'admin_init', 'gutenberg_inject_collaboration_disabled_post_types' ); /** * Modifies the post list UI and heartbeat responses for real-time collaboration. * * When RTC is enabled, hides the lock icon and user avatar, replaces the * user-specific lock text with "Currently being edited", changes the "Edit" * row action to "Join", and re-enables bulk-edit checkboxes that core * normally hides for locked posts (Quick Edit intentionally stays hidden, * as it is not collaboration-aware). * * @global string $pagenow The filename of the current screen. */ function gutenberg_post_list_collaboration_ui() { global $pagenow; if ( ! wp_is_collaboration_enabled() ) { return; } // Heartbeat filter applies globally (not just edit.php) since the // heartbeat API can fire from any admin page. add_filter( 'heartbeat_received', 'gutenberg_filter_locked_posts_heartbeat_for_rtc', 20, 2 ); // Register globally because Quick Edit submits `action=inline-save` through admin-ajax.php. add_action( 'wp_ajax_inline-save', 'gutenberg_block_quick_edit_for_active_lock', 0 ); // CSS, JS, and row action overrides only apply on the posts list page. if ( 'edit.php' !== $pagenow ) { return; } add_action( 'admin_head', 'gutenberg_post_list_collaboration_styles' ); add_filter( 'gettext', 'gutenberg_filter_locked_post_text_for_rtc', 10, 3 ); add_filter( 'post_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 ); add_filter( 'page_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 ); } add_action( 'admin_init', 'gutenberg_post_list_collaboration_ui' ); /** * Removes user-specific details from post lock heartbeat responses and adds * fresh locks owned by the current user when collaboration is enabled. * * Core populates other-user lock data at priority 10 and excludes locks owned * by the current user. This filter runs at priority 20 to replace those details * with generic text and add the current user's own locks. * * @param array $response The heartbeat response. * @param array $data The data sent by the client. * @return array Modified heartbeat response. */ function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response, $data = array() ) { if ( ! empty( $response['wp-check-locked-posts'] ) ) { foreach ( $response['wp-check-locked-posts'] as $key => $lock_data ) { $response['wp-check-locked-posts'][ $key ]['text'] = __( 'Currently being edited', 'gutenberg' ); unset( $response['wp-check-locked-posts'][ $key ]['avatar_src'] ); unset( $response['wp-check-locked-posts'][ $key ]['avatar_src_2x'] ); } } if ( ! empty( $data['wp-check-locked-posts'] ) && is_array( $data['wp-check-locked-posts'] ) ) { foreach ( $data['wp-check-locked-posts'] as $key ) { if ( isset( $response['wp-check-locked-posts'][ $key ] ) ) { continue; } $post_id = absint( substr( $key, 5 ) ); if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { continue; } $post = get_post( $post_id ); if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) { continue; } $lock_user = gutenberg_get_active_edit_lock_user( $post_id ); if ( $lock_user && get_current_user_id() === $lock_user ) { $response['wp-check-locked-posts'][ $key ] = array( 'text' => __( 'Currently being edited', 'gutenberg' ), ); } } } return $response; } if ( ! function_exists( 'gutenberg_block_quick_edit_for_active_lock' ) ) { /** * Rejects Quick Edit while the current user holds a fresh edit lock. * * Core handles locks owned by other users but excludes the current user's * locks. Rejecting them prevents Quick Edit changes from diverging from the * editing session. The server check also covers post lists loaded before the * lock was created. * * @since 7.1.0 */ function gutenberg_block_quick_edit_for_active_lock() { check_ajax_referer( 'inlineeditnonce', '_inline_edit' ); $post_id = isset( $_POST['post_ID'] ) ? (int) $_POST['post_ID'] : 0; if ( ! $post_id ) { return; } $post = get_post( $post_id ); if ( ! $post || wp_is_post_type_collaboration_disabled( $post->post_type ) ) { return; } $lock_user = gutenberg_get_active_edit_lock_user( $post_id ); if ( ! $lock_user ) { /* * Core creates a lock during inline save. Prevent that specific write * so a later Quick Edit is not mistaken for an active editor session. */ add_filter( 'update_post_metadata', static function ( $check, $object_id, $meta_key ) use ( $post_id ) { if ( $post_id === (int) $object_id && '_edit_lock' === $meta_key ) { return false; } return $check; }, 10, 3 ); return; } if ( get_current_user_id() !== $lock_user ) { // Core handles locks owned by another user. return; } wp_die( esc_html__( 'Quick Edit is disabled: You are currently editing this post in another tab or window.', 'gutenberg' ) ); } } /** * Outputs CSS to hide the post lock icon and user avatar in the post list * when real-time collaboration is enabled. * * Also re-enables checkboxes that WordPress core hides for locked posts, since * collaborative editing means the post is not exclusively locked. It toggles * "Edit" / "Join" action link text using the `.wp-locked` class managed by * heartbeat. */ function gutenberg_post_list_collaboration_styles() { ?> post_type ) ) { return $actions; } $title = _draft_or_post_title( $post->ID ); /* * Each state is rendered as `…`. * The toggle classes sit on the outer rather than the so they * fall outside core's responsive selector `.row-actions span a` at * <=782px, which otherwise outranks our class selectors and (a) leaves * both labels visible on unlocked rows and (b) forces `display: inline` * on the visible Join link to misalign with sibling row actions. The * visible label is still a direct text child of , so core's mobile * font-size rule * .row-actions span { font-size: 0; } * .row-actions span a { font-size: 13px; } * still reaches the visible label. CSS in * gutenberg_post_list_collaboration_styles() flips visibility on the * outer spans based on the row's `wp-locked` class, which core's * inline-edit-post.js maintains in response to heartbeat ticks. */ $actions['edit'] = sprintf( '%3$s' . '%5$s', esc_url( get_edit_post_link( $post->ID ) ), /* translators: %s: Post title. */ esc_attr( sprintf( __( 'Edit “%s”', 'default' ), $title ) ), __( 'Edit', 'default' ), /* translators: %s: Post title. */ esc_attr( sprintf( __( 'Join editing “%s”', 'gutenberg' ), $title ) ), /* translators: Action link text for a singular post in the post list. Can be any type of post. */ _x( 'Join', 'post list', 'gutenberg' ) ); return $actions; } /** * Adds the autosave's CRDT snapshot to the block editor settings when * real-time collaboration is enabled. * * The snapshot describes the document state the autosave captured. The editor * verifies its own shared document against it, and suppresses the "there is a * more recent autosave" notice when the shared document already contains * everything the autosave holds. * * @param array $settings Editor settings. * @param WP_Block_Editor_Context $block_editor_context The current block editor context. * @return array Filtered editor settings. */ function gutenberg_add_autosave_details_to_editor_settings( $settings, $block_editor_context ) { if ( ! isset( $settings['autosave'] ) || empty( $block_editor_context->post ) ) { return $settings; } if ( ! wp_is_collaboration_enabled() ) { return $settings; } $post = $block_editor_context->post; if ( wp_is_post_type_collaboration_disabled( $post->post_type ) ) { return $settings; } $autosave = wp_get_post_autosave( $post->ID ); if ( ! $autosave ) { return $settings; } $snapshot = get_post_meta( $autosave->ID, Gutenberg_REST_Autosaves_Controller::CRDT_SNAPSHOT_META_KEY, true ); /* * Snapshots can be missing from a pre-collaboration autosave, classic editor autosave, * and other paths. The worst case is a "more recent autosave" notice when newer CRDT * content is already present in the shared document. */ if ( ! is_string( $snapshot ) || '' === $snapshot ) { return $settings; } $settings['autosave']['crdtSnapshot'] = $snapshot; return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_add_autosave_details_to_editor_settings', 10, 2 );