Skip to content

Commit b047586

Browse files
committed
Wrap display-name-based mentions in mention tags
1 parent 01c4134 commit b047586

File tree

8 files changed

+485
-56
lines changed

8 files changed

+485
-56
lines changed

src/sidebar/components/Annotation/AnnotationEditor.tsx

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
1+
import {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
} from 'preact/hooks';
28

39
import type { Annotation } from '../../../types/api';
410
import type { SidebarSettings } from '../../../types/config';
@@ -9,6 +15,7 @@ import {
915
isReply,
1016
isSaved,
1117
} from '../../helpers/annotation-metadata';
18+
import type { UserItem } from '../../helpers/mention-suggestions';
1219
import { combineUsersForMentions } from '../../helpers/mention-suggestions';
1320
import type { MentionMode } from '../../helpers/mentions';
1421
import { applyTheme } from '../../helpers/theme';
@@ -137,6 +144,44 @@ function AnnotationEditor({
137144
[annotation, draft, isReplyAnno, store],
138145
);
139146

147+
const defaultAuthority = store.defaultAuthority();
148+
const mentionMode = useMemo(
149+
(): MentionMode =>
150+
isThirdPartyUser(annotation.user, defaultAuthority)
151+
? 'display-name'
152+
: 'username',
153+
[annotation.user, defaultAuthority],
154+
);
155+
// Map to track users that have been mentioned, based on their display name,
156+
// so that we can wrap user-name mentions in mention tags when the annotation
157+
// is eventually saved.
158+
const displayNameToUserMap = useRef<Map<string, UserItem>>(
159+
new Map(
160+
mentionMode === 'username'
161+
? []
162+
: // If the annotation is being edited, it may have mentions. Use them to
163+
// initialize the display names map
164+
annotation.mentions
165+
?.filter(mention => !!mention.display_name)
166+
.map(({ userid, username, display_name: displayName }) => [
167+
displayName!,
168+
{ userid, username, displayName },
169+
]),
170+
),
171+
);
172+
const onInsertMentionSuggestion = useCallback(
173+
(user: UserItem) => {
174+
const { displayName } = user;
175+
// We need to track the user info for every mention in display-name
176+
// mode, so that it is possible to wrap those mentions in tags
177+
// afterward.
178+
if (displayName && mentionMode === 'display-name') {
179+
displayNameToUserMap.current.set(displayName, user);
180+
}
181+
},
182+
[mentionMode],
183+
);
184+
140185
const onSave = async () => {
141186
// If there is any content in the tag editor input field that has
142187
// not been committed as a tag, go ahead and add it as a tag
@@ -148,8 +193,14 @@ function AnnotationEditor({
148193
isSaved(annotation) ? 'updated' : 'saved'
149194
}`;
150195
try {
151-
await annotationsService.save(annotation);
196+
await annotationsService.save(
197+
annotation,
198+
mentionMode === 'username'
199+
? { mentionMode }
200+
: { mentionMode, usersMap: displayNameToUserMap.current },
201+
);
152202
toastMessenger.success(successMessage, { visuallyHidden: true });
203+
displayNameToUserMap.current = new Map();
153204
} catch {
154205
toastMessenger.error('Saving annotation failed');
155206
}
@@ -158,6 +209,7 @@ function AnnotationEditor({
158209
// Revert changes to this annotation
159210
const onCancel = useCallback(() => {
160211
store.removeDraft(annotation);
212+
displayNameToUserMap.current = new Map();
161213
if (!isSaved(annotation)) {
162214
store.removeAnnotations([annotation]);
163215
}
@@ -178,15 +230,6 @@ function AnnotationEditor({
178230

179231
const textStyle = applyTheme(['annotationFontFamily'], settings);
180232

181-
const defaultAuthority = store.defaultAuthority();
182-
const mentionMode = useMemo(
183-
(): MentionMode =>
184-
isThirdPartyUser(annotation.user, defaultAuthority)
185-
? 'display-name'
186-
: 'username',
187-
[annotation.user, defaultAuthority],
188-
);
189-
190233
const mentionsEnabled = store.isFeatureEnabled('at_mentions');
191234
const usersWhoAnnotated = store.usersWhoAnnotated();
192235
const focusedGroupMembers = store.getFocusedGroupMembers();
@@ -219,6 +262,7 @@ function AnnotationEditor({
219262
showHelpLink={showHelpLink}
220263
mentions={annotation.mentions}
221264
mentionMode={mentionMode}
265+
onInsertMentionSuggestion={onInsertMentionSuggestion}
222266
/>
223267
<TagEditor
224268
onAddTag={onAddTag}

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,150 @@ describe('AnnotationEditor', () => {
477477
});
478478
});
479479

480+
function insertMentionSuggestion(wrapper, user) {
481+
wrapper.find('MarkdownEditor').props().onInsertMentionSuggestion(user);
482+
}
483+
484+
context('when annotation author is a third party user', () => {
485+
it('initializes display names map with annotation mentions', () => {
486+
const mentions = [
487+
{
488+
userid: 'acct:[email protected]',
489+
},
490+
{
491+
userid: 'acct:[email protected]',
492+
display_name: 'Foo',
493+
username: 'foo',
494+
},
495+
{
496+
userid: 'acct:[email protected]',
497+
display_name: 'Bar',
498+
username: 'bar',
499+
},
500+
];
501+
const annotation = {
502+
...fixtures.defaultAnnotation(),
503+
mentions,
504+
user: 'acct:[email protected]', // Third party user
505+
};
506+
const wrapper = createComponent({ annotation });
507+
508+
wrapper.find('AnnotationPublishControl').props().onSave();
509+
510+
assert.calledWith(
511+
fakeAnnotationsService.save,
512+
annotation,
513+
sinon.match({
514+
mentionMode: 'display-name',
515+
usersMap: new Map([
516+
[
517+
'Foo',
518+
{
519+
userid: 'acct:[email protected]',
520+
displayName: 'Foo',
521+
username: 'foo',
522+
},
523+
],
524+
[
525+
'Bar',
526+
{
527+
userid: 'acct:[email protected]',
528+
displayName: 'Bar',
529+
username: 'bar',
530+
},
531+
],
532+
]),
533+
}),
534+
);
535+
});
536+
537+
it('tracks user info for inserted mention suggestions', () => {
538+
const annotation = {
539+
...fixtures.defaultAnnotation(),
540+
mentions: [],
541+
user: 'acct:[email protected]', // Third party user
542+
};
543+
const wrapper = createComponent({ annotation });
544+
545+
insertMentionSuggestion(wrapper, {
546+
userid: 'acct:[email protected]',
547+
displayName: 'Jane Doe',
548+
username: 'jane_doe',
549+
});
550+
insertMentionSuggestion(wrapper, {
551+
userid: 'acct:[email protected]',
552+
displayName: 'John Doe',
553+
username: 'johndoe',
554+
});
555+
556+
// Users without displayName are ignored
557+
insertMentionSuggestion(wrapper, {
558+
userid: 'acct:[email protected]',
559+
username: 'ignored',
560+
});
561+
562+
wrapper.find('AnnotationPublishControl').props().onSave();
563+
564+
assert.calledWith(
565+
fakeAnnotationsService.save,
566+
annotation,
567+
sinon.match({
568+
mentionMode: 'display-name',
569+
usersMap: new Map([
570+
[
571+
'Jane Doe',
572+
{
573+
userid: 'acct:[email protected]',
574+
displayName: 'Jane Doe',
575+
username: 'jane_doe',
576+
},
577+
],
578+
[
579+
'John Doe',
580+
{
581+
userid: 'acct:[email protected]',
582+
displayName: 'John Doe',
583+
username: 'johndoe',
584+
},
585+
],
586+
]),
587+
}),
588+
);
589+
});
590+
});
591+
592+
context('when annotation author is a first party user', () => {
593+
it('does not track user info for inserted suggestions', () => {
594+
fakeStore.defaultAuthority.returns('hypothes.is');
595+
596+
const annotation = {
597+
...fixtures.defaultAnnotation(),
598+
mentions: [],
599+
user: 'acct:[email protected]', // First party user
600+
};
601+
const wrapper = createComponent({ annotation });
602+
603+
insertMentionSuggestion(wrapper, {
604+
userid: 'acct:[email protected]',
605+
displayName: 'Jane Doe',
606+
username: 'jane_doe',
607+
});
608+
insertMentionSuggestion(wrapper, {
609+
userid: 'acct:[email protected]',
610+
displayName: 'John Doe',
611+
username: 'johndoe',
612+
});
613+
614+
wrapper.find('AnnotationPublishControl').props().onSave();
615+
616+
assert.calledWith(
617+
fakeAnnotationsService.save,
618+
annotation,
619+
sinon.match({ mentionMode: 'username' }),
620+
);
621+
});
622+
});
623+
480624
it(
481625
'should pass a11y checks',
482626
checkAccessibility([

src/sidebar/components/MarkdownEditor.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ type TextAreaProps = {
207207
usersForMentions: UsersForMentions;
208208
onEditText: (text: string) => void;
209209
mentionMode: MentionMode;
210+
onInsertMentionSuggestion?: (user: UserItem) => void;
210211
};
211212

212213
function TextArea({
@@ -217,6 +218,7 @@ function TextArea({
217218
onEditText,
218219
onKeyDown,
219220
mentionMode,
221+
onInsertMentionSuggestion,
220222
...restProps
221223
}: TextAreaProps & JSX.TextareaHTMLAttributes) {
222224
const [popoverOpen, setPopoverOpen] = useState(false);
@@ -276,12 +278,15 @@ function TextArea({
276278
// Then update state to keep it in sync.
277279
onEditText(textarea.value);
278280

281+
// Additionally, notify that a mention was inserted from a suggestion
282+
onInsertMentionSuggestion?.(suggestion);
283+
279284
// Close popover and reset highlighted suggestion once the value is
280285
// replaced
281286
setPopoverOpen(false);
282287
setHighlightedSuggestion(0);
283288
},
284-
[mentionMode, onEditText, textareaRef],
289+
[mentionMode, onEditText, onInsertMentionSuggestion, textareaRef],
285290
);
286291

287292
const usersListboxId = useId();
@@ -553,6 +558,9 @@ export type MarkdownEditorProps = {
553558
/** List of mentions extracted from the annotation text. */
554559
mentions?: Mention[];
555560
mentionMode: MentionMode;
561+
562+
/** Invoked when a mention is inserted from a suggestion */
563+
onInsertMentionSuggestion?: (suggestion: UserItem) => void;
556564
};
557565

558566
/**
@@ -568,14 +576,18 @@ export default function MarkdownEditor({
568576
usersForMentions,
569577
mentions,
570578
mentionMode,
579+
onInsertMentionSuggestion,
571580
}: MarkdownEditorProps) {
572581
// Whether the preview mode is currently active.
573582
const [preview, setPreview] = useState(false);
574583

575584
// The input element where the user inputs their comment.
576585
const input = useRef<HTMLTextAreaElement>(null);
577586

578-
const textWithoutMentionTags = useMemo(() => unwrapMentions(text), [text]);
587+
const textWithoutMentionTags = useMemo(
588+
() => unwrapMentions(text, mentionMode),
589+
[mentionMode, text],
590+
);
579591

580592
useEffect(() => {
581593
if (!preview) {
@@ -642,6 +654,7 @@ export default function MarkdownEditor({
642654
mentionsEnabled={mentionsEnabled}
643655
usersForMentions={usersForMentions}
644656
mentionMode={mentionMode}
657+
onInsertMentionSuggestion={onInsertMentionSuggestion}
645658
/>
646659
)}
647660
</div>

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe('MarkdownEditor', () => {
5858
text="test"
5959
mentionsEnabled={false}
6060
usersForMentions={{ status: 'loaded', users: [], ...usersForMentions }}
61+
mentionMode="username"
6162
{...rest}
6263
/>,
6364
mountProps,
@@ -573,8 +574,10 @@ describe('MarkdownEditor', () => {
573574

574575
it('applies highlighted suggestion when `Enter` is pressed', () => {
575576
const onEditText = sinon.stub();
577+
const onInsertMentionSuggestion = sinon.stub();
576578
const wrapper = createComponent({
577579
onEditText,
580+
onInsertMentionSuggestion,
578581
mentionsEnabled: true,
579582
usersForMentions: {
580583
status: 'loaded',
@@ -595,6 +598,11 @@ describe('MarkdownEditor', () => {
595598

596599
// The textarea should include the username for second suggestion
597600
assert.calledWith(onEditText, '@two ');
601+
// Selected mention should have been passed to onInsertMentionSuggestion
602+
assert.calledWith(
603+
onInsertMentionSuggestion,
604+
sinon.match({ username: 'two', displayName: 'johndoe' }),
605+
);
598606
});
599607

600608
it('sets users to "loading" if users for mentions are being loaded', () => {

0 commit comments

Comments
 (0)