From b34eee726bf81720afc18194de1c7a6f8c029fd5 Mon Sep 17 00:00:00 2001 From: zerosoul Date: Fri, 22 Apr 2022 17:04:58 +0800 Subject: [PATCH] feat: brand new send file UX --- src/app/slices/message.js | 3 +- src/app/slices/ui.js | 80 ++++++++- src/assets/icons/delete.svg | 3 + src/common/component/FileBox/index.js | 2 +- src/common/component/FileMessage/Image.js | 24 +++ src/common/component/FileMessage/Progress.js | 21 +++ src/common/component/FileMessage/index.js | 147 ++++++++++++++++ src/common/component/FileMessage/styled.js | 61 +++++++ src/common/component/ImagePreviewModal.js | 8 +- src/common/component/Message/Reply.js | 6 +- src/common/component/Message/index.js | 4 + src/common/component/Message/renderContent.js | 113 ++++++++----- src/common/component/Message/styled.js | 4 +- src/common/component/MixedInput/index.js | 70 ++++---- src/common/component/Send/Replying.js | 12 +- src/common/component/Send/Toolbar.js | 76 ++++----- .../Send/UploadFileList/EditFileDetails.js | 78 +++++++++ .../component/Send/UploadFileList/index.js | 83 +++++++++ .../component/Send/UploadFileList/styled.js | 71 ++++++++ src/common/component/Send/index.js | 159 ++++++++++-------- src/common/component/Send/styled.js | 40 ++--- src/common/hook/useAddLocalFileMessage.js | 15 ++ src/common/hook/useRemoveLocalMessage.js | 15 ++ src/common/hook/useSendMessage.js | 86 ++++++++-- src/common/hook/useUploadFile.js | 18 +- src/common/utils.js | 23 ++- src/routes/chat/Layout.js | 69 ++++---- src/routes/chat/utils.js | 3 +- 28 files changed, 1003 insertions(+), 291 deletions(-) create mode 100644 src/assets/icons/delete.svg create mode 100644 src/common/component/FileMessage/Image.js create mode 100644 src/common/component/FileMessage/Progress.js create mode 100644 src/common/component/FileMessage/index.js create mode 100644 src/common/component/FileMessage/styled.js create mode 100644 src/common/component/Send/UploadFileList/EditFileDetails.js create mode 100644 src/common/component/Send/UploadFileList/index.js create mode 100644 src/common/component/Send/UploadFileList/styled.js create mode 100644 src/common/hook/useAddLocalFileMessage.js create mode 100644 src/common/hook/useRemoveLocalMessage.js diff --git a/src/app/slices/message.js b/src/app/slices/message.js index c74363a7..2c369481 100644 --- a/src/app/slices/message.js +++ b/src/app/slices/message.js @@ -33,7 +33,8 @@ const messageSlice = createSlice({ data.file_path )}&download=true`; // image - const isPic = isImage(properties.file_type, properties.size); + const props = properties ?? {}; + const isPic = isImage(props.content_type, props.size); if (isPic) { data.thumbnail = `${BASE_URL}/resource/file?file_path=${encodeURIComponent( data.file_path diff --git a/src/app/slices/ui.js b/src/app/slices/ui.js index 8eb0e4dc..9545d491 100644 --- a/src/app/slices/ui.js +++ b/src/app/slices/ui.js @@ -3,9 +3,14 @@ import { Views } from "../config"; const initialState = { online: true, ready: false, + userGuide: { + visible: false, + step: 1, + }, inputMode: "text", menuExpand: false, - fileListView: Views.item, + fileListView: Views.grid, + uploadFiles: {}, }; const uiSlice = createSlice({ name: "ui", @@ -29,6 +34,77 @@ const uiSlice = createSlice({ updateFileListView(state, action) { state.fileListView = action.payload; }, + updateUserGuide(state, action) { + const obj = action.payload || {}; + Object.keys(obj).forEach((key) => { + state.userGuide[key] = obj[key]; + }); + }, + updateUploadFiles(state, action) { + const { + context = "channel", + id = null, + operation = "add", + ...rest + } = action.payload; + if (!id || !context) return; + const _key = `${context}_${id}`; + let files = state.uploadFiles[_key]; + switch (operation) { + case "add": + { + const { data } = rest; + const isArray = Array.isArray(data); + console.log("add opt", data, files, isArray); + if (files) { + if (isArray) { + data.forEach((item) => { + files.push(item); + }); + // files = [...files, ...data]; + } else { + files.push(rest); + } + } else { + state.uploadFiles[_key] = isArray ? data : [data]; + } + } + + break; + + case "reset": + { + state.uploadFiles[_key] = []; + } + + break; + case "remove": + { + const { index } = rest; + const file = files[index]; + if (file) { + files.splice(index, 1); + URL.revokeObjectURL(file.url); + } + } + + break; + + case "update": + { + const { index, name } = rest; + const file = files[index]; + if (file) { + file.name = name; + } + } + + break; + + default: + break; + } + }, }, }); export const { @@ -38,5 +114,7 @@ export const { updateInputMode, toggleMenuExpand, updateFileListView, + updateUploadFiles, + updateUserGuide, } = uiSlice.actions; export default uiSlice.reducer; diff --git a/src/assets/icons/delete.svg b/src/assets/icons/delete.svg new file mode 100644 index 00000000..6ed0c560 --- /dev/null +++ b/src/assets/icons/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/common/component/FileBox/index.js b/src/common/component/FileBox/index.js index 09b431da..868e38ce 100644 --- a/src/common/component/FileBox/index.js +++ b/src/common/component/FileBox/index.js @@ -60,7 +60,7 @@ const renderPreview = (data) => { export default function FileBox({ preview = false, flex, - file_type, + file_type = "", name, size, created_at, diff --git a/src/common/component/FileMessage/Image.js b/src/common/component/FileMessage/Image.js new file mode 100644 index 00000000..6c304ea3 --- /dev/null +++ b/src/common/component/FileMessage/Image.js @@ -0,0 +1,24 @@ +// import React from 'react' +import { getDefaultSize } from "../../utils"; +export default function Image({ + thumbnail, + download, + content, + properties = {}, +}) { + const { width = 0, height = 0 } = getDefaultSize(properties); + console.log("image props", properties, width, height); + return ( + + ); +} diff --git a/src/common/component/FileMessage/Progress.js b/src/common/component/FileMessage/Progress.js new file mode 100644 index 00000000..bc126cb2 --- /dev/null +++ b/src/common/component/FileMessage/Progress.js @@ -0,0 +1,21 @@ +// import React from 'react' +import styled from "styled-components"; +const Styled = styled.div` + background: #ecfdff; + border-radius: 4px; + height: 8px; + overflow: hidden; + .progress { + transition: all 0.25s ease; + height: 8px; + background: #088ab2; + border-radius: 4px; + } +`; +export default function Progress({ value, width = "100%" }) { + return ( + +
+
+ ); +} diff --git a/src/common/component/FileMessage/index.js b/src/common/component/FileMessage/index.js new file mode 100644 index 00000000..a52c2fb8 --- /dev/null +++ b/src/common/component/FileMessage/index.js @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useSelector } from "react-redux"; +import Styled from "./styled"; +import Image from "./Image"; +import useRemoveLocalMessage from "../../hook/useRemoveLocalMessage"; +import useUploadFile from "../../hook/useUploadFile"; +import useSendMessage from "../../hook/useSendMessage"; +import Progress from "./Progress"; +import { getFileIcon, formatBytes, isImage, getImageSize } from "../../utils"; +import IconDownload from "../../../assets/icons/download.svg"; +import IconClose from "../../../assets/icons/close.circle.svg"; +dayjs.extend(relativeTime); +export default function FileMessage({ + context = "", + to = null, + created_at, + from_uid = null, + content = "", + download = "", + thumbnail = "", + properties = { local_id: 0, name: "", size: 0, content_type: "" }, +}) { + const [imageSize, setImageSize] = useState(null); + const [uploadinFile, setUploadinFile] = useState(false); + const removeLocalMessage = useRemoveLocalMessage({ context, id: to }); + const { + sendMessage, + isSuccess: sendMessageSuccess, + isSending, + } = useSendMessage({ + context, + from: from_uid, + to, + }); + const { + stopUploading, + data, + uploadFile, + progress, + isSuccess: uploadSuccess, + } = useUploadFile(); + const fromUser = useSelector((store) => store.contacts.byId[from_uid]); + const { size, name, content_type } = properties ?? {}; + useEffect(() => { + const handleUpSend = async ({ url, name, type }) => { + try { + setUploadinFile(true); + if (type.startsWith("image")) { + const size = await getImageSize(url); + setImageSize(size); + } + let file = await fetch(url) + .then((r) => r.blob()) + .then((blobFile) => new File([blobFile], name, { type })); + + await uploadFile(file); + setUploadinFile(false); + } catch (error) { + setUploadinFile(false); + console.log("fetch local file error", error); + } + }; + // local file + if (content && typeof content == "string" && content.startsWith("blob:")) { + handleUpSend({ url: content, name, type: content_type }); + } + }, [content, name, content_type]); + useEffect(() => { + const props = properties ?? {}; + const propsV2 = imageSize ? { ...props, ...imageSize } : props; + if (uploadSuccess) { + // 把已经上传的东西当做消息发出去 + const { path } = data; + sendMessage({ + ignoreLocal: true, + type: "file", + content: { path }, + properties: propsV2, + }); + } + }, [uploadSuccess, data, properties]); + useEffect(() => { + if (sendMessageSuccess) { + // 回收本地资源 + // URL.revokeObjectURL(content); + } + }, [sendMessageSuccess, content]); + const handleCancel = () => { + stopUploading(); + removeLocalMessage(properties.local_id); + }; + if (!properties) return null; + const icon = getFileIcon(content_type, name); + + if (!content || !fromUser || !name) return null; + + console.log("file content", content, name, content_type, size); + if (isImage(content_type, size)) + return ( + + ); + const sending = uploadinFile || isSending; + return ( + +
+ {icon} +
+ {name} + + {/* */} + {sending ? ( + + ) : ( + <> + {formatBytes(size)} + {dayjs(created_at).fromNow()} + + by {fromUser.name} + + + )} + +
+ {/* */} + {sending ? ( + + ) : ( + + + + )} +
+
+ ); +} diff --git a/src/common/component/FileMessage/styled.js b/src/common/component/FileMessage/styled.js new file mode 100644 index 00000000..6b987328 --- /dev/null +++ b/src/common/component/FileMessage/styled.js @@ -0,0 +1,61 @@ +import styled from "styled-components"; +const Styled = styled.div` + background: #f3f4f6; + border: 1px solid #d4d4d4; + box-sizing: border-box; + border-radius: 6px; + width: 370px; + height: 66px; + /* height: fit-content; */ + &.sending { + /* opacity: 0.9; */ + } + * { + user-select: text; + } + .basic { + padding: 8px; + 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%; + overflow: hidden; + .name { + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: #1c1c1e; + white-space: nowrap; + text-overflow: ellipsis; + } + .details { + white-space: nowrap; + font-weight: 400; + font-size: 12px; + line-height: 18px; + color: #616161; + display: flex; + gap: 16px; + .from strong { + font-weight: bold; + } + } + } + .download { + white-space: nowrap; + } + .cancel { + cursor: pointer; + } + } +`; +export default Styled; diff --git a/src/common/component/ImagePreviewModal.js b/src/common/component/ImagePreviewModal.js index b48d4952..437b1b76 100644 --- a/src/common/component/ImagePreviewModal.js +++ b/src/common/component/ImagePreviewModal.js @@ -67,7 +67,7 @@ export default function ImagePreviewModal({ // }); // }; if (!data) return null; - const { originUrl, name, type } = data; + const { originUrl, downloadLink, name, type } = data; return ( @@ -84,9 +84,9 @@ export default function ImagePreviewModal({ className="origin" download={name} type={type} - href={originUrl} - // target="_blank" - // rel="noreferrer" + href={downloadLink || originUrl} + target="_blank" + rel="noreferrer" > Download original diff --git a/src/common/component/Message/Reply.js b/src/common/component/Message/Reply.js index ac49453f..9e404e5e 100644 --- a/src/common/component/Message/Reply.js +++ b/src/common/component/Message/Reply.js @@ -116,9 +116,9 @@ const renderContent = (data) => { break; case ContentTypes.file: { - const { file_type, name, size } = properties; - const icon = getFileIcon(file_type, name); - if (isImage(file_type, size)) { + const { content_type, name, size } = properties; + const icon = getFileIcon(content_type, name); + if (isImage(content_type, size)) { res = ; } else { res = ( diff --git a/src/common/component/Message/index.js b/src/common/component/Message/index.js index 067b9940..23eb0623 100644 --- a/src/common/component/Message/index.js +++ b/src/common/component/Message/index.js @@ -47,6 +47,7 @@ function Message({ sending = false, content, thumbnail, + download, content_type = "text/plain", edited, properties, @@ -114,12 +115,15 @@ function Message({ /> ) : ( renderContent({ + context, + to: contextId, from_uid: fromUid, created_at: time, content_type, properties, content, thumbnail, + download, edited, }) )} diff --git a/src/common/component/Message/renderContent.js b/src/common/component/Message/renderContent.js index 7f6029a6..c546be37 100644 --- a/src/common/component/Message/renderContent.js +++ b/src/common/component/Message/renderContent.js @@ -1,27 +1,28 @@ -import React from "react"; - -// import * as linkfy from "linkifyjs"; +import React, { useState, useEffect } from "react"; import Linkit from "react-linkify"; - import dayjs from "dayjs"; +import BASE_URL, { ContentTypes } from "../../../app/config"; import Mention from "./Mention"; import MrakdownRender from "../MrakdownRender"; -import { getDefaultSize, isImage } from "../../utils"; -import FileBox from "../FileBox"; +import FileMessage from "../FileMessage"; import URLPreview from "./URLPreview"; import reactStringReplace from "react-string-replace"; +import { useGetArchiveMessageQuery } from "../../../app/services/message"; const renderContent = ({ + context, + to, from_uid, created_at, properties, content_type, content, + download, thumbnail, edited = false, }) => { let ctn = null; switch (content_type) { - case "text/plain": + case ContentTypes.text: ctn = ( <> ); break; - case "text/markdown": + case ContentTypes.markdown: { ctn = ; } break; - case "rustchat/file": + case ContentTypes.file: { - const { size, name, file_type } = properties; - if (isImage(file_type, size)) { - const { width, height } = getDefaultSize(properties); - ctn = ( - - ); - } else { - ctn = ( - - ); - } + // const { size, name, file_type } = properties; + ctn = ( + + ); + } + break; + case ContentTypes.archive: + { + // const { size, name, file_type } = properties; + ctn = ( + + ); } break; @@ -110,5 +111,39 @@ const renderContent = ({ } return ctn; }; +const ForwardedMessage = ({ context, to, from_uid, id }) => { + const [forwards, setForwards] = useState(null); + const { data, isSuccess } = useGetArchiveMessageQuery(id); + useEffect(() => { + if (isSuccess && data) { + const msgs = data.messages.map( + ({ content, file_id, thumbnail_id, content_type, properties }, idx) => { + const transformedContent = + content_type == ContentTypes.file + ? `${BASE_URL}/resource/archive/attachment?file_path=${id}&attachment_id=${file_id}` + : content; + const thumbnail = + content_type == ContentTypes.file + ? `${BASE_URL}/resource/archive/attachment?file_path=${id}&attachment_id=${thumbnail_id}` + : ""; + return renderContent({ + context, + to, + from_uid, + content: transformedContent, + content_type, + properties, + thumbnail, + }); + } + ); + setForwards(msgs); + } + }, [isSuccess, data]); + console.log("archive data", data); + if (!id) return null; + + return forwards; +}; export default renderContent; diff --git a/src/common/component/Message/styled.js b/src/common/component/Message/styled.js index 9de5864f..6db0a4a2 100644 --- a/src/common/component/Message/styled.js +++ b/src/common/component/Message/styled.js @@ -70,10 +70,10 @@ const StyledMsg = styled.div` font-size: 10px; } &.sending { - opacity: 0.5; + opacity: 0.9; } .img { - max-width: 400px; + max-width: 480px; cursor: pointer; } > .link { diff --git a/src/common/component/MixedInput/index.js b/src/common/component/MixedInput/index.js index ac71ab8d..4245b834 100644 --- a/src/common/component/MixedInput/index.js +++ b/src/common/component/MixedInput/index.js @@ -1,5 +1,6 @@ -import { useRef, useState, useCallback } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import { useKey } from "rooks"; +import { useDispatch } from "react-redux"; import { useSelector } from "react-redux"; import { Editor, Transforms } from "slate"; import { @@ -9,7 +10,7 @@ import { createTrailingBlockPlugin, createNodeIdPlugin, createParagraphPlugin, - createImagePlugin, + // createImagePlugin, createSoftBreakPlugin, createComboboxPlugin, createMentionPlugin, @@ -22,18 +23,14 @@ import { ELEMENT_PARAGRAPH, getPlateEditorRef, // usePlateEditorRef, - ELEMENT_IMAGE, + // ELEMENT_IMAGE, MentionCombobox, - // usePlateStore } from "@udecode/plate"; import { ReactEditor } from "slate-react"; import Styled from "./styled"; -// import StyledCombobox from "./StyledCombobox"; -import ImageElement from "./ImageElement"; import { CONFIG } from "./config"; import Contact from "../Contact"; -import useUploadFile from "../../hook/useUploadFile"; -// import Mention from "./Mention"; +import { updateUploadFiles } from "../../../app/slices/ui"; export const TEXT_EDITOR_PREFIX = "rustchat_text_editor"; export const setEditorFocus = (edtr) => { console.log("focus", edtr); @@ -51,7 +48,7 @@ export const clearEditorAndFocus = (edtr) => { }; let components = createPlateUI({ - [ELEMENT_IMAGE]: ImageElement, + // [ELEMENT_IMAGE]: ImageElement, // customize your components by plugin key }); const initialValue = [{ type: ELEMENT_PARAGRAPH, children: [{ text: "" }] }]; @@ -61,20 +58,40 @@ const Plugins = ({ sendMessages, members = [], }) => { + const dispatch = useDispatch(); const enableMentions = members.length > 0; - const { uploadFile } = useUploadFile(); const filesRef = useRef([]); - // const plateEditor = usePlateEditorRef(`${TEXT_EDITOR_PREFIX}_${id}`); const contactData = useSelector((store) => store.contacts.byId); const [msgs, setMsgs] = useState([]); const [cmdKey, setCmdKey] = useState(false); const editableRef = useRef(null); - // const editor = useEditorRef(); const initialProps = { ...CONFIG.editableProps, className: "box", placeholder, }; + useEffect(() => { + const handlePasteEvent = (evt) => { + const files = [...evt.clipboardData.files]; + if (files.length) { + const filesData = files.map((file) => { + const { size, type, name } = file; + console.log("paste event name", name); + const url = URL.createObjectURL(file); + return { size, type, name, url }; + }); + const [context, to] = id.split("_"); + + console.log("paste event", context, to, files, evt); + dispatch(updateUploadFiles({ context, id: to, data: filesData })); + } + }; + window.addEventListener("paste", handlePasteEvent); + return () => { + window.removeEventListener("paste", handlePasteEvent); + }; + // window.addEventListener("paste") + }, []); useKey( "Enter", @@ -108,19 +125,6 @@ const Plugins = ({ ); const pluginArr = [ createParagraphPlugin(), - createImagePlugin({ - options: { - uploadImage: async (dataUrl) => { - console.log("upload image", dataUrl); - const resp = await fetch(dataUrl); - const blob = await resp.blob(); - const { thumbnail, ...rest } = await uploadFile(blob); - const { name, file_type, size, path, hash } = rest; - filesRef.current.push({ name, file_type, size, path, hash }); - return thumbnail; - }, - }, - }), createNodeIdPlugin(), createSoftBreakPlugin(CONFIG.softBreak), createTrailingBlockPlugin(CONFIG.trailingBlock), @@ -153,18 +157,6 @@ const Plugins = ({ const handleChange = useCallback( async (val) => { console.log("tmps changed", val); - // const wtf = getMentionOnSelectItem(); - // const plateEditor = getPlateEditorRef(`${TEXT_EDITOR_PREFIX}_${id}`); - // const currentMentionInput = findMentionInput(plateEditor); - // const items = comboboxSelectors.filteredItems(); - // console.log( - // "mention check", - // items, - // isSelectionInMentionInput(plateEditor) - // ); - // if (items?.length == 0 && isSelectionInMentionInput(plateEditor)) { - // removeMentionInput(plateEditor, currentMentionInput[1]); - // } const tmps = []; const getMixedText = (children) => { const mentions = []; @@ -270,7 +262,3 @@ const Plugins = ({ ); }; export default Plugins; -// export default memo(Plugins, (prevs, nexts) => { -// console.log("placeholder", prevs.placeholder, nexts.placeholder); -// return prevs.placeholder == nexts.placeholder; -// }); diff --git a/src/common/component/Send/Replying.js b/src/common/component/Send/Replying.js index 51012740..fb09a9a2 100644 --- a/src/common/component/Send/Replying.js +++ b/src/common/component/Send/Replying.js @@ -8,6 +8,7 @@ import { getFileIcon, isImage } from "../../utils"; import { removeReplyingMessage } from "../../../app/slices/message"; import styled from "styled-components"; const Styled = styled.div` + background-color: #f3f4f6; z-index: 999; display: flex; justify-content: space-between; @@ -16,11 +17,6 @@ const Styled = styled.div` gap: 16px; border-top-left-radius: 8px; border-top-right-radius: 8px; - background-color: #f3f4f6; - position: absolute; - left: 0; - top: 0; - transform: translateY(-100%); width: 100%; padding: 12px 16px; white-space: nowrap; @@ -99,11 +95,11 @@ const renderContent = (data) => { break; case ContentTypes.file: { - const { file_type, name, size } = properties; - if (isImage(file_type, size)) { + const { content_type, name, size } = properties; + if (isImage(content_type, size)) { res = ; } else { - const icon = getFileIcon(file_type, name); + const icon = getFileIcon(content_type, name); res = ( <> {icon} diff --git a/src/common/component/Send/Toolbar.js b/src/common/component/Send/Toolbar.js index 96c07470..98576db2 100644 --- a/src/common/component/Send/Toolbar.js +++ b/src/common/component/Send/Toolbar.js @@ -1,8 +1,8 @@ -import { useState, useRef } from "react"; +import { useRef } from "react"; import styled from "styled-components"; +import { useDispatch } from "react-redux"; +import { updateUploadFiles } from "../../../app/slices/ui"; import Tooltip from "../../component/Tooltip"; - -import UploadModal from "../../component/UploadModal"; import AddIcon from "../../../assets/icons/add.solid.svg"; import MarkdownIcon from "../../../assets/icons/markdown.svg"; const Styled = styled.div` @@ -40,51 +40,45 @@ const Styled = styled.div` } `; export default function Toolbar({ toggleMode, mode, to, context }) { - const [files, setFiles] = useState([]); + const dispatch = useDispatch(); const fileInputRef = useRef(null); - const resetFiles = () => { - setFiles([]); - fileInputRef.current.value = ""; - }; const handleUpload = (evt) => { const files = [...evt.target.files]; console.log("files", files); - setFiles([...evt.target.files]); + const filesData = files.map((file) => { + const { size, type, name } = file; + const url = URL.createObjectURL(file); + return { size, type, name, url }; + }); + dispatch(updateUploadFiles({ context, id: to, data: filesData })); + fileInputRef.current.value = null; + fileInputRef.current.value = ""; + // setFiles([...evt.target.files]); }; return ( - <> - {files.length !== 0 && ( - - )} - -
- - - -
- -
- - -
+ +
+ + - - +
+ +
+ + +
+
+
); } diff --git a/src/common/component/Send/UploadFileList/EditFileDetails.js b/src/common/component/Send/UploadFileList/EditFileDetails.js new file mode 100644 index 00000000..fa958e93 --- /dev/null +++ b/src/common/component/Send/UploadFileList/EditFileDetails.js @@ -0,0 +1,78 @@ +import { useState } from "react"; +import styled from "styled-components"; +import Modal from "../../Modal"; +import Button from "../../styled/Button"; +import Input from "../../styled/Input"; +import CloseIcon from "../../../../assets/icons/close.svg"; +const StyledWrapper = styled.div` + background: #fff; + display: flex; + flex-direction: column; + filter: drop-shadow(0px 25px 50px rgba(31, 41, 55, 0.25)); + border-radius: 12px; + padding: 16px; + width: 406px; + .title { + display: flex; + align-items: center; + justify-content: space-between; + font-style: normal; + font-weight: 600; + font-size: 18px; + line-height: 28px; + color: #344054; + width: 100%; + .close { + cursor: pointer; + } + } + .input { + padding: 16px 0; + display: flex; + flex-direction: column; + gap: 8px; + label { + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: #475467; + } + } + .btns { + margin-top: 32px; + gap: 16px; + display: flex; + width: 100%; + justify-content: flex-end; + } +`; + +export default function EditFileDetails({ name, closeModal, updateName }) { + const [fileName, setFileName] = useState(name); + const handleNameChange = (evt) => { + setFileName(evt.target.value); + }; + const handleUpdate = () => { + updateName(fileName); + closeModal(); + }; + return ( + + +

+ File Details +

+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/common/component/Send/UploadFileList/index.js b/src/common/component/Send/UploadFileList/index.js new file mode 100644 index 00000000..79628f7c --- /dev/null +++ b/src/common/component/Send/UploadFileList/index.js @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Styled from "./styled"; +import EditFileDetailsModal from "./EditFileDetails"; +import { updateUploadFiles } from "../../../../app/slices/ui"; +import { getFileIcon, formatBytes } from "../../../utils"; +import EditIcon from "../../../../assets/icons/edit.svg"; +import DeleteIcon from "../../../../assets/icons/delete.svg"; + +export default function UploadFileList({ context = "", id = null }) { + const [editInfo, setEditInfo] = useState(null); + const dispatch = useDispatch(); + const files = useSelector( + (store) => store.ui.uploadFiles[`${context}_${id}`] + ); + const toggleModalVisible = (info) => { + setEditInfo((prev) => (prev ? null : info)); + }; + const handleOpenEditModal = (idx) => { + const info = files[idx]; + if (!info) return; + + toggleModalVisible({ ...info, index: idx }); + }; + const handleRemove = (idx) => { + dispatch( + updateUploadFiles({ context, id, operation: "remove", index: idx }) + ); + }; + const updateFileName = (name) => { + if (!name) return; + const { index } = editInfo; + dispatch( + updateUploadFiles({ context, id, operation: "update", index, name }) + ); + }; + if (!context || !id || !files || files.length == 0) return null; + console.log("upload files", files); + return ( + <> + {editInfo && ( + + )} + + + {files.map(({ name, url, size, type }, idx) => { + return ( +
  • +
    + {type.startsWith("image") ? ( + image + ) : ( + getFileIcon(type, name) + )} +
    +

    {name}

    + {formatBytes(size)} +
      +
    • + +
    • +
    • + +
    • +
    +
  • + ); + })} +
    + + ); +} diff --git a/src/common/component/Send/UploadFileList/styled.js b/src/common/component/Send/UploadFileList/styled.js new file mode 100644 index 00000000..b0f65632 --- /dev/null +++ b/src/common/component/Send/UploadFileList/styled.js @@ -0,0 +1,71 @@ +import styled from "styled-components"; +const Styled = styled.ul` + width: 100%; + overflow: auto; + display: flex; + justify-content: flex-start; + gap: 24px; + width: 100%; + padding: 24px 16px 16px 16px; + background: #e5e7eb; + box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05); + border-radius: 8px 8px 0px 0px; + .file { + position: relative; + display: flex; + flex-direction: column; + + background: #fcfcfd; + border-radius: 4px; + padding: 8px; + .preview { + display: flex; + justify-content: center; + align-items: center; + width: 160px; + height: 160px; + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + .name { + width: 160px; + margin: 16px 0 2px 0; + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: #1c1c1e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .size { + font-weight: 400; + font-size: 12px; + line-height: 18px; + color: #616161; + } + .opts { + visibility: hidden; + background: inherit; + border: 1px solid rgba(0, 0, 0, 0.08); + box-sizing: border-box; + border-radius: 6px; + display: flex; + align-items: center; + position: absolute; + right: -20px; + top: -10px; + .opt { + padding: 4px; + cursor: pointer; + } + } + &:hover .opts { + visibility: visible; + } + } +`; +export default Styled; diff --git a/src/common/component/Send/index.js b/src/common/component/Send/index.js index 0a2aa03a..fec0e7c7 100644 --- a/src/common/component/Send/index.js +++ b/src/common/component/Send/index.js @@ -3,12 +3,15 @@ import { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; // import { useKey } from "rooks"; import { getPlateEditorRef } from "@udecode/plate"; -import { updateInputMode } from "../../../app/slices/ui"; + +import useSendMessage from "../../hook/useSendMessage"; +import useAddLocalFileMessage from "../../hook/useAddLocalFileMessage"; import { removeReplyingMessage } from "../../../app/slices/message"; -import { useSendChannelMsgMutation } from "../../../app/services/channel"; -import { useSendMsgMutation } from "../../../app/services/contact"; -import { useReplyMessageMutation } from "../../../app/services/message"; +import { updateInputMode, updateUploadFiles } from "../../../app/slices/ui"; +import { ContentTypes } from "../../../app/config"; + import StyledSend from "./styled"; +import UploadFileList from "./UploadFileList"; import Replying from "./Replying"; import Toolbar from "./Toolbar"; import EmojiPicker from "./EmojiPicker"; @@ -31,21 +34,20 @@ function Send({ id = "", }) { const [markdownEditor, setMarkdownEditor] = useState(null); - const [replyMessage] = useReplyMessageMutation(); const dispatch = useDispatch(); + const addLocalFileMesage = useAddLocalFileMessage({ context, to: id }); // 谁发的 - const { from_uid, replying_mid = null, mode } = useSelector((store) => { - return { - mode: store.ui.inputMode, - from_uid: store.authData.uid, - replying_mid: store.message.replying[id], - }; - }); - - const [sendMsg] = useSendMsgMutation(); - const [sendChannelMsg] = useSendChannelMsgMutation(); - const sendMessage = context == "channel" ? sendChannelMsg : sendMsg; - // const sendingMessage = userSending || channelSending; + const { from_uid, replying_mid = null, mode, uploadFiles } = useSelector( + (store) => { + return { + mode: store.ui.inputMode, + from_uid: store.authData.uid, + replying_mid: store.message.replying[id], + uploadFiles: store.ui.uploadFiles[`${context}_${id}`], + }; + } + ); + const { sendMessage } = useSendMessage({ context, from: from_uid, to: id }); useEffect(() => { if (replying_mid) { @@ -73,80 +75,93 @@ function Send({ } }; const handleSendMessage = async (msgs = []) => { - if (!msgs || msgs.length == 0 || !id) return; - for await (const msg of msgs) { - console.log("send msg", msg); - const { type: content_type, content, properties = {} } = msg; - if (replying_mid) { - console.log("replying", replying_mid); - await replyMessage({ - id, - reply_mid: replying_mid, - type: content_type, - content, - context, - from_uid, - }); - dispatch(removeReplyingMessage(id)); - } else { + if (!id) return; + if (msgs && msgs.length) { + // send text msgs + for await (const msg of msgs) { + console.log("send msg", msg); + const { type: content_type, content, properties = {} } = msg; + properties.local_id = properties.local_id ?? new Date().getTime(); await sendMessage({ id, + reply_mid: replying_mid, type: content_type, content, from_uid, properties, }); + if (replying_mid) { + dispatch(removeReplyingMessage(id)); + } } } + // send files + if (uploadFiles && uploadFiles.length !== 0) { + uploadFiles.forEach((fileInfo) => { + const ts = new Date().getTime(); + const { url, name, size, type } = fileInfo; + const tmpMsg = { + mid: ts, + content: url, + content_type: ContentTypes.file, + created_at: ts, + properties: { + content_type: type, + name, + size, + local_id: ts, + }, + from_uid, + sending: true, + }; + addLocalFileMesage(tmpMsg); + }); + dispatch(updateUploadFiles({ context, id, operation: "reset" })); + } }; const sendMarkdown = async (content) => { - if (replying_mid) { - console.log("replying", replying_mid); - await replyMessage({ - id, - reply_mid: replying_mid, - type: "markdown", - content, - context, - from_uid, - }); - dispatch(removeReplyingMessage(id)); - } else { - sendMessage({ - id, - type: "markdown", - content, - from_uid, - properties: { local_id: new Date().getTime() }, - }); - } + sendMessage({ + id, + reply_mid: replying_mid, + type: "markdown", + content, + from_uid, + properties: { local_id: new Date().getTime() }, + }); }; const toggleMode = () => { dispatch(updateInputMode(mode == Modes.text ? Modes.markdown : Modes.text)); }; const placeholder = `Send to ${Types[context]}${name} `; return ( - + {replying_mid && } - - {mode == Modes.text && ( - + +
    + + {mode == Modes.text && ( + + )} + - )} - - {mode == Modes.markdown && ( - - )} + {mode == Modes.markdown && ( + + )} +
    ); } diff --git a/src/common/component/Send/styled.js b/src/common/component/Send/styled.js index 885792ea..d7ead0da 100644 --- a/src/common/component/Send/styled.js +++ b/src/common/component/Send/styled.js @@ -6,26 +6,28 @@ const StyledSend = styled.div` border-radius: var(--br); width: 100%; width: -webkit-fill-available; - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 15px; - padding: 14px 18px; - &.markdown { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; - gap: 0; - .input { - grid-column: span 2; + .send_box { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + padding: 14px 18px; + &.markdown { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 0; + .input { + grid-column: span 2; + } + } + &.reply { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + .input { + width: 100%; } - } - &.reply { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - .input { - width: 100%; } `; diff --git a/src/common/hook/useAddLocalFileMessage.js b/src/common/hook/useAddLocalFileMessage.js new file mode 100644 index 00000000..f44704c3 --- /dev/null +++ b/src/common/hook/useAddLocalFileMessage.js @@ -0,0 +1,15 @@ +// import React from 'react' +import { useDispatch } from "react-redux"; + +import { addMessage } from "../../app/slices/message"; +import { addChannelMsg } from "../../app/slices/message.channel"; +import { addUserMsg } from "../../app/slices/message.user"; +export default function useAddLocalFileMessage({ context, to }) { + const dispatch = useDispatch(); + const addContextMessage = context == "channel" ? addChannelMsg : addUserMsg; + const addLocalFileMesage = (data) => { + dispatch(addMessage(data)); + dispatch(addContextMessage({ id: to, mid: data.mid })); + }; + return addLocalFileMesage; +} diff --git a/src/common/hook/useRemoveLocalMessage.js b/src/common/hook/useRemoveLocalMessage.js new file mode 100644 index 00000000..9e01b8bb --- /dev/null +++ b/src/common/hook/useRemoveLocalMessage.js @@ -0,0 +1,15 @@ +// import second from 'first' +import { useDispatch } from "react-redux"; +import { removeMessage } from "../../app/slices/message"; +import { removeChannelMsg } from "../../app/slices/message.channel"; +import { removeUserMsg } from "../../app/slices/message.user"; +export default function useRemoveLocalMessage({ context = "user", id = 0 }) { + const dispatch = useDispatch(); + const removeContextMessage = + context == "channel" ? removeChannelMsg : removeUserMsg; + const removeLocalMessage = (mid) => { + dispatch(removeContextMessage({ id, mid })); + dispatch(removeMessage(mid)); + }; + return removeLocalMessage; +} diff --git a/src/common/hook/useSendMessage.js b/src/common/hook/useSendMessage.js index c0b25661..c16f8142 100644 --- a/src/common/hook/useSendMessage.js +++ b/src/common/hook/useSendMessage.js @@ -1,11 +1,20 @@ // import second from 'first' +import { removeReplyingMessage } from "../../app/slices/message"; import { useSendChannelMsgMutation } from "../../app/services/channel"; import { useSendMsgMutation } from "../../app/services/contact"; -export default function useSendMessage({ - context = "user", - from = null, - to = null, -}) { +import { useReplyMessageMutation } from "../../app/services/message"; +export default function useSendMessage( + props = { + context: "user", + from: null, + to: null, + } +) { + const { context = "user", from = null, to = null } = props; + const [ + replyMessage, + { isError: replyErr, isLoading: replying, isSuccess: replySuccess }, + ] = useReplyMessageMutation(); const [ sendChannelMsg, { @@ -19,19 +28,64 @@ export default function useSendMessage({ { isLoading: userSending, isSuccess: userSuccess, isError: userError }, ] = useSendMsgMutation(); const sendFn = context == "user" ? sendUserMsg : sendChannelMsg; - const sendMessage = ({ type = "text", content, properties = {} }) => { - sendFn({ - id: to, - content, - properties: { ...properties, local_id: new Date().getTime() }, - type, - from_uid: from, - }); + const sendMessages = async ({ + type = "text", + content, + users = [], + channels = [], + }) => { + if (users.length) { + for await (const uid of users) { + await sendUserMsg({ + type, + id: uid, + content, + }); + } + } + if (channels.length) { + for await (const cid of channels) { + await sendChannelMsg({ + type, + id: cid, + content, + }); + } + } + }; + const sendMessage = async ({ + type = "text", + content, + properties = {}, + reply_mid = null, + ...rest + }) => { + if (reply_mid) { + await replyMessage({ + id: to, + reply_mid, + type, + content, + context, + from_uid: from, + }); + removeReplyingMessage(to); + } else { + await sendFn({ + id: to, + content, + properties: { ...properties }, + type, + from_uid: from, + ...rest, + }); + } }; return { + sendMessages, sendMessage, - isError: channelError || userError, - isSending: userSending || channelSending, - isSuccess: channelSuccess || userSuccess, + isError: channelError || userError || replyErr, + isSending: userSending || channelSending || replying, + isSuccess: channelSuccess || userSuccess || replySuccess, }; } diff --git a/src/common/hook/useUploadFile.js b/src/common/hook/useUploadFile.js index fc3b0cb0..184ad40d 100644 --- a/src/common/hook/useUploadFile.js +++ b/src/common/hook/useUploadFile.js @@ -8,7 +8,8 @@ import { export default function useUploadFile() { const [data, setData] = useState(null); - const [uploadingFile, setUploadingFile] = useState(false); + // const [uploadingFile, setUploadingFile] = useState(false); + const canneledRef = useRef(false); const sliceUploadedCountRef = useRef(0); const totalSliceCountRef = useRef(1); const [ @@ -20,7 +21,7 @@ export default function useUploadFile() { { isLoading: isUploading, isSuccess: isUploaded, isError: uploadFileError }, ] = useUploadFileMutation(); - const uploadChunk = async (data) => { + const uploadChunk = (data) => { const { file_id, chunk, is_last } = data; const formData = new FormData(); formData.append("file_id", file_id); @@ -44,9 +45,10 @@ export default function useUploadFile() { console.log("file id", file_id); let uploadResult = null; + canneledRef.current = false; totalSliceCountRef.current = 1; sliceUploadedCountRef.current = 0; - setUploadingFile(true); + // setUploadingFile(true); // 2MB if (file_size <= 1000 * 1000 * 2) { // 一次性上传文件 @@ -60,6 +62,8 @@ export default function useUploadFile() { // const chunk=file.slice(block_size * index, block_size * (index + 1)); for await (const [idx] of _arr.entries()) { + // 退出循环 + if (canneledRef.current) break; try { const chunk = file.slice( FILE_SLICE_SIZE * idx, @@ -81,7 +85,7 @@ export default function useUploadFile() { } console.log("wtfff", uploadResult); } - setUploadingFile(false); + // setUploadingFile(false); const { data: { path, size, hash }, } = uploadResult; @@ -101,9 +105,13 @@ export default function useUploadFile() { setData(res); return res; }; + const stopUploading = () => { + canneledRef.current = true; + }; return { + stopUploading, data, - isUploading: uploadingFile, + isUploading: isPreparing || isUploading, progress: Number( (sliceUploadedCountRef.current / totalSliceCountRef.current) * 100 ).toFixed(2), diff --git a/src/common/utils.js b/src/common/utils.js index dd939181..ae9b5119 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -38,9 +38,13 @@ export const getNonNullValues = (obj, whiteList = ["log_id"]) => { }); return tmp; }; -export function getDefaultSize(size = null, min = 240) { +export function getDefaultSize(size = null, min = 480) { if (!size) return { width: 0, height: 0 }; const { width: oWidth, height: oHeight } = size; + if (oWidth == oHeight) { + const tmp = min > oWidth ? oWidth : min; + return { width: tmp, height: tmp }; + } const isVertical = oWidth > oHeight ? false : true; let dWidth = 0; let dHeight = 0; @@ -49,7 +53,6 @@ export function getDefaultSize(size = null, min = 240) { dWidth = (oWidth / oHeight) * dHeight; } else { dWidth = oWidth >= min ? min : oWidth; - // dHeight = oHeight >= min ? min : oHeight; dHeight = (oHeight / oWidth) * dWidth; } return { width: dWidth, height: dHeight }; @@ -65,6 +68,22 @@ export function formatBytes(bytes, decimals = 2) { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } +export const getImageSize = (url) => { + const size = { width: 0, height: 0 }; + if (!url) return size; + return new Promise((resolve) => { + const img = new Image(); + img.src = url; + img.onload = () => { + size.width = img.width; + size.height = img.height; + resolve(size); + }; + img.onerror = () => { + resolve(size); + }; + }); +}; export const getInitials = (name) => { const arr = name.split(" ").filter((n) => !!n); return arr diff --git a/src/routes/chat/Layout.js b/src/routes/chat/Layout.js index 4a9476eb..4196b16f 100644 --- a/src/routes/chat/Layout.js +++ b/src/routes/chat/Layout.js @@ -1,10 +1,11 @@ import { useState, useRef, useEffect } from "react"; import { useDrop } from "react-dnd"; +import { useDispatch } from "react-redux"; + import { NativeTypes } from "react-dnd-html5-backend"; import styled from "styled-components"; +import { updateUploadFiles } from "../../app/slices/ui"; import ImagePreviewModal from "../../common/component/ImagePreviewModal"; -import UploadModal from "../../common/component/UploadModal"; - const StyledWrapper = styled.article` position: relative; width: 100%; @@ -110,34 +111,39 @@ export default function Layout({ header, aside = null, contacts = null, - dropFiles = [], + // dropFiles = [], context = "channel", to = null, }) { - const messagesContainer = useRef(null); - const [files, setFiles] = useState([]); - const [previewImage, setPreviewImage] = useState(null); - const [{ isActive }, drop] = useDrop(() => ({ - accept: [NativeTypes.FILE], - drop({ files }) { - console.log("drop files", files); - if (files.length) { - setFiles((prevs) => [...prevs, ...files]); - } - }, - collect: (monitor) => ({ - isActive: monitor.canDrop() && monitor.isOver(), - }), - })); - useEffect(() => { - if (dropFiles.length) { - setFiles((prevs) => [...prevs, ...dropFiles]); - } - }, [dropFiles]); + const dispatch = useDispatch(); - const resetFiles = () => { - setFiles([]); - }; + const messagesContainer = useRef(null); + const [previewImage, setPreviewImage] = useState(null); + const [{ isActive }, drop] = useDrop( + () => ({ + accept: [NativeTypes.FILE], + drop({ files }) { + console.log("drop files", files, context, to); + if (files.length) { + const filesData = files.map((file) => { + const { size, type, name } = file; + const url = URL.createObjectURL(file); + return { size, type, name, url }; + }); + dispatch(updateUploadFiles({ context, id: to, data: filesData })); + } + }, + collect: (monitor) => ({ + isActive: monitor.canDrop() && monitor.isOver(), + }), + }), + [context, to] + ); + // useEffect(() => { + // if (dropFiles?.length) { + // setFiles((prevs) => [...prevs, ...dropFiles]); + // } + // }, [dropFiles]); const closePreviewModal = () => { setPreviewImage(null); @@ -156,8 +162,9 @@ export default function Layout({ target.classList.contains("preview") ) { const originUrl = target.dataset.origin || target.src; + const downloadLink = target.dataset.download || target.src; const meta = JSON.parse(target.dataset.meta || "{}"); - setPreviewImage({ originUrl, ...meta }); + setPreviewImage({ originUrl, downloadLink, ...meta }); } }, true @@ -196,14 +203,6 @@ export default function Layout({
    - {files.length !== 0 && ( - - )} ); } diff --git a/src/routes/chat/utils.js b/src/routes/chat/utils.js index e88f0889..a0fa358b 100644 --- a/src/routes/chat/utils.js +++ b/src/routes/chat/utils.js @@ -66,7 +66,8 @@ export const renderPreviewMessage = (message = null) => { break; case ContentTypes.file: { - if (isImage(properties.file_type, properties.size)) { + const props = properties ?? {}; + if (isImage(props.content_type, props.size)) { res = `[image]`; } else { res = `[file]`;