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";
|
2021-06-21 16:11:53 -04:00
|
|
|
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";
|
2021-06-21 16:11:53 -04:00
|
|
|
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";
|
|
|
|
|
2021-07-06 06:07:42 -04:00
|
|
|
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";
|
2021-06-21 08:28:26 -04:00
|
|
|
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";
|
2021-06-21 16:11:53 -04:00
|
|
|
|
2021-07-05 05:59:48 -04:00
|
|
|
type Props = {
|
2021-07-05 06:25:20 -04:00
|
|
|
channel: Channel;
|
2021-06-20 15:30:42 -04:00
|
|
|
};
|
|
|
|
|
2021-06-21 16:11:53 -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 };
|
2021-06-21 16:11:53 -04:00
|
|
|
|
|
|
|
const Base = styled.div`
|
2021-07-05 06:25:20 -04:00
|
|
|
display: flex;
|
2021-07-05 16:07:04 -04:00
|
|
|
align-items: flex-start;
|
2021-07-05 06:25:20 -04:00
|
|
|
background: var(--message-box);
|
|
|
|
|
|
|
|
textarea {
|
2021-07-06 06:04:51 -04:00
|
|
|
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-21 16:11:53 -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;
|
2021-07-06 06:04:51 -04:00
|
|
|
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
|
|
|
`;
|
|
|
|
|
2021-06-21 16:11:53 -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
|
|
|
}
|
2021-06-21 16:11:53 -04:00
|
|
|
`;
|
|
|
|
|
2021-06-22 12:45:34 -04:00
|
|
|
// ! FIXME: add to app config and load from app config
|
2021-07-06 06:07:42 -04:00
|
|
|
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-06-22 12:45:34 -04:00
|
|
|
|
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();
|
2021-07-31 05:31:16 -04:00
|
|
|
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-31 05:31:16 -04:00
|
|
|
});
|
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;
|
2021-07-10 10:57:29 -04:00
|
|
|
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}
|
2021-07-06 06:04:51 -04:00
|
|
|
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:07:04 -04:00
|
|
|
}
|
2021-07-05 16:28:38 -04:00
|
|
|
|
|
|
|
if (
|
|
|
|
!e.shiftKey &&
|
|
|
|
e.key === "Enter" &&
|
|
|
|
!isTouchscreenDevice
|
|
|
|
) {
|
|
|
|
e.preventDefault();
|
|
|
|
return send();
|
2021-07-05 16:07:04 -04:00
|
|
|
}
|
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>*/}
|
2021-07-10 06:21:05 -04:00
|
|
|
<IconButton
|
2021-07-10 10:57:29 -04:00
|
|
|
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
|
|
|
}
|