Skip to content

Commit

Permalink
Populate list of suggestions matching @mentions
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Jan 13, 2025
1 parent b70984b commit 107c276
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 32 deletions.
6 changes: 5 additions & 1 deletion src/sidebar/components/Annotation/AnnotationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ function AnnotationEditor({

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

const atMentionsEnabled = store.isFeatureEnabled('at_mentions');
const usersWhoAnnotated = store.usersWhoAnnotated();

return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<div
Expand All @@ -179,7 +182,8 @@ function AnnotationEditor({
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
text={text}
onEditText={onEditText}
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
atMentionsEnabled={atMentionsEnabled}
usersWhoAnnotated={usersWhoAnnotated}
/>
<TagEditor
onAddTag={onAddTag}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('AnnotationEditor', () => {
removeDraft: sinon.stub(),
removeAnnotations: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
usersWhoAnnotated: sinon.stub().returns([]),
};

$imports.$mock(mockImportedComponents());
Expand Down
186 changes: 155 additions & 31 deletions src/sidebar/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import {
import type { IconComponent } from '@hypothesis/frontend-shared/lib/types';
import classnames from 'classnames';
import type { Ref, JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';

import { ListenerCollection } from '../../shared/listener-collection';
import { isMacOS } from '../../shared/user-agent';
import {
LinkType,
Expand Down Expand Up @@ -181,49 +186,83 @@ function ToolbarButton({
);
}

export type UserItem = {
username: string;
displayName: string | null;
};

type TextAreaProps = {
classes?: string;
containerRef?: Ref<HTMLTextAreaElement>;
atMentionsEnabled: boolean;
usersWhoAnnotated: UserItem[];
onEditText: (text: string) => void;
};

function TextArea({
classes,
containerRef,
atMentionsEnabled,
usersWhoAnnotated,
onEditText,
onKeyDown,
...restProps
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
}: TextAreaProps & JSX.TextareaHTMLAttributes) {
const [popoverOpen, setPopoverOpen] = useState(false);
const [activeMention, setActiveMention] = useState<string>();
const textareaRef = useSyncedRef(containerRef);

useEffect(() => {
if (!atMentionsEnabled) {
return () => {};
const [highlightedSuggestion, setHighlightedSuggestion] = useState(0);
const suggestions = useMemo(() => {
if (!atMentionsEnabled || activeMention === undefined) {
return [];
}

const textarea = textareaRef.current!;
const listenerCollection = new ListenerCollection();
const checkForMentionAtCaret = () => {
return usersWhoAnnotated
.filter(
u =>
// Match all users if the active mention is empty, which happens right
// after typing `@`
!activeMention ||
`${u.username} ${u.displayName ?? ''}`

Check warning on line 226 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L225-L226

Added lines #L225 - L226 were not covered by tests
.toLowerCase()
.match(activeMention.toLowerCase()),
)
.slice(0, 10);
}, [activeMention, atMentionsEnabled, usersWhoAnnotated]);

const checkForMentionAtCaret = useCallback(
(textarea: HTMLTextAreaElement) => {
if (!atMentionsEnabled) {
return;

Check warning on line 236 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L236

Added line #L236 was not covered by tests
}

const term = termBeforePosition(textarea.value, textarea.selectionStart);
setPopoverOpen(term.startsWith('@'));
};

// We listen for `keyup` to make sure the text in the textarea reflects the
// just-pressed key when we evaluate it
listenerCollection.add(textarea, 'keyup', e => {
// `Esc` key is used to close the popover. Do nothing and let users close
// it that way, even if the caret is in a mention
if (e.key !== 'Escape') {
checkForMentionAtCaret();
const isAtMention = term.startsWith('@');

setPopoverOpen(isAtMention);
setActiveMention(isAtMention ? term.substring(1) : undefined);

// Reset highlighted suggestion when closing the popover
if (!isAtMention) {
setHighlightedSuggestion(0);
}
});
},
[atMentionsEnabled],
);
const applySuggestion = useCallback(
(suggestion: UserItem) => {
const textarea = textareaRef.current!;
const term = termBeforePosition(textarea.value, textarea.selectionStart);

Check warning on line 255 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L253-L255

Added lines #L253 - L255 were not covered by tests

// When clicking the textarea it's possible the caret is moved "into" a
// mention, so we check if the popover should be opened
listenerCollection.add(textarea, 'click', checkForMentionAtCaret);
onEditText(textarea.value.replace(term, `@${suggestion.username}`));

Check warning on line 257 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L257

Added line #L257 was not covered by tests

return () => listenerCollection.removeAll();
}, [atMentionsEnabled, popoverOpen, textareaRef]);
// Close popover and reset highlighted suggestion once the value is
// replaced
setPopoverOpen(false);
setHighlightedSuggestion(0);

Check warning on line 262 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L261-L262

Added lines #L261 - L262 were not covered by tests
},
[onEditText, textareaRef],
);

return (
<div className="relative">
Expand All @@ -234,17 +273,95 @@ function TextArea({
'focus:bg-white focus:outline-none focus:shadow-focus-inner',
classes,
)}
onInput={(e: Event) => onEditText((e.target as HTMLInputElement).value)}
{...restProps}
// We listen for `keyup` to make sure the text in the textarea reflects the
// just-pressed key when we evaluate it
onKeyUp={e => {
// `Esc` key is used to close the popover. Do nothing and let users close
// it that way, even if the caret is in a mention
// `Enter` is handled on keydown. Do not handle it here.
if (!['Escape', 'Enter'].includes(e.key)) {
checkForMentionAtCaret(e.target as HTMLTextAreaElement);
}
}}
onKeyDown={e => {
// Invoke original handler ir present
onKeyDown?.(e);

if (
!popoverOpen ||
suggestions.length === 0 ||
!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)

Check warning on line 295 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L294-L295

Added lines #L294 - L295 were not covered by tests
) {
return;
}

// When vertical arrows or Enter are pressed while the popover is open
// with suggestions, highlight or pick the right suggestion.
e.preventDefault();
e.stopPropagation();

Check warning on line 303 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L302-L303

Added lines #L302 - L303 were not covered by tests

if (e.key === 'ArrowDown') {
setHighlightedSuggestion(prev =>
Math.min(prev + 1, suggestions.length - 1),

Check warning on line 307 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L305-L307

Added lines #L305 - L307 were not covered by tests
);
} else if (e.key === 'ArrowUp') {
setHighlightedSuggestion(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
applySuggestion(suggestions[highlightedSuggestion]);

Check warning on line 313 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L309-L313

Added lines #L309 - L313 were not covered by tests
}
}}
// When clicking the textarea, it's possible the caret is moved "into" a
// mention, so we check if the popover should be opened
onClick={e => checkForMentionAtCaret(e.target as HTMLTextAreaElement)}
ref={textareaRef}
/>
{atMentionsEnabled && (
<Popover
open={popoverOpen}
onClose={() => setPopoverOpen(false)}
anchorElementRef={textareaRef}
classes="p-2"
classes="p-1"
>
Suggestions
<ul
className="flex-col gap-y-0.5"
role="listbox"
aria-orientation="vertical"
>
{suggestions.map((s, index) => (
// These options are indirectly handled via keyboard event
// handlers in the textarea, hence, we don't want to add keyboard
// event handler here, but we want to handle click events
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li

Check warning on line 338 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L338

Added line #L338 was not covered by tests
key={s.username}
className={classnames(
'flex justify-between items-center',
'rounded p-2 hover:bg-grey-2',
{
'bg-grey-2': highlightedSuggestion === index,
},
)}
onClick={e => {
e.stopPropagation();
applySuggestion(s);

Check warning on line 349 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L347-L349

Added lines #L347 - L349 were not covered by tests
}}
role="option"
aria-selected={highlightedSuggestion === index}
tabIndex={highlightedSuggestion === index ? 0 : -1}

Check warning on line 353 in src/sidebar/components/MarkdownEditor.tsx

View check run for this annotation

Codecov / codecov/patch

src/sidebar/components/MarkdownEditor.tsx#L353

Added line #L353 was not covered by tests
>
<span className="truncate">{s.username}</span>
<span className="text-color-text-light">{s.displayName}</span>
</li>
))}
{suggestions.length === 0 && (
<li className="italic p-2">
No matches. You can still write the username
</li>
)}
</ul>
</Popover>
)}
</div>
Expand Down Expand Up @@ -392,6 +509,13 @@ export type MarkdownEditorProps = {
text: string;

onEditText?: (text: string) => void;

/**
* List of users who have annotated current document and belong to active group.
* This is used to populate the @mentions suggestions, when `atMentionsEnabled`
* is `true`.
*/
usersWhoAnnotated: UserItem[];
};

/**
Expand All @@ -403,6 +527,7 @@ export default function MarkdownEditor({
onEditText = () => {},
text,
textStyle = {},
usersWhoAnnotated,
}: MarkdownEditorProps) {
// Whether the preview mode is currently active.
const [preview, setPreview] = useState(false);
Expand Down Expand Up @@ -467,12 +592,11 @@ export default function MarkdownEditor({
containerRef={input}
onClick={(e: Event) => e.stopPropagation()}
onKeyDown={handleKeyDown}
onInput={(e: Event) =>
onEditText((e.target as HTMLInputElement).value)
}
onEditText={onEditText}
value={text}
style={textStyle}
atMentionsEnabled={atMentionsEnabled}
usersWhoAnnotated={usersWhoAnnotated}
/>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/sidebar/components/test/MarkdownEditor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('MarkdownEditor', () => {
label="Test editor"
text="test"
atMentionsEnabled={false}
usersWhoAnnotated={[]}
{...props}
/>,
mountProps,
Expand Down
38 changes: 38 additions & 0 deletions src/sidebar/store/modules/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createSelector } from 'reselect';
import { hasOwn } from '../../../shared/has-own';
import type { Annotation, SavedAnnotation } from '../../../types/api';
import type { HighlightCluster } from '../../../types/shared';
import { username as getUsername } from '../../helpers/account-id';
import * as metadata from '../../helpers/annotation-metadata';
import { isHighlight, isSaved } from '../../helpers/annotation-metadata';
import { countIf, toTrueMap, trueKeys } from '../../util/collections';
Expand All @@ -34,6 +35,11 @@ type AnnotationStub = {
$tag?: string;
};

export type UserItem = {
user: string;
displayName: string | null;
};

const initialState = {
annotations: [],
highlighted: {},
Expand Down Expand Up @@ -567,6 +573,37 @@ const savedAnnotations = createSelector(
annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[],
);

/**
* Return the list of unique users who authored any annotation, ordered by username.
*/
const usersWhoAnnotated = createSelector(
(state: State) => state.annotations,
annotations => {
const usersMap = new Map<

Check warning on line 582 in src/sidebar/store/modules/annotations.ts

View check run for this annotation

Codecov / codecov/patch

src/sidebar/store/modules/annotations.ts#L580-L582

Added lines #L580 - L582 were not covered by tests
string,
{ user: string; username: string; displayName: string | null }
>();
annotations.forEach(anno => {
const { user } = anno;
const username = getUsername(user);
const displayName = anno.user_info?.display_name ?? null;

Check warning on line 589 in src/sidebar/store/modules/annotations.ts

View check run for this annotation

Codecov / codecov/patch

src/sidebar/store/modules/annotations.ts#L586-L589

Added lines #L586 - L589 were not covered by tests

// Keep a unique list of users
if (!usersMap.has(user)) {
usersMap.set(user, { user, username, displayName });

Check warning on line 593 in src/sidebar/store/modules/annotations.ts

View check run for this annotation

Codecov / codecov/patch

src/sidebar/store/modules/annotations.ts#L592-L593

Added lines #L592 - L593 were not covered by tests
}
});

// Sort users by username
return [...usersMap.values()].sort((a, b) => {
const lowerAUsername = a.username.toLowerCase();
const lowerBUsername = b.username.toLowerCase();

Check warning on line 600 in src/sidebar/store/modules/annotations.ts

View check run for this annotation

Codecov / codecov/patch

src/sidebar/store/modules/annotations.ts#L598-L600

Added lines #L598 - L600 were not covered by tests

return lowerAUsername.localeCompare(lowerBUsername);

Check warning on line 602 in src/sidebar/store/modules/annotations.ts

View check run for this annotation

Codecov / codecov/patch

src/sidebar/store/modules/annotations.ts#L602

Added line #L602 was not covered by tests
});
},
);

export const annotationsModule = createStoreModule(initialState, {
namespace: 'annotations',
reducers,
Expand Down Expand Up @@ -597,5 +634,6 @@ export const annotationsModule = createStoreModule(initialState, {
noteCount,
orphanCount,
savedAnnotations,
usersWhoAnnotated,
},
});

0 comments on commit 107c276

Please sign in to comment.