diff --git a/projects/packages/jetpack-mu-wpcom/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout new file mode 100644 index 000000000000..54eea051919c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +RTC: Add branded editor notices and room-limit enforcement for real-time collaboration. diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index 6121904c98e3..1fd5e6e89b85 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -454,6 +454,7 @@ public static function load_etk_features() { require_once __DIR__ . '/features/tags-education/tags-education.php'; require_once __DIR__ . '/features/wpcom-block-description-links/wpcom-block-description-links.php'; require_once __DIR__ . '/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php'; + require_once __DIR__ . '/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php'; require_once __DIR__ . '/features/wpcom-blocks/a8c-posts-list/a8c-posts-list.php'; require_once __DIR__ . '/features/wpcom-blocks/event-countdown/event-countdown.php'; require_once __DIR__ . '/features/wpcom-blocks/timeline/timeline.php'; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/class-wp-rest-rtc-notices.php b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/class-wp-rest-rtc-notices.php new file mode 100644 index 000000000000..692b3754f466 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/class-wp-rest-rtc-notices.php @@ -0,0 +1,237 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'dismiss_notice' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + register_rest_route( + 'wpcom/v2', + '/rtc-notices/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + register_rest_route( + 'wpcom/v2', + '/rtc-notices/join-request', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'record_join_request' ), + 'permission_callback' => array( $this, 'check_edit_post_permission' ), + 'args' => array( + 'post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + register_rest_route( + 'wpcom/v2', + '/rtc-notices/join-requests', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_join_requests' ), + 'permission_callback' => array( $this, 'check_admin_edit_post_permission' ), + 'args' => array( + 'post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + + register_rest_route( + 'wpcom/v2', + '/rtc-notices/join-requests/clear', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'clear_join_requests' ), + 'permission_callback' => array( $this, 'check_admin_edit_post_permission' ), + 'args' => array( + 'post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * Check if the current user is logged in. + * + * @return bool + */ + public function check_permission() { + return is_user_logged_in(); + } + + /** + * Check if the current user can edit the post specified in the request. + * + * @param WP_REST_Request $request The request. + * @return bool|WP_Error + */ + public function check_edit_post_permission( $request ) { + $post_id = $request->get_param( 'post_id' ); + + if ( ! get_post( $post_id ) ) { + return new \WP_Error( 'rest_post_invalid_id', __( 'Invalid post ID.', 'jetpack-mu-wpcom' ), array( 'status' => 404 ) ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to edit this post.', 'jetpack-mu-wpcom' ), array( 'status' => 403 ) ); + } + + return true; + } + + /** + * Check if the current user is an admin who can edit the post. + * + * @param WP_REST_Request $request The request. + * @return bool|WP_Error + */ + public function check_admin_edit_post_permission( $request ) { + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + + return $this->check_edit_post_permission( $request ); + } + + /** + * Dismiss the welcome notice. + * + * @return WP_REST_Response + */ + public function dismiss_notice() { + update_user_option( get_current_user_id(), self::OPTION_KEY, 'dismissed' ); + return rest_ensure_response( array( 'success' => true ) ); + } + + /** + * Get the dismissed status. + * + * @return WP_REST_Response + */ + public function get_status() { + return rest_ensure_response( array( 'dismissed' => self::is_dismissed() ) ); + } + + /** + * Check if the welcome notice is dismissed. + * Uses user options (not user meta) so dismissal is per-site on multisite/Simple. + * + * @return bool + */ + public static function is_dismissed() { + return 'dismissed' === get_user_option( self::OPTION_KEY, get_current_user_id() ); + } + + /** + * Record a join request from a blocked user. + * Stored as a transient per post so it auto-expires. + * + * @param WP_REST_Request $request The request. + * @return WP_REST_Response + */ + public function record_join_request( $request ) { + $post_id = $request->get_param( 'post_id' ); + $user = wp_get_current_user(); + + $key = self::JOIN_REQUEST_OPTION . '_' . $post_id; + $requests = get_transient( $key ); + if ( ! is_array( $requests ) ) { + $requests = array(); + } + + $requests[ $user->ID ] = array( + 'userName' => $user->display_name, + 'userId' => $user->ID, + 'time' => time(), + ); + + // Expire after 2 minutes. + set_transient( $key, $requests, 2 * MINUTE_IN_SECONDS ); + + return rest_ensure_response( array( 'success' => true ) ); + } + + /** + * Get pending join requests for a post. Admin only. + * + * @param WP_REST_Request $request The request. + * @return WP_REST_Response + */ + public function get_join_requests( $request ) { + $post_id = $request->get_param( 'post_id' ); + $key = self::JOIN_REQUEST_OPTION . '_' . $post_id; + $requests = get_transient( $key ); + + if ( ! is_array( $requests ) ) { + $requests = array(); + } + + // Filter out requests older than 60 seconds. + $now = time(); + $recent = array(); + foreach ( $requests as $uid => $req ) { + if ( $now - $req['time'] < 60 ) { + $recent[ $uid ] = $req; + } + } + + return rest_ensure_response( array( 'requests' => array_values( $recent ) ) ); + } + + /** + * Clear join requests for a post. + * + * @param WP_REST_Request $request The request. + * @return WP_REST_Response + */ + public function clear_join_requests( $request ) { + $post_id = $request->get_param( 'post_id' ); + delete_transient( self::JOIN_REQUEST_OPTION . '_' . $post_id ); + return rest_ensure_response( array( 'success' => true ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php new file mode 100644 index 000000000000..82cb897b3139 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php @@ -0,0 +1,133 @@ +register_routes(); + }, + 20 +); + +/** + * Get the maximum number of peers allowed per room. + * + * @return int Max peers per room. + */ +function wpcom_get_rtc_max_peers_per_room() { + return (int) apply_filters( 'wpcom_rtc_max_peers_per_room', 3 ); +} + +/** + * Get the maximum number of clients (tabs) allowed per user in a room. + * + * @return int Max clients per user. + */ +function wpcom_get_rtc_max_clients_per_user() { + return (int) apply_filters( 'wpcom_rtc_max_clients_per_user', 2 ); +} + +/** + * Check if the current user is the plan owner for this site. + * Works on Simple sites (via wpcom_get_blog_owner) and Atomic sites + * (via Jetpack connection master_user). Returns false on self-hosted + * since there is no WP.com plan to upgrade. + * + * @return bool + */ +function wpcom_rtc_is_plan_owner() { + $current_user_id = get_current_user_id(); + + // Simple sites: wpcom_get_blog_owner is the canonical source. + if ( function_exists( 'wpcom_get_blog_owner' ) ) { + $owner_id = wpcom_get_blog_owner( get_wpcom_blog_id() ); + return (int) $current_user_id === (int) $owner_id; + } + + // Atomic sites: the Jetpack connection master_user is the plan owner. + if ( class_exists( 'Jetpack_Options' ) ) { + $master_user = \Jetpack_Options::get_option( 'master_user' ); + if ( $master_user ) { + return (int) $current_user_id === (int) $master_user; + } + } + + return false; +} + +/** + * Enqueue block editor assets for RTC notices and limits. + */ +function wpcom_enqueue_rtc_notices_assets() { + global $pagenow; + + if ( ! \Automattic\Jetpack\RTC::is_enabled() ) { + return; + } + + // Real-time collaboration is not enabled in the site editor. + if ( + 'site-editor.php' === $pagenow || + ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ) { + return; + } + + // Don't show notices if the user has turned off RTC in Writing Settings. + // Both the old and new option names must be false to consider it disabled. + $old_option = get_option( 'wp_enable_real_time_collaboration', false ); + $new_option = get_option( 'wp_collaboration_enabled', false ); + if ( ! $old_option && ! $new_option ) { + return; + } + + // Notices are not relevant for P2 sites. + if ( function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() ) ) { + return; + } + + $handle = jetpack_mu_wpcom_enqueue_assets( 'gutenberg-rtc-notices', array( 'js', 'css' ) ); + wp_set_script_translations( $handle, 'jetpack-mu-wpcom' ); + + require_once __DIR__ . '/class-wp-rest-rtc-notices.php'; + + $is_admin_user = current_user_can( 'manage_options' ); + $is_plan_owner = wpcom_rtc_is_plan_owner(); + + $data = wp_json_encode( + array( + 'isAdmin' => $is_admin_user, + 'isPlanOwner' => $is_plan_owner, + 'welcomeDismissed' => WP_REST_RTC_Notices::is_dismissed(), + 'postId' => get_the_ID(), + 'postTitle' => get_the_title(), + 'postEditUrl' => get_edit_post_link( get_the_ID(), 'raw' ), + 'postsListUrl' => admin_url( 'edit.php' ), + 'siteSlug' => wpcom_get_site_slug(), + 'maxPeersPerRoom' => wpcom_get_rtc_max_peers_per_room(), + 'maxClientsPerUser' => wpcom_get_rtc_max_clients_per_user(), + 'enableLimitNotices' => apply_filters( 'wpcom_rtc_enable_limit_notices', false ), + ), + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP + ); + + wp_add_inline_script( + $handle, + "var wpcomRtcNotices = $data;", + 'before' + ); +} +add_action( 'enqueue_block_editor_assets', 'wpcom_enqueue_rtc_notices_assets' ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/images.d.ts b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/images.d.ts new file mode 100644 index 000000000000..cd4da83ca0d2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/images.d.ts @@ -0,0 +1,22 @@ +declare module '*.png' { + const src: string; + export default src; +} + +interface WpcomRtcNoticesConfig { + isAdmin: boolean; + isPlanOwner: boolean; + welcomeDismissed: boolean; + postId: number; + postTitle: string; + postEditUrl: string; + postsListUrl: string; + siteSlug: string; + maxPeersPerRoom?: number; + maxClientsPerUser?: number; + enableLimitNotices?: boolean; +} + +interface Window { + wpcomRtcNotices?: WpcomRtcNoticesConfig; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/index.tsx new file mode 100644 index 000000000000..4ff147b671f7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/index.tsx @@ -0,0 +1,72 @@ +/** + * Gutenberg RTC Notices & Limits + * + * WP.com-specific entry point for real-time collaboration in the block editor. + * Registers a sync.providers filter (priority 20) that wraps providers with + * room-limit enforcement, a filter on editor.SyncConnectionErrorModal to + * replace Gutenberg's default connection error modal with branded WP.com + * notices, and a block editor plugin for the welcome notice and admin polling. + */ + +import { addFilter } from '@wordpress/hooks'; +import { registerPlugin } from '@wordpress/plugins'; +import RtcAdminSomeoneWaitingNotice from './notices/rtc-admin-someone-waiting-notice'; +import { registerConnectionErrorModalFilter } from './notices/rtc-connection-error-modal-filter'; +import RtcNonAdminPostUpgradeNotice from './notices/rtc-non-admin-post-upgrade-notice'; +import RtcWelcomeNotice from './notices/rtc-welcome-notice'; +import { withRoomLimit } from './room-limit'; +import type { ProviderCreator } from '@wordpress/sync'; + +const enableLimitNotices = window.wpcomRtcNotices?.enableLimitNotices ?? false; + +/** + * Wrap all sync providers with room-limit enforcement. + * Runs at priority 20 so it wraps providers registered by the rtc package (priority 10). + */ +function registerRoomLimitFilter(): void { + const config = window.wpcomRtcNotices; + if ( ! config?.maxPeersPerRoom && ! config?.maxClientsPerUser ) { + return; + } + + addFilter( + 'sync.providers', + 'wpcom/rtc-room-limits', + ( providers: ProviderCreator[] ) => { + return providers.map( creator => + withRoomLimit( creator, config.maxPeersPerRoom, config.maxClientsPerUser ) + ); + }, + 20 + ); + + // Disable Gutenberg's built-in connection limit check so our + // room-limit.ts handles it with branded notices instead. + addFilter( + 'sync.pollingProvider.maxClientsPerRoom', + 'wpcom/rtc-disable-builtin-limit', + () => Number.MAX_SAFE_INTEGER + ); +} + +// Room-limit enforcement always runs (it stops polling, sends join requests). +// The branded modals are gated behind enableLimitNotices. +registerRoomLimitFilter(); + +if ( enableLimitNotices ) { + registerConnectionErrorModalFilter(); +} + +const RtcNoticesPlugin = () => { + return ( + <> + + { enableLimitNotices && } + { enableLimitNotices && } + + ); +}; + +registerPlugin( 'wpcom-rtc-notices', { + render: RtcNoticesPlugin, +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-admin-someone-waiting-notice.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-admin-someone-waiting-notice.tsx new file mode 100644 index 000000000000..5b654899f569 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-admin-someone-waiting-notice.tsx @@ -0,0 +1,107 @@ +/** + * RTC Admin Someone Waiting Notice + * + * Polls for pending join requests from non-admin users who were blocked + * by the collaborator limit. Shows a dismissible notice to the admin + * with the waiting user's name. + * + * Title: "Someone's waiting to join" + * CTA: "Upgrade to invite your entire team" + */ + +import apiFetch from '@wordpress/api-fetch'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import RtcNoticeModal from '../rtc-notice-modal'; + +interface JoinRequest { + userName: string; + userId: number; + time: number; +} + +const POLL_INTERVAL = 5000; + +const RtcAdminSomeoneWaitingNotice = () => { + const config = window.wpcomRtcNotices; + const [ waitingUser, setWaitingUser ] = useState< JoinRequest | null >( null ); + const dismissed = useRef( false ); + + const pollForRequests = useCallback( () => { + if ( ! config?.isPlanOwner || ! config.postId || dismissed.current ) { + return; + } + + apiFetch< { requests: JoinRequest[] } >( { + path: `/wpcom/v2/rtc-notices/join-requests?post_id=${ config.postId }`, + } ) + .then( response => { + if ( response.requests.length > 0 && ! dismissed.current ) { + setWaitingUser( response.requests[ 0 ] ); + } + } ) + .catch( () => {} ); + }, [ config ] ); + + useEffect( () => { + if ( ! config?.isPlanOwner || ! config.postId ) { + return; + } + + const interval = setInterval( pollForRequests, POLL_INTERVAL ); + return () => clearInterval( interval ); + }, [ config, pollForRequests ] ); + + const handleDismiss = useCallback( () => { + dismissed.current = true; + setWaitingUser( null ); + + if ( config?.postId ) { + apiFetch( { + path: '/wpcom/v2/rtc-notices/join-requests/clear', + method: 'POST', + data: { post_id: config.postId }, + } ).catch( () => {} ); + } + }, [ config ] ); + + if ( ! waitingUser || ! config?.isPlanOwner ) { + return null; + } + + const description = sprintf( + /* translators: 1: user name, 2: post title */ + __( + "%1$s wants to join \u201c%2$s\u201d while your team is already working on it, but you've hit your plan's collaborator limit.", + 'jetpack-mu-wpcom' + ), + waitingUser.userName, + config.postTitle + ); + + return ( + { + const slug = config.siteSlug || ''; + const upgradeUrl = `https://wordpress.com/setup/plan-upgrade/plans?${ new URLSearchParams( + { + siteSlug: slug, + feature: 'wpcom-ws-rtc', + cancel_to: `https://wordpress.com/post/${ slug }/${ config.postId }`, + } + ).toString() }`; + window.location.href = upgradeUrl; + }, + } } + onRequestClose={ handleDismiss } + className="rtc-notice-modal--someone-waiting" + /> + ); +}; + +export default RtcAdminSomeoneWaitingNotice; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-connection-error-modal-filter.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-connection-error-modal-filter.tsx new file mode 100644 index 000000000000..b99788972f34 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-connection-error-modal-filter.tsx @@ -0,0 +1,131 @@ +/** + * RTC Connection Error Modal Filter + * + * Replaces Gutenberg's default `SyncConnectionErrorModal` with a branded + * WP.com RTC notice using the `editor.SyncConnectionErrorModal` filter + * (added in Gutenberg PR #76554). + * + * Detects limit-related disconnections via: + * 1. The `connection-limit-exceeded` error code (from Gutenberg's built-in check). + * 2. The `isRoomLimitBreached()` flag (from our room-limit.ts wrapper). + * + * For non-admin users hitting the limit, also records a join request + * so the admin can be notified. + */ + +import apiFetch from '@wordpress/api-fetch'; +import { useEffect, useRef } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import { isRoomLimitBreached } from '../room-limit'; +import RtcNoticeModal from '../rtc-notice-modal'; +import type { FC } from 'react'; + +interface SyncConnectionErrorModalProps { + description: string; + error?: { code?: string }; + manualRetry?: () => void; + postType?: { slug?: string; labels?: { name?: string } } | null; + secondsRemainingUntilAutoRetry?: number; + title: string; +} + +const WpcomSyncConnectionErrorModal: FC< SyncConnectionErrorModalProps > = () => { + const config = window.wpcomRtcNotices; + const joinRequestSent = useRef( false ); + + // Non-admin: record a join request so the admin gets notified. + useEffect( () => { + if ( ! config || config.isAdmin || ! config.postId || joinRequestSent.current ) { + return; + } + joinRequestSent.current = true; + apiFetch( { + path: '/wpcom/v2/rtc-notices/join-request', + method: 'POST', + data: { post_id: config.postId }, + } ).catch( () => {} ); + }, [ config ] ); + + if ( ! config ) { + return null; + } + + const siteSlug = config.siteSlug || ''; + const upgradeUrl = `https://wordpress.com/setup/plan-upgrade/plans?${ new URLSearchParams( { + siteSlug, + feature: 'wpcom-ws-rtc', + cancel_to: `https://wordpress.com/post/${ siteSlug }/${ config.postId }`, + } ).toString() }`; + + if ( config.isPlanOwner ) { + return ( + { + window.location.href = upgradeUrl; + }, + } } + secondaryAction={ { + label: __( 'Back to posts', 'jetpack-mu-wpcom' ), + onClick: () => { + window.location.href = config.postsListUrl; + }, + } } + onRequestClose={ () => {} } + isDismissible={ false } + shouldCloseOnClickOutside={ false } + className="rtc-notice-modal--limit-reached" + /> + ); + } + + return ( + { + window.location.href = config.postsListUrl; + }, + } } + onRequestClose={ () => {} } + isDismissible={ false } + shouldCloseOnClickOutside={ false } + className="rtc-notice-modal--limit-reached" + /> + ); +}; + +/** + * Register the filter to replace Gutenberg's default sync connection error modal. + */ +export function registerConnectionErrorModalFilter(): void { + addFilter( + 'editor.SyncConnectionErrorModal', + 'wpcom/rtc-connection-error-modal', + ( OriginalComponent: FC< SyncConnectionErrorModalProps > ) => + ( props: SyncConnectionErrorModalProps ) => { + const isLimitError = + props.error?.code === 'connection-limit-exceeded' || isRoomLimitBreached(); + + if ( isLimitError ) { + return ; + } + + return ; + } + ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-non-admin-post-upgrade-notice.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-non-admin-post-upgrade-notice.tsx new file mode 100644 index 000000000000..188e9d521ce7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-non-admin-post-upgrade-notice.tsx @@ -0,0 +1,75 @@ +/** + * RTC Non-Admin Post Upgrade Notice + * + * Shown to non-admin users after the admin has upgraded the plan + * and there is now room for more collaborators on a post they + * were previously blocked from. + */ + +import { useEffect, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import RtcNoticeModal from '../rtc-notice-modal'; + +/** + * Props passed via a custom event from the RTC sync layer when + * a non-admin can now join after the admin upgraded. + */ +interface PostUpgradeData { + postTitle: string; + postEditUrl: string; +} + +const RtcNonAdminPostUpgradeNotice = () => { + const config = window.wpcomRtcNotices; + const [ noticeData, setNoticeData ] = useState< PostUpgradeData | null >( null ); + + // Listen for a custom event dispatched by the RTC sync layer. + useEffect( () => { + const handler = ( event: CustomEvent< PostUpgradeData > ) => { + if ( config && ! config.isAdmin ) { + setNoticeData( event.detail ); + } + }; + window.addEventListener( 'wpcom-rtc-post-upgrade', handler as EventListener ); + return () => { + window.removeEventListener( 'wpcom-rtc-post-upgrade', handler as EventListener ); + }; + }, [ config ] ); + + if ( ! noticeData || config?.isAdmin ) { + return null; + } + + const description = sprintf( + /* translators: %s: post title */ + __( + 'Your admin made room for more editors. Head back to \u201c%s\u201d and pick up where you left off.', + 'jetpack-mu-wpcom' + ), + noticeData.postTitle + ); + + const ctaLabel = sprintf( + /* translators: %s: post title */ + __( 'Edit \u201c%s\u201d', 'jetpack-mu-wpcom' ), + noticeData.postTitle + ); + + return ( + { + window.location.href = noticeData.postEditUrl; + }, + } } + onRequestClose={ () => setNoticeData( null ) } + className="rtc-notice-modal--non-admin-post-upgrade" + /> + ); +}; + +export default RtcNonAdminPostUpgradeNotice; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-welcome-notice.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-welcome-notice.tsx new file mode 100644 index 000000000000..93409ec22686 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/notices/rtc-welcome-notice.tsx @@ -0,0 +1,82 @@ +/** + * RTC Welcome Notice + * + * One-time dismissible notice shown when users first enter the editor + * on sites with real-time collaboration enabled. Dismissed per-site per-user. + * + * Title: "Edit posts together, in real time" + * Body: Explains RTC feature and invites feedback via support. + * CTA: "Start editing together" + */ + +import apiFetch from '@wordpress/api-fetch'; +import { useSelect } from '@wordpress/data'; +import { useState, useCallback, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { isRoomLimitBreached } from '../room-limit'; +import RtcNoticeModal from '../rtc-notice-modal'; + +// Wait before showing the welcome notice to give the sync connection +// time to establish and potentially hit the room limit first. +const WELCOME_NOTICE_DELAY_MS = 6000; + +interface SyncConnectionStatus { + status: string; +} + +const RtcWelcomeNotice = () => { + const config = window.wpcomRtcNotices; + + const [ isDismissed, setIsDismissed ] = useState( config?.welcomeDismissed ?? true ); + const [ isReady, setIsReady ] = useState( false ); + + // Delay showing the notice to avoid flashing it before a limit disconnect. + useEffect( () => { + const timeout = setTimeout( () => setIsReady( true ), WELCOME_NOTICE_DELAY_MS ); + return () => clearTimeout( timeout ); + }, [] ); + + // Don't show the welcome notice if the sync connection is lost (e.g. room + // limit reached). The limit-reached modal should take priority. + const isDisconnected = useSelect( select => { + const coreStore = select( 'core' ) as { + getSyncConnectionStatus?: () => SyncConnectionStatus | undefined; + }; + return ( + coreStore.getSyncConnectionStatus?.()?.status === 'disconnected' || isRoomLimitBreached() + ); + }, [] ); + + const dismissNotice = useCallback( () => { + setIsDismissed( true ); + apiFetch( { + path: '/wpcom/v2/rtc-notices/dismiss', + method: 'POST', + } ).catch( () => { + // Silently fail - the notice will show again next time if the API call fails. + } ); + }, [] ); + + if ( ! config || isDismissed || ! isReady || isDisconnected ) { + return null; + } + + return ( + + ); +}; + +export default RtcWelcomeNotice; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/room-limit.ts b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/room-limit.ts new file mode 100644 index 000000000000..cd8a36cef5c7 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/room-limit.ts @@ -0,0 +1,232 @@ +import type { ProviderCreator, ProviderCreatorResult } from '@wordpress/sync'; +import type { Awareness } from 'y-protocols/awareness'; + +/* + * Module-level state shared across all `withRoomLimit` wrappers. + * + * The polling manager in `@wordpress/sync` is a singleton that sends a single + * HTTP request for every registered room. To fully stop polling when the + * room limit is reached we must destroy *all* wrapped providers — entity + * rooms AND collection rooms — in one shot. We only trigger that global + * teardown when the current client is in the overflow set (newest by enteredAt). + */ +let breached = false; + +/** + * Returns true if the room limit has been breached in this window. + * Uses a window global so the value is accessible across webpack bundles + * (the RTC bundle sets it, the notices bundle reads it). + * + * @return Whether the room limit has been breached. + */ +export function isRoomLimitBreached(): boolean { + return ( window as Record< string, unknown > ).__wpcomRtcRoomLimitBreached === true; +} +const teardowns: Array< () => void > = []; + +const NOOP_RESULT: ProviderCreatorResult = { + destroy: () => {}, + on: () => {}, +}; + +/** + * Build a sorted list of unique collaborators (by WordPress user ID), ordered + * by the time they entered the room. + * + * The earliest `enteredAt` value for each user is used so that multiple tabs + * for the same user share a single, stable "join time". + * + * @param awareness - The Yjs awareness instance. + * @return Array of user descriptors sorted by enter time. + */ +function getSortedCollaborators( + awareness: Awareness +): Array< { id: number; enteredAt: number } > { + const byId = new Map< number, number >(); + + for ( const [ , state ] of awareness.getStates() ) { + const info = state?.collaboratorInfo as { id?: unknown; enteredAt?: unknown } | undefined; + const id = info?.id; + const enteredAt = info?.enteredAt; + if ( typeof id !== 'number' || typeof enteredAt !== 'number' ) { + continue; + } + const prev = byId.get( id ); + if ( prev === undefined || enteredAt < prev ) { + byId.set( id, enteredAt ); + } + } + + return Array.from( byId.entries() ) + .map( ( [ id, enteredAt ] ) => ( { id, enteredAt } ) ) + .sort( ( a, b ) => { + if ( a.enteredAt !== b.enteredAt ) { + return a.enteredAt - b.enteredAt; + } + return a.id - b.id; + } ); +} + +/** + * Build a sorted list of clients (tabs) for the local WordPress user, ordered + * by when they entered the room (earliest enteredAt per clientId). + * + * @param awareness - The Yjs awareness instance. + * @param localUserId - The WordPress user ID to filter by. + * @return Array of client descriptors sorted by enter time. + */ +function getSortedClientsForLocalUser( + awareness: Awareness, + localUserId: number +): Array< { clientId: number; enteredAt: number } > { + const byClientId = new Map< number, number >(); + + for ( const [ clientId, state ] of awareness.getStates() ) { + const info = state?.collaboratorInfo as { id?: unknown; enteredAt?: unknown } | undefined; + if ( info?.id !== localUserId || typeof info?.enteredAt !== 'number' ) { + continue; + } + const enteredAt = info.enteredAt as number; + const prev = byClientId.get( clientId ); + if ( prev === undefined || enteredAt < prev ) { + byClientId.set( clientId, enteredAt ); + } + } + + return Array.from( byClientId.entries() ) + .map( ( [ clientId, enteredAt ] ) => ( { clientId, enteredAt } ) ) + .sort( ( a, b ) => { + if ( a.enteredAt !== b.enteredAt ) { + return a.enteredAt - b.enteredAt; + } + return a.clientId - b.clientId; + } ); +} + +/** + * Wraps a provider creator to enforce a per-room user limit. + * + * Collaborators are ordered by `enteredAt`. When the total number of unique + * users (or clients for the same user) exceeds the allowed maximum, only the + * current client is considered "overflow" if it is among the newest (by enteredAt). + * In that case all wrapped providers in this window are destroyed. + * + * @param creator - The provider creator to wrap. + * @param maxPeersPerRoom - Max other unique users allowed. Undefined or <= 0 disables peer enforcement. + * @param maxClientsPerUser - Max additional clients (tabs) for the same user. Undefined or <= 0 disables per-user enforcement. + * @return Wrapped provider creator. + */ +export function withRoomLimit( + creator: ProviderCreator, + maxPeersPerRoom?: number, + maxClientsPerUser?: number +): ProviderCreator { + const hasPeerLimit = Boolean( maxPeersPerRoom && maxPeersPerRoom > 0 ); + const hasClientLimit = Boolean( maxClientsPerUser && maxClientsPerUser > 0 ); + if ( ! hasPeerLimit && ! hasClientLimit ) { + return creator; + } + + return async ( options ): Promise< ProviderCreatorResult > => { + if ( breached ) { + return NOOP_RESULT; + } + + const { awareness } = options; + const innerProvider = await creator( options ); + let destroyed = false; + + /** Trigger a global teardown for all wrapped providers in this window. */ + function destroyAll(): void { + breached = true; + ( window as Record< string, unknown > ).__wpcomRtcRoomLimitBreached = true; + + // Non-admin: record a join request so the admin gets notified. + const config = window.wpcomRtcNotices; + if ( config && ! config.isAdmin && config.postId ) { + const wp = ( window as Record< string, unknown > ).wp as + | { apiFetch?: ( opts: Record< string, unknown > ) => Promise< unknown > } + | undefined; + wp + ?.apiFetch?.( { + path: '/wpcom/v2/rtc-notices/join-request', + method: 'POST', + data: { post_id: config.postId }, + } ) + ?.catch?.( () => {} ); + } + + for ( const fn of teardowns ) { + fn(); + } + teardowns.length = 0; + } + + /** Tear down this single provider and detach the awareness listener. */ + function destroy(): void { + if ( destroyed ) { + return; + } + destroyed = true; + awareness?.off( 'change', onAwarenessChange ); + innerProvider.destroy(); + } + + /** React to awareness changes; trigger global teardown only when this client is in the overflow. */ + function onAwarenessChange(): void { + if ( destroyed || ! awareness ) { + return; + } + + const localUserId = awareness.getLocalState()?.collaboratorInfo?.id; + if ( typeof localUserId !== 'number' ) { + return; + } + + // Peer limit: too many unique users; only newest users (by enteredAt) are overflow. + if ( hasPeerLimit ) { + const collaborators = getSortedCollaborators( awareness ); + if ( collaborators.length > maxPeersPerRoom! ) { + const overflow = collaborators.slice( maxPeersPerRoom ); + if ( overflow.some( user => user.id === localUserId ) ) { + destroyAll(); + return; + } + } + } + + // Client limit: too many tabs for this user; only newest clients (by enteredAt) are overflow. + if ( hasClientLimit ) { + const localClientId = ( awareness as Awareness & { clientID?: unknown } ).clientID; + if ( typeof localClientId !== 'number' ) { + return; + } + const clientsForUser = getSortedClientsForLocalUser( awareness, localUserId ); + if ( clientsForUser.length > maxClientsPerUser! ) { + const overflow = clientsForUser.slice( maxClientsPerUser ); + if ( overflow.some( c => c.clientId === localClientId ) ) { + destroyAll(); + } + } + } + } + + teardowns.push( destroy ); + + if ( awareness ) { + awareness.on( 'change', onAwarenessChange ); + onAwarenessChange(); + } + + return { + destroy: () => { + const idx = teardowns.indexOf( destroy ); + if ( idx >= 0 ) { + teardowns.splice( idx, 1 ); + } + destroy(); + }, + on: innerProvider.on.bind( innerProvider ), + }; + }; +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-header-illustration.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-header-illustration.tsx new file mode 100644 index 000000000000..ffb0b49d45f3 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-header-illustration.tsx @@ -0,0 +1,21 @@ +/** + * RTC Header Illustration + * + * Blue decorative header for the RTC notice modals, featuring + * the "Real-time Collaboration" text with avatars and cursors. + */ + +import heroImage from './rtc-hero.png'; + +const RtcHeaderIllustration = () => { + return ( + + ); +}; + +export default RtcHeaderIllustration; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-hero.png b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-hero.png new file mode 100644 index 000000000000..fd360bc5d4ed Binary files /dev/null and b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-hero.png differ diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.scss b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.scss new file mode 100644 index 000000000000..93d8f15a897c --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.scss @@ -0,0 +1,106 @@ +@use "@wordpress/base-styles/mixins"; + +.rtc-notice-modal { + max-width: 409px; + border-radius: 8px; + overflow: hidden; + + .components-modal__header { + height: auto; + padding: 8px; + border-bottom: 0; + position: absolute; + inset-inline-start: 0; + inset-inline-end: 0; + margin: 0; + background-color: transparent; + z-index: 1; + + button { + inset-inline-start: unset; + + svg { + fill: #fff; + } + } + } + + .components-modal__content { + padding: 0; + margin-block-start: 0; + + &::before { + margin: 0; + } + } + + .rtc-notice-modal__header { + display: flex; + justify-content: center; + width: 100%; + line-height: 0; + } + + .rtc-notice-modal__header-illustration { + width: 100%; + height: auto; + display: block; + border-radius: 8px 8px 0 0; + } + + .rtc-notice-modal__body { + padding: 24px 32px 32px; + } + + .rtc-notice-modal__title { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 1.3; + color: #1e1e1e; + } + + .rtc-notice-modal__description { + margin: 12px 0 0; + font-size: 13px; + line-height: 1.6; + color: #1e1e1e; + } + + .rtc-notice-modal__actions { + display: flex; + flex-direction: column; + align-items: stretch; + margin-block-start: 24px; + gap: 12px; + } + + .rtc-notice-modal__primary-action.components-button { + justify-content: center; + padding: 12px 24px; + height: auto; + font-size: 14px; + font-weight: 500; + border-radius: 4px; + background-color: #3858e9; + + &:hover { + background-color: #2c48c7; + } + + &:focus { + box-shadow: 0 0 0 2px #3858e9; + } + } + + .rtc-notice-modal__secondary-action.components-button { + justify-content: center; + font-size: 14px; + color: #1e1e1e; + text-decoration: none; + + &:hover { + color: #3858e9; + } + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.tsx b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.tsx new file mode 100644 index 000000000000..6559dd1c1ee1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/rtc-notice-modal.tsx @@ -0,0 +1,110 @@ +/** + * RTC Notice Modal + * + * A generalized modal component for real-time collaboration notices. + * Used across different RTC notice scenarios (welcome, upgrade, blocked, etc.). + */ + +import { Button, Modal } from '@wordpress/components'; +import { useEffect, useRef } from '@wordpress/element'; +import clsx from 'clsx'; +import RtcHeaderIllustration from './rtc-header-illustration'; +import './rtc-notice-modal.scss'; +import type { FC, ReactNode } from 'react'; + +interface RtcNoticeAction { + /** Button label */ + label: string; + /** Click handler */ + onClick: () => void; + /** Button variant - defaults to 'primary' */ + variant?: 'primary' | 'secondary' | 'tertiary' | 'link'; +} + +interface RtcNoticeModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Modal title */ + title: string; + /** Modal description / body text */ + description: string | ReactNode; + /** Primary action button */ + primaryAction: RtcNoticeAction; + /** Optional secondary action (rendered as a link below the primary button) */ + secondaryAction?: RtcNoticeAction; + /** Handler for closing the modal (X button or overlay click) */ + onRequestClose: () => void; + /** Optional callback when modal opens */ + onOpen?: () => void; + /** Optional extra CSS class */ + className?: string; + /** Whether the modal shows the X close button. Defaults to true. */ + isDismissible?: boolean; + /** Whether clicking outside closes the modal. Defaults to true. */ + shouldCloseOnClickOutside?: boolean; +} + +const RtcNoticeModal: FC< RtcNoticeModalProps > = ( { + isOpen, + title, + description, + primaryAction, + secondaryAction, + onRequestClose, + onOpen, + className, + isDismissible = true, + shouldCloseOnClickOutside = true, +} ) => { + const prevIsOpen = useRef< boolean | null >( null ); + + useEffect( () => { + if ( ! prevIsOpen.current && isOpen ) { + onOpen?.(); + } + prevIsOpen.current = isOpen; + }, [ prevIsOpen, isOpen, onOpen ] ); + + if ( ! isOpen ) { + return null; + } + + return ( + +
+ +
+
+

{ title }

+

{ description }

+
+ + { secondaryAction && ( + + ) } +
+
+
+ ); +}; + +export default RtcNoticeModal; +export type { RtcNoticeAction, RtcNoticeModalProps }; diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/gutenberg-rtc-notices/RTC_Notices_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/gutenberg-rtc-notices/RTC_Notices_Test.php new file mode 100644 index 000000000000..72f4a9d5607d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/gutenberg-rtc-notices/RTC_Notices_Test.php @@ -0,0 +1,538 @@ +user_id = wp_insert_user( + array( + 'user_login' => 'rtc_test_user', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->user_id ); + } + + /** + * Original WP_Scripts instance. + * + * @var \WP_Scripts|null + */ + private $original_wp_scripts; + + /** + * Original WP_Styles instance. + * + * @var \WP_Styles|null + */ + private $original_wp_styles; + + /** + * Set up before each test. + * + * @before + */ + #[Before] + public function save_globals(): void { + global $wp_scripts, $wp_styles; + $this->original_wp_scripts = $wp_scripts; + $this->original_wp_styles = $wp_styles; + } + + /** + * Clean up after each test. + */ + public function tear_down(): void { + global $wp_scripts, $wp_styles; + $wp_scripts = $this->original_wp_scripts; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_styles = $this->original_wp_styles; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + wp_set_current_user( 0 ); + wp_delete_user( $this->user_id ); + + remove_all_filters( 'wpcom_rtc_max_peers_per_room' ); + remove_all_filters( 'wpcom_rtc_max_clients_per_user' ); + remove_all_filters( 'jetpack_rtc_enabled' ); + remove_all_filters( 'wpcom_rtc_enable_limit_notices' ); + + delete_option( 'wp_enable_real_time_collaboration' ); + delete_option( 'wp_collaboration_enabled' ); + + // Clean up any user options and transients left by tests. + delete_user_option( $this->user_id, WP_REST_RTC_Notices::OPTION_KEY ); + delete_transient( WP_REST_RTC_Notices::JOIN_REQUEST_OPTION . '_' . 999999 ); + + // Reset the REST server so stale route registrations don't leak + // into other test classes (e.g. Launchpad endpoint tests). + global $wp_rest_server; + $wp_rest_server = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + parent::tear_down(); + } + + /** + * Tests that default max peers per room is 2. + */ + public function test_default_max_peers_per_room() { + $this->assertSame( 3, wpcom_get_rtc_max_peers_per_room() ); + } + + /** + * Tests that max peers per room is filterable. + */ + public function test_filterable_max_peers_per_room() { + add_filter( + 'wpcom_rtc_max_peers_per_room', + function () { + return 5; + } + ); + + $this->assertSame( 5, wpcom_get_rtc_max_peers_per_room() ); + } + + /** + * Tests that default max clients per user is 2. + */ + public function test_default_max_clients_per_user() { + $this->assertSame( 2, wpcom_get_rtc_max_clients_per_user() ); + } + + /** + * Tests that max clients per user is filterable. + */ + public function test_filterable_max_clients_per_user() { + add_filter( + 'wpcom_rtc_max_clients_per_user', + function () { + return 3; + } + ); + + $this->assertSame( 3, wpcom_get_rtc_max_clients_per_user() ); + } + + /** + * Tests that welcome notice is not dismissed by default. + */ + public function test_welcome_notice_not_dismissed_by_default() { + $this->assertFalse( WP_REST_RTC_Notices::is_dismissed() ); + } + + /** + * Tests that welcome notice can be dismissed via user meta. + */ + public function test_welcome_notice_dismiss_persists() { + update_user_option( $this->user_id, WP_REST_RTC_Notices::OPTION_KEY, 'dismissed' ); + + $this->assertTrue( WP_REST_RTC_Notices::is_dismissed() ); + } + + /** + * Tests that welcome notice dismiss is per-user. + */ + public function test_welcome_notice_dismiss_is_per_user() { + $other_user_id = wp_insert_user( + array( + 'user_login' => 'rtc_test_user_2', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + + update_user_option( $this->user_id, WP_REST_RTC_Notices::OPTION_KEY, 'dismissed' ); + + wp_set_current_user( $other_user_id ); + $this->assertFalse( WP_REST_RTC_Notices::is_dismissed() ); + + wp_set_current_user( $this->user_id ); + $this->assertTrue( WP_REST_RTC_Notices::is_dismissed() ); + + wp_delete_user( $other_user_id ); + } + + /** + * Tests the dismiss REST endpoint. + */ + public function test_dismiss_rest_endpoint() { + $controller = new WP_REST_RTC_Notices(); + $response = $controller->dismiss_notice(); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertTrue( WP_REST_RTC_Notices::is_dismissed() ); + } + + /** + * Tests the status REST endpoint. + */ + public function test_status_rest_endpoint() { + $controller = new WP_REST_RTC_Notices(); + + $response = $controller->get_status(); + $this->assertFalse( $response->get_data()['dismissed'] ); + + $controller->dismiss_notice(); + + $response = $controller->get_status(); + $this->assertTrue( $response->get_data()['dismissed'] ); + } + + /** + * Tests that join request can be recorded and retrieved. + */ + public function test_join_request_record_and_retrieve() { + $post_id = wp_insert_post( + array( + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + $controller = new WP_REST_RTC_Notices(); + + $request = new WP_REST_Request( 'POST', '/wpcom/v2/rtc-notices/join-request' ); + $request->set_param( 'post_id', $post_id ); + $response = $controller->record_join_request( $request ); + $this->assertTrue( $response->get_data()['success'] ); + + $request = new WP_REST_Request( 'GET', '/wpcom/v2/rtc-notices/join-requests' ); + $request->set_param( 'post_id', $post_id ); + $response = $controller->get_join_requests( $request ); + $requests = $response->get_data()['requests']; + + $this->assertCount( 1, $requests ); + $this->assertSame( $this->user_id, $requests[0]['userId'] ); + + wp_delete_post( $post_id, true ); + } + + /** + * Tests that join requests can be cleared. + */ + public function test_clear_join_requests() { + $post_id = wp_insert_post( + array( + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + $controller = new WP_REST_RTC_Notices(); + + $request = new WP_REST_Request( 'POST', '/wpcom/v2/rtc-notices/join-request' ); + $request->set_param( 'post_id', $post_id ); + $controller->record_join_request( $request ); + + $request = new WP_REST_Request( 'POST', '/wpcom/v2/rtc-notices/join-requests/clear' ); + $request->set_param( 'post_id', $post_id ); + $controller->clear_join_requests( $request ); + + $request = new WP_REST_Request( 'GET', '/wpcom/v2/rtc-notices/join-requests' ); + $request->set_param( 'post_id', $post_id ); + $response = $controller->get_join_requests( $request ); + $this->assertCount( 0, $response->get_data()['requests'] ); + + wp_delete_post( $post_id, true ); + } + + /** + * Tests that permission check requires login. + */ + public function test_permission_requires_login() { + wp_set_current_user( 0 ); + $controller = new WP_REST_RTC_Notices(); + + $this->assertFalse( $controller->check_permission() ); + } + + /** + * Tests that join request rejects invalid post IDs. + */ + public function test_join_request_rejects_invalid_post_id() { + $controller = new WP_REST_RTC_Notices(); + + $request = new WP_REST_Request( 'POST', '/wpcom/v2/rtc-notices/join-request' ); + $request->set_param( 'post_id', 999999 ); + $result = $controller->check_edit_post_permission( $request ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'rest_post_invalid_id', $result->get_error_code() ); + } + + /** + * Tests that join request requires edit_post capability. + */ + public function test_join_request_requires_edit_post() { + $post_id = wp_insert_post( + array( + 'post_title' => 'Test Post', + 'post_status' => 'publish', + 'post_author' => $this->user_id, + ) + ); + + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'rtc_subscriber', + 'user_pass' => 'password', + 'role' => 'subscriber', + ) + ); + + $controller = new WP_REST_RTC_Notices(); + + $request = new WP_REST_Request( 'POST', '/wpcom/v2/rtc-notices/join-request' ); + $request->set_param( 'post_id', $post_id ); + + // Subscriber cannot edit posts. + wp_set_current_user( $subscriber_id ); + $result = $controller->check_edit_post_permission( $request ); + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + + // Admin can edit posts. + wp_set_current_user( $this->user_id ); + $result = $controller->check_edit_post_permission( $request ); + $this->assertTrue( $result ); + + wp_delete_user( $subscriber_id ); + wp_delete_post( $post_id, true ); + } + + /** + * Tests admin edit post permission denies non-admins. + */ + public function test_admin_edit_post_permission_denies_non_admin() { + $post_id = wp_insert_post( + array( + 'post_title' => 'Test Post', + 'post_status' => 'publish', + ) + ); + + $editor_id = wp_insert_user( + array( + 'user_login' => 'rtc_editor_perm', + 'user_pass' => 'password', + 'role' => 'editor', + ) + ); + + $controller = new WP_REST_RTC_Notices(); + $request = new WP_REST_Request( 'GET', '/wpcom/v2/rtc-notices/join-requests' ); + $request->set_param( 'post_id', $post_id ); + + wp_set_current_user( $editor_id ); + $this->assertFalse( $controller->check_admin_edit_post_permission( $request ) ); + + wp_set_current_user( $this->user_id ); + $this->assertTrue( $controller->check_admin_edit_post_permission( $request ) ); + + wp_delete_user( $editor_id ); + wp_delete_post( $post_id, true ); + } + + /** + * Reset scripts/styles to a fresh state for enqueue tests. + */ + private function reset_scripts(): void { + global $wp_scripts, $wp_styles; + $wp_scripts = new \WP_Scripts(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_styles = new \WP_Styles(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + + /** + * Tests that enqueue skips when RTC is not enabled. + */ + public function test_enqueue_skips_when_rtc_disabled() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_false' ); + + wpcom_enqueue_rtc_notices_assets(); + + $this->assertFalse( wp_script_is( 'jetpack-mu-wpcom-gutenberg-rtc-notices', 'enqueued' ) ); + } + + /** + * Tests that enqueue skips when RTC setting is off. + */ + public function test_enqueue_skips_when_rtc_setting_off() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + // Explicitly set to '0' — deleting won't work because gutenberg-rtc.php + // has a default_option filter that returns '1' when providers exist. + update_option( 'wp_enable_real_time_collaboration', '0' ); + update_option( 'wp_collaboration_enabled', '0' ); + + wpcom_enqueue_rtc_notices_assets(); + + $this->assertFalse( wp_script_is( 'jetpack-mu-wpcom-gutenberg-rtc-notices', 'enqueued' ) ); + } + + /** + * Tests that enqueue works when RTC is enabled and old setting is on. + */ + public function test_enqueue_works_with_old_rtc_option() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + update_option( 'wp_enable_real_time_collaboration', '1' ); + + wpcom_enqueue_rtc_notices_assets(); + + $this->assertTrue( wp_script_is( 'jetpack-mu-wpcom-gutenberg-rtc-notices', 'enqueued' ) ); + } + + /** + * Tests that enqueue works when RTC is enabled and new setting is on. + */ + public function test_enqueue_works_with_new_rtc_option() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + update_option( 'wp_collaboration_enabled', '1' ); + + wpcom_enqueue_rtc_notices_assets(); + + $this->assertTrue( wp_script_is( 'jetpack-mu-wpcom-gutenberg-rtc-notices', 'enqueued' ) ); + } + + /** + * Tests that the inline script includes expected config keys. + */ + public function test_enqueue_includes_inline_config() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + update_option( 'wp_enable_real_time_collaboration', '1' ); + + wpcom_enqueue_rtc_notices_assets(); + + global $wp_scripts; + $handle = 'jetpack-mu-wpcom-gutenberg-rtc-notices'; + $extra = $wp_scripts->registered[ $handle ]->extra['before'] ?? array(); + $inline = implode( "\n", array_filter( $extra ) ); + + $this->assertStringContainsString( 'wpcomRtcNotices', $inline ); + $this->assertStringContainsString( '"isAdmin"', $inline ); + $this->assertStringContainsString( '"welcomeDismissed"', $inline ); + $this->assertStringContainsString( '"maxPeersPerRoom"', $inline ); + $this->assertStringContainsString( '"maxClientsPerUser"', $inline ); + $this->assertStringContainsString( '"siteSlug"', $inline ); + } + + /** + * Tests that REST routes are registered. + */ + public function test_rest_routes_registered() { + $controller = new WP_REST_RTC_Notices(); + $controller->register_routes(); + + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wpcom/v2/rtc-notices/dismiss', $routes ); + $this->assertArrayHasKey( '/wpcom/v2/rtc-notices/status', $routes ); + $this->assertArrayHasKey( '/wpcom/v2/rtc-notices/join-request', $routes ); + $this->assertArrayHasKey( '/wpcom/v2/rtc-notices/join-requests', $routes ); + $this->assertArrayHasKey( '/wpcom/v2/rtc-notices/join-requests/clear', $routes ); + } + + /** + * Tests that plan owner returns false when no owner detection is available. + */ + public function test_plan_owner_returns_false_without_detection() { + $this->assertFalse( wpcom_rtc_is_plan_owner() ); + } + + /** + * Tests that plan owner uses Jetpack master_user when available. + */ + public function test_plan_owner_via_jetpack_master_user() { + if ( ! class_exists( 'Jetpack_Options' ) ) { + $this->markTestSkipped( 'Jetpack_Options not available' ); + } + + // Set master_user to current user. + \Jetpack_Options::update_option( 'master_user', $this->user_id ); + $this->assertTrue( wpcom_rtc_is_plan_owner() ); + + // Set master_user to a different user. + \Jetpack_Options::update_option( 'master_user', 999999 ); + $this->assertFalse( wpcom_rtc_is_plan_owner() ); + + \Jetpack_Options::delete_option( 'master_user' ); + } + + /** + * Tests that inline config includes isPlanOwner. + */ + public function test_enqueue_includes_is_plan_owner() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + update_option( 'wp_enable_real_time_collaboration', '1' ); + + wpcom_enqueue_rtc_notices_assets(); + + global $wp_scripts; + $handle = 'jetpack-mu-wpcom-gutenberg-rtc-notices'; + $extra = $wp_scripts->registered[ $handle ]->extra['before'] ?? array(); + $inline = implode( "\n", array_filter( $extra ) ); + + $this->assertStringContainsString( '"isPlanOwner"', $inline ); + $this->assertStringContainsString( '"enableLimitNotices"', $inline ); + } + + /** + * Tests that enqueue skips when both RTC options are explicitly off. + */ + public function test_enqueue_skips_when_both_options_explicitly_off() { + $this->reset_scripts(); + add_filter( 'jetpack_rtc_enabled', '__return_true' ); + update_option( 'wp_enable_real_time_collaboration', '0' ); + update_option( 'wp_collaboration_enabled', '0' ); + + wpcom_enqueue_rtc_notices_assets(); + + $this->assertFalse( wp_script_is( 'jetpack-mu-wpcom-gutenberg-rtc-notices', 'enqueued' ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/webpack.config.js b/projects/packages/jetpack-mu-wpcom/webpack.config.js index 02ea2bf0a897..c187717bb309 100644 --- a/projects/packages/jetpack-mu-wpcom/webpack.config.js +++ b/projects/packages/jetpack-mu-wpcom/webpack.config.js @@ -19,6 +19,7 @@ module.exports = async () => { './src/features/custom-css/custom-css/js/core-customizer-css-preview.js', 'customizer-control': './src/features/custom-css/custom-css/css/customizer-control.css', 'error-reporting': './src/features/error-reporting/index.js', + 'gutenberg-rtc-notices': './src/features/gutenberg-rtc-notices/index.tsx', 'holiday-snow': './src/features/holiday-snow/holiday-snow.scss', 'html-block-restricted-tags': './src/features/html-block-restricted-tags/html-block-restricted-tags.tsx', diff --git a/projects/packages/rtc/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout b/projects/packages/rtc/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout new file mode 100644 index 000000000000..3697c735bd29 --- /dev/null +++ b/projects/packages/rtc/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Disable RTC in site editor diff --git a/projects/packages/rtc/src/class-rtc.php b/projects/packages/rtc/src/class-rtc.php index 8d4fc825f8ae..15c3983d7f79 100644 --- a/projects/packages/rtc/src/class-rtc.php +++ b/projects/packages/rtc/src/class-rtc.php @@ -121,6 +121,16 @@ public static function register_rest_routes() { * @return void */ public static function enqueue_assets() { + global $pagenow; + + // Real-time collaboration is not enabled in the site editor. + if ( + 'site-editor.php' === $pagenow || + ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ) { + return; + } + $providers = self::get_providers(); // If HTTP polling (Gutenberg's built-in default provider when this script isn't enqueued)