Skip to content

Commit 22ee031

Browse files
committed
Populate list of suggestions matching @mentions
1 parent 4b7f380 commit 22ee031

File tree

5 files changed

+142
-9
lines changed

5 files changed

+142
-9
lines changed

src/sidebar/components/Annotation/AnnotationEditor.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ function AnnotationEditor({
167167

168168
const textStyle = applyTheme(['annotationFontFamily'], settings);
169169

170+
const atMentionsEnabled = store.isFeatureEnabled('at_mentions');
171+
const usersWhoAnnotated = store.usersWhoAnnotated();
172+
170173
return (
171174
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
172175
<div
@@ -179,7 +182,8 @@ function AnnotationEditor({
179182
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
180183
text={text}
181184
onEditText={onEditText}
182-
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
185+
atMentionsEnabled={atMentionsEnabled}
186+
usersWhoAnnotated={usersWhoAnnotated}
183187
/>
184188
<TagEditor
185189
onAddTag={onAddTag}

src/sidebar/components/Annotation/test/AnnotationEditor-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('AnnotationEditor', () => {
6262
removeDraft: sinon.stub(),
6363
removeAnnotations: sinon.stub(),
6464
isFeatureEnabled: sinon.stub().returns(false),
65+
usersWhoAnnotated: sinon.stub().returns([]),
6566
};
6667

6768
$imports.$mock(mockImportedComponents());

src/sidebar/components/MarkdownEditor.tsx

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,20 +181,44 @@ function ToolbarButton({
181181
);
182182
}
183183

184+
export type UserItem = {
185+
username: string;
186+
displayName: string | null;
187+
};
188+
184189
type TextAreaProps = {
185190
classes?: string;
186191
containerRef?: Ref<HTMLTextAreaElement>;
187192
atMentionsEnabled: boolean;
193+
usersWhoAnnotated: UserItem[];
188194
};
189195

190196
function TextArea({
191197
classes,
192198
containerRef,
193199
atMentionsEnabled,
200+
usersWhoAnnotated,
194201
...restProps
195202
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
196203
const [popoverOpen, setPopoverOpen] = useState(false);
204+
const [activeMention, setActiveMention] = useState<string>();
197205
const textareaRef = useSyncedRef(containerRef);
206+
const [highlightedSuggestion, setHighlightedSuggestion] = useState(0);
207+
const suggestions = useMemo(() => {
208+
if (!atMentionsEnabled || activeMention === undefined) {
209+
return [];
210+
}
211+
212+
return usersWhoAnnotated
213+
.filter(
214+
u =>
215+
// Match all users if the active mention is empty, which happens right
216+
// after typing `@`
217+
!activeMention ||
218+
`${u.username} ${u.displayName ?? ''}`.match(activeMention),
219+
)
220+
.slice(0, 10);
221+
}, [activeMention, atMentionsEnabled, usersWhoAnnotated]);
198222

199223
useEffect(() => {
200224
if (!atMentionsEnabled) {
@@ -212,17 +236,46 @@ function TextArea({
212236
if (e.key === 'Escape') {
213237
return;
214238
}
215-
setPopoverOpen(
216-
termBeforePosition(textarea.value, textarea.selectionStart).startsWith(
217-
'@',
218-
),
239+
240+
const termBeforeCaret = termBeforePosition(
241+
textarea.value,
242+
textarea.selectionStart,
219243
);
244+
const isAtMention = termBeforeCaret.startsWith('@');
245+
246+
setPopoverOpen(isAtMention);
247+
setActiveMention(isAtMention ? termBeforeCaret.substring(1) : undefined);
248+
});
249+
250+
listenerCollection.add(textarea, 'keydown', e => {
251+
if (
252+
!popoverOpen ||
253+
suggestions.length === 0 ||
254+
!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)
255+
) {
256+
return;
257+
}
258+
259+
// When vertical arrows or Enter are pressed while the popover is open
260+
// with suggestions, highlight or pick the right suggestion.
261+
e.preventDefault();
262+
e.stopPropagation();
263+
264+
if (e.key === 'ArrowDown') {
265+
setHighlightedSuggestion(prev =>
266+
Math.min(prev + 1, suggestions.length),
267+
);
268+
} else if (e.key === 'ArrowUp') {
269+
setHighlightedSuggestion(prev => Math.max(prev - 1, 0));
270+
} else if (e.key === 'Enter') {
271+
// TODO "Print" suggestion in textarea
272+
}
220273
});
221274

222275
return () => {
223276
listenerCollection.removeAll();
224277
};
225-
}, [atMentionsEnabled, popoverOpen, textareaRef]);
278+
}, [atMentionsEnabled, popoverOpen, suggestions.length, textareaRef]);
226279

227280
return (
228281
<div className="relative">
@@ -241,9 +294,30 @@ function TextArea({
241294
open={popoverOpen}
242295
onClose={() => setPopoverOpen(false)}
243296
anchorElementRef={textareaRef}
244-
classes="p-2"
297+
classes="p-1"
245298
>
246-
Suggestions
299+
<ul className="flex-col gap-y-0.5">
300+
{suggestions.map((s, index) => (
301+
<li
302+
key={s.username}
303+
className={classnames(
304+
'flex justify-between items-center',
305+
'rounded p-2 hover:bg-grey-2',
306+
{
307+
'bg-grey-2': highlightedSuggestion === index,
308+
},
309+
)}
310+
>
311+
<span className="truncate">{s.username}</span>
312+
<span className="text-color-text-light">{s.displayName}</span>
313+
</li>
314+
))}
315+
{suggestions.length === 0 && (
316+
<li className="italic p-2">
317+
No matches. You can still write the username
318+
</li>
319+
)}
320+
</ul>
247321
</Popover>
248322
)}
249323
</div>
@@ -391,6 +465,13 @@ export type MarkdownEditorProps = {
391465
text: string;
392466

393467
onEditText?: (text: string) => void;
468+
469+
/**
470+
* List of users who have annotated current document and belong to active group.
471+
* This is used to populate the @mentions suggestions, when `atMentionsEnabled`
472+
* is `true`.
473+
*/
474+
usersWhoAnnotated: UserItem[];
394475
};
395476

396477
/**
@@ -402,6 +483,7 @@ export default function MarkdownEditor({
402483
onEditText = () => {},
403484
text,
404485
textStyle = {},
486+
usersWhoAnnotated,
405487
}: MarkdownEditorProps) {
406488
// Whether the preview mode is currently active.
407489
const [preview, setPreview] = useState(false);
@@ -472,6 +554,7 @@ export default function MarkdownEditor({
472554
value={text}
473555
style={textStyle}
474556
atMentionsEnabled={atMentionsEnabled}
557+
usersWhoAnnotated={usersWhoAnnotated}
475558
/>
476559
)}
477560
</div>

src/sidebar/components/test/MarkdownEditor-test.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
checkAccessibility,
33
mockImportedComponents,
44
} from '@hypothesis/frontend-testing';
5-
import { mount } from '@hypothesis/frontend-testing';
5+
import { mount, unmountAll } from '@hypothesis/frontend-testing';
66
import { render } from 'preact';
77
import { act } from 'preact/test-utils';
88

@@ -23,13 +23,18 @@ describe('MarkdownEditor', () => {
2323
};
2424
let fakeIsMacOS;
2525
let MarkdownView;
26+
let fakeStore;
2627

2728
beforeEach(() => {
2829
fakeMarkdownCommands.convertSelectionToLink.resetHistory();
2930
fakeMarkdownCommands.toggleBlockStyle.resetHistory();
3031
fakeMarkdownCommands.toggleSpanStyle.resetHistory();
3132
fakeIsMacOS = sinon.stub().returns(false);
3233

34+
fakeStore = {
35+
isFeatureEnabled: sinon.stub().returns(false),
36+
};
37+
3338
MarkdownView = function MarkdownView() {
3439
return null;
3540
};
@@ -41,11 +46,13 @@ describe('MarkdownEditor', () => {
4146
'../../shared/user-agent': {
4247
isMacOS: fakeIsMacOS,
4348
},
49+
'../store': { useSidebarStore: () => fakeStore },
4450
});
4551
});
4652

4753
afterEach(() => {
4854
$imports.$restore();
55+
unmountAll();
4956
});
5057

5158
function createComponent(props = {}, mountProps = {}) {

src/sidebar/store/modules/annotations.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createSelector } from 'reselect';
88
import { hasOwn } from '../../../shared/has-own';
99
import type { Annotation, SavedAnnotation } from '../../../types/api';
1010
import type { HighlightCluster } from '../../../types/shared';
11+
import { username as getUsername } from '../../helpers/account-id';
1112
import * as metadata from '../../helpers/annotation-metadata';
1213
import { isHighlight, isSaved } from '../../helpers/annotation-metadata';
1314
import { countIf, toTrueMap, trueKeys } from '../../util/collections';
@@ -34,6 +35,11 @@ type AnnotationStub = {
3435
$tag?: string;
3536
};
3637

38+
export type UserItem = {
39+
user: string;
40+
displayName: string | null;
41+
};
42+
3743
const initialState = {
3844
annotations: [],
3945
highlighted: {},
@@ -567,6 +573,37 @@ const savedAnnotations = createSelector(
567573
annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[],
568574
);
569575

576+
/**
577+
* Return the list of unique users who authored any annotation, ordered by username.
578+
*/
579+
const usersWhoAnnotated = createSelector(
580+
(state: State) => state.annotations,
581+
annotations => {
582+
const usersMap = new Map<
583+
string,
584+
{ user: string; username: string; displayName: string | null }
585+
>();
586+
annotations.forEach(anno => {
587+
const { user } = anno;
588+
const username = getUsername(user);
589+
const displayName = anno.user_info?.display_name ?? null;
590+
591+
// Keep a unique list of users
592+
if (!usersMap.has(user)) {
593+
usersMap.set(user, { user, username, displayName });
594+
}
595+
});
596+
597+
// Sort users by username
598+
return [...usersMap.values()].sort((a, b) => {
599+
const lowerAUsername = a.username.toLowerCase();
600+
const lowerBUsername = b.username.toLowerCase();
601+
602+
return lowerAUsername.localeCompare(lowerBUsername);
603+
});
604+
},
605+
);
606+
570607
export const annotationsModule = createStoreModule(initialState, {
571608
namespace: 'annotations',
572609
reducers,
@@ -597,5 +634,6 @@ export const annotationsModule = createStoreModule(initialState, {
597634
noteCount,
598635
orphanCount,
599636
savedAnnotations,
637+
usersWhoAnnotated,
600638
},
601639
});

0 commit comments

Comments
 (0)