2017-11-02 18:01:28 +00:00
|
|
|
/*
|
2024-09-09 14:57:16 +01:00
|
|
|
Copyright 2017-2024 New Vector Ltd.
|
2017-11-02 18:01:28 +00:00
|
|
|
Copyright 2016 Aviral Dasgupta
|
|
|
|
|
|
2024-09-09 14:57:16 +01:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
|
|
|
Please see LICENSE files in the repository root for full details.
|
2017-11-02 18:01:28 +00:00
|
|
|
*/
|
|
|
|
|
|
2021-06-29 13:11:58 +01:00
|
|
|
import React, { createRef, KeyboardEvent } from "react";
|
2016-06-21 18:33:39 +05:30
|
|
|
import classNames from "classnames";
|
2021-06-29 13:11:58 +01:00
|
|
|
import { flatMap } from "lodash";
|
2023-08-04 08:36:16 +01:00
|
|
|
import { Room } from "matrix-js-sdk/src/matrix";
|
2016-06-01 16:54:21 +05:30
|
|
|
|
2021-10-22 17:23:32 -05:00
|
|
|
import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from "../../../autocomplete/Autocompleter";
|
2017-10-29 16:53:00 -06:00
|
|
|
import SettingsStore from "../../../settings/SettingsStore";
|
2021-11-18 12:47:11 +00:00
|
|
|
import RoomContext from "../../../contexts/RoomContext";
|
2016-06-01 16:54:21 +05:30
|
|
|
|
2021-05-12 12:18:56 +01:00
|
|
|
const MAX_PROVIDER_MATCHES = 20;
|
2016-09-13 15:41:52 +05:30
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
export const generateCompletionDomId = (n: number): string => `mx_Autocomplete_Completion_${n}`;
|
2019-09-30 14:04:39 +01:00
|
|
|
|
2020-04-20 19:00:54 +01:00
|
|
|
interface IProps {
|
2020-04-21 18:08:01 +01:00
|
|
|
// the query string for which to show autocomplete suggestions
|
2020-04-20 19:00:54 +01:00
|
|
|
query: string;
|
2020-04-21 18:08:01 +01:00
|
|
|
// method invoked with range and text content when completion is confirmed
|
2021-05-20 22:25:19 +01:00
|
|
|
onConfirm: (completion: ICompletion) => void;
|
2020-04-21 18:08:01 +01:00
|
|
|
// method invoked when selected (if any) completion changes
|
2021-05-20 22:25:19 +01:00
|
|
|
onSelectionChange?: (partIndex: number) => void;
|
2020-04-20 19:00:54 +01:00
|
|
|
selection: ISelectionRange;
|
2020-04-21 18:08:01 +01:00
|
|
|
// The room in which we're autocompleting
|
2020-04-20 19:00:54 +01:00
|
|
|
room: Room;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface IState {
|
|
|
|
|
completions: IProviderCompletions[];
|
|
|
|
|
completionList: ICompletion[];
|
|
|
|
|
selectionOffset: number;
|
|
|
|
|
shouldShowCompletions: boolean;
|
|
|
|
|
hide: boolean;
|
|
|
|
|
forceComplete: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
2023-05-05 09:11:14 +01:00
|
|
|
public autocompleter?: Autocompleter;
|
|
|
|
|
public queryRequested?: string;
|
|
|
|
|
public debounceCompletionsRequest?: number;
|
2020-05-29 21:42:33 +01:00
|
|
|
private containerRef = createRef<HTMLDivElement>();
|
2020-04-20 19:00:54 +01:00
|
|
|
|
2021-11-18 12:47:11 +00:00
|
|
|
public static contextType = RoomContext;
|
2024-08-01 17:14:28 +01:00
|
|
|
public declare context: React.ContextType<typeof RoomContext>;
|
2021-11-18 12:47:11 +00:00
|
|
|
|
2024-08-01 13:01:05 +01:00
|
|
|
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
|
|
|
|
super(props, context);
|
2016-07-03 22:15:13 +05:30
|
|
|
|
2016-06-01 16:54:21 +05:30
|
|
|
this.state = {
|
2016-07-03 22:15:13 +05:30
|
|
|
// list of completionResults, each containing completions
|
2016-06-21 15:46:20 +05:30
|
|
|
completions: [],
|
|
|
|
|
|
2016-07-03 22:15:13 +05:30
|
|
|
// array of completions, so we can look up current selection by offset quickly
|
|
|
|
|
completionList: [],
|
|
|
|
|
|
2016-09-13 15:41:52 +05:30
|
|
|
// how far down the completion list we are (THIS IS 1-INDEXED!)
|
2021-02-16 17:56:09 +00:00
|
|
|
selectionOffset: 1,
|
2016-09-13 15:41:52 +05:30
|
|
|
|
|
|
|
|
// whether we should show completions if they're available
|
|
|
|
|
shouldShowCompletions: true,
|
|
|
|
|
|
|
|
|
|
hide: false,
|
|
|
|
|
|
|
|
|
|
forceComplete: false,
|
2016-06-01 16:54:21 +05:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentDidMount(): void {
|
2021-11-18 12:47:11 +00:00
|
|
|
this.autocompleter = new Autocompleter(this.props.room, this.context.timelineRenderingType);
|
2020-04-21 10:01:05 +01:00
|
|
|
this.applyNewProps();
|
2019-05-09 14:57:09 +02:00
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
|
2019-05-09 14:57:09 +02:00
|
|
|
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
|
2023-05-05 09:11:14 +01:00
|
|
|
this.autocompleter?.destroy();
|
2019-05-09 14:57:09 +02:00
|
|
|
this.autocompleter = new Autocompleter(this.props.room);
|
2017-11-02 17:51:08 +00:00
|
|
|
}
|
|
|
|
|
|
2017-06-28 17:27:21 +01:00
|
|
|
// Query hasn't changed so don't try to complete it
|
2019-05-09 14:57:09 +02:00
|
|
|
if (oldQuery === this.props.query) {
|
2016-07-03 01:11:34 +05:30
|
|
|
return;
|
|
|
|
|
}
|
2016-06-17 04:58:09 +05:30
|
|
|
|
2019-05-09 14:57:09 +02:00
|
|
|
this.complete(this.props.query, this.props.selection);
|
2017-06-28 17:27:21 +01:00
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentWillUnmount(): void {
|
2023-05-05 09:11:14 +01:00
|
|
|
this.autocompleter?.destroy();
|
2017-11-02 17:51:08 +00:00
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private complete(query: string, selection: ISelectionRange): Promise<void> {
|
2017-07-07 15:30:31 +01:00
|
|
|
this.queryRequested = query;
|
2017-06-28 17:27:21 +01:00
|
|
|
if (this.debounceCompletionsRequest) {
|
|
|
|
|
clearTimeout(this.debounceCompletionsRequest);
|
|
|
|
|
}
|
|
|
|
|
if (query === "") {
|
|
|
|
|
this.setState({
|
|
|
|
|
// Clear displayed completions
|
|
|
|
|
completions: [],
|
|
|
|
|
completionList: [],
|
|
|
|
|
// Reset selected completion
|
2021-02-16 17:56:09 +00:00
|
|
|
selectionOffset: 1,
|
2017-06-28 17:27:21 +01:00
|
|
|
// Hide the autocomplete box
|
|
|
|
|
hide: true,
|
|
|
|
|
});
|
2023-03-13 15:07:20 +00:00
|
|
|
return Promise.resolve();
|
2017-06-28 17:27:21 +01:00
|
|
|
}
|
2017-10-29 16:53:00 -06:00
|
|
|
let autocompleteDelay = SettingsStore.getValue("autocompleteDelay");
|
2017-06-28 17:27:21 +01:00
|
|
|
|
|
|
|
|
// Don't debounce if we are already showing completions
|
2017-07-04 17:49:50 +01:00
|
|
|
if (this.state.completions.length > 0 || this.state.forceComplete) {
|
2017-06-28 17:27:21 +01:00
|
|
|
autocompleteDelay = 0;
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-12 11:40:38 +00:00
|
|
|
return new Promise((resolve) => {
|
2022-11-30 11:32:56 +00:00
|
|
|
this.debounceCompletionsRequest = window.setTimeout(() => {
|
2019-11-12 11:40:38 +00:00
|
|
|
resolve(this.processQuery(query, selection));
|
|
|
|
|
}, autocompleteDelay);
|
|
|
|
|
});
|
2017-06-28 17:27:21 +01:00
|
|
|
}
|
|
|
|
|
|
2023-05-10 08:41:55 +01:00
|
|
|
private async processQuery(query: string, selection: ISelectionRange): Promise<void> {
|
2017-11-02 17:51:08 +00:00
|
|
|
return this.autocompleter
|
2023-05-10 08:41:55 +01:00
|
|
|
?.getCompletions(query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES)
|
2017-07-07 15:30:31 +01:00
|
|
|
.then((completions) => {
|
|
|
|
|
// Only ever process the completions for the most recent query being processed
|
|
|
|
|
if (query !== this.queryRequested) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.processCompletions(completions);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private processCompletions(completions: IProviderCompletions[]): void {
|
2017-02-10 03:40:57 +05:30
|
|
|
const completionList = flatMap(completions, (provider) => provider.completions);
|
2016-06-12 17:02:46 +05:30
|
|
|
|
2016-09-13 15:41:52 +05:30
|
|
|
// Reset selection when completion list becomes empty.
|
2021-02-16 17:56:09 +00:00
|
|
|
let selectionOffset = 1;
|
2016-09-13 15:41:52 +05:30
|
|
|
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.
|
|
|
|
|
*/
|
2021-02-16 17:56:09 +00:00
|
|
|
const currentSelection =
|
|
|
|
|
this.state.selectionOffset <= 1
|
|
|
|
|
? null
|
2016-09-13 15:41:52 +05:30
|
|
|
: this.state.completionList[this.state.selectionOffset - 1].completion;
|
2017-02-10 03:40:57 +05:30
|
|
|
selectionOffset = completionList.findIndex((completion) => completion.completion === currentSelection);
|
2016-09-13 15:41:52 +05:30
|
|
|
if (selectionOffset === -1) {
|
2021-02-16 17:56:09 +00:00
|
|
|
selectionOffset = 1;
|
2016-09-13 15:41:52 +05:30
|
|
|
} else {
|
|
|
|
|
selectionOffset++; // selectionOffset is 1-indexed!
|
2016-06-12 17:02:46 +05:30
|
|
|
}
|
2016-09-13 15:41:52 +05:30
|
|
|
}
|
|
|
|
|
|
2021-02-16 17:56:09 +00:00
|
|
|
let hide = true;
|
2017-07-07 15:30:31 +01:00
|
|
|
// If `completion.command.command` is truthy, then a provider has matched with the query
|
|
|
|
|
const anyMatches = completions.some((completion) => !!completion.command.command);
|
2021-02-16 17:56:09 +00:00
|
|
|
if (anyMatches) {
|
|
|
|
|
hide = false;
|
|
|
|
|
if (this.props.onSelectionChange) {
|
2021-05-20 22:25:19 +01:00
|
|
|
this.props.onSelectionChange(selectionOffset - 1);
|
2021-02-16 17:56:09 +00:00
|
|
|
}
|
|
|
|
|
}
|
2016-09-13 15:41:52 +05:30
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
|
completions,
|
|
|
|
|
completionList,
|
|
|
|
|
selectionOffset,
|
|
|
|
|
hide,
|
2017-06-28 17:27:21 +01:00
|
|
|
// Force complete is turned off each time since we can't edit the query in that case
|
|
|
|
|
forceComplete: false,
|
2016-06-01 16:54:21 +05:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
public hasSelection(): boolean {
|
2019-05-31 15:05:09 +02:00
|
|
|
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
public countCompletions(): number {
|
2016-09-13 15:41:52 +05:30
|
|
|
return this.state.completionList.length;
|
2016-07-03 01:11:34 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// called from MessageComposerInput
|
2021-07-15 10:09:24 +01:00
|
|
|
public moveSelection(delta: number): void {
|
2016-09-13 15:41:52 +05:30
|
|
|
const completionCount = this.countCompletions();
|
2019-05-14 10:13:04 +02:00
|
|
|
if (completionCount === 0) return; // there are no items to move the selection through
|
2016-06-21 18:33:39 +05:30
|
|
|
|
2019-05-14 10:13:04 +02:00
|
|
|
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
|
2021-02-16 17:56:09 +00:00
|
|
|
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
|
|
|
|
|
this.setSelection(1 + index);
|
2016-09-13 15:41:52 +05:30
|
|
|
}
|
|
|
|
|
|
2023-03-13 15:07:20 +00:00
|
|
|
public onEscape(e: KeyboardEvent): boolean | undefined {
|
2016-09-13 15:41:52 +05:30
|
|
|
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
|
2016-09-21 07:28:07 +05:30
|
|
|
this.hide();
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private hide = (): void => {
|
2020-04-20 19:00:54 +01:00
|
|
|
this.setState({
|
|
|
|
|
hide: true,
|
2021-02-16 17:56:09 +00:00
|
|
|
selectionOffset: 1,
|
2020-04-20 19:00:54 +01:00
|
|
|
completions: [],
|
|
|
|
|
completionList: [],
|
|
|
|
|
});
|
|
|
|
|
};
|
2016-09-13 15:41:52 +05:30
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
public forceComplete(): Promise<number> {
|
2019-11-12 11:40:38 +00:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
this.setState(
|
|
|
|
|
{
|
|
|
|
|
forceComplete: true,
|
|
|
|
|
hide: false,
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
this.complete(this.props.query, this.props.selection).then(() => {
|
|
|
|
|
resolve(this.countCompletions());
|
|
|
|
|
});
|
2016-09-13 18:02:33 +05:30
|
|
|
},
|
|
|
|
|
);
|
2016-09-13 15:41:52 +05:30
|
|
|
});
|
2016-06-21 18:33:39 +05:30
|
|
|
}
|
|
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
public onConfirmCompletion = (): void => {
|
2021-02-16 17:56:09 +00:00
|
|
|
this.onCompletionClicked(this.state.selectionOffset);
|
2021-07-15 10:09:24 +01:00
|
|
|
};
|
2021-02-16 17:56:09 +00:00
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private onCompletionClicked = (selectionOffset: number): boolean => {
|
2021-02-16 17:56:09 +00:00
|
|
|
const count = this.countCompletions();
|
|
|
|
|
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
|
2016-07-03 22:15:13 +05:30
|
|
|
return false;
|
2016-07-04 21:44:35 +05:30
|
|
|
}
|
2016-07-03 22:15:13 +05:30
|
|
|
|
2018-07-24 13:48:05 +01:00
|
|
|
this.props.onConfirm(this.state.completionList[selectionOffset - 1]);
|
2016-09-21 07:40:48 +05:30
|
|
|
this.hide();
|
2016-07-03 22:15:13 +05:30
|
|
|
|
|
|
|
|
return true;
|
2020-04-20 19:00:54 +01:00
|
|
|
};
|
2016-07-03 22:15:13 +05:30
|
|
|
|
2021-07-15 10:09:24 +01:00
|
|
|
private setSelection(selectionOffset: number): void {
|
2021-06-29 13:11:58 +01:00
|
|
|
this.setState({ selectionOffset, hide: false });
|
2017-08-23 16:22:14 +01:00
|
|
|
if (this.props.onSelectionChange) {
|
2021-05-20 22:25:19 +01:00
|
|
|
this.props.onSelectionChange(selectionOffset - 1);
|
2017-08-23 16:22:14 +01:00
|
|
|
}
|
2016-07-03 22:15:13 +05:30
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentDidUpdate(prevProps: IProps): void {
|
2020-04-21 10:01:05 +01:00
|
|
|
this.applyNewProps(prevProps.query, prevProps.room);
|
2016-08-17 17:27:19 +05:30
|
|
|
// this is the selected completion, so scroll it into view if needed
|
2020-05-30 13:30:59 +01:00
|
|
|
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`] as HTMLElement;
|
2020-05-29 21:42:33 +01:00
|
|
|
|
2020-05-30 13:30:59 +01:00
|
|
|
if (selectedCompletion) {
|
|
|
|
|
selectedCompletion.scrollIntoView({
|
2020-05-29 21:42:33 +01:00
|
|
|
behavior: "auto",
|
|
|
|
|
block: "nearest",
|
|
|
|
|
});
|
2020-06-03 10:39:28 +01:00
|
|
|
} else if (this.containerRef.current) {
|
2020-05-29 21:42:33 +01:00
|
|
|
this.containerRef.current.scrollTo({ top: 0 });
|
2016-08-17 17:27:19 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-13 17:01:43 +00:00
|
|
|
public render(): React.ReactNode {
|
2016-09-13 15:41:52 +05:30
|
|
|
let position = 1;
|
2017-02-10 03:40:57 +05:30
|
|
|
const renderedCompletions = this.state.completions
|
|
|
|
|
.map((completionResult, i) => {
|
2020-04-20 19:04:55 +01:00
|
|
|
const completions = completionResult.completions.map((completion, j) => {
|
2019-09-30 14:04:39 +01:00
|
|
|
const selected = position === this.state.selectionOffset;
|
2021-06-29 13:11:58 +01:00
|
|
|
const className = classNames("mx_Autocomplete_Completion", { selected });
|
2017-02-10 03:40:57 +05:30
|
|
|
const componentPosition = position;
|
2016-06-21 18:33:39 +05:30
|
|
|
position++;
|
2016-07-03 01:11:34 +05:30
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
const onClick = (): void => {
|
2018-07-24 13:48:05 +01:00
|
|
|
this.onCompletionClicked(componentPosition);
|
2016-07-04 21:44:35 +05:30
|
|
|
};
|
2016-07-03 22:15:13 +05:30
|
|
|
|
2016-08-17 17:27:19 +05:30
|
|
|
return React.cloneElement(completion.component, {
|
2020-04-20 19:04:55 +01:00
|
|
|
"key": j,
|
2019-09-30 14:04:39 +01:00
|
|
|
"ref": `completion${componentPosition}`,
|
|
|
|
|
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
|
2016-08-17 17:27:19 +05:30
|
|
|
className,
|
|
|
|
|
onClick,
|
2019-09-30 14:04:39 +01:00
|
|
|
"aria-selected": selected,
|
2022-12-12 12:24:14 +01:00
|
|
|
});
|
2016-08-17 17:27:19 +05:30
|
|
|
});
|
2016-06-12 17:02:46 +05:30
|
|
|
|
|
|
|
|
return completions.length > 0 ? (
|
2021-02-18 10:55:24 +00:00
|
|
|
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
|
2019-05-19 15:23:43 +01:00
|
|
|
<div className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</div>
|
2017-10-11 17:56:17 +01:00
|
|
|
{completionResult.provider.renderCompletions(completions)}
|
2016-06-01 16:54:21 +05:30
|
|
|
</div>
|
2016-06-12 17:02:46 +05:30
|
|
|
) : null;
|
2017-02-10 03:40:57 +05:30
|
|
|
})
|
|
|
|
|
.filter((completion) => !!completion);
|
2016-06-01 16:54:21 +05:30
|
|
|
|
2016-09-13 15:41:52 +05:30
|
|
|
return !this.state.hide && renderedCompletions.length > 0 ? (
|
2021-02-18 10:55:24 +00:00
|
|
|
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
|
2017-10-11 17:56:17 +01:00
|
|
|
{renderedCompletions}
|
2016-06-01 16:54:21 +05:30
|
|
|
</div>
|
2016-09-08 00:36:23 +05:30
|
|
|
) : null;
|
2016-06-01 16:54:21 +05:30
|
|
|
}
|
|
|
|
|
}
|