feat: brand new send file UX

This commit is contained in:
zerosoul
2022-04-22 17:04:58 +08:00
parent c24ee53967
commit b34eee726b
28 changed files with 1003 additions and 291 deletions
+2 -1
View File
@@ -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
+79 -1
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3.25C13.4346 3.25 14.6126 4.34848 14.7388 5.75019L19 5.75C19.4142 5.75 19.75 6.08579 19.75 6.5C19.75 6.8797 19.4678 7.19349 19.1018 7.24315L19 7.25H18.417L17.1499 18.2292C17.0335 19.2384 16.179 20 15.1631 20H8.83688C7.821 20 6.9665 19.2384 6.85006 18.2292L5.582 7.25H5C4.6203 7.25 4.30651 6.96785 4.25685 6.60177L4.25 6.5C4.25 6.1203 4.53215 5.80651 4.89823 5.75685L5 5.75L9.26119 5.75019C9.38741 4.34848 10.5654 3.25 12 3.25ZM10.5 9.5C10.2545 9.5 10.0504 9.65477 10.0081 9.85886L10 9.9375V16.0625L10.0081 16.1411C10.0504 16.3452 10.2545 16.5 10.5 16.5C10.7455 16.5 10.9496 16.3452 10.9919 16.1411L11 16.0625V9.9375L10.9919 9.85886C10.9496 9.65477 10.7455 9.5 10.5 9.5ZM13.5 9.5C13.2545 9.5 13.0504 9.65477 13.0081 9.85886L13 9.9375V16.0625L13.0081 16.1411C13.0504 16.3452 13.2545 16.5 13.5 16.5C13.7455 16.5 13.9496 16.3452 13.9919 16.1411L14 16.0625V9.9375L13.9919 9.85886C13.9496 9.65477 13.7455 9.5 13.5 9.5ZM12 4.75C11.3952 4.75 10.8908 5.17947 10.775 5.75005H13.225C13.1092 5.17947 12.6048 4.75 12 4.75Z" fill="#D92D20"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -60,7 +60,7 @@ const renderPreview = (data) => {
export default function FileBox({
preview = false,
flex,
file_type,
file_type = "",
name,
size,
created_at,
+24
View File
@@ -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 (
<img
className="img preview"
style={{
width: width ? `${width}px` : "",
height: height ? `${height}px` : "",
}}
data-meta={JSON.stringify(properties)}
data-origin={content}
data-download={download}
src={thumbnail || download || content}
/>
);
}
@@ -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 (
<Styled style={{ width }}>
<div className="progress" style={{ width: `${value}%` }}></div>
</Styled>
);
}
+147
View File
@@ -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 (
<Image
properties={{ ...imageSize, ...properties }}
size={size}
content={content}
download={download}
thumbnail={thumbnail}
/>
);
const sending = uploadinFile || isSending;
return (
<Styled className={`file_message ${sending ? "sending" : ""}`}>
<div className="basic">
{icon}
<div className="info">
<span className="name">{name}</span>
<span className="details">
{/* <Progress value={30} width={"80%"} /> */}
{sending ? (
<Progress value={progress} width={"80%"} />
) : (
<>
<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>
{/* <IconClose className="cancel" /> */}
{sending ? (
<IconClose className="cancel" onClick={handleCancel} />
) : (
<a
className="download"
download={name}
href={`${content}&download=true`}
>
<IconDownload />
</a>
)}
</div>
</Styled>
);
}
@@ -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;
+4 -4
View File
@@ -67,7 +67,7 @@ export default function ImagePreviewModal({
// });
// };
if (!data) return null;
const { originUrl, name, type } = data;
const { originUrl, downloadLink, name, type } = data;
return (
<Modal>
<StyledWrapper>
@@ -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
</a>
+3 -3
View File
@@ -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 = <img className="pic" src={thumbnail} />;
} else {
res = (
+4
View File
@@ -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,
})
)}
+67 -32
View File
@@ -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 = (
<>
<Linkit
@@ -65,43 +66,43 @@ const renderContent = ({
</>
);
break;
case "text/markdown":
case ContentTypes.markdown:
{
ctn = <MrakdownRender content={content} />;
}
break;
case "rustchat/file":
case ContentTypes.file:
{
const { size, name, file_type } = properties;
if (isImage(file_type, size)) {
const { width, height } = getDefaultSize(properties);
// const { size, name, file_type } = properties;
ctn = (
<img
className="img preview"
style={{ width: `${width}px`, height: `${height}px` }}
data-meta={JSON.stringify({
width,
height,
name,
file_type,
size,
})}
data-origin={content}
src={thumbnail || content}
/>
);
} else {
ctn = (
<FileBox
<FileMessage
content_type={""}
properties={properties}
context={context}
to={to}
download={download}
thumbnail={thumbnail}
from_uid={from_uid}
created_at={created_at}
content={content}
size={size}
name={name}
file_type={file_type}
/>
);
}
break;
case ContentTypes.archive:
{
// const { size, name, file_type } = properties;
ctn = (
<ForwardedMessage
properties={properties}
context={context}
to={to}
from_uid={from_uid}
created_at={created_at}
id={content}
thumbnail={thumbnail}
/>
);
}
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;
+2 -2
View File
@@ -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 {
+29 -41
View File
@@ -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;
// });
+4 -8
View File
@@ -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 = <img className="pic" src={pictureIcon} />;
} else {
const icon = getFileIcon(file_type, name);
const icon = getFileIcon(content_type, name);
res = (
<>
{icon}
+14 -20
View File
@@ -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,28 +40,23 @@ 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 && (
<UploadModal
context={context}
files={files}
sendTo={to}
closeModal={resetFiles}
/>
)}
<Styled className={mode}>
<div className="md" onClick={toggleMode}>
<Tooltip placement="top" tip="Markdown">
@@ -75,7 +70,7 @@ export default function Toolbar({ toggleMode, mode, to, context }) {
<input
size={24}
ref={fileInputRef}
multiple={false}
multiple={true}
onChange={handleUpload}
type="file"
name="file"
@@ -85,6 +80,5 @@ export default function Toolbar({ toggleMode, mode, to, context }) {
</div>
</Tooltip>
</Styled>
</>
);
}
@@ -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 (
<Modal>
<StyledWrapper>
<h4 className="title">
File Details <CloseIcon className="close" onClick={closeModal} />
</h4>
<div className="input">
<label htmlFor="name">Name</label>
<Input id="name" value={fileName} onChange={handleNameChange} />
</div>
<div className="btns">
<Button className="ghost cancel" onClick={closeModal}>
Cancel
</Button>
<Button onClick={handleUpdate}>Save Changes</Button>
</div>
</StyledWrapper>
</Modal>
);
}
@@ -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 && (
<EditFileDetailsModal
name={editInfo.name}
updateName={updateFileName}
closeModal={toggleModalVisible}
/>
)}
<Styled>
{files.map(({ name, url, size, type }, idx) => {
return (
<li className="file" key={url}>
<div className="preview">
{type.startsWith("image") ? (
<img src={url} alt="image" />
) : (
getFileIcon(type, name)
)}
</div>
<h4 className="name">{name}</h4>
<span className="size">{formatBytes(size)}</span>
<ul className="opts">
<li
className="opt edit"
onClick={handleOpenEditModal.bind(null, idx)}
>
<EditIcon />
</li>
<li
className="opt delete"
data-index={idx}
onClick={handleRemove.bind(null, idx)}
>
<DeleteIcon />
</li>
</ul>
</li>
);
})}
</Styled>
</>
);
}
@@ -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;
+57 -42
View File
@@ -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) => {
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 [sendMsg] = useSendMsgMutation();
const [sendChannelMsg] = useSendChannelMsgMutation();
const sendMessage = context == "channel" ? sendChannelMsg : sendMsg;
// const sendingMessage = userSending || channelSending;
}
);
const { sendMessage } = useSendMessage({ context, from: from_uid, to: id });
useEffect(() => {
if (replying_mid) {
@@ -73,63 +75,70 @@ function Send({
}
};
const handleSendMessage = async (msgs = []) => {
if (!msgs || msgs.length == 0 || !id) return;
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;
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 {
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,
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 (
<StyledSend
className={`send ${mode} ${replying_mid ? "reply" : ""} ${context}`}
>
<StyledSend className={`send ${replying_mid ? "reply" : ""} ${context}`}>
{replying_mid && <Replying mid={replying_mid} id={id} />}
<UploadFileList context={context} id={id} />
<div className={`send_box ${mode}`}>
<EmojiPicker selectEmoji={insertEmoji} />
{mode == Modes.text && (
<MixedInput
@@ -139,7 +148,12 @@ function Send({
sendMessages={handleSendMessage}
/>
)}
<Toolbar context={context} to={id} mode={mode} toggleMode={toggleMode} />
<Toolbar
context={context}
to={id}
mode={mode}
toggleMode={toggleMode}
/>
{mode == Modes.markdown && (
<MarkdownEditor
placeholder={placeholder}
@@ -147,6 +161,7 @@ function Send({
sendMarkdown={sendMarkdown}
/>
)}
</div>
</StyledSend>
);
}
+2
View File
@@ -6,6 +6,7 @@ const StyledSend = styled.div`
border-radius: var(--br);
width: 100%;
width: -webkit-fill-available;
.send_box {
display: flex;
justify-content: space-between;
align-items: flex-start;
@@ -27,6 +28,7 @@ const StyledSend = styled.div`
.input {
width: 100%;
}
}
`;
export default StyledSend;
+15
View File
@@ -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;
}
+15
View File
@@ -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;
}
+66 -12
View File
@@ -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,
const sendMessages = async ({
type = "text",
content,
properties: { ...properties, local_id: new Date().getTime() },
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,
};
}
+13 -5
View File
@@ -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),
+21 -2
View File
@@ -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
+25 -26
View File
@@ -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 dispatch = useDispatch();
const messagesContainer = useRef(null);
const [files, setFiles] = useState([]);
const [previewImage, setPreviewImage] = useState(null);
const [{ isActive }, drop] = useDrop(() => ({
const [{ isActive }, drop] = useDrop(
() => ({
accept: [NativeTypes.FILE],
drop({ files }) {
console.log("drop files", files);
console.log("drop files", files, context, to);
if (files.length) {
setFiles((prevs) => [...prevs, ...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 }));
}
},
collect: (monitor) => ({
isActive: monitor.canDrop() && monitor.isOver(),
}),
}));
useEffect(() => {
if (dropFiles.length) {
setFiles((prevs) => [...prevs, ...dropFiles]);
}
}, [dropFiles]);
const resetFiles = () => {
setFiles([]);
};
}),
[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({
</div>
</div>
</StyledWrapper>
{files.length !== 0 && (
<UploadModal
context={context}
files={files}
sendTo={to}
closeModal={resetFiles}
/>
)}
</>
);
}
+2 -1
View File
@@ -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]`;