Skip to content
Snippets Groups Projects
MessageBox.tsx 20.8 KiB
Newer Older
insert's avatar
insert committed
import { Send, ShieldX } from "@styled-icons/boxicons-solid";
insert's avatar
insert committed
import Axios, { CancelTokenSource } from "axios";
import { observer } from "mobx-react-lite";
insert's avatar
insert committed
import { ChannelPermission } from "revolt.js/dist/api/permissions";
insert's avatar
insert committed
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
insert's avatar
insert committed
import { ulid } from "ulid";

import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";

import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
insert's avatar
insert committed
import { debounce } from "../../../lib/debounce";
insert's avatar
insert committed
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useTranslation } from "../../../lib/i18n";
insert's avatar
insert committed
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
    getRenderer,
    SMOOTH_SCROLL_ON_RECEIVE,
insert's avatar
insert committed
} from "../../../lib/renderer/Singleton";

import { dispatch, getState } from "../../../redux";
insert's avatar
insert committed
import { Reply } from "../../../redux/reducers/queue";
import { SoundContext } from "../../../context/Settings";
insert's avatar
insert committed
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
    FileUploader,
    grabFiles,
    uploadFile,
insert's avatar
insert committed
} from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
insert's avatar
insert committed
import { takeError } from "../../../context/revoltjs/util";
insert's avatar
insert committed
import IconButton from "../../ui/IconButton";

