You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			398 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			398 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
| /* eslint-disable @typescript-eslint/no-misused-promises */
 | |
| import React, { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
 | |
| 
 | |
| import { isNumber } from 'lodash';
 | |
| 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 { 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 {
 | |
|   openRightPanel,
 | |
|   showMessageInfoView,
 | |
|   toggleSelectedMessageId,
 | |
| } from '../../../../state/ducks/conversations';
 | |
| import { setRightOverlayMode } from '../../../../state/ducks/section';
 | |
| 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 DeleteItem = ({ messageId }: { messageId: string }) => {
 | |
|   const convoId = useSelectedConversationKey();
 | |
|   const isPublic = useSelectedIsPublic();
 | |
| 
 | |
|   const isDeletable = useMessageIsDeletable(messageId);
 | |
|   const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId);
 | |
| 
 | |
|   const onDelete = useCallback(() => {
 | |
|     if (convoId) {
 | |
|       if (!isPublic && isDeletable) {
 | |
|         void deleteMessagesById([messageId], convoId);
 | |
|       }
 | |
|       if (isPublic && isDeletableForEveryone) {
 | |
|         void deleteMessagesByIdForEveryone([messageId], convoId);
 | |
|       }
 | |
|     }
 | |
|   }, [convoId, isDeletable, isDeletableForEveryone, isPublic, messageId]);
 | |
| 
 | |
|   if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return <Item onClick={onDelete}>{window.i18n('delete')}</Item>;
 | |
| };
 | |
| 
 | |
| 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 ? (
 | |
|     <>
 | |
|       <Item onClick={onBan}>{window.i18n('banUser')}</Item>
 | |
|       <Item onClick={onUnban}>{window.i18n('banUnbanUser')}</Item>
 | |
|       {isSenderAdmin ? (
 | |
|         <Item onClick={removeModerator}>{window.i18n('adminRemoveAsAdmin')}</Item>
 | |
|       ) : (
 | |
|         <Item onClick={addModerator}>{window.i18n('adminPromoteToAdmin')}</Item>
 | |
|       )}
 | |
|     </>
 | |
|   ) : 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 ? <Item onClick={onRetry}>{window.i18n('resend')}</Item> : null;
 | |
| };
 | |
| 
 | |
| export const showMessageInfoOverlay = async ({
 | |
|   messageId,
 | |
|   dispatch,
 | |
| }: {
 | |
|   messageId: string;
 | |
|   dispatch: Dispatch<any>;
 | |
| }) => {
 | |
|   const found = await Data.getMessageById(messageId);
 | |
|   if (found) {
 | |
|     dispatch(showMessageInfoView(messageId));
 | |
|     dispatch(
 | |
|       setRightOverlayMode({
 | |
|         type: 'message_info',
 | |
|         params: { messageId, visibleAttachmentIndex: 0 },
 | |
|       })
 | |
|     );
 | |
|     dispatch(openRightPanel());
 | |
|   } else {
 | |
|     window.log.warn(`[showMessageInfoOverlay] Message ${messageId} not found in db`);
 | |
|   }
 | |
| };
 | |
| 
 | |
| export const MessageContextMenu = (props: Props) => {
 | |
|   const { messageId, contextMenuId, enableReactions } = props;
 | |
|   const dispatch = useDispatch();
 | |
|   const { hideAll } = useContextMenu();
 | |
| 
 | |
|   const isSelectedBlocked = useSelectedIsBlocked();
 | |
|   const convoId = useSelectedConversationKey();
 | |
| 
 | |
|   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<HTMLDivElement>(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 selectMessageText = window.i18n('messageSelect');
 | |
| 
 | |
|   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 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,
 | |
|       index: targetAttachmentIndex,
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   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 (
 | |
|     <StyledMessageContextMenu ref={contextMenuRef}>
 | |
|       {enableReactions && showEmojiPanel && (
 | |
|         <StyledEmojiPanelContainer role="button" x={mouseX} y={mouseY}>
 | |
|           <SessionEmojiPanel
 | |
|             ref={emojiPanelRef}
 | |
|             // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | |
|             onEmojiClicked={onEmojiClick}
 | |
|             show={showEmojiPanel}
 | |
|             isModal={true}
 | |
|             onKeyDown={onEmojiKeyDown}
 | |
|           />
 | |
|         </StyledEmojiPanelContainer>
 | |
|       )}
 | |
|       <SessionContextMenuContainer>
 | |
|         <Menu id={contextMenuId} onVisibilityChange={onVisibilityChange} animation="fade">
 | |
|           {enableReactions && (
 | |
|             // eslint-disable-next-line @typescript-eslint/no-misused-promises
 | |
|             <MessageReactBar
 | |
|               action={onEmojiClick}
 | |
|               additionalAction={onShowEmoji}
 | |
|               messageId={messageId}
 | |
|             />
 | |
|           )}
 | |
|           {attachments?.length ? (
 | |
|             <Item onClick={saveAttachment}>{window.i18n('attachmentsDownload')}</Item>
 | |
|           ) : null}
 | |
|           <Item onClick={copyText}>{window.i18n('copy')}</Item>
 | |
|           {(isSent || !isOutgoing) && <Item onClick={onReply}>{window.i18n('reply')}</Item>}
 | |
|           <Item
 | |
|             onClick={() => {
 | |
|               void showMessageInfoOverlay({ messageId, dispatch });
 | |
|             }}
 | |
|           >
 | |
|             {window.i18n('info')}
 | |
|           </Item>
 | |
|           <RetryItem messageId={messageId} />
 | |
|           {isDeletable ? <Item onClick={onSelect}>{selectMessageText}</Item> : null}
 | |
|           <DeleteItem messageId={messageId} />
 | |
|           <AdminActionItems messageId={messageId} />
 | |
|         </Menu>
 | |
|       </SessionContextMenuContainer>
 | |
|     </StyledMessageContextMenu>
 | |
|   );
 | |
| };
 |