revite/src/components/common/messaging/MessageBox.tsx

510 lines
16 KiB
TypeScript
Raw Normal View History

2021-07-30 17:40:49 -04:00
import { Send, ShieldX } from "@styled-icons/boxicons-solid";
2021-07-05 06:23:23 -04:00
import Axios, { CancelTokenSource } from "axios";
import { ChannelPermission } from "revolt.js/dist/api/permissions";
2021-07-30 17:40:49 -04:00
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
2021-07-05 06:23:23 -04:00
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
2021-06-23 13:26:41 -04:00
import { debounce } from "../../../lib/debounce";
2021-07-05 06:23:23 -04:00
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useTranslation } from "../../../lib/i18n";
2021-07-05 06:23:23 -04:00
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
2021-07-05 06:25:20 -04:00
SingletonMessageRenderer,
SMOOTH_SCROLL_ON_RECEIVE,
2021-07-05 06:23:23 -04:00
} from "../../../lib/renderer/Singleton";
import { dispatch, getState } from "../../../redux";
2021-07-05 06:23:23 -04:00
import { Reply } from "../../../redux/reducers/queue";
2021-06-26 17:16:43 -04:00
import { SoundContext } from "../../../context/Settings";
2021-07-05 06:23:23 -04:00
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
2021-07-05 06:25:20 -04:00
FileUploader,
grabFiles,
uploadFile,
2021-07-05 06:23:23 -04:00
} from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
2021-07-05 06:23:23 -04:00
import { takeError } from "../../../context/revoltjs/util";
2021-06-20 15:30:42 -04:00
2021-07-05 06:23:23 -04:00
import IconButton from "../../ui/IconButton";
import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview";
2021-06-23 13:26:41 -04:00
import ReplyBar from "./bars/ReplyBar";
type Props = {
2021-07-05 06:25:20 -04:00
channel: Channel;
2021-06-20 15:30:42 -04:00
};
export type UploadState =
2021-07-05 06:25:20 -04:00
| { 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`
2021-07-05 06:25:20 -04:00
display: flex;
align-items: flex-start;
2021-07-05 06:25:20 -04:00
background: var(--message-box);
textarea {
font-size: var(--text-size);
2021-07-05 06:25:20 -04:00
background: transparent;
2021-07-06 07:16:29 -04:00
&::placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
2021-07-05 06:25:20 -04:00
}
`;
2021-06-26 17:16:43 -04:00
const Blocked = styled.div`
2021-07-05 06:25:20 -04:00
display: flex;
align-items: center;
user-select: none;
font-size: var(--text-size);
2021-07-05 06:25:20 -04:00
color: var(--tertiary-foreground);
2021-07-06 07:16:29 -04:00
.text {
padding: 14px 14px 14px 0;
}
2021-07-05 06:25:20 -04:00
svg {
flex-shrink: 0;
}
2021-06-26 17:16:43 -04:00
`;
const Action = styled.div`
2021-07-06 07:16:29 -04:00
display: flex;
2021-07-05 06:25:20 -04:00
place-items: center;
2021-07-05 15:06:08 -04:00
> div {
2021-07-06 07:16:29 -04:00
height: 48px;
width: 48px;
padding: 12px;
}
.mobile {
@media (pointer: fine) {
display: none;
}
2021-07-05 15:06:08 -04:00
}
`;
// ! FIXME: add to app config and load from app config
export const CAN_UPLOAD_AT_ONCE = 4;
export default function MessageBox({ channel }: Props) {
2021-07-06 14:29:27 -04:00
const [draft, setDraft] = useState(getState().drafts[channel._id] ?? "");
2021-07-05 06:25:20 -04:00
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();
const client = useContext(AppContext);
const translate = useTranslation();
2021-07-30 17:40:49 -04:00
if (!(channel.permission & ChannelPermission.SendMessage)) {
2021-07-05 06:25:20 -04:00
return (
<Base>
<Blocked>
2021-07-06 07:16:29 -04:00
<Action>
2021-07-06 11:42:32 -04:00
<PermissionTooltip
permission="SendMessages"
placement="top">
<ShieldX size={22} />
</PermissionTooltip>
2021-07-06 07:16:29 -04:00
</Action>
<div className="text">
<Text id="app.main.channel.misc.no_sending" />
</div>
2021-07-05 06:25:20 -04:00
</Blocked>
</Base>
);
}
function setMessage(content?: string) {
2021-07-06 14:29:27 -04:00
setDraft(content ?? "");
2021-07-05 06:25:20 -04:00
if (content) {
dispatch({
type: "SET_DRAFT",
channel: channel._id,
content,
});
} else {
dispatch({
type: "CLEAR_DRAFT",
channel: channel._id,
});
}
}
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]);
async function send() {
if (uploadState.type === "uploading" || uploadState.type === "sending")
return;
const content = draft?.trim() ?? "";
if (uploadState.type === "attached") return sendFile(content);
if (content.length === 0) return;
stopTyping();
setMessage();
setReplies([]);
playSound("outbound");
const nonce = ulid();
dispatch({
2021-07-05 06:25:20 -04:00
type: "QUEUE_ADD",
nonce,
channel: channel._id,
message: {
_id: nonce,
channel: channel._id,
author: client.user!._id,
content,
replies,
},
});
2021-07-05 06:25:20 -04:00
defer(() =>
SingletonMessageRenderer.jumpToBottom(
channel._id,
SMOOTH_SCROLL_ON_RECEIVE,
),
);
try {
2021-07-30 17:40:49 -04:00
await channel.sendMessage({
2021-07-05 06:25:20 -04:00
content,
nonce,
replies,
});
} catch (error) {
dispatch({
type: "QUEUE_FAIL",
error: takeError(error),
nonce,
});
}
}
async function sendFile(content: string) {
if (uploadState.type !== "attached") return;
const attachments: string[] = [];
2021-07-05 06:25:20 -04:00
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 {
2021-07-30 17:40:49 -04:00
await channel.sendMessage({
2021-07-05 06:25:20 -04:00
content,
nonce,
replies,
attachments,
});
} catch (err) {
setUploadState({
type: "failed",
files,
error: takeError(err),
});
return;
}
setMessage();
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,
]);
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete(setMessage, {
users: { type: "channel", id: channel._id },
channels:
channel.channel_type === "TextChannel"
2021-07-30 17:40:49 -04:00
? { server: channel.server_id! }
2021-07-05 06:25:20 -04:00
: undefined,
});
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,
)
}
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._id}
replies={replies}
setReplies={setReplies}
/>
<Base>
2021-07-30 17:40:49 -04:00
{channel.permission & ChannelPermission.UploadFiles ? (
2021-07-05 06:25:20 -04:00
<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}
2021-07-05 16:28:38 -04:00
<TextAreaAutoSize
autoFocus
hideBorder
maxRows={20}
id="message"
onKeyUp={onKeyUp}
value={draft ?? ""}
padding="var(--message-box-padding)"
2021-07-05 16:28:38 -04:00
onKeyDown={(e) => {
if (onKeyDown(e)) return;
if (
e.key === "ArrowUp" &&
(!draft || draft.length === 0)
) {
e.preventDefault();
internalEmit("MessageRenderer", "edit_last");
return;
}
2021-07-05 16:28:38 -04:00
if (
!e.shiftKey &&
e.key === "Enter" &&
!isTouchscreenDevice
) {
e.preventDefault();
return send();
}
2021-07-05 16:28:38 -04:00
debouncedStopTyping(true);
}}
placeholder={
channel.channel_type === "DirectMessage"
? translate("app.main.channel.message_who", {
2021-07-30 17:40:49 -04:00
person: channel.recipient?.username,
2021-07-06 14:29:27 -04:00
})
2021-07-05 16:28:38 -04:00
: channel.channel_type === "SavedMessages"
? translate("app.main.channel.message_saved")
: translate("app.main.channel.message_where", {
2021-07-06 14:29:27 -04:00
channel_name: channel.name,
})
2021-07-05 16:28:38 -04:00
}
disabled={
uploadState.type === "uploading" ||
uploadState.type === "sending"
}
onChange={(e) => {
setMessage(e.currentTarget.value);
startTyping();
onChange(e);
}}
onFocus={onFocus}
onBlur={onBlur}
/>
2021-07-06 07:16:29 -04:00
<Action>
{/*<IconButton onClick={emojiPicker}>
<HappyAlt size={20} />
</IconButton>*/}
<IconButton
className="mobile"
onClick={send}
onMouseDown={(e) => e.preventDefault()}>
2021-07-06 07:16:29 -04:00
<Send size={20} />
</IconButton>
</Action>
2021-07-05 06:25:20 -04:00
</Base>
</>
);
2021-06-20 15:30:42 -04:00
}