mirror of
https://github.com/spacebarchat/client.git
synced 2024-11-22 02:12:38 +01:00
initial implementation of attachment uploading
This commit is contained in:
parent
1c32fe99ce
commit
3c8007d95b
@ -1,8 +1,8 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export const HorizontalDivider = styled.div`
|
||||
export const HorizontalDivider = styled.div<{ nomargin?: boolean }>`
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
margin-top: ${(props) => (props.nomargin ? "0" : "8px")};
|
||||
z-index: 1;
|
||||
height: 0;
|
||||
border-top: thin solid var(--text-disabled);
|
||||
|
99
src/components/messaging/AttachmentUpload.tsx
Normal file
99
src/components/messaging/AttachmentUpload.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import styled from "styled-components";
|
||||
import Icon from "../Icon";
|
||||
import IconButton from "../IconButton";
|
||||
|
||||
const Container = styled.li`
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
min-height: 200px;
|
||||
max-height: 200px;
|
||||
`;
|
||||
|
||||
const MediaContainer = styled.div`
|
||||
position: relative;
|
||||
margin-top: auto;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const ActionsContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(25%, -25%);
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const ActionBarWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-secondary);
|
||||
`;
|
||||
|
||||
const FileDetails = styled.div`
|
||||
margin-top: auto;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
function AttachmentUpload({ file, remove }: Props) {
|
||||
// create a preview url for the file
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<MediaContainer>
|
||||
<img
|
||||
src={previewUrl}
|
||||
style={{
|
||||
borderRadius: "3px",
|
||||
maxWidth: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</MediaContainer>
|
||||
<ActionsContainer>
|
||||
<ActionBarWrapper>
|
||||
<IconButton onClick={remove}>
|
||||
<Icon size="24px" icon="mdiTrashCan" color="var(--danger)" />
|
||||
</IconButton>
|
||||
</ActionBarWrapper>
|
||||
</ActionsContainer>
|
||||
<FileDetails>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "16px",
|
||||
fontWeight: "var(--font-weight-regular)",
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</FileDetails>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttachmentUpload;
|
@ -3,6 +3,7 @@ import useLogger from "../../hooks/useLogger";
|
||||
import { useAppStore } from "../../stores/AppStore";
|
||||
import Channel from "../../stores/objects/Channel";
|
||||
|
||||
import { RESTPostAPIChannelMessageJSONBody } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useMemo } from "react";
|
||||
import { BaseEditor, Descendant, Node, createEditor } from "slate";
|
||||
@ -12,6 +13,10 @@ import Guild from "../../stores/objects/Guild";
|
||||
import User from "../../stores/objects/User";
|
||||
import { Permissions } from "../../utils/Permissions";
|
||||
import Snowflake from "../../utils/Snowflake";
|
||||
import { HorizontalDivider } from "../Divider";
|
||||
import Icon from "../Icon";
|
||||
import IconButton from "../IconButton";
|
||||
import AttachmentUpload from "./AttachmentUpload";
|
||||
|
||||
type CustomElement = { type: "paragraph"; children: CustomText[] };
|
||||
type CustomText = { text: string; bold?: true };
|
||||
@ -39,6 +44,30 @@ const InnerContainer = styled.div`
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const UploadActionWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const CustomIcon = styled(Icon)`
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
`;
|
||||
|
||||
const AttachmentsList = styled.ul`
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin: 0 0 2px 6px;
|
||||
padding: 20px 10px 10px;
|
||||
overflow-x: auto;
|
||||
list-style: none;
|
||||
`;
|
||||
|
||||
const initialEditorValue: Descendant[] = [
|
||||
{
|
||||
type: "paragraph",
|
||||
@ -64,13 +93,15 @@ function MessageInput(props: Props) {
|
||||
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
|
||||
const [content, setContent] = React.useState("");
|
||||
const [canSendMessages, setCanSendMessages] = React.useState(true);
|
||||
const uploadRef = React.useRef<HTMLInputElement>(null);
|
||||
const [attachments, setAttachments] = React.useState<File[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const permission = Permissions.getPermission(app.account!.id, props.guild, props.channel);
|
||||
setCanSendMessages(permission.has("SEND_MESSAGES"));
|
||||
}, [props.channel, props.guild]);
|
||||
|
||||
const serialize = (value: Descendant[]) => {
|
||||
const serialize = React.useCallback((value: Descendant[]) => {
|
||||
return (
|
||||
value
|
||||
// Return the string content of each paragraph in the value's children.
|
||||
@ -78,51 +109,94 @@ function MessageInput(props: Props) {
|
||||
// Join them all with line breaks denoting paragraphs.
|
||||
.join("\n")
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (!props.channel) {
|
||||
logger.warn("No channel selected, cannot send message");
|
||||
return;
|
||||
}
|
||||
const uploadProgressCallback = React.useCallback((e: ProgressEvent) => {
|
||||
const progress = Math.round((e.loaded * 100) / e.total);
|
||||
console.log(`uploadProgressCallback`, progress);
|
||||
}, []);
|
||||
|
||||
e.preventDefault();
|
||||
const shouldFail = app.experiments.isTreatmentEnabled("message_queue", 2);
|
||||
const shouldSend = !app.experiments.isTreatmentEnabled("message_queue", 1);
|
||||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (!props.channel) {
|
||||
logger.warn("No channel selected, cannot send message");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.channel.canSendMessage(content) && !shouldFail) return;
|
||||
e.preventDefault();
|
||||
const shouldFail = app.experiments.isTreatmentEnabled("message_queue", 2);
|
||||
const shouldSend = !app.experiments.isTreatmentEnabled("message_queue", 1);
|
||||
|
||||
const nonce = Snowflake.generate();
|
||||
app.queue.add({
|
||||
id: nonce,
|
||||
author: app.account! as unknown as User,
|
||||
content,
|
||||
channel: props.channel.id,
|
||||
});
|
||||
const canSend = props.channel.canSendMessage(content, attachments);
|
||||
console.log(`canSendMessage`, canSend);
|
||||
if (!canSend && !shouldFail) return;
|
||||
|
||||
if (shouldSend) {
|
||||
props.channel.sendMessage({ content, nonce }).catch((error) => {
|
||||
app.queue.error(nonce, error as string);
|
||||
const nonce = Snowflake.generate();
|
||||
app.queue.add({
|
||||
id: nonce,
|
||||
author: app.account! as unknown as User,
|
||||
content,
|
||||
channel: props.channel.id,
|
||||
// attachments,
|
||||
});
|
||||
|
||||
if (shouldSend) {
|
||||
let body: RESTPostAPIChannelMessageJSONBody | FormData;
|
||||
if (attachments.length > 0) {
|
||||
const data = new FormData();
|
||||
data.append("payload_json", JSON.stringify({ content, nonce }));
|
||||
attachments.forEach((file, index) => {
|
||||
data.append(`files[${index}]`, file);
|
||||
});
|
||||
body = data;
|
||||
} else {
|
||||
body = { content, nonce };
|
||||
}
|
||||
props.channel.sendMessage(body, uploadProgressCallback).catch((error) => {
|
||||
app.queue.error(nonce, error as string);
|
||||
});
|
||||
}
|
||||
|
||||
setContent("");
|
||||
setAttachments([]);
|
||||
|
||||
// reset slate editor
|
||||
const point = { path: [0, 0], offset: 0 };
|
||||
editor.selection = { anchor: point, focus: point };
|
||||
editor.history = { redos: [], undos: [] };
|
||||
editor.children = initialEditorValue;
|
||||
}
|
||||
},
|
||||
[props.channel, content, attachments],
|
||||
);
|
||||
|
||||
setContent("");
|
||||
|
||||
// reset slate editor
|
||||
const point = { path: [0, 0], offset: 0 };
|
||||
editor.selection = { anchor: point, focus: point };
|
||||
editor.history = { redos: [], undos: [] };
|
||||
editor.children = initialEditorValue;
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (value: Descendant[]) => {
|
||||
const onChange = React.useCallback((value: Descendant[]) => {
|
||||
const isAstChange = editor.operations.some((op) => "set_selection" !== op.type);
|
||||
if (isAstChange) {
|
||||
setContent(serialize(value));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = React.useCallback(() => {
|
||||
if (!props.channel) {
|
||||
logger.warn("No channel selected, cannot send message");
|
||||
return;
|
||||
}
|
||||
uploadRef.current?.click();
|
||||
}, [props.channel]);
|
||||
|
||||
const onChangeFile = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files) return;
|
||||
const files = Array.from(e.target.files);
|
||||
setAttachments(files);
|
||||
}, []);
|
||||
|
||||
const removeAttachment = React.useCallback((index: number) => {
|
||||
const newAttachments = [...attachments];
|
||||
newAttachments.splice(index, 1);
|
||||
setAttachments(newAttachments);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@ -132,13 +206,35 @@ function MessageInput(props: Props) {
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<>
|
||||
<AttachmentsList>
|
||||
{attachments.map((file, index) => (
|
||||
<AttachmentUpload key={index} file={file} remove={() => removeAttachment(index)} />
|
||||
))}
|
||||
</AttachmentsList>
|
||||
<HorizontalDivider nomargin />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: "16px",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<UploadActionWrapper>
|
||||
<input
|
||||
type="file"
|
||||
ref={uploadRef}
|
||||
style={{ display: "none" }}
|
||||
onChange={onChangeFile}
|
||||
multiple={true}
|
||||
/>
|
||||
<IconButton onClick={handleFileUpload}>
|
||||
<CustomIcon icon="mdiPlusCircle" size="24px" />
|
||||
</IconButton>
|
||||
</UploadActionWrapper>
|
||||
<Slate editor={editor} initialValue={initialEditorValue} onChange={onChange}>
|
||||
<Editable
|
||||
onKeyDown={onKeyDown}
|
||||
@ -147,7 +243,7 @@ function MessageInput(props: Props) {
|
||||
width: "100%",
|
||||
outline: "none",
|
||||
wordBreak: "break-word",
|
||||
padding: "12px 16px",
|
||||
padding: "12px 16px 12px 0",
|
||||
overflowY: "auto",
|
||||
maxHeight: "50vh",
|
||||
cursor: !canSendMessages ? "not-allowed" : "text",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { APIMessage } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import type { APIAttachment, APIMessage } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { MessageType } from "@spacebarchat/spacebar-api-types/v9";
|
||||
import { action, computed, makeAutoObservable, observable } from "mobx";
|
||||
|
||||
@ -16,6 +16,7 @@ export type QueuedMessageData = {
|
||||
channel: string;
|
||||
author: User;
|
||||
content: string;
|
||||
attachments?: File[];
|
||||
};
|
||||
|
||||
export interface QueuedMessage {
|
||||
@ -27,6 +28,7 @@ export interface QueuedMessage {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
type: MessageType;
|
||||
attachments: APIAttachment[];
|
||||
}
|
||||
|
||||
export default class MessageQueue {
|
||||
@ -45,6 +47,17 @@ export default class MessageQueue {
|
||||
timestamp: new Date(),
|
||||
status: QueuedMessageStatus.SENDING,
|
||||
type: MessageType.Default,
|
||||
attachments:
|
||||
data.attachments?.map((x) => ({
|
||||
id: Snowflake.generate(),
|
||||
filename: x.name,
|
||||
size: x.size,
|
||||
url: URL.createObjectURL(x),
|
||||
proxy_url: URL.createObjectURL(x),
|
||||
height: 0,
|
||||
width: 0,
|
||||
content_type: x.type,
|
||||
})) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -198,15 +198,22 @@ export default class Channel {
|
||||
}
|
||||
|
||||
@action
|
||||
async sendMessage(data: RESTPostAPIChannelMessageJSONBody) {
|
||||
async sendMessage(data: RESTPostAPIChannelMessageJSONBody | FormData, cb?: (e: ProgressEvent) => void) {
|
||||
if (data instanceof FormData)
|
||||
return this.app.rest.postFormData<RESTPostAPIChannelMessageResult>(
|
||||
Routes.channelMessages(this.id),
|
||||
data,
|
||||
undefined,
|
||||
cb,
|
||||
);
|
||||
return this.app.rest.post<RESTPostAPIChannelMessageJSONBody, RESTPostAPIChannelMessageResult>(
|
||||
Routes.channelMessages(this.id),
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
canSendMessage(content: string) {
|
||||
if (!content || !content.trim() || !content.replace(/\r?\n|\r/g, "")) {
|
||||
canSendMessage(content: string, attachments: File[]) {
|
||||
if (!attachments.length && (!content || !content.trim() || !content.replace(/\r?\n|\r/g, ""))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,6 @@ export default class REST {
|
||||
mode: "cors",
|
||||
"User-Agent": "Spacebar-Client/1.0",
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
@ -110,7 +109,10 @@ export default class REST {
|
||||
this.logger.debug(`POST ${url}; payload:`, body);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
.then(async (res) => {
|
||||
@ -135,6 +137,41 @@ export default class REST {
|
||||
});
|
||||
}
|
||||
|
||||
public async postFormData<U>(
|
||||
path: string,
|
||||
body: FormData,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryParams: Record<string, any> = {},
|
||||
progressCb?: (ev: ProgressEvent) => void,
|
||||
): Promise<U> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = REST.makeAPIUrl(path, queryParams);
|
||||
this.logger.debug(`POST ${url}; payload:`, body);
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (progressCb) xhr.upload.addEventListener("progress", progressCb);
|
||||
xhr.addEventListener("loadend", () => {
|
||||
// if success, resolve text or json
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (xhr.responseType === "json") return resolve(xhr.response);
|
||||
|
||||
return resolve(JSON.parse(xhr.response));
|
||||
}
|
||||
|
||||
// if theres content, reject with text
|
||||
if (xhr.getResponseHeader("content-length") !== "0") return reject(xhr.responseText);
|
||||
|
||||
// reject with status code if theres no content
|
||||
return reject(xhr.statusText);
|
||||
});
|
||||
xhr.open("POST", url);
|
||||
// set headers
|
||||
Object.entries(this.headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(
|
||||
path: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
Loading…
Reference in New Issue
Block a user