Skip to content

Wrap display-name-based mentions in mention tags #6892

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions src/sidebar/components/Annotation/AnnotationEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<Map<string, UserItem>>(
new Map(
mentionMode === 'username'
? []
: // If the annotation is being edited, it may have mentions. Use them to
// initialize the display names map
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen when editing an existing annotation with a mention, where the user has changed their display name since the mention was originally created? To handle that I think we'd need to use the current display name when unwrapping the mention, rather than whatever the mention text was originally. I think that can be left out of this PR, but should be tracked somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good point. In fact, I think this also applies to username-based mentions. If their username has changed, we should unwrap as the most recent username, otherwise when wrapping back it will generate an invalid userid.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created this issue to track it #6906

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
Expand All @@ -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');
}
Expand All @@ -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]);
}
Expand All @@ -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();
Expand Down Expand Up @@ -219,6 +262,7 @@ function AnnotationEditor({
showHelpLink={showHelpLink}
mentions={annotation.mentions}
mentionMode={mentionMode}
onInsertMentionSuggestion={onInsertMentionSuggestion}
/>
<TagEditor
onAddTag={onAddTag}
Expand Down
144 changes: 144 additions & 0 deletions src/sidebar/components/Annotation/test/AnnotationEditor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,150 @@ describe('AnnotationEditor', () => {
});
});

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:[email protected]',
},
{
userid: 'acct:[email protected]',
display_name: 'Foo',
username: 'foo',
},
{
userid: 'acct:[email protected]',
display_name: 'Bar',
username: 'bar',
},
];
const annotation = {
...fixtures.defaultAnnotation(),
mentions,
user: 'acct:[email protected]', // 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:[email protected]',
displayName: 'Foo',
username: 'foo',
},
],
[
'Bar',
{
userid: 'acct:[email protected]',
displayName: 'Bar',
username: 'bar',
},
],
]),
}),
);
});

it('tracks user info for inserted mention suggestions', () => {
const annotation = {
...fixtures.defaultAnnotation(),
mentions: [],
user: 'acct:[email protected]', // Third party user
};
const wrapper = createComponent({ annotation });

insertMentionSuggestion(wrapper, {
userid: 'acct:[email protected]',
displayName: 'Jane Doe',
username: 'jane_doe',
});
insertMentionSuggestion(wrapper, {
userid: 'acct:[email protected]',
displayName: 'John Doe',
username: 'johndoe',
});

// Users without displayName are ignored
insertMentionSuggestion(wrapper, {
userid: 'acct:[email protected]',
username: 'ignored',
});

wrapper.find('AnnotationPublishControl').props().onSave();

assert.calledWith(
fakeAnnotationsService.save,
annotation,
sinon.match({
mentionMode: 'display-name',
usersMap: new Map([
[
'Jane Doe',
{
userid: 'acct:[email protected]',
displayName: 'Jane Doe',
username: 'jane_doe',
},
],
[
'John Doe',
{
userid: 'acct:[email protected]',
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:[email protected]', // First party user
};
const wrapper = createComponent({ annotation });

insertMentionSuggestion(wrapper, {
userid: 'acct:[email protected]',
displayName: 'Jane Doe',
username: 'jane_doe',
});
insertMentionSuggestion(wrapper, {
userid: 'acct:[email protected]',
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([
Expand Down
17 changes: 15 additions & 2 deletions src/sidebar/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ type TextAreaProps = {
usersForMentions: UsersForMentions;
onEditText: (text: string) => void;
mentionMode: MentionMode;
onInsertMentionSuggestion?: (user: UserItem) => void;
};

function TextArea({
Expand All @@ -217,6 +218,7 @@ function TextArea({
onEditText,
onKeyDown,
mentionMode,
onInsertMentionSuggestion,
...restProps
}: TextAreaProps & JSX.TextareaHTMLAttributes) {
const [popoverOpen, setPopoverOpen] = useState(false);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
};

/**
Expand All @@ -568,14 +576,18 @@ export default function MarkdownEditor({
usersForMentions,
mentions,
mentionMode,
onInsertMentionSuggestion,
}: MarkdownEditorProps) {
// Whether the preview mode is currently active.
const [preview, setPreview] = useState(false);

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

const textWithoutMentionTags = useMemo(() => unwrapMentions(text), [text]);
const textWithoutMentionTags = useMemo(
() => unwrapMentions(text, mentionMode),
[mentionMode, text],
);

useEffect(() => {
if (!preview) {
Expand Down Expand Up @@ -642,6 +654,7 @@ export default function MarkdownEditor({
mentionsEnabled={mentionsEnabled}
usersForMentions={usersForMentions}
mentionMode={mentionMode}
onInsertMentionSuggestion={onInsertMentionSuggestion}
/>
)}
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/sidebar/components/test/MarkdownEditor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('MarkdownEditor', () => {
text="test"
mentionsEnabled={false}
usersForMentions={{ status: 'loaded', users: [], ...usersForMentions }}
mentionMode="username"
{...rest}
/>,
mountProps,
Expand Down Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
Loading