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 { $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() { // 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_collaboration_inject_setting' ) ) { /** * Registers the real-time collaboration setting. */ function gutenberg_register_real_time_collaboration_setting() { $option_name = 'wp_collaboration_enabled'; register_setting( 'writing', $option_name, array( 'type' => 'boolean', 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ), 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => true, 'show_in_rest' => true, ) ); add_settings_field( $option_name, __( 'Collaboration', 'gutenberg' ), function () use ( $option_name ) { $option_value = get_option( $option_name ); if ( wp_is_collaboration_allowed() ) : ?>
' . __( '%s Real-time collaboration has been disabled.', 'gutenberg' ) . '

', '' . __( 'Note:', 'gutenberg' ) . '' ); ?>
true ) ), 'wp_is_post_type_collaboration_disabled' ) ); wp_add_inline_script( 'wp-core-data', 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';' . 'window._wpCollaborationDisabledPostTypes = ' . wp_json_encode( $disabled_post_types ) . ';', 'after' ); } add_action( 'admin_init', 'gutenberg_inject_real_time_collaboration_setting' ); /** * Core adds an option with the default value, so we need to set the option to * our intended default when the Gutenberg plugin is activated, provided * collaboration is allowed. */ function gutenberg_set_collaboration_option_on_activation() { if ( wp_is_collaboration_allowed() ) { update_option( 'wp_collaboration_enabled', '1' ); } } add_action( 'activate_' . plugin_basename( dirname( __DIR__, 3 ) . '/gutenberg.php' ), 'gutenberg_set_collaboration_option_on_activation' ); /** * 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 controls that core normally hides * for locked posts (since collaborative editing is possible). * * @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 ); // 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' ); /** * Filters the heartbeat response to remove user-specific lock information * when real-time collaboration is enabled. * * WordPress core's wp_check_locked_posts() runs at priority 10 and populates * the 'wp-check-locked-posts' key with user name, avatar, and text. This * filter runs at priority 20 to replace that data with a generic message, * preventing user-specific lock info from reaching the client. * * @param array $response The heartbeat response. * @return array Modified heartbeat response. */ function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response ) { 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'] ); } } return $response; } /** * 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 and row actions that WordPress core hides for * locked posts, since collaborative editing means the post is not exclusively * locked. Toggles "Edit" / "Join" action link text via the * `.wp-collaborative-editing` class that the heartbeat already manages. */ 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 it — that's the fix for the original "Edit invisible * at 0px on mobile" regression. 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”' ), $title ) ), __( 'Edit' ), /* 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; }