1
0
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:
Puyodead1 2023-09-01 17:38:27 -04:00
parent 1c32fe99ce
commit 3c8007d95b
No known key found for this signature in database
GPG Key ID: BA5F91AAEF68E5CE
6 changed files with 296 additions and 44 deletions

View File

@ -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);

View 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;

View File

@ -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",

View File

@ -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,
})) ?? [],
});
}

View File

@ -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;
}

View File

@ -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