revite/src/components/common/AutoComplete.tsx

479 lines
16 KiB
TypeScript
Raw Normal View History

import { SYSTEM_USER_ID } from "revolt.js";
import { Channel } from "revolt.js/dist/maps/Channels";
import { User } from "revolt.js/dist/maps/Users";
2021-07-05 06:23:23 -04:00
import styled, { css } from "styled-components";
import { StateUpdater, useState } from "preact/hooks";
2021-07-05 06:23:23 -04:00
import { useClient } from "../../context/revoltjs/RevoltClient";
2021-07-05 06:23:23 -04:00
2021-06-22 08:28:03 -04:00
import { emojiDictionary } from "../../assets/emojis";
2021-06-22 08:35:43 -04:00
import ChannelIcon from "./ChannelIcon";
2021-07-05 06:23:23 -04:00
import Emoji from "./Emoji";
import UserIcon from "./user/UserIcon";
2021-06-22 08:28:03 -04:00
export type AutoCompleteState =
2021-07-05 06:25:20 -04:00
| { type: "none" }
| ({ selected: number; within: boolean } & (
| {
type: "emoji";
matches: string[];
}
| {
type: "user";
matches: User[];
}
| {
type: "channel";
2021-07-29 13:41:01 -04:00
matches: Channel[];
2021-07-05 06:25:20 -04:00
}
));
2021-06-22 08:28:03 -04:00
export type SearchClues = {
2021-07-05 06:25:20 -04:00
users?: { type: "channel"; id: string } | { type: "all" };
channels?: { server: string };
2021-06-22 08:28:03 -04:00
};
export type AutoCompleteProps = {
2021-07-05 06:25:20 -04:00
detached?: boolean;
state: AutoCompleteState;
setState: StateUpdater<AutoCompleteState>;
onKeyUp: (ev: KeyboardEvent) => void;
onKeyDown: (ev: KeyboardEvent) => boolean;
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>;
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>;
2021-07-05 06:23:23 -04:00
};
2021-06-22 08:28:03 -04:00
2021-07-05 06:23:23 -04:00
export function useAutoComplete(
2021-07-05 06:25:20 -04:00
setValue: (v?: string) => void,
searchClues?: SearchClues,
2021-07-05 06:23:23 -04:00
): AutoCompleteProps {
2021-07-05 06:25:20 -04:00
const [state, setState] = useState<AutoCompleteState>({ type: "none" });
const [focused, setFocused] = useState(false);
const client = useClient();
2021-07-05 06:25:20 -04:00
function findSearchString(
el: HTMLTextAreaElement,
): ["emoji" | "user" | "channel", string, number] | undefined {
if (el.selectionStart === el.selectionEnd) {
const cursor = el.selectionStart;
const content = el.value.slice(0, cursor);
2021-07-05 06:25:20 -04:00
const valid = /\w/;
2021-07-05 06:25:20 -04:00
let j = content.length - 1;
if (content[j] === "@") {
return ["user", "", j];
} else if (content[j] === "#") {
return ["channel", "", j];
}
while (j >= 0 && valid.test(content[j])) {
j--;
}
if (j === -1) return;
const current = content[j];
2021-07-05 06:25:20 -04:00
if (current === ":" || current === "@" || current === "#") {
const search = content.slice(j + 1, content.length);
2021-07-05 06:25:20 -04:00
if (search.length > 0) {
return [
current === "#"
? "channel"
: current === ":"
? "emoji"
: "user",
search.toLowerCase(),
j + 1,
];
}
}
}
}
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
const el = ev.currentTarget;
const result = findSearchString(el);
2021-07-05 06:25:20 -04:00
if (result) {
const [type, search] = result;
2021-07-05 06:25:20 -04:00
const regex = new RegExp(search, "i");
if (type === "emoji") {
// ! TODO: we should convert it to a Binary Search Tree and use that
const matches = Object.keys(emojiDictionary)
2021-07-05 06:25:20 -04:00
.filter((emoji: string) => emoji.match(regex))
.splice(0, 5);
if (matches.length > 0) {
const currentPosition =
2021-07-05 06:25:20 -04:00
state.type !== "none" ? state.selected : 0;
setState({
type: "emoji",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
if (type === "user" && searchClues?.users) {
let users: User[] = [];
switch (searchClues.users.type) {
case "all":
users = [...client.users.values()];
2021-07-05 06:25:20 -04:00
break;
case "channel": {
const channel = client.channels.get(
searchClues.users.id,
);
2021-07-05 06:25:20 -04:00
switch (channel?.channel_type) {
case "Group":
case "DirectMessage":
users = channel.recipients!.filter(
(x) => typeof x !== "undefined",
) as User[];
2021-07-05 06:25:20 -04:00
break;
case "TextChannel":
2021-08-05 09:47:00 -04:00
{
const server = channel.server_id;
users = [...client.members.keys()]
.map((x) => JSON.parse(x))
.filter((x) => x.server === server)
.map((x) => client.users.get(x.user))
.filter(
(x) => typeof x !== "undefined",
) as User[];
}
2021-07-05 06:25:20 -04:00
break;
default:
return;
}
}
}
users = users.filter((x) => x._id !== SYSTEM_USER_ID);
const matches = (
2021-07-05 06:25:20 -04:00
search.length > 0
? users.filter((user) =>
user.username.toLowerCase().match(regex),
)
: users
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) {
const currentPosition =
2021-07-05 06:25:20 -04:00
state.type !== "none" ? state.selected : 0;
setState({
type: "user",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
if (type === "channel" && searchClues?.channels) {
const channels = client.servers
2021-07-05 06:25:20 -04:00
.get(searchClues.channels.server)
2021-07-30 17:40:49 -04:00
?.channels.filter(
(x) => typeof x !== "undefined",
) as Channel[];
2021-07-05 06:25:20 -04:00
const matches = (
2021-07-05 06:25:20 -04:00
search.length > 0
? channels.filter((channel) =>
2021-07-29 13:41:01 -04:00
channel.name!.toLowerCase().match(regex),
2021-07-05 06:25:20 -04:00
)
: channels
)
.splice(0, 5)
.filter((x) => typeof x !== "undefined");
if (matches.length > 0) {
const currentPosition =
2021-07-05 06:25:20 -04:00
state.type !== "none" ? state.selected : 0;
setState({
type: "channel",
matches,
selected: Math.min(currentPosition, matches.length - 1),
within: false,
});
return;
}
}
}
if (state.type !== "none") {
setState({ type: "none" });
}
}
function selectCurrent(el: HTMLTextAreaElement) {
if (state.type !== "none") {
const result = findSearchString(el);
2021-07-05 06:25:20 -04:00
if (result) {
const [_type, search, index] = result;
2021-07-05 06:25:20 -04:00
const content = el.value.split("");
2021-07-05 06:25:20 -04:00
if (state.type === "emoji") {
content.splice(
index,
search.length,
state.matches[state.selected],
": ",
);
} else if (state.type === "user") {
content.splice(
index - 1,
search.length + 1,
"<@",
state.matches[state.selected]._id,
"> ",
);
} else {
content.splice(
index - 1,
search.length + 1,
"<#",
state.matches[state.selected]._id,
"> ",
);
}
setValue(content.join(""));
}
}
}
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
ev.preventDefault();
selectCurrent(document.querySelector("#message")!);
setFocused(false);
2021-07-05 06:25:20 -04:00
}
function onKeyDown(e: KeyboardEvent) {
if (focused && state.type !== "none") {
if (e.key === "ArrowUp") {
e.preventDefault();
if (state.selected > 0) {
setState({
...state,
selected: state.selected - 1,
});
}
return true;
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (state.selected < state.matches.length - 1) {
setState({
...state,
selected: state.selected + 1,
});
}
return true;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
selectCurrent(e.currentTarget as HTMLTextAreaElement);
return true;
}
}
return false;
}
function onKeyUp(e: KeyboardEvent) {
if (e.currentTarget !== null) {
2021-08-05 09:47:00 -04:00
// @ts-expect-error Type mis-match.
2021-07-05 06:25:20 -04:00
onChange(e);
}
}
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
setFocused(true);
onChange(ev);
}
function onBlur() {
if (state.type !== "none" && state.within) return;
setFocused(false);
}
return {
state: focused ? state : { type: "none" },
setState,
onClick,
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
};
2021-06-22 08:28:03 -04:00
}
2021-07-03 18:35:27 -04:00
const Base = styled.div<{ detached?: boolean }>`
2021-07-05 06:25:20 -04:00
position: relative;
> div {
bottom: 0;
width: 100%;
position: absolute;
background: var(--primary-header);
}
button {
gap: 8px;
margin: 4px;
padding: 6px;
border: none;
display: flex;
font-size: 1em;
cursor: pointer;
align-items: center;
flex-direction: row;
2021-07-10 10:42:13 -04:00
font-family: inherit;
2021-07-05 06:25:20 -04:00
background: transparent;
color: var(--foreground);
width: calc(100% - 12px);
2021-07-10 10:42:13 -04:00
border-radius: var(--border-radius);
2021-07-05 06:25:20 -04:00
span {
display: grid;
place-items: center;
}
&.active {
background: var(--primary-background);
}
}
${(props) =>
props.detached &&
css`
bottom: 8px;
> div {
2021-07-10 10:42:13 -04:00
border-radius: var(--border-radius);
2021-07-05 06:25:20 -04:00
}
`}
2021-06-22 08:28:03 -04:00
`;
2021-07-05 06:23:23 -04:00
export default function AutoComplete({
2021-07-05 06:25:20 -04:00
detached,
state,
setState,
onClick,
2021-07-05 06:23:23 -04:00
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
2021-07-05 06:25:20 -04:00
return (
<Base detached={detached}>
<div>
{state.type === "emoji" &&
state.matches.map((match, i) => (
<button
2021-08-05 09:47:00 -04:00
key={match}
2021-07-05 06:25:20 -04:00
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<Emoji
emoji={
(emojiDictionary as Record<string, string>)[
match
]
}
size={20}
/>
:{match}:
</button>
))}
{state.type === "user" &&
state.matches.map((match, i) => (
<button
2021-08-05 09:47:00 -04:00
key={match}
2021-07-05 06:25:20 -04:00
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<UserIcon size={24} target={match} status={true} />
{match.username}
</button>
))}
{state.type === "channel" &&
state.matches.map((match, i) => (
<button
2021-08-05 09:47:00 -04:00
key={match}
2021-07-05 06:25:20 -04:00
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={onClick}>
<ChannelIcon size={24} target={match} />
{match.name}
</button>
))}
</div>
</Base>
);
2021-06-22 08:28:03 -04:00
}