@@ -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,
},
});