Skip to content
Snippets Groups Projects
AutoComplete.tsx 10.1 KiB
Newer Older
insert's avatar
insert committed
import { SYSTEM_USER_ID, User } from "revolt.js";
import { Channels } from "revolt.js/dist/api/objects";
import styled, { css } from "styled-components";

insert's avatar
insert committed
import { StateUpdater, useContext, useState } from "preact/hooks";
insert's avatar
insert committed

insert's avatar
insert committed
import { AppContext } from "../../context/revoltjs/RevoltClient";
insert's avatar
insert committed

insert's avatar
insert committed
import { emojiDictionary } from "../../assets/emojis";
insert's avatar
insert committed
import ChannelIcon from "./ChannelIcon";
insert's avatar
insert committed
import Emoji from "./Emoji";
import UserIcon from "./user/UserIcon";
insert's avatar
insert committed

export type AutoCompleteState =
insert's avatar
insert committed
	| { type: "none" }
	| ({ selected: number; within: boolean } & (
			| {
					type: "emoji";
					matches: string[];
			  }
			| {
					type: "user";
					matches: User[];
			  }
			| {
					type: "channel";
					matches: Channels.TextChannel[];
			  }
	  ));
insert's avatar
insert committed

export type SearchClues = {
insert's avatar
insert committed
	users?: { type: "channel"; id: string } | { type: "all" };
	channels?: { server: string };
insert's avatar
insert committed
};

export type AutoCompleteProps = {
insert's avatar
insert committed
	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>;
};
insert's avatar
insert committed

insert's avatar
insert committed
export function useAutoComplete(
	setValue: (v?: string) => void,
	searchClues?: SearchClues,
): AutoCompleteProps {
	const [state, setState] = useState<AutoCompleteState>({ type: "none" });
	const [focused, setFocused] = useState(false);
	const client = useContext(AppContext);

	function findSearchString(
		el: HTMLTextAreaElement,
	): ["emoji" | "user" | "channel", string, number] | undefined {
		if (el.selectionStart === el.selectionEnd) {
			let cursor = el.selectionStart;
			let content = el.value.slice(0, cursor);

			let valid = /\w/;

			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;
			let current = content[j];

			if (current === ":" || current === "@" || current === "#") {
				let search = content.slice(j + 1, content.length);
				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;

		let result = findSearchString(el);
		if (result) {
			let [type, search] = result;
			const regex = new RegExp(search, "i");

			if (type === "emoji") {
				// ! FIXME: we should convert it to a Binary Search Tree and use that
				let matches = Object.keys(emojiDictionary)
					.filter((emoji: string) => emoji.match(regex))
					.splice(0, 5);

				if (matches.length > 0) {
					let currentPosition =
						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.toArray();
						break;
					case "channel": {
						let channel = client.channels.get(searchClues.users.id);
						switch (channel?.channel_type) {
							case "Group":
							case "DirectMessage":
								users = client.users
									.mapKeys(channel.recipients)
									.filter(
										(x) => typeof x !== "undefined",
									) as User[];
								break;
							case "TextChannel":
								const server = channel.server;
								users = client.servers.members
									.toArray()
									.filter(
										(x) => x._id.substr(0, 26) === server,
									)
									.map((x) =>
										client.users.get(x._id.substr(26)),
									)
									.filter(
										(x) => typeof x !== "undefined",
									) as User[];
								break;
							default:
								return;
						}
					}
				}

				users = users.filter((x) => x._id !== SYSTEM_USER_ID);

				let matches = (
					search.length > 0
						? users.filter((user) =>
								user.username.toLowerCase().match(regex),
						  )
						: users
				)
					.splice(0, 5)
					.filter((x) => typeof x !== "undefined");

				if (matches.length > 0) {
					let currentPosition =
						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) {
				let channels = client.servers
					.get(searchClues.channels.server)
					?.channels.map((x) => client.channels.get(x))
					.filter(
						(x) => typeof x !== "undefined",
					) as Channels.TextChannel[];

				let matches = (
					search.length > 0
						? channels.filter((channel) =>
								channel.name.toLowerCase().match(regex),
						  )
						: channels
				)
					.splice(0, 5)
					.filter((x) => typeof x !== "undefined");

				if (matches.length > 0) {
					let currentPosition =
						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") {
			let result = findSearchString(el);
			if (result) {
				let [_type, search, index] = result;

				let content = el.value.split("");
				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")!);
	}

	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) {
			// @ts-expect-error
			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,
	};
const Base = styled.div<{ detached?: boolean }>`
insert's avatar
insert committed
	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;
		border-radius: 6px;
		align-items: center;
		flex-direction: row;
		background: transparent;
		color: var(--foreground);
		width: calc(100% - 12px);

		span {
			display: grid;
			place-items: center;
		}

		&.active {
			background: var(--primary-background);
		}
	}

	${(props) =>
		props.detached &&
		css`
			bottom: 8px;

			> div {
				border-radius: 4px;
			}
		`}
insert's avatar
insert committed
`;

insert's avatar
insert committed
export default function AutoComplete({
	detached,
	state,
	setState,
	onClick,
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
	return (
		<Base detached={detached}>
			<div>
				{state.type === "emoji" &&
					state.matches.map((match, i) => (
						<button
							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
							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
							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>
	);
insert's avatar
insert committed
}