feat: message reaction

This commit is contained in:
zerosoul
2022-03-05 13:26:06 +08:00
parent acd007fb71
commit 2579761c7a
28 changed files with 826 additions and 304 deletions
+77 -14
View File
@@ -1,7 +1,11 @@
// import React from 'react'
import { useState, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import styled from "styled-components";
import toast from "react-hot-toast";
import { useOutsideClick } from "rooks";
import StyledMenu from "../StyledMenu";
import DeleteMessageConfirm from "./DeleteMessageConfirm";
import EmojiPicker from "./EmojiPicker";
const StyledCmds = styled.ul`
position: absolute;
right: 10px;
@@ -13,41 +17,100 @@ const StyledCmds = styled.ul`
border-radius: 6px;
background-color: #fff;
visibility: hidden;
&.visible {
visibility: visible;
}
.cmd {
display: flex;
cursor: pointer;
padding: 4px;
&:hover {
background-color: #f3f4f6;
}
img {
width: 24px;
height: 24px;
}
}
.menu {
position: absolute;
top: 0;
right: 36px;
}
`;
// https://static.nicegoodthings.com/project/rustchat/icon.forward.svg
// https://static.nicegoodthings.com/project/rustchat/icon.edit.svg
export default function Commands() {
export default function Commands({
message = null,
mid = 0,
uid = 0,
menuVisible,
toggleMenu,
emojiPopVisible,
toggleEmojiPopover,
}) {
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const currUid = useSelector((store) => store.authData.user.uid);
const menuRef = useRef(null);
const handleClick = () => {
toast.success("cooming soon");
};
useOutsideClick(menuRef, toggleMenu);
const toggleDeleteModal = () => {
setDeleteModalVisible((prev) => !prev);
};
const alwaysVisible = menuVisible || emojiPopVisible;
return (
<StyledCmds className="cmds">
<li className="cmd" onClick={handleClick}>
<StyledCmds className={`cmds ${alwaysVisible ? "visible" : ""}`}>
<li className="cmd" onClick={toggleEmojiPopover}>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.reply.svg"
alt="icon emoji"
/>
</li>
<li className="cmd" onClick={handleClick}>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.edit.svg"
alt="icon emoji"
/>
</li>
<li className="cmd" onClick={handleClick}>
{emojiPopVisible && (
<EmojiPicker mid={mid} hidePicker={toggleEmojiPopover} />
)}
{currUid == uid ? (
<li className="cmd" onClick={handleClick}>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.edit.svg"
alt="icon edit"
/>
</li>
) : (
<li className="cmd" onClick={handleClick}>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.forward.svg"
alt="icon reply"
/>
</li>
)}
<li className="cmd" onClick={toggleMenu}>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.dots.svg"
alt="icon emoji"
/>
</li>
{menuVisible && (
<StyledMenu className="menu" ref={menuRef}>
<li className="item">Edit Message</li>
<li className="item underline">Pin Message</li>
<li className="item">Reply</li>
{currUid == uid && (
<li className="item danger" onClick={toggleDeleteModal}>
Delete Message
</li>
)}
</StyledMenu>
)}
{deleteModalVisible && (
<DeleteMessageConfirm
closeModal={toggleDeleteModal}
message={{ mid, ...message }}
/>
)}
</StyledCmds>
);
}
@@ -0,0 +1,45 @@
// import React from "react";
import { useEffect, useState } from "react";
// import { useDispatch } from "react-redux";
// import BASE_URL from "../../app/config";
import { useLazyDeleteMessageQuery } from "../../../app/services/message";
import StyledModal from "../styled/Modal";
import Button from "../styled/Button";
import Modal from "../Modal";
import PreviewMessage from "./PreviewMessage";
export default function LogoutConfirmModal({ closeModal, message = null }) {
// const dispatch = useDispatch();
const [deleteMessage, { isLoading, isSuccess }] = useLazyDeleteMessageQuery();
const handleDelete = (evt) => {
const { mid } = evt.target.dataset;
if (!mid) return;
deleteMessage(mid);
};
useEffect(() => {
if (isSuccess) {
closeModal();
}
}, [isSuccess]);
if (!message) return;
const { mid, ...previewContent } = message;
return (
<Modal>
<StyledModal
className="animate__animated animate__fadeInDown animate__faster"
buttons={
<>
<Button onClick={closeModal}>Cancel</Button>
<Button data-mid={mid} onClick={handleDelete} className="danger">
{isLoading ? "Deleting" : `Delete`}
</Button>
</>
}
title="Delete Message"
description="Are you sure want to delete this message?"
>
<PreviewMessage data={previewContent} />
</StyledModal>
</Modal>
);
}
@@ -0,0 +1,61 @@
// import { Picker } from "emoji-mart";
// import "emoji-mart/css/emoji-mart.css";
import { useRef } from "react";
import styled from "styled-components";
import { useOutsideClick } from "rooks";
import { useLikeMessageMutation } from "../../../app/services/message";
const StyledPicker = styled.div`
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
position: absolute;
left: -10px;
top: 0;
transform: translateX(-100%);
background-color: #fff;
padding: 5px;
.emojis {
display: flex;
gap: 4px;
.emoji {
cursor: pointer;
border-radius: 4px;
padding: 4px;
font-size: 30px;
&:hover {
background-color: #f3f4f6;
}
}
}
`;
const emojis = {
thumb_up: "👍",
ok: "👌",
like: "❤️",
};
export default function EmojiPicker({ mid, hidePicker }) {
const wrapperRef = useRef(null);
const [reactMessage, { isLoading }] = useLikeMessageMutation();
useOutsideClick(wrapperRef, hidePicker);
const handleReact = (action) => {
console.log("react", action);
reactMessage({ mid, action });
};
return (
<StyledPicker ref={wrapperRef}>
<ul className="emojis">
{Object.entries(emojis).map(([key, emoji]) => {
return (
<li
className="emoji"
key={key}
onClick={handleReact.bind(null, key)}
>
{emoji}
</li>
);
})}
</ul>
</StyledPicker>
);
}
export { emojis };
@@ -0,0 +1,23 @@
// import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
// import { useSelector } from "react-redux";
import Avatar from "../Avatar";
import StyledWrapper from "./styled";
export default function PreviewMessage({ data = null }) {
if (!data) return null;
const { avatar, name, time, content } = data;
return (
<StyledWrapper className={`preview`}>
<div className="avatar">
<Avatar url={avatar} name={name} />
</div>
<div className="details">
<div className="up">
<span className="name">{name}</span>
<i className="time">{dayjs(time).format("YYYY-MM-DD h:mm:ss A")}</i>
</div>
<div className={`down`}>{content}</div>
</div>
</StyledWrapper>
);
}
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
import styled from "styled-components";
const Styled = styled.div`
display: flex;
`;
export default function Removed() {
return <Styled>Removed</Styled>;
}
+52 -36
View File
@@ -1,46 +1,16 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import { useDispatch, useSelector } from "react-redux";
import { useInViewRef } from "rooks";
import Tippy from "@tippyjs/react";
import Linkify from "react-linkify";
import Profile from "../Profile";
import Avatar from "../Avatar";
import BASE_URL from "../../../app/config";
import { setChannelMsgRead } from "../../../app/slices/message.channel";
import { setUserMsgRead } from "../../../app/slices/message.user";
import StyledWrapper from "./styled";
import Commands from "./Commands";
const renderContent = (type, content) => {
let ctn = null;
switch (type) {
case "text/plain":
ctn = (
<Linkify
componentDecorator={(decoratedHref, decoratedText, key) => (
<a target="blank" href={decoratedHref} key={key}>
{decoratedText}
</a>
)}
>
{content}
</Linkify>
);
break;
case "image/jpeg":
ctn = (
<img
className="img"
src={`${BASE_URL}/resource/image?id=${encodeURIComponent(content)}`}
/>
);
break;
default:
break;
}
return ctn;
};
import { emojis } from "./EmojiPicker";
import renderContent from "./renderContent";
export default function Message({
gid = "",
mid = "",
@@ -51,11 +21,21 @@ export default function Message({
content_type = "text/plain",
unread = false,
pending,
removed = false,
likes,
}) {
const [myRef, inView] = useInViewRef();
const [emojiPopVisible, setEmojiPopVisible] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const disptach = useDispatch();
const avatarRef = useRef(null);
const contacts = useSelector((store) => store.contacts);
const toggleMenu = () => {
setMenuVisible((prev) => !prev);
};
const toggleEmojiPopover = () => {
setEmojiPopVisible((prev) => !prev);
};
useEffect(() => {
if (!unread) {
avatarRef.current?.scrollIntoView();
@@ -73,8 +53,10 @@ export default function Message({
if (!contacts) return null;
const currUser = contacts.find((c) => c.uid == fromUid) || {};
return (
<StyledWrapper ref={myRef}>
return removed ? (
"removed"
) : (
<StyledWrapper ref={myRef} className={`${menuVisible ? "menu" : ""}`}>
<Tippy
interactive
placement="left"
@@ -89,12 +71,46 @@ export default function Message({
<div className="up">
<span className="name">{currUser.name}</span>
<i className="time">{dayjs(time).format("YYYY-MM-DD h:mm:ss A")}</i>
{likes && (
<span className="likes">
{Object.entries(
likes.reduce((acc, val) => {
return { ...acc, [val]: (acc[val] || 0) + 1 };
}, {})
).map(([l, count]) => {
return (
<i
className="like"
// data-count={count > 1 ? count : ""}
key={l}
>
{emojis[l]}
{count > 1 ? <em>{`+${count}`} </em> : null}
</i>
);
})}
</span>
)}
</div>
<div className={`down ${pending ? "pending" : ""}`}>
{renderContent(content_type, content)}
</div>
</div>
<Commands />
<Commands
message={{
name: currUser.name,
avatar: currUser.avatar,
time,
content: renderContent(content_type, content),
}}
mid={mid}
uid={fromUid}
toggleMenu={toggleMenu}
menuVisible={menuVisible}
emojiPopVisible={emojiPopVisible}
toggleEmojiPopover={toggleEmojiPopover}
/>
</StyledWrapper>
);
}
@@ -0,0 +1,34 @@
import Linkify from "react-linkify";
import BASE_URL from "../../../app/config";
const renderContent = (type, content) => {
let ctn = null;
switch (type) {
case "text/plain":
ctn = (
<Linkify
componentDecorator={(decoratedHref, decoratedText, key) => (
<a target="blank" href={decoratedHref} key={key}>
{decoratedText}
</a>
)}
>
{content}
</Linkify>
);
break;
case "image/jpeg":
ctn = (
<img
className="img"
src={`${BASE_URL}/resource/image?id=${encodeURIComponent(content)}`}
/>
);
break;
default:
break;
}
return ctn;
};
export default renderContent;
+29 -1
View File
@@ -7,12 +7,16 @@ const StyledMsg = styled.div`
padding: 4px;
margin: 8px 0;
border-radius: 8px;
&:hover {
&:hover,
&.preview {
background: #f5f6f7;
.cmds {
visibility: visible;
}
}
&.menu {
z-index: 9;
}
.avatar {
cursor: pointer;
img {
@@ -41,6 +45,30 @@ const StyledMsg = styled.div`
font-size: 12px;
line-height: 18px;
}
.likes {
display: flex;
gap: 8px;
font-size: 16px;
/* align-items: center; */
.like {
position: relative;
display: flex;
align-items: center;
gap: 6px;
em {
font-size: 12px;
color: #999;
}
/* &:after {
content: attr(data-count);
position: absolute;
top: -4px;
right: -8px;
font-size: 12px;
color: #999;
} */
}
}
}
.down {
user-select: text;