diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index e0976465a20860..fa69fb18fae986 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -14,6 +14,7 @@ - `usePostActions` no longer adds the "Set as homepage" and "Set as posts page" actions to pages: they moved to `@wordpress/core-actions`, which registers them on the server for the users who can manage the site settings when the theme has no `front-page` template, so a plugin can remove them with `wp_unregister_entity_actions()`. The pages they apply to come from the `wp:action-set-homepage` and `wp:action-set-posts-page` REST links ([#84932](https://github.com/WordPress/gutenberg/pull/84932)). - The post summary of the DataForm-based inspector, the media editor, and the private `usePostFields` hook, through which the site editor screens get their fields, read the fields of the post type from the server, through the Entity fields API. The media editor also lays out the attachment fields with the form of the attachment view configuration ([#84700](https://github.com/WordPress/gutenberg/pull/84700)). - Migrate the classic-theme template selector to `@wordpress/ui` `SelectControl` ([#84345](https://github.com/WordPress/gutenberg/pull/84345)). +- Notes: Show a dot on the "All notes" toolbar toggle when unresolved notes have activity you have not seen. Your own notes never count, and opening the sidebar or selecting a thread clears it ([#81951](https://github.com/WordPress/gutenberg/pull/81951)). - Show viewport dimensions and responsive editing status in the editor footer, and clarify when viewport-specific styling is available ([#84146](https://github.com/WordPress/gutenberg/pull/84146)). ### Deprecations diff --git a/packages/editor/README.md b/packages/editor/README.md index 2a1bdcd7b4fa93..8e951635573527 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -927,6 +927,8 @@ _Parameters_ - _props.title_ `string`: Title displayed at the top of the sidebar. - _props.isPinnable_ `[boolean]`: Whether to allow to pin sidebar to the toolbar. When set to `true` it also automatically renders a corresponding menu item. - _props.icon_ `[WPBlockTypeIconRender]`: The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. +- _props.badge_ `[boolean]`: Shows a dot over the pinned toolbar toggle, flagging that the sidebar has something new. Pass `badgeLabel` so it is announced. +- _props.badgeLabel_ `[string]`: Accessible label for the pinned toolbar toggle while a badge is shown, replacing `title` so the badge is announced. ### PluginSidebarMoreMenuItem diff --git a/packages/editor/src/components/collab-sidebar/constants.js b/packages/editor/src/components/collab-sidebar/constants.js index 9d580d5b6d18c4..63b901edfc7120 100644 --- a/packages/editor/src/components/collab-sidebar/constants.js +++ b/packages/editor/src/components/collab-sidebar/constants.js @@ -1,2 +1,10 @@ export const ALL_NOTES_SIDEBAR = 'edit-post/collab-history-sidebar'; + +// `{ [ postId ]: date_gmt }` map of when the user last saw each post's notes. +export const NOTES_LAST_SEEN_SCOPE = 'core'; +export const NOTES_LAST_SEEN_PREFERENCE = 'notesLastSeen'; + +// Most recently seen posts kept in that map, so the persisted preference stays small. +export const NOTES_LAST_SEEN_LIMIT = 100; + export const NOTE_FORMAT_NAME = 'core/note'; diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 4c352c45f380ba..a609d414c805e7 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -18,7 +18,15 @@ import { } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; import { decodeEntities } from '@wordpress/html-entities'; +import { store as interfaceStore } from '@wordpress/interface'; +import { store as preferencesStore } from '@wordpress/preferences'; import { store as editorStore } from '../../store'; +import { + ALL_NOTES_SIDEBAR, + NOTES_LAST_SEEN_LIMIT, + NOTES_LAST_SEEN_PREFERENCE, + NOTES_LAST_SEEN_SCOPE, +} from './constants'; import { unlock } from '../../lock-unlock'; import { createBoardStore } from './board-store'; import { @@ -28,11 +36,14 @@ import { getInlineMarkerStart, getNoteIdsFromMetadata, addNoteIdToMetadata, + getLatestNoteActivity, + hasUnseenNoteActivity, getNoteAtCaret, pickNoteForCaret, readInlineSelection, removeInlineNote, removeNoteIdFromMetadata, + setNotesLastSeen, wrapInlineNote, } from './utils'; @@ -177,6 +188,91 @@ export function useNoteThreads( postId ) { }; } +/** + * Whether the post has note activity from others the current user has not + * seen, which the "All notes" toggle flags with a dot. + * + * Opening "All notes" or selecting a thread marks the notes as seen, by + * storing the newest `date_gmt` shown rather than the current time, which + * keeps the comparison immune to clock skew. + * + * @param {Object} options Options. + * @param {?number} options.postId Post the notes belong to. + * @param {Array} options.notes Every note thread loaded for the post. + * @return {boolean} Whether there is unseen activity. + */ +export function useUnseenNotes( { postId, notes } ) { + const { set: setPreference } = useDispatch( preferencesStore ); + + const { lastSeenByPost, currentUserId, hasLooked } = useSelect( + ( select ) => ( { + lastSeenByPost: select( preferencesStore ).get( + NOTES_LAST_SEEN_SCOPE, + NOTES_LAST_SEEN_PREFERENCE + ), + currentUserId: select( coreStore ).getCurrentUser()?.id, + // A selected note is a number; 'new' is the add-note form, which + // is not reading anyone else's note. + hasLooked: + select( interfaceStore ).getActiveComplementaryArea( + 'core' + ) === ALL_NOTES_SIDEBAR || + typeof unlock( select( editorStore ) ).getSelectedNote() === + 'number', + } ), + [] + ); + + const lastSeen = postId ? lastSeenByPost?.[ postId ] : undefined; + const latestActivity = useMemo( + () => getLatestNoteActivity( notes ), + [ notes ] + ); + // Only open threads count. This reads the full list rather than + // `unresolvedNotes`, which drops threads whose block was deleted. + const hasUnseenNotes = useMemo( + () => + hasUnseenNoteActivity( + ( notes ?? [] ).filter( + ( thread ) => thread.status === 'hold' + ), + lastSeen, + currentUserId + ), + [ notes, lastSeen, currentUserId ] + ); + + useEffect( () => { + if ( + ! hasLooked || + ! postId || + ! latestActivity || + ( lastSeen && lastSeen >= latestActivity ) + ) { + return; + } + setPreference( + NOTES_LAST_SEEN_SCOPE, + NOTES_LAST_SEEN_PREFERENCE, + setNotesLastSeen( + lastSeenByPost, + postId, + latestActivity, + NOTES_LAST_SEEN_LIMIT + ) + ); + }, [ + hasLooked, + postId, + latestActivity, + lastSeen, + lastSeenByPost, + setPreference, + ] ); + + return hasUnseenNotes; +} + export function useNoteActions() { const { createNotice } = useDispatch( noticesStore ); const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore ); diff --git a/packages/editor/src/components/collab-sidebar/index.jsx b/packages/editor/src/components/collab-sidebar/index.jsx index 833c695a232d0a..7d769941ffcd52 100644 --- a/packages/editor/src/components/collab-sidebar/index.jsx +++ b/packages/editor/src/components/collab-sidebar/index.jsx @@ -23,6 +23,7 @@ import { useNoteSelection, useNoteThreads, usePickNote, + useUnseenNotes, } from './hooks'; import { getNoteIdsFromMetadata, pickPrimaryNote } from './utils'; import PostTypeSupportCheck from '../post-type-support-check'; @@ -64,6 +65,7 @@ function NotesSidebar( { postId, drafts } ) { }, [] ); const { getActiveComplementaryArea } = useSelect( interfaceStore ); const { notes, unresolvedNotes } = useNoteThreads( postId ); + const hasUnseenNotes = useUnseenNotes( { postId, notes } ); const { onStart, onDiscard } = useNoteActions(); // Here rather than in `Notes`, which unmounts with its surface: a draft // must be restored on block selection even while no note is shown. @@ -195,6 +197,8 @@ function NotesSidebar( { postId, drafts } ) { } icon={ commentIcon } + badge={ hasUnseenNotes } + badgeLabel={ __( 'All notes, new activity' ) } closeLabel={ __( 'Close Notes' ) } > diff --git a/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js b/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js index 7e2e42800ef444..70f5a89e21051d 100644 --- a/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js +++ b/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js @@ -24,7 +24,10 @@ import { BLOCK_LEVEL_NOTE_START, getInlineMarkerStart, getNoteMarkerSelector, + getLatestNoteActivity, getNoteAnchorRect, + hasUnseenNoteActivity, + setNotesLastSeen, } from '../utils'; import { noteFormat } from '../format'; @@ -1230,6 +1233,101 @@ describe( 'getNoteMarkerSelector / noteFormat', () => { } ); } ); +const ME = 7; +const SOMEONE_ELSE = 9; + +function makeThread( { id = 1, author = SOMEONE_ELSE, date, replies = [] } ) { + return { id, author, date_gmt: date, reply: replies }; +} + +describe( 'getLatestNoteActivity', () => { + it( 'returns null without threads', () => { + expect( getLatestNoteActivity( undefined ) ).toBeNull(); + } ); + + it( 'returns the newest timestamp, counting replies', () => { + expect( + getLatestNoteActivity( [ + makeThread( { + id: 1, + date: '2026-08-01T10:00:00', + replies: [ + makeThread( { id: 2, date: '2026-08-05T10:00:00' } ), + ], + } ), + makeThread( { id: 3, date: '2026-08-03T10:00:00' } ), + ] ) + ).toBe( '2026-08-05T10:00:00' ); + } ); +} ); + +describe( 'hasUnseenNoteActivity', () => { + const threads = [ + makeThread( { + id: 1, + author: ME, + date: '2026-08-01T10:00:00', + replies: [ makeThread( { id: 2, date: '2026-08-03T10:00:00' } ) ], + } ), + ]; + + it( 'is false when the current user is unknown', () => { + expect( hasUnseenNoteActivity( threads, undefined, undefined ) ).toBe( + false + ); + } ); + + it( 'counts a reply from somebody else after the last seen time', () => { + expect( + hasUnseenNoteActivity( threads, '2026-08-02T10:00:00', ME ) + ).toBe( true ); + expect( + hasUnseenNoteActivity( threads, '2026-08-03T10:00:00', ME ) + ).toBe( false ); + } ); + + it( 'never counts the current user’s own activity', () => { + expect( + hasUnseenNoteActivity( + [ makeThread( { author: ME, date: '2026-08-05T10:00:00' } ) ], + undefined, + ME + ) + ).toBe( false ); + } ); +} ); + +describe( 'setNotesLastSeen', () => { + it( 'adds or overwrites an entry without mutating the existing map', () => { + const existing = { 10: '2026-08-01T10:00:00' }; + expect( + setNotesLastSeen( existing, 11, '2026-08-02T10:00:00', 5 ) + ).toEqual( { 10: '2026-08-01T10:00:00', 11: '2026-08-02T10:00:00' } ); + expect( + setNotesLastSeen( existing, 10, '2026-08-09T10:00:00', 5 ) + ).toEqual( { 10: '2026-08-09T10:00:00' } ); + expect( existing ).toEqual( { 10: '2026-08-01T10:00:00' } ); + } ); + + it( 'drops the least recently seen posts past the limit', () => { + expect( + setNotesLastSeen( + { + 1: '2026-08-01T10:00:00', + 2: '2026-08-02T10:00:00', + 3: '2026-08-03T10:00:00', + }, + 4, + '2026-08-04T10:00:00', + 2 + ) + ).toEqual( { + 3: '2026-08-03T10:00:00', + 4: '2026-08-04T10:00:00', + } ); + } ); +} ); + function mockRect( element, top ) { element.getBoundingClientRect = () => ( { top } ); } diff --git a/packages/editor/src/components/collab-sidebar/utils.js b/packages/editor/src/components/collab-sidebar/utils.js index 505e5fef8eeaac..29bd643f683e2f 100644 --- a/packages/editor/src/components/collab-sidebar/utils.js +++ b/packages/editor/src/components/collab-sidebar/utils.js @@ -900,3 +900,68 @@ export function scrollNoteThreadIntoView( noteId, container ) { element?.scrollIntoView( { block: 'nearest' } ); } ); } + +// `date_gmt` is a fixed-width `Y-m-d\TH:i:s` string, so the helpers below +// compare timestamps as strings. Parsing them as `Date` would read them as +// local time. + +/** + * Returns the newest `date_gmt` across the given note threads and their replies. + * + * @param {Array} threads Note threads, each optionally carrying a `reply` array. + * @return {?string} Newest `date_gmt` found, or `null` when there is none. + */ +export function getLatestNoteActivity( threads ) { + let latest = null; + for ( const thread of threads ?? [] ) { + for ( const entry of [ thread, ...( thread?.reply ?? [] ) ] ) { + const date = entry?.date_gmt; + if ( date && ( latest === null || date > latest ) ) { + latest = date; + } + } + } + return latest; +} + +/** + * Whether any of the threads, or their replies, has activity by somebody other + * than the current user since `lastSeen`. + * + * @param {Array} threads Note threads, each optionally carrying a `reply` array. + * @param {?string} lastSeen `date_gmt` the user last saw the post's notes, if ever. + * @param {?number} currentUserId Current user id. Without it nothing counts. + * @return {boolean} Whether there is unseen activity. + */ +export function hasUnseenNoteActivity( threads, lastSeen, currentUserId ) { + if ( ! currentUserId ) { + return false; + } + return ( threads ?? [] ).some( ( thread ) => + [ thread, ...( thread?.reply ?? [] ) ].some( + ( entry ) => + !! entry?.date_gmt && + entry.author !== currentUserId && + ( ! lastSeen || entry.date_gmt > lastSeen ) + ) + ); +} + +/** + * Records a post's "notes last seen" timestamp, keeping only the `limit` most + * recently seen posts. + * + * @param {?Object} lastSeen Existing `{ [ postId ]: date_gmt }` map. + * @param {number} postId Post the timestamp belongs to. + * @param {string} timestamp Newest `date_gmt` the user has been shown. + * @param {number} limit How many posts to keep. + * @return {Object} New map. + */ +export function setNotesLastSeen( lastSeen, postId, timestamp, limit ) { + const entries = Object.entries( { ...lastSeen, [ postId ]: timestamp } ); + if ( entries.length <= limit ) { + return Object.fromEntries( entries ); + } + entries.sort( ( [ , a ], [ , b ] ) => b.localeCompare( a ) ); + return Object.fromEntries( entries.slice( 0, limit ) ); +} diff --git a/packages/editor/src/components/plugin-sidebar/index.jsx b/packages/editor/src/components/plugin-sidebar/index.jsx index 300f23292555fc..a528403f6a5590 100644 --- a/packages/editor/src/components/plugin-sidebar/index.jsx +++ b/packages/editor/src/components/plugin-sidebar/index.jsx @@ -18,6 +18,8 @@ import { ComplementaryArea } from '@wordpress/interface'; * @param {string} props.title Title displayed at the top of the sidebar. * @param {boolean} [props.isPinnable=true] Whether to allow to pin sidebar to the toolbar. When set to `true` it also automatically renders a corresponding menu item. * @param {WPBlockTypeIconRender} [props.icon=inherits from the plugin] The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug string, or an SVG WP element, to be rendered when the sidebar is pinned to toolbar. + * @param {boolean} [props.badge] Shows a dot over the pinned toolbar toggle, flagging that the sidebar has something new. Pass `badgeLabel` so it is announced. + * @param {string} [props.badgeLabel] Accessible label for the pinned toolbar toggle while a badge is shown, replacing `title` so the badge is announced. * * @example * ```js diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md index 61a920d225c2a6..c8961490e31295 100644 --- a/packages/interface/CHANGELOG.md +++ b/packages/interface/CHANGELOG.md @@ -6,6 +6,10 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). +### Enhancements + +- `ComplementaryArea`: Add `badge` and `badgeLabel` props, rendering a dot over the pinned toolbar toggle ([#81951](https://github.com/WordPress/gutenberg/pull/81951)). + ### Internal - Remove misplaced block breadcrumb styles ([#84295](https://github.com/WordPress/gutenberg/pull/84295)). diff --git a/packages/interface/src/components/complementary-area/README.md b/packages/interface/src/components/complementary-area/README.md index b270da23931a22..ef9de3063604b5 100644 --- a/packages/interface/src/components/complementary-area/README.md +++ b/packages/interface/src/components/complementary-area/README.md @@ -9,6 +9,20 @@ Besides rendering the complementary area, the component renders a button in `Pin ## Props +### badge + +Shows a dot over the pinned toolbar toggle, flagging that the area has something new. The dot is hidden from assistive technology; pass `badgeLabel` so it is announced. Without a badge the pinned toggle's markup is left untouched. + +- Type: `Boolean` +- Required: No + +### badgeLabel + +Accessible label for the pinned toolbar toggle while a badge is shown, replacing `title` so the badge is announced and appears in the toggle's tooltip - for example "All notes, new activity". Ignored when there is no `badge`. + +- Type: `String` +- Required: No + ### children The content to be displayed within the complementary area. diff --git a/packages/interface/src/components/complementary-area/index.tsx b/packages/interface/src/components/complementary-area/index.tsx index 1415d9bbb2ce8e..f51201fd8ab8ee 100644 --- a/packages/interface/src/components/complementary-area/index.tsx +++ b/packages/interface/src/components/complementary-area/index.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes } from 'react'; +import type { HTMLAttributes, ReactNode } from 'react'; import clsx from 'clsx'; import { Button, @@ -138,6 +138,34 @@ function ComplementaryAreaFill( { ); } +/** + * Overlays a dot on the pinned toolbar toggle. Without a badge the toggle is + * returned unwrapped. + */ +function PinnedToggle( { + badge, + children, +}: { + /** Whether to show the dot. */ + badge?: boolean; + /** The pinned toggle. */ + children: ReactNode; +} ) { + if ( ! badge ) { + return children; + } + + return ( +
+ { children } +
+ ); +} + function useAdjustComplementaryListener( scope: string, identifier: string, @@ -194,6 +222,8 @@ function useAdjustComplementaryListener( } function ComplementaryArea( { + badge, + badgeLabel, children, className, closeLabel = __( 'Close plugin' ), @@ -296,21 +326,27 @@ function ComplementaryArea( { { isPinnable && ( { isPinned && ( - + + + ) } ) } diff --git a/packages/interface/src/components/complementary-area/style.scss b/packages/interface/src/components/complementary-area/style.scss index 95e013f1d0f615..4fc92cdf78dae8 100644 --- a/packages/interface/src/components/complementary-area/style.scss +++ b/packages/interface/src/components/complementary-area/style.scss @@ -77,3 +77,32 @@ .interface-complementary-area__fill { height: 100%; } + +// Only rendered with a badge. Hidden on mobile like the pinned items it wraps. +.interface-complementary-area__pinned-toggle { + display: none; + position: relative; + + @include break-small() { + display: flex; + } +} + +.interface-complementary-area__badge { + position: absolute; + top: 2px; + inset-inline-end: 2px; + width: $grid-unit-10; + height: $grid-unit-10; + border-radius: 50%; + // Separates the dot from the icon underneath. + box-shadow: 0 0 0 2px $white; + background: var(--wp-admin-theme-color, #3858e9); + pointer-events: none; + + @media (forced-colors: active) { + forced-color-adjust: none; + background: Highlight; + box-shadow: 0 0 0 2px Canvas; + } +} diff --git a/packages/interface/src/components/complementary-area/types.ts b/packages/interface/src/components/complementary-area/types.ts index 4eee209384c2e4..0768d568d5c276 100644 --- a/packages/interface/src/components/complementary-area/types.ts +++ b/packages/interface/src/components/complementary-area/types.ts @@ -27,6 +27,16 @@ export type ComplementaryAreaFillProps = { }; export type ComplementaryAreaProps = { + /** + * Shows a dot over the pinned toolbar toggle, flagging that the area has + * something new. Pass `badgeLabel` so assistive technology is told too. + */ + badge?: boolean; + /** + * Accessible label for the pinned toolbar toggle while a badge is shown, + * replacing `title` so the badge is announced. + */ + badgeLabel?: string; /** * The content to be displayed within the complementary area. */ diff --git a/test/e2e/specs/editor/collaboration/collaboration-notes.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-notes.spec.ts index b286bfe1cff801..c769aad12dece2 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-notes.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-notes.spec.ts @@ -68,7 +68,8 @@ test.describe( 'Collaboration - Notes Sync', () => { // Open the All Notes sidebar on User B's page. const toggleButton = page2 .getByRole( 'region', { name: 'Editor top bar' } ) - .getByRole( 'button', { name: 'All notes', exact: true } ); + // Not `exact`: the name gains ", new activity" with unseen notes. + .getByRole( 'button', { name: 'All notes' } ); // The button may need a moment to appear after the note syncs. await expect( toggleButton ).toBeVisible( { timeout: 10000 } ); @@ -143,7 +144,8 @@ test.describe( 'Collaboration - Notes Sync', () => { // Open notes sidebar on User A's page. const toggleButton = page .getByRole( 'region', { name: 'Editor top bar' } ) - .getByRole( 'button', { name: 'All notes', exact: true } ); + // Not `exact`: the name gains ", new activity" with unseen notes. + .getByRole( 'button', { name: 'All notes' } ); await expect( toggleButton ).toBeVisible( { timeout: 10000 } ); const isExpanded = await toggleButton.getAttribute( 'aria-expanded' ); if ( isExpanded === 'false' ) { @@ -230,7 +232,8 @@ test.describe( 'Collaboration - Notes Sync', () => { // Open notes sidebar on User B's page. const toggleButton = page2 .getByRole( 'region', { name: 'Editor top bar' } ) - .getByRole( 'button', { name: 'All notes', exact: true } ); + // Not `exact`: the name gains ", new activity" with unseen notes. + .getByRole( 'button', { name: 'All notes' } ); await expect( toggleButton ).toBeVisible( { timeout: 10000 } ); const isExpanded = await toggleButton.getAttribute( 'aria-expanded' ); if ( isExpanded === 'false' ) { diff --git a/test/e2e/specs/editor/various/block-notes-unseen-badge.spec.ts b/test/e2e/specs/editor/various/block-notes-unseen-badge.spec.ts new file mode 100644 index 00000000000000..f4ea4b2431e83b --- /dev/null +++ b/test/e2e/specs/editor/various/block-notes-unseen-badge.spec.ts @@ -0,0 +1,181 @@ +import { + test, + expect, + type RequestUtils, +} from '@wordpress/e2e-test-utils-playwright'; + +type SeedNote = { author?: number; content: string }; + +/** + * Creates a draft post with one paragraph anchoring each note. + * + * Notes need `status: 'hold'`, otherwise the endpoint stores them resolved. + * + * @param requestUtils Request utils. + * @param notes Notes to seed. + * @return Post id. + */ +async function createPostWithNotes( + requestUtils: RequestUtils, + notes: SeedNote[] +): Promise< number > { + const post: { id: number } = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/posts', + data: { title: 'Notes badge', status: 'draft' }, + } ); + const noteIds: number[] = []; + for ( const note of notes ) { + noteIds.push( ( await addNote( requestUtils, post.id, note ) ).id ); + } + await requestUtils.rest( { + method: 'PUT', + path: `/wp/v2/posts/${ post.id }`, + data: { + content: noteIds + .map( + ( id ) => + `\n

Paragraph ${ id }

\n` + ) + .join( '\n\n' ), + }, + } ); + return post.id; +} + +function addNote( + requestUtils: RequestUtils, + postId: number, + { author, content }: SeedNote +): Promise< { id: number } > { + return requestUtils.rest( { + method: 'POST', + path: '/wp/v2/comments', + data: { post: postId, type: 'note', status: 'hold', content, author }, + } ); +} + +test.describe( 'Notes: unseen badge', () => { + let collaboratorId: number; + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllUsers(); + const collaborator = await requestUtils.createUser( { + username: 'notes-collaborator', + email: 'notes-collaborator@example.com', + password: 'notes-collaborator-password', + roles: [ 'editor' ], + } ); + collaboratorId = collaborator.id; + } ); + + test.beforeEach( async ( { requestUtils } ) => { + // The "last seen" timestamps live in the persisted preferences. + await requestUtils.resetPreferences(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllComments( 'note' ); + await requestUtils.deleteAllPosts(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllUsers(); + await requestUtils.resetPreferences(); + } ); + + test( 'flags a collaborator’s note until the sidebar is opened', async ( { + admin, + page, + requestUtils, + } ) => { + const postId = await createPostWithNotes( requestUtils, [ + { content: 'A note to self' }, + { author: collaboratorId, content: 'Please rework this' }, + ] ); + await admin.editPost( postId ); + + const toggle = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'All notes' } ); + const badge = page.locator( '.interface-complementary-area__badge' ); + + await expect( badge ).toBeVisible(); + await expect( toggle ).toHaveAccessibleName( + 'All notes, new activity' + ); + + await toggle.click(); + await expect( badge ).toBeHidden(); + await expect( toggle ).toHaveAccessibleName( 'All notes' ); + + // The cleared state persists across a reload. + await admin.editPost( postId ); + await expect( toggle ).toBeVisible(); + await expect( badge ).toBeHidden(); + + // New activity from the collaborator brings it back. + await addNote( requestUtils, postId, { + author: collaboratorId, + content: 'One more thing', + } ); + await admin.editPost( postId ); + await expect( badge ).toBeVisible(); + } ); + + test( 'clears when a thread is selected', async ( { + admin, + page, + requestUtils, + } ) => { + const postId = await createPostWithNotes( requestUtils, [ + { author: collaboratorId, content: 'Worth a second look' }, + ] ); + // Wide enough for the floating notes to render beside the canvas. + await page.setViewportSize( { width: 1500, height: 900 } ); + await admin.editPost( postId ); + + const badge = page.locator( '.interface-complementary-area__badge' ); + await expect( badge ).toBeVisible(); + + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Close Settings' } ) + .click(); + const thread = page + .getByRole( 'treeitem', { name: /Worth a second look/ } ) + .first(); + await expect( thread ).toBeVisible(); + // The floating note being on screen does not count as seen. + await expect( badge ).toBeVisible(); + + await thread.click(); + await expect( badge ).toBeHidden(); + } ); + + test( 'starting a note of your own does not clear the badge', async ( { + admin, + page, + editor, + requestUtils, + } ) => { + const postId = await createPostWithNotes( requestUtils, [ + { author: collaboratorId, content: 'Still needs reading' }, + ] ); + await admin.editPost( postId ); + + const badge = page.locator( '.interface-complementary-area__badge' ); + await expect( badge ).toBeVisible(); + + // A fresh block: selecting the noted one would select its thread. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'My paragraph' }, + } ); + await editor.clickBlockOptionsMenuItem( 'Add note' ); + await expect( + page.getByRole( 'textbox', { name: 'New note', exact: true } ) + ).toBeFocused(); + await expect( badge ).toBeVisible(); + } ); +} );