// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import _ from 'lodash'; import type Quill from 'quill'; import Delta from 'quill-delta'; import type { RefObject } from 'react'; import React from 'react'; import { Popper } from 'react-popper'; import classNames from 'classnames'; import { createPortal } from 'react-dom'; import type { ConversationType } from '../../state/ducks/conversations'; import { Avatar, AvatarSize } from '../../components/Avatar'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { MemberRepository } from '../memberRepository'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import { matchBlotTextPartitions } from '../util'; import { handleOutsideClick } from '../../util/handleOutsideClick'; import { sameWidthModifier } from '../../util/popperUtil'; import { UserText } from '../../components/UserText'; export type MentionCompletionOptions = { getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; memberRepositoryRef: RefObject; setMentionPickerElement: (element: JSX.Element | null) => void; me?: ConversationType; theme: ThemeType; }; const MENTION_REGEX = /(?:^|\W)@([-+\p{L}\p{M}\p{N}]*)$/u; export class MentionCompletion { results: ReadonlyArray; index: number; root: HTMLDivElement; quill: Quill; options: MentionCompletionOptions; suggestionListRef: RefObject; outsideClickDestructor?: () => void; 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(); const clearResults = () => { if (this.results.length) { this.clearResults(); } 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)); this.quill.on('selection-change', this.onSelectionChange.bind(this)); } destroy(): void { this.outsideClickDestructor?.(); this.outsideClickDestructor = undefined; 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( '[aria-selected="true"]' ); if (selectedElement) { selectedElement.scrollIntoViewIfNeeded(false); } } } onSelectionChange(): void { // Selection should never change while we're editing a mention this.clearResults(); } possiblyShowMemberResults(): ReadonlyArray { const range = this.quill.getSelection(); if (range) { const [blot, index] = this.quill.getLeaf(range.index); const [leftTokenTextMatch] = matchBlotTextPartitions( blot, index, MENTION_REGEX ); if (leftTokenTextMatch) { const [, leftTokenText] = leftTokenTextMatch; let results: ReadonlyArray = []; const memberRepository = this.options.memberRepositoryRef.current; if (memberRepository) { if (leftTokenText === '') { results = memberRepository.getMembers(this.options.me); } else { const fullMentionText = leftTokenText; results = memberRepository.search(fullMentionText, this.options.me); } } return results; } } return []; } 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(); } } completeMention(resultIndexArg?: number): void { const resultIndex = resultIndexArg || this.index; const range = this.quill.getSelection(); if (range == null) { return; } const member = this.results[resultIndex]; const [blot, index] = this.quill.getLeaf(range.index); const [leftTokenTextMatch] = matchBlotTextPartitions( blot, index, MENTION_REGEX ); if (leftTokenTextMatch) { const [, leftTokenText] = leftTokenTextMatch; this.insertMention( member, range.index - leftTokenText.length - 1, leftTokenText.length + 1, true ); } } getAttributesForInsert(index: number): Record { 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 ); } insertMention( mention: ConversationType, 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); if (withTrailingSpace) { this.quill.updateContents(delta.insert(' ', attributes), 'user'); this.quill.setSelection(index + 2, 0, 'user'); } else { this.quill.updateContents(delta, 'user'); this.quill.setSelection(index + 1, 0, 'user'); } this.clearResults(); } clearResults(): void { this.results = []; this.index = 0; this.render(); } onUnmount(): void { this.outsideClickDestructor?.(); this.outsideClickDestructor = undefined; this.options.setMentionPickerElement(null); } render(): void { const { results: memberResults, index: memberResultsIndex } = this; const { getPreferredBadge, theme } = this.options; if (memberResults.length === 0) { this.onUnmount(); return; } const element = createPortal( {({ ref, style }) => (
{memberResults.map((member, index) => ( ))}
)}
, this.root ); // 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], } ); this.options.setMentionPickerElement(element); } }