Skip to content
Snippets Groups Projects
MessageBox.tsx 14.9 KiB
Newer Older
insert's avatar
insert committed
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import Tooltip, { PermissionTooltip } from "../Tooltip";
import { Channel } from "revolt.js";
import styled from "styled-components";
insert's avatar
insert committed
import { defer } from "../../../lib/defer";
insert's avatar
insert committed
import IconButton from "../../ui/IconButton";
import { Send, X } from '@styled-icons/boxicons-regular';
insert's avatar
insert committed
import { debounce } from "../../../lib/debounce";
import Axios, { CancelTokenSource } from "axios";
import { useTranslation } from "../../../lib/i18n";
insert's avatar
insert committed
import { Reply } from "../../../redux/reducers/queue";
import { connectState } from "../../../redux/connector";
import { SoundContext } from "../../../context/Settings";
import { WithDispatcher } from "../../../redux/reducers";
import { takeError } from "../../../context/revoltjs/util";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { ChannelPermission } from "revolt.js/dist/api/permissions";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { useChannelPermission } from "../../../context/revoltjs/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
insert's avatar
insert committed
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
import { ShieldX } from "@styled-icons/boxicons-regular";
insert's avatar
insert committed
import ReplyBar from "./bars/ReplyBar";
import FilePreview from './bars/FilePreview';
nizune's avatar
nizune committed
import { Styleshare } from "@styled-icons/simple-icons";
Jan 0660's avatar
Jan 0660 committed
import owoify from "owoify-js";
insert's avatar
insert committed
type Props = WithDispatcher & {
    channel: Channel;
    draft?: string;
Jan 0660's avatar
Jan 0660 committed
    options: ExperimentOptions;
export type UploadState =
    | { type: "none" }
    | { type: "attached"; files: File[] }
    | { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource }
    | { type: "sending"; files: File[] }
    | { type: "failed"; files: File[]; error: string };

const Base = styled.div`
    display: flex;
    padding: 0 12px;
    background: var(--message-box);

    textarea {
        font-size: .875rem;
        background: transparent;
    }
`;

const Blocked = styled.div`
    display: flex;
    align-items: center;
    padding: 14px 0;
    user-select: none;
    font-size: .875rem;
    color: var(--tertiary-foreground);

    svg {
        flex-shrink: 0;
nizune's avatar
nizune committed
        margin-inline-end: 8px;
const Action = styled.div`
    display: grid;
    place-items: center;
`;

// ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 5;

Jan 0660's avatar
Jan 0660 committed
function MessageBox({ channel, draft, dispatcher, options }: Props) {
    const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' });
insert's avatar
insert committed
    const [ typing, setTyping ] = useState<boolean | number>(false);
    const [ replies, setReplies ] = useState<Reply[]>([]);
    const playSound = useContext(SoundContext);
    const { openScreen } = useIntermediate();
insert's avatar
insert committed
    const client = useContext(AppContext);
    const translate = useTranslation();
    const permissions = useChannelPermission(channel._id);
    if (!(permissions & ChannelPermission.SendMessage)) {
        return (
            <Base>
                    <PermissionTooltip permission="SendMessages" placement="top">
nizune's avatar
nizune committed
                        <ShieldX size={22}/>
                    </PermissionTooltip>
                    <Text id="app.main.channel.misc.no_sending" />
                </Blocked>
insert's avatar
insert committed
    function setMessage(content?: string) {
        if (content) {
            dispatcher({
                type: "SET_DRAFT",
                channel: channel._id,
                content
            });
        } else {
            dispatcher({
                type: "CLEAR_DRAFT",
                channel: channel._id
            });
        }
    }

insert's avatar
insert committed
    useEffect(() => {
        function append(content: string, action: 'quote' | 'mention') {
            const text =
                action === "quote"
                    ? `${content
                          .split("\n")
                          .map(x => `> ${x}`)
                          .join("\n")}\n\n`
                    : `${content} `;

            if (!draft || draft.length === 0) {
                setMessage(text);
            } else {
                setMessage(`${draft}\n${text}`);
            }
        }

        return internalSubscribe("MessageBox", "append", append);
    }, [ draft ]);

insert's avatar
insert committed
    async function send() {
        if (uploadState.type === 'uploading' || uploadState.type === 'sending') return;
        
Jan 0660's avatar
Jan 0660 committed
        let content = draft?.trim() ?? '';
        if (options.enabled?.includes("censor")) {
            content = content.replace(/(?<=(^| )[A-z])([eyuioa])/g, "\\*");
        }
        function gayify(): string {
            const cycle =
                [
                    "F66", "FC6", "CF6", "6F6", "6FC", "6CF", "66F", "C6F"
                ];
            let res = "$\\textsf{";
            let i = 0;
            for (let ci = 0; ci < content.length; ci++) {
                let str = content[ci];
                if (str == " ") {
                    res += str;
                    continue;
                }
                if (str == "{" || str == "}" || str == "\\" || str == "&" || str == "%" || str == "$" || str == "#" || str == "_")
                    str = "\\" + str;
                res += `\\color{#${cycle[i]}}${str}`;
                i++;
                if (i == cycle.length)
                    i = 0;
            }
            res += "}$";
            return res;
        }

        options.enabled?.forEach(hhhh => {
            if (hhhh == "owo" || hhhh == "uwu" || hhhh == "uvu")
                content = owoify(content, hhhh);
            console.log(hhhh);
        });
        if (options.enabled?.includes("rainbow") && !content.includes("\n")) {
            const gay = gayify();
            if (gay.length <= 2000)
                content = gay;
        }
        if (uploadState.type === 'attached') return sendFile(content);
