Skip to content

Commit

Permalink
Display suggestions popover when at-mentioning
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Dec 19, 2024
1 parent 2931e45 commit 0d1a3a3
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 8 deletions.
9 changes: 8 additions & 1 deletion src/sidebar/components/Annotation/AnnotationEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ function AnnotationEditor({

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

const atMentionsEnabled = store.isFeatureEnabled('at_mentions');
const usersMatchingTerm = useCallback(
(term: string) => store.usersWhoAnnotated(term),
[store],
);

return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<div
Expand All @@ -179,7 +185,8 @@ function AnnotationEditor({
label={isReplyAnno ? 'Enter reply' : 'Enter comment'}
text={text}
onEditText={onEditText}
atMentionsEnabled={store.isFeatureEnabled('at_mentions')}
atMentionsEnabled={atMentionsEnabled}
usersMatchingTerm={usersMatchingTerm}
/>
<TagEditor
onAddTag={onAddTag}
Expand Down
89 changes: 82 additions & 7 deletions src/sidebar/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,20 +181,36 @@ function ToolbarButton({
);
}

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

type TextAreaProps = {
classes?: string;
containerRef?: Ref<HTMLTextAreaElement>;
atMentionsEnabled: boolean;
usersMatchingTerm: (mention: string) => UserItem[];
};

function TextArea({
classes,
containerRef,
atMentionsEnabled,
usersMatchingTerm,
...restProps
}: TextAreaProps & JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const [activeMention, setActiveMention] = useState<string>();
const textareaRef = useSyncedRef(containerRef);
const [highlightedSuggestion, setHighlightedSuggestion] = useState(0);
const suggestions = useMemo(() => {
if (!atMentionsEnabled || activeMention === undefined) {
return [];
}

return usersMatchingTerm(activeMention);
}, [activeMention, atMentionsEnabled, usersMatchingTerm]);

useEffect(() => {
if (!atMentionsEnabled) {
Expand All @@ -212,17 +228,46 @@ function TextArea({
if (e.key === 'Escape') {
return;
}
setPopoverOpen(
termBeforePosition(textarea.value, textarea.selectionStart).startsWith(
'@',
),

const termBeforeCaret = termBeforePosition(
textarea.value,
textarea.selectionStart,
);
const isAtMention = termBeforeCaret.startsWith('@');

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

listenerCollection.add(textarea, 'keydown', e => {
if (
!popoverOpen ||
suggestions.length === 0 ||
!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)
) {
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();

if (e.key === 'ArrowDown') {
setHighlightedSuggestion(prev =>
Math.min(prev + 1, suggestions.length),
);
} else if (e.key === 'ArrowUp') {
setHighlightedSuggestion(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
// TODO "Print" suggestion in textarea
}
});

return () => {
listenerCollection.removeAll();
};
}, [atMentionsEnabled, popoverOpen, textareaRef]);
}, [atMentionsEnabled, popoverOpen, suggestions.length, textareaRef]);

return (
<div className="relative">
Expand All @@ -241,9 +286,30 @@ function TextArea({
open={popoverOpen}
onClose={() => setPopoverOpen(false)}
anchorElementRef={textareaRef}
classes="p-2"
classes="p-1"
>
Suggestions
<ul className="flex-col gap-y-0.5">
{suggestions.map((s, index) => (
<li
key={s.user}
className={classnames(
'flex justify-between items-center',
'rounded p-2 hover:bg-grey-2',
{
'bg-grey-2': highlightedSuggestion === index,
},
)}
>
<span className="truncate">{s.user}</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 @@ -391,6 +457,13 @@ export type MarkdownEditorProps = {
text: string;

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

/**
* A function that returns a list of users which username or display name
* match provided term.
* This is used only if `atMentionsEnabled` is `true`.
*/
usersMatchingTerm: (term: string) => UserItem[];
};

/**
Expand All @@ -402,6 +475,7 @@ export default function MarkdownEditor({
onEditText = () => {},
text,
textStyle = {},
usersMatchingTerm,
}: MarkdownEditorProps) {
// Whether the preview mode is currently active.
const [preview, setPreview] = useState(false);
Expand Down Expand Up @@ -472,6 +546,7 @@ export default function MarkdownEditor({
value={text}
style={textStyle}
atMentionsEnabled={atMentionsEnabled}
usersMatchingTerm={usersMatchingTerm}
/>
)}
</div>
Expand Down
41 changes: 41 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 } 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,40 @@ const savedAnnotations = createSelector(
annotations => annotations.filter(ann => isSaved(ann)) as SavedAnnotation[],
);

/**
* Return a list of unique users who authored any annotation, ordered by username.
* Optionally filter and slice the list
*/
function usersWhoAnnotated({ annotations }: State, term?: string, max = 10) {
const usersMap = new Map<
string,
{ user: string; displayName: string | null }
>();
annotations.forEach(anno => {
const user = username(anno.user);
const displayName = anno.user_info?.display_name ?? null;

if (
// Keep a unique list of users
!usersMap.has(user) &&
// Match all users if the term is empty
(!term || `${user} ${displayName ?? ''}`.match(term))
) {
usersMap.set(user, { user, displayName });
}
});

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

return lowerAUsername.localeCompare(lowerBUsername);
})
.slice(0, max);
}

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

0 comments on commit 0d1a3a3

Please sign in to comment.