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