2018-11-14 19:10:32 +00:00
|
|
|
|
/* global
|
|
|
|
|
$,
|
|
|
|
|
_,
|
2019-01-16 03:03:56 +00:00
|
|
|
|
ConversationController
|
2018-11-14 19:10:32 +00:00
|
|
|
|
emojiData,
|
|
|
|
|
EmojiPanel,
|
|
|
|
|
extension,
|
|
|
|
|
i18n,
|
|
|
|
|
Signal,
|
|
|
|
|
storage,
|
2019-01-16 03:03:56 +00:00
|
|
|
|
textsecure,
|
2018-11-14 19:10:32 +00:00
|
|
|
|
Whisper,
|
|
|
|
|
*/
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line func-names
|
2018-04-27 21:25:04 +00:00
|
|
|
|
(function() {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
window.Whisper = window.Whisper || {};
|
2018-07-18 16:38:42 +00:00
|
|
|
|
const { Message } = window.Signal.Types;
|
|
|
|
|
const {
|
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
getAbsoluteAttachmentPath,
|
|
|
|
|
} = window.Signal.Migrations;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
Whisper.ExpiredToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('expiredWarning') };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
Whisper.BlockedToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('unblockToSend') };
|
|
|
|
|
},
|
|
|
|
|
});
|
2018-09-13 19:57:07 +00:00
|
|
|
|
Whisper.BlockedGroupToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('unblockGroupToSend') };
|
|
|
|
|
},
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
Whisper.LeftGroupToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('youLeftTheGroup') };
|
|
|
|
|
},
|
|
|
|
|
});
|
2018-08-15 19:31:29 +00:00
|
|
|
|
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') };
|
|
|
|
|
},
|
|
|
|
|
});
|
2018-12-02 01:48:53 +00:00
|
|
|
|
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
|
|
|
|
|
},
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-04-12 21:54:45 +00:00
|
|
|
|
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
|
|
|
|
Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('messageBodyTooLong') };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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) {
|
|
|
|
|
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, 'prune', this.onPrune);
|
2019-03-29 18:30:43 +00:00
|
|
|
|
this.listenTo(this.model, 'unload', () => this.unload('model trigger'));
|
2018-11-14 19:10:32 +00:00
|
|
|
|
this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
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);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'scroll-to-message',
|
|
|
|
|
this.scrollToMessage
|
|
|
|
|
);
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'reply',
|
|
|
|
|
this.setQuoteMessage
|
|
|
|
|
);
|
2019-03-15 22:18:00 +00:00
|
|
|
|
this.listenTo(this.model.messageCollection, 'retry', this.retrySend);
|
2018-05-03 02:43:23 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'show-contact-detail',
|
|
|
|
|
this.showContactDetail
|
|
|
|
|
);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'show-lightbox',
|
|
|
|
|
this.showLightbox
|
|
|
|
|
);
|
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'download',
|
|
|
|
|
this.downloadAttachment
|
|
|
|
|
);
|
2018-05-03 02:43:23 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'open-conversation',
|
|
|
|
|
this.openConversation
|
|
|
|
|
);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'show-message-detail',
|
|
|
|
|
this.showMessageDetail
|
|
|
|
|
);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.listenTo(this.model.messageCollection, 'navigate-to', url => {
|
|
|
|
|
window.location = url;
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.debouncedMaybeGrabLinkPreview = _.debounce(
|
|
|
|
|
this.maybeGrabLinkPreview.bind(this),
|
|
|
|
|
200
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
this.render();
|
|
|
|
|
|
|
|
|
|
this.loadingScreen = new Whisper.ConversationLoadingScreen();
|
|
|
|
|
this.loadingScreen.render();
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
this.window = options.window;
|
|
|
|
|
this.fileInput = new Whisper.FileInputView({
|
2018-12-02 01:48:53 +00:00
|
|
|
|
el: this.$('.attachment-list'),
|
2018-04-17 21:03:31 +00:00
|
|
|
|
});
|
2019-01-15 17:33:23 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.fileInput,
|
|
|
|
|
'choose-attachment',
|
|
|
|
|
this.onChooseAttachment
|
|
|
|
|
);
|
2019-01-15 17:42:55 +00:00
|
|
|
|
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.view.restoreBottomOffset();
|
2019-01-15 17:42:55 +00:00
|
|
|
|
this.toggleMicrophone();
|
2019-01-16 03:03:56 +00:00
|
|
|
|
if (this.fileInput.hasFiles()) {
|
|
|
|
|
this.removeLinkPreview();
|
|
|
|
|
}
|
2019-01-15 17:42:55 +00:00
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
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(),
|
2018-09-27 00:23:17 +00:00
|
|
|
|
avatarPath: this.model.getAvatarPath(),
|
2019-03-12 00:20:16 +00:00
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
isVerified: this.model.isVerified(),
|
|
|
|
|
isMe: this.model.isMe(),
|
|
|
|
|
isGroup: !this.model.isPrivate(),
|
2019-03-12 00:20:16 +00:00
|
|
|
|
isArchived: this.model.get('isArchived'),
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
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(),
|
|
|
|
|
|
2019-01-15 18:03:16 +00:00
|
|
|
|
// These are view only and don't update the Conversation model, so they
|
2018-07-09 21:29:13 +00:00
|
|
|
|
// need a manual update call.
|
|
|
|
|
onShowSafetyNumber: () => {
|
|
|
|
|
this.showSafetyNumber();
|
|
|
|
|
},
|
|
|
|
|
onShowAllMedia: async () => {
|
|
|
|
|
await this.showAllMedia();
|
|
|
|
|
this.updateHeader();
|
|
|
|
|
},
|
2019-02-11 22:52:58 +00:00
|
|
|
|
onShowGroupMembers: async () => {
|
|
|
|
|
await this.showMembers();
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.updateHeader();
|
|
|
|
|
},
|
|
|
|
|
onGoBack: () => {
|
|
|
|
|
this.resetPanel();
|
|
|
|
|
this.updateHeader();
|
|
|
|
|
},
|
2019-03-12 00:20:16 +00:00
|
|
|
|
|
|
|
|
|
onArchive: () => {
|
2019-03-20 20:43:24 +00:00
|
|
|
|
this.unload('archive');
|
2019-03-12 00:20:16 +00:00
|
|
|
|
this.model.setArchived(true);
|
|
|
|
|
},
|
|
|
|
|
onMoveToInbox: () => {
|
|
|
|
|
this.model.setArchived(false);
|
|
|
|
|
},
|
2018-07-09 21:29:13 +00:00
|
|
|
|
};
|
|
|
|
|
};
|
2018-05-18 23:57:26 +00:00
|
|
|
|
this.titleView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'title-wrapper',
|
2018-07-09 21:29:13 +00:00
|
|
|
|
Component: window.Signal.Components.ConversationHeader,
|
|
|
|
|
props: getHeaderProps(this.model),
|
2018-05-18 23:57:26 +00:00
|
|
|
|
});
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.updateHeader = () => this.titleView.update(getHeaderProps());
|
|
|
|
|
this.listenTo(this.model, 'change', this.updateHeader);
|
|
|
|
|
this.$('.conversation-header').append(this.titleView.el);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
this.view = new Whisper.MessageListView({
|
|
|
|
|
collection: this.model.messageCollection,
|
|
|
|
|
window: this.window,
|
|
|
|
|
});
|
|
|
|
|
this.$('.discussion-container').append(this.view.el);
|
|
|
|
|
this.view.render();
|
|
|
|
|
|
|
|
|
|
this.$messageField = this.$('.send-message');
|
|
|
|
|
|
|
|
|
|
this.onResize = this.forceUpdateMessageFieldSize.bind(this);
|
|
|
|
|
this.window.addEventListener('resize', this.onResize);
|
|
|
|
|
|
|
|
|
|
this.onFocus = () => {
|
|
|
|
|
if (this.$el.css('display') !== 'none') {
|
|
|
|
|
this.markRead();
|
2016-09-07 00:12:45 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
};
|
|
|
|
|
this.window.addEventListener('focus', this.onFocus);
|
|
|
|
|
|
|
|
|
|
extension.windows.onClosed(() => {
|
|
|
|
|
this.unload('windows closed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.fetchMessages();
|
|
|
|
|
|
|
|
|
|
this.$('.send-message').focus(this.focusBottomBar.bind(this));
|
|
|
|
|
this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
|
|
|
|
|
|
|
|
|
|
this.$emojiPanelContainer = this.$('.emoji-panel-container');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
events: {
|
2018-05-08 21:30:11 +00:00
|
|
|
|
keydown: 'onKeyDown',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'submit .send': 'checkUnverifiedSendMessage',
|
|
|
|
|
'input .send-message': 'updateMessageFieldSize',
|
|
|
|
|
'keydown .send-message': 'updateMessageFieldSize',
|
2019-01-16 03:03:56 +00:00
|
|
|
|
'keyup .send-message': 'onKeyUp',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
click: 'onClick',
|
|
|
|
|
'click .bottom-bar': 'focusMessageField',
|
|
|
|
|
'click .capture-audio .microphone': 'captureAudio',
|
2018-07-09 21:29:13 +00:00
|
|
|
|
'click .module-scroll-down': 'scrollToBottom',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'click button.emoji': 'toggleEmojiPanel',
|
|
|
|
|
'focus .send-message': 'focusBottomBar',
|
|
|
|
|
'change .file-input': 'toggleMicrophone',
|
|
|
|
|
'blur .send-message': 'unfocusBottomBar',
|
|
|
|
|
'loadMore .message-list': 'loadMoreMessages',
|
|
|
|
|
'newOffscreenMessage .message-list': 'addScrollDownButtonWithCount',
|
|
|
|
|
'atBottom .message-list': 'removeScrollDownButton',
|
|
|
|
|
'farFromBottom .message-list': 'addScrollDownButton',
|
|
|
|
|
'lazyScroll .message-list': 'onLazyScroll',
|
|
|
|
|
'force-resize': 'forceUpdateMessageFieldSize',
|
2018-12-02 01:48:53 +00:00
|
|
|
|
|
|
|
|
|
'click button.paperclip': 'onChooseAttachment',
|
|
|
|
|
'change input.file-input': 'onChoseAttachment',
|
|
|
|
|
|
|
|
|
|
dragover: 'onDragOver',
|
|
|
|
|
dragleave: 'onDragLeave',
|
|
|
|
|
drop: 'onDrop',
|
|
|
|
|
paste: 'onPaste',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
2018-12-02 01:48:53 +00:00
|
|
|
|
|
|
|
|
|
onChooseAttachment(e) {
|
2019-01-15 17:33:23 +00:00
|
|
|
|
if (e) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
2018-12-02 01:48:53 +00:00
|
|
|
|
|
|
|
|
|
this.$('input.file-input').click();
|
|
|
|
|
},
|
|
|
|
|
async onChoseAttachment() {
|
|
|
|
|
const fileField = this.$('input.file-input');
|
2019-01-16 18:32:57 +00:00
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2018-12-02 01:48:53 +00:00
|
|
|
|
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);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onPrune() {
|
|
|
|
|
if (!this.model.messageCollection.length || !this.lastActivity) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (this.isHidden() && this.lastActivity < oneHourAgo) {
|
|
|
|
|
this.unload('inactivity');
|
|
|
|
|
} else if (this.view.atBottom()) {
|
|
|
|
|
this.trim();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
unload(reason) {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'unloading conversation',
|
|
|
|
|
this.model.idForLogging(),
|
|
|
|
|
'due to:',
|
|
|
|
|
reason
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.fileInput.remove();
|
|
|
|
|
this.titleView.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();
|
|
|
|
|
}
|
2018-04-18 20:06:33 +00:00
|
|
|
|
if (this.quoteView) {
|
|
|
|
|
this.quoteView.remove();
|
|
|
|
|
}
|
2018-07-09 21:29:13 +00:00
|
|
|
|
if (this.lightBoxView) {
|
|
|
|
|
this.lightBoxView.remove();
|
|
|
|
|
}
|
2018-04-26 20:48:08 +00:00
|
|
|
|
if (this.lightboxGalleryView) {
|
|
|
|
|
this.lightboxGalleryView.remove();
|
2018-04-26 20:49:10 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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();
|
2017-05-12 22:16:02 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.window.removeEventListener('resize', this.onResize);
|
|
|
|
|
this.window.removeEventListener('focus', this.onFocus);
|
|
|
|
|
|
|
|
|
|
window.autosize.destroy(this.$messageField);
|
|
|
|
|
|
|
|
|
|
this.view.remove();
|
|
|
|
|
|
|
|
|
|
this.remove();
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.model.messageCollection.forEach(model => {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
model.trigger('unload');
|
|
|
|
|
});
|
|
|
|
|
this.model.messageCollection.reset([]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
trim() {
|
|
|
|
|
const MAX = 100;
|
|
|
|
|
const toRemove = this.model.messageCollection.length - MAX;
|
|
|
|
|
if (toRemove <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const models = [];
|
|
|
|
|
for (let i = 0; i < toRemove; i += 1) {
|
|
|
|
|
const model = this.model.messageCollection.at(i);
|
|
|
|
|
models.push(model);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!models.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'trimming conversation',
|
|
|
|
|
this.model.idForLogging(),
|
|
|
|
|
'of',
|
|
|
|
|
models.length,
|
|
|
|
|
'old messages'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.model.messageCollection.remove(models);
|
2018-04-27 21:25:04 +00:00
|
|
|
|
_.forEach(models, model => {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
model.trigger('unload');
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markAllAsVerifiedDefault(unverified) {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
return Promise.all(
|
|
|
|
|
unverified.map(contact => {
|
|
|
|
|
if (contact.isUnverified()) {
|
|
|
|
|
return contact.setVerifiedDefault();
|
|
|
|
|
}
|
2017-07-26 21:55:59 +00:00
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
return null;
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
2018-01-05 00:51:00 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
markAllAsApproved(untrusted) {
|
|
|
|
|
return Promise.all(untrusted.map(contact => contact.setApproved()));
|
|
|
|
|
},
|
2016-03-21 06:15:21 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
openSafetyNumberScreens(unverified) {
|
|
|
|
|
if (unverified.length === 1) {
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.showSafetyNumber(unverified.at(0));
|
2018-04-17 21:03:31 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
2015-11-11 04:39:36 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.showMembers(null, unverified, { needVerify: true });
|
|
|
|
|
},
|
2017-05-19 01:33:35 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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());
|
|
|
|
|
}
|
2015-01-26 20:37:13 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// Need to re-add, since unverified set may have changed
|
|
|
|
|
if (this.banner) {
|
|
|
|
|
this.banner.remove();
|
|
|
|
|
this.banner = null;
|
|
|
|
|
}
|
2015-02-13 04:36:44 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-11-14 19:10:32 +00:00
|
|
|
|
renderTypingBubble() {
|
|
|
|
|
const timers = this.model.contactTypingTimers || {};
|
|
|
|
|
const records = _.values(timers);
|
|
|
|
|
const mostRecent = _.first(_.sortBy(records, 'timestamp'));
|
|
|
|
|
|
|
|
|
|
if (!mostRecent && this.typingBubbleView) {
|
|
|
|
|
this.typingBubbleView.remove();
|
|
|
|
|
this.typingBubbleView = null;
|
|
|
|
|
}
|
|
|
|
|
if (!mostRecent) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { sender } = mostRecent;
|
|
|
|
|
const contact = ConversationController.getOrCreate(sender, 'private');
|
|
|
|
|
const props = {
|
|
|
|
|
...contact.format(),
|
|
|
|
|
conversationType: this.model.isPrivate() ? 'direct' : 'group',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (this.typingBubbleView) {
|
|
|
|
|
this.typingBubbleView.update(props);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.typingBubbleView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'message-wrapper typing-bubble-wrapper',
|
|
|
|
|
Component: Signal.Components.TypingBubble,
|
|
|
|
|
props,
|
|
|
|
|
});
|
|
|
|
|
this.typingBubbleView.$el.appendTo(this.$('.typing-container'));
|
|
|
|
|
|
|
|
|
|
if (this.view.atBottom()) {
|
|
|
|
|
this.typingBubbleView.el.scrollIntoView();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
toggleMicrophone() {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
if (
|
|
|
|
|
this.$('.send-message').val().length > 0 ||
|
|
|
|
|
this.fileInput.hasFiles()
|
|
|
|
|
) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.capture-audio').hide();
|
|
|
|
|
} else {
|
|
|
|
|
this.$('.capture-audio').show();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
captureAudio(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
2018-12-02 01:48:53 +00:00
|
|
|
|
if (this.fileInput.hasFiles()) {
|
|
|
|
|
const toast = new Whisper.VoiceNoteMustBeOnlyAttachmentToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// 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.$('.send-message').attr('disabled', true);
|
|
|
|
|
this.$('.microphone').hide();
|
|
|
|
|
},
|
|
|
|
|
handleAudioCapture(blob) {
|
2018-12-02 01:48:53 +00:00
|
|
|
|
this.fileInput.addAttachment({
|
|
|
|
|
contentType: blob.type,
|
|
|
|
|
file: blob,
|
|
|
|
|
isVoiceNote: true,
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.bottom-bar form').submit();
|
|
|
|
|
},
|
|
|
|
|
endCaptureAudio() {
|
|
|
|
|
this.$('.send-message').removeAttr('disabled');
|
|
|
|
|
this.$('.microphone').show();
|
|
|
|
|
this.captureAudioView = null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
unfocusBottomBar() {
|
|
|
|
|
this.$('.bottom-bar form').removeClass('active');
|
|
|
|
|
},
|
|
|
|
|
focusBottomBar() {
|
|
|
|
|
this.$('.bottom-bar form').addClass('active');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onLazyScroll() {
|
|
|
|
|
// The in-progress fetch check is important, because while that happens, lots
|
|
|
|
|
// of messages are added to the DOM, one by one, changing window size and
|
|
|
|
|
// generating scroll events.
|
|
|
|
|
if (!this.isHidden() && window.isFocused() && !this.inProgressFetch) {
|
|
|
|
|
this.lastActivity = Date.now();
|
|
|
|
|
this.markRead();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
updateUnread() {
|
|
|
|
|
this.resetLastSeenIndicator();
|
|
|
|
|
// Waiting for scrolling caused by resetLastSeenIndicator to settle down
|
|
|
|
|
setTimeout(this.markRead.bind(this), 1);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onLoaded() {
|
|
|
|
|
const view = this.loadingScreen;
|
|
|
|
|
if (view) {
|
|
|
|
|
const openDelta = Date.now() - this.openStart;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'Conversation',
|
|
|
|
|
this.model.idForLogging(),
|
|
|
|
|
'took',
|
|
|
|
|
openDelta,
|
|
|
|
|
'milliseconds to load'
|
|
|
|
|
);
|
|
|
|
|
this.loadingScreen = null;
|
|
|
|
|
view.remove();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onOpened() {
|
|
|
|
|
this.openStart = Date.now();
|
|
|
|
|
this.lastActivity = Date.now();
|
|
|
|
|
|
2018-07-12 18:33:59 +00:00
|
|
|
|
this.model.updateLastMessage();
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const statusPromise = this.throttledGetProfiles();
|
|
|
|
|
// eslint-disable-next-line more/no-then
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.statusFetch = statusPromise.then(() =>
|
2018-04-27 21:27:32 +00:00
|
|
|
|
// eslint-disable-next-line more/no-then
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.model.updateVerified().then(() => {
|
|
|
|
|
this.onVerifiedChange();
|
|
|
|
|
this.statusFetch = null;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info('done with status fetch');
|
2018-04-27 21:25:04 +00:00
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
// We schedule our catch-up decrypt right after any in-progress fetch of
|
|
|
|
|
// messages from the database, then ensure that the loading screen is only
|
|
|
|
|
// dismissed when that is complete.
|
|
|
|
|
const messagesLoaded = this.inProgressFetch || Promise.resolve();
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line more/no-then
|
2018-07-27 01:13:56 +00:00
|
|
|
|
messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
this.view.resetScrollPosition();
|
|
|
|
|
this.$el.trigger('force-resize');
|
|
|
|
|
this.focusMessageField();
|
2018-11-14 19:10:32 +00:00
|
|
|
|
this.renderTypingBubble();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
if (this.inProgressFetch) {
|
|
|
|
|
// eslint-disable-next-line more/no-then
|
|
|
|
|
this.inProgressFetch.then(this.updateUnread.bind(this));
|
|
|
|
|
} else {
|
|
|
|
|
this.updateUnread();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addScrollDownButtonWithCount() {
|
|
|
|
|
this.updateScrollDownButton(1);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addScrollDownButton() {
|
|
|
|
|
if (!this.scrollDownButton) {
|
|
|
|
|
this.updateScrollDownButton();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateScrollDownButton(count) {
|
|
|
|
|
if (this.scrollDownButton) {
|
|
|
|
|
this.scrollDownButton.increment(count);
|
|
|
|
|
} else {
|
|
|
|
|
this.scrollDownButton = new Whisper.ScrollDownButtonView({ count });
|
|
|
|
|
this.scrollDownButton.render();
|
|
|
|
|
const container = this.$('.discussion-container');
|
|
|
|
|
container.append(this.scrollDownButton.el);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeScrollDownButton() {
|
|
|
|
|
if (this.scrollDownButton) {
|
|
|
|
|
const button = this.scrollDownButton;
|
|
|
|
|
this.scrollDownButton = null;
|
|
|
|
|
button.remove();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeLastSeenIndicator() {
|
|
|
|
|
if (this.lastSeenIndicator) {
|
|
|
|
|
const indicator = this.lastSeenIndicator;
|
|
|
|
|
this.lastSeenIndicator = null;
|
|
|
|
|
indicator.remove();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
|
2018-08-15 19:31:29 +00:00
|
|
|
|
async scrollToMessage(options = {}) {
|
2019-03-15 22:18:00 +00:00
|
|
|
|
const { author, sentAt, referencedMessageNotFound } = options;
|
2018-08-15 19:31:29 +00:00
|
|
|
|
|
|
|
|
|
// For simplicity's sake, we show the 'not found' toast no matter what if we were
|
|
|
|
|
// not able to find the referenced message when the quote was received.
|
|
|
|
|
if (referencedMessageNotFound) {
|
|
|
|
|
const toast = new Whisper.OriginalNotFoundToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Look for message in memory first, which would tell us if we could scroll to it
|
|
|
|
|
const targetMessage = this.model.messageCollection.find(item => {
|
2019-02-08 01:25:48 +00:00
|
|
|
|
const messageAuthor = item.getContact();
|
2018-08-15 19:31:29 +00:00
|
|
|
|
|
2019-02-08 01:25:48 +00:00
|
|
|
|
if (!messageAuthor || author !== messageAuthor.id) {
|
2018-08-15 19:31:29 +00:00
|
|
|
|
return false;
|
|
|
|
|
}
|
2019-03-15 22:18:00 +00:00
|
|
|
|
if (sentAt !== item.get('sent_at')) {
|
2018-08-15 19:31:29 +00:00
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2018-08-15 19:31:29 +00:00
|
|
|
|
// If there's no message already in memory, we won't be scrolling. So we'll gather
|
|
|
|
|
// some more information then show an informative toast to the user.
|
|
|
|
|
if (!targetMessage) {
|
2019-03-15 22:18:00 +00:00
|
|
|
|
const collection = await window.Signal.Data.getMessagesBySentAt(
|
|
|
|
|
sentAt,
|
|
|
|
|
{
|
|
|
|
|
MessageCollection: Whisper.MessageCollection,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2019-03-26 01:10:30 +00:00
|
|
|
|
const found = Boolean(
|
|
|
|
|
collection.find(item => {
|
|
|
|
|
const messageAuthor = item.getContact();
|
|
|
|
|
return messageAuthor && author === messageAuthor.id;
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-08-15 19:31:29 +00:00
|
|
|
|
|
2019-03-26 01:10:30 +00:00
|
|
|
|
if (found) {
|
2018-08-15 19:31:29 +00:00
|
|
|
|
const toast = new Whisper.FoundButNotLoadedToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
} else {
|
|
|
|
|
const toast = new Whisper.OriginalNoLongerAvailableToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-08-15 19:31:29 +00:00
|
|
|
|
const databaseId = targetMessage.id;
|
|
|
|
|
const el = this.$(`#${databaseId}`);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (!el || el.length === 0) {
|
2018-08-15 19:31:29 +00:00
|
|
|
|
const toast = new Whisper.OriginalNoLongerAvailableToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
|
|
|
|
|
window.log.info(
|
2019-03-15 22:18:00 +00:00
|
|
|
|
`Error: had target message ${targetMessage.idForLogging()} in messageCollection, but it was not in DOM`
|
2018-08-15 19:31:29 +00:00
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
el[0].scrollIntoView();
|
|
|
|
|
},
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
async showAllMedia() {
|
2018-04-25 22:15:24 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
2018-04-25 15:16:16 +00:00
|
|
|
|
const conversationId = this.model.get('id');
|
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
);
|
2018-07-18 16:38:42 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
// 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,
|
2019-01-30 20:15:07 +00:00
|
|
|
|
});
|
2019-02-19 23:17:52 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2018-07-18 23:00:51 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-14 02:16:57 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
// Unlike visual media, only one non-image attachment is supported
|
|
|
|
|
const documents = rawDocuments.map(message => {
|
|
|
|
|
const attachments = message.attachments || [];
|
|
|
|
|
const attachment = attachments[0];
|
|
|
|
|
return {
|
|
|
|
|
contentType: attachment.contentType,
|
|
|
|
|
index: 0,
|
|
|
|
|
attachment,
|
|
|
|
|
message,
|
|
|
|
|
};
|
2018-04-26 20:48:08 +00:00
|
|
|
|
});
|
2018-04-25 20:45:02 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
const saveAttachment = async ({ attachment, message } = {}) => {
|
|
|
|
|
const timestamp = message.received_at;
|
|
|
|
|
Signal.Types.Attachment.save({
|
|
|
|
|
attachment,
|
|
|
|
|
document,
|
|
|
|
|
getAbsolutePath: getAbsoluteAttachmentPath,
|
|
|
|
|
timestamp,
|
|
|
|
|
});
|
|
|
|
|
};
|
2018-04-25 20:42:08 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
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}'`);
|
2018-04-25 20:42:08 +00:00
|
|
|
|
}
|
2019-02-19 23:17:52 +00:00
|
|
|
|
};
|
2018-04-25 20:42:08 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
return {
|
|
|
|
|
documents,
|
|
|
|
|
media,
|
|
|
|
|
onItemClick,
|
|
|
|
|
};
|
2018-04-14 02:16:57 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const view = new Whisper.ReactWrapperView({
|
2018-07-09 21:29:13 +00:00
|
|
|
|
className: 'panel-wrapper',
|
2018-04-15 06:52:33 +00:00
|
|
|
|
Component: Signal.Components.MediaGallery,
|
2019-02-19 23:17:52 +00:00
|
|
|
|
props: await getProps(),
|
|
|
|
|
onClose: () => {
|
|
|
|
|
this.stopListening(this.model.messageCollection, 'remove', update);
|
|
|
|
|
this.resetPanel();
|
2018-04-25 20:42:08 +00:00
|
|
|
|
},
|
2018-04-14 02:16:57 +00:00
|
|
|
|
});
|
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
const update = async () => {
|
|
|
|
|
view.update(await getProps());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.listenTo(this.model.messageCollection, 'remove', update);
|
|
|
|
|
|
2018-04-14 02:16:57 +00:00
|
|
|
|
this.listenBack(view);
|
2018-04-11 15:41:38 +00:00
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
scrollToBottom() {
|
|
|
|
|
// If we're above the last seen indicator, we should scroll there instead
|
|
|
|
|
// Note: if we don't end up at the bottom of the conversation, button won't go away!
|
|
|
|
|
if (this.lastSeenIndicator) {
|
|
|
|
|
const location = this.lastSeenIndicator.$el.position().top;
|
|
|
|
|
if (location > 0) {
|
|
|
|
|
this.lastSeenIndicator.el.scrollIntoView();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.removeLastSeenIndicator();
|
|
|
|
|
}
|
|
|
|
|
this.view.scrollToBottom();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
resetLastSeenIndicator(options = {}) {
|
|
|
|
|
_.defaults(options, { scroll: true });
|
|
|
|
|
|
|
|
|
|
let unreadCount = 0;
|
|
|
|
|
let oldestUnread = null;
|
|
|
|
|
|
|
|
|
|
// We need to iterate here because unseen non-messages do not contribute to
|
|
|
|
|
// the badge number, but should be reflected in the indicator's count.
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.model.messageCollection.forEach(model => {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (!model.get('unread')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2015-02-13 04:36:44 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
unreadCount += 1;
|
|
|
|
|
if (!oldestUnread) {
|
|
|
|
|
oldestUnread = model;
|
|
|
|
|
}
|
|
|
|
|
});
|
2015-01-13 23:27:18 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.removeLastSeenIndicator();
|
2014-10-25 01:44:30 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (oldestUnread) {
|
|
|
|
|
this.lastSeenIndicator = new Whisper.LastSeenIndicatorView({
|
|
|
|
|
count: unreadCount,
|
|
|
|
|
});
|
|
|
|
|
const lastSeenEl = this.lastSeenIndicator.render().$el;
|
2017-06-14 00:36:32 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));
|
2017-06-14 00:36:32 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (this.view.atBottom() || options.scroll) {
|
|
|
|
|
lastSeenEl[0].scrollIntoView();
|
|
|
|
|
}
|
2017-06-14 00:36:32 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// scrollIntoView is an async operation, but we have no way to listen for
|
|
|
|
|
// completion of the resultant scroll.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!this.view.atBottom()) {
|
|
|
|
|
this.addScrollDownButtonWithCount(unreadCount);
|
|
|
|
|
}
|
|
|
|
|
}, 1);
|
2018-07-27 02:36:21 +00:00
|
|
|
|
} else if (this.view.atBottom()) {
|
|
|
|
|
// If we already thought we were at the bottom, then ensure that's the case.
|
|
|
|
|
// Attempting to account for unpredictable completion of message rendering.
|
|
|
|
|
setTimeout(() => this.view.scrollToBottom(), 1);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
focusMessageField() {
|
2018-07-20 23:37:57 +00:00
|
|
|
|
if (this.panels && this.panels.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$messageField.focus();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
focusMessageFieldAndClearDisabled() {
|
|
|
|
|
this.$messageField.removeAttr('disabled');
|
|
|
|
|
this.$messageField.focus();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async loadMoreMessages() {
|
|
|
|
|
if (this.inProgressFetch) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.view.measureScrollPosition();
|
|
|
|
|
const startingHeight = this.view.scrollHeight;
|
|
|
|
|
|
|
|
|
|
await this.fetchMessages();
|
|
|
|
|
// We delay this work to let scrolling/layout settle down first
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.view.measureScrollPosition();
|
|
|
|
|
const endingHeight = this.view.scrollHeight;
|
|
|
|
|
const delta = endingHeight - startingHeight;
|
|
|
|
|
const height = this.view.outerHeight;
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const newScrollPosition = this.view.scrollPosition + delta - height;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.view.$el.scrollTop(newScrollPosition);
|
|
|
|
|
}, 1);
|
|
|
|
|
},
|
|
|
|
|
fetchMessages() {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info('fetchMessages');
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.bar-container').show();
|
|
|
|
|
if (this.inProgressFetch) {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.warn('Multiple fetchMessage calls!');
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Avoiding await, since we want to capture the promise and make it available via
|
|
|
|
|
// this.inProgressFetch
|
|
|
|
|
// eslint-disable-next-line more/no-then
|
2018-04-27 21:25:04 +00:00
|
|
|
|
this.inProgressFetch = this.model
|
|
|
|
|
.fetchContacts()
|
2018-04-17 21:03:31 +00:00
|
|
|
|
.then(() => this.model.fetchMessages())
|
2018-07-27 01:13:56 +00:00
|
|
|
|
.then(async () => {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.bar-container').hide();
|
2018-07-27 01:13:56 +00:00
|
|
|
|
await Promise.all(
|
|
|
|
|
this.model.messageCollection.where({ unread: 1 }).map(async m => {
|
|
|
|
|
const latest = await window.Signal.Data.getMessageById(m.id, {
|
|
|
|
|
Message: Whisper.Message,
|
|
|
|
|
});
|
|
|
|
|
m.merge(latest);
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.inProgressFetch = null;
|
2018-04-27 21:25:04 +00:00
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.error(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'fetchMessages error:',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
this.inProgressFetch = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.inProgressFetch;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addMessage(message) {
|
|
|
|
|
// This is debounced, so it won't hit the database too often.
|
|
|
|
|
this.lazyUpdateVerified();
|
|
|
|
|
|
2018-07-25 22:02:27 +00:00
|
|
|
|
// 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);
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (message.isOutgoing()) {
|
|
|
|
|
this.removeLastSeenIndicator();
|
|
|
|
|
}
|
|
|
|
|
if (this.lastSeenIndicator) {
|
|
|
|
|
this.lastSeenIndicator.increment(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.isHidden() && !window.isFocused()) {
|
|
|
|
|
// The conversation is visible, but window is not focused
|
|
|
|
|
if (!this.lastSeenIndicator) {
|
|
|
|
|
this.resetLastSeenIndicator({ scroll: false });
|
2018-04-27 21:25:04 +00:00
|
|
|
|
} else if (
|
|
|
|
|
this.view.atBottom() &&
|
|
|
|
|
this.model.get('unreadCount') === this.lastSeenIndicator.getCount()
|
|
|
|
|
) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// The count check ensures that the last seen indicator is still in
|
|
|
|
|
// sync with the real number of unread, so we can scroll to it.
|
|
|
|
|
// We only do this if we're at the bottom, because that signals that
|
|
|
|
|
// the user is okay with us changing scroll around so they see the
|
|
|
|
|
// right unseen message first.
|
|
|
|
|
this.resetLastSeenIndicator({ scroll: true });
|
|
|
|
|
}
|
|
|
|
|
} else if (!this.isHidden() && window.isFocused()) {
|
|
|
|
|
// The conversation is visible and in focus
|
|
|
|
|
this.markRead();
|
|
|
|
|
|
|
|
|
|
// When we're scrolled up and we don't already have a last seen indicator
|
|
|
|
|
// we add a new one.
|
|
|
|
|
if (!this.view.atBottom() && !this.lastSeenIndicator) {
|
|
|
|
|
this.resetLastSeenIndicator({ scroll: false });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
onClick() {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// If there are sub-panels open, we don't want to respond to clicks
|
|
|
|
|
if (!this.panels || !this.panels.length) {
|
|
|
|
|
this.markRead();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
findNewestVisibleUnread() {
|
|
|
|
|
const collection = this.model.messageCollection;
|
|
|
|
|
const { length } = collection;
|
|
|
|
|
const viewportBottom = this.view.outerHeight;
|
|
|
|
|
const unreadCount = this.model.get('unreadCount') || 0;
|
|
|
|
|
|
|
|
|
|
// Start with the most recent message, search backwards in time
|
|
|
|
|
let foundUnread = 0;
|
|
|
|
|
for (let i = length - 1; i >= 0; i -= 1) {
|
|
|
|
|
// Search the latest 30, then stop if we believe we've covered all known
|
|
|
|
|
// unread messages. The unread should be relatively recent.
|
|
|
|
|
// Why? local notifications can be unread but won't be reflected the
|
|
|
|
|
// conversation's unread count.
|
|
|
|
|
if (i > 30 && foundUnread >= unreadCount) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2015-06-16 20:43:40 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const message = collection.at(i);
|
|
|
|
|
if (!message.get('unread')) {
|
|
|
|
|
// eslint-disable-next-line no-continue
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2015-06-04 00:23:55 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
foundUnread += 1;
|
2015-11-12 20:08:29 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const el = this.$(`#${message.id}`);
|
|
|
|
|
const position = el.position();
|
|
|
|
|
const { top } = position;
|
2017-09-11 16:50:35 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// We're fully below the viewport, continue searching up.
|
|
|
|
|
if (top > viewportBottom) {
|
|
|
|
|
// eslint-disable-next-line no-continue
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2015-06-16 20:43:40 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// If the bottom fits on screen, we'll call it visible. Even if the
|
|
|
|
|
// message is really tall.
|
|
|
|
|
const height = el.height();
|
|
|
|
|
const bottom = top + height;
|
|
|
|
|
if (bottom <= viewportBottom) {
|
|
|
|
|
return message;
|
|
|
|
|
}
|
2015-06-16 20:43:40 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// Continue searching up.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
markRead() {
|
|
|
|
|
let unread;
|
|
|
|
|
|
|
|
|
|
if (this.view.atBottom()) {
|
|
|
|
|
unread = this.model.messageCollection.last();
|
|
|
|
|
} else {
|
|
|
|
|
unread = this.findNewestVisibleUnread();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (unread) {
|
|
|
|
|
this.model.markRead(unread.get('received_at'));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-02-11 22:52:58 +00:00
|
|
|
|
async showMembers(e, providedMembers, options = {}) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
_.defaults(options, { needVerify: false });
|
|
|
|
|
|
2019-02-11 23:59:21 +00:00
|
|
|
|
const model = providedMembers || this.model.contactCollection;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const view = new Whisper.GroupMemberList({
|
2019-02-11 22:52:58 +00:00
|
|
|
|
model,
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// we pass this in to allow nested panels
|
|
|
|
|
listenBack: this.listenBack.bind(this),
|
|
|
|
|
needVerify: options.needVerify,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.listenBack(view);
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-20 17:42:28 +00:00
|
|
|
|
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}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
const dialog = new Whisper.ConfirmationDialogView({
|
2019-01-22 17:48:22 +00:00
|
|
|
|
message: i18n('identityKeyErrorOnSend', [
|
|
|
|
|
contact.getTitle(),
|
|
|
|
|
contact.getTitle(),
|
|
|
|
|
]),
|
2018-07-09 21:29:13 +00:00
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
showSafetyNumber(id) {
|
|
|
|
|
let conversation;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
if (!id && this.model.isPrivate()) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// eslint-disable-next-line prefer-destructuring
|
2019-03-15 22:18:00 +00:00
|
|
|
|
conversation = this.model;
|
|
|
|
|
} else {
|
|
|
|
|
conversation = ConversationController.get(id);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
2019-03-15 22:18:00 +00:00
|
|
|
|
if (conversation) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const view = new Whisper.KeyVerificationPanelView({
|
2019-03-15 22:18:00 +00:00
|
|
|
|
model: conversation,
|
2018-04-17 21:03:31 +00:00
|
|
|
|
});
|
|
|
|
|
this.listenBack(view);
|
2018-07-27 18:07:23 +00:00
|
|
|
|
this.updateHeader();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
downloadAttachment({ attachment, timestamp, isDangerous }) {
|
2018-10-04 01:12:42 +00:00
|
|
|
|
if (isDangerous) {
|
|
|
|
|
const toast = new Whisper.DangerousFileTypeToast();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
Signal.Types.Attachment.save({
|
|
|
|
|
attachment,
|
|
|
|
|
document,
|
|
|
|
|
getAbsolutePath: getAbsoluteAttachmentPath,
|
2019-03-15 22:18:00 +00:00
|
|
|
|
timestamp,
|
2018-04-17 21:03:31 +00:00
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
deleteMessage(messageId) {
|
|
|
|
|
const message = this.model.messageCollection.get(messageId);
|
|
|
|
|
if (!message) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`deleteMessage: Did not find message for id ${messageId}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
const dialog = new Whisper.ConfirmationDialogView({
|
|
|
|
|
message: i18n('deleteWarning'),
|
|
|
|
|
okText: i18n('delete'),
|
|
|
|
|
resolve: () => {
|
2018-07-25 22:02:27 +00:00
|
|
|
|
window.Signal.Data.removeMessage(message.id, {
|
|
|
|
|
Message: Whisper.Message,
|
|
|
|
|
});
|
|
|
|
|
message.trigger('unload');
|
2018-07-27 01:13:56 +00:00
|
|
|
|
this.model.messageCollection.remove(message.id);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.resetPanel();
|
|
|
|
|
this.updateHeader();
|
|
|
|
|
},
|
2018-04-17 21:03:31 +00:00
|
|
|
|
});
|
2018-07-09 21:29:13 +00:00
|
|
|
|
|
|
|
|
|
this.$el.prepend(dialog.el);
|
|
|
|
|
dialog.focusCancel();
|
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
showLightbox({ attachment, messageId }) {
|
|
|
|
|
const message = this.model.messageCollection.get(messageId);
|
|
|
|
|
if (!message) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`showLightbox: did not find message for id ${messageId}`
|
|
|
|
|
);
|
|
|
|
|
}
|
2018-07-09 21:29:13 +00:00
|
|
|
|
const { contentType, path } = attachment;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
|
|
|
|
|
!Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
|
|
|
|
|
) {
|
|
|
|
|
this.downloadAttachment({ attachment, message });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-11-14 18:47:19 +00:00
|
|
|
|
const attachments = message.get('attachments') || [];
|
2019-01-30 20:15:07 +00:00
|
|
|
|
|
|
|
|
|
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) {
|
2018-11-14 18:47:19 +00:00
|
|
|
|
const props = {
|
|
|
|
|
objectURL: getAbsoluteAttachmentPath(path),
|
|
|
|
|
contentType,
|
2018-12-14 22:10:24 +00:00
|
|
|
|
caption: attachment.caption,
|
2018-11-14 18:47:19 +00:00
|
|
|
|
onSave: () => this.downloadAttachment({ attachment, message }),
|
|
|
|
|
};
|
|
|
|
|
this.lightboxView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'lightbox-wrapper',
|
|
|
|
|
Component: Signal.Components.Lightbox,
|
|
|
|
|
props,
|
2019-02-21 22:15:43 +00:00
|
|
|
|
onClose: () => {
|
|
|
|
|
Signal.Backbone.Views.Lightbox.hide();
|
|
|
|
|
this.stopListening(message);
|
|
|
|
|
},
|
2018-11-14 18:47:19 +00:00
|
|
|
|
});
|
2019-02-21 22:15:43 +00:00
|
|
|
|
this.listenTo(message, 'expired', () => this.lightboxView.remove());
|
2018-11-14 18:47:19 +00:00
|
|
|
|
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectedIndex = _.findIndex(
|
2019-01-30 20:15:07 +00:00
|
|
|
|
media,
|
2018-11-14 18:47:19 +00:00
|
|
|
|
item => attachment.path === item.path
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onSave = async (options = {}) => {
|
|
|
|
|
Signal.Types.Attachment.save({
|
|
|
|
|
attachment: options.attachment,
|
|
|
|
|
document,
|
2019-03-08 20:27:45 +00:00
|
|
|
|
index: options.index + 1,
|
2018-11-14 18:47:19 +00:00
|
|
|
|
getAbsolutePath: getAbsoluteAttachmentPath,
|
2019-03-08 20:27:45 +00:00
|
|
|
|
timestamp: options.message.get('sent_at'),
|
2018-11-14 18:47:19 +00:00
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
const props = {
|
2018-11-14 18:47:19 +00:00
|
|
|
|
media,
|
|
|
|
|
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
|
|
|
|
|
onSave,
|
2018-07-09 21:29:13 +00:00
|
|
|
|
};
|
2018-11-14 18:47:19 +00:00
|
|
|
|
this.lightboxGalleryView = new Whisper.ReactWrapperView({
|
2018-07-09 21:29:13 +00:00
|
|
|
|
className: 'lightbox-wrapper',
|
2018-11-14 18:47:19 +00:00
|
|
|
|
Component: Signal.Components.LightboxGallery,
|
2018-07-09 21:29:13 +00:00
|
|
|
|
props,
|
2019-02-21 22:15:43 +00:00
|
|
|
|
onClose: () => {
|
|
|
|
|
Signal.Backbone.Views.Lightbox.hide();
|
|
|
|
|
this.stopListening(message);
|
|
|
|
|
},
|
2018-07-09 21:29:13 +00:00
|
|
|
|
});
|
2019-02-21 22:15:43 +00:00
|
|
|
|
this.listenTo(message, 'expired', () =>
|
|
|
|
|
this.lightboxGalleryView.remove()
|
|
|
|
|
);
|
2018-11-14 18:47:19 +00:00
|
|
|
|
Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
showMessageDetail(messageId) {
|
|
|
|
|
const message = this.model.messageCollection.get(messageId);
|
|
|
|
|
if (!message) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`showMessageDetail: Did not find message for id ${messageId}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
const onClose = () => {
|
|
|
|
|
this.stopListening(message, 'change', update);
|
|
|
|
|
this.resetPanel();
|
|
|
|
|
this.updateHeader();
|
|
|
|
|
};
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
const props = message.getPropsForMessageDetail();
|
|
|
|
|
const view = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'message-detail-wrapper',
|
|
|
|
|
Component: Signal.Components.MessageDetail,
|
|
|
|
|
props,
|
2019-02-19 23:17:52 +00:00
|
|
|
|
onClose,
|
2018-07-09 21:29:13 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const update = () => view.update(message.getPropsForMessageDetail());
|
|
|
|
|
this.listenTo(message, 'change', update);
|
2019-02-19 23:17:52 +00:00
|
|
|
|
this.listenTo(message, 'expired', onClose);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
// We could listen to all involved contacts, but we'll call that overkill
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.listenBack(view);
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.updateHeader();
|
|
|
|
|
view.render();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
showContactDetail({ contact, signalAccount }) {
|
2018-05-03 02:43:23 +00:00
|
|
|
|
const view = new Whisper.ReactWrapperView({
|
2018-05-05 01:19:54 +00:00
|
|
|
|
Component: Signal.Components.ContactDetail,
|
2018-05-23 19:15:46 +00:00
|
|
|
|
className: 'contact-detail-pane panel',
|
2018-05-03 02:43:23 +00:00
|
|
|
|
props: {
|
2019-03-15 22:18:00 +00:00
|
|
|
|
contact,
|
|
|
|
|
signalAccount,
|
2018-05-03 02:43:23 +00:00
|
|
|
|
onSendMessage: () => {
|
2019-03-15 22:18:00 +00:00
|
|
|
|
if (signalAccount) {
|
|
|
|
|
this.openConversation(signalAccount);
|
2018-05-03 02:43:23 +00:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
2018-07-09 21:29:13 +00:00
|
|
|
|
onClose: () => {
|
|
|
|
|
this.resetPanel();
|
|
|
|
|
this.updateHeader();
|
|
|
|
|
},
|
2018-05-03 02:43:23 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.listenBack(view);
|
2018-07-18 16:38:42 +00:00
|
|
|
|
this.updateHeader();
|
2018-05-03 02:43:23 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async openConversation(number) {
|
2019-01-14 21:49:58 +00:00
|
|
|
|
window.Whisper.events.trigger('showConversation', number);
|
2018-05-03 02:43:23 +00:00
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
listenBack(view) {
|
|
|
|
|
this.panels = this.panels || [];
|
|
|
|
|
if (this.panels.length > 0) {
|
|
|
|
|
this.panels[0].$el.hide();
|
|
|
|
|
}
|
|
|
|
|
this.panels.unshift(view);
|
|
|
|
|
view.$el.insertBefore(this.$('.panel').first());
|
|
|
|
|
},
|
|
|
|
|
resetPanel() {
|
2018-07-09 21:29:13 +00:00
|
|
|
|
if (!this.panels || !this.panels.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const view = this.panels.shift();
|
2018-07-09 21:29:13 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (this.panels.length > 0) {
|
|
|
|
|
this.panels[0].$el.show();
|
|
|
|
|
}
|
|
|
|
|
view.remove();
|
|
|
|
|
|
|
|
|
|
if (this.panels.length === 0) {
|
|
|
|
|
this.$el.trigger('force-resize');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
endSession() {
|
|
|
|
|
this.model.endSession();
|
|
|
|
|
},
|
|
|
|
|
|
2018-07-09 21:29:13 +00:00
|
|
|
|
setDisappearingMessages(seconds) {
|
|
|
|
|
if (seconds > 0) {
|
|
|
|
|
this.model.updateExpirationTimer(seconds);
|
|
|
|
|
} else {
|
|
|
|
|
this.model.updateExpirationTimer(null);
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async destroyMessages() {
|
2018-07-09 21:29:13 +00:00
|
|
|
|
try {
|
|
|
|
|
await this.confirm(i18n('deleteConversationConfirmation'));
|
2018-07-27 01:13:56 +00:00
|
|
|
|
try {
|
|
|
|
|
await this.model.destroyMessages();
|
2019-04-04 22:33:04 +00:00
|
|
|
|
this.unload('delete messages');
|
2019-05-02 00:06:57 +00:00
|
|
|
|
this.model.updateLastMessage();
|
2018-07-27 01:13:56 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
window.log.error(
|
|
|
|
|
'destroyMessages: Failed to successfully delete conversation',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
}
|
2018-07-09 21:29:13 +00:00
|
|
|
|
} catch (error) {
|
2018-07-27 01:13:56 +00:00
|
|
|
|
// nothing to see here, user canceled out of dialog
|
2018-07-09 21:29:13 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
showSendConfirmationDialog(e, contacts) {
|
|
|
|
|
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: () => {
|
|
|
|
|
this.checkUnverifiedSendMessage(e, { force: true });
|
|
|
|
|
},
|
|
|
|
|
reject: () => {
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.$el.prepend(dialog.el);
|
|
|
|
|
dialog.focusCancel();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async checkUnverifiedSendMessage(e, options = {}) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.sendStart = Date.now();
|
|
|
|
|
this.$messageField.attr('disabled', true);
|
|
|
|
|
|
|
|
|
|
_.defaults(options, { force: false });
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
try {
|
|
|
|
|
await this.model.updateVerified();
|
|
|
|
|
const contacts = this.model.getUnverified();
|
|
|
|
|
if (!contacts.length) {
|
|
|
|
|
this.checkUntrustedSendMessage(e, options);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2015-07-08 19:45:40 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (options.force) {
|
|
|
|
|
await this.markAllAsVerifiedDefault(contacts);
|
|
|
|
|
this.checkUnverifiedSendMessage(e, options);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2016-04-16 02:17:16 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.showSendConfirmationDialog(e, contacts);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.error(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'checkUnverifiedSendMessage error:',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async checkUntrustedSendMessage(e, options = {}) {
|
|
|
|
|
_.defaults(options, { force: false });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const contacts = await this.model.getUntrusted();
|
|
|
|
|
if (!contacts.length) {
|
|
|
|
|
this.sendMessage(e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2016-04-16 02:17:16 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (options.force) {
|
|
|
|
|
await this.markAllAsApproved(contacts);
|
|
|
|
|
this.sendMessage(e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2015-06-19 20:43:24 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.showSendConfirmationDialog(e, contacts);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.error(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'checkUntrustedSendMessage error:',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleEmojiPanel(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!this.emojiPanel) {
|
|
|
|
|
this.openEmojiPanel();
|
|
|
|
|
} else {
|
|
|
|
|
this.closeEmojiPanel();
|
|
|
|
|
}
|
|
|
|
|
},
|
2018-05-08 21:30:11 +00:00
|
|
|
|
onKeyDown(event) {
|
|
|
|
|
if (event.key !== 'Escape') {
|
|
|
|
|
return;
|
2018-05-08 05:47:33 +00:00
|
|
|
|
}
|
2018-05-08 21:30:11 +00:00
|
|
|
|
this.closeEmojiPanel();
|
2018-05-08 05:47:33 +00:00
|
|
|
|
},
|
2018-04-17 21:03:31 +00:00
|
|
|
|
openEmojiPanel() {
|
|
|
|
|
this.$emojiPanelContainer.outerHeight(200);
|
|
|
|
|
this.emojiPanel = new EmojiPanel(this.$emojiPanelContainer[0], {
|
|
|
|
|
onClick: this.insertEmoji.bind(this),
|
|
|
|
|
});
|
2019-01-15 17:42:55 +00:00
|
|
|
|
this.view.resetScrollPosition();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
},
|
|
|
|
|
closeEmojiPanel() {
|
2018-05-08 21:30:11 +00:00
|
|
|
|
if (this.emojiPanel === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$emojiPanelContainer.empty().outerHeight(0);
|
|
|
|
|
this.emojiPanel = null;
|
2019-01-15 17:42:55 +00:00
|
|
|
|
this.view.resetScrollPosition();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
},
|
|
|
|
|
insertEmoji(e) {
|
|
|
|
|
const colons = `:${emojiData[e.index].short_name}:`;
|
|
|
|
|
|
|
|
|
|
const textarea = this.$messageField[0];
|
2019-01-02 20:22:47 +00:00
|
|
|
|
if (textarea.selectionStart || textarea.selectionStart === 0) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const startPos = textarea.selectionStart;
|
|
|
|
|
const endPos = textarea.selectionEnd;
|
|
|
|
|
|
|
|
|
|
textarea.value =
|
|
|
|
|
textarea.value.substring(0, startPos) +
|
|
|
|
|
colons +
|
|
|
|
|
textarea.value.substring(endPos, textarea.value.length);
|
|
|
|
|
textarea.selectionStart = startPos + colons.length;
|
|
|
|
|
textarea.selectionEnd = startPos + colons.length;
|
|
|
|
|
} else {
|
|
|
|
|
textarea.value += colons;
|
|
|
|
|
}
|
|
|
|
|
this.focusMessageField();
|
|
|
|
|
},
|
2018-04-17 21:31:16 +00:00
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
async setQuoteMessage(messageId) {
|
2018-04-19 01:25:55 +00:00
|
|
|
|
this.quote = null;
|
2019-03-15 22:18:00 +00:00
|
|
|
|
this.quotedMessage = null;
|
2018-04-18 20:06:33 +00:00
|
|
|
|
|
2018-04-19 01:25:55 +00:00
|
|
|
|
if (this.quoteHolder) {
|
|
|
|
|
this.quoteHolder.unload();
|
|
|
|
|
this.quoteHolder = null;
|
|
|
|
|
}
|
2018-04-18 20:06:33 +00:00
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
const message = this.model.messageCollection.get(messageId);
|
2018-04-19 01:25:55 +00:00
|
|
|
|
if (message) {
|
2019-03-15 22:18:00 +00:00
|
|
|
|
this.quotedMessage = message;
|
2018-04-20 19:04:48 +00:00
|
|
|
|
|
2019-03-15 22:18:00 +00:00
|
|
|
|
if (message) {
|
|
|
|
|
const quote = await this.model.makeQuote(this.quotedMessage);
|
|
|
|
|
this.quote = quote;
|
|
|
|
|
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
}
|
2018-04-19 01:25:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.renderQuotedMessage();
|
2018-04-18 20:06:33 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderQuotedMessage() {
|
|
|
|
|
if (this.quoteView) {
|
|
|
|
|
this.quoteView.remove();
|
|
|
|
|
this.quoteView = null;
|
|
|
|
|
}
|
|
|
|
|
if (!this.quotedMessage) {
|
2019-01-15 03:20:45 +00:00
|
|
|
|
this.view.restoreBottomOffset();
|
2018-04-18 20:06:33 +00:00
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = new Whisper.Message({
|
2018-09-27 00:23:17 +00:00
|
|
|
|
conversationId: this.model.id,
|
2018-04-19 01:25:55 +00:00
|
|
|
|
quote: this.quote,
|
2018-04-18 20:06:33 +00:00
|
|
|
|
});
|
|
|
|
|
message.quotedMessage = this.quotedMessage;
|
2018-04-19 01:25:55 +00:00
|
|
|
|
this.quoteHolder = message;
|
|
|
|
|
|
2018-04-20 22:17:08 +00:00
|
|
|
|
const props = message.getPropsForQuote();
|
2018-04-18 20:06:33 +00:00
|
|
|
|
|
|
|
|
|
this.listenTo(message, 'scroll-to-message', this.scrollToMessage);
|
|
|
|
|
|
|
|
|
|
const contact = this.quotedMessage.getContact();
|
|
|
|
|
if (contact) {
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.listenTo(contact, 'change', this.renderQuotedMesage);
|
2018-04-18 20:06:33 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.quoteView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'quote-wrapper',
|
|
|
|
|
Component: window.Signal.Components.Quote,
|
2019-01-15 03:20:45 +00:00
|
|
|
|
elCallback: el => this.$('.send').prepend(el),
|
2018-04-20 22:17:08 +00:00
|
|
|
|
props: Object.assign({}, props, {
|
2018-07-09 21:29:13 +00:00
|
|
|
|
withContentAbove: true,
|
2018-04-20 22:17:08 +00:00
|
|
|
|
onClose: () => {
|
|
|
|
|
this.setQuoteMessage(null);
|
|
|
|
|
},
|
|
|
|
|
}),
|
2019-01-15 03:20:45 +00:00
|
|
|
|
onInitialRender: () => {
|
|
|
|
|
this.view.restoreBottomOffset();
|
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
},
|
2018-04-18 20:06:33 +00:00
|
|
|
|
});
|
2018-04-17 21:31:16 +00:00
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
async sendMessage(e) {
|
|
|
|
|
this.removeLastSeenIndicator();
|
|
|
|
|
this.closeEmojiPanel();
|
2018-11-14 19:10:32 +00:00
|
|
|
|
this.model.clearTypingTimers();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-04-12 21:54:45 +00:00
|
|
|
|
const input = this.$messageField;
|
|
|
|
|
const message = window.Signal.Emoji.replaceColons(input.val()).trim();
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
let toast;
|
|
|
|
|
if (extension.expired()) {
|
|
|
|
|
toast = new Whisper.ExpiredToast();
|
|
|
|
|
}
|
|
|
|
|
if (this.model.isPrivate() && storage.isBlocked(this.model.id)) {
|
|
|
|
|
toast = new Whisper.BlockedToast();
|
|
|
|
|
}
|
2018-09-13 19:57:07 +00:00
|
|
|
|
if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) {
|
|
|
|
|
toast = new Whisper.BlockedGroupToast();
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (!this.model.isPrivate() && this.model.get('left')) {
|
|
|
|
|
toast = new Whisper.LeftGroupToast();
|
|
|
|
|
}
|
2019-04-12 21:54:45 +00:00
|
|
|
|
if (message.length > MAX_MESSAGE_BODY_LENGTH) {
|
|
|
|
|
toast = new Whisper.MessageBodyTooLongToast();
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
if (toast) {
|
2018-08-15 19:31:29 +00:00
|
|
|
|
toast.$el.appendTo(this.$el);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
toast.render();
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (!message.length && !this.fileInput.hasFiles()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2015-09-16 03:50:00 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
const attachments = await this.fileInput.getFiles();
|
|
|
|
|
const sendDelta = Date.now() - this.sendStart;
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.model.sendMessage(
|
|
|
|
|
message,
|
|
|
|
|
attachments,
|
|
|
|
|
this.quote,
|
|
|
|
|
this.getLinkPreview()
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
input.val('');
|
2018-04-19 18:19:23 +00:00
|
|
|
|
this.setQuoteMessage(null);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.resetLinkPreview();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
this.forceUpdateMessageFieldSize(e);
|
2018-12-02 01:48:53 +00:00
|
|
|
|
this.fileInput.clearAttachments();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
} catch (error) {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.error(
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'Error pulling attached files before send',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-01-16 03:03:56 +00:00
|
|
|
|
onKeyUp() {
|
|
|
|
|
this.maybeBumpTyping();
|
|
|
|
|
this.debouncedMaybeGrabLinkPreview();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
maybeGrabLinkPreview() {
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messageText = this.$messageField.val().trim();
|
2019-02-11 23:10:32 +00:00
|
|
|
|
const caretLocation = this.$messageField.get(0).selectionStart;
|
2019-01-16 03:03:56 +00:00
|
|
|
|
|
|
|
|
|
if (!messageText) {
|
|
|
|
|
this.resetLinkPreview();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.disableLinkPreviews) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-02-11 23:10:32 +00:00
|
|
|
|
const links = window.Signal.LinkPreviews.findLinks(
|
|
|
|
|
messageText,
|
|
|
|
|
caretLocation
|
|
|
|
|
);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
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 size = await textsecure.messaging.getProxiedSize(url);
|
|
|
|
|
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
|
|
|
|
|
|
|
|
|
|
let results = [];
|
|
|
|
|
const jobs = chunks.map(chunk => async () => {
|
|
|
|
|
const { start, end } = chunk;
|
|
|
|
|
|
|
|
|
|
const result = await textsecure.messaging.makeProxiedRequest(url, {
|
|
|
|
|
start,
|
|
|
|
|
end,
|
|
|
|
|
returnArrayBuffer: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...chunk,
|
|
|
|
|
...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(results);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
contentType,
|
|
|
|
|
data,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async getPreview(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) {
|
|
|
|
|
this.view.restoreBottomOffset();
|
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
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.$('.send').prepend(el),
|
|
|
|
|
props,
|
|
|
|
|
onInitialRender: () => {
|
|
|
|
|
this.view.restoreBottomOffset();
|
|
|
|
|
this.updateMessageFieldSize({});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2018-11-14 19:10:32 +00:00
|
|
|
|
// Called whenever the user changes the message composition field. But only
|
|
|
|
|
// fires if there's content in the message field after the change.
|
|
|
|
|
maybeBumpTyping() {
|
|
|
|
|
const messageText = this.$messageField.val();
|
|
|
|
|
if (messageText.length) {
|
2018-12-10 18:21:40 +00:00
|
|
|
|
this.model.throttledBumpTyping();
|
2018-11-14 19:10:32 +00:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
updateMessageFieldSize(event) {
|
|
|
|
|
const keyCode = event.which || event.keyCode;
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
if (
|
|
|
|
|
keyCode === 13 &&
|
|
|
|
|
!event.altKey &&
|
|
|
|
|
!event.shiftKey &&
|
|
|
|
|
!event.ctrlKey
|
|
|
|
|
) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// enter pressed - submit the form now
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.$('.bottom-bar form').submit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.toggleMicrophone();
|
|
|
|
|
|
|
|
|
|
this.view.measureScrollPosition();
|
|
|
|
|
window.autosize(this.$messageField);
|
|
|
|
|
|
|
|
|
|
const $attachmentPreviews = this.$('.attachment-previews');
|
|
|
|
|
const $bottomBar = this.$('.bottom-bar');
|
2018-04-18 20:06:33 +00:00
|
|
|
|
const includeMargin = true;
|
|
|
|
|
const quoteHeight = this.quoteView
|
|
|
|
|
? this.quoteView.$el.outerHeight(includeMargin)
|
|
|
|
|
: 0;
|
|
|
|
|
|
2018-04-27 21:25:04 +00:00
|
|
|
|
const height =
|
|
|
|
|
this.$messageField.outerHeight() +
|
2018-04-17 21:03:31 +00:00
|
|
|
|
$attachmentPreviews.outerHeight() +
|
|
|
|
|
this.$emojiPanelContainer.outerHeight() +
|
2018-04-18 20:06:33 +00:00
|
|
|
|
quoteHeight +
|
2018-04-17 21:03:31 +00:00
|
|
|
|
parseInt($bottomBar.css('min-height'), 10);
|
|
|
|
|
|
|
|
|
|
$bottomBar.outerHeight(height);
|
|
|
|
|
|
|
|
|
|
this.view.scrollToBottomIfNeeded();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
forceUpdateMessageFieldSize(event) {
|
|
|
|
|
if (this.isHidden()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.view.scrollToBottomIfNeeded();
|
|
|
|
|
window.autosize.update(this.$messageField);
|
|
|
|
|
this.updateMessageFieldSize(event);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isHidden() {
|
2018-04-27 21:25:04 +00:00
|
|
|
|
return (
|
|
|
|
|
this.$el.css('display') === 'none' ||
|
|
|
|
|
this.$('.panel').css('display') === 'none'
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
});
|
2018-04-27 21:25:04 +00:00
|
|
|
|
})();
|