signal-desktop/ts/quill/mentions/completion.tsx

321 lines
9.2 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-11-03 01:19:52 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import _ from 'lodash';
import type Quill from 'quill';
2020-11-03 01:19:52 +00:00
import Delta from 'quill-delta';
import type { RefObject } from 'react';
import React from 'react';
2020-11-03 01:19:52 +00:00
import { Popper } from 'react-popper';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import type { ConversationType } from '../../state/ducks/conversations';
2022-12-09 20:37:45 +00:00
import { Avatar, AvatarSize } from '../../components/Avatar';
2021-11-17 18:38:52 +00:00
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { MemberRepository } from '../memberRepository';
2021-11-17 18:38:52 +00:00
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import { matchBlotTextPartitions } from '../util';
2022-09-27 20:24:21 +00:00
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { sameWidthModifier } from '../../util/popperUtil';
2023-04-20 17:03:43 +00:00
import { UserText } from '../../components/UserText';
2020-11-03 01:19:52 +00:00
export type MentionCompletionOptions = {
2021-11-17 18:38:52 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2020-11-03 01:19:52 +00:00
i18n: LocalizerType;
memberRepositoryRef: RefObject<MemberRepository>;
setMentionPickerElement: (element: JSX.Element | null) => void;
me?: ConversationType;
2021-11-17 18:38:52 +00:00
theme: ThemeType;
};
2020-11-03 01:19:52 +00:00
const MENTION_REGEX = /(?:^|\W)@([-+\p{L}\p{M}\p{N}]*)$/u;
2020-11-03 01:19:52 +00:00
export class MentionCompletion {
results: ReadonlyArray<ConversationType>;
2020-11-03 01:19:52 +00:00
index: number;
root: HTMLDivElement;
quill: Quill;
options: MentionCompletionOptions;
suggestionListRef: RefObject<HTMLDivElement>;
2022-09-29 20:13:45 +00:00
outsideClickDestructor?: () => void;
2022-09-27 20:24:21 +00:00
2020-11-03 01:19:52 +00:00
constructor(quill: Quill, options: MentionCompletionOptions) {
this.results = [];
this.index = 0;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill = quill;
this.suggestionListRef = React.createRef<HTMLDivElement>();
const clearResults = () => {
if (this.results.length) {
this.clearResults();
2020-11-03 01:19:52 +00:00
}
return true;
};
const changeIndex = (by: number) => (): boolean => {
if (this.results.length) {
this.changeIndex(by);
return false;
}
return true;
};
this.quill.keyboard.addBinding({ key: 37 }, clearResults); // Left Arrow
this.quill.keyboard.addBinding({ key: 38 }, changeIndex(-1)); // Up Arrow
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // Right Arrow
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // Down Arrow
this.quill.on('text-change', _.debounce(this.onTextChange.bind(this), 0));
2020-11-03 01:19:52 +00:00
this.quill.on('selection-change', this.onSelectionChange.bind(this));
}
destroy(): void {
2022-09-29 20:13:45 +00:00
this.outsideClickDestructor?.();
this.outsideClickDestructor = undefined;
2020-11-03 01:19:52 +00:00
this.root.remove();
}
changeIndex(by: number): void {
this.index = (this.index + by + this.results.length) % this.results.length;
this.render();
const suggestionList = this.suggestionListRef.current;
if (suggestionList) {
const selectedElement = suggestionList.querySelector<HTMLElement>(
'[aria-selected="true"]'
);
if (selectedElement) {
selectedElement.scrollIntoViewIfNeeded(false);
}
}
}
onSelectionChange(): void {
// Selection should never change while we're editing a mention
this.clearResults();
2020-11-03 01:19:52 +00:00
}
possiblyShowMemberResults(): ReadonlyArray<ConversationType> {
2020-11-03 01:19:52 +00:00
const range = this.quill.getSelection();
if (range) {
const [blot, index] = this.quill.getLeaf(range.index);
2020-11-03 01:19:52 +00:00
const [leftTokenTextMatch] = matchBlotTextPartitions(
blot,
index,
MENTION_REGEX
);
2020-11-03 01:19:52 +00:00
if (leftTokenTextMatch) {
const [, leftTokenText] = leftTokenTextMatch;
2020-11-03 01:19:52 +00:00
let results: ReadonlyArray<ConversationType> = [];
2020-11-03 01:19:52 +00:00
const memberRepository = this.options.memberRepositoryRef.current;
2020-11-03 01:19:52 +00:00
if (memberRepository) {
if (leftTokenText === '') {
results = memberRepository.getMembers(this.options.me);
} else {
const fullMentionText = leftTokenText;
results = memberRepository.search(fullMentionText, this.options.me);
}
}
2020-11-03 01:19:52 +00:00
return results;
2020-11-03 01:19:52 +00:00
}
}
return [];
}
2020-11-03 01:19:52 +00:00
onTextChange(): void {
const showMemberResults = this.possiblyShowMemberResults();
if (showMemberResults.length > 0) {
this.results = showMemberResults;
this.index = 0;
this.render();
} else if (this.results.length !== 0) {
this.clearResults();
}
2020-11-03 01:19:52 +00:00
}
completeMention(resultIndexArg?: number): void {
const resultIndex = resultIndexArg || this.index;
2020-11-03 01:19:52 +00:00
const range = this.quill.getSelection();
if (range == null) {
2022-09-13 21:48:09 +00:00
return;
}
2020-11-03 01:19:52 +00:00
const member = this.results[resultIndex];
2020-11-03 01:19:52 +00:00
const [blot, index] = this.quill.getLeaf(range.index);
2020-11-03 01:19:52 +00:00
const [leftTokenTextMatch] = matchBlotTextPartitions(
blot,
index,
MENTION_REGEX
);
2020-11-03 01:19:52 +00:00
if (leftTokenTextMatch) {
const [, leftTokenText] = leftTokenTextMatch;
2020-11-03 01:19:52 +00:00
this.insertMention(
member,
range.index - leftTokenText.length - 1,
leftTokenText.length + 1,
true
);
}
2020-11-03 01:19:52 +00:00
}
getAttributesForInsert(index: number): Record<string, unknown> {
const character = index > 0 ? index - 1 : 0;
const contents = this.quill.getContents(character, 1);
return contents.ops.reduce(
(acc, op) => ({ acc, ...op.attributes }),
{} as Record<string, unknown>
);
}
2020-11-03 01:19:52 +00:00
insertMention(
mention: ConversationType,
2020-11-03 01:19:52 +00:00
index: number,
range: number,
withTrailingSpace = false
): void {
// The mention + space we add won't be formatted unless we manually provide attributes
const attributes = this.getAttributesForInsert(range - 1);
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ mention }, attributes);
2020-11-03 01:19:52 +00:00
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' ', attributes), 'user');
2020-11-03 01:19:52 +00:00
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');
this.quill.setSelection(index + 1, 0, 'user');
}
this.clearResults();
2020-11-03 01:19:52 +00:00
}
clearResults(): void {
this.results = [];
this.index = 0;
2020-11-03 01:19:52 +00:00
this.render();
2020-11-03 01:19:52 +00:00
}
onUnmount(): void {
2022-09-29 20:13:45 +00:00
this.outsideClickDestructor?.();
this.outsideClickDestructor = undefined;
this.options.setMentionPickerElement(null);
2020-11-03 01:19:52 +00:00
}
render(): void {
const { results: memberResults, index: memberResultsIndex } = this;
2021-11-17 18:38:52 +00:00
const { getPreferredBadge, theme } = this.options;
2020-11-03 01:19:52 +00:00
if (memberResults.length === 0) {
2022-09-29 20:13:45 +00:00
this.onUnmount();
2020-11-03 01:19:52 +00:00
return;
}
const element = createPortal(
<Popper placement="top-start" modifiers={[sameWidthModifier]}>
2020-11-03 01:19:52 +00:00
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__suggestions"
style={style}
role="listbox"
aria-expanded
aria-activedescendant={`mention-result--${
memberResults.length ? memberResults[memberResultsIndex].name : ''
}`}
tabIndex={0}
>
<div
ref={this.suggestionListRef}
className="module-composition-input__suggestions--scroller"
>
{memberResults.map((member, index) => (
<button
type="button"
2023-08-16 20:54:39 +00:00
key={member.serviceId}
2020-11-03 01:19:52 +00:00
id={`mention-result--${member.name}`}
role="option button"
aria-selected={memberResultsIndex === index}
onClick={() => {
this.completeMention(index);
2020-11-03 01:19:52 +00:00
}}
className={classNames(
'module-composition-input__suggestions__row',
'module-composition-input__suggestions__row--mention',
memberResultsIndex === index
? 'module-composition-input__suggestions__row--selected'
: null
)}
>
<Avatar
2021-05-07 22:21:10 +00:00
acceptedMessageRequest={member.acceptedMessageRequest}
2020-11-03 01:19:52 +00:00
avatarPath={member.avatarPath}
2021-11-17 18:38:52 +00:00
badge={getPreferredBadge(member.badges)}
2020-11-03 01:19:52 +00:00
conversationType="direct"
i18n={this.options.i18n}
2021-05-07 22:21:10 +00:00
isMe={member.isMe}
sharedGroupNames={member.sharedGroupNames}
2022-12-09 20:37:45 +00:00
size={AvatarSize.TWENTY_EIGHT}
2021-11-17 18:38:52 +00:00
theme={theme}
2020-11-03 01:19:52 +00:00
title={member.title}
2021-05-07 22:21:10 +00:00
unblurredAvatarPath={member.unblurredAvatarPath}
2020-11-03 01:19:52 +00:00
/>
<div className="module-composition-input__suggestions__title">
2023-04-20 17:03:43 +00:00
<UserText text={member.title} />
2020-11-03 01:19:52 +00:00
</div>
</button>
))}
</div>
</div>
)}
</Popper>,
this.root
);
2022-09-29 20:13:45 +00:00
// Just to make sure that we don't propagate outside clicks until this
// is closed.
this.outsideClickDestructor?.();
this.outsideClickDestructor = handleOutsideClick(
() => {
this.onUnmount();
return true;
},
{
name: 'quill.mentions.completion',
containerElements: [this.root],
}
);
2020-11-03 01:19:52 +00:00
this.options.setMentionPickerElement(element);
}
}