/* Copyright 2017-2024 New Vector Ltd. Copyright 2016 Aviral Dasgupta SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { createRef, type RefObject } from "react"; import classNames from "classnames"; import { flatMap } from "lodash"; import { type Room } from "matrix-js-sdk/src/matrix"; import Autocompleter, { type ICompletion, type ISelectionRange, type IProviderCompletions, } from "../../../autocomplete/Autocompleter"; import SettingsStore from "../../../settings/SettingsStore"; import RoomContext from "../../../contexts/RoomContext"; const MAX_PROVIDER_MATCHES = 20; export const generateCompletionDomId = (n: number): string => `mx_Autocomplete_Completion_${n}`; interface IProps { // the query string for which to show autocomplete suggestions query: string; // method invoked with range and text content when completion is confirmed onConfirm: (completion: ICompletion) => void; // method invoked when selected (if any) completion changes onSelectionChange?: (partIndex: number) => void; selection: ISelectionRange; // The room in which we're autocompleting room: Room; } interface IState { completions: IProviderCompletions[]; completionList: ICompletion[]; selectionOffset: number; shouldShowCompletions: boolean; hide: boolean; forceComplete: boolean; } export default class Autocomplete extends React.PureComponent { public autocompleter?: Autocompleter; public queryRequested?: string; public debounceCompletionsRequest?: number; private containerRef = createRef(); private completionRefs: Record> = {}; public static contextType = RoomContext; declare public context: React.ContextType; public constructor(props: IProps) { super(props); this.state = { // list of completionResults, each containing completions completions: [], // array of completions, so we can look up current selection by offset quickly completionList: [], // how far down the completion list we are (THIS IS 1-INDEXED!) selectionOffset: 1, // whether we should show completions if they're available shouldShowCompletions: true, hide: false, forceComplete: false, }; } public componentDidMount(): void { this.autocompleter = new Autocompleter(this.props.room, this.context.timelineRenderingType); this.applyNewProps(); } private applyNewProps(oldQuery?: string, oldRoom?: Room): void { if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { this.autocompleter?.destroy(); this.autocompleter = new Autocompleter(this.props.room); } // Query hasn't changed so don't try to complete it if (oldQuery === this.props.query) { return; } this.complete(this.props.query, this.props.selection); } public componentWillUnmount(): void { this.autocompleter?.destroy(); } private complete(query: string, selection: ISelectionRange): Promise { this.queryRequested = query; if (this.debounceCompletionsRequest) { clearTimeout(this.debounceCompletionsRequest); } if (query === "") { this.setState({ // Clear displayed completions completions: [], completionList: [], // Reset selected completion selectionOffset: 1, // Hide the autocomplete box hide: true, }); return Promise.resolve(); } let autocompleteDelay = SettingsStore.getValue("autocompleteDelay"); // Don't debounce if we are already showing completions if (this.state.completions.length > 0 || this.state.forceComplete) { autocompleteDelay = 0; } return new Promise((resolve) => { this.debounceCompletionsRequest = window.setTimeout(() => { resolve(this.processQuery(query, selection)); }, autocompleteDelay); }); } private async processQuery(query: string, selection: ISelectionRange): Promise { if (!this.autocompleter) return; const completions = await this.autocompleter.getCompletions( query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES, ); // Only ever process the completions for the most recent query being processed if (query !== this.queryRequested) { return; } await this.processCompletions(completions); } private async processCompletions(completions: IProviderCompletions[]): Promise { const completionList = flatMap(completions, (provider) => provider.completions); // Reset selection when completion list becomes empty. let selectionOffset = 1; if (completionList.length > 0) { /* If the currently selected completion is still in the completion list, try to find it and jump to it. If not, select composer. */ const currentSelection = this.state.selectionOffset <= 1 ? null : this.state.completionList[this.state.selectionOffset - 1].completion; selectionOffset = completionList.findIndex((completion) => completion.completion === currentSelection); if (selectionOffset === -1) { selectionOffset = 1; } else { selectionOffset++; // selectionOffset is 1-indexed! } } let hide = true; // If `completion.command.command` is truthy, then a provider has matched with the query const anyMatches = completions.some((completion) => !!completion.command.command); if (anyMatches) { hide = false; if (this.props.onSelectionChange) { this.props.onSelectionChange(selectionOffset - 1); } } const deferred = Promise.withResolvers(); this.setState( { completions, completionList, selectionOffset, hide, // Force complete is turned off each time since we can't edit the query in that case forceComplete: false, }, deferred.resolve, ); await deferred.promise; } public hasSelection(): boolean { return this.countCompletions() > 0 && this.state.selectionOffset !== 0; } public countCompletions(): number { return this.state.completionList.length; } // called from MessageComposerInput public moveSelection(delta: number): void { const completionCount = this.countCompletions(); if (completionCount === 0) return; // there are no items to move the selection through // Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount; this.setSelection(1 + index); } public onEscape(e: KeyboardEvent | React.KeyboardEvent): boolean | undefined { const completionCount = this.countCompletions(); if (completionCount === 0) { // autocomplete is already empty, so don't preventDefault return; } e.preventDefault(); // selectionOffset = 0, so we don't end up completing when autocomplete is hidden this.hide(); } private hide = (): void => { this.setState({ hide: true, selectionOffset: 1, completions: [], completionList: [], }); }; public forceComplete(): Promise { return new Promise((resolve) => { this.setState( { forceComplete: true, hide: false, }, () => { this.complete(this.props.query, this.props.selection).then(() => { resolve(this.countCompletions()); }); }, ); }); } public onConfirmCompletion = (): void => { this.onCompletionClicked(this.state.selectionOffset); }; private onCompletionClicked = (selectionOffset: number): boolean => { const count = this.countCompletions(); if (count === 0 || selectionOffset < 1 || selectionOffset > count) { return false; } this.props.onConfirm(this.state.completionList[selectionOffset - 1]); this.hide(); return true; }; private setSelection(selectionOffset: number): void { this.setState({ selectionOffset, hide: false }); if (this.props.onSelectionChange) { this.props.onSelectionChange(selectionOffset - 1); } } public componentDidUpdate(prevProps: IProps): void { this.applyNewProps(prevProps.query, prevProps.room); // this is the selected completion, so scroll it into view if needed const selectedCompletion = this.completionRefs[`completion${this.state.selectionOffset}`]?.current; if (selectedCompletion) { selectedCompletion.scrollIntoView({ behavior: "auto", block: "nearest", }); } else if (this.containerRef.current) { this.containerRef.current.scrollTo({ top: 0 }); } } public render(): React.ReactNode { let position = 1; const renderedCompletions = this.state.completions .map((completionResult, i) => { const completions = completionResult.completions.map((completion, j) => { const selected = position === this.state.selectionOffset; const className = classNames("mx_Autocomplete_Completion", { selected }); const componentPosition = position; position++; const onClick = (): void => { this.onCompletionClicked(componentPosition); }; const refId = `completion${componentPosition}`; if (!this.completionRefs[refId]) { this.completionRefs[refId] = createRef(); } return React.cloneElement(completion.component, { "key": j, "ref": this.completionRefs[refId], "id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs className, onClick, "aria-selected": selected, }); }); return completions.length > 0 ? (
{completionResult.provider.getName()}
{completionResult.provider.renderCompletions(completions)}
) : null; }) .filter((completion) => !!completion); return !this.state.hide && renderedCompletions.length > 0 ? (
{renderedCompletions}
) : null; } }