diff --git a/src/components/common/AutoComplete.tsx b/src/components/common/AutoComplete.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5908dcb65d0c3581876f769de16a1bc66f56cccb --- /dev/null +++ b/src/components/common/AutoComplete.tsx @@ -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> + ) +} diff --git a/src/components/markdown/Emoji.tsx b/src/components/common/Emoji.tsx similarity index 92% rename from src/components/markdown/Emoji.tsx rename to src/components/common/Emoji.tsx index b7043e7f07bfbde27f9892405b0d54dc3484b636..74e381b5aa161fa3f13892030ff7a4aaa068e4bc 100644 --- a/src/components/markdown/Emoji.tsx +++ b/src/components/common/Emoji.tsx @@ -24,7 +24,7 @@ function parseEmoji(emoji: string) { 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 ( <img alt={emoji} diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 87dfeedd82b9ad7de0cb6b8d8b167de524c10551..362c08758ccdc25b4cefb712faf5c83356620de0 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -10,6 +10,8 @@ import { QueuedMessage } from "../../../redux/reducers/queue"; import { MessageObject } from "../../../context/revoltjs/util"; import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase"; import Overline from "../../ui/Overline"; +import { useContext } from "preact/hooks"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; interface Props { attachContext?: boolean @@ -23,7 +25,8 @@ interface Props { export default function Message({ attachContext, message, contrast, content: replacement, head, queued }: Props) { // TODO: Can improve re-renders here by providing a list // 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; return ( @@ -31,6 +34,7 @@ export default function Message({ attachContext, message, contrast, content: rep head={head} contrast={contrast} sending={typeof queued !== 'undefined'} + mention={message.mentions?.includes(client.user!._id)} failed={typeof queued?.error !== 'undefined'} onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}> <MessageInfo> diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index e938e73cad90c157a5496347081b8553f9fe1b70..4260bdea8ec3516481eac28d6408bbcf3bf93c74 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -20,6 +20,7 @@ import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib import FilePreview from './bars/FilePreview'; import { debounce } from "../../../lib/debounce"; +import AutoComplete, { useAutoComplete } from "../AutoComplete"; type Props = WithDispatcher & { channel: Channel; @@ -226,9 +227,11 @@ function MessageBox({ channel, draft, dispatcher }: Props) { } const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]); + const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } = useAutoComplete(setMessage, { users: { type: 'channel', id: channel._id } }); return ( <> + <AutoComplete {...autoCompleteProps} /> <FilePreview state={uploadState} addFile={() => uploadState.type === 'attached' && grabFiles(20_000_000, files => setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] }), () => openScreen({ id: "error", error: "FileTooLarge" }), true)} @@ -271,7 +274,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) { padding={14} id="message" value={draft ?? ''} + onKeyUp={onKeyUp} onKeyDown={e => { + if (onKeyDown(e)) return; + if ( e.key === "ArrowUp" && (!draft || draft.length === 0) @@ -298,7 +304,10 @@ function MessageBox({ channel, draft, dispatcher }: Props) { onChange={e => { setMessage(e.currentTarget.value); startTyping(); - }} /> + onChange(e); + }} + onFocus={onFocus} + onBlur={onBlur} /> <Action> <IconButton onClick={send}> <Send size={20} /> diff --git a/src/components/markdown/Renderer.tsx b/src/components/markdown/Renderer.tsx index c2a859290d43a49b1dd540d377d7a057e73107d2..287bb4a5d7d0235658601d5140f4a9f004fdad97 100644 --- a/src/components/markdown/Renderer.tsx +++ b/src/components/markdown/Renderer.tsx @@ -1,10 +1,11 @@ import MarkdownIt from "markdown-it"; import { RE_MENTIONS } from "revolt.js"; -import { generateEmoji } from "./Emoji"; import { useContext } from "preact/hooks"; import { MarkdownProps } from "./Markdown"; import styles from "./Markdown.module.scss"; +import { generateEmoji } from "../common/Emoji"; import { internalEmit } from "../../lib/eventEmitter"; +import { emojiDictionary } from "../../assets/emojis"; import { AppContext } from "../../context/revoltjs/RevoltClient"; import Prism from "prismjs"; @@ -47,7 +48,7 @@ export const md: MarkdownIt = MarkdownIt({ } }) .disable("image") -.use(MarkdownEmoji/*, { defs: emojiDictionary }*/) +.use(MarkdownEmoji, { defs: emojiDictionary }) .use(MarkdownSpoilers) .use(MarkdownSup) .use(MarkdownSub) diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index da9c1571ebe9a1c923329fa514b09a795244f947..26c90bc79847e477632f0cc35eccf76ca96d5707 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -113,6 +113,11 @@ function Context({ auth, sync, children, dispatcher }: Props) { dispatcher({ type: "LOGOUT" }); delete client.user; + // ! FIXME: write procedure client.clear(); + client.users.clear(); + client.channels.clear(); + client.servers.clear(); + client.servers.members.clear(); dispatcher({ type: "RESET" }); openScreen({ id: "none" }); diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx index 3c58b23ffecfd2bf6c9012cbcb946f8ab77de87d..7e5566b33bfc0e96ab1508589e6c0ad4d1971979 100644 --- a/src/pages/settings/panes/Languages.tsx +++ b/src/pages/settings/panes/Languages.tsx @@ -1,10 +1,10 @@ import { Text } from "preact-i18n"; import styles from "./Panes.module.scss"; import Tip from "../../../components/ui/Tip"; +import Emoji from "../../../components/common/Emoji"; import Checkbox from "../../../components/ui/Checkbox"; import { connectState } from "../../../redux/connector"; import { WithDispatcher } from "../../../redux/reducers"; -import { Emoji } from "../../../components/markdown/Emoji"; import { Language, LanguageEntry, Languages as Langs } from "../../../context/Locale"; interface Props {