Add auto complete back.

This commit is contained in:
Paul 2021-06-22 13:28:03 +01:00
parent 56dda66c1c
commit f724cfdf4f
7 changed files with 389 additions and 6 deletions

View file

@ -0,0 +1,364 @@
import { StateUpdater, useContext, useState } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis";
import UserIcon from "./user/UserIcon";
import styled from "styled-components";
import { SYSTEM_USER_ID, User } from "revolt.js";
import Emoji from "./Emoji";
export type AutoCompleteState =
| { type: "none" }
| {
type: "emoji";
matches: string[];
selected: number;
within: boolean;
}
| {
type: "user";
matches: User[];
selected: number;
within: boolean;
};
export type SearchClues = {
users?: { type: 'channel', id: string } | { type: 'all' }
};
export type AutoCompleteProps = {
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>
}
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", 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
];
}
while (j >= 0 && valid.test(content[j])) {
j--;
}
if (j === -1) return;
let current = content[j];
if (current === ":" || current === "@") {
let search = content.slice(j + 1, content.length);
if (search.length > 0) {
return [
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 (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 {
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`
position: relative;
> div {
bottom: 0;
width: 100%;
position: absolute;
background: var(--primary-header);
}
button {
gap: 8px;
margin: 4px;
padding: 6px;
border: none;
display: flex;
cursor: pointer;
border-radius: 6px;
flex-direction: row;
background: transparent;
color: var(--foreground);
width: calc(100% - 12px);
span {
display: grid;
place-items: center;
}
&.active {
background: var(--primary-background);
}
}
`;
export default function AutoComplete({ state, setState, onClick }: Pick<AutoCompleteProps, 'state' | 'setState' | 'onClick'>) {
return (
<Base>
<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 any)[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>
))}
</div>
</Base>
)
}

View file

@ -24,7 +24,7 @@ function parseEmoji(emoji: string) {
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`; return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
} }
export function Emoji({ emoji, size }: { emoji: string, size?: number }) { export default function Emoji({ emoji, size }: { emoji: string, size?: number }) {
return ( return (
<img <img
alt={emoji} alt={emoji}

View file

@ -10,6 +10,8 @@ import { QueuedMessage } from "../../../redux/reducers/queue";
import { MessageObject } from "../../../context/revoltjs/util"; import { MessageObject } from "../../../context/revoltjs/util";
import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase"; import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
import Overline from "../../ui/Overline"; import Overline from "../../ui/Overline";
import { useContext } from "preact/hooks";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
interface Props { interface Props {
attachContext?: boolean attachContext?: boolean
@ -23,7 +25,8 @@ interface Props {
export default function Message({ attachContext, message, contrast, content: replacement, head, queued }: Props) { export default function Message({ attachContext, message, contrast, content: replacement, head, queued }: Props) {
// TODO: Can improve re-renders here by providing a list // TODO: Can improve re-renders here by providing a list
// TODO: of dependencies. We only need to update on u/avatar. // TODO: of dependencies. We only need to update on u/avatar.
let user = useUser(message.author); const user = useUser(message.author);
const client = useContext(AppContext);
const content = message.content as string; const content = message.content as string;
return ( return (
@ -31,6 +34,7 @@ export default function Message({ attachContext, message, contrast, content: rep
head={head} head={head}
contrast={contrast} contrast={contrast}
sending={typeof queued !== 'undefined'} sending={typeof queued !== 'undefined'}
mention={message.mentions?.includes(client.user!._id)}
failed={typeof queued?.error !== 'undefined'} failed={typeof queued?.error !== 'undefined'}
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}>
<MessageInfo> <MessageInfo>

View file

@ -20,6 +20,7 @@ import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib
import FilePreview from './bars/FilePreview'; import FilePreview from './bars/FilePreview';
import { debounce } from "../../../lib/debounce"; import { debounce } from "../../../lib/debounce";
import AutoComplete, { useAutoComplete } from "../AutoComplete";
type Props = WithDispatcher & { type Props = WithDispatcher & {
channel: Channel; channel: Channel;
@ -226,9 +227,11 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
} }
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]); const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = useAutoComplete(setMessage, { users: { type: 'channel', id: channel._id } });
return ( return (
<> <>
<AutoComplete {...autoCompleteProps} />
<FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' && <FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' &&
grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }), grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }),
() => openScreen({ id: "error", error: "FileTooLarge" }), true)} () => openScreen({ id: "error", error: "FileTooLarge" }), true)}
@ -271,7 +274,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
padding={14} padding={14}
id="message" id="message"
value={draft ?? ''} value={draft ?? ''}
onKeyUp={onKeyUp}
onKeyDown={e => { onKeyDown={e => {
if (onKeyDown(e)) return;
if ( if (
e.key === "ArrowUp" && e.key === "ArrowUp" &&
(!draft || draft.length === 0) (!draft || draft.length === 0)
@ -298,7 +304,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) {
onChange={e => { onChange={e => {
setMessage(e.currentTarget.value); setMessage(e.currentTarget.value);
startTyping(); startTyping();
}} /> onChange(e);
}}
onFocus={onFocus}
onBlur={onBlur} />
<Action> <Action>
<IconButton onClick={send}> <IconButton onClick={send}>
<Send size={20} /> <Send size={20} />

View file

@ -1,10 +1,11 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { RE_MENTIONS } from "revolt.js"; import { RE_MENTIONS } from "revolt.js";
import { generateEmoji } from "./Emoji";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { MarkdownProps } from "./Markdown"; import { MarkdownProps } from "./Markdown";
import styles from "./Markdown.module.scss"; import styles from "./Markdown.module.scss";
import { generateEmoji } from "../common/Emoji";
import { internalEmit } from "../../lib/eventEmitter"; import { internalEmit } from "../../lib/eventEmitter";
import { emojiDictionary } from "../../assets/emojis";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { AppContext } from "../../context/revoltjs/RevoltClient";
import Prism from "prismjs"; import Prism from "prismjs";
@ -47,7 +48,7 @@ export const md: MarkdownIt = MarkdownIt({
} }
}) })
.disable("image") .disable("image")
.use(MarkdownEmoji/*, { defs: emojiDictionary }*/) .use(MarkdownEmoji, { defs: emojiDictionary })
.use(MarkdownSpoilers) .use(MarkdownSpoilers)
.use(MarkdownSup) .use(MarkdownSup)
.use(MarkdownSub) .use(MarkdownSub)

View file

@ -113,6 +113,11 @@ function Context({ auth, sync, children, dispatcher }: Props) {
dispatcher({ type: "LOGOUT" }); dispatcher({ type: "LOGOUT" });
delete client.user; delete client.user;
// ! FIXME: write procedure client.clear();
client.users.clear();
client.channels.clear();
client.servers.clear();
client.servers.members.clear();
dispatcher({ type: "RESET" }); dispatcher({ type: "RESET" });
openScreen({ id: "none" }); openScreen({ id: "none" });

View file

@ -1,10 +1,10 @@
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import styles from "./Panes.module.scss"; import styles from "./Panes.module.scss";
import Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
import Emoji from "../../../components/common/Emoji";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { WithDispatcher } from "../../../redux/reducers"; import { WithDispatcher } from "../../../redux/reducers";
import { Emoji } from "../../../components/markdown/Emoji";
import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale"; import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale";
interface Props { interface Props {