From 33038e7252aea9f1d4d4cd10e7232b659229266c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 19 Dec 2024 09:50:23 +0100 Subject: [PATCH] Populate list of suggestions matching @mentions --- .../Annotation/AnnotationEditor.tsx | 6 +- .../Annotation/test/AnnotationEditor-test.js | 1 + src/sidebar/components/MarkdownEditor.tsx | 190 +++++++++++++---- .../components/MentionSuggestionsPopover.tsx | 71 +++++++ .../components/test/MarkdownEditor-test.js | 198 +++++++++++++++--- .../test/MentionSuggestionsPopover-test.js | 82 ++++++++ src/sidebar/store/modules/annotations.ts | 40 ++++ .../store/modules/test/annotations-test.js | 50 +++++ src/sidebar/util/term-before-position.ts | 38 +++- .../util/test/term-before-position-test.js | 34 ++- 10 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 src/sidebar/components/MentionSuggestionsPopover.tsx create mode 100644 src/sidebar/components/test/MentionSuggestionsPopover-test.js diff --git a/src/sidebar/components/Annotation/AnnotationEditor.tsx b/src/sidebar/components/Annotation/AnnotationEditor.tsx index 30d76ec2593..f8c569f5284 100644 --- a/src/sidebar/components/Annotation/AnnotationEditor.tsx +++ b/src/sidebar/components/Annotation/AnnotationEditor.tsx @@ -167,6 +167,9 @@ function AnnotationEditor({ const textStyle = applyTheme(['annotationFontFamily'], settings); + const mentionsEnabled = store.isFeatureEnabled('at_mentions'); + const usersWhoAnnotated = store.usersWhoAnnotated(); + return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
{ removeDraft: sinon.stub(), removeAnnotations: sinon.stub(), isFeatureEnabled: sinon.stub().returns(false), + usersWhoAnnotated: sinon.stub().returns([]), }; $imports.$mock(mockImportedComponents()); diff --git a/src/sidebar/components/MarkdownEditor.tsx b/src/sidebar/components/MarkdownEditor.tsx index a587638693d..4ccbcb78eb0 100644 --- a/src/sidebar/components/MarkdownEditor.tsx +++ b/src/sidebar/components/MarkdownEditor.tsx @@ -2,7 +2,6 @@ import { Button, IconButton, Link, - Popover, useSyncedRef, } from '@hypothesis/frontend-shared'; import { @@ -20,9 +19,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, @@ -31,8 +35,12 @@ import { toggleSpanStyle, } from '../markdown-commands'; import type { EditorState } from '../markdown-commands'; -import { termBeforePosition } from '../util/term-before-position'; +import { + getContainingWordOffsets, + termBeforePosition, +} from '../util/term-before-position'; import MarkdownView from './MarkdownView'; +import MentionSuggestionsPopover from './MentionSuggestionsPopover'; /** * Toolbar commands that modify the editor state. This excludes the Help link @@ -181,49 +189,96 @@ function ToolbarButton({ ); } +export type UserItem = { + username: string; + displayName: string | null; +}; + type TextAreaProps = { classes?: string; containerRef?: Ref; - atMentionsEnabled: boolean; + mentionsEnabled: boolean; + usersForSuggestions: UserItem[]; + onEditText: (text: string) => void; }; function TextArea({ classes, containerRef, - atMentionsEnabled, + mentionsEnabled, + usersForSuggestions, + onEditText, + onKeyDown, ...restProps -}: TextAreaProps & JSX.TextareaHTMLAttributes) { +}: TextAreaProps & JSX.TextareaHTMLAttributes) { const [popoverOpen, setPopoverOpen] = useState(false); + const [activeMention, setActiveMention] = useState(); const textareaRef = useSyncedRef(containerRef); - - useEffect(() => { - if (!atMentionsEnabled) { - return () => {}; + const [highlightedSuggestion, setHighlightedSuggestion] = useState(0); + const suggestions = useMemo(() => { + if (!mentionsEnabled || activeMention === undefined) { + return []; } - const textarea = textareaRef.current!; - const listenerCollection = new ListenerCollection(); - const checkForMentionAtCaret = () => { - 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(); + return usersForSuggestions + .filter( + u => + // Match all users if the active mention is empty, which happens right + // after typing `@` + !activeMention || + `${u.username} ${u.displayName ?? ''}` + .toLowerCase() + .match(activeMention.toLowerCase()), + ) + .slice(0, 10); + }, [activeMention, mentionsEnabled, usersForSuggestions]); + + const checkForMentionAtCaret = useCallback( + (textarea: HTMLTextAreaElement) => { + if (!mentionsEnabled) { + return; } - }); - // 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); + const term = termBeforePosition(textarea.value, textarea.selectionStart); + const isAtMention = term.startsWith('@'); - return () => listenerCollection.removeAll(); - }, [atMentionsEnabled, popoverOpen, textareaRef]); + setPopoverOpen(isAtMention); + setActiveMention(isAtMention ? term.substring(1) : undefined); + + // Reset highlighted suggestion when closing the popover + if (!isAtMention) { + setHighlightedSuggestion(0); + } + }, + [mentionsEnabled], + ); + const applySuggestion = useCallback( + (suggestion: UserItem) => { + const textarea = textareaRef.current!; + const { value } = textarea; + const { start, end } = getContainingWordOffsets( + value, + textarea.selectionStart, + ); + const beforeMention = value.slice(0, start); + const beforeCaret = `${beforeMention}@${suggestion.username} `; + const afterMention = value.slice(end); + + // Set textarea value directly, set new caret position and keep it focused. + textarea.value = `${beforeCaret}${afterMention}`; + textarea.selectionStart = beforeCaret.length; + textarea.selectionEnd = beforeCaret.length; + textarea.focus(); + // Then update state to keep it in sync. + onEditText(textarea.value); + + // Close popover and reset highlighted suggestion once the value is + // replaced + setPopoverOpen(false); + setHighlightedSuggestion(0); + }, + [onEditText, textareaRef], + ); return (
@@ -234,18 +289,59 @@ 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 if present + onKeyDown?.(e); + + if (!popoverOpen || suggestions.length === 0) { + return; + } + + // When vertical arrows are pressed while the popover is open with + // suggestions, highlight the right suggestion. + // When `Enter` is pressed, apply highlighted suggestion. + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedSuggestion(prev => + Math.min(prev + 1, suggestions.length - 1), + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedSuggestion(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + applySuggestion(suggestions[highlightedSuggestion]); + } + }} + onClick={e => { + e.stopPropagation(); + // When clicking the textarea, it's possible the caret is moved "into" a + // mention, so we check if the popover should be opened + checkForMentionAtCaret(e.target as HTMLTextAreaElement); + }} ref={textareaRef} /> - {atMentionsEnabled && ( - setPopoverOpen(false)} anchorElementRef={textareaRef} - classes="p-2" - > - Suggestions - + suggestions={suggestions} + highlightedSuggestion={highlightedSuggestion} + onSelectSuggestion={applySuggestion} + /> )}
); @@ -380,7 +476,7 @@ export type MarkdownEditorProps = { * Whether the at-mentions feature ir enabled or not. * Defaults to false. */ - atMentionsEnabled?: boolean; + mentionsEnabled?: boolean; /** An accessible label for the input field */ label: string; @@ -392,17 +488,25 @@ export type MarkdownEditorProps = { text: string; onEditText?: (text: string) => void; + + /** + * Base list of users used to populate the @mentions suggestions, when + * `mentionsEnabled` is `true`. + * The list will be filtered and narrowed down based on the partial mention. + */ + usersForSuggestions: UserItem[]; }; /** * Viewer/editor for the body of an annotation in markdown format. */ export default function MarkdownEditor({ - atMentionsEnabled = false, + mentionsEnabled = false, label, onEditText = () => {}, text, textStyle = {}, + usersForSuggestions, }: MarkdownEditorProps) { // Whether the preview mode is currently active. const [preview, setPreview] = useState(false); @@ -465,14 +569,12 @@ export default function MarkdownEditor({ 'text-base touch:text-touch-base', )} 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} + mentionsEnabled={mentionsEnabled} + usersForSuggestions={usersForSuggestions} /> )}
diff --git a/src/sidebar/components/MentionSuggestionsPopover.tsx b/src/sidebar/components/MentionSuggestionsPopover.tsx new file mode 100644 index 00000000000..a6bc661f2fa --- /dev/null +++ b/src/sidebar/components/MentionSuggestionsPopover.tsx @@ -0,0 +1,71 @@ +import { Popover } from '@hypothesis/frontend-shared'; +import type { PopoverProps } from '@hypothesis/frontend-shared/lib/components/feedback/Popover'; +import classnames from 'classnames'; + +export type UserItem = { + username: string; + displayName: string | null; +}; + +export type MentionSuggestionsPopoverProps = Pick< + PopoverProps, + 'open' | 'onClose' | 'anchorElementRef' +> & { + /** List of suggestions to display */ + suggestions: UserItem[]; + /** Index for currently highlighted suggestion */ + highlightedSuggestion: number; + /** Invoked when a suggestion is selected */ + onSelectSuggestion: (selectedSuggestion: UserItem) => void; +}; + +/** + * A Popover component that displays a list of user suggestions. + */ +export default function MentionSuggestionsPopover({ + suggestions, + onSelectSuggestion, + highlightedSuggestion, + ...popoverProps +}: MentionSuggestionsPopoverProps) { + return ( + + + + ); +} diff --git a/src/sidebar/components/test/MarkdownEditor-test.js b/src/sidebar/components/test/MarkdownEditor-test.js index b8efe9ca920..7e8a374bdce 100644 --- a/src/sidebar/components/test/MarkdownEditor-test.js +++ b/src/sidebar/components/test/MarkdownEditor-test.js @@ -53,17 +53,14 @@ describe('MarkdownEditor', () => { , mountProps, ); } - function createConnectedComponent(props = {}) { - return createComponent(props, { connected: true }); - } - const commands = [ { command: 'Bold', @@ -388,108 +385,249 @@ describe('MarkdownEditor', () => { const textareaDOMNode = textarea.getDOMNode(); textareaDOMNode.value = text; + textareaDOMNode.selectionStart = text.length; act(() => textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup', { key })), ); wrapper.update(); } - [true, false].forEach(atMentionsEnabled => { + function keyDownInTextarea(wrapper, key) { + const textarea = wrapper.find('textarea'); + const textareaDOMNode = textarea.getDOMNode(); + + act(() => + textareaDOMNode.dispatchEvent(new KeyboardEvent('keydown', { key })), + ); + wrapper.update(); + } + + function getHighlightedSuggestion(wrapper) { + return wrapper + .find('MentionSuggestionsPopover') + .prop('highlightedSuggestion'); + } + + function suggestionsPopoverIsOpen(wrapper) { + return wrapper.find('MentionSuggestionsPopover').prop('open'); + } + + [true, false].forEach(mentionsEnabled => { it('renders Popover if @mentions are enabled', () => { - const wrapper = createComponent({ atMentionsEnabled }); - assert.equal(wrapper.exists('Popover'), atMentionsEnabled); + const wrapper = createComponent({ mentionsEnabled }); + assert.equal( + wrapper.exists('MentionSuggestionsPopover'), + mentionsEnabled, + ); + + // Popover is opened after typing "@" + typeInTextarea(wrapper, '@'); + if (mentionsEnabled) { + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); + } }); }); it('opens Popover when an @mention is typed in textarea', () => { - const wrapper = createConnectedComponent({ atMentionsEnabled: true }); + const wrapper = createComponent({ mentionsEnabled: true }); typeInTextarea(wrapper, '@johndoe'); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); }); it('closes Popover when cursor moves away from @mention', () => { - const wrapper = createConnectedComponent({ atMentionsEnabled: true }); + const wrapper = createComponent({ mentionsEnabled: true }); // Popover is open after typing the at-mention typeInTextarea(wrapper, '@johndoe'); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); // Once a space is typed after the at-mention, the popover is closed typeInTextarea(wrapper, '@johndoe '); - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); }); it('closes Popover when @mention is removed', () => { - const wrapper = createConnectedComponent({ atMentionsEnabled: true }); + const wrapper = createComponent({ mentionsEnabled: true }); // Popover is open after typing the at-mention typeInTextarea(wrapper, '@johndoe'); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); // Once the at-mention is removed, the popover is closed typeInTextarea(wrapper, ''); - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); }); it('opens Popover when cursor moves into an @mention', () => { const text = '@johndoe '; - const wrapper = createConnectedComponent({ + const wrapper = createComponent({ text, - atMentionsEnabled: true, + mentionsEnabled: true, }); const textarea = wrapper.find('textarea'); const textareaDOMNode = textarea.getDOMNode(); // Popover is initially closed - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); // Move cursor to the left textareaDOMNode.selectionStart = text.length - 1; act(() => textareaDOMNode.dispatchEvent(new KeyboardEvent('keyup'))); wrapper.update(); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); }); it('closes Popover when onClose is called', () => { - const wrapper = createConnectedComponent({ atMentionsEnabled: true }); + const wrapper = createComponent({ mentionsEnabled: true }); // Popover is initially open typeInTextarea(wrapper, '@johndoe'); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); - wrapper.find('Popover').props().onClose(); + wrapper.find('MentionSuggestionsPopover').props().onClose(); wrapper.update(); - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); }); it('ignores `Escape` key press in textarea', () => { - const wrapper = createConnectedComponent({ atMentionsEnabled: true }); + const wrapper = createComponent({ mentionsEnabled: true }); // Popover is still closed if the key is `Escape` typeInTextarea(wrapper, '@johndoe', 'Escape'); - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); }); it('opens popover when clicking textarea and moving the caret to a mention', () => { const text = 'text @johndoe more text'; - const wrapper = createConnectedComponent({ + const wrapper = createComponent({ text, - atMentionsEnabled: true, + mentionsEnabled: true, }); const textarea = wrapper.find('textarea'); const textareaDOMNode = textarea.getDOMNode(); // Popover is initially closed - assert.isFalse(wrapper.find('Popover').prop('open')); + assert.isFalse(suggestionsPopoverIsOpen(wrapper)); // Move cursor to overlap with the mention textareaDOMNode.selectionStart = text.indexOf('@') + 1; act(() => textareaDOMNode.dispatchEvent(new MouseEvent('click'))); wrapper.update(); - assert.isTrue(wrapper.find('Popover').prop('open')); + assert.isTrue(suggestionsPopoverIsOpen(wrapper)); + }); + + it('allows changing highlighted suggestion via vertical arrow keys', () => { + const wrapper = createComponent({ + mentionsEnabled: true, + usersForSuggestions: [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ], + }); + + typeInTextarea(wrapper, '@johndoe'); + + // Initially, first suggestion is highlighted + assert.equal(getHighlightedSuggestion(wrapper), 0); + + // Pressing arrow up has no effect while first suggestion is highlighted + keyDownInTextarea(wrapper, 'ArrowUp'); + assert.equal(getHighlightedSuggestion(wrapper), 0); + + // Pressing arrow down, we can highlight subsequent suggestions + keyDownInTextarea(wrapper, 'ArrowDown'); + assert.equal(getHighlightedSuggestion(wrapper), 1); + keyDownInTextarea(wrapper, 'ArrowDown'); + assert.equal(getHighlightedSuggestion(wrapper), 2); + + // Once last suggestion is highlighted, pressing arrow down has no effect + keyDownInTextarea(wrapper, 'ArrowDown'); + assert.equal(getHighlightedSuggestion(wrapper), 2); + + // Pressing arrow up, we can highlight preceding suggestions + keyDownInTextarea(wrapper, 'ArrowUp'); + assert.equal(getHighlightedSuggestion(wrapper), 1); + keyDownInTextarea(wrapper, 'ArrowUp'); + assert.equal(getHighlightedSuggestion(wrapper), 0); + }); + + it('applies highlighted suggestion when `Enter` is pressed', () => { + const onEditText = sinon.stub(); + const wrapper = createComponent({ + onEditText, + mentionsEnabled: true, + usersForSuggestions: [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ], + }); + + typeInTextarea(wrapper, '@johndoe'); + + // Arrow down is pressed to highlight second suggestion + keyDownInTextarea(wrapper, 'ArrowDown'); + // Then Enter is pressed to apply it + keyDownInTextarea(wrapper, 'Enter'); + + // The textarea should include the username for second suggestion + assert.calledWith(onEditText, '@two '); + }); + + [ + // With no users, there won't be any suggestions regardless of the text + { usersForSuggestions: [], text: '@', expectedSuggestions: 0 }, + + // With users, there won't be suggestions when none of them matches the + // mention + { + usersForSuggestions: [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ], + text: '@nothing_will_match', + expectedSuggestions: 0, + }, + + // With users, there will be suggestions when any of them matches the + // mention + { + usersForSuggestions: [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ], + text: '@two', + expectedSuggestions: 1, + }, + { + usersForSuggestions: [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ], + text: '@johndoe', + expectedSuggestions: 3, + }, + ].forEach(({ usersForSuggestions, text, expectedSuggestions }) => { + it('resolves expected suggestions when based on active mention matches', () => { + const wrapper = createComponent({ + mentionsEnabled: true, + usersForSuggestions, + }); + + typeInTextarea(wrapper, text); + + assert.equal( + wrapper.find('MentionSuggestionsPopover').prop('suggestions').length, + expectedSuggestions, + ); + }); }); }); diff --git a/src/sidebar/components/test/MentionSuggestionsPopover-test.js b/src/sidebar/components/test/MentionSuggestionsPopover-test.js new file mode 100644 index 00000000000..70c4f65b72f --- /dev/null +++ b/src/sidebar/components/test/MentionSuggestionsPopover-test.js @@ -0,0 +1,82 @@ +import { mount } from '@hypothesis/frontend-testing'; +import { useRef } from 'preact/hooks'; +import sinon from 'sinon'; + +import MentionSuggestionsPopover from '../MentionSuggestionsPopover'; + +describe('MentionSuggestionsPopover', () => { + const defaultSuggestions = [ + { username: 'one', displayName: 'johndoe' }, + { username: 'two', displayName: 'johndoe' }, + { username: 'three', displayName: 'johndoe' }, + ]; + + function TestComponent(props) { + const anchorRef = useRef(); + return ( +
+
+ +
+ ); + } + + function createComponent(props) { + return mount( + , + { connected: true }, + ); + } + + function suggestionAt(wrapper, index) { + return wrapper.find('[role="option"]').at(index); + } + + [ + { suggestions: [], shouldRenderFallback: true }, + { suggestions: defaultSuggestions, shouldRenderFallback: false }, + ].forEach(({ suggestions, shouldRenderFallback }) => { + it('shows fallback message when no suggestions are provided', () => { + const wrapper = createComponent({ suggestions }); + assert.equal( + wrapper.exists('[data-testid="suggestions-fallback"]'), + shouldRenderFallback, + ); + }); + }); + + [ + { index: 0, expectedText: 'onejohndoe' }, + { index: 1, expectedText: 'twojohndoe' }, + { index: 2, expectedText: 'threejohndoe' }, + ].forEach(({ index, expectedText }) => { + it('shows provided suggestions as a list', () => { + const wrapper = createComponent(); + assert.equal(suggestionAt(wrapper, index).text(), expectedText); + }); + }); + + [0, 1, 2].forEach(index => { + it('highlights expected suggestion', () => { + const wrapper = createComponent({ highlightedSuggestion: index }); + wrapper.find('[role="option"]').forEach((el, i) => { + assert.equal(el.prop('aria-selected'), i === index); + }); + }); + + it('applies a suggestion when clicked', () => { + const onSelectSuggestion = sinon.stub(); + const wrapper = createComponent({ onSelectSuggestion }); + + suggestionAt(wrapper, index).simulate('click'); + + assert.calledWith(onSelectSuggestion, defaultSuggestions[index]); + }); + }); +}); diff --git a/src/sidebar/store/modules/annotations.ts b/src/sidebar/store/modules/annotations.ts index 1ad0dad2b7b..a3da4c8e023 100644 --- a/src/sidebar/store/modules/annotations.ts +++ b/src/sidebar/store/modules/annotations.ts @@ -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'; @@ -34,6 +35,11 @@ type AnnotationStub = { $tag?: string; }; +export type UserItem = { + user: string; + displayName: string | null; +}; + const initialState = { annotations: [], highlighted: {}, @@ -567,6 +573,39 @@ 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< + string, + { user: string; username: string; displayName: string | null } + >(); + annotations.forEach(anno => { + const { user } = anno; + + // Keep a unique list of users + if (usersMap.has(user)) { + return; + } + + const username = getUsername(user); + const displayName = anno.user_info?.display_name ?? null; + usersMap.set(user, { user, username, displayName }); + }); + + // Sort users by username + return [...usersMap.values()].sort((a, b) => { + const lowerAUsername = a.username.toLowerCase(); + const lowerBUsername = b.username.toLowerCase(); + + return lowerAUsername.localeCompare(lowerBUsername); + }); + }, +); + export const annotationsModule = createStoreModule(initialState, { namespace: 'annotations', reducers, @@ -597,5 +636,6 @@ export const annotationsModule = createStoreModule(initialState, { noteCount, orphanCount, savedAnnotations, + usersWhoAnnotated, }, }); diff --git a/src/sidebar/store/modules/test/annotations-test.js b/src/sidebar/store/modules/test/annotations-test.js index 168e736bf49..4146db190ca 100644 --- a/src/sidebar/store/modules/test/annotations-test.js +++ b/src/sidebar/store/modules/test/annotations-test.js @@ -543,4 +543,54 @@ describe('sidebar/store/modules/annotations', () => { }); }); }); + + describe('usersWhoAnnotated', () => { + it('returns expected list of unique and sorted users', () => { + const store = createTestStore(); + + // Add a few annotations assigned to duplicated unordered users + store.addAnnotations([ + Object.assign(fixtures.defaultAnnotation(), { + id: 'a1', + user: 'acct:jondoe@hypothes.is', + }), + Object.assign(fixtures.defaultAnnotation(), { + id: 'a2', + user: 'acct:jondoe@hypothes.is', + }), + Object.assign(fixtures.defaultAnnotation(), { + id: 'a3', + user: 'acct:janedoe@hypothes.is', + user_info: { + display_name: 'Jane Doe', + }, + }), + Object.assign(fixtures.defaultAnnotation(), { + id: 'a3', + user: 'acct:janedoe@hypothes.is', + user_info: { + display_name: 'Jane Doe', + }, + }), + ]); + + // Only one instance of every user should be returned, and they should be + // sorted by username + assert.deepEqual( + [ + { + user: 'acct:janedoe@hypothes.is', + username: 'janedoe', + displayName: 'Jane Doe', + }, + { + user: 'acct:jondoe@hypothes.is', + username: 'jondoe', + displayName: null, + }, + ], + store.usersWhoAnnotated(), + ); + }); + }); }); diff --git a/src/sidebar/util/term-before-position.ts b/src/sidebar/util/term-before-position.ts index 95be55a566e..05eb82497c4 100644 --- a/src/sidebar/util/term-before-position.ts +++ b/src/sidebar/util/term-before-position.ts @@ -1,9 +1,43 @@ /** * Returns the "word" right before a specific position in an input string. * - * In this context, a word is anything between a space or newline, and provided - * position. + * In this context, a word is anything between a space, newline or tab, and + * provided position. */ export function termBeforePosition(text: string, position: number): string { return text.slice(0, position).match(/\S+$/)?.[0] ?? ''; } + +export type WordOffsets = { + start: number; + end: number; +}; + +/** + * Returns the `start` and `end` positions for the word that overlaps with + * provided reference position. + * + * For example, given the text "hello hypothesis", and the reference position 9 + * (which corresponds to the `p` character) it will return the start and end of + * the word `hypothesis`, hence { start: 6, end: 16 }. + * + * Useful to get the coordinates of the word matching the caret in text inputs + * and textareas. + */ +export function getContainingWordOffsets( + text: string, + referencePosition: number, +): WordOffsets { + const precedingEmptyCharPos = text + .slice(0, referencePosition) + .search(/\s\S*$/); + const subsequentEmptyCharPos = text.slice(referencePosition).search(/\s/); + + return { + start: (precedingEmptyCharPos === -1 ? -1 : precedingEmptyCharPos) + 1, + end: + subsequentEmptyCharPos === -1 + ? text.length + : referencePosition + subsequentEmptyCharPos, + }; +} diff --git a/src/sidebar/util/test/term-before-position-test.js b/src/sidebar/util/test/term-before-position-test.js index e323f393413..f1f6a8c9a01 100644 --- a/src/sidebar/util/test/term-before-position-test.js +++ b/src/sidebar/util/test/term-before-position-test.js @@ -1,4 +1,7 @@ -import { termBeforePosition } from '../term-before-position'; +import { + getContainingWordOffsets, + termBeforePosition, +} from '../term-before-position'; describe('term-before-position', () => { // To make these tests more predictable, we place the `$` sign in the position @@ -10,26 +13,31 @@ describe('term-before-position', () => { { text: '$Hello world', expectedTerm: '', + expectedOffsets: { start: 0, end: 5 }, }, { text: 'Hello world$', expectedTerm: 'world', + expectedOffsets: { start: 6, end: 11 }, }, // Position in the middle of words { text: 'Hell$o world', expectedTerm: 'Hell', + expectedOffsets: { start: 0, end: 5 }, }, { text: 'Hello wor$ld', expectedTerm: 'wor', + expectedOffsets: { start: 6, end: 11 }, }, // Position preceded by "empty space" { text: 'Hello $world', expectedTerm: '', + expectedOffsets: { start: 6, end: 11 }, }, { text: `Text with @@ -38,35 +46,45 @@ describe('term-before-position', () => { lines `, expectedTerm: '', + expectedOffsets: { start: 31, end: 31 }, }, // Position preceded by/in the middle of a word for multi-line text { text: `Text with$ multiple - + lines `, expectedTerm: 'with', + expectedOffsets: { start: 5, end: 9 }, }, { text: `Text with multiple - + li$nes `, expectedTerm: 'li', + expectedOffsets: { start: 32, end: 37 }, }, - ].forEach(({ text, expectedTerm }) => { - it('returns the term right before provided position', () => { - // Get the position of the `$` sign in the text, then remove it - const position = text.indexOf('$'); - const textWithoutDollarSign = text.replace('$', ''); + ].forEach(({ text, expectedTerm, expectedOffsets }) => { + // Get the position of the `$` sign in the text, then remove it + const position = text.indexOf('$'); + const textWithoutDollarSign = text.replace('$', ''); + it('`termBeforePosition` returns the term right before provided position', () => { assert.equal( termBeforePosition(textWithoutDollarSign, position), expectedTerm, ); }); + + it('`getContainingWordOffsets` returns expected offsets', () => { + assert.deepEqual( + getContainingWordOffsets(textWithoutDollarSign, position), + expectedOffsets, + ); + }); }); });