feat: message context menu

This commit is contained in:
zerosoul
2022-05-12 21:22:28 +08:00
parent 25d69465c9
commit 6d5f8941d4
9 changed files with 429 additions and 215 deletions
+2
View File
@@ -8,6 +8,7 @@ export default function ContextMenu({ items = [], hideMenu }) {
if (!item) return null;
const {
title,
icon = null,
handler = (evt) => {
evt.preventDefault();
hideMenu();
@@ -26,6 +27,7 @@ export default function ContextMenu({ items = [], hideMenu }) {
hideMenu();
}}
>
{icon}
{title}
</li>
);
+3 -1
View File
@@ -22,7 +22,9 @@ export default function DeleteMessageConfirmModal({ closeModal, mids = [] }) {
// className="animate__animated animate__fadeInDown animate__faster"
buttons={
<>
<Button onClick={closeModal.bind(null, false)}>Cancel</Button>
<Button className="cancel" onClick={closeModal.bind(null, false)}>
Cancel
</Button>
<Button
disabled={isDeleting}
onClick={handleDelete}
+100 -149
View File
@@ -1,14 +1,13 @@
import { useState, useRef, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useState, useRef } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import Tippy from "@tippyjs/react";
import { hideAll } from "tippy.js";
// import toast from "react-hot-toast";
import { updateSelectMessages } from "../../../app/slices/ui";
import { addReplyingMessage } from "../../../app/slices/message";
import StyledMenu from "../styled/Menu";
import Tooltip from "../../component/Tooltip";
import DeleteMessageConfirm from "../DeleteMessageConfirm";
import useFavMessage from "../../hook/useFavMessage";
import ReactionPicker from "./ReactionPicker";
import replyIcon from "../../../assets/icons/reply.svg?url";
@@ -16,13 +15,9 @@ import reactIcon from "../../../assets/icons/reaction.svg?url";
import editIcon from "../../../assets/icons/edit.svg?url";
import IconBookmark from "../../../assets/icons/bookmark.add.svg";
import moreIcon from "../../../assets/icons/more.svg?url";
import ForwardModal from "../ForwardModal";
import PinMessageModal from "./PinMessageModal";
import usePinMessage from "../../hook/usePinMessage";
import { ContentTypes } from "../../../app/config";
import toast from "react-hot-toast";
import useMessageOperation from "./useMessageOperation";
const StyledCmds = styled.ul`
/* z-index: 9999; */
position: absolute;
right: 10px;
top: 0;
@@ -62,26 +57,30 @@ const StyledCmds = styled.ul`
}
`;
export default function Commands({
content_type = ContentTypes.text,
context = "user",
contextId = 0,
mid = 0,
from_uid = 0,
toggleEditMessage,
}) {
const {
canDelete,
canReply,
canEdit,
canPin,
unPin,
pinned,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
PinModal,
DeleteModal,
ForwardModal,
} = useMessageOperation({ mid, context, contextId });
const { addFavorite, isFavorited } = useFavMessage({
cid: context == "channel" ? contextId : null,
});
const [mids, setMids] = useState([]);
const dispatch = useDispatch();
const [pinModalVisible, setPinModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [forwardModalVisible, setForwardModalVisible] = useState(false);
const [tippyVisible, setTippyVisible] = useState(false);
const { canPin, pins, unpinMessage, isUnpinSuccess } = usePinMessage(
context == "channel" ? contextId : undefined
);
const currUid = useSelector((store) => store.authData.uid);
const cmdsRef = useRef(null);
const handleReply = (fromMenu) => {
if (contextId) {
@@ -91,19 +90,7 @@ export default function Commands({
hideAll();
}
};
const toggleForwardModal = () => {
hideAll();
console.log("midss", mids);
setForwardModalVisible((prev) => !prev);
};
const toggleDeleteModal = () => {
hideAll();
setDeleteModalVisible((prev) => !prev);
};
const togglePinModal = () => {
hideAll();
setPinModalVisible((prev) => !prev);
};
const handleTippyVisible = (visible = true) => {
setTippyVisible(visible);
};
@@ -113,7 +100,7 @@ export default function Commands({
};
const handleUnpin = () => {
hideAll();
unpinMessage(mid);
unPin(mid);
};
const handleAddFav = async () => {
hideAll();
@@ -129,128 +116,92 @@ export default function Commands({
toast.error("Added Favorites Failed!");
}
};
useEffect(() => {
if (isUnpinSuccess) {
toast.success("Unpin Message Successfully!");
}
}, [isUnpinSuccess]);
useEffect(() => {
if (content_type == ContentTypes.archive) {
// forward message
const forwardEle = document.querySelector(
`[data-msg-mid='${mid}'] .down [data-forwarded-mids]`
);
if (forwardEle) {
const mids = forwardEle.dataset.forwardedMids.split(",");
setMids(mids);
}
} else {
setMids([mid]);
}
}, [mid, content_type]);
const enablePin = context == "channel" && canPin;
const enableEdit =
currUid == from_uid &&
[ContentTypes.text, ContentTypes.markdown].includes(content_type);
const enableReply = currUid != from_uid;
const pinned = enablePin ? pins.findIndex((p) => p.mid == mid) > -1 : false;
return (
<StyledCmds
ref={cmdsRef}
className={`cmds ${tippyVisible ? "visible" : ""}`}
>
<Tippy
duration={0}
delay={[0, 0]}
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
placement="left-start"
trigger="click"
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
<>
<StyledCmds
ref={cmdsRef}
className={`cmds ${tippyVisible ? "visible" : ""}`}
>
<li className="cmd">
<Tooltip placement="top" tip="Add Reaction">
<img src={reactIcon} className="toggler" alt="icon emoji" />
<Tippy
duration={0}
delay={[0, 0]}
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
placement="left-start"
trigger="click"
content={<ReactionPicker mid={mid} hidePicker={hideAll} />}
>
<li className="cmd">
<Tooltip placement="top" tip="Add Reaction">
<img src={reactIcon} className="toggler" alt="icon emoji" />
</Tooltip>
</li>
</Tippy>
{canEdit && (
<li className="cmd" onClick={toggleEditMessage}>
<Tooltip placement="top" tip="Edit">
<img src={editIcon} alt="icon edit" />
</Tooltip>
</li>
)}
{canReply && (
<li className="cmd" onClick={handleReply}>
<Tooltip placement="top" tip="Reply">
<img src={replyIcon} alt="icon reply" />
</Tooltip>
</li>
)}
<li className="cmd fav" onClick={handleAddFav}>
<Tooltip placement="top" tip="Add to Favorites">
<IconBookmark />
</Tooltip>
</li>
</Tippy>
{enableEdit && (
<li className="cmd" onClick={toggleEditMessage}>
<Tooltip placement="top" tip="Edit">
<img src={editIcon} alt="icon edit" />
</Tooltip>
</li>
)}
{enableReply && (
<li className="cmd" onClick={handleReply}>
<Tooltip placement="top" tip="Reply">
<img src={replyIcon} alt="icon reply" />
</Tooltip>
</li>
)}
<li className="cmd fav" onClick={handleAddFav}>
<Tooltip placement="top" tip="Add to Favorites">
<IconBookmark />
</Tooltip>
</li>
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
popperOptions={{ strategy: "fixed" }}
placement="left-start"
trigger="click"
content={
<StyledMenu className="menu">
{/* <li className="item">Edit Message</li> */}
{enablePin && (
<li
className="item"
onClick={pinned ? handleUnpin : togglePinModal}
>
{pinned ? `Unpin Message` : `Pin Message`}
<Tippy
onShow={handleTippyVisible.bind(null, true)}
onHide={handleTippyVisible.bind(null, false)}
interactive
popperOptions={{ strategy: "fixed" }}
placement="left-start"
trigger="click"
content={
<StyledMenu className="menu">
{canPin && (
<li
className="item"
onClick={pinned ? handleUnpin : togglePinModal}
>
{pinned ? `Unpin Message` : `Pin Message`}
</li>
)}
<li className="item" onClick={toggleForwardModal}>
Forward
</li>
)}
<li className="item" onClick={toggleForwardModal}>
Forward
</li>
<li className="item" onClick={handleReply.bind(null, true)}>
Reply
</li>
<li className="item" onClick={handleSelect.bind(null, mid)}>
Select
</li>
{currUid == from_uid && (
<li className="item danger" onClick={toggleDeleteModal}>
Delete Message
<li className="item" onClick={handleReply.bind(null, true)}>
Reply
</li>
)}
</StyledMenu>
}
>
<li className="cmd">
<Tooltip placement="top" tip="More">
<img src={moreIcon} alt="icon more" />
</Tooltip>
</li>
</Tippy>
{deleteModalVisible && (
<DeleteMessageConfirm closeModal={toggleDeleteModal} mids={mid} />
)}
{forwardModalVisible && (
<ForwardModal mids={mids} closeModal={toggleForwardModal} />
)}
{pinModalVisible && (
<PinMessageModal
mid={mid}
gid={contextId}
closeModal={togglePinModal}
/>
)}
</StyledCmds>
<li className="item" onClick={handleSelect.bind(null, mid)}>
Select
</li>
{canDelete && (
<li className="item danger" onClick={toggleDeleteModal}>
Delete Message
</li>
)}
</StyledMenu>
}
>
<li className="cmd">
<Tooltip placement="top" tip="More">
<img src={moreIcon} alt="icon more" />
</Tooltip>
</li>
</Tippy>
</StyledCmds>
{PinModal}
{ForwardModal}
{DeleteModal}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
// import { useState, useEffect } from "react";
import Tippy from "@tippyjs/react";
import { useDispatch } from "react-redux";
import ContextMenu from "../ContextMenu";
import IconDelete from "../../../assets/icons/delete.svg";
import IconEdit from "../../../assets/icons/edit.svg";
import IconReply from "../../../assets/icons/reply.svg";
import IconForward from "../../../assets/icons/forward.svg";
import IconPin from "../../../assets/icons/pin.svg";
import IconCopy from "../../../assets/icons/copy.svg";
import IconSelect from "../../../assets/icons/select.svg";
import { updateSelectMessages } from "../../../app/slices/ui";
import { addReplyingMessage } from "../../../app/slices/message";
import useMessageOperation from "./useMessageOperation";
export default function MessageContextMenu({
context,
contextId,
mid,
offset = {},
visible,
hide,
editMessage,
children,
}) {
const {
copyContent,
isMarkdown,
canEdit,
canPin,
canCopy,
canReply,
pinned,
unPin,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
PinModal,
ForwardModal,
DeleteModal,
} = useMessageOperation({ mid, contextId, context });
const dispatch = useDispatch();
const handleSelect = () => {
dispatch(updateSelectMessages({ context, id: contextId, data: mid }));
// hideAll();
};
const handleReply = () => {
// console.log("dddd", contextId, mid);
if (contextId) {
dispatch(addReplyingMessage({ id: contextId, mid }));
}
};
return (
<>
{ForwardModal}
{PinModal}
{DeleteModal}
<Tippy
duration={0}
delay={[0, 0]}
content={
<ContextMenu
hideMenu={hide}
items={[
canEdit && {
title: "Edit Message",
icon: <IconEdit className="icon" />,
handler: editMessage,
},
canReply && {
title: "Reply",
icon: <IconReply className="icon" />,
handler: handleReply,
},
canCopy && {
title: isMarkdown ? "Copy Markdown" : "Copy Text",
icon: <IconCopy className="icon" />,
handler: copyContent,
},
canPin && {
title: pinned ? "Unpin" : "Pin",
icon: <IconPin className="icon" />,
handler: pinned ? unPin.bind(null, mid) : togglePinModal,
},
{
title: "Forward",
icon: <IconForward className="icon" />,
handler: toggleForwardModal,
},
{
title: "Select",
icon: <IconSelect className="icon" />,
handler: handleSelect,
},
{
title: "Delete",
danger: true,
icon: <IconDelete className="icon" />,
handler: toggleDeleteModal,
},
]}
/>
}
interactive
placement="right-start"
popperOptions={{ strategy: "fixed" }}
offset={[offset.y, offset.x]}
visible={visible}
onClickOutside={hide}
key={mid}
>
{children}
</Tippy>
</>
);
}
+82 -62
View File
@@ -14,6 +14,9 @@ import Commands from "./Commands";
import EditMessage from "./EditMessage";
import renderContent from "./renderContent";
import Tooltip from "../Tooltip";
import ContextMenu from "./ContextMenu";
import useContextMenu from "../../hook/useContextMenu";
dayjs.extend(isToday);
dayjs.extend(isYesterday);
function Message({
@@ -24,6 +27,12 @@ function Message({
updateReadIndex,
read = true,
}) {
const {
visible: contextMenuVisible,
offset,
handleContextMenuEvent,
hideContextMenu,
} = useContextMenu();
const inviewRef = useInView();
const [edit, setEdit] = useState(false);
const avatarRef = useRef(null);
@@ -78,71 +87,82 @@ function Message({
console.log("render message");
// return null;
return (
<StyledWrapper
data-msg-mid={mid}
ref={inviewRef}
className={`message ${readOnly ? "readonly" : ""}`}
<ContextMenu
editMessage={toggleEditMessage}
context={context}
contextId={contextId}
mid={mid}
visible={contextMenuVisible}
hide={hideContextMenu}
offset={offset}
>
<Tippy
disabled={readOnly}
duration={0}
interactive
placement="left"
trigger="click"
content={<Profile uid={fromUid} type="card" />}
<StyledWrapper
onContextMenu={handleContextMenuEvent}
data-msg-mid={mid}
ref={inviewRef}
className={`message ${readOnly ? "readonly" : ""}`}
>
<div className="avatar" data-uid={fromUid} ref={avatarRef}>
<Avatar url={currUser.avatar} name={currUser.name} />
<Tippy
disabled={readOnly}
duration={0}
interactive
placement="left"
trigger="click"
content={<Profile uid={fromUid} type="card" />}
>
<div className="avatar" data-uid={fromUid} ref={avatarRef}>
<Avatar url={currUser.avatar} name={currUser.name} />
</div>
</Tippy>
<div className="details">
<div className="up">
<span className="name">{currUser.name}</span>
<Tooltip
delay={200}
disabled={!timePrefix || readOnly}
placement="top"
tip={dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
>
<i className="time">
{timePrefix
? `${timePrefix} ${dayjsTime.format("h:mm A")}`
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</i>
</Tooltip>
</div>
<div className={`down ${sending ? "sending" : ""}`}>
{reply_mid && <Reply mid={reply_mid} />}
{edit ? (
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
) : (
renderContent({
context,
to: contextId,
from_uid: fromUid,
created_at: time,
content_type,
properties,
content,
thumbnail,
download,
edited,
})
)}
{reactions && <Reaction mid={mid} reactions={reactions} />}
</div>
</div>
</Tippy>
<div className="details">
<div className="up">
<span className="name">{currUser.name}</span>
<Tooltip
delay={200}
disabled={!timePrefix || readOnly}
placement="top"
tip={dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
>
<i className="time">
{timePrefix
? `${timePrefix} ${dayjsTime.format("h:mm A")}`
: dayjsTime.format("YYYY-MM-DD h:mm:ss A")}
</i>
</Tooltip>
</div>
<div className={`down ${sending ? "sending" : ""}`}>
{reply_mid && <Reply mid={reply_mid} />}
{edit ? (
<EditMessage mid={mid} cancelEdit={toggleEditMessage} />
) : (
renderContent({
context,
to: contextId,
from_uid: fromUid,
created_at: time,
content_type,
properties,
content,
thumbnail,
download,
edited,
})
)}
{reactions && <Reaction mid={mid} reactions={reactions} />}
</div>
</div>
{!edit && !readOnly && (
<Commands
content_type={content_type}
context={context}
contextId={contextId}
mid={mid}
from_uid={fromUid}
toggleEditMessage={toggleEditMessage}
/>
)}
</StyledWrapper>
{!edit && !readOnly && (
<Commands
content_type={content_type}
context={context}
contextId={contextId}
mid={mid}
from_uid={fromUid}
toggleEditMessage={toggleEditMessage}
/>
)}
</StyledWrapper>
</ContextMenu>
);
}
export default React.memo(Message, (prevs, nexts) => {
@@ -0,0 +1,101 @@
import { useState, useEffect } from "react";
import DeleteMessageConfirm from "../DeleteMessageConfirm";
import ForwardModal from "../ForwardModal";
import PinMessageModal from "./PinMessageModal";
import { ContentTypes } from "../../../app/config";
import { useSelector } from "react-redux";
import useCopy from "../../hook/useCopy";
import usePinMessage from "../../hook/usePinMessage";
import toast from "react-hot-toast";
export default function useMessageOperation({ mid, context, contextId }) {
const [copied, copy] = useCopy();
const { content_type, currUid, from_uid, content } = useSelector((store) => {
return {
content: store.message[mid]?.content,
from_uid: store.message[mid]?.from_uid,
content_type: store.message[mid]?.content_type,
currUid: store.authData.uid,
};
});
const { canPin, pins, unpinMessage, isUnpinSuccess } = usePinMessage(
context == "channel" ? contextId : undefined
);
const [mids, setMids] = useState([]);
const [pinModalVisible, setPinModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [forwardModalVisible, setForwardModalVisible] = useState(false);
const toggleForwardModal = () => {
// hideAll();
setForwardModalVisible((prev) => !prev);
};
const toggleDeleteModal = () => {
// hideAll();
setDeleteModalVisible((prev) => !prev);
};
const togglePinModal = () => {
// hideAll();
setPinModalVisible((prev) => !prev);
};
const copyContent = () => {
copy(content);
};
useEffect(() => {
if (content_type == ContentTypes.archive) {
// forward message
const forwardEle = document.querySelector(
`[data-msg-mid='${mid}'] .down [data-forwarded-mids]`
);
if (forwardEle) {
const mids = forwardEle.dataset.forwardedMids.split(",");
setMids(mids);
}
} else {
setMids([mid]);
}
}, [mid, content_type]);
useEffect(() => {
if (isUnpinSuccess) {
toast.success("Unpin Message Successfully!");
}
}, [isUnpinSuccess]);
useEffect(() => {
if (copied) {
toast.success("Copied");
}
}, [copied]);
const enablePin = context == "channel" && canPin;
const enableReply = currUid != from_uid;
const enableEdit =
currUid == from_uid &&
[ContentTypes.text, ContentTypes.markdown].includes(content_type);
const canDelete = currUid == from_uid;
const canCopy = [ContentTypes.text, ContentTypes.markdown].includes(
content_type
);
return {
copyContent,
canCopy,
isMarkdown: content_type == ContentTypes.markdown,
canDelete,
canPin: context == "channel" && canPin,
pinned: enablePin ? pins.findIndex((p) => p.mid == mid) > -1 : false,
unPin: unpinMessage,
canReply: enableReply,
canEdit: enableEdit,
toggleDeleteModal,
toggleForwardModal,
togglePinModal,
DeleteModal: deleteModalVisible ? (
<DeleteMessageConfirm closeModal={toggleDeleteModal} mids={mid} />
) : null,
ForwardModal: forwardModalVisible ? (
<ForwardModal mids={mids} closeModal={toggleForwardModal} />
) : null,
PinModal: pinModalVisible ? (
<PinMessageModal mid={mid} gid={contextId} closeModal={togglePinModal} />
) : null,
};
}
+18 -3
View File
@@ -8,20 +8,35 @@ const StyledMenu = styled.ul`
box-shadow: 0px 20px 25px 20px rgba(31, 41, 55, 0.1),
0px 10px 10px rgba(31, 41, 55, 0.04);
border-radius: 12px;
min-width: 200px;
.item {
display: flex;
align-items: center;
gap: 14px;
white-space: nowrap;
cursor: pointer;
border-radius: 6px;
padding: 6px 8px;
padding: 6px;
font-style: normal;
font-weight: 500;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #616161;
/* transition: color 0.2s ease; */
.icon {
width: 20px;
height: 20px;
path {
fill: #475467;
}
}
&:hover {
background-color: #22ccee;
color: #fff;
.icon {
path {
fill: #fff;
}
}
}
&.underline {
border-bottom: 1px solid #e5e5e5;