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.
		
		
		
		
		
			
		
			
				
	
	
		
			419 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			419 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
| import { useCallback, useEffect, useState } from 'react';
 | |
| 
 | |
| import { useDispatch, useSelector } from 'react-redux';
 | |
| import styled from 'styled-components';
 | |
| // tslint:disable-next-line: no-submodule-imports
 | |
| import useKey from 'react-use/lib/useKey';
 | |
| import { clipboard } from 'electron';
 | |
| import { PropsForAttachment, closeRightPanel } from '../../../../../state/ducks/conversations';
 | |
| import { resetRightOverlayMode, setRightOverlayMode } from '../../../../../state/ducks/section';
 | |
| import { getMessageInfoId } from '../../../../../state/selectors/conversations';
 | |
| import { Flex } from '../../../../basic/Flex';
 | |
| import { Header, HeaderTitle, StyledScrollContainer } from '../components';
 | |
| 
 | |
| import { IsDetailMessageViewContext } from '../../../../../contexts/isDetailViewContext';
 | |
| import { Data } from '../../../../../data/data';
 | |
| import { useRightOverlayMode } from '../../../../../hooks/useUI';
 | |
| import {
 | |
|   replyToMessage,
 | |
|   resendMessage,
 | |
| } from '../../../../../interactions/conversationInteractions';
 | |
| import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions';
 | |
| import {
 | |
|   useMessageAttachments,
 | |
|   useMessageAuthor,
 | |
|   useMessageBody,
 | |
|   useMessageDirection,
 | |
|   useMessageIsDeletable,
 | |
|   useMessageQuote,
 | |
|   useMessageSender,
 | |
|   useMessageServerTimestamp,
 | |
|   useMessageText,
 | |
|   useMessageTimestamp,
 | |
| } from '../../../../../state/selectors';
 | |
| import {
 | |
|   useSelectedConversationKey,
 | |
|   useSelectedIsGroupOrCommunity,
 | |
|   useSelectedIsPrivate,
 | |
|   useSelectedIsPublic,
 | |
| } from '../../../../../state/selectors/selectedConversation';
 | |
| import { canDisplayImagePreview } from '../../../../../types/Attachment';
 | |
| import { isAudio } from '../../../../../types/MIME';
 | |
| import {
 | |
|   getAudioDuration,
 | |
|   getVideoDuration,
 | |
| } from '../../../../../types/attachments/VisualAttachment';
 | |
| import { GoogleChrome } from '../../../../../util';
 | |
| import { saveAttachmentToDisk } from '../../../../../util/attachmentsUtil';
 | |
| import { SpacerLG, SpacerMD, SpacerXL } from '../../../../basic/Text';
 | |
| import { PanelButtonGroup, PanelIconButton } from '../../../../buttons';
 | |
| import { Message } from '../../../message/message-item/Message';
 | |
| import { AttachmentInfo, MessageInfo } from './components';
 | |
| import { AttachmentCarousel } from './components/AttachmentCarousel';
 | |
| import { ToastUtils } from '../../../../../session/utils';
 | |
| import { showCopyAccountIdAction } from '../../../../menu/items/CopyAccountId';
 | |
| 
 | |
| // NOTE we override the default max-widths when in the detail isDetailView
 | |
| const StyledMessageBody = styled.div`
 | |
|   padding-bottom: var(--margins-lg);
 | |
|   .module-message {
 | |
|     pointer-events: none;
 | |
| 
 | |
|     max-width: 100%;
 | |
|     @media (min-width: 1200px) {
 | |
|       max-width: 100%;
 | |
|     }
 | |
|   }
 | |
| `;
 | |
| 
 | |
