diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts index eee5a7036da3f2..847d4b3eaa2709 100644 --- a/packages/core-data/src/awareness/types.ts +++ b/packages/core-data/src/awareness/types.ts @@ -94,3 +94,9 @@ export type EqualityFieldCheck< State, FieldName extends keyof State > = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean; + +export interface PostSaveEvent { + savedAt: number; + savedByClientId: number; + postStatus: string | undefined; +} diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index b055cce8dcb64c..212a6b12c2e1c6 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -2,6 +2,7 @@ * External dependencies */ import { useEffect, useState } from '@wordpress/element'; +import type { Y } from '@wordpress/sync'; /** * Internal dependencies @@ -9,6 +10,7 @@ import { useEffect, useState } from '@wordpress/element'; import { getSyncManager } from '../sync'; import type { PostEditorAwarenessState as ActiveCollaborator, + PostSaveEvent, YDocDebugData, } from '../awareness/types'; import type { SelectionState } from '../types'; @@ -156,3 +158,71 @@ export function useIsDisconnected( return usePostEditorAwarenessState( postId, postType ) .isCurrentCollaboratorDisconnected; } + +/** + * Hook that subscribes to the CRDT state map and returns the most recent + * save event (timestamp + client ID). The state map is updated by + * `markEntityAsSaved` in `@wordpress/sync` + * + * @param postId The ID of the post. + * @param postType The type of the post. + */ +export function useLastPostSave( + postId: number | null, + postType: string | null +): PostSaveEvent | null { + const [ lastSave, setLastSave ] = useState< PostSaveEvent | null >( null ); + + useEffect( () => { + if ( null === postId || null === postType ) { + setLastSave( null ); + return; + } + + const awareness = getSyncManager()?.getAwareness< PostEditorAwareness >( + `postType/${ postType }`, + postId.toString() + ); + + if ( ! awareness ) { + setLastSave( null ); + return; + } + + awareness.setUp(); + + const stateMap = awareness.doc.getMap( 'state' ); + const recordMap = awareness.doc.getMap( 'document' ); + + // Only notify for saves that occur after the observer is + // set up. This prevents false notifications when the Y.Doc + // syncs historical state on page load or peer reconnect. + const setupTime = Date.now(); + + const observer = ( event: Y.YMapEvent< unknown > ) => { + if ( event.keysChanged.has( 'savedAt' ) ) { + const savedAt = stateMap.get( 'savedAt' ) as number; + const savedByClientId = stateMap.get( 'savedBy' ) as number; + + if ( + typeof savedAt === 'number' && + typeof savedByClientId === 'number' && + savedAt > setupTime + ) { + const postStatus = recordMap.get( 'status' ) as + | string + | undefined; + setLastSave( { savedAt, savedByClientId, postStatus } ); + } + } + }; + + stateMap.observe( observer ); + + return () => { + stateMap.unobserve( observer ); + }; + }, [ postId, postType ] ); + + return lastSave; +} diff --git a/packages/core-data/src/private-apis.js b/packages/core-data/src/private-apis.js index 5f6e1919f3a459..bac5a347682ec7 100644 --- a/packages/core-data/src/private-apis.js +++ b/packages/core-data/src/private-apis.js @@ -6,6 +6,7 @@ import { RECEIVE_INTERMEDIATE_RESULTS } from './utils'; import { useActiveCollaborators, useResolvedSelection, + useLastPostSave, } from './hooks/use-post-editor-awareness-state'; import { lock } from './lock-unlock'; import { retrySyncConnection } from './sync'; @@ -17,4 +18,5 @@ lock( privateApis, { retrySyncConnection, useActiveCollaborators, useResolvedSelection, + useLastPostSave, } ); diff --git a/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts new file mode 100644 index 00000000000000..baf03e7be85051 --- /dev/null +++ b/packages/editor/src/components/collaborators-presence/test/use-collaborator-notifications.ts @@ -0,0 +1,454 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useCollaboratorNotifications } from '../use-collaborator-notifications'; + +// --- Mocks --- + +const mockCreateNotice = jest.fn(); +let mockActiveCollaborators: any[] = []; +let mockLastPostSave: { savedAt: number; savedByClientId: number } | null = + null; +let mockEditorState = { + postStatus: 'draft', + isCollaborationEnabled: true, +}; + +jest.mock( '@wordpress/data', () => ( { + useSelect: jest.fn(), + useDispatch: jest.fn(), +} ) ); + +jest.mock( '@wordpress/notices', () => ( { + store: 'core/notices', +} ) ); + +// Mock the editor store to prevent deep import chain (blocks, rich-text, etc.) +jest.mock( '../../../store', () => ( { + store: 'core/editor', +} ) ); + +// Mock the private APIs and unlock mechanism +jest.mock( '@wordpress/core-data', () => ( { + privateApis: {}, +} ) ); + +jest.mock( '../../../lock-unlock', () => ( { + unlock: jest.fn( () => ( { + useActiveCollaborators: jest.fn( () => mockActiveCollaborators ), + useLastPostSave: jest.fn( () => mockLastPostSave ), + } ) ), +} ) ); + +// --- Helpers --- + +const BASE_ENTERED_AT = 1704067200000; + +function makeCollaborator( overrides: Record< string, unknown > = {} ) { + return { + clientId: 1, + isMe: false, + isConnected: true, + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 1000, + }, + ...overrides, + }; +} + +function makeMe( overrides: Record< string, unknown > = {} ) { + return makeCollaborator( { + clientId: 999, + isMe: true, + collaboratorInfo: { + id: 1, + name: 'Me', + slug: 'me', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 5000, // joined later than Alice + }, + ...overrides, + } ); +} + +// --- Setup --- + +function buildMockSelect() { + return () => ( { + getCurrentPostAttribute: ( attr: string ) => + attr === 'status' ? mockEditorState.postStatus : undefined, + isCollaborationEnabledForCurrentPost: () => + mockEditorState.isCollaborationEnabled, + } ); +} + +beforeEach( () => { + mockActiveCollaborators = []; + mockLastPostSave = null; + mockEditorState = { + postStatus: 'draft', + isCollaborationEnabled: true, + }; + mockCreateNotice.mockClear(); + ( useSelect as jest.Mock ).mockImplementation( ( selector: Function ) => + selector( buildMockSelect() ) + ); + ( useDispatch as jest.Mock ).mockReturnValue( { + createNotice: mockCreateNotice, + } ); +} ); + +// --- Tests --- + +describe( 'useCollaboratorNotifications', () => { + describe( 'initial mount', () => { + it( 'does not fire join notifications for collaborators already present on mount', () => { + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; + + renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire any notification when no collaborators are present', () => { + mockActiveCollaborators = []; + + renderHook( () => useCollaboratorNotifications( 123, 'post' ) ); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire join notifications when collaborators load after an initially empty state', () => { + // Simulates the store hydrating: first render has no collaborators, + // second render receives the full list. + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'collaborator join notifications', () => { + it( 'does not fire a join notification for the current user', () => { + mockActiveCollaborators = []; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'skips join notification for collaborators who joined before current user', () => { + // Alice joined BEFORE the current user (smaller enteredAt) + const me = makeMe(); // enteredAt: BASE_ENTERED_AT + 5000 + const aliceJoinedFirst = makeCollaborator( { + collaboratorInfo: { + id: 100, + name: 'Alice', + slug: 'alice', + avatar_urls: {}, + browserType: 'Chrome', + enteredAt: BASE_ENTERED_AT + 1000, // joined earlier than me + }, + } ); + + mockActiveCollaborators = [ me ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice appears in the state — but she was there before us + mockActiveCollaborators = [ me, aliceJoinedFirst ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'fires join notification for a collaborator who joined after current user', () => { + const me = makeMe(); // enteredAt: BASE_ENTERED_AT + 5000 + const bobJoinedAfter = makeCollaborator( { + clientId: 2, + collaboratorInfo: { + id: 200, + name: 'Bob', + slug: 'bob', + avatar_urls: {}, + browserType: 'Firefox', + enteredAt: BASE_ENTERED_AT + 10000, // joined after me + }, + } ); + + mockActiveCollaborators = [ me ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockActiveCollaborators = [ me, bobJoinedAfter ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Bob has joined the post.', + expect.objectContaining( { + id: 'collab-user-entered-200', + } ) + ); + } ); + } ); + + describe( 'collaborator leave notifications', () => { + it( 'fires a leave notification when a collaborator disconnects (isConnected → false)', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disconnects — still in the list but greyed out. + mockActiveCollaborators = [ + makeMe(), + { ...alice, isConnected: false }, + ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Alice has left the post.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-user-exited-100', + } ) + ); + } ); + + it( 'does not fire a duplicate leave notification when a disconnected collaborator is removed from the list', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disconnects (greyed out). + mockActiveCollaborators = [ + makeMe(), + { ...alice, isConnected: false }, + ]; + rerender(); + mockCreateNotice.mockClear(); + + // After the 5s delay Alice is fully removed from the list. + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'fires a leave notification when a connected collaborator is removed from the list directly', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disappears from the list without going through isConnected=false + // (e.g. polling detects the disconnect and removes in one update). + mockActiveCollaborators = [ makeMe() ]; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Alice has left the post.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-user-exited-100', + } ) + ); + } ); + + it( 'does not fire a leave notification for the current user', () => { + const me = makeMe(); + mockActiveCollaborators = [ me, makeCollaborator() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // "Me" disconnects + mockActiveCollaborators = [ + { ...me, isConnected: false }, + makeCollaborator(), + ]; + rerender(); + + // Should not notify about self + const selfLeaveCall = mockCreateNotice.mock.calls.find( + ( [ , message ] ) => message.includes( 'Me' ) + ); + expect( selfLeaveCall ).toBeUndefined(); + } ); + } ); + + describe( 'post updated notifications', () => { + it( 'fires a post updated notification when a collaborator saves (draft)', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // State map reports Alice saved + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: alice.clientId, + }; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Draft saved by Alice.', + expect.objectContaining( { + type: 'snackbar', + isDismissible: false, + id: 'collab-post-updated-100', + } ) + ); + } ); + + it( 'fires a post updated notification with "Post updated" for published status', () => { + mockEditorState = { + ...mockEditorState, + postStatus: 'publish', + }; + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: alice.clientId, + }; + rerender(); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + 'info', + 'Post updated by Alice.', + expect.objectContaining( { + id: 'collab-post-updated-100', + } ) + ); + } ); + + it( 'does not fire a notification when the current user saves', () => { + const me = makeMe(); + mockActiveCollaborators = [ me, makeCollaborator() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // State map reports "me" saved + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: me.clientId, + }; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire duplicate notifications for the same savedAt timestamp', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const savedAt = Date.now(); + + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // First save event + mockLastPostSave = { + savedAt, + savedByClientId: alice.clientId, + }; + rerender(); + mockCreateNotice.mockClear(); + + // Rerender with same savedAt — should not notify again + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + + it( 'does not fire a notification when a peer reconnects without a new save', () => { + const alice = makeCollaborator(); + mockActiveCollaborators = [ makeMe(), alice ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // Alice disconnects and reconnects — useLastPostSave filters + // pre-existing state map values via its baseline check, so + // lastPostSave stays null. + mockActiveCollaborators = [ + makeMe(), + { ...alice, isConnected: false }, + ]; + rerender(); + mockCreateNotice.mockClear(); + + mockActiveCollaborators = [ + makeMe(), + { ...alice, clientId: 50, isConnected: true }, + ]; + rerender(); + + // Only the leave/join notices should have fired, no save notice. + const saveNotice = mockCreateNotice.mock.calls.find( + ( [ , msg ] ) => ( msg as string ).includes( 'saved' ) + ); + expect( saveNotice ).toBeUndefined(); + } ); + + it( 'does not fire a notification when the saver is not in the collaborator list', () => { + mockActiveCollaborators = [ makeMe(), makeCollaborator() ]; + const { rerender } = renderHook( () => + useCollaboratorNotifications( 123, 'post' ) + ); + + // State map reports a save by unknown clientId + mockLastPostSave = { + savedAt: Date.now(), + savedByClientId: 12345, + }; + rerender(); + + expect( mockCreateNotice ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts new file mode 100644 index 00000000000000..6a3820f505934c --- /dev/null +++ b/packages/editor/src/components/collaborators-presence/use-collaborator-notifications.ts @@ -0,0 +1,258 @@ +/** + * WordPress dependencies + */ +import { usePrevious } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { + privateApis, + type PostEditorAwarenessState, +} from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editorStore } from '../../store'; + +const { useActiveCollaborators, useLastPostSave } = unlock( privateApis ); + +/** + * Notice IDs for each notification type. Using stable IDs prevents duplicate + * notices if the same event is processed more than once. + */ +const NOTIFICATION_TYPE = { + COLLAB_POST_UPDATED: 'collab-post-updated', + COLLAB_USER_ENTERED: 'collab-user-entered', + COLLAB_USER_EXITED: 'collab-user-exited', +} as const; + +const NOTIFICATIONS_CONFIG = { + userEntered: true, + userExited: true, + postUpdated: true, +}; + +const PUBLISHED_STATUSES = [ 'publish', 'private', 'future' ]; + +/** + * Returns the snackbar message for a post updated notification. + * + * @param name Display name of the collaborator who saved. + * @param status WordPress post status at the time of save. + * @param isFirstPublish Whether this save transitioned the post to published. + */ +function getPostUpdatedMessage( + name: string, + status: string, + isFirstPublish: boolean +): string { + if ( isFirstPublish ) { + /* translators: %s: collaborator display name */ + return sprintf( __( 'Post published by %s.' ), name ); + } + if ( PUBLISHED_STATUSES.includes( status ) ) { + /* translators: %s: collaborator display name */ + return sprintf( __( 'Post updated by %s.' ), name ); + } + /* translators: %s: collaborator display name */ + return sprintf( __( 'Draft saved by %s.' ), name ); +} + +/** + * Hook that watches for collaborator join/leave events and remote save events, + * dispatching snackbar notices accordingly. + * + * @param postId The ID of the post being edited. + * @param postType The post type of the post being edited. + */ +export function useCollaboratorNotifications( + postId: number | null, + postType: string | null +): void { + const activeCollaborators = useActiveCollaborators( + postId, + postType + ) as PostEditorAwarenessState[]; + + const lastPostSave = useLastPostSave( postId, postType ); + + const { postStatus, isCollaborationEnabled } = useSelect( ( select ) => { + const editorSel = select( editorStore ); + return { + postStatus: editorSel.getCurrentPostAttribute( 'status' ) as + | string + | undefined, + isCollaborationEnabled: + editorSel.isCollaborationEnabledForCurrentPost(), + }; + }, [] ); + + const { createNotice } = useDispatch( noticesStore ); + + const prevCollaborators = usePrevious( activeCollaborators ); + const prevPostSave = usePrevious( lastPostSave ); + + /* + * Detect collaborator joins and leaves. + */ + useEffect( () => { + if ( ! isCollaborationEnabled ) { + return; + } + + /* + * On first render usePrevious returns undefined. On subsequent renders + * the list may still be empty while the store hydrates. In both cases, + * skip to avoid spurious "X joined" toasts for users already present. + */ + if ( ! prevCollaborators || prevCollaborators.length === 0 ) { + return; + } + + function notify( noticeId: string, message: string ) { + void createNotice( 'info', message, { + id: noticeId, + type: 'snackbar', + isDismissible: false, + } ); + } + + const prevMap = new Map< number, PostEditorAwarenessState >( + prevCollaborators.map( ( c ) => [ c.clientId, c ] ) + ); + const newMap = new Map< number, PostEditorAwarenessState >( + activeCollaborators.map( ( c ) => [ c.clientId, c ] ) + ); + + /* + * Detect joins: new clientIds that weren't in the previous state. + */ + if ( NOTIFICATIONS_CONFIG.userEntered ) { + const me = activeCollaborators.find( ( c ) => c.isMe ); + + for ( const [ clientId, collaborator ] of newMap ) { + if ( prevMap.has( clientId ) || collaborator.isMe ) { + continue; + } + + /* + * Skip collaborators who were present before the current user + * joined. Their enteredAt is earlier than ours, meaning we're + * the newcomer. + */ + if ( + me && + collaborator.collaboratorInfo.enteredAt < + me.collaboratorInfo.enteredAt + ) { + continue; + } + + notify( + `${ NOTIFICATION_TYPE.COLLAB_USER_ENTERED }-${ collaborator.collaboratorInfo.id }`, + sprintf( + /* translators: %s: collaborator display name */ + __( '%s has joined the post.' ), + collaborator.collaboratorInfo.name + ) + ); + } + } + + /* + * Detect leaves by iterating the previous collaborator list. A leave + * notification fires when a previously-connected collaborator either: + * - transitions to isConnected=false (greyed-out in the UI), or + * - disappears from the list entirely while still connected. + * Already-disconnected collaborators that are later removed from the + * list (after the 5 s delay) are silently ignored. + */ + if ( NOTIFICATIONS_CONFIG.userExited ) { + for ( const [ clientId, prevCollab ] of prevMap ) { + if ( prevCollab.isMe || ! prevCollab.isConnected ) { + continue; + } + + const newCollab = newMap.get( clientId ); + if ( newCollab?.isConnected ) { + continue; + } + + notify( + `${ NOTIFICATION_TYPE.COLLAB_USER_EXITED }-${ prevCollab.collaboratorInfo.id }`, + sprintf( + /* translators: %s: collaborator display name */ + __( '%s has left the post.' ), + prevCollab.collaboratorInfo.name + ) + ); + } + } + }, [ + activeCollaborators, + prevCollaborators, + isCollaborationEnabled, + createNotice, + ] ); + + /* + * Detect remote save events via the CRDT state map. The savedByClientId + * is a Y.Doc client ID which maps to a collaborator via clientId. + */ + useEffect( () => { + if ( + ! isCollaborationEnabled || + ! NOTIFICATIONS_CONFIG.postUpdated || + ! lastPostSave || + ! postStatus + ) { + return; + } + + if ( prevPostSave && lastPostSave.savedAt === prevPostSave.savedAt ) { + return; + } + + const saver = activeCollaborators.find( + ( c ) => c.clientId === lastPostSave.savedByClientId && ! c.isMe + ); + + if ( ! saver ) { + return; + } + + // Prefer the remote status from Y.Doc (accurate at save time) over + // the local Redux value, which may not have synced yet. + const effectiveStatus = + lastPostSave.postStatus ?? postStatus ?? 'draft'; + + // prevPostSave is null on the first save this session, so fall back + // to the Redux status (still pre-save when the notification fires). + const prevStatus = prevPostSave?.postStatus ?? postStatus; + const isFirstPublish = + ! ( prevStatus && PUBLISHED_STATUSES.includes( prevStatus ) ) && + PUBLISHED_STATUSES.includes( effectiveStatus ); + + const message = getPostUpdatedMessage( + saver.collaboratorInfo.name, + effectiveStatus, + isFirstPublish + ); + + void createNotice( 'info', message, { + id: `${ NOTIFICATION_TYPE.COLLAB_POST_UPDATED }-${ saver.collaboratorInfo.id }`, + type: 'snackbar', + isDismissible: false, + } ); + }, [ + lastPostSave, + prevPostSave, + activeCollaborators, + isCollaborationEnabled, + postStatus, + createNotice, + ] ); +} diff --git a/packages/editor/src/components/editor-interface/index.js b/packages/editor/src/components/editor-interface/index.js index f6ab27d02652c2..080962691a4e1c 100644 --- a/packages/editor/src/components/editor-interface/index.js +++ b/packages/editor/src/components/editor-interface/index.js @@ -27,6 +27,7 @@ import InserterSidebar from '../inserter-sidebar'; import ListViewSidebar from '../list-view-sidebar'; import { RevisionsHeader, RevisionsCanvas } from '../post-revisions-preview'; import { CollaboratorsOverlay } from '../collaborators-overlay'; +import { useCollaboratorNotifications } from '../collaborators-presence/use-collaborator-notifications'; import SavePublishPanels from '../save-publish-panels'; import TextEditor from '../text-editor'; import VisualEditor from '../visual-editor'; @@ -122,6 +123,10 @@ export default function EditorInterface( { isRevisionsMode: _isRevisionsMode(), }; }, [] ); + // Runs unconditionally so join/leave/save notifications are dispatched + // regardless of viewport width or whether the header centre area is visible. + useCollaboratorNotifications( postId, postType ); + const isLargeViewport = useViewportMatch( 'medium' ); const secondarySidebarLabel = isListViewOpened ? __( 'Document Overview' ) diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index 070d98198fdf8a..ea052ce4a3e3fe 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -30,7 +30,9 @@ import { const POLLING_INTERVAL_IN_MS = 1000; // 1 second or 1000 milliseconds const POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS = 250; // 250 milliseconds -const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 30 * 1000; // 30 seconds +// Must be less than the server-side AWARENESS_TIMEOUT (30 s) to avoid +// false disconnects when the tab is in the background. +const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 25 * 1000; // 25 seconds const MAX_ERROR_BACKOFF_IN_MS = 30 * 1000; // 30 seconds const POLLING_MANAGER_ORIGIN = 'polling-manager'; diff --git a/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts index 99b0e039252b80..3ec39bc0ad3bc9 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-presence.spec.ts @@ -43,6 +43,10 @@ test.describe( 'Collaboration - Presence', () => { await presenceButton.click(); // The popover should list the second collaborator by name. - await expect( page.getByText( 'Test Collaborator' ) ).toBeVisible(); + await expect( + page.locator( '.editor-collaborators-presence__list-item-name', { + hasText: 'Test Collaborator', + } ) + ).toBeVisible(); } ); } );