diff --git a/src/sidebar/components/Annotation/AnnotationEditor.tsx b/src/sidebar/components/Annotation/AnnotationEditor.tsx index 30d76ec2593..570a96fa345 100644 --- a/src/sidebar/components/Annotation/AnnotationEditor.tsx +++ b/src/sidebar/components/Annotation/AnnotationEditor.tsx @@ -167,6 +167,12 @@ function AnnotationEditor({ const textStyle = applyTheme(['annotationFontFamily'], settings); + const atMentionsEnabled = store.isFeatureEnabled('at_mentions'); + const usersMatchingTerm = useCallback( + (term: string) => store.usersWhoAnnotated(term), + [store], + ); + return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
; atMentionsEnabled: boolean; + usersMatchingTerm: (mention: string) => UserItem[]; }; function TextArea({ classes, containerRef, atMentionsEnabled, + usersMatchingTerm, ...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 usersMatchingTerm(activeMention); + }, [activeMention, atMentionsEnabled, usersMatchingTerm]); useEffect(() => { if (!atMentionsEnabled) { @@ -212,17 +228,46 @@ function TextArea({ if (e.key === 'Escape') { return; } - setPopoverOpen( - termBeforePosition(textarea.value, textarea.selectionStart).startsWith( - '@', - ), + + const termBeforeCaret = termBeforePosition( + textarea.value, + textarea.selectionStart, ); + const isAtMention = termBeforeCaret.startsWith('@'); + + setPopoverOpen(isAtMention); + setActiveMention(isAtMention ? termBeforeCaret.substring(1) : undefined); + }); + + 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 (
@@ -241,9 +286,30 @@ function TextArea({ open={popoverOpen} onClose={() => setPopoverOpen(false)} anchorElementRef={textareaRef} - classes="p-2" + classes="p-1" > - Suggestions +
    + {suggestions.map((s, index) => ( +
  • + {s.user} + {s.displayName} +
  • + ))} + {suggestions.length === 0 && ( +
  • + No matches. You can still write the username +
  • + )} +
)}
@@ -391,6 +457,13 @@ export type MarkdownEditorProps = { text: string; onEditText?: (text: string) => void; + + /** + * A function that returns a list of users which username or display name + * match provided term. + * This is used only if `atMentionsEnabled` is `true`. + */ + usersMatchingTerm: (term: string) => UserItem[]; }; /** @@ -402,6 +475,7 @@ export default function MarkdownEditor({ onEditText = () => {}, text, textStyle = {}, + usersMatchingTerm, }: MarkdownEditorProps) { // Whether the preview mode is currently active. const [preview, setPreview] = useState(false); @@ -472,6 +546,7 @@ export default function MarkdownEditor({ value={text} style={textStyle} atMentionsEnabled={atMentionsEnabled} + usersMatchingTerm={usersMatchingTerm} /> )}
diff --git a/src/sidebar/store/modules/annotations.ts b/src/sidebar/store/modules/annotations.ts index 1ad0dad2b7b..9b635c8fd72 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 } 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,40 @@ const savedAnnotations = createSelector( annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[], ); +/** + * Return a list of unique users who authored any annotation, ordered by username. + * Optionally filter and slice the list + */ +function usersWhoAnnotated({ annotations }: State, term?: string, max = 10) { + const usersMap = new Map< + string, + { user: string; displayName: string | null } + >(); + annotations.forEach(anno => { + const user = username(anno.user); + const displayName = anno.user_info?.display_name ?? null; + + if ( + // Keep a unique list of users + !usersMap.has(user) && + // Match all users if the term is empty + (!term || `${user} ${displayName ?? ''}`.match(term)) + ) { + usersMap.set(user, { user, displayName }); + } + }); + + // Sort users by username and pick the top 10 + return [...usersMap.values()] + .sort((a, b) => { + const lowerAUsername = a.user.toLowerCase(); + const lowerBUsername = b.user.toLowerCase(); + + return lowerAUsername.localeCompare(lowerBUsername); + }) + .slice(0, max); +} + export const annotationsModule = createStoreModule(initialState, { namespace: 'annotations', reducers, @@ -597,5 +637,6 @@ export const annotationsModule = createStoreModule(initialState, { noteCount, orphanCount, savedAnnotations, + usersWhoAnnotated, }, });