| const MessageBody = ({
 | |
|   messageId,
 | |
|   supportsAttachmentCarousel,
 | |
| }: {
 | |
|   messageId: string;
 | |
|   supportsAttachmentCarousel: boolean;
 | |
| }) => {
 | |
|   const quote = useMessageQuote(messageId);
 | |
|   const text = useMessageText(messageId);
 | |
| 
 | |
|   // NOTE we don't want to render the message body if it's empty and the attachments carousel can render it instead
 | |
|   if (supportsAttachmentCarousel && !text && !quote) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return (
 | |
|     <IsDetailMessageViewContext.Provider value={true}>
 | |
|       <StyledMessageBody>
 | |
|         <Message messageId={messageId} />
 | |
|       </StyledMessageBody>
 | |
|     </IsDetailMessageViewContext.Provider>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const StyledMessageInfoContainer = styled.div`
 | |
|   height: calc(100% - 48px);
 | |
|   width: 100%;
 | |
|   max-width: 650px;
 | |
|   overflow: hidden auto;
 | |
|   z-index: 2;
 | |
| 
 | |
|   margin-inline-start: auto;
 | |
|   margin-inline-end: auto;
 | |
|   padding: var(--margins-sm) var(--margins-2xl) var(--margins-lg);
 | |
| `;
 | |
| 
 | |
| type MessageInfoProps = {
 | |
|   errors: Array<Error>;
 | |
|   attachments: Array<PropsForAttachment>;
 | |
| };
 | |
| 
 | |
| async function getPropsForMessageInfo(
 | |
|   messageId: string | undefined,
 | |
|   attachments: Array<PropsForAttachment>
 | |
| ): Promise<MessageInfoProps | null> {
 | |
|   if (!messageId) {
 | |
|     return null;
 | |
|   }
 | |
|   const found = await Data.getMessageById(messageId);
 | |
|   const attachmentsWithMediaDetails: Array<PropsForAttachment> = [];
 | |
|   if (found) {
 | |
|     // process attachments so we have the fileSize, url and screenshots
 | |
|     for (let i = 0; i < attachments.length; i++) {
 | |
|       const props = found.getPropsForAttachment(attachments[i]);
 | |
|       if (
 | |
|         props?.contentType &&
 | |
|         GoogleChrome.isVideoTypeSupported(props?.contentType) &&
 | |
|         !props.duration &&
 | |
|         props.url
 | |
|       ) {
 | |
|         // eslint-disable-next-line no-await-in-loop
 | |
|         const duration = await getVideoDuration({
 | |
|           objectUrl: props.url,
 | |
|           contentType: props.contentType,
 | |
|         });
 | |
|         attachmentsWithMediaDetails.push({
 | |
|           ...props,
 | |
|           duration,
 | |
|         });
 | |
|       } else if (props?.contentType && isAudio(props.contentType) && !props.duration && props.url) {
 | |
|         // eslint-disable-next-line no-await-in-loop
 | |
|         const duration = await getAudioDuration({
 | |
|           objectUrl: props.url,
 | |
|           contentType: props.contentType,
 | |
|         });
 | |
| 
 | |
|         attachmentsWithMediaDetails.push({
 | |
|           ...props,
 | |
|           duration,
 | |
|         });
 | |
|       } else if (props) {
 | |
|         attachmentsWithMediaDetails.push(props);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // This will make the error message for outgoing key errors a bit nicer
 | |
|     const errors = (found.get('errors') || []).map((error: any) => {
 | |
|       return error;
 | |
|     });
 | |
| 
 | |
|     const toRet: MessageInfoProps = {
 | |
|       errors,
 | |
|       attachments: attachmentsWithMediaDetails,
 | |
|     };
 | |
| 
 | |
|     return toRet;
 | |
|   }
 | |
|   return null;
 | |
| }
 | |
| 
 | |
| function useMessageInfo(messageId: string | undefined) {
 | |
|   const [details, setDetails] = useState<MessageInfoProps | null>(null);
 | |
| 
 | |
|   const fromState = useMessageAttachments(messageId);
 | |
| 
 | |
|   // this is not ideal, but also doesn't seem to create any performance issue at the moment.
 | |
|   // TODO: ideally, we'd want to save the attachment duration anytime we save one to the disk (incoming/outgoing), and just retrieve it from the redux state here.
 | |
|   useEffect(() => {
 | |
|     let mounted = true;
 | |
|     // eslint-disable-next-line more/no-then
 | |
|     void getPropsForMessageInfo(messageId, fromState || [])
 | |
|       .then(result => {
 | |
|         if (mounted) {
 | |
|           setDetails(result);
 | |
|         }
 | |
|       })
 | |
|       .catch(window.log.error);
 | |
| 
 | |
|     return () => {
 | |
|       mounted = false;
 | |
|     };
 | |
|   }, [fromState, messageId]);
 | |
| 
 | |
|   return details;
 | |
| }
 | |
| 
 | |
| type WithMessageIdOpt = { messageId: string };
 | |
| 
 | |
| /**
 | |
|  * Always shown, even if the message has no body so we always have at least one item in the PanelButtonGroup
 | |
|  */
 | |
| function CopyMessageBodyButton({ messageId }: WithMessageIdOpt) {
 | |
|   const messageBody = useMessageBody(messageId);
 | |
|   return (
 | |
|     <PanelIconButton
 | |
|       text={window.i18n('copy')}
 | |
|       iconType="copy"
 | |
|       onClick={() => {
 | |
|         clipboard.writeText(messageBody || '');
 | |
|         ToastUtils.pushCopiedToClipBoard();
 | |
|       }}
 | |
|       dataTestId="copy-msg-from-details"
 | |
|     />
 | |
|   );
 | |
| }
 | |
| 
 | |
| function ReplyToMessageButton({ messageId }: WithMessageIdOpt) {
 | |
|   const dispatch = useDispatch();
 | |
|   if (!messageId) {
 | |
|     return null;
 | |
|   }
 | |
|   return (
 | |
|     <PanelIconButton
 | |
|       text={window.i18n('reply')}
 | |
|       iconType="reply"
 | |
|       onClick={() => {
 | |
|         // eslint-disable-next-line more/no-then
 | |
|         void replyToMessage(messageId).then(foundIt => {
 | |
|           if (foundIt) {
 | |
|             dispatch(closeRightPanel());
 | |
|             dispatch(resetRightOverlayMode());
 | |
|           }
 | |
|         });
 | |
|       }}
 | |
|       dataTestId="reply-to-msg-from-details"
 | |
|     />
 | |
|   );
 | |
| }
 | |
| 
 | |
| function CopySenderSessionId({ messageId }: WithMessageIdOpt) {
 | |
|   const isGroupOrCommunity = useSelectedIsGroupOrCommunity();
 | |
|   const isPrivate = useSelectedIsPrivate();
 | |
|   const isPublic = useSelectedIsPublic();
 | |
|   const senderId = useMessageAuthor(messageId);
 | |
| 
 | |
|   const isGroup = isGroupOrCommunity && !isPublic;
 | |
|   const isPrivateAndShouldShow =
 | |
|     senderId && showCopyAccountIdAction({ isPrivate, pubkey: senderId });
 | |
| 
 | |
|   if (senderId && (isGroup || isPrivateAndShouldShow)) {
 | |
|     return (
 | |
|       <PanelIconButton
 | |
|         text={window.i18n('accountIDCopy')}
 | |
|         iconType="copy"
 | |
|         onClick={() => {
 | |
|           clipboard.writeText(senderId);
 | |
|           ToastUtils.pushCopiedToClipBoard();
 | |
|         }}
 | |
|         dataTestId="copy-sender-from-details"
 | |
|       />
 | |
|     );
 | |
|   }
 | |
|   return null;
 | |
| }
 | |
| 
 | |
| export const OverlayMessageInfo = () => {
 | |
|   const dispatch = useDispatch();
 | |
| 
 | |
|   const rightOverlayMode = useRightOverlayMode();
 | |
|   const messageId = useSelector(getMessageInfoId);
 | |
|   const messageInfo = useMessageInfo(messageId);
 | |
|   const isDeletable = useMessageIsDeletable(messageId);
 | |
|   const direction = useMessageDirection(messageId);
 | |
|   const timestamp = useMessageTimestamp(messageId);
 | |
|   const serverTimestamp = useMessageServerTimestamp(messageId);
 | |
|   const sender = useMessageSender(messageId);
 | |
| 
 | |
|   // we close the right panel when switching conversation so the convoId of that message is always the selectedConversationKey
 | |
|   // is always the currently selected conversation
 | |
|   const convoId = useSelectedConversationKey();
 | |
| 
 | |
|   const closePanel = useCallback(() => {
 | |
|     dispatch(closeRightPanel());
 | |
|     dispatch(resetRightOverlayMode());
 | |
|   }, [dispatch]);
 | |
| 
 | |
|   useKey('Escape', closePanel);
 | |
| 
 | |
|   // close the panel if the messageInfo is associated with a deleted message
 | |
|   useEffect(() => {
 | |
|     if (!sender) {
 | |
|       closePanel();
 | |
|     }
 | |
|   }, [sender, closePanel]);
 | |
| 
 | |
|   if (!rightOverlayMode || !messageInfo || !convoId || !messageId || !sender) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   const { params } = rightOverlayMode;
 | |
|   const visibleAttachmentIndex = params?.visibleAttachmentIndex || 0;
 | |
| 
 | |
|   const { errors, attachments } = messageInfo;
 | |
| 
 | |
|   const hasAttachments = attachments && attachments.length > 0;
 | |
|   const supportsAttachmentCarousel = canDisplayImagePreview(attachments);
 | |
|   const hasErrors = errors && errors.length > 0;
 | |
| 
 | |
|   const handleChangeAttachment = (changeDirection: 1 | -1) => {
 | |
|     if (!hasAttachments) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const newVisibleIndex = visibleAttachmentIndex + changeDirection;
 | |
|     if (newVisibleIndex > attachments.length - 1) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (newVisibleIndex < 0) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (attachments[newVisibleIndex]) {
 | |
|       dispatch(
 | |
|         setRightOverlayMode({
 | |
|           type: 'message_info',
 | |
|           params: { messageId, visibleAttachmentIndex: newVisibleIndex },
 | |
|         })
 | |
|       );
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <StyledScrollContainer>
 | |
|       <Flex container={true} flexDirection={'column'} alignItems={'center'}>
 | |
|         <Header hideBackButton={true} closeButtonOnClick={closePanel}>
 | |
|           <HeaderTitle>{window.i18n('messageInfo')}</HeaderTitle>
 | |
|         </Header>
 | |
|         <StyledMessageInfoContainer>
 | |
|           <MessageBody
 | |
|             messageId={messageId}
 | |
|             supportsAttachmentCarousel={supportsAttachmentCarousel}
 | |
|           />
 | |
|           {hasAttachments && (
 | |
|             <>
 | |
|               {supportsAttachmentCarousel && (
 | |
|                 <>
 | |
|                   <AttachmentCarousel
 | |
|                     messageId={messageId}
 | |
|                     attachments={attachments}
 | |
|                     visibleIndex={visibleAttachmentIndex}
 | |
|                     nextAction={() => {
 | |
|                       handleChangeAttachment(1);
 | |
|                     }}
 | |
|                     previousAction={() => {
 | |
|                       handleChangeAttachment(-1);
 | |
|                     }}
 | |
|                   />
 | |
|                   <SpacerXL />
 | |
|                 </>
 | |
|               )}
 | |
|               <AttachmentInfo attachment={attachments[visibleAttachmentIndex]} />
 | |
|               <SpacerMD />
 | |
|             </>
 | |
|           )}
 | |
|           <MessageInfo messageId={messageId} errors={messageInfo.errors} />
 | |
|           <SpacerLG />
 | |
|           <PanelButtonGroup style={{ margin: '0' }}>
 | |
|             {/* CopyMessageBodyButton is always shown so the PanelButtonGroup always has at least one item */}
 | |
|             <CopyMessageBodyButton messageId={messageId} />
 | |
|             <ReplyToMessageButton messageId={messageId} />
 | |
|             <CopySenderSessionId messageId={messageId} />
 | |
|             {hasErrors && direction === 'outgoing' && (
 | |
|               <PanelIconButton
 | |
|                 text={window.i18n('resend')}
 | |
|                 iconType="resend"
 | |
|                 onClick={() => {
 | |
|                   void resendMessage(messageId);
 | |
|                   dispatch(closeRightPanel());
 | |
|                   dispatch(resetRightOverlayMode());
 | |
|                 }}
 | |
|                 dataTestId="resend-msg-from-details"
 | |
|               />
 | |
|             )}
 | |
|             {hasAttachments && (
 | |
|               <PanelIconButton
 | |
|                 text={window.i18n('save')}
 | |
|                 iconType="saveToDisk"
 | |
|                 dataTestId="save-attachment-from-details"
 | |
|                 onClick={() => {
 | |
|                   if (hasAttachments) {
 | |
|                     void saveAttachmentToDisk({
 | |
|                       conversationId: convoId,
 | |
|                       messageSender: sender,
 | |
|                       messageTimestamp: serverTimestamp || timestamp || Date.now(),
 | |
|                       attachment: attachments[visibleAttachmentIndex],
 | |
|                       index: visibleAttachmentIndex,
 | |
|                     });
 | |
|                   }
 | |
|                 }}
 | |
|               />
 | |
|             )}
 | |
|             {isDeletable && (
 | |
|               <PanelIconButton
 | |
|                 text={window.i18n('delete')}
 | |
|                 iconType="delete"
 | |
|                 color={'var(--danger-color)'}
 | |
|                 dataTestId="delete-from-details"
 | |
|                 onClick={() => {
 | |
|                   void deleteMessagesById([messageId], convoId);
 | |
|                 }}
 | |
|               />
 | |
|             )}
 | |
|           </PanelButtonGroup>
 | |
|           <SpacerXL />
 | |
|         </StyledMessageInfoContainer>
 | |
|       </Flex>
 | |
|     </StyledScrollContainer>
 | |
|   );
 | |
| };
 |