From f2aa41309a2f02c6626b99e5f808b5c47efc8309 Mon Sep 17 00:00:00 2001 From: Maria Rushkova <66270386+mrushkova@users.noreply.github.com> Date: Fri, 28 Jan 2022 14:08:19 +0100 Subject: [PATCH] Add message context menu (#206) --- .../src/components/Form/DropdownMenu.tsx | 21 +++- .../src/components/Form/MessageMenu.tsx | 118 ++++++++++++++++++ .../src/components/Icons/DeleteIcon.tsx | 4 + .../src/components/Icons/EditIcon.tsx | 4 + .../src/components/Icons/PinIcon.tsx | 41 ++++++ .../src/components/Icons/ReplyIcon.tsx | 4 + .../src/components/Messages/UiMessage.tsx | 13 +- .../components/Reactions/ReactionButton.tsx | 9 ++ .../components/Reactions/ReactionPicker.tsx | 19 ++- .../src/components/Reactions/Reactions.tsx | 30 +++++ .../react-chat/src/hooks/useClickPosition.ts | 30 +++++ 11 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 packages/react-chat/src/components/Form/MessageMenu.tsx create mode 100644 packages/react-chat/src/components/Icons/PinIcon.tsx create mode 100644 packages/react-chat/src/hooks/useClickPosition.ts diff --git a/packages/react-chat/src/components/Form/DropdownMenu.tsx b/packages/react-chat/src/components/Form/DropdownMenu.tsx index 10aa61e..f799dff 100644 --- a/packages/react-chat/src/components/Form/DropdownMenu.tsx +++ b/packages/react-chat/src/components/Form/DropdownMenu.tsx @@ -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 ( - + {children} ); @@ -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; + } `; diff --git a/packages/react-chat/src/components/Form/MessageMenu.tsx b/packages/react-chat/src/components/Form/MessageMenu.tsx new file mode 100644 index 0000000..add6e63 --- /dev/null +++ b/packages/react-chat/src/components/Form/MessageMenu.tsx @@ -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>; + setReply: (val: Reply | undefined) => void; + messageRef: React.MutableRefObject; +} + +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 ? ( +
+ + + + + + { + setReply({ + sender: message.sender, + content: message.content, + image: message.image, + id: message.id, + }); + setShowMenu(false); + }} + > + + Reply + + + {userMessage && ( + { + setShowMenu(false); + }} + > + + Edit + + )} + {activeChannel?.type !== "channel" && ( + { + setShowMenu(false); + }} + > + + Pin + + )} + + {userMessage && ( + { + setShowMenu(false); + }} + > + + Delete message + + )} + +
+ ) : ( + <> + ); +}; + +const MessageDropdown = styled(DropdownMenu)` + width: 176px; +`; diff --git a/packages/react-chat/src/components/Icons/DeleteIcon.tsx b/packages/react-chat/src/components/Icons/DeleteIcon.tsx index f1ab68e..7e3c693 100644 --- a/packages/react-chat/src/components/Icons/DeleteIcon.tsx +++ b/packages/react-chat/src/components/Icons/DeleteIcon.tsx @@ -31,4 +31,8 @@ const Icon = styled.svg` &.red { fill: ${({ theme }) => theme.redColor}; } + + &.grey { + fill: ${({ theme }) => theme.secondary}; + } `; diff --git a/packages/react-chat/src/components/Icons/EditIcon.tsx b/packages/react-chat/src/components/Icons/EditIcon.tsx index fc2f1d7..3489b80 100644 --- a/packages/react-chat/src/components/Icons/EditIcon.tsx +++ b/packages/react-chat/src/components/Icons/EditIcon.tsx @@ -27,4 +27,8 @@ export function EditIcon({ width, height, className }: EditIconProps) { const Icon = styled.svg` fill: ${({ theme }) => theme.tertiary}; + + &.grey { + fill: ${({ theme }) => theme.secondary}; + } `; diff --git a/packages/react-chat/src/components/Icons/PinIcon.tsx b/packages/react-chat/src/components/Icons/PinIcon.tsx new file mode 100644 index 0000000..a2cb3da --- /dev/null +++ b/packages/react-chat/src/components/Icons/PinIcon.tsx @@ -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 ( + + + + + ); +} + +const Icon = styled.svg` + fill: ${({ theme }) => theme.secondary}; + + &.menu { + fill: ${({ theme }) => theme.tertiary}; + } + + &.small { + width: 14px; + height: 14px; + } +`; diff --git a/packages/react-chat/src/components/Icons/ReplyIcon.tsx b/packages/react-chat/src/components/Icons/ReplyIcon.tsx index f53887d..e5c6a87 100644 --- a/packages/react-chat/src/components/Icons/ReplyIcon.tsx +++ b/packages/react-chat/src/components/Icons/ReplyIcon.tsx @@ -27,4 +27,8 @@ const Icon = styled.svg` &.input { fill: ${({ theme }) => theme.primary}; } + + &.menu { + fill: ${({ theme }) => theme.tertiary}; + } `; diff --git a/packages/react-chat/src/components/Messages/UiMessage.tsx b/packages/react-chat/src/components/Messages/UiMessage.tsx index 2519eab..0e700b4 100644 --- a/packages/react-chat/src/components/Messages/UiMessage.tsx +++ b/packages/react-chat/src/components/Messages/UiMessage.tsx @@ -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 ( {(idx === 0 || !equalDate(prevMessage.date, message.date)) && ( @@ -115,7 +118,7 @@ export function UiMessage({ )} - + { if (identity) setShowMenu((e) => !e); @@ -173,6 +176,13 @@ export function UiMessage({ /> )} + {identity && ( 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; } diff --git a/packages/react-chat/src/components/Reactions/ReactionPicker.tsx b/packages/react-chat/src/components/Reactions/ReactionPicker.tsx index a2f25a0..ff551e5 100644 --- a/packages/react-chat/src/components/Reactions/ReactionPicker.tsx +++ b/packages/react-chat/src/components/Reactions/ReactionPicker.tsx @@ -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"} > {" "} ))} @@ -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; diff --git a/packages/react-chat/src/components/Reactions/Reactions.tsx b/packages/react-chat/src/components/Reactions/Reactions.tsx index 83c4c4c..1a30c08 100644 --- a/packages/react-chat/src/components/Reactions/Reactions.tsx +++ b/packages/react-chat/src/components/Reactions/Reactions.tsx @@ -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 ( + {userMessage && ( + + + + + )} + {activeChannel?.type !== "channel" && ( + + + + + )} + {userMessage && ( + + + + + )} ); } diff --git a/packages/react-chat/src/hooks/useClickPosition.ts b/packages/react-chat/src/hooks/useClickPosition.ts new file mode 100644 index 0000000..f924f33 --- /dev/null +++ b/packages/react-chat/src/hooks/useClickPosition.ts @@ -0,0 +1,30 @@ +import { RefObject, useCallback, useEffect, useState } from "react"; + +export const useClickPosition = (ref: RefObject) => { + 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 }; +};