Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cd53227
Notes: badge the "All notes" toggle with unseen note activity
adamsilverstein Aug 21, 2026
9fcb1ed
Notes: cover the unseen badge end to end
adamsilverstein Aug 21, 2026
15ea8a0
Notes: match the All notes toggle by prefix in the collaboration specs
adamsilverstein Aug 22, 2026
31ee3ab
Notes: clear the unseen badge when a thread is selected
adamsilverstein Aug 22, 2026
e650159
Merge remote-tracking branch 'origin/trunk' into add/81940-unseen-not…
adamsilverstein Aug 27, 2026
bb44404
Merge remote-tracking branch 'origin/trunk' into add/81940-unseen-not…
adamsilverstein Oct 1, 2026
8adc8fa
Notes: match Prettier 3.9 ternary indentation after the trunk merge
adamsilverstein Oct 1, 2026
000f262
Notes: move the unseen badge changelog entry back under Unreleased
adamsilverstein Oct 1, 2026
6491a36
Merge remote-tracking branch 'origin/trunk' into add/81940-unseen-not…
adamsilverstein Oct 1, 2026
f365caf
Merge remote-tracking branch 'origin/trunk' into add/81940-unseen-not…
adamsilverstein Oct 8, 2026
179de47
Interface: Hide the pinned toggle badge on mobile
adamsilverstein Oct 8, 2026
2b21b37
Notes: Update unseen badge e2e tests for trunk note selection
adamsilverstein Oct 8, 2026
4c9578e
Merge remote-tracking branch 'origin/trunk' into add/81940-unseen-not…
adamsilverstein Oct 9, 2026
3b1bd70
Notes: Show a dot for unseen notes and highlight them in All notes
adamsilverstein Oct 9, 2026
b6154ae
Merge origin/trunk into add/81940-unseen-notes-badge
adamsilverstein Oct 12, 2026
77c99e8
Move changelog entry under Unreleased
adamsilverstein Oct 12, 2026
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
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Enhancements

