/* eslint-disable @typescript-eslint/no-misused-promises */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Item, ItemParams, Menu, useContextMenu } from 'react-contexify'; import { useDispatch } from 'react-redux'; import { useClickAway, useMouse } from 'react-use'; import styled from 'styled-components'; import { isNumber } from 'lodash'; import { Data } from '../../../../data/data'; import { MessageInteraction } from '../../../../interactions'; import { replyToMessage } from '../../../../interactions/conversationInteractions'; import { deleteMessagesById, deleteMessagesByIdForEveryone, } from '../../../../interactions/conversations/unsendingInteractions'; import { addSenderAsModerator, removeSenderFromModerator, } from '../../../../interactions/messageInteractions'; import { MessageRenderingProps } from '../../../../models/messageType'; import { pushUnblockToSend } from '../../../../session/utils/Toast'; import { showMessageDetailsView, toggleSelectedMessageId, } from '../../../../state/ducks/conversations'; import { useMessageAttachments, useMessageBody, useMessageDirection, useMessageIsDeletable, useMessageIsDeletableForEveryone, useMessageSender, useMessageSenderIsAdmin, useMessageServerTimestamp, useMessageStatus, useMessageTimestamp, } from '../../../../state/selectors'; import { useSelectedConversationKey, useSelectedIsBlocked, useSelectedIsPublic, useSelectedWeAreAdmin, useSelectedWeAreModerator, } from '../../../../state/selectors/selectedConversation'; import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil'; import { Reactions } from '../../../../util/reactions'; import { SessionContextMenuContainer } from '../../../SessionContextMenuContainer'; import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel'; import { MessageReactBar } from './MessageReactBar'; export type MessageContextMenuSelectorProps = Pick< MessageRenderingProps, | 'sender' | 'direction' | 'status' | 'isDeletable' | 'isSenderAdmin' | 'text' | 'serverTimestamp' | 'timestamp' >; type Props = { messageId: string; contextMenuId: string; enableReactions: boolean }; const StyledMessageContextMenu = styled.div` position: relative; .contexify { margin-left: -104px; } `; const StyledEmojiPanelContainer = styled.div<{ x: number; y: number }>` position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 101; ${StyledEmojiPanel} { position: absolute; left: ${props => `${props.x}px`}; top: ${props => `${props.y}px`}; } `; const DeleteForEveryone = ({ messageId }: { messageId: string }) => { const convoId = useSelectedConversationKey(); const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); if (!convoId || !isDeletableForEveryone) { return null; } const onDeleteForEveryone = () => { void deleteMessagesByIdForEveryone([messageId], convoId); }; const unsendMessageText = window.i18n('deleteForEveryone'); return {unsendMessageText}; }; type MessageId = { messageId: string }; const AdminActionItems = ({ messageId }: MessageId) => { const convoId = useSelectedConversationKey(); const isPublic = useSelectedIsPublic(); const weAreModerator = useSelectedWeAreModerator(); const weAreAdmin = useSelectedWeAreAdmin(); const showAdminActions = (weAreAdmin || weAreModerator) && isPublic; const sender = useMessageSender(messageId); const isSenderAdmin = useMessageSenderIsAdmin(messageId); if (!convoId || !sender) { return null; } const addModerator = () => { void addSenderAsModerator(sender, convoId); }; const removeModerator = () => { void removeSenderFromModerator(sender, convoId); }; const onBan = () => { MessageInteraction.banUser(sender, convoId); }; const onUnban = () => { MessageInteraction.unbanUser(sender, convoId); }; return showAdminActions ? ( <> {window.i18n('banUser')} {window.i18n('unbanUser')} {isSenderAdmin ? ( {window.i18n('removeFromModerators')} ) : ( {window.i18n('addAsModerator')} )} ) : null; }; const RetryItem = ({ messageId }: MessageId) => { const direction = useMessageDirection(messageId); const status = useMessageStatus(messageId); const isOutgoing = direction === 'outgoing'; const showRetry = status === 'error' && isOutgoing; const onRetry = useCallback(async () => { const found = await Data.getMessageById(messageId); if (found) { await found.retrySend(); } }, [messageId]); return showRetry ? {window.i18n('resend')} : null; }; export const MessageContextMenu = (props: Props) => { const { messageId, contextMenuId, enableReactions } = props; const dispatch = useDispatch(); const { hideAll } = useContextMenu(); const isSelectedBlocked = useSelectedIsBlocked(); const convoId = useSelectedConversationKey(); const isPublic = useSelectedIsPublic(); const direction = useMessageDirection(messageId); const status = useMessageStatus(messageId); const isDeletable = useMessageIsDeletable(messageId); const text = useMessageBody(messageId); const attachments = useMessageAttachments(messageId); const timestamp = useMessageTimestamp(messageId); const serverTimestamp = useMessageServerTimestamp(messageId); const sender = useMessageSender(messageId); const isOutgoing = direction === 'outgoing'; const isSent = status === 'sent' || status === 'read'; // a read message should be replyable const emojiPanelRef = useRef(null); const [showEmojiPanel, setShowEmojiPanel] = useState(false); // emoji-mart v5.2.2 default dimensions const emojiPanelWidth = 354; const emojiPanelHeight = 435; const contextMenuRef = useRef(null); const { docX, docY } = useMouse(contextMenuRef); const [mouseX, setMouseX] = useState(0); const [mouseY, setMouseY] = useState(0); const onVisibilityChange = useCallback( (isVisible: boolean) => { if (isVisible) { if (showEmojiPanel) { setShowEmojiPanel(false); } window.contextMenuShown = true; return; } // This function will called before the click event // on the message would trigger (and I was unable to // prevent propagation in this case), so use a short timeout setTimeout(() => { window.contextMenuShown = false; }, 100); }, [showEmojiPanel] ); const onShowDetail = async () => { const found = await Data.getMessageById(messageId); if (found) { const messageDetailsProps = await found.getPropsForMessageDetail(); dispatch(showMessageDetailsView(messageDetailsProps)); } else { window.log.warn(`Message ${messageId} not found in db`); } }; const selectMessageText = window.i18n('selectMessage'); const deleteMessageJustForMeText = window.i18n('deleteJustForMe'); const onReply = useCallback(() => { if (isSelectedBlocked) { pushUnblockToSend(); return; } void replyToMessage(messageId); }, [isSelectedBlocked, messageId]); const copyText = useCallback(() => { MessageInteraction.copyBodyToClipboard(text); }, [text]); const onSelect = useCallback(() => { dispatch(toggleSelectedMessageId(messageId)); }, [dispatch, messageId]); const onDelete = useCallback(() => { if (convoId) { void deleteMessagesById([messageId], convoId); } }, [convoId, messageId]); const onShowEmoji = () => { hideAll(); setMouseX(docX); setMouseY(docY); setShowEmojiPanel(true); }; const onCloseEmoji = () => { setShowEmojiPanel(false); hideAll(); }; const onEmojiLoseFocus = () => { window.log.debug('closed due to lost focus'); onCloseEmoji(); }; const onEmojiClick = async (args: any) => { const emoji = args.native ?? args; onCloseEmoji(); await Reactions.sendMessageReaction(messageId, emoji); }; const onEmojiKeyDown = (event: any) => { if (event.key === 'Escape' && showEmojiPanel) { onCloseEmoji(); } }; const saveAttachment = (e: ItemParams) => { // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment // and the context menu save attachment item to save the right attachment I did not find a better way for now. // Note: If you change this, also make sure to update the `handleContextMenu()` in GenericReadableMessage.tsx const targetAttachmentIndex = isNumber(e?.props?.dataAttachmentIndex) ? e.props.dataAttachmentIndex : 0; e.event.stopPropagation(); if (!attachments?.length || !convoId || !sender) { return; } if (targetAttachmentIndex > attachments.length) { return; } const messageTimestamp = timestamp || serverTimestamp || 0; void saveAttachmentToDisk({ attachment: attachments[targetAttachmentIndex], messageTimestamp, messageSender: sender, conversationId: convoId, }); }; useClickAway(emojiPanelRef, () => { onEmojiLoseFocus(); }); useEffect(() => { if (emojiPanelRef.current) { const { innerWidth: windowWidth, innerHeight: windowHeight } = window; if (mouseX + emojiPanelWidth > windowWidth) { let x = mouseX; x = (mouseX + emojiPanelWidth - windowWidth) * 2; if (x === mouseX) { return; } setMouseX(mouseX - x); } if (mouseY + emojiPanelHeight > windowHeight) { const y = mouseY + emojiPanelHeight * 1.25 - windowHeight; if (y === mouseY) { return; } setMouseY(mouseY - y); } } }, [emojiPanelWidth, emojiPanelHeight, mouseX, mouseY]); if (!convoId) { return null; } return ( {enableReactions && showEmojiPanel && ( )} {enableReactions && ( // eslint-disable-next-line @typescript-eslint/no-misused-promises )} {attachments?.length ? ( {window.i18n('downloadAttachment')} ) : null} {window.i18n('copyMessage')} {(isSent || !isOutgoing) && ( {window.i18n('replyToMessage')} )} {(!isPublic || isOutgoing) && ( {window.i18n('moreInformation')} )} {isDeletable ? {selectMessageText} : null} {isDeletable && !isPublic ? ( {deleteMessageJustForMeText} ) : null} ); };