Skip to content

Commit 2c21f0f

Browse files
committed
Use most recent username or display name when rendering mentions
1 parent 283e81a commit 2c21f0f

File tree

7 files changed

+111
-29
lines changed

7 files changed

+111
-29
lines changed

src/sidebar/components/Annotation/AnnotationBody.tsx

+10-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Annotation } from '../../../types/api';
66
import type { SidebarSettings } from '../../../types/config';
77
import { isThirdPartyUser } from '../../helpers/account-id';
88
import { isHidden } from '../../helpers/annotation-metadata';
9+
import type { MentionMode } from '../../helpers/mentions';
910
import { applyTheme } from '../../helpers/theme';
1011
import { withServices } from '../../service-context';
1112
import { useSidebarStore } from '../../store';
@@ -80,10 +81,13 @@ function AnnotationBody({ annotation, settings }: AnnotationBodyProps) {
8081

8182
const textStyle = applyTheme(['annotationFontFamily'], settings);
8283

83-
const shouldLinkTags = useMemo(
84-
() => annotation && !isThirdPartyUser(annotation?.user, defaultAuthority),
84+
const authorIsThirdParty = useMemo(
85+
() => isThirdPartyUser(annotation.user, defaultAuthority),
8586
[annotation, defaultAuthority],
8687
);
88+
const mentionMode: MentionMode = authorIsThirdParty
89+
? 'display-name'
90+
: 'username';
8791

8892
const createTagSearchURL = (tag: string) => {
8993
return store.getLink('search.tag', { tag });
@@ -108,6 +112,7 @@ function AnnotationBody({ annotation, settings }: AnnotationBodyProps) {
108112
style={textStyle}
109113
mentions={annotation.mentions}
110114
mentionsEnabled={mentionsEnabled}
115+
mentionMode={mentionMode}
111116
/>
112117
</Excerpt>
113118
)}
@@ -122,7 +127,9 @@ function AnnotationBody({ annotation, settings }: AnnotationBodyProps) {
122127
key={tag}
123128
tag={tag}
124129
href={
125-
shouldLinkTags ? createTagSearchURL(tag) : undefined
130+
!authorIsThirdParty
131+
? createTagSearchURL(tag)
132+
: undefined
126133
}
127134
/>
128135
);

src/sidebar/components/MarkdownEditor.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@ export default function MarkdownEditor({
634634
style={textStyle}
635635
mentions={mentions}
636636
mentionsEnabled={mentionsEnabled}
637+
mentionMode={mentionMode}
637638
/>
638639
) : (
639640
<TextArea

src/sidebar/components/MarkdownView.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from 'preact/hooks';
1111

1212
import type { Mention } from '../../types/api';
13-
import type { InvalidUsername } from '../helpers/mentions';
13+
import type { InvalidMentionContent, MentionMode } from '../helpers/mentions';
1414
import { processAndReplaceMentionElements } from '../helpers/mentions';
1515
import { replaceLinksWithEmbeds } from '../media-embedder';
1616
import { renderMathAndMarkdown } from '../render-markdown';
@@ -55,6 +55,7 @@ export type MarkdownViewProps = {
5555
classes?: string;
5656
style?: Record<string, string>;
5757
mentions?: Mention[];
58+
mentionMode: MentionMode;
5859

5960
/**
6061
* Whether the at-mentions feature ir enabled or not.
@@ -67,7 +68,7 @@ export type MarkdownViewProps = {
6768
clearTimeout_?: typeof clearTimeout;
6869
};
6970

70-
type PopoverContent = Mention | InvalidUsername | null;
71+
type PopoverContent = Mention | InvalidMentionContent | null;
7172

7273
/**
7374
* A component which renders markdown as HTML, replaces recognized links with
@@ -81,6 +82,7 @@ export default function MarkdownView(props: MarkdownViewProps) {
8182
style,
8283
mentions = [],
8384
mentionsEnabled = false,
85+
mentionMode,
8486
setTimeout_ = setTimeout,
8587
clearTimeout_ = clearTimeout,
8688
} = props;
@@ -94,7 +96,7 @@ export default function MarkdownView(props: MarkdownViewProps) {
9496
const mentionsPopoverRef = useRef<HTMLElement>(null);
9597

9698
const elementToMentionMap = useRef(
97-
new Map<HTMLElement, Mention | InvalidUsername>(),
99+
new Map<HTMLElement, Mention | InvalidMentionContent>(),
98100
);
99101
const [popoverContent, setPopoverContent] = useState<PopoverContent>(null);
100102
const popoverContentTimeout = useRef<ReturnType<typeof setTimeout> | null>();
@@ -145,8 +147,9 @@ export default function MarkdownView(props: MarkdownViewProps) {
145147
elementToMentionMap.current = processAndReplaceMentionElements(
146148
content.current!,
147149
mentions,
150+
mentionMode,
148151
);
149-
}, [mentions]);
152+
}, [mentionMode, mentions]);
150153

151154
// Monitor mouse position when mentions popover is visible and hide when it
152155
// goes outside the anchor or popover.

src/sidebar/components/mentions/MentionPopoverContent.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { formatDateTime } from '@hypothesis/frontend-shared';
22

33
import type { Mention } from '../../../types/api';
4-
import type { InvalidUsername } from '../../helpers/mentions';
4+
import type { InvalidMentionContent } from '../../helpers/mentions';
55

66
export type MentionPopoverContent = {
7-
content: Mention | InvalidUsername;
7+
content: Mention | InvalidMentionContent;
88
};
99

1010
/**

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

+7-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('MarkdownView', () => {
1818
return mount(
1919
<MarkdownView
2020
markdown=""
21+
mentionMode="username"
2122
{...props}
2223
setTimeout_={callback => setTimeout(callback, 0)}
2324
clearTimeout_={fakeClearTimeout}
@@ -98,13 +99,17 @@ describe('MarkdownView', () => {
9899
});
99100
});
100101

101-
[undefined, [{}]].forEach(mentions => {
102+
[
103+
{ mentions: undefined, mentionMode: 'username' },
104+
{ mentions: [{}], mentionMode: 'display-name' },
105+
].forEach(({ mentions, mentionMode }) => {
102106
it('renders mention tags based on provided mentions', () => {
103-
createComponent({ mentions });
107+
createComponent({ mentions, mentionMode });
104108
assert.calledWith(
105109
fakeProcessAndReplaceMentionElements,
106110
sinon.match.any,
107111
mentions ?? [],
112+
mentionMode,
108113
);
109114
});
110115
});

src/sidebar/helpers/mentions.ts

+30-16
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,6 @@ export function wrapDisplayNameMentions(
7777
DISPLAY_NAME_MENTIONS_PAT,
7878
(match, precedingChar, displayName) => {
7979
const suggestion = usersMap.get(displayName);
80-
81-
// TODO Should we still build a mention tag so that it renders as an
82-
// invalid mention instead of plain text?
8380
if (!suggestion) {
8481
return `${precedingChar}${displayNameMention(displayName)}`;
8582
}
@@ -150,26 +147,27 @@ export function unwrapMentions({
150147
}
151148

152149
/**
153-
* A username for a mention that the backend "discarded" as invalid. Possible
154-
* reasons are: the user does not exist, belongs to a different group, is
155-
* nipsa'd, etc.
150+
* The content of a mention tag that the backend "discarded" as invalid.
151+
* Possible reasons are: the user does not exist, belongs to a different group,
152+
* is nipsa'd, etc.
156153
*/
157-
export type InvalidUsername = string;
154+
export type InvalidMentionContent = string;
158155

159156
/**
160157
* Replace an unprocessed mention tag with another element that represents the
161158
* proper type of mention ('link', 'no-link' or 'invalid').
162159
*/
163160
function processAndReplaceMention(
164161
unprocessedMention: HTMLElement,
165-
mention?: Mention,
166-
): [HTMLElement, Mention | InvalidUsername] {
167-
const username = unprocessedMention.textContent ?? '';
168-
const mentionOrUsername = mention ?? username;
162+
mention: Mention | undefined,
163+
mentionMode: MentionMode,
164+
): [HTMLElement, Mention | InvalidMentionContent] {
165+
const originalTagContent = unprocessedMention.textContent ?? '';
166+
const mentionOrInvalidContent = mention ?? originalTagContent;
169167

170168
// If this mention element has already been processed, return as is
171169
if (unprocessedMention.hasAttribute('data-hyp-mention-type')) {
172-
return [unprocessedMention, mentionOrUsername];
170+
return [unprocessedMention, mentionOrInvalidContent];
173171
}
174172

175173
const type =
@@ -180,7 +178,20 @@ function processAndReplaceMention(
180178

181179
processedMention.setAttribute('data-hyp-mention', '');
182180
processedMention.setAttribute('data-hyp-mention-type', type);
183-
processedMention.textContent = username;
181+
182+
// For valid mentions, resolve the most recent username or display name, in
183+
// case it has changed since the mention was created.
184+
// The only exception is when a valid mention with an empty display name is
185+
// resolved, in which case we fall back to the original tag content.
186+
if (!mention) {
187+
processedMention.textContent = originalTagContent;
188+
} else if (mentionMode === 'username') {
189+
processedMention.textContent = `@${mention.username}`;
190+
} else {
191+
processedMention.textContent = mention.display_name
192+
? `@${mention.display_name}`
193+
: originalTagContent;
194+
}
184195

185196
if (type === 'link') {
186197
// If the mention exists in the list of mentions and contains a link, render
@@ -197,7 +208,7 @@ function processAndReplaceMention(
197208
}
198209

199210
unprocessedMention.replaceWith(processedMention);
200-
return [processedMention, mentionOrUsername];
211+
return [processedMention, mentionOrInvalidContent];
201212
}
202213

203214
/**
@@ -212,7 +223,8 @@ function processAndReplaceMention(
212223
export function processAndReplaceMentionElements(
213224
element: HTMLElement,
214225
mentions: Mention[],
215-
): Map<HTMLElement, Mention | InvalidUsername> {
226+
mentionMode: MentionMode,
227+
): Map<HTMLElement, Mention | InvalidMentionContent> {
216228
const mentionElements = element.querySelectorAll('[data-hyp-mention]');
217229
const foundMentions = new Map<HTMLElement, Mention | string>();
218230

@@ -223,7 +235,9 @@ export function processAndReplaceMentionElements(
223235
? mentions.find(m => m.userid === mentionUserId)
224236
: undefined;
225237

226-
foundMentions.set(...processAndReplaceMention(htmlMentionElement, mention));
238+
foundMentions.set(
239+
...processAndReplaceMention(htmlMentionElement, mention, mentionMode),
240+
);
227241
}
228242

229243
return foundMentions;

src/sidebar/helpers/test/mentions-test.js

+54-2
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ describe('processAndReplaceMentionElements', () => {
321321
<p>Mention without ID: <a data-hyp-mention="">@user_id_missing</a></p>
322322
`;
323323

324-
const result = processAndReplaceMentionElements(container, mentions);
324+
const result = processAndReplaceMentionElements(
325+
container,
326+
mentions,
327+
'username',
328+
);
325329
assert.equal(result.size, 4);
326330

327331
const [
@@ -391,7 +395,11 @@ describe('processAndReplaceMentionElements', () => {
391395
invalidProcessedMention,
392396
);
393397

394-
const result = processAndReplaceMentionElements(container, mentions);
398+
const result = processAndReplaceMentionElements(
399+
container,
400+
mentions,
401+
'username',
402+
);
395403
assert.equal(result.size, 3);
396404

397405
const [
@@ -409,6 +417,50 @@ describe('processAndReplaceMentionElements', () => {
409417
assert.equal(thirdElement, invalidProcessedMention);
410418
assert.equal(thirdMention, '@invalid');
411419
});
420+
421+
[
422+
{
423+
mentionMode: 'username',
424+
oldContent: '@janedoe',
425+
mention: { username: 'janedoe_updated' },
426+
expectedContent: '@janedoe_updated',
427+
},
428+
{
429+
mentionMode: 'display-name',
430+
oldContent: '@Jane Doe',
431+
mention: { display_name: 'Jane Doe Updated' },
432+
expectedContent: '@Jane Doe Updated',
433+
},
434+
{
435+
mentionMode: 'display-name',
436+
oldContent: '@Jane Doe',
437+
mention: { display_name: '' },
438+
expectedContent: '@Jane Doe',
439+
},
440+
].forEach(({ mentionMode, oldContent, mention, expectedContent }) => {
441+
it('returns most recent usernames or display names', () => {
442+
const mentions = [
443+
{
444+
userid: 'acct:[email protected]',
445+
...mention,
446+
},
447+
];
448+
const container = document.createElement('div');
449+
container.innerHTML = mentionElement({
450+
username: 'janedoe',
451+
content: oldContent,
452+
}).outerHTML;
453+
454+
const result = processAndReplaceMentionElements(
455+
container,
456+
mentions,
457+
mentionMode,
458+
);
459+
const [[mentionEl]] = [...result.entries()];
460+
461+
assert.equal(mentionEl.textContent, expectedContent);
462+
});
463+
});
412464
});
413465

414466
// To make these tests more predictable, we place the `$` sign in the position

0 commit comments

Comments
 (0)