- 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. Opening "All notes" highlights the threads that were unseen, with their replies expanded, until they scroll into view ([#81951](https://github.com/WordPress/gutenberg/pull/81951)).

### Bug Fixes

Expand Down
2 changes: 2 additions & 0 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions packages/editor/src/components/collab-sidebar/constants.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
export const ALL_NOTES_SIDEBAR = 'edit-post/collab-history-sidebar';

/**
* Preference holding the per-post timestamp of the last time the current user
* looked at a post's notes, as a `{ [ postId ]: date_gmt }` map. It rides on
* the existing preferences store - and therefore the `persisted_preferences`
* user meta - rather than introducing a read-tracking API of its own.
*/
export const NOTES_LAST_SEEN_SCOPE = 'core';
export const NOTES_LAST_SEEN_PREFERENCE = 'notesLastSeen';

/**
* How many posts keep an entry in that map. The map is persisted with every
* other editor preference, so it is capped to the most recently seen posts
* instead of growing with every post the user ever opens.
*/
export const NOTES_LAST_SEEN_LIMIT = 100;

export const NOTE_FORMAT_NAME = 'core/note';
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Stack } from '@wordpress/ui';

export function FloatingContainer( {
floating,
containerRef,
className,
style,
children,
Expand All @@ -13,7 +14,7 @@ export function FloatingContainer( {
<Stack
direction="column"
className={ clsx( className, { 'is-floating': isFloating } ) }
ref={ isFloating ? floating.ref : undefined }
ref={ isFloating ? floating.ref : containerRef }
style={
isFloating
? {
Expand Down
156 changes: 156 additions & 0 deletions packages/editor/src/components/collab-sidebar/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,11 +36,14 @@ import {
getInlineMarkerStart,
getNoteIdsFromMetadata,
addNoteIdToMetadata,
getLatestNoteActivity,
getUnseenNoteIds,
getNoteAtCaret,
pickNoteForCaret,
readInlineSelection,
removeInlineNote,
removeNoteIdFromMetadata,
setNotesLastSeen,
wrapInlineNote,
} from './utils';

Expand Down Expand Up @@ -177,6 +188,151 @@ export function useNoteThreads( postId ) {
};
}

const NO_NOTE_IDS = [];

/**
* Tracks whether the post has note activity the current user has not seen, and
* marks the post's notes as seen while the "All notes" sidebar is open.
*
* Only a single per-post timestamp is stored, so this cannot say which notes a
* user has read - just that something happened since they last looked. That
* is why the badge is a dot rather than a count.
*
* Two things count as having looked. Opening the pinned "All notes" sidebar -
* the one the badge sits on - is the obvious one. Selecting a thread is the
* other, and it matters because of the floating notes: on a large viewport they
* sit in the canvas margin and show every unresolved thread. Someone can read
* all of them there without ever touching "All notes", and their badge would
* otherwise never clear. The floating notes being on screen cannot itself
* count, since nobody asked for them.
*
* Selecting a thread expands it - replies unfold, a clamped note can be opened
* with "Show more" - so it is the point where reading is demonstrated rather
* than assumed. Those finer expansions are deliberately not wired up as
* separate signals: they all imply a selection, and a post whose notes are
* short and reply-free offers nothing to expand at all, which would leave its
* badge stuck forever. A badge that never clears is worse than one that clears
* eagerly.
*
* The recorded timestamp is the newest `date_gmt` the user has actually been
* shown rather than the current time, which keeps the comparison immune to
* clock skew between the browser and the server.
*
* Opening "All notes" advances that timestamp straight away, so the threads
* that were unseen at that moment are captured first and returned as
* `highlightedNoteIds`, letting the sidebar point them out. The list is kept
* until the sidebar closes and only lives in memory.
*
* @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 {{hasUnseenNotes: boolean, highlightedNoteIds: Array}} Whether any thread has unseen activity, and the threads to highlight in "All notes".
*/
export function useUnseenNotes( { postId, notes } ) {
const { set: setPreference } = useDispatch( preferencesStore );

const { lastSeenByPost, currentUserId, isAllNotesOpen, hasSelectedNote } =
useSelect(
( select ) => ( {
lastSeenByPost: select( preferencesStore ).get(
NOTES_LAST_SEEN_SCOPE,
NOTES_LAST_SEEN_PREFERENCE
),
currentUserId: select( coreStore ).getCurrentUser()?.id,
isAllNotesOpen:
select( interfaceStore ).getActiveComplementaryArea(
'core'
) === ALL_NOTES_SIDEBAR,
/*
* `getSelectedNote` also returns 'new' for the add-note form.
* Authoring a note is not reading anyone else's, so only a real
* thread id counts.
*/
hasSelectedNote:
typeof unlock( select( editorStore ) ).getSelectedNote() ===
'number',
} ),
[]
);

const lastSeen = postId ? lastSeenByPost?.[ postId ] : undefined;
const latestActivity = useMemo(
() => getLatestNoteActivity( notes ),
[ notes ]
);
/*
* Resolving a thread is not activity worth badging, so only open threads
* are eligible. This deliberately reads the full list rather than
* `unresolvedNotes`, which drops threads whose block has been deleted -
* those still need attention and still appear under "All notes".
*/
const openThreads = useMemo(
() => ( notes ?? [] ).filter( ( thread ) => thread.status === 'hold' ),
[ notes ]
);

const unseenNoteIds = useMemo(
() => getUnseenNoteIds( openThreads, lastSeen, currentUserId ),
[ openThreads, lastSeen, currentUserId ]
);

const [ highlightedNoteIds, setHighlightedNoteIds ] =
useState( NO_NOTE_IDS );
// Runs before the effect below advances the timestamp, so it still sees
// what was unseen when the sidebar opened. Activity arriving while it
// stays open is added too.
useEffect( () => {
if ( ! isAllNotesOpen ) {
setHighlightedNoteIds( NO_NOTE_IDS );
return;
}
if ( ! unseenNoteIds.length ) {
return;
}
setHighlightedNoteIds( ( current ) => {
const added = unseenNoteIds.filter(
( id ) => ! current.includes( id )
);
return added.length ? [ ...current, ...added ] : current;
} );
}, [ isAllNotesOpen, unseenNoteIds ] );

const hasLooked = isAllNotesOpen || hasSelectedNote;

useEffect( () => {
if ( ! hasLooked || ! postId || ! latestActivity ) {
return;
}
// Already recorded, including for activity arriving while the sidebar
// stays open.
if ( 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: unseenNoteIds.length > 0,
highlightedNoteIds,
};
}

export function useNoteActions() {
const { createNotice } = useDispatch( noticesStore );
const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore );
Expand Down
17 changes: 16 additions & 1 deletion packages/editor/src/components/collab-sidebar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
useNoteSelection,
useNoteThreads,
usePickNote,
useUnseenNotes,
} from './hooks';
import { getNoteIdsFromMetadata, pickPrimaryNote } from './utils';
import PostTypeSupportCheck from '../post-type-support-check';
Expand Down Expand Up @@ -64,6 +65,10 @@ function NotesSidebar( { postId, drafts } ) {
}, [] );
const { getActiveComplementaryArea } = useSelect( interfaceStore );
const { notes, unresolvedNotes } = useNoteThreads( postId );
const { hasUnseenNotes, highlightedNoteIds } = 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.
Expand Down Expand Up @@ -195,10 +200,20 @@ function NotesSidebar( { postId, drafts } ) {
</h2>
}
icon={ commentIcon }
badge={ hasUnseenNotes }
badgeLabel={
hasUnseenNotes
? __( 'All notes, new activity' )
: undefined
}
closeLabel={ __( 'Close Notes' ) }
>
<NoteDraftsContext.Provider value={ drafts }>
<Notes notes={ notes } sidebarRef={ sidebarRef } />
<Notes
notes={ notes }
sidebarRef={ sidebarRef }
highlightedNoteIds={ highlightedNoteIds }
/>
</NoteDraftsContext.Provider>
</PluginSidebar>
) }
Expand Down
Loading
Loading