diff --git a/src/app/config.js b/src/app/config.js index 7a352b3e..82dcfb9c 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -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"; diff --git a/src/app/slices/message.js b/src/app/slices/message.js index 1cdb63be..a08a6923 100644 --- a/src/app/slices/message.js +++ b/src/app/slices/message.js @@ -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) { diff --git a/src/app/slices/message.user.js b/src/app/slices/message.user.js index 898649bb..52478bc4 100644 --- a/src/app/slices/message.user.js +++ b/src/app/slices/message.user.js @@ -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); diff --git a/src/common/component/Message/FileBox.js b/src/common/component/Message/FileBox.js new file mode 100644 index 00000000..86701a53 --- /dev/null +++ b/src/common/component/Message/FileBox.js @@ -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 ( + + {icon} +
+ {name} + + {formatBytes(size)} + {dayjs(created_at).fromNow()} + + by {fromUser.name} + + +
+ + + +
+ ); +} diff --git a/src/common/component/Message/Reply.js b/src/common/component/Message/Reply.js index 13a83528..4840ab8a 100644 --- a/src/common/component/Message/Reply.js +++ b/src/common/component/Message/Reply.js @@ -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 = ; break; + case ContentTypes.file: + { + const { file_type, name } = properties; + const icon = getFileIcon(file_type, name); + res = ( + <> + {icon} + {name} + + ); + } + break; default: break; diff --git a/src/common/component/Message/index.js b/src/common/component/Message/index.js index 87442d13..6e99e3f6 100644 --- a/src/common/component/Message/index.js +++ b/src/common/component/Message/index.js @@ -99,6 +99,8 @@ function Message({ contextId = 0, mid = "", context = "user" }) { /> ) : ( renderContent({ + from_uid: fromUid, + created_at: time, content_type, properties, content, diff --git a/src/common/component/Message/renderContent.js b/src/common/component/Message/renderContent.js index 81b0e387..5d40e2e3 100644 --- a/src/common/component/Message/renderContent.js +++ b/src/common/component/Message/renderContent.js @@ -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 = ( + ); } diff --git a/src/common/component/Message/styled.js b/src/common/component/Message/styled.js index f6a818c2..cf8c5a3e 100644 --- a/src/common/component/Message/styled.js +++ b/src/common/component/Message/styled.js @@ -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; } diff --git a/src/common/hook/useSendFileMessage.js b/src/common/hook/useSendFileMessage.js new file mode 100644 index 00000000..5778d7d8 --- /dev/null +++ b/src/common/hook/useSendFileMessage.js @@ -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, + }; +} diff --git a/src/common/hook/useSendImageMessage.js b/src/common/hook/useSendImageMessage.js new file mode 100644 index 00000000..b518a62c --- /dev/null +++ b/src/common/hook/useSendImageMessage.js @@ -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, + }; +} diff --git a/src/common/utils.js b/src/common/utils.js index f27e07cb..da504e65 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -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 = ; + } + break; + case checks.pdf.test(_type): + icon = ; + break; + case checks.code.test(_type): + icon = ; + break; + case checks.doc.test(_type): + icon = ; + break; + case checks.audio.test(_type): + icon = ; + break; + case checks.video.test(_type): + icon = ; + break; + + default: + icon = ; + break; + } + return icon; +}; diff --git a/src/routes/home/index.js b/src/routes/home/index.js index b33accf8..7dd1090d 100644 --- a/src/routes/home/index.js +++ b/src/routes/home/index.js @@ -62,7 +62,6 @@ export default function HomePage() {
{/* */} - {/* */}