Skip to content

Commit c5c81e1

Browse files
committed
Display suggestions popover when at-mentioning
1 parent 1b6ceb1 commit c5c81e1

File tree

3 files changed

+131
-8
lines changed

3 files changed

+131
-8
lines changed

src/sidebar/components/Annotation/AnnotationEditor.tsx

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

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

170+
const atMentionsEnabled = store.isFeatureEnabled('at_mentions');
171+
const usersMatchingTerm = useCallback(
172+
(term: string) => store.usersWhoAnnotated(term),
173+
[store],
174+
);
175+
170176
return (
171177
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
172178
<div
@@ -179,7 +185,8 @@ function AnnotationEditor({
179185
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
180186
text={text}
181187
onEditText={onEditText}
182-
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
188+
atMentionsEnabled={atMentionsEnabled}
189+
usersMatchingTerm={usersMatchingTerm}
183190
/>
184191
<TagEditor
185192
onAddTag={onAddTag}

src/sidebar/components/MarkdownEditor.tsx

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

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

190196
function TextArea({
191197
classes,
192198
containerRef,
193199
atMentionsEnabled,
200+
usersMatchingTerm,
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 usersMatchingTerm(activeMention);
213+
}, [activeMention, atMentionsEnabled, usersMatchingTerm]);
198214

199215
useEffect(() => {
200216
if (!atMentionsEnabled) {
@@ -212,17 +228,46 @@ function TextArea({
212228
if (e.key === 'Escape') {
213229
return;
214230
}
215-
setPopoverOpen(
216-
termBeforePosition(textarea.value, textarea.selectionStart).startsWith(
217-
'@',
218-
),
231+
232+
const termBeforeCaret = termBeforePosition(
233+
textarea.value,
234+
textarea.selectionStart,
219235
);
236+
const isAtMention = termBeforeCaret.startsWith('@');
237+
238+
setPopoverOpen(isAtMention);
239+
setActiveMention(isAtMention ? termBeforeCaret.substring(1) : undefined);
240+
});
241+
242+
listenerCollection.add(textarea, 'keydown', e => {
243+
if (
244+
!popoverOpen ||
245+
suggestions.length === 0 ||
246+
!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)
247+
) {
248+
return;
249+
}
250+
251+
// When vertical arrows or Enter are pressed while the popover is open
252+
// with suggestions, highlight or pick the right suggestion.
253+
e.preventDefault();
254+
e.stopPropagation();
255+
256+
if (e.key === 'ArrowDown') {
257+
setHighlightedSuggestion(prev =>
258+
Math.min(prev + 1, suggestions.length),
259+
);
260+
} else if (e.key === 'ArrowUp') {
261+
setHighlightedSuggestion(prev => Math.max(prev - 1, 0));
262+
} else if (e.key === 'Enter') {
263+
// TODO "Print" suggestion in textarea
264+
}
220265
});
221266

222267
return () => {
223268
listenerCollection.removeAll();
224269
};
225-
}, [atMentionsEnabled, popoverOpen, textareaRef]);
270+
}, [atMentionsEnabled, popoverOpen, suggestions.length, textareaRef]);
226271

227272
return (
228273
<div className="relative">
@@ -241,9 +286,30 @@ function TextArea({
241286
open={popoverOpen}
242287
onClose={() => setPopoverOpen(false)}
243288
anchorElementRef={textareaRef}
244-
classes="p-2"
289+
classes="p-1"
245290
>
246-
Suggestions
291+
<ul className="flex-col gap-y-0.5">
292+
{suggestions.map((s, index) => (
293+
<li
294+
key={s.user}
295+
className={classnames(
296+
'flex justify-between items-center',
297+
'rounded p-2 hover:bg-grey-2',
298+
{
299+
'bg-grey-2': highlightedSuggestion === index,
300+
},
301+
)}
302+
>
303+
<span className="truncate">{s.user}</span>
304+
<span className="text-color-text-light">{s.displayName}</span>
305+
</li>
306+
))}
307+
{suggestions.length === 0 && (
308+
<li className="italic p-2">
309+
No matches. You can still write the username
310+
</li>
311+
)}
312+
</ul>
247313
</Popover>
248314
)}
249315
</div>
@@ -391,6 +457,13 @@ export type MarkdownEditorProps = {
391457
text: string;
392458

393459
onEditText?: (text: string) => void;
460+
461+
/**
462+
* A function that returns a list of users which username or display name
463+
* match provided term.
464+
* This is used only if `atMentionsEnabled` is `true`.
465+
*/
466+
usersMatchingTerm: (term: string) => UserItem[];
394467
};
395468

396469
/**
@@ -402,6 +475,7 @@ export default function MarkdownEditor({
402475
onEditText = () => {},
403476
text,
404477
textStyle = {},
478+
usersMatchingTerm,
405479
}: MarkdownEditorProps) {
406480
// Whether the preview mode is currently active.
407481
const [preview, setPreview] = useState(false);
@@ -472,6 +546,7 @@ export default function MarkdownEditor({
472546
value={text}
473547
style={textStyle}
474548
atMentionsEnabled={atMentionsEnabled}
549+
usersMatchingTerm={usersMatchingTerm}
475550
/>
476551
)}
477552
</div>

src/sidebar/store/modules/annotations.ts

Lines changed: 41 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 } 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,40 @@ const savedAnnotations = createSelector(
567573
annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[],
568574
);
569575

576+
/**
577+
* Return a list of unique users who authored any annotation, ordered by username.
578+
* Optionally filter and slice the list
579+
*/
580+
function usersWhoAnnotated({ annotations }: State, term?: string, max = 10) {
581+
const usersMap = new Map<
582+
string,
583+
{ user: string; displayName: string | null }
584+
>();
585+
annotations.forEach(anno => {
586+
const user = username(anno.user);
587+
const displayName = anno.user_info?.display_name ?? null;
588+
589+
if (
590+
// Keep a unique list of users
591+
!usersMap.has(user) &&
592+
// Match all users if the term is empty
593+
(!term || `${user} ${displayName ?? ''}`.match(term))
594+
) {
595+
usersMap.set(user, { user, displayName });
596+
}
597+
});
598+
599+
// Sort users by username and pick the top 10
600+
return [...usersMap.values()]
601+
.sort((a, b) => {
602+
const lowerAUsername = a.user.toLowerCase();
603+
const lowerBUsername = b.user.toLowerCase();
604+
605+
return lowerAUsername.localeCompare(lowerBUsername);
606+
})
607+
.slice(0, max);
608+
}
609+
570610
export const annotationsModule = createStoreModule(initialState, {
571611
namespace: 'annotations',
572612
reducers,
@@ -597,5 +637,6 @@ export const annotationsModule = createStoreModule(initialState, {
597637
noteCount,
598638
orphanCount,
599639
savedAnnotations,
640+
usersWhoAnnotated,
600641
},
601642
});

0 commit comments

Comments
 (0)