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..da5aea389e8 100644 --- a/src/sidebar/components/MarkdownEditor.tsx +++ b/src/sidebar/components/MarkdownEditor.tsx @@ -20,9 +20,14 @@ import { import type { IconComponent } from '@hypothesis/frontend-shared/lib/types'; import classnames from 'classnames'; import type { Ref, JSX } from 'preact'; -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; -import { ListenerCollection } from '../../shared/listener-collection'; import { isMacOS } from '../../shared/user-agent'; import { LinkType, @@ -181,49 +186,84 @@ function ToolbarButton({ ); } +export type UserItem = { + username: string; + displayName: string | null; +}; + type TextAreaProps = { classes?: string; containerRef?: Ref; atMentionsEnabled: boolean; + usersWhoAnnotated: UserItem[]; + onEditText: (text: string) => void; }; function TextArea({ classes, containerRef, atMentionsEnabled, + usersWhoAnnotated, + onEditText, + onKeyDown, ...restProps -}: TextAreaProps & JSX.TextareaHTMLAttributes) { +}: TextAreaProps & JSX.TextareaHTMLAttributes) { const [popoverOpen, setPopoverOpen] = useState(false); + const [activeMention, setActiveMention] = useState(); const textareaRef = useSyncedRef(containerRef); - - useEffect(() => { - if (!atMentionsEnabled) { - return () => {}; + const [highlightedSuggestion, setHighlightedSuggestion] = useState(0); + const suggestions = useMemo(() => { + if (!atMentionsEnabled || activeMention === undefined) { + return []; } - const textarea = textareaRef.current!; - const listenerCollection = new ListenerCollection(); - const checkForMentionAtCaret = () => { + return usersWhoAnnotated + .filter( + u => + // Match all users if the active mention is empty, which happens right + // after typing `@` + !activeMention || + `${u.username} ${u.displayName ?? ''}` + .toLowerCase() + .match(activeMention.toLowerCase()), + ) + .slice(0, 10); + }, [activeMention, atMentionsEnabled, usersWhoAnnotated]); + + const checkForMentionAtCaret = useCallback( + (textarea: HTMLTextAreaElement) => { + if (!atMentionsEnabled) { + return; + } + const term = termBeforePosition(textarea.value, textarea.selectionStart); - setPopoverOpen(term.startsWith('@')); - }; - - // We listen for `keyup` to make sure the text in the textarea reflects the - // just-pressed key when we evaluate it - listenerCollection.add(textarea, 'keyup', e => { - // `Esc` key is used to close the popover. Do nothing and let users close - // it that way, even if the caret is in a mention - if (e.key !== 'Escape') { - checkForMentionAtCaret(); + const isAtMention = term.startsWith('@'); + + setPopoverOpen(isAtMention); + setActiveMention(isAtMention ? term.substring(1) : undefined); + + // Reset highlighted suggestion when closing the popover + if (!isAtMention) { + setHighlightedSuggestion(0); } - }); + }, + [atMentionsEnabled], + ); + const applySuggestion = useCallback( + (suggestion: UserItem) => { + const textarea = textareaRef.current!; + const term = termBeforePosition(textarea.value, textarea.selectionStart); - // When clicking the textarea it's possible the caret is moved "into" a - // mention, so we check if the popover should be opened - listenerCollection.add(textarea, 'click', checkForMentionAtCaret); + // TODO Do not replace the term, but the exact range of the term + onEditText(textarea.value.replace(term, `@${suggestion.username} `)); - return () => listenerCollection.removeAll(); - }, [atMentionsEnabled, popoverOpen, textareaRef]); + // Close popover and reset highlighted suggestion once the value is + // replaced + setPopoverOpen(false); + setHighlightedSuggestion(0); + }, + [onEditText, textareaRef], + ); return (
@@ -234,7 +274,42 @@ function TextArea({ 'focus:bg-white focus:outline-none focus:shadow-focus-inner', classes, )} + onInput={(e: Event) => onEditText((e.target as HTMLInputElement).value)} {...restProps} + // We listen for `keyup` to make sure the text in the textarea reflects the + // just-pressed key when we evaluate it + onKeyUp={e => { + // `Esc` key is used to close the popover. Do nothing and let users close + // it that way, even if the caret is in a mention + // `Enter` is handled on keydown. Do not handle it here. + if (!['Escape', 'Enter'].includes(e.key)) { + checkForMentionAtCaret(e.target as HTMLTextAreaElement); + } + }} + onKeyDown={e => { + // Invoke original handler ir present + onKeyDown?.(e); + + if (!popoverOpen || suggestions.length === 0) { + return; + } + + // When vertical arrows or Enter are pressed while the popover is open + // with suggestions, highlight or pick the right suggestion. + if (e.key === 'ArrowDown') { + setHighlightedSuggestion(prev => + Math.min(prev + 1, suggestions.length - 1), + ); + } else if (e.key === 'ArrowUp') { + setHighlightedSuggestion(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + applySuggestion(suggestions[highlightedSuggestion]); + } + }} + // When clicking the textarea, it's possible the caret is moved "into" a + // mention, so we check if the popover should be opened + onClick={e => checkForMentionAtCaret(e.target as HTMLTextAreaElement)} ref={textareaRef} /> {atMentionsEnabled && ( @@ -242,9 +317,45 @@ function TextArea({ open={popoverOpen} onClose={() => setPopoverOpen(false)} anchorElementRef={textareaRef} - classes="p-2" + classes="p-1" > - Suggestions +
    + {suggestions.map((s, index) => ( + // These options are indirectly handled via keyboard event + // handlers in the textarea, hence, we don't want to add keyboard + // event handler here, but we want to handle click events + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
  • { + e.stopPropagation(); + applySuggestion(s); + }} + role="option" + aria-selected={highlightedSuggestion === index} + tabIndex={highlightedSuggestion === index ? 0 : -1} + > + {s.username} + {s.displayName} +
  • + ))} + {suggestions.length === 0 && ( +
  • + No matches. You can still write the username +
  • + )} +
)}
@@ -392,6 +503,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 +521,7 @@ export default function MarkdownEditor({ onEditText = () => {}, text, textStyle = {}, + usersWhoAnnotated, }: MarkdownEditorProps) { // Whether the preview mode is currently active. const [preview, setPreview] = useState(false); @@ -467,12 +586,11 @@ export default function MarkdownEditor({ containerRef={input} onClick={(e: Event) => e.stopPropagation()} onKeyDown={handleKeyDown} - onInput={(e: Event) => - onEditText((e.target as HTMLInputElement).value) - } + onEditText={onEditText} 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..c312875c292 100644 --- a/src/sidebar/components/test/MarkdownEditor-test.js +++ b/src/sidebar/components/test/MarkdownEditor-test.js @@ -54,6 +54,7 @@ describe('MarkdownEditor', () => { label="Test editor" text="test" atMentionsEnabled={false} + usersWhoAnnotated={[]} {...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, }, });