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