Files
ThreadNet-Web/src/components/views/rooms/Autocomplete.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

329 lines
12 KiB
TypeScript
Raw Normal View History

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
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2017-11-02 18:01:28 +00:00
*/
import React, { createRef, RefObject } 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";
import { Room } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
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";
import RoomContext from "../../../contexts/RoomContext";
2016-06-01 16:54:21 +05:30
const MAX_PROVIDER_MATCHES = 20;
2016-09-13 15:41:52 +05:30
export const generateCompletionDomId = (n: number): string => `mx_Autocomplete_Completion_${n}`;
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> {
public autocompleter?: Autocompleter;
public queryRequested?: string;
public debounceCompletionsRequest?: number;
private containerRef = createRef<HTMLDivElement>();
private completionRefs: Record<string, RefObject<HTMLElement>> = {};
2020-04-20 19:00:54 +01:00
public static contextType = RoomContext;
2024-12-02 09:39:36 +00:00
declare public context: React.ContextType<typeof RoomContext>;
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
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!)
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
};
}
public componentDidMount(): void {
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) {
this.autocompleter?.destroy();
2019-05-09 14:57:09 +02:00
this.autocompleter = new Autocompleter(this.props.room);
}
// 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;
}
2019-05-09 14:57:09 +02:00
this.complete(this.props.query, this.props.selection);
}
public componentWillUnmount(): void {
this.autocompleter?.destroy();
}
2021-07-15 10:09:24 +01:00
private complete(query: string, selection: ISelectionRange): Promise<void> {
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();
}
2017-10-29 16:53:00 -06:00
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) => {
2022-11-30 11:32:56 +00:00
this.debounceCompletionsRequest = window.setTimeout(() => {
resolve(this.processQuery(query, selection));
}, autocompleteDelay);
});
}
private async processQuery(query: string, selection: ISelectionRange): Promise<void> {
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<void> {
const completionList = flatMap(completions, (provider) => provider.completions);
2016-09-13 15:41:52 +05:30
// Reset selection when completion list becomes empty.
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.
*/
const currentSelection =
this.state.selectionOffset <= 1
? null
2016-09-13 15:41:52 +05:30
: this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex((completion) => completion.completion === currentSelection);
2016-09-13 15:41:52 +05:30
if (selectionOffset === -1) {
selectionOffset = 1;
2016-09-13 15:41:52 +05:30
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
2016-09-13 15:41:52 +05:30
}
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) {
2021-05-20 22:25:19 +01:00
this.props.onSelectionChange(selectionOffset - 1);
}
}
2016-09-13 15:41:52 +05:30
const deferred = defer<void>();
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;
2016-06-01 16:54:21 +05:30
}
2021-07-15 10:09:24 +01:00
public hasSelection(): boolean {
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();
if (completionCount === 0) return; // there are no items to move the selection through
2016-06-21 18:33:39 +05:30
// 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);
2016-09-13 15:41:52 +05:30
}
public onEscape(e: KeyboardEvent | React.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
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,
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> {
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 15:41:52 +05:30
});
2016-06-21 18:33:39 +05:30
}
2021-07-15 10:09:24 +01:00
public onConfirmCompletion = (): void => {
this.onCompletionClicked(this.state.selectionOffset);
2021-07-15 10:09:24 +01:00
};
2021-07-15 10:09:24 +01:00
private onCompletionClicked = (selectionOffset: number): boolean => {
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
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
}
public componentDidUpdate(prevProps: IProps): void {
2020-04-21 10:01:05 +01:00
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 {
2016-09-13 15:41:52 +05:30
let position = 1;
const renderedCompletions = this.state.completions
.map((completionResult, i) => {
2020-04-20 19:04:55 +01:00
const completions = completionResult.completions.map((completion, j) => {
const selected = position === this.state.selectionOffset;
2021-06-29 13:11:58 +01:00
const className = classNames("mx_Autocomplete_Completion", { selected });
const componentPosition = position;
2016-06-21 18:33:39 +05:30
position++;
2016-07-03 01:11:34 +05:30
const onClick = (): void => {
this.onCompletionClicked(componentPosition);
2016-07-04 21:44:35 +05:30
};
2016-07-03 22:15:13 +05:30
const refId = `completion${componentPosition}`;
if (!this.completionRefs[refId]) {
this.completionRefs[refId] = createRef();
}
return React.cloneElement(completion.component, {
2020-04-20 19:04:55 +01:00
"key": j,
"ref": this.completionRefs[refId],
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className,
onClick,
"aria-selected": selected,
2022-12-12 12:24:14 +01:00
});
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<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>
) : null;
})
.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 ? (
<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>
) : null;
2016-06-01 16:54:21 +05:30
}
}