/* global $, _, ConversationController, MessageController, extension, i18n, Signal, storage, textsecure, Whisper, */ // eslint-disable-next-line func-names (function() { 'use strict'; window.Whisper = window.Whisper || {}; const { Message } = window.Signal.Types; const { upgradeMessageSchema, getAbsoluteAttachmentPath, copyIntoTempDirectory, getAbsoluteTempPath, deleteTempFile, } = window.Signal.Migrations; const { getOlderMessagesByConversation, getMessageMetricsForConversation, getMessageById, getMessagesBySentAt, getNewerMessagesByConversation, } = window.Signal.Data; Whisper.ExpiredToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('expiredWarning') }; }, }); Whisper.BlockedToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('unblockToSend') }; }, }); Whisper.BlockedGroupToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('unblockGroupToSend') }; }, }); Whisper.LeftGroupToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('youLeftTheGroup') }; }, }); Whisper.OriginalNotFoundToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('originalMessageNotFound') }; }, }); Whisper.OriginalNoLongerAvailableToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('originalMessageNotAvailable') }; }, }); Whisper.FoundButNotLoadedToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('messageFoundButNotLoaded') }; }, }); Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') }; }, }); const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ render_attributes() { return { toastMessage: i18n('messageBodyTooLong') }; }, }); Whisper.ConversationLoadingScreen = Whisper.View.extend({ templateName: 'conversation-loading-screen', className: 'conversation-loading-screen', }); Whisper.ConversationView = Whisper.View.extend({ className() { return ['conversation', this.model.get('type')].join(' '); }, id() { return `conversation-${this.model.cid}`; }, template: $('#conversation').html(), render_attributes() { return { 'send-message': i18n('sendMessage'), }; }, initialize(options) { // Events on Conversation model this.listenTo(this.model, 'destroy', this.stopListening); this.listenTo(this.model, 'change:verified', this.onVerifiedChange); this.listenTo(this.model, 'newmessage', this.addMessage); this.listenTo(this.model, 'opened', this.onOpened); this.listenTo(this.model, 'backgrounded', this.resetEmojiResults); this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage); this.listenTo(this.model, 'unload', reason => this.unload(`model trigger - ${reason}`) ); // Events on Message models - we still listen to these here because they // can be emitted by the non-reduxified MessageDetail pane this.listenTo( this.model.messageCollection, 'show-identity', this.showSafetyNumber ); this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); this.listenTo( this.model.messageCollection, 'show-visual-attachment', this.showLightbox ); this.listenTo( this.model.messageCollection, 'display-tap-to-view-message', this.displayTapToViewMessage ); this.listenTo( this.model.messageCollection, 'navigate-to', this.navigateTo ); this.listenTo( this.model.messageCollection, 'download-new-version', this.downloadNewVersion ); this.lazyUpdateVerified = _.debounce( this.model.updateVerified.bind(this.model), 1000 // one second ); this.throttledGetProfiles = _.throttle( this.model.getProfiles.bind(this.model), 1000 * 60 * 5 // five minutes ); this.debouncedMaybeGrabLinkPreview = _.debounce( this.maybeGrabLinkPreview.bind(this), 200 ); this.render(); this.loadingScreen = new Whisper.ConversationLoadingScreen(); this.loadingScreen.render(); this.loadingScreen.$el.prependTo(this.$('.discussion-container')); this.window = options.window; const attachmentListEl = $( '
' ); this.fileInput = new Whisper.FileInputView({ el: attachmentListEl, }); this.listenTo( this.fileInput, 'choose-attachment', this.onChooseAttachment ); this.listenTo(this.fileInput, 'staged-attachments-changed', () => { this.toggleMicrophone(); if (this.fileInput.hasFiles()) { this.removeLinkPreview(); } }); extension.windows.onClosed(() => { this.unload('windows closed'); }); this.$('.send-message').focus(this.focusBottomBar.bind(this)); this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); this.setupHeader(); this.setupTimeline(); this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); }, events: { click: 'onClick', 'click .composition-area-placeholder': 'onClickPlaceholder', 'click .bottom-bar': 'focusMessageField', 'click .capture-audio .microphone': 'captureAudio', 'focus .send-message': 'focusBottomBar', 'blur .send-message': 'unfocusBottomBar', 'click button.paperclip': 'onChooseAttachment', 'change input.file-input': 'onChoseAttachment', dragover: 'onDragOver', dragleave: 'onDragLeave', drop: 'onDrop', paste: 'onPaste', }, setupHeader() { const getHeaderProps = () => { const expireTimer = this.model.get('expireTimer'); const expirationSettingName = expireTimer ? Whisper.ExpirationTimerOptions.getName(expireTimer || 0) : null; return { id: this.model.id, name: this.model.getName(), phoneNumber: this.model.getNumber(), profileName: this.model.getProfileName(), color: this.model.getColor(), avatarPath: this.model.getAvatarPath(), isVerified: this.model.isVerified(), isMe: this.model.isMe(), isGroup: !this.model.isPrivate(), isArchived: this.model.get('isArchived'), expirationSettingName, showBackButton: Boolean(this.panels && this.panels.length), timerOptions: Whisper.ExpirationTimerOptions.map(item => ({ name: item.getName(), value: item.get('seconds'), })), onSetDisappearingMessages: seconds => this.setDisappearingMessages(seconds), onDeleteMessages: () => this.destroyMessages(), onResetSession: () => this.endSession(), // These are view only and don't update the Conversation model, so they // need a manual update call. onShowSafetyNumber: () => { this.showSafetyNumber(); }, onShowAllMedia: async () => { await this.showAllMedia(); this.updateHeader(); }, onShowGroupMembers: async () => { await this.showMembers(); this.updateHeader(); }, onGoBack: () => { this.resetPanel(); this.updateHeader(); }, onArchive: () => { this.unload('archive'); this.model.setArchived(true); }, onMoveToInbox: () => { this.model.setArchived(false); }, }; }; this.titleView = new Whisper.ReactWrapperView({ className: 'title-wrapper', Component: window.Signal.Components.ConversationHeader, props: getHeaderProps(this.model), }); this.updateHeader = () => this.titleView.update(getHeaderProps()); this.listenTo(this.model, 'change', this.updateHeader); this.$('.conversation-header').append(this.titleView.el); }, setupCompositionArea({ attachmentListEl }) { const compositionApi = { current: null }; this.compositionApi = compositionApi; const micCellEl = $(`
`)[0]; const attCellEl = $(`
`)[0]; const props = { compositionApi, onClickAddPack: () => this.showStickerManager(), onPickSticker: (packId, stickerId) => this.sendStickerMessage({ packId, stickerId }), onSubmit: message => this.sendMessage(message), onEditorStateChange: (msg, caretLocation) => this.onEditorStateChange(msg, caretLocation), micCellEl, attCellEl, attachmentListEl, }; this.compositionAreaView = new Whisper.ReactWrapperView({ className: 'composition-area-wrapper', JSX: Signal.State.Roots.createCompositionArea(window.reduxStore, props), }); // Finally, add it to the DOM this.$('.composition-area-placeholder').append( this.compositionAreaView.el ); }, setupTimeline() { const { id } = this.model; const replyToMessage = messageId => { this.setQuoteMessage(messageId); }; const retrySend = messageId => { this.retrySend(messageId); }; const deleteMessage = messageId => { this.deleteMessage(messageId); }; const showMessageDetail = messageId => { this.showMessageDetail(messageId); }; const openConversation = (conversationId, messageId) => { this.openConversation(conversationId, messageId); }; const showContactDetail = options => { this.showContactDetail(options); }; const showVisualAttachment = options => { this.showLightbox(options); }; const downloadAttachment = options => { this.downloadAttachment(options); }; const displayTapToViewMessage = messageId => this.displayTapToViewMessage(messageId); const showIdentity = conversationId => { this.showSafetyNumber(conversationId); }; const openLink = url => { this.navigateTo(url); }; const downloadNewVersion = () => { this.downloadNewVersion(); }; const scrollToQuotedMessage = async options => { const { author, sentAt } = options; const conversationId = this.model.id; const messages = await getMessagesBySentAt(sentAt, { MessageCollection: Whisper.MessageCollection, }); const message = messages.find( item => item.get('conversationId') === conversationId && item.getSource() === author ); if (!message) { const toast = new Whisper.OriginalNotFoundToast(); toast.$el.appendTo(this.$el); toast.render(); return; } this.scrollToMessage(message.id); }; const loadOlderMessages = async oldestMessageId => { const { messagesAdded, setMessagesLoading, } = window.reduxActions.conversations; const conversationId = this.model.id; setMessagesLoading(conversationId, true); const finish = this.setInProgressFetch(); try { const message = await getMessageById(oldestMessageId, { Message: Whisper.Message, }); if (!message) { throw new Error( `loadOlderMessages: failed to load message ${oldestMessageId}` ); } const receivedAt = message.get('received_at'); const models = await getOlderMessagesByConversation(conversationId, { receivedAt, limit: 500, MessageCollection: Whisper.MessageCollection, }); if (models.length < 1) { window.log.warn( 'loadOlderMessages: requested, but loaded no messages' ); return; } const cleaned = await this.cleanModels(models); this.model.messageCollection.add(cleaned); const isNewMessage = false; messagesAdded( id, models.map(model => model.getReduxData()), isNewMessage, document.hasFocus() ); } catch (error) { setMessagesLoading(conversationId, true); throw error; } finally { finish(); } }; const loadNewerMessages = async newestMessageId => { const { messagesAdded, setMessagesLoading, } = window.reduxActions.conversations; const conversationId = this.model.id; setMessagesLoading(conversationId, true); const finish = this.setInProgressFetch(); try { const message = await getMessageById(newestMessageId, { Message: Whisper.Message, }); if (!message) { throw new Error( `loadNewerMessages: failed to load message ${newestMessageId}` ); } const receivedAt = message.get('received_at'); const models = await getNewerMessagesByConversation(this.model.id, { receivedAt, limit: 500, MessageCollection: Whisper.MessageCollection, }); if (models.length < 1) { window.log.warn( 'loadNewerMessages: requested, but loaded no messages' ); return; } const cleaned = await this.cleanModels(models); this.model.messageCollection.add(cleaned); const isNewMessage = false; messagesAdded( id, models.map(model => model.getReduxData()), isNewMessage, document.hasFocus() ); } catch (error) { setMessagesLoading(conversationId, false); throw error; } finally { finish(); } }; const markMessageRead = async messageId => { if (!document.hasFocus()) { return; } const message = await getMessageById(messageId, { Message: Whisper.Message, }); if (!message) { throw new Error( `markMessageRead: failed to load message ${messageId}` ); } await this.model.markRead(message.get('received_at')); }; this.timelineView = new Whisper.ReactWrapperView({ className: 'timeline-wrapper', JSX: Signal.State.Roots.createTimeline(window.reduxStore, { id, deleteMessage, displayTapToViewMessage, downloadAttachment, downloadNewVersion, loadNewerMessages, loadNewestMessages: this.loadNewestMessages.bind(this), loadAndScroll: this.loadAndScroll.bind(this), loadOlderMessages, markMessageRead, openConversation, openLink, replyToMessage, retrySend, scrollToQuotedMessage, showContactDetail, showIdentity, showMessageDetail, showVisualAttachment, }), }); this.$('.timeline-placeholder').append(this.timelineView.el); }, async cleanModels(collection) { const result = collection .filter(message => Boolean(message.id)) .map(message => MessageController.register(message.id, message)); const eliminated = collection.length - result.length; if (eliminated > 0) { window.log.warn( `cleanModels: Eliminated ${eliminated} messages without an id` ); } for (let max = result.length, i = 0; i < max; i += 1) { const message = result[i]; const { attributes } = message; const { schemaVersion } = attributes; if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { // Yep, we really do want to wait for each of these // eslint-disable-next-line no-await-in-loop const upgradedMessage = await upgradeMessageSchema(attributes); message.set(upgradedMessage); // eslint-disable-next-line no-await-in-loop await window.Signal.Data.saveMessage(upgradedMessage, { Message: Whisper.Message, }); } } return result; }, async scrollToMessage(messageId) { const message = await getMessageById(messageId, { Message: Whisper.Message, }); if (!message) { throw new Error(`scrollToMessage: failed to load message ${messageId}`); } if (this.model.messageCollection.get(messageId)) { const { scrollToMessage } = window.reduxActions.conversations; scrollToMessage(this.model.id, messageId); return; } this.loadAndScroll(messageId); }, setInProgressFetch() { let resolvePromise; this.model.inProgressFetch = new Promise(resolve => { resolvePromise = resolve; }); const finish = () => { resolvePromise(); this.model.inProgressFinish = null; }; return finish; }, async loadAndScroll(messageId, options) { const { disableScroll } = options || {}; const { messagesReset, setMessagesLoading, } = window.reduxActions.conversations; const conversationId = this.model.id; setMessagesLoading(conversationId, true); const finish = this.setInProgressFetch(); try { const message = await getMessageById(messageId, { Message: Whisper.Message, }); if (!message) { throw new Error( `loadMoreAndScroll: failed to load message ${messageId}` ); } const receivedAt = message.get('received_at'); const older = await getOlderMessagesByConversation(conversationId, { limit: 250, receivedAt, MessageCollection: Whisper.MessageCollection, }); const newer = await getNewerMessagesByConversation(conversationId, { limit: 250, receivedAt, MessageCollection: Whisper.MessageCollection, }); const metrics = await getMessageMetricsForConversation(conversationId); const all = [...older.models, message, ...newer.models]; const cleaned = await this.cleanModels(all); this.model.messageCollection.reset(cleaned); messagesReset( conversationId, cleaned.map(model => model.getReduxData()), metrics, disableScroll ? undefined : messageId ); } catch (error) { setMessagesLoading(conversationId, false); throw error; } finally { finish(); } }, async loadNewestMessages(newestMessageId) { const { messagesReset, setMessagesLoading, } = window.reduxActions.conversations; const conversationId = this.model.id; setMessagesLoading(conversationId, true); const finish = this.setInProgressFetch(); try { let scrollToLatestUnread = true; if (newestMessageId) { const message = await getMessageById(newestMessageId, { Message: Whisper.Message, }); if (!message) { window.log.warn( `loadNewestMessages: did not find message ${newestMessageId}` ); } // If newest in-memory message is unread, scrolling down would mean going to // the very bottom, not the oldest unread. scrollToLatestUnread = !message.isUnread(); } const metrics = await getMessageMetricsForConversation(conversationId); if (scrollToLatestUnread && metrics.oldestUnread) { this.loadAndScroll(metrics.oldestUnread.id, { disableScroll: true }); return; } const messages = await getOlderMessagesByConversation(conversationId, { limit: 500, MessageCollection: Whisper.MessageCollection, }); const cleaned = await this.cleanModels(messages); this.model.messageCollection.reset(cleaned); messagesReset( conversationId, cleaned.map(model => model.getReduxData()), metrics ); } catch (error) { setMessagesLoading(conversationId, false); throw error; } finally { finish(); } }, // We need this, or clicking the reactified buttons will submit the form and send any // mid-composition message content. onClickPlaceholder(e) { e.preventDefault(); }, onChooseAttachment(e) { if (e) { e.stopPropagation(); e.preventDefault(); } this.$('input.file-input').click(); }, async onChoseAttachment() { const fileField = this.$('input.file-input'); const files = fileField.prop('files'); for (let i = 0, max = files.length; i < max; i += 1) { const file = files[i]; // eslint-disable-next-line no-await-in-loop await this.fileInput.maybeAddAttachment(file); this.toggleMicrophone(); } fileField.val(null); }, onDragOver(e) { this.fileInput.onDragOver(e); }, onDragLeave(e) { this.fileInput.onDragLeave(e); }, onDrop(e) { this.fileInput.onDrop(e); }, onPaste(e) { this.fileInput.onPaste(e); }, unload(reason) { window.log.info( 'unloading conversation', this.model.idForLogging(), 'due to:', reason ); const { conversationUnloaded } = window.reduxActions.conversations; if (conversationUnloaded) { conversationUnloaded(this.model.id); } this.fileInput.remove(); this.titleView.remove(); this.timelineView.remove(); if (this.stickerButtonView) { this.stickerButtonView.remove(); } if (this.stickerPreviewModalView) { this.stickerPreviewModalView.remove(); } if (this.captureAudioView) { this.captureAudioView.remove(); } if (this.banner) { this.banner.remove(); } if (this.lastSeenIndicator) { this.lastSeenIndicator.remove(); } if (this.scrollDownButton) { this.scrollDownButton.remove(); } if (this.quoteView) { this.quoteView.remove(); } if (this.lightboxView) { this.lightboxView.remove(); } if (this.lightboxGalleryView) { this.lightboxGalleryView.remove(); } if (this.panels && this.panels.length) { for (let i = 0, max = this.panels.length; i < max; i += 1) { const panel = this.panels[i]; panel.remove(); } } this.window.removeEventListener('focus', this.onFocus); this.remove(); this.model.messageCollection.reset([]); }, navigateTo(url) { window.location = url; }, downloadNewVersion() { window.location = 'https://signal.org/download'; }, markAllAsVerifiedDefault(unverified) { return Promise.all( unverified.map(contact => { if (contact.isUnverified()) { return contact.setVerifiedDefault(); } return null; }) ); }, markAllAsApproved(untrusted) { return Promise.all(untrusted.map(contact => contact.setApproved())); }, openSafetyNumberScreens(unverified) { if (unverified.length === 1) { this.showSafetyNumber(unverified.at(0).id); return; } this.showMembers(null, unverified, { needVerify: true }); }, onVerifiedChange() { if (this.model.isUnverified()) { const unverified = this.model.getUnverified(); let message; if (!unverified.length) { return; } if (unverified.length > 1) { message = i18n('multipleNoLongerVerified'); } else { message = i18n('noLongerVerified', unverified.at(0).getTitle()); } // Need to re-add, since unverified set may have changed if (this.banner) { this.banner.remove(); this.banner = null; } this.banner = new Whisper.BannerView({ message, onDismiss: () => { this.markAllAsVerifiedDefault(unverified); }, onClick: () => { this.openSafetyNumberScreens(unverified); }, }); const container = this.$('.discussion-container'); container.append(this.banner.el); } else if (this.banner) { this.banner.remove(); this.banner = null; } }, toggleMicrophone() { this.compositionApi.current.setShowMic(!this.fileInput.hasFiles()); }, captureAudio(e) { e.preventDefault(); if (this.fileInput.hasFiles()) { const toast = new Whisper.VoiceNoteMustBeOnlyAttachmentToast(); toast.$el.appendTo(this.$el); toast.render(); return; } // Note - clicking anywhere will close the audio capture panel, due to // the onClick handler in InboxView, which calls its closeRecording method. if (this.captureAudioView) { this.captureAudioView.remove(); this.captureAudioView = null; } this.captureAudioView = new Whisper.RecorderView(); const view = this.captureAudioView; view.render(); view.on('send', this.handleAudioCapture.bind(this)); view.on('closed', this.endCaptureAudio.bind(this)); view.$el.appendTo(this.$('.capture-audio')); this.compositionApi.current.setMicActive(true); this.disableMessageField(); this.$('.microphone').hide(); }, handleAudioCapture(blob) { this.fileInput.addAttachment({ contentType: blob.type, file: blob, isVoiceNote: true, }); this.sendMessage(); }, endCaptureAudio() { this.enableMessageField(); this.$('.microphone').show(); this.captureAudioView = null; this.compositionApi.current.setMicActive(false); }, unfocusBottomBar() { this.$('.bottom-bar form').removeClass('active'); }, focusBottomBar() { this.$('.bottom-bar form').addClass('active'); }, async onOpened(messageId) { this.openStart = Date.now(); this.lastActivity = Date.now(); this.focusMessageField(); this.model.updateLastMessage(); const statusPromise = this.throttledGetProfiles(); // eslint-disable-next-line more/no-then this.statusFetch = statusPromise.then(() => // eslint-disable-next-line more/no-then this.model.updateVerified().then(() => { this.onVerifiedChange(); this.statusFetch = null; window.log.info('done with status fetch'); }) ); if (messageId) { const message = await getMessageById(messageId, { Message: Whisper.Message, }); if (message) { this.loadAndScroll(messageId); return; } window.log.warn(`onOpened: Did not find message ${messageId}`); } this.loadNewestMessages(); }, async retrySend(messageId) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error(`retrySend: Did not find message for id ${messageId}`); } await message.retrySend(); }, async showAllMedia() { // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: const DEFAULT_MEDIA_FETCH_COUNT = 50; const DEFAULT_DOCUMENTS_FETCH_COUNT = 150; const conversationId = this.model.get('id'); const getProps = async () => { const rawMedia = await Signal.Data.getMessagesWithVisualMediaAttachments( conversationId, { limit: DEFAULT_MEDIA_FETCH_COUNT, MessageCollection: Whisper.MessageCollection, } ); const rawDocuments = await Signal.Data.getMessagesWithFileAttachments( conversationId, { limit: DEFAULT_DOCUMENTS_FETCH_COUNT, MessageCollection: Whisper.MessageCollection, } ); // First we upgrade these messages to ensure that they have thumbnails for (let max = rawMedia.length, i = 0; i < max; i += 1) { const message = rawMedia[i]; const { schemaVersion } = message; if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { // Yep, we really do want to wait for each of these // eslint-disable-next-line no-await-in-loop rawMedia[i] = await upgradeMessageSchema(message); // eslint-disable-next-line no-await-in-loop await window.Signal.Data.saveMessage(rawMedia[i], { Message: Whisper.Message, }); } } const media = _.flatten( rawMedia.map(message => { const { attachments } = message; return (attachments || []) .filter( attachment => attachment.thumbnail && !attachment.pending && !attachment.error ) .map((attachment, index) => { const { thumbnail } = attachment; return { objectURL: getAbsoluteAttachmentPath(attachment.path), thumbnailObjectUrl: thumbnail ? getAbsoluteAttachmentPath(thumbnail.path) : null, contentType: attachment.contentType, index, attachment, message, }; }); }) ); // Unlike visual media, only one non-image attachment is supported const documents = rawDocuments .filter(message => Boolean(message.attachments && message.attachments.length) ) .map(message => { const attachments = message.attachments || []; const attachment = attachments[0]; return { contentType: attachment.contentType, index: 0, attachment, message, }; }); const saveAttachment = async ({ attachment, message } = {}) => { const timestamp = message.sent_at; Signal.Types.Attachment.save({ attachment, document, getAbsolutePath: getAbsoluteAttachmentPath, timestamp, }); }; const onItemClick = async ({ message, attachment, type }) => { switch (type) { case 'documents': { saveAttachment({ message, attachment }); break; } case 'media': { const selectedIndex = media.findIndex( mediaMessage => mediaMessage.attachment.path === attachment.path ); this.lightboxGalleryView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', Component: Signal.Components.LightboxGallery, props: { media, onSave: saveAttachment, selectedIndex, }, onClose: () => Signal.Backbone.Views.Lightbox.hide(), }); Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); break; } default: throw new TypeError(`Unknown attachment type: '${type}'`); } }; return { documents, media, onItemClick, }; }; const view = new Whisper.ReactWrapperView({ className: 'panel', Component: Signal.Components.MediaGallery, props: await getProps(), onClose: () => { this.stopListening(this.model.messageCollection, 'remove', update); this.resetPanel(); }, }); const update = async () => { view.update(await getProps()); }; this.listenTo(this.model.messageCollection, 'remove', update); this.listenBack(view); }, focusMessageField() { if (this.panels && this.panels.length) { return; } const { compositionApi } = this; if (compositionApi && compositionApi.current) { compositionApi.current.focusInput(); } }, focusMessageFieldAndClearDisabled() { this.compositionApi.current.setDisabled(false); this.focusMessageField(); }, disableMessageField() { this.compositionApi.current.setDisabled(true); }, enableMessageField() { this.compositionApi.current.setDisabled(false); }, resetEmojiResults() { this.compositionApi.current.resetEmojiResults(false); }, async addMessage(message) { // This is debounced, so it won't hit the database too often. this.lazyUpdateVerified(); // We do this here because we don't want convo.messageCollection to have // anything in it unless it has an associated view. This is so, when we // fetch on open, it's clean. this.model.addSingleMessage(message); }, async showMembers(e, providedMembers, options = {}) { _.defaults(options, { needVerify: false }); const model = providedMembers || this.model.contactCollection; const view = new Whisper.GroupMemberList({ model, // we pass this in to allow nested panels listenBack: this.listenBack.bind(this), needVerify: options.needVerify, }); this.listenBack(view); }, forceSend({ contactId, messageId }) { const contact = ConversationController.get(contactId); const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error( `deleteMessage: Did not find message for id ${messageId}` ); } const dialog = new Whisper.ConfirmationDialogView({ message: i18n('identityKeyErrorOnSend', [ contact.getTitle(), contact.getTitle(), ]), okText: i18n('sendAnyway'), resolve: async () => { await contact.updateVerified(); if (contact.isUnverified()) { await contact.setVerifiedDefault(); } const untrusted = await contact.isUntrusted(); if (untrusted) { await contact.setApproved(); } message.resend(contact.id); }, }); this.$el.prepend(dialog.el); dialog.focusCancel(); }, showSafetyNumber(id) { let conversation; if (!id && this.model.isPrivate()) { // eslint-disable-next-line prefer-destructuring conversation = this.model; } else { conversation = ConversationController.get(id); } if (conversation) { const view = new Whisper.KeyVerificationPanelView({ model: conversation, }); this.listenBack(view); this.updateHeader(); } }, downloadAttachment({ attachment, timestamp, isDangerous }) { if (isDangerous) { const toast = new Whisper.DangerousFileTypeToast(); toast.$el.appendTo(this.$el); toast.render(); return; } Signal.Types.Attachment.save({ attachment, document, getAbsolutePath: getAbsoluteAttachmentPath, timestamp, }); }, async displayTapToViewMessage(messageId) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error( `displayTapToViewMessage: Did not find message for id ${messageId}` ); } if (!message.isTapToView()) { throw new Error( `displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` ); } if (message.isErased()) { throw new Error( `displayTapToViewMessage: Message ${message.idForLogging()} is already erased` ); } const firstAttachment = message.get('attachments')[0]; if (!firstAttachment || !firstAttachment.path) { throw new Error( `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` ); } const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); const tempPath = await copyIntoTempDirectory(absolutePath); const tempAttachment = { ...firstAttachment, path: tempPath, }; await message.markViewed(); const closeLightbox = async () => { if (!this.lightboxView) { return; } const { lightboxView } = this; this.lightboxView = null; this.stopListening(message); Signal.Backbone.Views.Lightbox.hide(); lightboxView.remove(); await deleteTempFile(tempPath); }; this.listenTo(message, 'expired', closeLightbox); this.listenTo(message, 'change', () => { if (this.lightBoxView) { this.lightBoxView.update(getProps()); } }); const getProps = () => { const { path, contentType } = tempAttachment; return { objectURL: getAbsoluteTempPath(path), contentType, }; }; this.lightboxView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', Component: Signal.Components.Lightbox, props: getProps(), onClose: closeLightbox, }); Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); }, deleteMessage(messageId) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error( `deleteMessage: Did not find message for id ${messageId}` ); } const dialog = new Whisper.ConfirmationDialogView({ message: i18n('deleteWarning'), okText: i18n('delete'), resolve: () => { window.Signal.Data.removeMessage(message.id, { Message: Whisper.Message, }); message.trigger('unload'); this.model.messageCollection.remove(message.id); this.resetPanel(); this.updateHeader(); }, }); this.$el.prepend(dialog.el); dialog.focusCancel(); }, showStickerPackPreview(packId, packKey) { if (!window.ENABLE_STICKER_SEND) { return; } window.Signal.Stickers.downloadEphemeralPack(packId, packKey); const props = { packId, onClose: async () => { this.stickerPreviewModalView.remove(); this.stickerPreviewModalView = null; await window.Signal.Stickers.removeEphemeralPack(packId); }, }; this.stickerPreviewModalView = new Whisper.ReactWrapperView({ className: 'sticker-preview-modal-wrapper', JSX: Signal.State.Roots.createStickerPreviewModal( window.reduxStore, props ), }); }, showLightbox({ attachment, messageId }) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error( `showLightbox: did not find message for id ${messageId}` ); } const sticker = message.get('sticker'); if (sticker) { const { packId, packKey } = sticker; this.showStickerPackPreview(packId, packKey); return; } const { contentType, path } = attachment; if ( !Signal.Util.GoogleChrome.isImageTypeSupported(contentType) && !Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) ) { this.downloadAttachment({ attachment, message }); return; } const attachments = message.get('attachments') || []; const media = attachments .filter(item => item.thumbnail && !item.pending && !item.error) .map((item, index) => ({ objectURL: getAbsoluteAttachmentPath(item.path), path: item.path, contentType: item.contentType, index, message, attachment: item, })); if (media.length === 1) { const props = { objectURL: getAbsoluteAttachmentPath(path), contentType, caption: attachment.caption, onSave: () => { const timestamp = message.get('sent_at'); this.downloadAttachment({ attachment, timestamp, message }); }, }; this.lightboxView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', Component: Signal.Components.Lightbox, props, onClose: () => { Signal.Backbone.Views.Lightbox.hide(); this.stopListening(message); }, }); this.listenTo(message, 'expired', () => this.lightboxView.remove()); Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); return; } const selectedIndex = _.findIndex( media, item => attachment.path === item.path ); const onSave = async (options = {}) => { Signal.Types.Attachment.save({ attachment: options.attachment, document, index: options.index + 1, getAbsolutePath: getAbsoluteAttachmentPath, timestamp: options.message.get('sent_at'), }); }; const props = { media, selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, onSave, }; this.lightboxGalleryView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', Component: Signal.Components.LightboxGallery, props, onClose: () => { Signal.Backbone.Views.Lightbox.hide(); this.stopListening(message); }, }); this.listenTo(message, 'expired', () => this.lightboxGalleryView.remove() ); Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); }, showMessageDetail(messageId) { const message = this.model.messageCollection.get(messageId); if (!message) { throw new Error( `showMessageDetail: Did not find message for id ${messageId}` ); } const onClose = () => { this.stopListening(message, 'change', update); this.resetPanel(); this.updateHeader(); }; const props = message.getPropsForMessageDetail(); const view = new Whisper.ReactWrapperView({ className: 'panel message-detail-wrapper', Component: Signal.Components.MessageDetail, props, onClose, }); const update = () => view.update(message.getPropsForMessageDetail()); this.listenTo(message, 'change', update); this.listenTo(message, 'expired', onClose); // We could listen to all involved contacts, but we'll call that overkill this.listenBack(view); this.updateHeader(); view.render(); }, showStickerManager() { const view = new Whisper.ReactWrapperView({ className: ['sticker-manager-wrapper', 'panel'].join(' '), JSX: Signal.State.Roots.createStickerManager(window.reduxStore), onClose: () => { this.resetPanel(); this.updateHeader(); }, }); this.listenBack(view); this.updateHeader(); view.render(); }, showContactDetail({ contact, signalAccount }) { const view = new Whisper.ReactWrapperView({ Component: Signal.Components.ContactDetail, className: 'contact-detail-pane panel', props: { contact, signalAccount, onSendMessage: () => { if (signalAccount) { this.openConversation(signalAccount); } }, }, onClose: () => { this.resetPanel(); this.updateHeader(); }, }); this.listenBack(view); this.updateHeader(); }, async openConversation(number) { window.Whisper.events.trigger('showConversation', number); }, listenBack(view) { this.panels = this.panels || []; this.panels.unshift(view); view.$el.insertAfter(this.$('.panel').last()); view.$el.one('animationend', () => { view.$el.addClass('panel--static'); }); }, resetPanel() { if (!this.panels || !this.panels.length) { return; } const view = this.panels.shift(); if (this.panels.length > 0) { this.panels[0].$el.fadeIn(250); } view.$el.addClass('panel--remove').one('transitionend', () => { view.remove(); if (this.panels.length === 0) { // Make sure poppers are positioned properly window.dispatchEvent(new Event('resize')); } }); }, endSession() { this.model.endSession(); }, setDisappearingMessages(seconds) { if (seconds > 0) { this.model.updateExpirationTimer(seconds); } else { this.model.updateExpirationTimer(null); } }, async destroyMessages() { try { await this.confirm(i18n('deleteConversationConfirmation')); try { this.unload('delete messages'); await this.model.destroyMessages(); this.model.updateLastMessage(); } catch (error) { window.log.error( 'destroyMessages: Failed to successfully delete conversation', error && error.stack ? error.stack : error ); } } catch (error) { // nothing to see here, user canceled out of dialog } }, showSendAnywayDialog(contacts) { return new Promise(resolve => { let message; const isUnverified = this.model.isUnverified(); if (contacts.length > 1) { if (isUnverified) { message = i18n('changedSinceVerifiedMultiple'); } else { message = i18n('changedRecentlyMultiple'); } } else { const contactName = contacts.at(0).getTitle(); if (isUnverified) { message = i18n('changedSinceVerified', [contactName, contactName]); } else { message = i18n('changedRecently', [contactName, contactName]); } } const dialog = new Whisper.ConfirmationDialogView({ message, okText: i18n('sendAnyway'), resolve: () => resolve(true), reject: () => resolve(false), }); this.$el.prepend(dialog.el); dialog.focusCancel(); }); }, async sendStickerMessage(options = {}) { try { const contacts = await this.getUntrustedContacts(options); if (contacts && contacts.length) { const sendAnyway = await this.showSendAnywayDialog(contacts); if (sendAnyway) { this.sendStickerMessage({ ...options, force: true }); } return; } const { packId, stickerId } = options; this.model.sendStickerMessage(packId, stickerId); } catch (error) { window.log.error( 'clickSend error:', error && error.stack ? error.stack : error ); } }, async getUntrustedContacts(options = {}) { // This will go to the trust store for the latest identity key information, // and may result in the display of a new banner for this conversation. await this.model.updateVerified(); const unverifiedContacts = this.model.getUnverified(); if (options.force) { if (unverifiedContacts.length) { await this.markAllAsVerifiedDefault(unverifiedContacts); // We only want force to break us through one layer of checks // eslint-disable-next-line no-param-reassign options.force = false; } } else if (unverifiedContacts.length) { return unverifiedContacts; } const untrustedContacts = await this.model.getUntrusted(); if (options.force) { if (untrustedContacts.length) { await this.markAllAsApproved(untrustedContacts); } } else if (untrustedContacts.length) { return untrustedContacts; } return null; }, async setQuoteMessage(messageId) { this.quote = null; this.quotedMessage = null; if (this.quoteHolder) { this.quoteHolder.unload(); this.quoteHolder = null; } const message = this.model.messageCollection.get(messageId); if (message) { this.quotedMessage = message; if (message) { const quote = await this.model.makeQuote(this.quotedMessage); this.quote = quote; this.focusMessageFieldAndClearDisabled(); } } this.renderQuotedMessage(); }, renderQuotedMessage() { if (this.quoteView) { this.quoteView.remove(); this.quoteView = null; } if (!this.quotedMessage) { return; } const message = new Whisper.Message({ conversationId: this.model.id, quote: this.quote, }); message.quotedMessage = this.quotedMessage; this.quoteHolder = message; const props = message.getPropsForQuote(); this.listenTo(message, 'scroll-to-message', () => { this.scrollToMessage(message.quotedMessage.id); }); const contact = this.quotedMessage.getContact(); if (contact) { this.listenTo(contact, 'change', this.renderQuotedMesage); } this.quoteView = new Whisper.ReactWrapperView({ className: 'quote-wrapper', Component: window.Signal.Components.Quote, elCallback: el => this.$(this.compositionApi.current.attSlotRef.current).prepend(el), props: Object.assign({}, props, { withContentAbove: true, onClose: () => { this.setQuoteMessage(null); }, }), }); }, async sendMessage(message = '', options = {}) { this.sendStart = Date.now(); try { const contacts = await this.getUntrustedContacts(options); this.disableMessageField(); if (contacts && contacts.length) { const sendAnyway = await this.showSendAnywayDialog(contacts); if (sendAnyway) { this.sendMessage(message, { force: true }); return; } this.focusMessageFieldAndClearDisabled(); return; } } catch (error) { this.focusMessageFieldAndClearDisabled(); window.log.error( 'sendMessage error:', error && error.stack ? error.stack : error ); return; } this.model.clearTypingTimers(); let toast; if (extension.expired()) { toast = new Whisper.ExpiredToast(); } if (this.model.isPrivate() && storage.isBlocked(this.model.id)) { toast = new Whisper.BlockedToast(); } if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) { toast = new Whisper.BlockedGroupToast(); } if (!this.model.isPrivate() && this.model.get('left')) { toast = new Whisper.LeftGroupToast(); } if (message.length > MAX_MESSAGE_BODY_LENGTH) { toast = new Whisper.MessageBodyTooLongToast(); } if (toast) { toast.$el.appendTo(this.$el); toast.render(); this.focusMessageFieldAndClearDisabled(); return; } try { if (!message.length && !this.fileInput.hasFiles()) { return; } const attachments = await this.fileInput.getFiles(); const sendDelta = Date.now() - this.sendStart; window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); this.model.sendMessage( message, attachments, this.quote, this.getLinkPreview() ); this.compositionApi.current.reset(); this.setQuoteMessage(null); this.resetLinkPreview(); this.fileInput.clearAttachments(); } catch (error) { window.log.error( 'Error pulling attached files before send', error && error.stack ? error.stack : error ); } finally { this.focusMessageFieldAndClearDisabled(); } }, onEditorStateChange(messageText, caretLocation) { this.maybeBumpTyping(messageText); this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); }, maybeGrabLinkPreview(message, caretLocation) { // Don't generate link previews if user has turned them off if (!storage.get('linkPreviews', false)) { return; } // Do nothing if we're offline if (!textsecure.messaging) { return; } // If we have attachments, don't add link preview if (this.fileInput.hasFiles()) { return; } // If we're behind a user-configured proxy, we don't support link previews if (window.isBehindProxy()) { return; } if (!message) { this.resetLinkPreview(); return; } if (this.disableLinkPreviews) { return; } const links = window.Signal.LinkPreviews.findLinks( message, caretLocation ); const { currentlyMatchedLink } = this; if (links.includes(currentlyMatchedLink)) { return; } this.currentlyMatchedLink = null; this.excludedPreviewUrls = this.excludedPreviewUrls || []; const link = links.find( item => window.Signal.LinkPreviews.isLinkInWhitelist(item) && !this.excludedPreviewUrls.includes(item) ); if (!link) { this.removeLinkPreview(); return; } this.currentlyMatchedLink = link; this.addLinkPreview(link); }, resetLinkPreview() { this.disableLinkPreviews = false; this.excludedPreviewUrls = []; this.removeLinkPreview(); }, removeLinkPreview() { (this.preview || []).forEach(item => { if (item.url) { URL.revokeObjectURL(item.url); } }); this.preview = null; this.previewLoading = null; this.currentlyMatchedLink = false; this.renderLinkPreview(); }, async makeChunkedRequest(url) { const PARALLELISM = 3; const first = await textsecure.messaging.makeProxiedRequest(url, { start: 0, end: Signal.Crypto.getRandomValue(1023, 2047), returnArrayBuffer: true, }); const { totalSize, result } = first; const initialOffset = result.data.byteLength; const firstChunk = { start: 0, end: initialOffset, ...result, }; const chunks = await Signal.LinkPreviews.getChunkPattern( totalSize, initialOffset ); let results = []; const jobs = chunks.map(chunk => async () => { const { start, end } = chunk; const jobResult = await textsecure.messaging.makeProxiedRequest(url, { start, end, returnArrayBuffer: true, }); return { ...chunk, ...jobResult.result, }; }); while (jobs.length > 0) { const activeJobs = []; for (let i = 0, max = PARALLELISM; i < max; i += 1) { if (!jobs.length) { break; } const job = jobs.shift(); activeJobs.push(job()); } // eslint-disable-next-line no-await-in-loop results = results.concat(await Promise.all(activeJobs)); } if (!results.length) { throw new Error('No responses received'); } const { contentType } = results[0]; const data = Signal.LinkPreviews.assembleChunks( [firstChunk].concat(results) ); return { contentType, data, }; }, async getStickerPackPreview(url) { const isPackDownloaded = pack => pack && (pack.status === 'downloaded' || pack.status === 'installed'); const isPackValid = pack => pack && (pack.status === 'ephemeral' || pack.status === 'downloaded' || pack.status === 'installed'); let id; let key; try { ({ id, key } = window.Signal.Stickers.getDataFromLink(url)); const keyBytes = window.Signal.Crypto.bytesFromHexString(key); const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes); const existing = window.Signal.Stickers.getStickerPack(id); if (!isPackDownloaded(existing)) { await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64); } const pack = window.Signal.Stickers.getStickerPack(id); if (!isPackValid(pack)) { return null; } if (pack.key !== keyBase64) { return null; } const { title, coverStickerId } = pack; const sticker = pack.stickers[coverStickerId]; const data = pack.status === 'ephemeral' ? await window.Signal.Migrations.readTempData(sticker.path) : await window.Signal.Migrations.readStickerData(sticker.path); return { title, url, image: { ...sticker, data, size: data.byteLength, contentType: 'image/webp', }, }; } catch (error) { window.log.error( 'getStickerPackPreview error:', error && error.stack ? error.stack : error ); return null; } finally { if (id) { await window.Signal.Stickers.removeEphemeralPack(id); } } }, async getPreview(url) { if (window.Signal.LinkPreviews.isStickerPack(url)) { return this.getStickerPackPreview(url); } let html; try { html = await textsecure.messaging.makeProxiedRequest(url); } catch (error) { if (error.code >= 300) { return null; } } const title = window.Signal.LinkPreviews.getTitleMetaTag(html); const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html); let image; let objectUrl; try { if (imageUrl) { if (!Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) { const primaryDomain = Signal.LinkPreviews.getDomain(url); const imageDomain = Signal.LinkPreviews.getDomain(imageUrl); throw new Error( `imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}` ); } const data = await this.makeChunkedRequest(imageUrl); // Ensure that this file is either small enough or is resized to meet our // requirements for attachments const withBlob = await this.fileInput.autoScale({ contentType: data.contentType, file: new Blob([data.data], { type: data.contentType, }), }); const attachment = await this.fileInput.readFile(withBlob); objectUrl = URL.createObjectURL(withBlob.file); const dimensions = await Signal.Types.VisualAttachment.getImageDimensions( { objectUrl, logger: window.log, } ); image = { ...attachment, ...dimensions, contentType: withBlob.file.type, }; } } catch (error) { // We still want to show the preview if we failed to get an image window.log.error( 'getPreview failed to get image for link preview:', error.message ); } finally { if (objectUrl) { URL.revokeObjectURL(objectUrl); } } return { title, url, image, }; }, async addLinkPreview(url) { (this.preview || []).forEach(item => { if (item.url) { URL.revokeObjectURL(item.url); } }); this.preview = null; this.currentlyMatchedLink = url; this.previewLoading = this.getPreview(url); const promise = this.previewLoading; this.renderLinkPreview(); try { const result = await promise; if ( url !== this.currentlyMatchedLink || promise !== this.previewLoading ) { // another request was started, or this was canceled return; } // If we couldn't pull down the initial URL if (!result) { this.excludedPreviewUrls.push(url); this.removeLinkPreview(); return; } if (result.image) { const blob = new Blob([result.image.data], { type: result.image.contentType, }); result.image.url = URL.createObjectURL(blob); } else if (!result.title) { // A link preview isn't worth showing unless we have either a title or an image this.removeLinkPreview(); return; } this.preview = [result]; this.renderLinkPreview(); } catch (error) { window.log.error( 'Problem loading link preview, disabling.', error && error.stack ? error.stack : error ); this.disableLinkPreviews = true; this.removeLinkPreview(); } }, renderLinkPreview() { if (this.previewView) { this.previewView.remove(); this.previewView = null; } if (!this.currentlyMatchedLink) { return; } const first = (this.preview && this.preview[0]) || null; const props = { ...first, domain: first && window.Signal.LinkPreviews.getDomain(first.url), isLoaded: Boolean(first), onClose: () => { this.disableLinkPreviews = true; this.removeLinkPreview(); }, }; this.previewView = new Whisper.ReactWrapperView({ className: 'preview-wrapper', Component: window.Signal.Components.StagedLinkPreview, elCallback: el => this.$(this.compositionApi.current.attSlotRef.current).prepend(el), props, }); }, getLinkPreview() { // Don't generate link previews if user has turned them off if (!storage.get('linkPreviews', false)) { return []; } if (!this.preview) { return []; } return this.preview.map(item => { if (item.image) { // We eliminate the ObjectURL here, unneeded for send or save return { ...item, image: _.omit(item.image, 'url'), }; } return item; }); }, // Called whenever the user changes the message composition field. But only // fires if there's content in the message field after the change. maybeBumpTyping(messageText) { if (messageText.length) { this.model.throttledBumpTyping(); } }, }); })();