Add message context menu (#206)

This commit is contained in:
Maria Rushkova 2022-01-28 14:08:19 +01:00 committed by GitHub
parent fc2d65f202
commit f2aa41309a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 283 additions and 10 deletions

View File

@ -6,11 +6,16 @@ import { textSmallStyles } from "../Text";
type DropdownMenuProps = {
children: ReactNode;
className?: string;
style?: { top: number; left: number };
};
export function DropdownMenu({ children, className }: DropdownMenuProps) {
export function DropdownMenu({
children,
className,
style,
}: DropdownMenuProps) {
return (
<MenuBlock className={className}>
<MenuBlock className={className} style={style}>
<MenuList>{children}</MenuList>
</MenuBlock>
);
@ -24,8 +29,6 @@ const MenuBlock = styled.div`
border-radius: 8px;
padding: 8px 0;
position: absolute;
top: calc(100% - 8px);
right: 8px;
z-index: 2;
`;
@ -47,6 +50,10 @@ export const MenuItem = styled.li`
background: ${({ theme }) => theme.border};
}
&.picker:hover {
background: ${({ theme }) => theme.bodyBackgroundColor};
}
& > svg.red {
fill: ${({ theme }) => theme.redColor};
}
@ -74,4 +81,10 @@ export const MenuSection = styled.div`
margin: 0;
border: none;
}
&.message {
padding: 4px 0 0;
margin: 4px 0 0;
border-bottom: none;
}
`;

View File

@ -0,0 +1,118 @@
import { utils } from "@waku/status-communities/dist/cjs";
import { BaseEmoji } from "emoji-mart";
import React, { useRef } from "react";
import styled from "styled-components";
import { useIdentity } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { useClickOutside } from "../../hooks/useClickOutside";
import { useClickPosition } from "../../hooks/useClickPosition";
import { useContextMenu } from "../../hooks/useContextMenu";
import { Reply } from "../../hooks/useReply";
import { ChatMessage } from "../../models/ChatMessage";
import { DeleteIcon } from "../Icons/DeleteIcon";
import { EditIcon } from "../Icons/EditIcon";
import { PinIcon } from "../Icons/PinIcon";
import { ReplySvg } from "../Icons/ReplyIcon";
import { ReactionPicker } from "../Reactions/ReactionPicker";
import { DropdownMenu, MenuItem, MenuSection, MenuText } from "./DropdownMenu";
interface MessageMenuProps {
message: ChatMessage;
messageReactions: BaseEmoji[];
setMessageReactions: React.Dispatch<React.SetStateAction<BaseEmoji[]>>;
setReply: (val: Reply | undefined) => void;
messageRef: React.MutableRefObject<null>;
}
export const MessageMenu = ({
message,
messageReactions,
setMessageReactions,
setReply,
messageRef,
}: MessageMenuProps) => {
const identity = useIdentity();
const { activeChannel } = useMessengerContext();
const { showMenu, setShowMenu } = useContextMenu(message.id);
const { topPosition, leftPosition } = useClickPosition(messageRef);
const menuStyle = {
top: topPosition,
left: leftPosition,
};
const ref = useRef(null);
useClickOutside(ref, () => setShowMenu(false));
const userMessage =
identity && message.sender === utils.bufToHex(identity.publicKey);
return identity && showMenu ? (
<div ref={ref} id={"messageDropdown"}>
<MessageDropdown style={menuStyle}>
<MenuItem className="picker">
<ReactionPicker
messageReactions={messageReactions}
setMessageReactions={setMessageReactions}
className="menu"
/>
</MenuItem>
<MenuSection className={`${!userMessage && "message"}`}>
<MenuItem
onClick={() => {
setReply({
sender: message.sender,
content: message.content,
image: message.image,
id: message.id,
});
setShowMenu(false);
}}
>
<ReplySvg width={16} height={16} className="menu" />
<MenuText>Reply</MenuText>
</MenuItem>
{userMessage && (
<MenuItem
onClick={() => {
setShowMenu(false);
}}
>
<EditIcon width={16} height={16} />
<MenuText>Edit</MenuText>
</MenuItem>
)}
{activeChannel?.type !== "channel" && (
<MenuItem
onClick={() => {
setShowMenu(false);
}}
>
<PinIcon width={16} height={16} className="menu" />
<MenuText>Pin</MenuText>
</MenuItem>
)}
</MenuSection>
{userMessage && (
<MenuItem
onClick={() => {
setShowMenu(false);
}}
>
<DeleteIcon width={16} height={16} className="red" />
<MenuText className="red">Delete message</MenuText>
</MenuItem>
)}
</MessageDropdown>
</div>
) : (
<></>
);
};
const MessageDropdown = styled(DropdownMenu)`
width: 176px;
`;

View File

@ -31,4 +31,8 @@ const Icon = styled.svg`
&.red {
fill: ${({ theme }) => theme.redColor};
}
&.grey {
fill: ${({ theme }) => theme.secondary};
}
`;

View File

@ -27,4 +27,8 @@ export function EditIcon({ width, height, className }: EditIconProps) {
const Icon = styled.svg`
fill: ${({ theme }) => theme.tertiary};
&.grey {
fill: ${({ theme }) => theme.secondary};
}
`;

View File

@ -0,0 +1,41 @@
import React from "react";
import styled from "styled-components";
type PinIconProps = {
width: number;
height: number;
className?: string;
};
export function PinIcon({ width, height, className }: PinIconProps) {
return (
<Icon
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M14.8956 7.28455C14.8956 6.45612 14.2474 5.60892 13.4478 5.39227C12.6482 5.17563 12 5.67157 12 6.5C12 7.32843 12.6482 8.17563 13.4478 8.39227C14.2474 8.60892 14.8956 8.11297 14.8956 7.28455Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C8.68629 2 6 4.68629 6 8C6 10.9077 8.06835 13.3323 10.814 13.8828C11.0613 13.9324 11.25 14.1429 11.25 14.3951L11.25 21C11.25 21.4142 11.5858 21.75 12 21.75C12.4142 21.75 12.75 21.4142 12.75 21L12.75 14.3951C12.75 14.1429 12.9387 13.9324 13.186 13.8828C15.9317 13.3323 18 10.9077 18 8C18 4.68629 15.3137 2 12 2ZM7.5 8C7.5 10.4853 9.51472 12.5 12 12.5C14.4853 12.5 16.5 10.4853 16.5 8C16.5 5.51472 14.4853 3.5 12 3.5C9.51472 3.5 7.5 5.51472 7.5 8Z"
/>
</Icon>
);
}
const Icon = styled.svg`
fill: ${({ theme }) => theme.secondary};
&.menu {
fill: ${({ theme }) => theme.tertiary};
}
&.small {
width: 14px;
height: 14px;
}
`;

View File

@ -27,4 +27,8 @@ const Icon = styled.svg`
&.input {
fill: ${({ theme }) => theme.primary};
}
&.menu {
fill: ${({ theme }) => theme.tertiary};
}
`;

View File

@ -13,6 +13,7 @@ import { ChatMessage } from "../../models/ChatMessage";
import { equalDate } from "../../utils";
import { ChatMessageContent } from "../Chat/ChatMessageContent";
import { ContactMenu } from "../Form/ContactMenu";
import { MessageMenu } from "../Form/MessageMenu";
import { UntrustworthIcon } from "../Icons/UntrustworthIcon";
import { UserLogo } from "../Members/UserLogo";
import { Reactions } from "../Reactions/Reactions";
@ -104,6 +105,8 @@ export function UiMessage({
const ref = useRef(null);
useClickOutside(ref, () => setShowMenu(false));
const messageRef = useRef(null);
return (
<MessageOuterWrapper>
{(idx === 0 || !equalDate(prevMessage.date, message.date)) && (
@ -115,7 +118,7 @@ export function UiMessage({
)}
<MessageWrapper className={`${mentioned && "mention"}`} id={message.id}>
<MessageQuote quote={quote} />
<UserMessageWrapper>
<UserMessageWrapper ref={messageRef}>
<IconBtn
onClick={() => {
if (identity) setShowMenu((e) => !e);
@ -173,6 +176,13 @@ export function UiMessage({
/>
)}
</ContentWrapper>
<MessageMenu
message={message}
setReply={setReply}
messageReactions={messageReactions}
setMessageReactions={setMessageReactions}
messageRef={messageRef}
/>
</UserMessageWrapper>
{identity && (
<Reactions
@ -190,4 +200,5 @@ export function UiMessage({
const UserMessageWrapper = styled.div`
width: 100%;
display: flex;
position: relative;
`;

View File

@ -56,15 +56,24 @@ export const ReactionBtn = styled.button`
align-items: center;
border-radius: 8px;
align-self: center;
position: relative;
&:hover {
background: ${({ theme }) => theme.buttonBgHover};
}
&.red:hover {
background: ${({ theme }) => theme.buttonNoBgHover};
}
&:hover > svg {
fill: ${({ theme }) => theme.tertiary};
}
&.red:hover > svg {
fill: ${({ theme }) => theme.redColor};
}
&:hover > div {
visibility: visible;
}

View File

@ -10,7 +10,7 @@ const emojiLaughing = getEmojiDataFromNative("😆", "twitter", data);
const emojiDisappointed = getEmojiDataFromNative("😥", "twitter", data);
const emojiRage = getEmojiDataFromNative("😡", "twitter", data);
const emojiArr = [
export const emojiArr = [
emojiHeart,
emojiLike,
emojiDislike,
@ -46,13 +46,14 @@ export function ReactionPicker({
key={emoji.id}
onClick={() => handleReaction(emoji)}
className={`${messageReactions.includes(emoji) && "chosen"}`}
menuMode={className === "menu"}
>
{" "}
<Emoji
emoji={emoji}
set={"twitter"}
skin={emoji.skin || 1}
size={32}
size={className === "menu" ? 20 : 32}
/>
</EmojiBtn>
))}
@ -78,11 +79,19 @@ const Wrapper = styled.div`
transform: none;
border-radius: 16px 16px 16px 4px;
}
&.menu {
width: 100%;
position: static;
box-shadow: unset;
border: none;
padding: 0;
}
`;
const EmojiBtn = styled.button`
width: 40px;
height: 40px;
export const EmojiBtn = styled.button<{ menuMode: boolean }>`
width: ${({ menuMode }) => (menuMode ? "24px" : "40px")};
height: ${({ menuMode }) => (menuMode ? "24px" : "40px")};
display: flex;
justify-content: center;
align-items: center;

View File

@ -1,10 +1,16 @@
import { utils } from "@waku/status-communities/dist/cjs";
import { BaseEmoji } from "emoji-mart";
import React from "react";
import styled from "styled-components";
import { useIdentity } from "../../contexts/identityProvider";
import { useMessengerContext } from "../../contexts/messengerProvider";
import { Reply } from "../../hooks/useReply";
import { ChatMessage } from "../../models/ChatMessage";
import { Tooltip } from "../Form/Tooltip";
import { DeleteIcon } from "../Icons/DeleteIcon";
import { EditIcon } from "../Icons/EditIcon";
import { PinIcon } from "../Icons/PinIcon";
import { ReplySvg } from "../Icons/ReplyIcon";
import { ReactionBtn, ReactionButton } from "./ReactionButton";
@ -22,6 +28,12 @@ export function Reactions({
messageReactions,
setMessageReactions,
}: ReactionsProps) {
const identity = useIdentity();
const { activeChannel } = useMessengerContext();
const userMessage =
identity && message.sender === utils.bufToHex(identity.publicKey);
return (
<Wrapper>
<ReactionButton
@ -41,6 +53,24 @@ export function Reactions({
<ReplySvg width={22} height={22} />
<Tooltip tip="Reply" />
</ReactionBtn>
{userMessage && (
<ReactionBtn>
<EditIcon width={22} height={22} className="grey" />
<Tooltip tip="Edit" />
</ReactionBtn>
)}
{activeChannel?.type !== "channel" && (
<ReactionBtn>
<PinIcon width={22} height={22} />
<Tooltip tip="Pin" />
</ReactionBtn>
)}
{userMessage && (
<ReactionBtn className="red">
<DeleteIcon width={22} height={22} className="grey" />
<Tooltip tip="Delete" />
</ReactionBtn>
)}
</Wrapper>
);
}

View File

@ -0,0 +1,30 @@
import { RefObject, useCallback, useEffect, useState } from "react";
export const useClickPosition = (ref: RefObject<HTMLDivElement>) => {
const [topPosition, setTopPosition] = useState(0);
const [leftPosition, setLeftPosition] = useState(0);
const getPosition = useCallback(
(e: MouseEvent) => {
if (ref.current) {
const target = e.target as HTMLImageElement;
const imgTarget = target.tagName === "IMG";
const rect = ref.current.getBoundingClientRect();
const x = ref.current.clientWidth - e.clientX < 180 ? 180 : 0;
setLeftPosition(imgTarget ? -200 : e.clientX - rect.left - x);
setTopPosition(imgTarget ? 0 : e.clientY - rect.top);
}
},
[setTopPosition, setLeftPosition]
);
useEffect(() => {
document.addEventListener("contextmenu", getPosition);
return () => {
document.removeEventListener("contextmenu", getPosition);
};
});
return { topPosition, leftPosition };
};