feat: send file message
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -99,6 +99,8 @@ function Message({ contextId = 0, mid = "", context = "user" }) {
|
||||
/>
|
||||
) : (
|
||||
renderContent({
|
||||
from_uid: fromUid,
|
||||
created_at: time,
|
||||
content_type,
|
||||
properties,
|
||||
content,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user