feat: send file message

This commit is contained in:
zerosoul
2022-03-28 20:45:53 +08:00
parent d07b0e9e91
commit 774bf2c335
12 changed files with 396 additions and 7 deletions
+2
View File
@@ -8,12 +8,14 @@ export const ContentTypes = {
image: "image/png",
imageJPG: "image/jpeg",
file: "rustchat/file",
formData: "multipart/form-data",
json: "application/json",
};
export const googleClientID =
"418687074928-naojba82n9ktf0rkvnqoor4nhr54ql1b.apps.googleusercontent.com";
// "840319286941-6ds7lbvk55eq8mjortf68cb2ll65lprt.apps.googleusercontent.com";
export const tokenHeader = "X-API-Key";
export const FILE_SLICE_SIZE = 1000 * 200 * 8; //200kb
export const KEY_TOKEN = "RUSTCHAT_TOKEN";
export const KEY_EXPIRE = "RUSTCHAT_TOKEN_EXPIRE";
export const KEY_REFRESH_TOKEN = "RUSTCHAT_REFRESH_TOKEN";
+19 -2
View File
@@ -1,7 +1,8 @@
import { createSlice } from "@reduxjs/toolkit";
import BASE_URL from "../config";
import BASE_URL, { ContentTypes } from "../config";
const initialState = {
replying: {},
fileMessages: [],
};
const messageSlice = createSlice({
name: "message",
@@ -11,7 +12,7 @@ const messageSlice = createSlice({
return initialState;
},
fullfillMessage(state, action) {
return action.payload;
return Object.assign({ ...initialState }, action.payload);
},
updateMessage(state, action) {
const { mid, ...rest } = action.payload;
@@ -33,6 +34,17 @@ const messageSlice = createSlice({
// 如果是正发送,并且已存在,则不覆盖
if (sending && state[mid]) return;
const isImage = content_type.startsWith("image");
const isFile = content_type == ContentTypes.file;
// file
if (!sending && isFile) {
data.file_path = content;
data.content = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(
data.file_path
)}`;
// 加入file message 列表
state.fileMessages.unshift(mid);
}
// image
if (!sending && isImage) {
data.image_id = content;
data.content = `${BASE_URL}/resource/image?id=${encodeURIComponent(
@@ -50,6 +62,11 @@ const messageSlice = createSlice({
: [action.payload];
mids.forEach((id) => {
delete state[id];
// 从file message 列表删掉
const fidIdx = state.fileMessages.findIndex((fid) => fid == id);
if (fidIdx > -1) {
state.fileMessages.splice(fidIdx, 1);
}
});
},
addReplyingMessage(state, action) {
+4
View File
@@ -23,6 +23,10 @@ const userMsgSlice = createSlice({
if (midExsited || localMsgExsited) return;
state.byId[id].push(+mid);
// 只要有新消息,就检查下
if (state.ids.findIndex((uid) => uid == id) == -1) {
state.ids.push(+id);
}
} else {
state.byId[id] = [+mid];
state.ids.push(+id);
+81
View File
@@ -0,0 +1,81 @@
// import React from 'react'
import styled from "styled-components";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useSelector } from "react-redux";
import { getFileIcon, formatBytes } from "../../utils";
import IconDownload from "../../../assets/icons/download.svg";
dayjs.extend(relativeTime);
const Styled = styled.div`
padding: 8px;
background: #f3f4f6;
border: 1px solid #d4d4d4;
box-sizing: border-box;
border-radius: 6px;
width: 370px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.icon {
width: 36px;
height: 48px;
}
.info {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #1c1c1e;
}
.details {
font-weight: 400;
font-size: 12px;
line-height: 18px;
color: #616161;
display: flex;
gap: 16px;
.from strong {
font-weight: bold;
}
}
}
.download {
white-space: nowrap;
}
`;
export default function FileBox({
file_type,
name,
size,
created_at,
from_uid,
content,
}) {
const fromUser = useSelector((store) => store.contacts.byId[from_uid]);
const icon = getFileIcon(file_type, name);
if (!content || !fromUser || !name) return null;
console.log("file content", content, name);
return (
<Styled>
{icon}
<div className="info">
<span className="name">{name}</span>
<span className="details">
<i className="size">{formatBytes(size)}</i>
<i className="time">{dayjs(created_at).fromNow()}</i>
<i className="from">
by <strong>{fromUser.name}</strong>
</i>
</span>
</div>
<a className="download" download={name} href={content}>
<IconDownload />
</a>
</Styled>
);
}
+25 -1
View File
@@ -2,6 +2,7 @@
import styled from "styled-components";
import { useSelector } from "react-redux";
import { ContentTypes } from "../../../app/config";
import { getFileIcon } from "../../utils";
import Avatar from "../Avatar";
const Styled = styled.div`
cursor: pointer;
@@ -37,15 +38,26 @@ const Styled = styled.div`
font-size: 14px;
line-height: 20px;
color: #616161;
display: flex;
align-items: center;
.pic {
display: inherit;
max-width: 34px;
}
.icon {
width: 15px;
height: 20px;
}
.name {
margin-left: 5px;
font-size: 10px;
color: #555;
}
}
/* padding-left: 10px; */
`;
const renderContent = (data) => {
const { content_type, content, thumbnail } = data;
const { content_type, content, thumbnail, properties } = data;
let res = null;
switch (content_type) {
case ContentTypes.text:
@@ -55,6 +67,18 @@ const renderContent = (data) => {
case ContentTypes.imageJPG:
res = <img className="pic" src={thumbnail} />;
break;
case ContentTypes.file:
{
const { file_type, name } = properties;
const icon = getFileIcon(file_type, name);
res = (
<>
{icon}
<span className="name">{name}</span>
</>
);
}
break;
default:
break;
+2
View File
@@ -99,6 +99,8 @@ function Message({ contextId = 0, mid = "", context = "user" }) {
/>
) : (
renderContent({
from_uid: fromUid,
created_at: time,
content_type,
properties,
content,
+19 -1
View File
@@ -2,7 +2,10 @@ import Linkify from "react-linkify";
import dayjs from "dayjs";
import MrakdownRender from "../MrakdownRender";
import { getDefaultSize } from "../../utils";
import FileBox from "./FileBox";
const renderContent = ({
from_uid,
created_at,
properties,
content_type,
content,
@@ -53,7 +56,22 @@ const renderContent = ({
className="img preview"
style={{ width: `${width}px`, height: `${height}px` }}
data-origin={content}
src={thumbnail}
src={thumbnail || content}
/>
);
}
break;
case "rustchat/file":
{
const { size, name, file_type } = properties;
ctn = (
<FileBox
from_uid={from_uid}
created_at={created_at}
content={content}
size={size}
name={name}
file_type={file_type}
/>
);
}
+3 -2
View File
@@ -30,7 +30,7 @@ const StyledMsg = styled.div`
border-radius: 50%;
}
}
.details {
> .details {
width: 100%;
display: flex;
flex-direction: column;
@@ -74,8 +74,9 @@ const StyledMsg = styled.div`
cursor: pointer;
}
a {
text-decoration: none;
border-radius: 2px;
background-color: #f5feff;
/* background-color: #f5feff; */
padding: 2px;
color: #1fe1f9;
}
+124
View File
@@ -0,0 +1,124 @@
import { useState, useRef } from "react";
// import { ContentTypes } from "../../app/config";
// import { sliceFile } from "../utils";
import { FILE_SLICE_SIZE } from "../../app/config";
import {
usePrepareUploadFileMutation,
useUploadFileMutation,
} from "../../app/services/message";
import { useSendMsgMutation } from "../../app/services/contact";
import { useSendChannelMsgMutation } from "../../app/services/channel";
export default function useUploadImageMessage({
context = "user",
from = null,
to = null,
}) {
// const slicedRef = useRef(false);
const [uploadingFile, setUploadingFile] = useState(false);
const sliceUploadedCountRef = useRef(0);
const totalSliceCountRef = useRef(1);
// const [uploadedSliceCount, setUploadedSliceCount] = useState(0)
const [
prepareUploadFile,
{ isLoading: isPreparing, isSuccess: isPrepared },
] = usePrepareUploadFileMutation();
const [
uploadFile,
{ isLoading: isUploading, isSuccess: isUploaded, isError: uploadFileError },
] = useUploadFileMutation();
const [
sendChannelMsg,
{
isLoading: channelSending,
isSuccess: channelSuccess,
isError: channelError,
},
] = useSendChannelMsgMutation();
const [
sendUserMsg,
{ isLoading: userSending, isSuccess: userSuccess, isError: userError },
] = useSendMsgMutation();
const sendFn = context == "user" ? sendUserMsg : sendChannelMsg;
const uploadChunk = async (data) => {
const { file_id, chunk, is_last } = data;
const formData = new FormData();
formData.append("file_id", file_id);
formData.append("chunk_data", chunk);
formData.append("chunk_is_last", is_last);
return uploadFile(formData);
};
const sendFileMessage = async (file) => {
if (!file) return;
const { name, type: file_type, size: file_size } = file;
// 拿file id
const { data: file_id } = await prepareUploadFile();
console.log("file id", file_id);
let uploadResult = null;
totalSliceCountRef.current = 1;
setUploadingFile(true);
// 2MB
if (file_size <= 1000 * 1000 * 2) {
// 一次性上传文件
uploadResult = await uploadChunk({ file_id, chunk: file, is_last: true });
sliceUploadedCountRef.current = 1;
} else {
// 分片上传文件
totalSliceCountRef.current = Math.ceil(file_size / FILE_SLICE_SIZE);
const totalSliceCount = totalSliceCountRef.current;
const _arr = new Array(totalSliceCount);
// const chunk=file.slice(block_size * index, block_size * (index + 1));
for await (const [idx] of _arr.entries()) {
try {
const chunk = file.slice(
FILE_SLICE_SIZE * idx,
FILE_SLICE_SIZE * (idx + 1),
file_type
);
if (idx == _arr.length - 1) {
uploadResult = await uploadChunk({ file_id, chunk, is_last: true });
} else {
await uploadChunk({ file_id, chunk, is_last: false });
}
sliceUploadedCountRef.current = sliceUploadedCountRef.current + 1;
} catch (error) {
return;
}
}
console.log("wtfff", uploadResult);
}
setUploadingFile(false);
const {
data: { path, size, hash },
} = uploadResult;
const content = JSON.stringify({
name,
size,
hash,
path,
});
console.log("upload content", content);
await sendFn({
id: to,
content,
type: "file",
properties: { file_type },
from_uid: from,
});
};
const isSending =
userSending || channelSending || isPreparing || uploadingFile;
return {
progress: isSending
? sliceUploadedCountRef.current / totalSliceCountRef.current
: 1,
sendFileMessage,
isError: channelError || userError || uploadFileError,
isSending,
isSuccess: (channelSuccess || userSuccess) && isPrepared,
};
}
+39
View File
@@ -0,0 +1,39 @@
// import second from 'first'
import { useSendChannelMsgMutation } from "../../app/services/channel";
import { useSendMsgMutation } from "../../app/services/contact";
export default function useSendImageMessage({
context = "user",
from = null,
to = null,
}) {
const [
sendChannelMsg,
{
isLoading: channelSending,
isSuccess: channelSuccess,
isError: channelError,
},
] = useSendChannelMsgMutation();
const [
sendUserMsg,
{ isLoading: userSending, isSuccess: userSuccess, isError: userError },
] = useSendMsgMutation();
const uploadFn = context == "user" ? sendUserMsg : sendChannelMsg;
const sendImageMessage = (file) => {
if (!file) return;
const { name, size, type } = file;
uploadFn({
id: to,
content: file,
properties: { name, size, type, local_id: new Date().getTime() },
type: "image",
from_uid: from,
});
};
return {
sendImageMessage,
isError: channelError || userError,
isSending: userSending || channelSending,
isSuccess: channelSuccess || userSuccess,
};
}
+78
View File
@@ -1,3 +1,10 @@
import IconPdf from "../assets/icons/file.pdf.svg";
import IconAudio from "../assets/icons/file.audio.svg";
import IconVideo from "../assets/icons/file.video.svg";
import IconUnkown from "../assets/icons/file.unkown.svg";
import IconDoc from "../assets/icons/file.doc.svg";
import IconCode from "../assets/icons/file.code.svg";
import IconImage from "../assets/icons/file.image.svg";
export const isObjectEqual = (obj1, obj2) => {
let o1 = Object.entries(obj1 ?? {})
.sort()
@@ -7,6 +14,17 @@ export const isObjectEqual = (obj1, obj2) => {
.toString();
return o1 === o2;
};
export const isTreatAsImage = (file) => {
let isImage = false;
if (!file) return isImage;
const { type, size } = file;
if (type.startsWith("image")) {
// 10MB
return size < 1000 * 1000;
}
return isImage;
};
export const getNonNullValues = (obj, whiteList = ["log_id"]) => {
const tmp = {};
Object.keys(obj).forEach((k) => {
@@ -86,3 +104,63 @@ export const getInitialsAvatar = ({
/* istanbul ignore next */
return canvas.toDataURL("image/png");
};
/**
* @param {File|Blob} - file to slice
* @param {Number} - chunksAmount
* @return {Array} - an array of Blobs
**/
export function sliceFile(file, chunksAmount) {
if (!file) return null;
let byteIndex = 0;
let chunks = [];
for (let i = 0; i < chunksAmount; i += 1) {
let byteEnd = Math.ceil((file.size / chunksAmount) * (i + 1));
chunks.push(file.slice(byteIndex, byteEnd));
byteIndex += byteEnd - byteIndex;
}
return chunks;
}
export const getFileIcon = (type, name = "") => {
let icon = null;
const checks = {
image: /^image/gi,
audio: /^audio/gi,
video: /^video/gi,
code: /(json|javascript|java|rb|c|php|xml|css|html)$/gi,
doc: /^text/gi,
pdf: /\/pdf$/gi,
};
const _arr = name.split(".");
const _type = type || _arr[_arr.length - 1];
switch (true) {
case checks.image.test(_type):
{
console.log("image");
icon = <IconImage className="icon" />;
}
break;
case checks.pdf.test(_type):
icon = <IconPdf className="icon" />;
break;
case checks.code.test(_type):
icon = <IconCode className="icon" />;
break;
case checks.doc.test(_type):
icon = <IconDoc className="icon" />;
break;
case checks.audio.test(_type):
icon = <IconAudio className="icon" />;
break;
case checks.video.test(_type):
icon = <IconVideo className="icon" />;
break;
default:
icon = <IconUnkown className="icon" />;
break;
}
return icon;
};
-1
View File
@@ -62,7 +62,6 @@ export default function HomePage() {
<div className="divider"></div>
{/* <Tools expand={menuExpand} /> */}
<Menu toggle={toggleExpand} expand={menuExpand} />
{/* <CurrentUser expand={menuExpand} /> */}
</div>
<div className="col right">
<Outlet />