2023-06-23 10:52:03 +00:00
|
|
|
import {offset, type Offset} from "caret-pos";
|
|
|
|
import {Component} from "preact";
|
2023-06-27 11:51:04 +00:00
|
|
|
import {type UserLabelsData} from "../../storage/exports.js";
|
2023-06-23 10:52:03 +00:00
|
|
|
import {log, querySelectorAll} from "../../utilities/exports.js";
|
2020-10-10 23:32:27 +00:00
|
|
|
|
|
|
|
type Props = {
|
2023-12-15 14:16:00 +00:00
|
|
|
/**
|
|
|
|
* Whether the Anonymize Usernames feature is enabled, in which case this
|
|
|
|
* feature needs to handle collecting usernames a little differently.
|
|
|
|
*/
|
2023-06-23 10:52:03 +00:00
|
|
|
anonymizeUsernamesEnabled: boolean;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** The list of known groups to use for the group autocompletions. */
|
2023-06-23 10:52:03 +00:00
|
|
|
knownGroups: Set<string>;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* All the User Labels the user has saved to use for additional username
|
|
|
|
* completions.
|
|
|
|
*/
|
2023-06-23 10:52:03 +00:00
|
|
|
userLabels: UserLabelsData;
|
2020-10-10 23:32:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
type State = {
|
2023-12-15 14:16:00 +00:00
|
|
|
/** All the groups without leading tildes. */
|
2020-10-10 23:32:27 +00:00
|
|
|
groups: Set<string>;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** Whether the group autocompletion list is hidden or not. */
|
2020-10-10 23:32:27 +00:00
|
|
|
groupsHidden: boolean;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** The current set of group matches. */
|
2020-10-10 23:32:27 +00:00
|
|
|
groupsMatches: Set<string>;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** The position where the group autocompletion list should be shown. */
|
2022-02-23 13:52:06 +00:00
|
|
|
groupsPosition: Offset | undefined;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** All the usernames without leading @-symbols. */
|
2020-10-10 23:32:27 +00:00
|
|
|
usernames: Set<string>;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** Whether the username autocompletion list is hidden or not. */
|
2020-10-10 23:32:27 +00:00
|
|
|
usernamesHidden: boolean;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** The current set of username matches. */
|
2020-10-10 23:32:27 +00:00
|
|
|
usernamesMatches: Set<string>;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
|
|
|
/** The position where the username autocompletion list should be shown. */
|
2022-02-23 13:52:06 +00:00
|
|
|
usernamesPosition: Offset | undefined;
|
2020-10-10 23:32:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
export class AutocompleteFeature extends Component<Props, State> {
|
|
|
|
constructor(props: Props) {
|
|
|
|
super(props);
|
|
|
|
|
|
|
|
// Get all the groups without their leading tildes.
|
2023-06-23 10:52:03 +00:00
|
|
|
const groups = Array.from(props.knownGroups).map((value) =>
|
|
|
|
value.startsWith("~") ? value.slice(1) : value,
|
2020-10-10 23:32:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
const usernames = [
|
2023-12-15 14:16:00 +00:00
|
|
|
// Get all the usernames on the page without their leading @-symbols.
|
|
|
|
...querySelectorAll<HTMLElement>(".link-user").map((value) => {
|
2023-06-23 10:52:03 +00:00
|
|
|
if (props.anonymizeUsernamesEnabled) {
|
|
|
|
return (value.dataset.trxUsername ?? "<unknown>").toLowerCase();
|
2022-02-24 12:46:51 +00:00
|
|
|
}
|
|
|
|
|
2023-06-23 10:52:03 +00:00
|
|
|
return value.textContent!.replace(/^@/, "").toLowerCase();
|
2022-02-24 12:46:51 +00:00
|
|
|
}),
|
2023-12-15 14:16:00 +00:00
|
|
|
// Get all the usernames from the saved User Labels.
|
2023-06-26 10:10:08 +00:00
|
|
|
...props.userLabels.map(({value}) => value.username),
|
2020-10-10 23:32:27 +00:00
|
|
|
].sort((a, b) => a.localeCompare(b));
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
groups: new Set(groups),
|
|
|
|
groupsHidden: true,
|
|
|
|
groupsMatches: new Set(groups),
|
2022-02-23 13:52:06 +00:00
|
|
|
groupsPosition: undefined,
|
2020-10-10 23:32:27 +00:00
|
|
|
usernames: new Set(usernames),
|
|
|
|
usernamesHidden: true,
|
|
|
|
usernamesMatches: new Set(usernames),
|
2022-02-23 13:52:06 +00:00
|
|
|
usernamesPosition: undefined,
|
2020-10-10 23:32:27 +00:00
|
|
|
};
|
|
|
|
|
2023-06-23 10:52:03 +00:00
|
|
|
document.addEventListener("keydown", this.globalInputHandler);
|
2023-08-14 13:09:32 +00:00
|
|
|
document.addEventListener("compositionupdate", this.globalInputHandler);
|
2020-10-10 23:32:27 +00:00
|
|
|
|
|
|
|
log(
|
|
|
|
`Autocomplete: Initialized with ${this.state.groups.size} groups and ` +
|
2022-02-23 13:52:06 +00:00
|
|
|
`${this.state.usernames.size} usernames.`,
|
2020-10-10 23:32:27 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
/**
|
|
|
|
* The global input handler for `keydown` and `compositionupdate` events.
|
|
|
|
*
|
|
|
|
* See https://gitlab.com/tildes-community/tildes-reextended/-/issues/31 for
|
|
|
|
* why we also need to listen for `compositionupdate`.
|
|
|
|
*/
|
2023-08-14 13:09:32 +00:00
|
|
|
globalInputHandler = (event: CompositionEvent | KeyboardEvent) => {
|
2023-12-14 12:11:02 +00:00
|
|
|
const textarea = event.target;
|
|
|
|
|
2020-10-10 23:32:27 +00:00
|
|
|
// Only add the autocompletes to textareas.
|
2023-12-14 12:11:02 +00:00
|
|
|
if (!(textarea instanceof HTMLTextAreaElement)) {
|
2020-10-10 23:32:27 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Helper function to create autocompletes with.
|
|
|
|
const createHandler = (
|
|
|
|
prefix: string,
|
|
|
|
target: string,
|
2022-02-23 13:52:06 +00:00
|
|
|
values: Set<string>,
|
2020-10-10 23:32:27 +00:00
|
|
|
) => {
|
|
|
|
const dataAttribute = `data-trx-autocomplete-${target}`;
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
// Get the key that was pressed.
|
2023-08-14 13:09:32 +00:00
|
|
|
const key = event instanceof KeyboardEvent ? event.key : event.data;
|
2023-12-15 14:16:00 +00:00
|
|
|
|
2023-12-14 12:11:02 +00:00
|
|
|
if (key === prefix && !textarea.getAttribute(dataAttribute)) {
|
|
|
|
textarea.setAttribute(dataAttribute, "true");
|
|
|
|
textarea.addEventListener("keyup", (event) => {
|
|
|
|
if (!(event.target instanceof HTMLTextAreaElement)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.textareaInputHandler(event.target, prefix, target, values);
|
2020-12-16 16:46:20 +00:00
|
|
|
});
|
2020-10-10 23:32:27 +00:00
|
|
|
|
2023-12-14 12:11:02 +00:00
|
|
|
this.textareaInputHandler(textarea, prefix, target, values);
|
2020-10-10 23:32:27 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-06-23 10:52:03 +00:00
|
|
|
createHandler("~", "groups", this.state.groups);
|
|
|
|
createHandler("@", "usernames", this.state.usernames);
|
2020-10-10 23:32:27 +00:00
|
|
|
};
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
/** The input handler for any `<textarea>` elements. */
|
2020-10-10 23:32:27 +00:00
|
|
|
textareaInputHandler = (
|
2023-12-14 12:11:02 +00:00
|
|
|
textarea: HTMLTextAreaElement,
|
2020-10-10 23:32:27 +00:00
|
|
|
prefix: string,
|
|
|
|
target: string,
|
2022-02-23 13:52:06 +00:00
|
|
|
values: Set<string>,
|
2020-10-10 23:32:27 +00:00
|
|
|
) => {
|
|
|
|
const text = textarea.value;
|
|
|
|
|
|
|
|
// If the prefix isn't in the textarea, return early.
|
|
|
|
if (!text.includes(prefix)) {
|
|
|
|
this.hide(target);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Grab the starting position of the caret (text cursor).
|
|
|
|
const position = textarea.selectionStart;
|
|
|
|
|
|
|
|
// Grab the last index of the prefix inside the beginning of the textarea
|
|
|
|
// and the starting position of the caret.
|
|
|
|
const prefixIndex = text.slice(0, position).lastIndexOf(prefix);
|
|
|
|
|
|
|
|
// Grab the input between the prefix and the caret position, which will be
|
|
|
|
// what the user is currently typing.
|
|
|
|
const input = text.slice(prefixIndex + prefix.length, position);
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
// If there is any whitespace in the input, return early. Usernames and
|
|
|
|
// groups cannot have whitespace in them which means that the user has
|
|
|
|
// finished typing what the autocomplete should handle.
|
|
|
|
if (/\s/.test(input)) {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.hide(target);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
// Find any values that match using case-insensitive includes.
|
2020-10-10 23:32:27 +00:00
|
|
|
const matches = new Set<string>(
|
2023-12-14 12:11:02 +00:00
|
|
|
[...values].filter((value) =>
|
|
|
|
value.toLowerCase().includes(input.toLowerCase()),
|
|
|
|
),
|
2020-10-10 23:32:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
// If there are no matches, return early.
|
|
|
|
if (matches.size === 0) {
|
|
|
|
this.hide(target);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Otherwise make sure the list is shown in the correct place and also
|
|
|
|
// has all the new matches.
|
|
|
|
this.show(target, offset(textarea));
|
|
|
|
this.update(target, matches);
|
|
|
|
};
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
/** Update the available matches. */
|
2020-10-10 23:32:27 +00:00
|
|
|
update = (target: string, matches: Set<string>) => {
|
2023-06-23 10:52:03 +00:00
|
|
|
if (target === "groups") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({
|
2022-02-23 13:52:06 +00:00
|
|
|
groupsMatches: matches,
|
2020-10-10 23:32:27 +00:00
|
|
|
});
|
2023-06-23 10:52:03 +00:00
|
|
|
} else if (target === "usernames") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({
|
2022-02-23 13:52:06 +00:00
|
|
|
usernamesMatches: matches,
|
2020-10-10 23:32:27 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
/** Show the autocomplete list in the given position. */
|
2020-10-10 23:32:27 +00:00
|
|
|
show = (target: string, position: Offset) => {
|
2023-06-23 10:52:03 +00:00
|
|
|
if (target === "groups") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({
|
|
|
|
groupsHidden: false,
|
2022-02-23 13:52:06 +00:00
|
|
|
groupsPosition: position,
|
2020-10-10 23:32:27 +00:00
|
|
|
});
|
2023-06-23 10:52:03 +00:00
|
|
|
} else if (target === "usernames") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({
|
|
|
|
usernamesHidden: false,
|
2022-02-23 13:52:06 +00:00
|
|
|
usernamesPosition: position,
|
2020-10-10 23:32:27 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
/** Hide the autocomplete list. */
|
2020-10-10 23:32:27 +00:00
|
|
|
hide = (target: string) => {
|
2023-06-23 10:52:03 +00:00
|
|
|
if (target === "groups") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({groupsHidden: true});
|
2023-06-23 10:52:03 +00:00
|
|
|
} else if (target === "usernames") {
|
2020-10-10 23:32:27 +00:00
|
|
|
this.setState({usernamesHidden: true});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
render() {
|
2023-12-15 14:16:00 +00:00
|
|
|
// Create the `<li>` elements for groups and usernames.
|
2023-06-23 10:52:03 +00:00
|
|
|
const groups = [...this.state.groupsMatches].map((value) => (
|
|
|
|
<li>~{value}</li>
|
|
|
|
));
|
|
|
|
const usernames = [...this.state.usernamesMatches].map((value) => (
|
|
|
|
<li>@{value}</li>
|
|
|
|
));
|
2020-10-10 23:32:27 +00:00
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
// Figure out which lists are hidden.
|
2023-06-23 10:52:03 +00:00
|
|
|
const groupsHidden = this.state.groupsHidden ? "trx-hidden" : "";
|
|
|
|
const usernamesHidden = this.state.usernamesHidden ? "trx-hidden" : "";
|
2020-10-10 23:32:27 +00:00
|
|
|
|
2023-12-15 14:16:00 +00:00
|
|
|
// Calculate the position for the `<ul>` elements.
|
2020-10-10 23:32:27 +00:00
|
|
|
const groupsLeft = this.state.groupsPosition?.left ?? 0;
|
|
|
|
const groupsTop =
|
|
|
|
(this.state.groupsPosition?.top ?? 0) +
|
|
|
|
(this.state.groupsPosition?.height ?? 0);
|
|
|
|
|
|
|
|
const usernamesLeft = this.state.usernamesPosition?.left ?? 0;
|
|
|
|
const usernamesTop =
|
|
|
|
(this.state.usernamesPosition?.top ?? 0) +
|
|
|
|
(this.state.usernamesPosition?.height ?? 0);
|
|
|
|
|
2023-06-23 10:52:03 +00:00
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<ul
|
|
|
|
id="trx-autocomplete-usernames"
|
|
|
|
class={`trx-autocomplete ${usernamesHidden}`}
|
|
|
|
style={`left: ${usernamesLeft}px; top: ${usernamesTop}px`}
|
|
|
|
>
|
|
|
|
{usernames}
|
|
|
|
</ul>
|
|
|
|
<ul
|
|
|
|
id="trx-autocomplete-groups"
|
|
|
|
class={`trx-autocomplete ${groupsHidden}`}
|
|
|
|
style={`left: ${groupsLeft}px; top: ${groupsTop}px`}
|
|
|
|
>
|
|
|
|
{groups}
|
|
|
|
</ul>
|
|
|
|
</>
|
|
|
|
);
|
2020-10-10 23:32:27 +00:00
|
|
|
}
|
|
|
|
}
|