Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

### Bug Fixes

- Notes: Show distinct authors and repliers across all unresolved threads on a block in the toolbar avatar indicator, retaining access to resolved threads when none remain unresolved ([#82357](https://github.com/WordPress/gutenberg/pull/82357)).

- More menu: Align SVG prefix icons with item labels using `Menu.PrefixIcon`, preserving Dashicon and custom component support. ([#82346](https://github.com/WordPress/gutenberg/pull/82346))
- Attach media an Image or Gallery block displays to the post on save, when it is not already attached to another post, matching what uploading into that post has always done ([#81977](https://github.com/WordPress/gutenberg/pull/81977)).
- Color the welcome guide's hovered button icon with `color` rather than `fill`, so stroke-based icons follow it. ([#78812](https://github.com/WordPress/gutenberg/pull/78812))
Expand Down
17 changes: 15 additions & 2 deletions packages/editor/src/components/collab-sidebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ The Notes sidebar (a.k.a. collab sidebar) lets users attach threaded notes to in
- **All notes** - a full sidebar (opened from the editor's More menu) listing every note thread on the current post.
- **Floating notes** - on larger viewports, unresolved notes also float next to their associated blocks in the canvas, positioned to track scroll and avoid overlap.

Notes are stored as WordPress comments (`type: 'note'`) attached to the post. A block references its thread via `metadata.noteId` on block attributes. Each thread has a top-level note plus replies; threads can be resolved (stored as status `approved`) or reopened.
Notes are stored as WordPress comments (`type: 'note'`) attached to the post. A block references its threads via `metadata.noteId` on block attributes. Each thread has a top-level note plus replies; threads can be resolved (stored as status `approved`) or reopened.

## Toolbar participants

The “View notes” button shows distinct authors and repliers across every unresolved
thread on the selected block, including block-level and inline notes. Participants
appear in order of their first contribution. When all threads are resolved, the
button includes participants from those resolved threads so the notes remain
accessible.

Up to three participants appear as avatars. With more participants, two avatars
appear alongside the remaining count. Activating the button opens the first
unresolved thread, falling back to the first resolved thread. Aggregating avatars
does not change the sidebar’s thread ordering or focus behavior.

## File structure

Expand Down Expand Up @@ -39,7 +52,7 @@ collab-sidebar/
NotesSidebarContainer (index.jsx) - gates on post type support
└── NotesSidebar (index.jsx) - owns sidebarRef + useNoteThreads + sidebar registration
├── AddNoteMenuItem - slot fill in the block toolbar
├── NoteAvatarIndicator - slot fill in the block toolbar (per-thread avatars)
├── NoteAvatarIndicator - slot fill in the block toolbar (participants across the block’s threads)
├── PluginSidebar (all-notes) - full sidebar
│ └── Notes (notes.jsx) - owns outer Stack + aria-label + useNoteActions + keyboard nav
│ ├── AddNote - rendered when no threads (template-locked) or selectedNote === 'new'
Expand Down
26 changes: 15 additions & 11 deletions packages/editor/src/components/collab-sidebar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
import { useMemo, useRef } from '@wordpress/element';
import { useViewportMatch } from '@wordpress/compose';
import { useShortcut } from '@wordpress/keyboard-shortcuts';
import { comment as commentIcon } from '@wordpress/icons';
Expand All @@ -20,7 +20,11 @@ import { NoteAvatarIndicator } from './note-indicator-toolbar';
import { NoteHighlightStyles } from './note-highlight-styles';
import { useGlobalStyles } from '../global-styles';
import { useEnableFloatingSidebar, useNoteThreads } from './hooks';
import { getNoteIdsFromMetadata, pickPrimaryNote } from './utils';
import {
getNoteIdsFromMetadata,
pickIndicatorNotes,
pickPrimaryNote,
} from './utils';
import PostTypeSupportCheck from '../post-type-support-check';
import { unlock } from '../../lock-unlock';

Expand Down Expand Up @@ -49,7 +53,6 @@ function NotesSidebar( { postId } ) {
};
}, [] );

const blockNoteIds = getNoteIdsFromMetadata( { noteId } );
const { isDistractionFree } = useSelect( ( select ) => {
const { get } = select( preferencesStore );
return {
Expand Down Expand Up @@ -140,12 +143,13 @@ function NotesSidebar( { postId } ) {
const { merged: GlobalStyles } = useGlobalStyles();
const backgroundColor = GlobalStyles?.styles?.color?.background;

// Surface one thread for the avatar indicator.
const currentThreads =
blockNoteIds.length > 0
? notes.filter( ( thread ) => blockNoteIds.includes( thread.id ) )
: [];
const currentThread = pickPrimaryNote( currentThreads );
// Represent all relevant threads on the selected block.
const indicatorThreads = useMemo( () => {
const noteIds = getNoteIdsFromMetadata( { noteId } );
return pickIndicatorNotes(
notes.filter( ( thread ) => noteIds.includes( thread.id ) )
);
}, [ notes, noteId ] );

if ( isDistractionFree ) {
return <AddNoteMenuItem isDistractionFree />;
Expand All @@ -157,9 +161,9 @@ function NotesSidebar( { postId } ) {
threads={ unresolvedNotes }
selectedId={ selectedNoteId }
/>
{ !! currentThread && (
{ indicatorThreads.length > 0 && (
<NoteAvatarIndicator
note={ currentThread }
notes={ indicatorThreads }
onClick={ () => openNoteForBlock( clientId ) }
/>
) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { unlock } from '../../lock-unlock';
import { getAvatarBorderColor } from './utils';
import { getAvatarBorderColor, getThreadParticipants } from './utils';

const { NoteIconToolbarSlotFill } = unlock( blockEditorPrivateApis );

Expand Down Expand Up @@ -63,35 +63,11 @@ function ThreadParticipants( { participants } ) {
);
}

export function NoteAvatarIndicator( { onClick, note } ) {
const threadParticipants = useMemo( () => {
if ( ! note ) {
return [];
}

// Track thread participants (original author + repliers), sorted by
// date so they appear in chronological order.
const participantsMap = new Map();
const allNotes = [ note, ...note.reply ].sort(
( a, b ) => new Date( a.date ) - new Date( b.date )
);

for ( const entry of allNotes ) {
if ( ! entry.author_name || participantsMap.has( entry.author ) ) {
continue;
}

participantsMap.set( entry.author, {
id: entry.author,
name: entry.author_name,
avatar:
entry.author_avatar_urls?.[ '48' ] ||
entry.author_avatar_urls?.[ '96' ],
} );
}

return Array.from( participantsMap.values() );
}, [ note ] );
export function NoteAvatarIndicator( { onClick, notes } ) {
const threadParticipants = useMemo(
() => getThreadParticipants( notes ?? [] ),
[ notes ]
);

if ( ! threadParticipants.length ) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { createElement } from '@wordpress/element';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import NotesSidebar from '../index';

const state = vi.hoisted( () => ( {
notes: [],
noteId: [ 1, 2 ],
selectNote: vi.fn(),
} ) );

vi.mock( '../../../lock-unlock', () => ( { unlock: ( value ) => value } ) );
vi.mock( '@wordpress/block-editor', () => ( {
store: 'block-editor',
privateApis: {
NoteIconToolbarSlotFill: { Fill: ( { children } ) => children },
},
} ) );
vi.mock( '../../../store', () => ( { store: 'editor' } ) );
vi.mock( '@wordpress/interface', () => ( { store: 'interface' } ) );
vi.mock( '@wordpress/preferences', () => ( { store: 'preferences' } ) );
vi.mock( '@wordpress/keyboard-shortcuts', () => ( { useShortcut: () => {} } ) );
vi.mock( '@wordpress/data', async ( importOriginal ) => {
const actual = await importOriginal();
const selectors = {
getSelectedBlockClientId: () => 'block-a',
getBlockAttributes: () => ( { metadata: { noteId: state.noteId } } ),
getBlockName: () => 'core/paragraph',
get: () => false,
getSelectedNote: () => undefined,
getCurrentPostId: () => 1,
getEditorMode: () => 'visual',
isRevisionsMode: () => false,
getActiveComplementaryArea: () => 'edit-post/collab-sidebar',
getSettings: () => ( {
__experimentalDiscussionSettings: { avatarURL: 'default-avatar' },
} ),
};
return {
...actual,
useSelect: ( callback ) =>
typeof callback === 'function'
? callback( () => selectors )
: selectors,
useDispatch: () => ( {
enableComplementaryArea: vi.fn(),
toggleBlockSpotlight: vi.fn(),
selectBlock: vi.fn(),
selectNote: state.selectNote,
} ),
};
} );
vi.mock( '../hooks', () => ( {
useEnableFloatingSidebar: () => {},
useNoteThreads: () => ( {
notes: state.notes,
unresolvedNotes: state.notes.filter(
( note ) => note.status === 'hold'
),
} ),
} ) );
vi.mock( '../../global-styles', () => ( { useGlobalStyles: () => ( {} ) } ) );
vi.mock( '../../post-type-support-check', () => ( {
default: ( { children } ) => children,
} ) );
vi.mock( '../../plugin-sidebar', () => ( { default: () => null } ) );
vi.mock( '../notes', () => ( { Notes: () => null } ) );
vi.mock( '../add-note-menu-item', () => ( { AddNoteMenuItem: () => null } ) );
vi.mock( '../note-highlight-styles', () => ( {
NoteHighlightStyles: () => null,
} ) );

const makeNote = ( id, author, overrides = {} ) => ( {
id,
author,
author_name: `User ${ author }`,
date: `2026-01-0${ id }T00:00:00`,
status: 'hold',
blockClientId: 'block-a',
reply: [],
...overrides,
} );

const avatars = () =>
within( screen.getByRole( 'button', { name: 'View notes' } ) ).getAllByRole(
'img'
);

describe( 'block toolbar note participants', () => {
beforeEach( () => {
state.noteId = [ 1, 2 ];
state.notes = [ makeNote( 1, 1 ), makeNote( 2, 2 ) ];
state.selectNote.mockClear();
} );
it( 'shows participants from every selected-block thread and excludes other blocks', () => {
state.notes.push( makeNote( 3, 3, { blockClientId: 'block-b' } ) );
render( createElement( NotesSidebar ) );
expect( avatars().map( ( avatar ) => avatar.alt ) ).toEqual( [
'User 1',
'User 2',
] );
expect( avatars()[ 0 ] ).toHaveAttribute( 'src', 'default-avatar' );
} );

it( 'updates participants when a thread is resolved, reopened, and deleted', () => {
const { rerender } = render( createElement( NotesSidebar ) );
state.notes = [
state.notes[ 0 ],
{ ...state.notes[ 1 ], status: 'approved' },
];
rerender( createElement( NotesSidebar ) );
expect( avatars().map( ( avatar ) => avatar.alt ) ).toEqual( [
'User 1',
] );
state.notes = [
state.notes[ 0 ],
{ ...state.notes[ 1 ], status: 'hold' },
];
rerender( createElement( NotesSidebar ) );
expect( avatars() ).toHaveLength( 2 );
state.noteId = [ 1 ];
state.notes = [ state.notes[ 0 ] ];
rerender( createElement( NotesSidebar ) );
expect( avatars() ).toHaveLength( 1 );
} );

it( 'shows two avatars and the remaining count for four distinct participants', () => {
state.notes[ 1 ].reply = [
makeNote( 3, 3 ),
makeNote( 4, 4 ),
makeNote( 5, 1 ),
];
render( createElement( NotesSidebar ) );
expect( avatars() ).toHaveLength( 2 );
expect( screen.getByText( '+2' ) ).toBeVisible();
} );

it( 'opens the primary thread when View notes is activated', async () => {
render( createElement( NotesSidebar ) );
fireEvent.click( screen.getByRole( 'button', { name: 'View notes' } ) );
await vi.waitFor( () =>
expect( state.selectNote ).toHaveBeenCalledWith( 1, {
focus: true,
} )
);
} );

it( 'keeps resolved threads visible and hides the indicator when no threads remain', () => {
state.notes = state.notes.map( ( note ) => ( {
...note,
status: 'approved',
} ) );
const { rerender } = render( createElement( NotesSidebar ) );
expect( avatars() ).toHaveLength( 2 );
state.notes = [];
rerender( createElement( NotesSidebar ) );
expect(
screen.queryByRole( 'button', { name: 'View notes' } )
).not.toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
removeNoteIdFromMetadata,
calculateNotePositions,
pickPrimaryNote,
pickIndicatorNotes,
getThreadParticipants,
BLOCK_LEVEL_NOTE_START,
getInlineMarkerStart,
getNoteMarkerSelector,
Expand Down Expand Up @@ -1053,3 +1055,63 @@ describe( 'getNoteMarkerSelector / noteFormat', () => {
expect( marker.matches( getNoteMarkerSelector( 7 ) ) ).toBe( true );
} );
} );

describe( 'note indicator participants', () => {
const makeNote = ( author, date, reply = [] ) => ( {
author,
author_name: `User ${ author }`,
date,
reply,
status: 'hold',
author_avatar_urls: { 48: `avatar-${ author }` },
} );

it( 'includes authors and repliers once in first-contribution order', () => {
const reply = makeNote( 2, '2026-01-03T00:00:00' );
const threads = [
makeNote( 1, '2026-01-02T00:00:00', [ reply ] ),
makeNote( 2, '2026-01-01T00:00:00' ),
makeNote( 3, '2026-01-04T00:00:00' ),
];
const original = JSON.parse( JSON.stringify( threads ) );
expect( getThreadParticipants( threads ) ).toEqual( [
{ id: 2, name: 'User 2', avatar: 'avatar-2' },
{ id: 1, name: 'User 1', avatar: 'avatar-1' },
{ id: 3, name: 'User 3', avatar: 'avatar-3' },
] );
expect( threads ).toEqual( original );
} );

it( 'excludes resolved-only participants while unresolved threads remain', () => {
const unresolved = makeNote( 1, '2026-01-01T00:00:00' );
const resolved = {
...makeNote( 2, '2026-01-02T00:00:00' ),
status: 'approved',
};
expect(
getThreadParticipants(
pickIndicatorNotes( [ resolved, unresolved ] )
)
).toEqual( [ { id: 1, name: 'User 1', avatar: 'avatar-1' } ] );
} );

it( 'keeps every resolved thread accessible when none are unresolved', () => {
const threads = [ { status: 'approved' }, { status: 'approved' } ];
expect( pickIndicatorNotes( threads ) ).toEqual( threads );
} );

it( 'returns no participants when there are no threads', () => {
expect( getThreadParticipants( pickIndicatorNotes( [] ) ) ).toEqual(
[]
);
} );

it( 'skips unnamed entries and supports missing replies and avatar sizes', () => {
const thread = makeNote( 1, '2026-01-01T00:00:00' );
delete thread.reply;
thread.author_avatar_urls = { 96: 'large-avatar' };
expect(
getThreadParticipants( [ { ...thread, author_name: '' }, thread ] )
).toEqual( [ { id: 1, name: 'User 1', avatar: 'large-avatar' } ] );
} );
} );
Loading
Loading