insert's avatar
insert committed
        if (content.length === 0) return;
        
        stopTyping();
insert's avatar
insert committed
        setMessage();
insert's avatar
insert committed
        setReplies([]);
        playSound('outbound');

        const nonce = ulid();
insert's avatar
insert committed
        dispatcher({
            type: "QUEUE_ADD",
            nonce,
            channel: channel._id,
            message: {
                _id: nonce,
                channel: channel._id,
                author: client.user!._id,
insert's avatar
insert committed
                
                content,
                replies
insert's avatar
insert committed
            }
        });

        defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE));

        try {
            await client.channels.sendMessage(channel._id, {
                content,
insert's avatar
insert committed
                nonce,
                replies
insert's avatar
insert committed
            });
        } catch (error) {
            dispatcher({
                type: "QUEUE_FAIL",
                error: takeError(error),
                nonce
            });
        }
    }

    async function sendFile(content: string) {
        if (uploadState.type !== 'attached') return;
        let attachments: string[] = [];

        const cancel = Axios.CancelToken.source();
        const files = uploadState.files;
        stopTyping();
        setUploadState({ type: "uploading", files, percent: 0, cancel });

        try {
            for (let i=0;i<files.length&&i<CAN_UPLOAD_AT_ONCE;i++) {
                const file = files[i];
                attachments.push(
                    await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, {
                        onUploadProgress: e =>
                        setUploadState({
                            type: "uploading",
                            files,
                            percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / Math.min(files.length, CAN_UPLOAD_AT_ONCE)),
                            cancel
                        }),
                        cancelToken: cancel.token
                    })
                );
            }
        } catch (err) {
            if (err?.message === "cancel") {
                setUploadState({
                    type: "attached",
                    files
                });
            } else {
                setUploadState({
                    type: "failed",
                    files,
                    error: takeError(err)
                });
            }

            return;
        }

        setUploadState({
            type: "sending",
            files
        });

        const nonce = ulid();
        try {
            await client.channels.sendMessage(channel._id, {
                content,
                nonce,
insert's avatar
insert committed
                replies,
                attachments
            });
        } catch (err) {
            setUploadState({
                type: "failed",
                files,
                error: takeError(err)
            });

            return;
        }

        setMessage();
