// tslint:disable: no-backbone-get-set-outside-model import React from 'react'; import classNames from 'classnames'; import { SessionCompositionBox, StagedAttachmentType } from './SessionCompositionBox'; import { ClosedGroup, Constants, Utils } from '../../../session'; import _ from 'lodash'; import { AttachmentUtil, GoogleChrome } from '../../../util'; import { ConversationHeaderWithDetails } from '../../conversation/ConversationHeader'; import { SessionRightPanelWithDetails } from './SessionRightPanel'; import { SessionTheme } from '../../../state/ducks/SessionTheme'; import { DefaultTheme, useTheme } from 'styled-components'; import { SessionMessagesList } from './SessionMessagesList'; import { LightboxGallery, MediaItemType } from '../../LightboxGallery'; import { Message } from '../../conversation/media-gallery/types/Message'; import { AttachmentType, save } from '../../../types/Attachment'; import { ToastUtils, UserUtils } from '../../../session/utils'; import * as MIME from '../../../types/MIME'; import { SessionFileDropzone } from './SessionFileDropzone'; import { ConversationType } from '../../../state/ducks/conversations'; import { MessageView } from '../../MainViewController'; import { pushUnblockToSend } from '../../../session/utils/Toast'; import { MessageDetail } from '../../conversation/MessageDetail'; import { ConversationController } from '../../../session/conversations'; import { getMessageById, getPubkeysInPublicConversation } from '../../../data/data'; import autoBind from 'auto-bind'; import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmentsManager'; import { deleteOpenGroupMessages } from '../../../interactions/conversation'; import { ConversationTypeEnum } from '../../../models/conversation'; import { SessionButtonColor } from '../SessionButton'; import { AddModeratorsDialog } from '../../conversation/ModeratorsAddDialog'; import { RemoveModeratorsDialog } from '../../conversation/ModeratorsRemoveDialog'; import { UpdateGroupNameDialog } from '../../conversation/UpdateGroupNameDialog'; import { UpdateGroupMembersDialog } from '../../conversation/UpdateGroupMembersDialog'; import { getOurNumber } from '../../../state/selectors/user'; import { useSelector } from 'react-redux'; import { InviteContactsDialog } from '../../conversation/InviteContactsDialog'; interface State { // Message sending progress messageProgressVisible: boolean; sendingProgress: number; prevSendingProgress: number; // Sending failed: -1 // Not send yet: 0 // Sending message: 1 // Sending success: 2 sendingProgressStatus: -1 | 0 | 1 | 2; unreadCount: number; selectedMessages: Array; displayScrollToBottomButton: boolean; showOverlay: boolean; showRecordingView: boolean; showOptionsPane: boolean; // if set, the `More Info` of a message screen is shown on top of the conversation. messageDetailShowProps?: any; // FIXME set the type for this stagedAttachments: Array; isDraggingFile: boolean; // quoted message quotedMessageTimestamp?: number; quotedMessageProps?: any; // lightbox options lightBoxOptions?: any; modal: JSX.Element | null; } interface Props { ourNumber: string; selectedConversationKey: string; selectedConversation?: ConversationType; theme: DefaultTheme; messages: Array; actions: any; } export class SessionConversation extends React.Component { private readonly compositionBoxRef: React.RefObject; private readonly messageContainerRef: React.RefObject; private dragCounter: number; private publicMembersRefreshTimeout?: NodeJS.Timeout; constructor(props: any) { super(props); const unreadCount = this.props.selectedConversation?.unreadCount || 0; this.state = { messageProgressVisible: false, sendingProgress: 0, prevSendingProgress: 0, sendingProgressStatus: 0, unreadCount, selectedMessages: [], displayScrollToBottomButton: false, showOverlay: false, showRecordingView: false, showOptionsPane: false, stagedAttachments: [], isDraggingFile: false, modal: null }; this.compositionBoxRef = React.createRef(); this.messageContainerRef = React.createRef(); this.dragCounter = 0; autoBind(this); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public componentDidUpdate(prevProps: Props, prevState: State) { const { selectedConversationKey: newConversationKey, selectedConversation: newConversation, } = this.props; const { selectedConversationKey: oldConversationKey } = prevProps; // if the convo is valid, and it changed, register for drag events if (newConversationKey && newConversation && newConversationKey !== oldConversationKey) { // Pause thread to wait for rendering to complete setTimeout(() => { const div = this.messageContainerRef.current; div?.addEventListener('dragenter', this.handleDragIn); div?.addEventListener('dragleave', this.handleDragOut); div?.addEventListener('dragover', this.handleDrag); div?.addEventListener('drop', this.handleDrop); }, 100); // if the conversation changed, we have to stop our refresh of member list if (this.publicMembersRefreshTimeout) { global.clearInterval(this.publicMembersRefreshTimeout); this.publicMembersRefreshTimeout = undefined; } // if the newConversation changed, and is public, start our refresh members list if (newConversation.isPublic) { // TODO use abort controller to stop those requests too void this.updateMemberList(); this.publicMembersRefreshTimeout = global.setInterval(this.updateMemberList, 10000); } } // if we do not have a model, unregister for events if (!newConversation) { const div = this.messageContainerRef.current; div?.removeEventListener('dragenter', this.handleDragIn); div?.removeEventListener('dragleave', this.handleDragOut); div?.removeEventListener('dragover', this.handleDrag); div?.removeEventListener('drop', this.handleDrop); } if (newConversationKey !== oldConversationKey) { void this.loadInitialMessages(); this.setState({ showOptionsPane: false, selectedMessages: [], displayScrollToBottomButton: false, showOverlay: false, showRecordingView: false, stagedAttachments: [], isDraggingFile: false, messageDetailShowProps: undefined, quotedMessageProps: undefined, quotedMessageTimestamp: undefined, }); } } public componentWillUnmount() { const div = this.messageContainerRef.current; div?.removeEventListener('dragenter', this.handleDragIn); div?.removeEventListener('dragleave', this.handleDragOut); div?.removeEventListener('dragover', this.handleDrag); div?.removeEventListener('drop', this.handleDrop); if (this.publicMembersRefreshTimeout) { global.clearInterval(this.publicMembersRefreshTimeout); this.publicMembersRefreshTimeout = undefined; } } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~ RENDER METHODS ~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public render() { const { showRecordingView, showOptionsPane, quotedMessageProps, lightBoxOptions, selectedMessages, isDraggingFile, stagedAttachments, messageDetailShowProps, } = this.state; const selectionMode = !!selectedMessages.length; const { selectedConversation, selectedConversationKey, messages, actions } = this.props; if (!selectedConversation || !messages) { // return an empty message view return ; } const conversationModel = ConversationController.getInstance().get(selectedConversationKey); // TODO VINCE: OPTIMISE FOR NEW SENDING??? const sendMessageFn = ( body: any, attachments: any, quote: any, preview: any, groupInvitation: any ) => { if (!conversationModel) { return; } void conversationModel.sendMessage(body, attachments, quote, preview, groupInvitation); if (this.messageContainerRef.current) { // force scrolling to bottom on message sent // this will mark all messages as read, and reset the conversation unreadCount (this.messageContainerRef .current as any).scrollTop = this.messageContainerRef.current?.scrollHeight; } }; const showMessageDetails = !!messageDetailShowProps; const isPublic = selectedConversation.isPublic || false; const isPrivate = selectedConversation.type === ConversationTypeEnum.PRIVATE; return (
{this.renderHeader()}
{/* */}
{showMessageDetails && }
{lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}
{showRecordingView &&
} {isDraggingFile && }
{ void this.replyToMessage(undefined); }} textarea={this.compositionBoxRef} clearAttachments={this.clearAttachments} removeAttachment={this.removeAttachment} onChoseAttachments={this.onChoseAttachments} theme={this.props.theme} />
{ this.state.modal ? this.state.modal : null}
); } public renderHeader() { const headerProps = this.getHeaderProps(); return ; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~ GETTER METHODS ~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public async loadInitialMessages() { const { selectedConversation, selectedConversationKey } = this.props; if (!selectedConversation) { return; } const conversationModel = ConversationController.getInstance().get(selectedConversationKey); const unreadCount = await conversationModel.getUnreadCount(); const messagesToFetch = Math.max( Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT, unreadCount ); this.props.actions.fetchMessagesForConversation({ conversationKey: selectedConversationKey, count: messagesToFetch, }); } public getHeaderProps() { const { selectedConversationKey, ourNumber } = this.props; const { selectedMessages, messageDetailShowProps } = this.state; const conversation = ConversationController.getInstance().getOrThrow(selectedConversationKey); const expireTimer = conversation.get('expireTimer'); const expirationSettingName = expireTimer ? window.Whisper.ExpirationTimerOptions.getName(expireTimer || 0) : null; const members = conversation.get('members') || []; const headerProps = { id: conversation.id, name: conversation.getName(), phoneNumber: conversation.getNumber(), profileName: conversation.getProfileName(), avatarPath: conversation.getAvatarPath(), isMe: conversation.isMe(), isBlocked: conversation.isBlocked(), isGroup: !conversation.isPrivate(), isPrivate: conversation.isPrivate(), isPublic: conversation.isPublic(), isAdmin: conversation.isAdmin(ourNumber), members, subscriberCount: conversation.get('subscriberCount'), isKickedFromGroup: conversation.get('isKickedFromGroup'), left: conversation.get('left'), expirationSettingName, showBackButton: Boolean(messageDetailShowProps), timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({ name: item.getName(), value: item.get('seconds'), })), hasNickname: !!conversation.getNickname(), selectionMode: !!selectedMessages.length, onSetDisappearingMessages: conversation.updateExpirationTimer, onDeleteMessages: conversation.deleteMessages, onDeleteSelectedMessages: this.deleteSelectedMessages, onClearNickname: conversation.clearNickname, onCloseOverlay: () => { this.setState({ selectedMessages: [] }); }, onGoBack: () => { this.setState({ messageDetailShowProps: undefined, }); }, onUpdateGroupName: () => { window.Whisper.events.trigger('updateGroupName', conversation); }, onBlockUser: () => { void conversation.block(); }, onUnblockUser: () => { void conversation.unblock(); }, onCopyPublicKey: () => { conversation.copyPublicKey(); }, onLeaveGroup: () => { window.Whisper.events.trigger('leaveClosedGroup', conversation); }, onInviteContacts: () => { window.Whisper.events.trigger('inviteContacts', conversation); }, onMarkAllRead: () => { void conversation.markReadBouncy(Date.now()); }, onAddModerators: () => { window.Whisper.events.trigger('addModerators', conversation); }, onRemoveModerators: () => { window.Whisper.events.trigger('removeModerators', conversation); }, onAvatarClick: (pubkey: any) => { this.toggleRightPanel(); }, }; return headerProps; } public getMessagesListProps() { const { selectedConversation, selectedConversationKey, ourNumber, messages, actions, } = this.props; const { quotedMessageTimestamp, selectedMessages } = this.state; return { selectedMessages, ourPrimary: ourNumber, conversationKey: selectedConversationKey, messages, resetSelection: this.resetSelection, quotedMessageTimestamp, conversation: selectedConversation as ConversationType, selectMessage: this.selectMessage, deleteMessage: this.deleteMessage, fetchMessagesForConversation: actions.fetchMessagesForConversation, replyToMessage: this.replyToMessage, showMessageDetails: this.showMessageDetails, onClickAttachment: this.onClickAttachment, onDownloadAttachment: this.saveAttachment, messageContainerRef: this.messageContainerRef, onDeleteSelectedMessages: this.deleteSelectedMessages, }; } public getRightPanelProps() { const { selectedConversationKey } = this.props; const conversation = ConversationController.getInstance().getOrThrow(selectedConversationKey); const ourPrimary = window.storage.get('primaryDevicePubKey'); const members = conversation.get('members') || []; const isAdmin = conversation.isMediumGroup() ? true : conversation.isPublic() ? conversation.isAdmin(ourPrimary) : false; return { id: conversation.id, name: conversation.getName(), memberCount: members.length, phoneNumber: conversation.getNumber(), profileName: conversation.getProfileName(), avatarPath: conversation.getAvatarPath(), isKickedFromGroup: conversation.get('isKickedFromGroup'), left: conversation.get('left'), isGroup: !conversation.isPrivate(), isPublic: conversation.isPublic(), isAdmin, isBlocked: conversation.isBlocked(), timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({ name: item.getName(), value: item.get('seconds'), })), onSetDisappearingMessages: (seconds: any) => { if (seconds > 0) { void conversation.updateExpirationTimer(seconds); } else { void conversation.updateExpirationTimer(null); } }, onGoBack: () => { this.toggleRightPanel(); }, onUpdateGroupName: () => { // warrick: remove trigger once everything is cleaned up // window.Whisper.events.trigger('updateGroupName', conversation); const avatarPath = conversation.getAvatarPath(); const groupName = conversation.getName(); const groupId = conversation.id; const members = conversation.get('members') || []; const isPublic = conversation.isPublic(); let isAdmin = true; let titleText; titleText = window.i18n('updateGroupDialogTitle', groupName); if (isPublic) { // fix the title // I'd much prefer to integrate mods with groupAdmins // but lets discuss first... isAdmin = conversation.isAdmin(window.storage.get('primaryDevicePubKey')); } const onClose = () => { this.setState({ ...this.state, modal: null }) } const onUpdateGroupNameSubmit = (newGroupName: string, newAvatarPath: string) => { if (newGroupName !== groupName || newAvatarPath !== avatarPath) { ClosedGroup.initiateGroupUpdate( groupId, newGroupName, members, newAvatarPath ) } } this.setState({ ...this.state, modal: ( ) }) }, onUpdateGroupMembers: async () => { // window.Whisper.events.trigger('updateGroupMembers', conversation); // return; if (conversation.isMediumGroup()) { // make sure all the members' convo exists so we can add or remove them await Promise.all( conversation .get('members') .map(m => ConversationController.getInstance().getOrCreateAndWait( m, ConversationTypeEnum.PRIVATE ) ) ); } const groupName = conversation.getName(); const isPublic = conversation.isPublic(); const groupId = conversation.id; const members = conversation.get('members') || []; const avatarPath = conversation.getAvatarPath(); const theme = this.props.theme; const titleText = window.i18n('updateGroupDialogTitle', groupName); const ourPK = Utils.UserUtils.getOurPubKeyStrFromCache(); let admins = conversation.get('groupAdmins'); const isAdmin = conversation.get('groupAdmins')?.includes(ourPK) ? true : false; const convos = ConversationController.getInstance().getConversations() .filter(d => !!d); let existingMembers = conversation.get('members') || []; let existingZombies = conversation.get('zombies') || []; let contactsAndMembers = convos.filter( d => existingMembers.includes(d.id) && d.isPrivate() && !d.isMe() ); // contactsAndMembers = _.uniqBy(contactsAndMembers, true, d => d.id); contactsAndMembers = _.uniqBy(contactsAndMembers, 'id'); // at least make sure it's an array if (!Array.isArray(existingMembers)) { existingMembers = []; } const onClose = () => { this.setState({ ...this.state, modal: null }) } const onSubmit = async (newMembers: string[]) => { const _ = window.Lodash; const ourPK = Utils.UserUtils.getOurPubKeyStrFromCache(); const allMembersAfterUpdate = window.Lodash.concat(newMembers, [ourPK]); if (!isAdmin) { window.log.warn('Skipping update of members, we are not the admin'); return; } // new members won't include the zombies. We are the admin and we want to remove them not matter what // We need to NOT trigger an group update if the list of member is the same. // we need to merge all members, including zombies for this call. // we consider that the admin ALWAYS wants to remove zombies (actually they should be removed // automatically by him when the LEFT message is received) const allExistingMembersWithZombies = _.uniq( existingMembers.concat(existingZombies) ); const notPresentInOld = allMembersAfterUpdate.filter( (m: string) => !allExistingMembersWithZombies.includes(m) ); // be sure to include zombies in here const membersToRemove = allExistingMembersWithZombies.filter( (m: string) => !allMembersAfterUpdate.includes(m) ); const xor = _.xor(membersToRemove, notPresentInOld); if (xor.length === 0) { window.log.info('skipping group update: no detected changes in group member list'); return; } // If any extra devices of removed exist in newMembers, ensure that you filter them // Note: I think this is useless const filteredMembers = allMembersAfterUpdate.filter( (member: string) => !_.includes(membersToRemove, member) ); ClosedGroup.initiateGroupUpdate( groupId, groupName, filteredMembers, avatarPath ); } this.setState({ ...this.state, modal: () }) // warrick: delete old code }, onInviteContacts: () => { this.setState({ ...this.state, modal: ( this.setState({ ...this.state, modal: null })} theme={this.props.theme} />) }) }, onLeaveGroup: () => { window.Whisper.events.trigger('leaveClosedGroup', conversation); }, onAddModerators: () => { // window.Whisper.events.trigger('addModerators', conversation); this.setState({ ...this.state, modal: ( this.setState({ ...this.state, modal: null })} theme={this.props.theme} />) }) }, onRemoveModerators: () => { // window.Whisper.events.trigger('removeModerators', conversation); this.setState({ ...this.state, modal: ( this.setState({ ...this.state, modal: null })} theme={this.props.theme} />) }) }, onShowLightBox: (lightBoxOptions = {}) => { this.setState({ lightBoxOptions }); }, }; } public toggleRightPanel() { const { showOptionsPane } = this.state; this.setState({ showOptionsPane: !showOptionsPane }); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public updateSendingProgress(value: number, status: -1 | 0 | 1 | 2) { // If you're sending a new message, reset previous value to zero const prevSendingProgress = status === 1 ? 0 : this.state.sendingProgress; this.setState({ sendingProgress: value, prevSendingProgress, sendingProgressStatus: status, }); } public resetSendingProgress() { this.setState({ sendingProgress: 0, prevSendingProgress: 0, sendingProgressStatus: 0, }); } public onMessageSending() { // Set sending state 5% to show message sending const initialValue = 5; this.updateSendingProgress(initialValue, 1); if (this.state.quotedMessageTimestamp) { this.setState({ quotedMessageTimestamp: undefined, quotedMessageProps: undefined, }); } } public onMessageSuccess() { this.updateSendingProgress(100, 2); } public onMessageFailure() { this.updateSendingProgress(100, -1); } public async deleteMessagesById(messageIds: Array, askUserForConfirmation: boolean) { // Get message objects const { selectedConversationKey, selectedConversation, messages } = this.props; const conversationModel = ConversationController.getInstance().getOrThrow( selectedConversationKey ); if (!selectedConversation) { window.log.info('No valid selected conversation.'); return; } const selectedMessages = messages.filter(message => messageIds.find(selectedMessage => selectedMessage === message.id) ); const multiple = selectedMessages.length > 1; // In future, we may be able to unsend private messages also // isServerDeletable also defined in ConversationHeader.tsx for // future reference const isServerDeletable = selectedConversation.isPublic; const warningMessage = (() => { if (selectedConversation.isPublic) { return multiple ? window.i18n('deleteMultiplePublicWarning') : window.i18n('deletePublicWarning'); } return multiple ? window.i18n('deleteMultipleWarning') : window.i18n('deleteWarning'); })(); const doDelete = async () => { let toDeleteLocally; if (selectedConversation.isPublic) { // Get our Moderator status const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); if (!ourDevicePubkey) { return; } const isAdmin = conversationModel.isAdmin(ourDevicePubkey); const isAllOurs = selectedMessages.every( message => ourDevicePubkey === message.attributes.source ); if (!isAllOurs && !isAdmin) { ToastUtils.pushMessageDeleteForbidden(); this.setState({ selectedMessages: [] }); return; } toDeleteLocally = await deleteOpenGroupMessages(selectedMessages, conversationModel); if (toDeleteLocally.length === 0) { // Message failed to delete from server, show error? return; } } else { toDeleteLocally = selectedMessages; } await Promise.all( toDeleteLocally.map(async message => { await conversationModel.removeMessage(message.id); }) ); // Update view and trigger update this.setState({ selectedMessages: [] }, ToastUtils.pushDeleted); }; let title = ''; // Note: keep that i18n logic separated so the scripts in tools/ find the usage of those if (isServerDeletable) { if (multiple) { title = window.i18n('deleteMessagesForEveryone'); } else { title = window.i18n('deleteMessageForEveryone'); } } else { if (multiple) { title = window.i18n('deleteMessages'); } else { title = window.i18n('deleteMessage'); } } const okText = window.i18n(isServerDeletable ? 'deleteForEveryone' : 'delete'); if (askUserForConfirmation) { const onClickClose = () => { this.props.actions.updateConfirmModal(null); } this.props.actions.updateConfirmModal({ title, message: warningMessage, okText, okTheme: SessionButtonColor.Danger, onClickOk: doDelete, onClickClose, closeAfterClick: true }) } else { void doDelete(); } } public async deleteSelectedMessages() { await this.deleteMessagesById(this.state.selectedMessages, true); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public selectMessage(messageId: string) { const selectedMessages = this.state.selectedMessages.includes(messageId) ? // Add to array if not selected. Else remove. this.state.selectedMessages.filter(id => id !== messageId) : [...this.state.selectedMessages, messageId]; this.setState({ selectedMessages }); } public deleteMessage(messageId: string) { this.setState({ selectedMessages: [messageId] }, this.deleteSelectedMessages); } public resetSelection() { this.setState({ selectedMessages: [] }); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ MICROPHONE METHODS ~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ private onLoadVoiceNoteView() { this.setState({ showRecordingView: true, selectedMessages: [], }); } private onExitVoiceNoteView() { this.setState({ showRecordingView: false, }); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~ MESSAGE QUOTE ~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ private async replyToMessage(quotedMessageTimestamp?: number) { if (this.props.selectedConversation?.isBlocked) { pushUnblockToSend(); return; } if (!_.isEqual(this.state.quotedMessageTimestamp, quotedMessageTimestamp)) { const { messages, selectedConversationKey } = this.props; const conversationModel = ConversationController.getInstance().getOrThrow( selectedConversationKey ); let quotedMessageProps = null; if (quotedMessageTimestamp) { const quotedMessage = messages.find(m => m.attributes.sent_at === quotedMessageTimestamp); if (quotedMessage) { const quotedMessageModel = await getMessageById(quotedMessage.id); if (quotedMessageModel) { quotedMessageProps = await conversationModel.makeQuote(quotedMessageModel); } } } this.setState({ quotedMessageTimestamp, quotedMessageProps }, () => { this.compositionBoxRef.current?.focus(); }); } } private showMessageDetails(messageProps: any) { messageProps.onDeleteMessage = async (id: string) => { await this.deleteMessagesById([id], false); this.setState({ messageDetailShowProps: undefined }); }; this.setState({ messageDetailShowProps: messageProps, showOptionsPane: false, }); } private onClickAttachment(attachment: any, message: any) { const media = (message.attachments || []).map((attachmentForMedia: any) => { return { objectURL: attachmentForMedia.url, contentType: attachmentForMedia.contentType, attachment: attachmentForMedia, }; }); const lightBoxOptions = { media, attachment, }; this.setState({ lightBoxOptions }); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ private onKeyDown(event: any) { const messageContainer = this.messageContainerRef.current; if (!messageContainer) { return; } const selectionMode = !!this.state.selectedMessages.length; const recordingMode = this.state.showRecordingView; const pageHeight = messageContainer.clientHeight; const arrowScrollPx = 50; const pageScrollPx = pageHeight * 0.8; if (event.key === 'Escape') { // EXIT MEDIA VIEW if (recordingMode) { // EXIT RECORDING VIEW } // EXIT WHAT ELSE? } if (event.target.classList.contains('conversation-content')) { switch (event.key) { case 'Escape': if (selectionMode) { this.resetSelection(); } break; // Scrolling case 'ArrowUp': messageContainer.scrollBy(0, -arrowScrollPx); break; case 'ArrowDown': messageContainer.scrollBy(0, arrowScrollPx); break; case 'PageUp': messageContainer.scrollBy(0, -pageScrollPx); break; case 'PageDown': messageContainer.scrollBy(0, pageScrollPx); break; default: } } } private clearAttachments() { this.state.stagedAttachments.forEach(attachment => { if (attachment.url) { URL.revokeObjectURL(attachment.url); } if (attachment.videoUrl) { URL.revokeObjectURL(attachment.videoUrl); } }); this.setState({ stagedAttachments: [] }); } private removeAttachment(attachment: AttachmentType) { const { stagedAttachments } = this.state; const updatedStagedAttachments = (stagedAttachments || []).filter( m => m.fileName !== attachment.fileName ); this.setState({ stagedAttachments: updatedStagedAttachments }); } private addAttachments(newAttachments: Array) { const { stagedAttachments } = this.state; let newAttachmentsFiltered: Array = []; if (newAttachments?.length > 0) { if (newAttachments.some(a => a.isVoiceMessage) && stagedAttachments.length > 0) { throw new Error('A voice note cannot be sent with other attachments'); } // do not add already added attachments newAttachmentsFiltered = newAttachments.filter( a => !stagedAttachments.some(b => b.file.path === a.file.path) ); } this.setState({ stagedAttachments: [...stagedAttachments, ...newAttachmentsFiltered], }); } private renderLightBox({ media, attachment }: { media: Array; attachment: any }) { const selectedIndex = media.length > 1 ? media.findIndex((mediaMessage: any) => mediaMessage.attachment.path === attachment.path) : 0; return ( { this.setState({ lightBoxOptions: undefined }); }} selectedIndex={selectedIndex} onSave={this.saveAttachment} /> ); } // THIS DOES NOT DOWNLOAD ANYTHING! it just saves it where the user wants private async saveAttachment({ attachment, message, index, }: { attachment: AttachmentType; message?: Message; index?: number; }) { const { getAbsoluteAttachmentPath } = window.Signal.Migrations; attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType); save({ attachment, document, getAbsolutePath: getAbsoluteAttachmentPath, timestamp: message?.received_at || Date.now(), }); } private async onChoseAttachments(attachmentsFileList: Array) { if (!attachmentsFileList || attachmentsFileList.length === 0) { return; } // tslint:disable-next-line: prefer-for-of for (let i = 0; i < attachmentsFileList.length; i++) { await this.maybeAddAttachment(attachmentsFileList[i]); } } // tslint:disable: max-func-body-length cyclomatic-complexity private async maybeAddAttachment(file: any) { if (!file) { return; } const fileName = file.name; const contentType = file.type; const { stagedAttachments } = this.state; if (window.Signal.Util.isFileDangerous(fileName)) { ToastUtils.pushDangerousFileError(); return; } if (stagedAttachments.length >= 32) { ToastUtils.pushMaximumAttachmentsError(); return; } const haveNonImage = _.some( stagedAttachments, attachment => !MIME.isImage(attachment.contentType) ); // You can't add another attachment if you already have a non-image staged if (haveNonImage) { ToastUtils.pushMultipleNonImageError(); return; } // You can't add a non-image attachment if you already have attachments staged if (!MIME.isImage(contentType) && stagedAttachments.length > 0) { ToastUtils.pushCannotMixError(); return; } const { VisualAttachment } = window.Signal.Types; const renderVideoPreview = async () => { const objectUrl = URL.createObjectURL(file); try { const type = 'image/png'; const thumbnail = await VisualAttachment.makeVideoScreenshot({ objectUrl, contentType: type, logger: window.log, }); const data = await VisualAttachment.blobToArrayBuffer(thumbnail); const url = window.Signal.Util.arrayBufferToObjectURL({ data, type, }); this.addAttachments([ { file, size: file.size, fileName, contentType, videoUrl: objectUrl, url, isVoiceMessage: false, }, ]); } catch (error) { URL.revokeObjectURL(objectUrl); } }; const renderImagePreview = async () => { if (!MIME.isJPEG(contentType)) { const urlImage = URL.createObjectURL(file); if (!urlImage) { throw new Error('Failed to create object url for image!'); } this.addAttachments([ { file, size: file.size, fileName, contentType, url: urlImage, isVoiceMessage: false, }, ]); return; } const url = await window.autoOrientImage(file); this.addAttachments([ { file, size: file.size, fileName, contentType, url, isVoiceMessage: false, }, ]); }; let blob = null; try { blob = await AttachmentUtil.autoScale({ contentType, file, }); if (blob.file.size >= Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES) { ToastUtils.pushFileSizeErrorAsByte(Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES); return; } } catch (error) { window.log.error( 'Error ensuring that image is properly sized:', error && error.stack ? error.stack : error ); ToastUtils.pushLoadAttachmentFailure(error?.message); return; } try { if (GoogleChrome.isImageTypeSupported(contentType)) { // this does not add the preview to the message outgoing // this is just for us, for the list of attachments we are sending // the files are scaled down under getFiles() await renderImagePreview(); } else if (GoogleChrome.isVideoTypeSupported(contentType)) { await renderVideoPreview(); } else { this.addAttachments([ { file, size: file.size, contentType, fileName, url: '', isVoiceMessage: false, }, ]); } } catch (e) { window.log.error( `Was unable to generate thumbnail for file type ${contentType}`, e && e.stack ? e.stack : e ); this.addAttachments([ { file, size: file.size, contentType, fileName, isVoiceMessage: false, url: '', }, ]); } } private handleDrag(e: any) { e.preventDefault(); e.stopPropagation(); } private handleDragIn(e: any) { e.preventDefault(); e.stopPropagation(); this.dragCounter++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { this.setState({ isDraggingFile: true }); } } private handleDragOut(e: any) { e.preventDefault(); e.stopPropagation(); this.dragCounter--; if (this.dragCounter === 0) { this.setState({ isDraggingFile: false }); } } private handleDrop(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (e?.dataTransfer?.files && e.dataTransfer.files.length > 0) { void this.onChoseAttachments(Array.from(e.dataTransfer.files)); e.dataTransfer.clearData(); this.dragCounter = 0; this.setState({ isDraggingFile: false }); } } private async updateMemberList() { const allPubKeys = await getPubkeysInPublicConversation(this.props.selectedConversationKey); const allMembers = allPubKeys.map((pubKey: string) => { const conv = ConversationController.getInstance().get(pubKey); const profileName = conv?.getProfileName() || 'Anonymous'; return { id: pubKey, authorPhoneNumber: pubKey, authorProfileName: profileName, }; }); window.lokiPublicChatAPI.setListOfMembers(allMembers); } }