diff --git a/src/sidebar/components/Annotation/AnnotationEditor.tsx b/src/sidebar/components/Annotation/AnnotationEditor.tsx index 0c76fd3b79b..90a4cccd688 100644 --- a/src/sidebar/components/Annotation/AnnotationEditor.tsx +++ b/src/sidebar/components/Annotation/AnnotationEditor.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; import type { Annotation } from '../../../types/api'; import type { SidebarSettings } from '../../../types/config'; @@ -9,6 +15,7 @@ import { isReply, isSaved, } from '../../helpers/annotation-metadata'; +import type { UserItem } from '../../helpers/mention-suggestions'; import { combineUsersForMentions } from '../../helpers/mention-suggestions'; import type { MentionMode } from '../../helpers/mentions'; import { applyTheme } from '../../helpers/theme'; @@ -137,6 +144,44 @@ function AnnotationEditor({ [annotation, draft, isReplyAnno, store], ); + const defaultAuthority = store.defaultAuthority(); + const mentionMode = useMemo( + (): MentionMode => + isThirdPartyUser(annotation.user, defaultAuthority) + ? 'display-name' + : 'username', + [annotation.user, defaultAuthority], + ); + // Map to track users that have been mentioned, based on their display name, + // so that we can wrap user-name mentions in mention tags when the annotation + // is eventually saved. + const displayNameToUserMap = useRef>( + new Map( + mentionMode === 'username' + ? [] + : // If the annotation is being edited, it may have mentions. Use them to + // initialize the display names map + annotation.mentions + ?.filter(mention => !!mention.display_name) + .map(({ userid, username, display_name: displayName }) => [ + displayName!, + { userid, username, displayName }, + ]), + ), + ); + const onInsertMentionSuggestion = useCallback( + (user: UserItem) => { + const { displayName } = user; + // We need to track the user info for every mention in display-name + // mode, so that it is possible to wrap those mentions in tags + // afterward. + if (displayName && mentionMode === 'display-name') { + displayNameToUserMap.current.set(displayName, user); + } + }, + [mentionMode], + ); + const onSave = async () => { // If there is any content in the tag editor input field that has // not been committed as a tag, go ahead and add it as a tag @@ -148,8 +193,14 @@ function AnnotationEditor({ isSaved(annotation) ? 'updated' : 'saved' }`; try { - await annotationsService.save(annotation); + await annotationsService.save( + annotation, + mentionMode === 'username' + ? { mentionMode } + : { mentionMode, usersMap: displayNameToUserMap.current }, + ); toastMessenger.success(successMessage, { visuallyHidden: true }); + displayNameToUserMap.current = new Map(); } catch { toastMessenger.error('Saving annotation failed'); } @@ -158,6 +209,7 @@ function AnnotationEditor({ // Revert changes to this annotation const onCancel = useCallback(() => { store.removeDraft(annotation); + displayNameToUserMap.current = new Map(); if (!isSaved(annotation)) { store.removeAnnotations([annotation]); } @@ -178,15 +230,6 @@ function AnnotationEditor({ const textStyle = applyTheme(['annotationFontFamily'], settings); - const defaultAuthority = store.defaultAuthority(); - const mentionMode = useMemo( - (): MentionMode => - isThirdPartyUser(annotation.user, defaultAuthority) - ? 'display-name' - : 'username', - [annotation.user, defaultAuthority], - ); - const mentionsEnabled = store.isFeatureEnabled('at_mentions'); const usersWhoAnnotated = store.usersWhoAnnotated(); const focusedGroupMembers = store.getFocusedGroupMembers(); @@ -219,6 +262,7 @@ function AnnotationEditor({ showHelpLink={showHelpLink} mentions={annotation.mentions} mentionMode={mentionMode} + onInsertMentionSuggestion={onInsertMentionSuggestion} /> { }); }); + function insertMentionSuggestion(wrapper, user) { + wrapper.find('MarkdownEditor').props().onInsertMentionSuggestion(user); + } + + context('when annotation author is a third party user', () => { + it('initializes display names map with annotation mentions', () => { + const mentions = [ + { + userid: 'acct:ignored@example.com', + }, + { + userid: 'acct:foo@example.com', + display_name: 'Foo', + username: 'foo', + }, + { + userid: 'acct:bar@example.com', + display_name: 'Bar', + username: 'bar', + }, + ]; + const annotation = { + ...fixtures.defaultAnnotation(), + mentions, + user: 'acct:username@example.com', // Third party user + }; + const wrapper = createComponent({ annotation }); + + wrapper.find('AnnotationPublishControl').props().onSave(); + + assert.calledWith( + fakeAnnotationsService.save, + annotation, + sinon.match({ + mentionMode: 'display-name', + usersMap: new Map([ + [ + 'Foo', + { + userid: 'acct:foo@example.com', + displayName: 'Foo', + username: 'foo', + }, + ], + [ + 'Bar', + { + userid: 'acct:bar@example.com', + displayName: 'Bar', + username: 'bar', + }, + ], + ]), + }), + ); + }); + + it('tracks user info for inserted mention suggestions', () => { + const annotation = { + ...fixtures.defaultAnnotation(), + mentions: [], + user: 'acct:username@example.com', // Third party user + }; + const wrapper = createComponent({ annotation }); + + insertMentionSuggestion(wrapper, { + userid: 'acct:jane_doe@example.com', + displayName: 'Jane Doe', + username: 'jane_doe', + }); + insertMentionSuggestion(wrapper, { + userid: 'acct:johndoe@example.com', + displayName: 'John Doe', + username: 'johndoe', + }); + + // Users without displayName are ignored + insertMentionSuggestion(wrapper, { + userid: 'acct:ignored@example.com', + username: 'ignored', + }); + + wrapper.find('AnnotationPublishControl').props().onSave(); + + assert.calledWith( + fakeAnnotationsService.save, + annotation, + sinon.match({ + mentionMode: 'display-name', + usersMap: new Map([ + [ + 'Jane Doe', + { + userid: 'acct:jane_doe@example.com', + displayName: 'Jane Doe', + username: 'jane_doe', + }, + ], + [ + 'John Doe', + { + userid: 'acct:johndoe@example.com', + displayName: 'John Doe', + username: 'johndoe', + }, + ], + ]), + }), + ); + }); + }); + + context('when annotation author is a first party user', () => { + it('does not track user info for inserted suggestions', () => { + fakeStore.defaultAuthority.returns('hypothes.is'); + + const annotation = { + ...fixtures.defaultAnnotation(), + mentions: [], + user: 'acct:username@hypothes.is', // First party user + }; + const wrapper = createComponent({ annotation }); + + insertMentionSuggestion(wrapper, { + userid: 'acct:jane_doe@example.com', + displayName: 'Jane Doe', + username: 'jane_doe', + }); + insertMentionSuggestion(wrapper, { + userid: 'acct:johndoe@example.com', + displayName: 'John Doe', + username: 'johndoe', + }); + + wrapper.find('AnnotationPublishControl').props().onSave(); + + assert.calledWith( + fakeAnnotationsService.save, + annotation, + sinon.match({ mentionMode: 'username' }), + ); + }); + }); + it( 'should pass a11y checks', checkAccessibility([ diff --git a/src/sidebar/components/MarkdownEditor.tsx b/src/sidebar/components/MarkdownEditor.tsx index dce34917ac2..efb7f15dfbf 100644 --- a/src/sidebar/components/MarkdownEditor.tsx +++ b/src/sidebar/components/MarkdownEditor.tsx @@ -207,6 +207,7 @@ type TextAreaProps = { usersForMentions: UsersForMentions; onEditText: (text: string) => void; mentionMode: MentionMode; + onInsertMentionSuggestion?: (user: UserItem) => void; }; function TextArea({ @@ -217,6 +218,7 @@ function TextArea({ onEditText, onKeyDown, mentionMode, + onInsertMentionSuggestion, ...restProps }: TextAreaProps & JSX.TextareaHTMLAttributes) { const [popoverOpen, setPopoverOpen] = useState(false); @@ -276,12 +278,15 @@ function TextArea({ // Then update state to keep it in sync. onEditText(textarea.value); + // Additionally, notify that a mention was inserted from a suggestion + onInsertMentionSuggestion?.(suggestion); + // Close popover and reset highlighted suggestion once the value is // replaced setPopoverOpen(false); setHighlightedSuggestion(0); }, - [mentionMode, onEditText, textareaRef], + [mentionMode, onEditText, onInsertMentionSuggestion, textareaRef], ); const usersListboxId = useId(); @@ -553,6 +558,9 @@ export type MarkdownEditorProps = { /** List of mentions extracted from the annotation text. */ mentions?: Mention[]; mentionMode: MentionMode; + + /** Invoked when a mention is inserted from a suggestion */ + onInsertMentionSuggestion?: (suggestion: UserItem) => void; }; /** @@ -568,6 +576,7 @@ export default function MarkdownEditor({ usersForMentions, mentions, mentionMode, + onInsertMentionSuggestion, }: MarkdownEditorProps) { // Whether the preview mode is currently active. const [preview, setPreview] = useState(false); @@ -575,7 +584,10 @@ export default function MarkdownEditor({ // The input element where the user inputs their comment. const input = useRef(null); - const textWithoutMentionTags = useMemo(() => unwrapMentions(text), [text]); + const textWithoutMentionTags = useMemo( + () => unwrapMentions(text, mentionMode), + [mentionMode, text], + ); useEffect(() => { if (!preview) { @@ -642,6 +654,7 @@ export default function MarkdownEditor({ mentionsEnabled={mentionsEnabled} usersForMentions={usersForMentions} mentionMode={mentionMode} + onInsertMentionSuggestion={onInsertMentionSuggestion} /> )} diff --git a/src/sidebar/components/test/MarkdownEditor-test.js b/src/sidebar/components/test/MarkdownEditor-test.js index 50ebf8c1dce..fa375c074a0 100644 --- a/src/sidebar/components/test/MarkdownEditor-test.js +++ b/src/sidebar/components/test/MarkdownEditor-test.js @@ -58,6 +58,7 @@ describe('MarkdownEditor', () => { text="test" mentionsEnabled={false} usersForMentions={{ status: 'loaded', users: [], ...usersForMentions }} + mentionMode="username" {...rest} />, mountProps, @@ -573,8 +574,10 @@ describe('MarkdownEditor', () => { it('applies highlighted suggestion when `Enter` is pressed', () => { const onEditText = sinon.stub(); + const onInsertMentionSuggestion = sinon.stub(); const wrapper = createComponent({ onEditText, + onInsertMentionSuggestion, mentionsEnabled: true, usersForMentions: { status: 'loaded', @@ -595,6 +598,11 @@ describe('MarkdownEditor', () => { // The textarea should include the username for second suggestion assert.calledWith(onEditText, '@two '); + // Selected mention should have been passed to onInsertMentionSuggestion + assert.calledWith( + onInsertMentionSuggestion, + sinon.match({ username: 'two', displayName: 'johndoe' }), + ); }); it('sets users to "loading" if users for mentions are being loaded', () => { diff --git a/src/sidebar/helpers/mentions.ts b/src/sidebar/helpers/mentions.ts index c067685b334..4b11496e85c 100644 --- a/src/sidebar/helpers/mentions.ts +++ b/src/sidebar/helpers/mentions.ts @@ -9,30 +9,85 @@ const BOUNDARY_CHARS = String.raw`[\s,.;:|?!'"\-()[\]{}]`; // See https://github.com/hypothesis/h/blob/797d9a4/h/models/user.py#L25 const USERNAME_PAT = '[A-Za-z0-9_][A-Za-z0-9._]+[A-Za-z0-9_]'; -// Pattern that finds user mentions in text. -const MENTIONS_PAT = new RegExp( +// Pattern that finds username-based mentions in text. +const USERNAME_MENTIONS_PAT = new RegExp( `(^|${BOUNDARY_CHARS})@(${USERNAME_PAT})(?=${BOUNDARY_CHARS}|$)`, 'g', ); +// Pattern that matches display names. +// Display names can have any amount of characters, except opening or closing +// square brackets, which are used to delimit the display name itself. +const DISPLAY_NAME_PAT = String.raw`[^\]^[]*`; + +// Pattern that finds display-name-based mentions in text. +const DISPLAY_NAME_MENTIONS_PAT = new RegExp( + `(^|${BOUNDARY_CHARS})@\\[(${DISPLAY_NAME_PAT})](?=${BOUNDARY_CHARS}|$)`, + 'g', +); + +/** + * Create a mention tag for provided userid and content. + * + * `{content}` + */ +function buildMentionTag(userid: string, content: string): string { + const tag = document.createElement('a'); + + tag.setAttribute('data-hyp-mention', ''); + tag.setAttribute('data-userid', userid); + tag.textContent = content; + + return tag.outerHTML; +} + /** - * Wrap all occurrences of @mentions in provided text into the corresponding - * special tag, as long as they are surrounded by "empty" space (space, tab, new - * line, or beginning/end of the whole text). + * Wrap all occurrences of @mention in provided text into the corresponding + * special tag, as long as they are surrounded by boundary chars. * * For example: `@someuser` with the `hypothes.is` authority would become * `@someuser` */ export function wrapMentions(text: string, authority: string): string { - return text.replace(MENTIONS_PAT, (match, precedingChar, username) => { - const tag = document.createElement('a'); + return text.replace( + USERNAME_MENTIONS_PAT, + (match, precedingChar, username) => { + const mentionTag = buildMentionTag( + buildAccountID(username, authority), + `@${username}`, + ); + return `${precedingChar}${mentionTag}`; + }, + ); +} - tag.setAttribute('data-hyp-mention', ''); - tag.setAttribute('data-userid', buildAccountID(username, authority)); - tag.textContent = `@${username}`; +/** + * Wrap all occurrences of @[Display Name] in provided text into the + * corresponding mention tag, as long as they are surrounded by boundary chars. + * + * Every matched plain-text mention will need a corresponding entry in the + * users map to produce a valid mention tag. + * Non-matching ones will be kept as plain-text. + */ +export function wrapDisplayNameMentions( + text: string, + usersMap: Map, +): string { + return text.replace( + DISPLAY_NAME_MENTIONS_PAT, + (match, precedingChar, displayName) => { + const suggestion = usersMap.get(displayName); - return `${precedingChar}${tag.outerHTML}`; - }); + // TODO Should we still build a mention tag so that it renders as an + // invalid mention instead of plain text? + if (!suggestion) { + return `${precedingChar}@[${displayName}]`; + } + + const mentionTag = buildMentionTag(suggestion.userid, `@${displayName}`); + return `${precedingChar}${mentionTag}`; + }, + ); } // Pattern that matches the special tag used to wrap mentions. @@ -46,13 +101,23 @@ const MENTION_TAG_RE = /]\bdata-hyp-mention\b[^>]*>([^<]+)<\/a>/g; /** * Replace all mentions wrapped in the special `` tag with * their plain-text representation. + * The plain-text representation depends on the mention mode: + * - `username`: @username + * - `display-name`: @[Display Name] */ -export function unwrapMentions(text: string) { +export function unwrapMentions(text: string, mentionMode: MentionMode) { // Use a regex rather than HTML parser to replace the mentions in order // to avoid modifying any of the content outside of the replaced tags. This // includes avoiding modifications such as encoding characters that will // happen when parsing and re-serializing HTML via eg. `innerHTML`. - return text.replace(MENTION_TAG_RE, (match, mention) => mention); + return text.replace(MENTION_TAG_RE, (match, mention) => { + if (mentionMode === 'username') { + return mention; + } + + const [atChar, ...rest] = mention; + return `${atChar}[${rest.join('')}]`; + }); } /** diff --git a/src/sidebar/helpers/test/mentions-test.js b/src/sidebar/helpers/test/mentions-test.js index bdb8712d6c7..591e465f5ef 100644 --- a/src/sidebar/helpers/test/mentions-test.js +++ b/src/sidebar/helpers/test/mentions-test.js @@ -5,15 +5,22 @@ import { getContainingMentionOffsets, termBeforePosition, toPlainTextMention, + wrapDisplayNameMentions, } from '../mentions'; /** * @param {string} username - * @param {string} [authority] + * @param {string} [authority] - Defaults to 'hypothes.is' + * @param {string} [content] - Defaults to `@${username}` * @param {'link'|'no-link'|'invalid'} [type] * @returns {HTMLAnchorElement} */ -function mentionElement({ username, authority = 'hypothes.is', type }) { +function mentionElement({ + username, + authority = 'hypothes.is', + content = `@${username}`, + type, +}) { const element = document.createElement('a'); element.setAttribute('data-hyp-mention', ''); @@ -23,7 +30,7 @@ function mentionElement({ username, authority = 'hypothes.is', type }) { element.setAttribute('data-hyp-mention-type', type); } - element.textContent = `@${username}`; + element.textContent = content; return element; } @@ -31,6 +38,14 @@ function mentionElement({ username, authority = 'hypothes.is', type }) { const mentionTag = (username, authority) => mentionElement({ username, authority }).outerHTML; +/** + * @param {string} displayName + * @param {string} username + * @param {string} [authority] + */ +const displayNameMentionTag = (displayName, username, authority) => + mentionElement({ content: `@${displayName}`, username, authority }).outerHTML; + [ // Mention at the end { @@ -72,7 +87,7 @@ look at ${mentionTag('foo', 'example.com')} comment`, authority: 'example.com', textWithTags: `Hey ${mentionTag('jane', 'example.com')}, look at this quote from ${mentionTag('rob', 'example.com')}`, }, - // Mentions wrapped in punctuation chars + // Mentions wrapped in boundary chars { text: '(@jane) {@rob} and @john?', authority: 'example.com', @@ -117,9 +132,86 @@ Hello ${mentionTag('jane.doe', 'example.com')}.`, }); }); - describe('unwrapMentions', () => { + describe('unwrapMentions - `username` mode', () => { + it('removes wrapping mention tags', () => { + assert.equal(unwrapMentions(textWithTags, 'username'), text); + }); + }); +}); + +[ + // Mention at the end + { + text: 'Hello @[John Doe]', + usersMap: new Map([['John Doe', { userid: 'acct:john_doe@hypothes.is' }]]), + textWithTags: `Hello ${displayNameMentionTag('John Doe', 'john_doe')}`, + }, + // Mention at the beginning + { + text: '@[Jane Doe] look at this', + usersMap: new Map([['Jane Doe', { userid: 'acct:jane_doe@hypothes.is' }]]), + textWithTags: `${displayNameMentionTag('Jane Doe', 'jane_doe')} look at this`, + }, + // Mention not found in users map + { + text: '@[Jane Doe] look at this', + usersMap: new Map(), + textWithTags: `@[Jane Doe] look at this`, + }, + // Mention in the middle + { + text: 'foo @[Jane Doe] bar', + usersMap: new Map([['Jane Doe', { userid: 'acct:jane_doe@hypothes.is' }]]), + textWithTags: `foo ${displayNameMentionTag('Jane Doe', 'jane_doe')} bar`, + }, + // Multi-line mentions + { + text: `@[Albert Banana] hello + @[Someone Else] how are you + look at @[Foo] comment`, + usersMap: new Map([ + ['Albert Banana', { userid: 'acct:username@example.com' }], + ['Someone Else', { userid: 'acct:another@example.com' }], + ['Foo', { userid: 'acct:foo@example.com' }], + ]), + textWithTags: `${displayNameMentionTag('Albert Banana', 'username', 'example.com')} hello + ${displayNameMentionTag('Someone Else', 'another', 'example.com')} how are you + look at ${displayNameMentionTag('Foo', 'foo', 'example.com')} comment`, + }, + // No mentions + { + text: 'Just some text', + usersMap: new Map(), + textWithTags: 'Just some text', + }, + // Mentions wrapped in boundary chars + { + text: '(@[Albert Banana]), {@[Jane Doe]} and [@[Someone Else]]', + usersMap: new Map([ + ['Albert Banana', { userid: 'acct:username@hypothes.is' }], + ['Jane Doe', { userid: 'acct:jane_doe@hypothes.is' }], + ['Someone Else', { userid: 'acct:another@hypothes.is' }], + ]), + textWithTags: `(${displayNameMentionTag('Albert Banana', 'username')}), {${displayNameMentionTag('Jane Doe', 'jane_doe')}} and [${displayNameMentionTag('Someone Else', 'another')}]`, + }, + // Mentions containing boundary chars + { + text: 'Hello @[Dwayne "The Rock" Johnson]', + usersMap: new Map([ + ['Dwayne "The Rock" Johnson', { userid: 'acct:djohnson@hypothes.is' }], + ]), + textWithTags: `Hello ${displayNameMentionTag('Dwayne "The Rock" Johnson', 'djohnson')}`, + }, +].forEach(({ text, usersMap, textWithTags }) => { + describe('wrapDisplayNameMentions', () => { + it('wraps every display-name mention in a mention tag', () => { + assert.equal(wrapDisplayNameMentions(text, usersMap), textWithTags); + }); + }); + + describe('unwrapMentions - `display-name` mode', () => { it('removes wrapping mention tags', () => { - assert.equal(unwrapMentions(textWithTags), text); + assert.equal(unwrapMentions(textWithTags, 'display-name'), text); }); }); }); @@ -300,7 +392,7 @@ describe('processAndReplaceMentionElements', () => { expectedOffsets: { start: 32, end: 37 }, }, - // Including punctuation characters + // Including boundary characters ...[ ',', '.', diff --git a/src/sidebar/services/annotations.ts b/src/sidebar/services/annotations.ts index d1f56b419db..720304fe974 100644 --- a/src/sidebar/services/annotations.ts +++ b/src/sidebar/services/annotations.ts @@ -8,7 +8,8 @@ import type { import type { AnnotationEventType, SidebarSettings } from '../../types/config'; import { parseAccountID } from '../helpers/account-id'; import * as metadata from '../helpers/annotation-metadata'; -import { wrapMentions } from '../helpers/mentions'; +import type { UserItem } from '../helpers/mention-suggestions'; +import { wrapDisplayNameMentions, wrapMentions } from '../helpers/mentions'; import { defaultPermissions, privatePermissions, @@ -18,6 +19,19 @@ import type { SidebarStore } from '../store'; import type { AnnotationActivityService } from './annotation-activity'; import type { APIService } from './api'; +export type MentionsOptions = + | { + mentionMode: 'username'; + } + | { + mentionMode: 'display-name'; + /** + * A display-name/user-info map so that mention tags can be generated from + * display-name mentions + */ + usersMap: Map; + }; + /** * A service for creating, updating and persisting annotations both in the * local store and on the backend via the API. @@ -45,7 +59,10 @@ export class AnnotationsService { * Apply changes for the given `annotation` from its draft in the store (if * any) and return a new object with those changes integrated. */ - private _applyDraftChanges(annotation: Annotation): Annotation { + private _applyDraftChanges( + annotation: Annotation, + mentionsOptions: MentionsOptions, + ): Annotation { const changes: Partial = {}; const draft = this._store.getDraft(annotation); const authority = @@ -54,10 +71,15 @@ export class AnnotationsService { const mentionsEnabled = this._store.isFeatureEnabled('at_mentions'); if (draft) { + const textWrapper = !mentionsEnabled + ? (text: string) => text + : mentionsOptions.mentionMode === 'display-name' + ? (text: string) => + wrapDisplayNameMentions(text, mentionsOptions.usersMap) + : (text: string) => wrapMentions(text, authority); + changes.tags = draft.tags; - changes.text = mentionsEnabled - ? wrapMentions(draft.text, authority) - : draft.text; + changes.text = textWrapper(draft.text); changes.permissions = draft.isPrivate ? privatePermissions(annotation.user) : sharedPermissions(annotation.user, annotation.group); @@ -232,11 +254,17 @@ export class AnnotationsService { * the annotation's `Draft` will be removed and the annotation added * to the store. */ - async save(annotation: Annotation) { + async save( + annotation: Annotation, + mentionsOptions: MentionsOptions = { mentionMode: 'username' }, + ) { let saved: Promise; let eventType: AnnotationEventType; - const annotationWithChanges = this._applyDraftChanges(annotation); + const annotationWithChanges = this._applyDraftChanges( + annotation, + mentionsOptions, + ); if (!metadata.isSaved(annotation)) { saved = this._api.annotation.create({}, annotationWithChanges); diff --git a/src/sidebar/services/test/annotations-test.js b/src/sidebar/services/test/annotations-test.js index dad4381b4c9..f39a5603624 100644 --- a/src/sidebar/services/test/annotations-test.js +++ b/src/sidebar/services/test/annotations-test.js @@ -514,35 +514,70 @@ describe('AnnotationsService', () => { { profile: { userid: 'acct:foo@bar.com' }, mentionsEnabled: false, + text: 'hello @bob', expectedText: 'hello @bob', + mentionMode: 'username', + }, + { + profile: { userid: 'acct:foo@bar.com' }, + mentionsEnabled: false, + text: 'hello @[John Doe]', + expectedText: 'hello @[John Doe]', + mentionMode: 'display-name', }, { profile: { userid: 'acct:foo@bar.com' }, mentionsEnabled: true, + text: 'hello @bob', expectedText: 'hello @bob', + mentionMode: 'username', + }, + { + profile: { userid: 'acct:foo@bar.com' }, + mentionsEnabled: true, + text: 'hello @[John Doe]', + expectedText: + 'hello @John Doe', + mentionMode: 'display-name', }, { profile: { userid: 'acct:foo' }, mentionsEnabled: true, + text: 'hello @bob', expectedText: 'hello @bob', + mentionMode: 'username', }, - ].forEach(({ profile, mentionsEnabled, expectedText }) => { - it('wraps mentions in tags when feature is enabled', async () => { - fakeStore.isFeatureEnabled.returns(mentionsEnabled); - fakeStore.profile.returns(profile); - fakeStore.getDraft.returns({ text: 'hello @bob' }); - - await svc.save(fixtures.defaultAnnotation()); - - assert.calledWith( - fakeApi.annotation.create, - {}, - sinon.match({ text: expectedText }), - ); - }); - }); + { + profile: { userid: 'acct:foo@bar.com' }, + mentionsEnabled: true, + text: 'hello @[Unknown Display Name]', + expectedText: 'hello @[Unknown Display Name]', + mentionMode: 'display-name', + }, + ].forEach( + ({ profile, mentionsEnabled, text, expectedText, mentionMode }) => { + it('wraps mentions in tags when feature is enabled', async () => { + fakeStore.isFeatureEnabled.returns(mentionsEnabled); + fakeStore.profile.returns(profile); + fakeStore.getDraft.returns({ text }); + + await svc.save(fixtures.defaultAnnotation(), { + mentionMode, + usersMap: new Map([ + ['John Doe', { userid: 'acct:john_doe@hypothes.is' }], + ]), + }); + + assert.calledWith( + fakeApi.annotation.create, + {}, + sinon.match({ text: expectedText }), + ); + }); + }, + ); context('successful save', () => { it('copies over internal app-specific keys to the annotation object', () => {