insert's avatar
insert committed
        setReplies([]);
        playSound('outbound');

        if (files.length > CAN_UPLOAD_AT_ONCE) {
            setUploadState({
                type: "attached",
                files: files.slice(CAN_UPLOAD_AT_ONCE)
            });
        } else {
            setUploadState({ type: "none" });
        }
    }
    
    function startTyping() {
        if (typeof typing === 'number' && + new Date() < typing) return;

        const ws = client.websocket;
        if (ws.connected) {
            setTyping(+ new Date() + 4000);
            ws.send({
                type: "BeginTyping",
                channel: channel._id
            });
        }
    }

    function stopTyping(force?: boolean) {
        if (force || typing) {
            const ws = client.websocket;
            if (ws.connected) {
                setTyping(false);
                ws.send({
                    type: "EndTyping",
                    channel: channel._id
                });
            }
        }
    }

    const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
insert's avatar
insert committed
    const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } =
        useAutoComplete(setMessage, {
            users: { type: 'channel', id: channel._id },
            channels: channel.channel_type === 'TextChannel' ? { server: channel.server } : undefined
        });
insert's avatar
insert committed
    return (
insert's avatar
insert committed
            <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)}
                removeFile={index => {
                    if (uploadState.type !== 'attached') return;
                    if (uploadState.files.length === 1) {
                        setUploadState({ type: 'none' });
                    } else {
                        setUploadState({ type: 'attached', files: uploadState.files.filter((_, i) => index !== i) });
                    }
                }} />
insert's avatar
insert committed
            <ReplyBar channel={channel._id} replies={replies} setReplies={setReplies} />
            <Base>
                { (permissions & ChannelPermission.UploadFiles) ? <Action>
                    <FileUploader
                        size={24}
                        behaviour='multi'
                        style='attachment'
                        fileType='attachments'
                        maxFileSize={20_000_000}

                        attached={uploadState.type !== 'none'}
                        uploading={uploadState.type === 'uploading' || uploadState.type === 'sending'}

                        remove={async () => setUploadState({ type: "none" })}
                        onChange={files => setUploadState({ type: "attached", files })}
                        cancel={() => uploadState.type === 'uploading' && uploadState.cancel.cancel("cancel")}
                        append={files => {
                            if (files.length === 0) return;

                            if (uploadState.type === 'none') {
                                setUploadState({ type: 'attached', files });
                            } else if (uploadState.type === 'attached') {
                                setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] });
                            }
                        }}
                </Action> : undefined }
                <TextAreaAutoSize
insert's avatar
insert committed
                    autoFocus
                    hideBorder
                    maxRows={5}
insert's avatar
insert committed
                    padding={14}
insert's avatar
insert committed
                    id="message"
                    value={draft ?? ''}
insert's avatar
insert committed
                    onKeyUp={onKeyUp}
                    onKeyDown={e => {
insert's avatar
insert committed
                        if (onKeyDown(e)) return;

insert's avatar
insert committed
                        if (
                            e.key === "ArrowUp" &&
                            (!draft || draft.length === 0)
                        ) {
                            e.preventDefault();
                            internalEmit("MessageRenderer", "edit_last");
                            return;
                        }

                        if (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
                            e.preventDefault();
                            return send();
                        }
                        
                        debouncedStopTyping(true);
                    }}
                    placeholder={
                        channel.channel_type === "DirectMessage" ? translate("app.main.channel.message_who", {
                            person: client.users.get(client.channels.getRecipient(channel._id))?.username })
                        : channel.channel_type === "SavedMessages" ? translate("app.main.channel.message_saved")
                        : translate("app.main.channel.message_where", { channel_name: channel.name })
insert's avatar
insert committed
                    }
                    disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'}
                    onChange={e => {
                        setMessage(e.currentTarget.value);
                        startTyping();
insert's avatar
insert committed
                        onChange(e);
                    }}
                    onFocus={onFocus}
                    onBlur={onBlur} />
                { isTouchscreenDevice && <Action>
                    <IconButton onClick={send}>
                        <Send size={20} />
                    </IconButton>
                </Action> }
            </Base>
        </>
insert's avatar
insert committed
    )
}

export default connectState<Omit<Props, "dispatcher" | "draft">>(MessageBox, (state, { channel }) => {
    return {
Jan 0660's avatar
Jan 0660 committed
        draft: state.drafts[channel._id],
        options: state.experiments
insert's avatar
insert committed
    }
}, true)