import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview";
insert's avatar
insert committed
import ReplyBar from "./bars/ReplyBar";
Jan 0660's avatar
Jan 0660 committed
import owoify from "owoify-js";
Jan 0660's avatar
Jan 0660 committed
import { ExperimentOptions } from "../../../redux/reducers/experiments";
Jan 0660's avatar
h  
Jan 0660 committed
import Button from "../../ui/Button";
type Props = {
insert's avatar
insert committed
    channel: Channel;
Jan 0660's avatar
Jan 0660 committed
    experiments: 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;
insert's avatar
insert committed
    align-items: flex-start;
    background: var(--message-box);

    textarea {
        font-size: var(--text-size);
        background: transparent;

        &::placeholder {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
const Blocked = styled.div`
    display: flex;
    align-items: center;
    user-select: none;
    font-size: var(--text-size);
    color: var(--tertiary-foreground);
    .text {
        padding: 14px 14px 14px 0;
    }

    svg {
        flex-shrink: 0;
    }
const Action = styled.div`
    display: flex;
    place-items: center;
        height: 48px;
        width: 48px;
        padding: 12px;
    }

    .mobile {
        @media (pointer: fine) {
            display: none;
        }
// ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 4;
export default observer(({ channel }: Props) => {
insert's avatar
insert committed
    const [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
Jan 0660's avatar
h  
Jan 0660 committed
    let messageText = draft ?? "";
Jan 0660's avatar
Jan 0660 committed
    const experiments = getState().experiments;
    const [uploadState, setUploadState] = useState<UploadState>({
        type: "none",
    });
    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 renderer = getRenderer(channel);

insert's avatar
insert committed
    if (!(channel.permission & ChannelPermission.SendMessage)) {
        return (
            <Base>
                    <Action>
                        <PermissionTooltip
                            permission="SendMessages"
                            placement="top">
                            <ShieldX size={22} />
                        </PermissionTooltip>
                    </Action>
                    <div className="text">
                        <Text id="app.main.channel.misc.no_sending" />
                    </div>
                </Blocked>
            </Base>
insert's avatar
insert committed
    const setMessage = useCallback(
        (content?: string) => {
            setDraft(content ?? "");
insert's avatar
insert committed

insert's avatar
insert committed
            if (content) {
                dispatch({
                    type: "SET_DRAFT",
                    channel: channel._id,
                    content,
                });
            } else {
                dispatch({
                    type: "CLEAR_DRAFT",
                    channel: channel._id,
                });
            }
        },
        [channel._id],
    );
insert's avatar
insert committed
    useEffect(() => {
        function append(content: string, action: "quote" | "mention") {
insert's avatar
insert committed
            const text =
                action === "quote"
                    ? `${content
                          .split("\n")
                          .map((x) => `> ${x}`)
insert's avatar
insert committed
                          .join("\n")}\n\n`
                    : `${content} `;

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

insert's avatar
insert committed
        return internalSubscribe(
            "MessageBox",
            "append",
            append as (...args: unknown[]) => void,
        );
    }, [draft, setMessage]);
insert's avatar
insert committed
    async function send() {
        if (uploadState.type === "uploading" || uploadState.type === "sending")
            return;

        let content = draft?.trim() ?? "";
Jan 0660's avatar
Jan 0660 committed
        if (experiments.enabled?.includes("censor")) {
Jan 0660's avatar
Jan 0660 committed
            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;
        }

Jan 0660's avatar
Jan 0660 committed
        experiments.enabled?.forEach(hhhh => {
Jan 0660's avatar
Jan 0660 committed
            if (hhhh == "owo" || hhhh == "uwu" || hhhh == "uvu")
                content = owoify(content, hhhh);
            console.log(hhhh);
        });
Jan 0660's avatar
Jan 0660 committed
        if (experiments.enabled?.includes("rainbow") && !content.includes("\n")) {
Jan 0660's avatar
Jan 0660 committed
            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();
        dispatch({
insert's avatar
insert committed
            type: "QUEUE_ADD",
            nonce,
            channel: channel._id,
            message: {
                _id: nonce,
                channel: channel._id,
                author: client.user!._id,
insert's avatar
insert committed
                content,
                replies,
            },
        defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
insert's avatar
insert committed

        try {
insert's avatar
insert committed
            await channel.sendMessage({
insert's avatar
insert committed
                content,
insert's avatar
insert committed
                nonce,
                replies,
insert's avatar
insert committed
            });
        } catch (error) {
            dispatch({
insert's avatar
insert committed
                type: "QUEUE_FAIL",
                error: takeError(error),
                nonce,
    async function sendFile(content: string) {
        if (uploadState.type !== "attached") return;
        const 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 {
insert's avatar
insert committed
            await channel.sendMessage({
                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() + 2500);
            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,
insert's avatar
insert committed
    // eslint-disable-next-line
    const debouncedStopTyping = useCallback(
        debounce(stopTyping as (...args: unknown[]) => void, 1000),
        [channel._id],
    );
    const {
        onChange,
        onKeyUp,
        onKeyDown,
        onFocus,
        onBlur,
        ...autoCompleteProps
    } = useAutoComplete(setMessage, {
        users: { type: "channel", id: channel._id },
        channels:
            channel.channel_type === "TextChannel"
insert's avatar
insert committed
                ? { server: channel.server_id! }
                : undefined,
    });
Jan 0660's avatar
h  
Jan 0660 committed
    const messageTextArea = <TextAreaAutoSize
        autoFocus
        hideBorder
        maxRows={20}
        id="message"
        onKeyUp={onKeyUp}
        value={messageText}
        padding="var(--message-box-padding)"
        onKeyDown={(e) => {
            if (onKeyDown(e)) return;

            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: channel.recipient?.username,
                })
                : channel.channel_type === "SavedMessages"
                ? translate("app.main.channel.message_saved")
                : translate("app.main.channel.message_where", {
                    channel_name: channel.name,
                })
        }
        disabled={
            uploadState.type === "uploading" ||
            uploadState.type === "sending"
        }
        onChange={(e) => {
            setMessage(e.currentTarget.value);
            startTyping();
            onChange(e);
        }}
        onFocus={onFocus}
        onBlur={onBlur}
    />;

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,
                            ),
                        });
                }}
            />
            <ReplyBar
                channel={channel}
                replies={replies}
                setReplies={setReplies}
            />
            <Base>
insert's avatar
insert committed
                {channel.permission & 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}
Jan 0660's avatar
h  
Jan 0660 committed
                {/*<Button onClick={() =>*/}
                {/*    openScreen({*/}
                {/*        id: "szuru_pop",*/}
                {/*        callback: (value) => {*/}
                {/*            setMessage(value)*/}
                {/*            console.log("callback called")*/}

                    {/*    if (*/}
                    {/*        !e.shiftKey &&*/}
                    {/*        e.key === "Enter" &&*/}
                    {/*        !isTouchscreenDevice*/}
                    {/*    ) {*/}
                    {/*        e.preventDefault();*/}
                    {/*        return send();*/}
                    {/*    }*/}

                    {/*    debouncedStopTyping(true);*/}
                    {/*}}*/}
                <TextAreaAutoSize
insert's avatar
insert committed
                    autoFocus
                    hideBorder
insert's avatar
insert committed
                    maxRows={20}
insert's avatar
insert committed
                    id="message"
insert's avatar
insert committed
                    onKeyUp={onKeyUp}
Jan 0660's avatar
h  
Jan 0660 committed
                    value={messageText}
                    padding="var(--message-box-padding)"
insert's avatar
insert committed
                    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;
                        }

insert's avatar
insert committed
                        if (
                            !e.shiftKey &&
                            e.key === "Enter" &&
                            !isTouchscreenDevice
                        ) {
                            e.preventDefault();
                            return send();
                        }
insert's avatar
insert committed

                        if (e.key === "Escape") {
                            if (replies.length > 0) {
                                setReplies(replies.slice(0, -1));
                            } else if (
                                uploadState.type === "attached" &&
                                uploadState.files.length > 0
                            ) {
                                setUploadState({
                                    type:
                                        uploadState.files.length > 1
                                            ? "attached"
                                            : "none",
                                    files: uploadState.files.slice(0, -1),
                                });
                            }
                        debouncedStopTyping(true);
                    }}
                    placeholder={
insert's avatar
insert committed
                        channel.channel_type === "DirectMessage"
                            ? translate("app.main.channel.message_who", {
insert's avatar
insert committed
                                  person: channel.recipient?.username,
insert's avatar
insert committed
                              })
insert's avatar
insert committed
                            : channel.channel_type === "SavedMessages"
                            ? translate("app.main.channel.message_saved")
                            : translate("app.main.channel.message_where", {
insert's avatar
insert committed
                                  channel_name: channel.name ?? undefined,
insert's avatar
insert committed
                              })
insert's avatar
insert committed
                    }
                    disabled={
                        uploadState.type === "uploading" ||
                        uploadState.type === "sending"
insert's avatar
insert committed
                    }
insert's avatar
insert committed
                    onChange={(e) => {
                        setMessage(e.currentTarget.value);
                        startTyping();
insert's avatar
insert committed
                        onChange(e);
                    }}
                    onFocus={onFocus}
insert's avatar
insert committed
                    onBlur={onBlur}
                />
                <Action>
                    {/*<IconButton onClick={emojiPicker}>
                        <HappyAlt size={20} />
                </IconButton>*/}
insert's avatar
insert committed
                    <IconButton
                        className="mobile"
                        onClick={send}
                        onMouseDown={(e) => e.preventDefault()}>
                        <Send size={20} />
                    </IconButton>
                </Action>
            </Base>
        </>