diff --git a/src/sidebar/components/Annotation/AnnotationEditor.tsx b/src/sidebar/components/Annotation/AnnotationEditor.tsx index 30d76ec2593..083df80987f 100644 --- a/src/sidebar/components/Annotation/AnnotationEditor.tsx +++ b/src/sidebar/components/Annotation/AnnotationEditor.tsx @@ -167,6 +167,9 @@ function AnnotationEditor({ const textStyle = applyTheme(['annotationFontFamily'], settings); + const atMentionsEnabled = store.isFeatureEnabled('at_mentions'); + const usersWhoAnnotated = store.usersWhoAnnotated(); + return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
{ removeDraft: sinon.stub(), removeAnnotations: sinon.stub(), isFeatureEnabled: sinon.stub().returns(false), + usersWhoAnnotated: sinon.stub().returns([]), }; $imports.$mock(mockImportedComponents()); diff --git a/src/sidebar/components/MarkdownEditor.tsx b/src/sidebar/components/MarkdownEditor.tsx index a587638693d..fbf1304a3f8 100644 --- a/src/sidebar/components/MarkdownEditor.tsx +++ b/src/sidebar/components/MarkdownEditor.tsx @@ -181,20 +181,44 @@ function ToolbarButton({ ); } +export type UserItem = { + username: string; + displayName: string | null; +}; + type TextAreaProps = { classes?: string; containerRef?: Ref; atMentionsEnabled: boolean; + usersWhoAnnotated: UserItem[]; }; function TextArea({ classes, containerRef, atMentionsEnabled, + usersWhoAnnotated, ...restProps }: TextAreaProps & JSX.TextareaHTMLAttributes) { const [popoverOpen, setPopoverOpen] = useState(false); + const [activeMention, setActiveMention] = useState(); const textareaRef = useSyncedRef(containerRef); + const [highlightedSuggestion, setHighlightedSuggestion] = useState(0); + const suggestions = useMemo(() => { + if (!atMentionsEnabled || activeMention === undefined) { + return []; + } + + return usersWhoAnnotated + .filter( + u => + // Match all users if the active mention is empty, which happens right + // after typing `@` + !activeMention || + `${u.username} ${u.displayName ?? ''}`.match(activeMention), + ) + .slice(0, 10); + }, [activeMention, atMentionsEnabled, usersWhoAnnotated]); useEffect(() => { if (!atMentionsEnabled) { @@ -205,7 +229,10 @@ function TextArea({ const listenerCollection = new ListenerCollection(); const checkForMentionAtCaret = () => { const term = termBeforePosition(textarea.value, textarea.selectionStart); - setPopoverOpen(term.startsWith('@')); + const isAtMention = term.startsWith('@'); + + setPopoverOpen(isAtMention); + setActiveMention(isAtMention ? term.substring(1) : undefined); }; // We listen for `keyup` to make sure the text in the textarea reflects the @@ -222,8 +249,33 @@ function TextArea({ // mention, so we check if the popover should be opened listenerCollection.add(textarea, 'click', checkForMentionAtCaret); + listenerCollection.add(textarea, 'keydown', e => { + if ( + !popoverOpen || + suggestions.length === 0 || + !['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key) + ) { + return; + } + + // When vertical arrows or Enter are pressed while the popover is open + // with suggestions, highlight or pick the right suggestion. + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'ArrowDown') { + setHighlightedSuggestion(prev => + Math.min(prev + 1, suggestions.length), + ); + } else if (e.key === 'ArrowUp') { + setHighlightedSuggestion(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + // TODO "Print" suggestion in textarea + } + }); + return () => listenerCollection.removeAll(); - }, [atMentionsEnabled, popoverOpen, textareaRef]); + }, [atMentionsEnabled, popoverOpen, suggestions.length, textareaRef]); return (
@@ -242,9 +294,30 @@ function TextArea({ open={popoverOpen} onClose={() => setPopoverOpen(false)} anchorElementRef={textareaRef} - classes="p-2" + classes="p-1" > - Suggestions +
    + {suggestions.map((s, index) => ( +
  • + {s.username} + {s.displayName} +
  • + ))} + {suggestions.length === 0 && ( +
  • + No matches. You can still write the username +
  • + )} +
)}
@@ -392,6 +465,13 @@ export type MarkdownEditorProps = { text: string; onEditText?: (text: string) => void; + + /** + * List of users who have annotated current document and belong to active group. + * This is used to populate the @mentions suggestions, when `atMentionsEnabled` + * is `true`. + */ + usersWhoAnnotated: UserItem[]; }; /** @@ -403,6 +483,7 @@ export default function MarkdownEditor({ onEditText = () => {}, text, textStyle = {}, + usersWhoAnnotated, }: MarkdownEditorProps) { // Whether the preview mode is currently active. const [preview, setPreview] = useState(false); @@ -473,6 +554,7 @@ export default function MarkdownEditor({ value={text} style={textStyle} atMentionsEnabled={atMentionsEnabled} + usersWhoAnnotated={usersWhoAnnotated} /> )}
diff --git a/src/sidebar/components/test/MarkdownEditor-test.js b/src/sidebar/components/test/MarkdownEditor-test.js index b8efe9ca920..598a09d0337 100644 --- a/src/sidebar/components/test/MarkdownEditor-test.js +++ b/src/sidebar/components/test/MarkdownEditor-test.js @@ -2,7 +2,7 @@ import { checkAccessibility, mockImportedComponents, } from '@hypothesis/frontend-testing'; -import { mount } from '@hypothesis/frontend-testing'; +import { mount, unmountAll } from '@hypothesis/frontend-testing'; import { render } from 'preact'; import { act } from 'preact/test-utils'; @@ -23,6 +23,7 @@ describe('MarkdownEditor', () => { }; let fakeIsMacOS; let MarkdownView; + let fakeStore; beforeEach(() => { fakeMarkdownCommands.convertSelectionToLink.resetHistory(); @@ -30,6 +31,10 @@ describe('MarkdownEditor', () => { fakeMarkdownCommands.toggleSpanStyle.resetHistory(); fakeIsMacOS = sinon.stub().returns(false); + fakeStore = { + isFeatureEnabled: sinon.stub().returns(false), + }; + MarkdownView = function MarkdownView() { return null; }; @@ -41,11 +46,13 @@ describe('MarkdownEditor', () => { '../../shared/user-agent': { isMacOS: fakeIsMacOS, }, + '../store': { useSidebarStore: () => fakeStore }, }); }); afterEach(() => { $imports.$restore(); + unmountAll(); }); function createComponent(props = {}, mountProps = {}) { diff --git a/src/sidebar/store/modules/annotations.ts b/src/sidebar/store/modules/annotations.ts index 1ad0dad2b7b..78034c57d62 100644 --- a/src/sidebar/store/modules/annotations.ts +++ b/src/sidebar/store/modules/annotations.ts @@ -8,6 +8,7 @@ import { createSelector } from 'reselect'; import { hasOwn } from '../../../shared/has-own'; import type { Annotation, SavedAnnotation } from '../../../types/api'; import type { HighlightCluster } from '../../../types/shared'; +import { username as getUsername } from '../../helpers/account-id'; import * as metadata from '../../helpers/annotation-metadata'; import { isHighlight, isSaved } from '../../helpers/annotation-metadata'; import { countIf, toTrueMap, trueKeys } from '../../util/collections'; @@ -34,6 +35,11 @@ type AnnotationStub = { $tag?: string; }; +export type UserItem = { + user: string; + displayName: string | null; +}; + const initialState = { annotations: [], highlighted: {}, @@ -567,6 +573,37 @@ const savedAnnotations = createSelector( annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[], ); +/** + * Return the list of unique users who authored any annotation, ordered by username. + */ +const usersWhoAnnotated = createSelector( + (state: State) => state.annotations, + annotations => { + const usersMap = new Map< + string, + { user: string; username: string; displayName: string | null } + >(); + annotations.forEach(anno => { + const { user } = anno; + const username = getUsername(user); + const displayName = anno.user_info?.display_name ?? null; + + // Keep a unique list of users + if (!usersMap.has(user)) { + usersMap.set(user, { user, username, displayName }); + } + }); + + // Sort users by username + return [...usersMap.values()].sort((a, b) => { + const lowerAUsername = a.username.toLowerCase(); + const lowerBUsername = b.username.toLowerCase(); + + return lowerAUsername.localeCompare(lowerBUsername); + }); + }, +); + export const annotationsModule = createStoreModule(initialState, { namespace: 'annotations', reducers, @@ -597,5 +634,6 @@ export const annotationsModule = createStoreModule(initialState, { noteCount, orphanCount, savedAnnotations, + usersWhoAnnotated, }, });