2018-11-14 19:10:32 +00:00
|
|
|
|
/* global
|
|
|
|
|
$,
|
|
|
|
|
_,
|
2019-05-31 22:42:01 +00:00
|
|
|
|
ConversationController,
|
2018-11-14 19:10:32 +00:00
|
|
|
|
extension,
|
|
|
|
|
i18n,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
loadImage,
|
|
|
|
|
MessageController,
|
2018-11-14 19:10:32 +00:00
|
|
|
|
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 || {};
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const { Message, MIME, VisualAttachment } = window.Signal.Types;
|
2018-07-18 16:38:42 +00:00
|
|
|
|
const {
|
|
|
|
|
upgradeMessageSchema,
|
|
|
|
|
getAbsoluteAttachmentPath,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
getAbsoluteDraftPath,
|
2019-08-05 20:53:15 +00:00
|
|
|
|
copyIntoTempDirectory,
|
|
|
|
|
getAbsoluteTempPath,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
deleteDraftFile,
|
2019-08-05 20:53:15 +00:00
|
|
|
|
deleteTempFile,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
readDraftData,
|
|
|
|
|
writeNewDraftData,
|
2018-07-18 16:38:42 +00:00
|
|
|
|
} = window.Signal.Migrations;
|
2019-05-31 22:42:01 +00:00
|
|
|
|
const {
|
|
|
|
|
getOlderMessagesByConversation,
|
|
|
|
|
getMessageMetricsForConversation,
|
|
|
|
|
getMessageById,
|
|
|
|
|
getMessagesBySentAt,
|
|
|
|
|
getNewerMessagesByConversation,
|
|
|
|
|
} = window.Signal.Data;
|
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') };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
Whisper.FileSizeToast = Whisper.ToastView.extend({
|
|
|
|
|
templateName: 'file-size-modal',
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return {
|
|
|
|
|
'file-size-warning': i18n('fileSizeWarning'),
|
|
|
|
|
limit: this.model.limit,
|
|
|
|
|
units: this.model.units,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
Whisper.UnableToLoadToast = Whisper.ToastView.extend({
|
|
|
|
|
render_attributes() {
|
|
|
|
|
return { toastMessage: i18n('unableToLoadAttachment') };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
|
|
|
|
|
template: i18n('dangerousFileType'),
|
|
|
|
|
});
|
|
|
|
|
Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({
|
|
|
|
|
template: i18n('oneNonImageAtATimeToast'),
|
|
|
|
|
});
|
|
|
|
|
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
|
|
|
|
|
template: i18n('cannotMixImageAdnNonImageAttachments'),
|
|
|
|
|
});
|
|
|
|
|
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
|
|
|
|
|
template: i18n('maximumAttachments'),
|
|
|
|
|
});
|
|
|
|
|
|
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) {
|
2019-05-31 22:42:01 +00:00
|
|
|
|
// Events on Conversation model
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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);
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.listenTo(this.model, 'backgrounded', this.resetEmojiResults);
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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
|
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);
|
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
2019-05-31 22:42:01 +00:00
|
|
|
|
'show-visual-attachment',
|
2018-07-09 21:29:13 +00:00
|
|
|
|
this.showLightbox
|
|
|
|
|
);
|
2019-06-26 19:33:13 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'display-tap-to-view-message',
|
|
|
|
|
this.displayTapToViewMessage
|
|
|
|
|
);
|
2018-05-03 02:43:23 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
2019-05-31 22:42:01 +00:00
|
|
|
|
'navigate-to',
|
|
|
|
|
this.navigateTo
|
2018-07-09 21:29:13 +00:00
|
|
|
|
);
|
2019-06-10 21:40:02 +00:00
|
|
|
|
this.listenTo(
|
|
|
|
|
this.model.messageCollection,
|
|
|
|
|
'download-new-version',
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.downloadNewVersion
|
2019-06-10 21:40:02 +00:00
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
this.lazyUpdateVerified = _.debounce(
|
|
|
|
|
this.model.updateVerified.bind(this.model),
|
|
|
|
|
1000 // one second
|
|
|
|
|
);
|
2019-09-06 19:16:54 +00:00
|
|
|
|
this.model.throttledGetProfiles =
|
|
|
|
|
this.model.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
|
|
|
|
|
);
|
2019-09-03 22:41:21 +00:00
|
|
|
|
this.debouncedSaveDraft = _.debounce(this.saveDraft.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;
|
2019-08-06 19:18:37 +00:00
|
|
|
|
const attachmentListEl = $(
|
|
|
|
|
'<div class="module-composition-area__attachment-list"></div>'
|
|
|
|
|
);
|
2019-08-07 00:40:25 +00:00
|
|
|
|
|
|
|
|
|
this.attachmentListView = new Whisper.ReactWrapperView({
|
2019-08-06 19:18:37 +00:00
|
|
|
|
el: attachmentListEl,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
Component: window.Signal.Components.AttachmentList,
|
|
|
|
|
props: this.getPropsForAttachmentList(),
|
2019-01-15 17:42:55 +00:00
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
|
|
|
|
extension.windows.onClosed(() => {
|
|
|
|
|
this.unload('windows closed');
|
|
|
|
|
});
|
|
|
|
|
|
2019-06-11 15:07:56 +00:00
|
|
|
|
this.setupHeader();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.setupTimeline();
|
2019-08-06 19:18:37 +00:00
|
|
|
|
this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] });
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
events: {
|
2019-06-27 20:35:21 +00:00
|
|
|
|
'click .composition-area-placeholder': 'onClickPlaceholder',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
'click .bottom-bar': 'focusMessageField',
|
|
|
|
|
'click .capture-audio .microphone': 'captureAudio',
|
2018-12-02 01:48:53 +00:00
|
|
|
|
'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
|
|
|
|
|
2019-06-11 15:07:56 +00:00
|
|
|
|
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(),
|
2019-08-09 23:12:29 +00:00
|
|
|
|
onSearchInConversation: () => {
|
|
|
|
|
const { searchInConversation } = window.reduxActions.search;
|
|
|
|
|
const name = this.model.isMe()
|
|
|
|
|
? i18n('noteToSelf')
|
|
|
|
|
: this.model.getTitle();
|
|
|
|
|
searchInConversation(this.model.id, name);
|
|
|
|
|
},
|
2019-06-11 15:07:56 +00:00
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
},
|
|
|
|
|
|
2019-08-06 19:18:37 +00:00
|
|
|
|
setupCompositionArea({ attachmentListEl }) {
|
2019-06-27 20:35:21 +00:00
|
|
|
|
const compositionApi = { current: null };
|
|
|
|
|
this.compositionApi = compositionApi;
|
2019-05-16 22:14:06 +00:00
|
|
|
|
|
2019-08-06 19:18:37 +00:00
|
|
|
|
const micCellEl = $(`
|
|
|
|
|
<div class="capture-audio">
|
|
|
|
|
<button class="microphone"></button>
|
|
|
|
|
</div>
|
|
|
|
|
`)[0];
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const props = {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
id: this.model.id,
|
2019-06-27 20:35:21 +00:00
|
|
|
|
compositionApi,
|
2019-05-16 22:32:11 +00:00
|
|
|
|
onClickAddPack: () => this.showStickerManager(),
|
|
|
|
|
onPickSticker: (packId, stickerId) =>
|
|
|
|
|
this.sendStickerMessage({ packId, stickerId }),
|
2019-06-27 20:35:21 +00:00
|
|
|
|
onSubmit: message => this.sendMessage(message),
|
|
|
|
|
onEditorStateChange: (msg, caretLocation) =>
|
|
|
|
|
this.onEditorStateChange(msg, caretLocation),
|
2019-09-13 21:54:19 +00:00
|
|
|
|
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
|
2019-08-07 00:40:25 +00:00
|
|
|
|
onChooseAttachment: this.onChooseAttachment.bind(this),
|
2019-08-06 19:18:37 +00:00
|
|
|
|
micCellEl,
|
|
|
|
|
attachmentListEl,
|
2019-05-16 22:32:11 +00:00
|
|
|
|
};
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.compositionAreaView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'composition-area-wrapper',
|
|
|
|
|
JSX: Signal.State.Roots.createCompositionArea(window.reduxStore, props),
|
2019-05-16 22:32:11 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Finally, add it to the DOM
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.$('.composition-area-placeholder').append(
|
|
|
|
|
this.compositionAreaView.el
|
|
|
|
|
);
|
2019-05-16 22:32:11 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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();
|
|
|
|
|
};
|
2019-08-07 00:40:25 +00:00
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.showToast(Whisper.OriginalNotFoundToast);
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
};
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const markMessageRead = async (messageId, forceFocus) => {
|
|
|
|
|
// We need a forceFocus parameter because the BrowserWindow focus event fires
|
|
|
|
|
// before the document realizes that it has focus.
|
|
|
|
|
if (!document.hasFocus() && !forceFocus) {
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
showToast(ToastView) {
|
|
|
|
|
const toast = new ToastView();
|
|
|
|
|
toast.$el.appendTo(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
},
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-05-24 23:58:27 +00:00
|
|
|
|
// We need this, or clicking the reactified buttons will submit the form and send any
|
2019-05-16 22:32:11 +00:00
|
|
|
|
// mid-composition message content.
|
2019-05-24 23:58:27 +00:00
|
|
|
|
onClickPlaceholder(e) {
|
2019-05-16 22:32:11 +00:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
},
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
onChooseAttachment() {
|
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
|
2019-08-07 00:40:25 +00:00
|
|
|
|
await this.maybeAddAttachment(file);
|
2019-01-16 18:32:57 +00:00
|
|
|
|
this.toggleMicrophone();
|
|
|
|
|
}
|
|
|
|
|
|
2018-12-02 01:48:53 +00:00
|
|
|
|
fileField.val(null);
|
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
const { conversationUnloaded } = window.reduxActions.conversations;
|
|
|
|
|
if (conversationUnloaded) {
|
|
|
|
|
conversationUnloaded(this.model.id);
|
|
|
|
|
}
|
|
|
|
|
|
2019-09-14 00:10:00 +00:00
|
|
|
|
if (this.model.get('draftChanged')) {
|
|
|
|
|
if (this.model.hasDraft()) {
|
|
|
|
|
this.model.set({
|
|
|
|
|
draftChanged: false,
|
|
|
|
|
draftTimestamp: Date.now(),
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this.model.set({
|
|
|
|
|
draftChanged: false,
|
|
|
|
|
draftTimestamp: null,
|
|
|
|
|
});
|
|
|
|
|
}
|
2019-09-03 22:41:21 +00:00
|
|
|
|
|
|
|
|
|
// We don't wait here; we need to take down the view
|
|
|
|
|
this.saveModel();
|
2019-09-14 00:10:00 +00:00
|
|
|
|
|
2019-09-03 22:41:21 +00:00
|
|
|
|
this.model.updateLastMessage();
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.titleView.remove();
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.timelineView.remove();
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (this.attachmentListView) {
|
|
|
|
|
this.attachmentListView.remove();
|
|
|
|
|
}
|
|
|
|
|
if (this.captionEditorView) {
|
|
|
|
|
this.captionEditorView.remove();
|
|
|
|
|
}
|
2019-05-16 22:32:11 +00:00
|
|
|
|
if (this.stickerButtonView) {
|
|
|
|
|
this.stickerButtonView.remove();
|
|
|
|
|
}
|
|
|
|
|
if (this.stickerPreviewModalView) {
|
|
|
|
|
this.stickerPreviewModalView.remove();
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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();
|
|
|
|
|
}
|
2019-06-26 19:33:13 +00:00
|
|
|
|
if (this.lightboxView) {
|
|
|
|
|
this.lightboxView.remove();
|
2018-07-09 21:29:13 +00:00
|
|
|
|
}
|
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.remove();
|
|
|
|
|
|
|
|
|
|
this.model.messageCollection.reset([]);
|
|
|
|
|
},
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
navigateTo(url) {
|
|
|
|
|
window.location = url;
|
|
|
|
|
},
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
downloadNewVersion() {
|
|
|
|
|
window.location = 'https://signal.org/download';
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
onDragOver(e) {
|
|
|
|
|
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.$el.addClass('dropoff');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onDragLeave(e) {
|
|
|
|
|
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async onDrop(e) {
|
|
|
|
|
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const { files } = e.originalEvent.dataTransfer;
|
|
|
|
|
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.maybeAddAttachment(file);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onPaste(e) {
|
|
|
|
|
const { items } = e.originalEvent.clipboardData;
|
|
|
|
|
let imgBlob = null;
|
|
|
|
|
for (let i = 0; i < items.length; i += 1) {
|
|
|
|
|
if (items[i].type.split('/')[0] === 'image') {
|
|
|
|
|
imgBlob = items[i].getAsFile();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (imgBlob !== null) {
|
|
|
|
|
const file = imgBlob;
|
|
|
|
|
this.maybeAddAttachment(file);
|
|
|
|
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getPropsForAttachmentList() {
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
// In conversation model/redux
|
|
|
|
|
attachments: draftAttachments.map(attachment => ({
|
|
|
|
|
...attachment,
|
|
|
|
|
url: attachment.screenshotPath
|
|
|
|
|
? getAbsoluteDraftPath(attachment.screenshotPath)
|
|
|
|
|
: getAbsoluteDraftPath(attachment.path),
|
|
|
|
|
})),
|
|
|
|
|
// Passed in from ConversationView
|
|
|
|
|
onAddAttachment: this.onChooseAttachment.bind(this),
|
|
|
|
|
onClickAttachment: this.onClickAttachment.bind(this),
|
|
|
|
|
onCloseAttachment: this.onCloseAttachment.bind(this),
|
|
|
|
|
onClose: this.clearAttachments.bind(this),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onClickAttachment(attachment) {
|
|
|
|
|
const getProps = () => ({
|
|
|
|
|
url: attachment.url,
|
|
|
|
|
caption: attachment.caption,
|
|
|
|
|
attachment,
|
|
|
|
|
onSave,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const onSave = caption => {
|
|
|
|
|
// eslint-disable-next-line no-param-reassign
|
|
|
|
|
attachment.caption = caption;
|
|
|
|
|
this.captionEditorView.remove();
|
|
|
|
|
Signal.Backbone.Views.Lightbox.hide();
|
|
|
|
|
this.attachmentListView.update(this.getPropsForAttachmentList());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.captionEditorView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'attachment-list-wrapper',
|
|
|
|
|
Component: window.Signal.Components.CaptionEditor,
|
|
|
|
|
props: getProps(),
|
|
|
|
|
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
|
|
|
|
});
|
|
|
|
|
Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async deleteDraftAttachment(attachment) {
|
|
|
|
|
if (attachment.screenshotPath) {
|
|
|
|
|
await deleteDraftFile(attachment.screenshotPath);
|
|
|
|
|
}
|
|
|
|
|
if (attachment.path) {
|
|
|
|
|
await deleteDraftFile(attachment.path);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async saveModel() {
|
|
|
|
|
await window.Signal.Data.updateConversation(
|
|
|
|
|
this.model.id,
|
|
|
|
|
this.model.attributes,
|
|
|
|
|
{
|
|
|
|
|
Conversation: Whisper.Conversation,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async addAttachment(attachment) {
|
|
|
|
|
const onDisk = await this.writeDraftAttachment(attachment);
|
|
|
|
|
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
this.model.set({
|
|
|
|
|
draftAttachments: [...draftAttachments, onDisk],
|
2019-09-14 00:10:00 +00:00
|
|
|
|
draftChanged: true,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
});
|
|
|
|
|
await this.saveModel();
|
|
|
|
|
|
|
|
|
|
this.updateAttachmentsView();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async onCloseAttachment(attachment) {
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
|
|
|
|
|
this.model.set({
|
|
|
|
|
draftAttachments: _.reject(
|
|
|
|
|
draftAttachments,
|
|
|
|
|
item => item.path === attachment.path
|
|
|
|
|
),
|
2019-09-14 00:10:00 +00:00
|
|
|
|
draftChanged: true,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.updateAttachmentsView();
|
|
|
|
|
|
|
|
|
|
await this.saveModel();
|
|
|
|
|
await this.deleteDraftAttachment(attachment);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async clearAttachments() {
|
|
|
|
|
this.voiceNoteAttachment = null;
|
|
|
|
|
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
this.model.set({
|
|
|
|
|
draftAttachments: [],
|
2019-09-14 00:10:00 +00:00
|
|
|
|
draftChanged: true,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.updateAttachmentsView();
|
|
|
|
|
|
|
|
|
|
// We're fine doing this all at once; at most it should be 32 attachments
|
|
|
|
|
await Promise.all([
|
|
|
|
|
this.saveModel(),
|
|
|
|
|
Promise.all(
|
|
|
|
|
draftAttachments.map(attachment =>
|
|
|
|
|
this.deleteDraftAttachment(attachment)
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
hasFiles() {
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
return draftAttachments.length > 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async getFiles() {
|
|
|
|
|
if (this.voiceNoteAttachment) {
|
|
|
|
|
// We don't need to pull these off disk; we return them as-is
|
|
|
|
|
return [this.voiceNoteAttachment];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
const files = _.compact(
|
|
|
|
|
await Promise.all(
|
|
|
|
|
draftAttachments.map(attachment => this.getFile(attachment))
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return files;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async getFile(attachment) {
|
|
|
|
|
if (!attachment) {
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await readDraftData(attachment.path);
|
|
|
|
|
if (data.byteLength !== attachment.size) {
|
|
|
|
|
window.log.error(
|
|
|
|
|
`Attachment size from disk ${
|
|
|
|
|
data.byteLength
|
|
|
|
|
} did not match attachment size ${attachment.size}`
|
|
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
..._.pick(attachment, ['contentType', 'fileName', 'size']),
|
|
|
|
|
data,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
arrayBufferFromFile(file) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const FR = new FileReader();
|
|
|
|
|
FR.onload = e => {
|
|
|
|
|
resolve(e.target.result);
|
|
|
|
|
};
|
|
|
|
|
FR.onerror = reject;
|
|
|
|
|
FR.onabort = reject;
|
|
|
|
|
FR.readAsArrayBuffer(file);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
showFileSizeError({ limit, units, u }) {
|
|
|
|
|
const toast = new Whisper.FileSizeToast({
|
|
|
|
|
model: { limit, units: units[u] },
|
|
|
|
|
});
|
|
|
|
|
toast.$el.insertAfter(this.$el);
|
|
|
|
|
toast.render();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
updateAttachmentsView() {
|
|
|
|
|
this.attachmentListView.update(this.getPropsForAttachmentList());
|
|
|
|
|
this.toggleMicrophone();
|
|
|
|
|
if (this.hasFiles()) {
|
|
|
|
|
this.removeLinkPreview();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async writeDraftAttachment(attachment) {
|
|
|
|
|
let toWrite = attachment;
|
|
|
|
|
|
|
|
|
|
if (toWrite.data) {
|
|
|
|
|
const path = await writeNewDraftData(toWrite.data);
|
|
|
|
|
toWrite = {
|
|
|
|
|
..._.omit(toWrite, ['data']),
|
|
|
|
|
path,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (toWrite.screenshotData) {
|
|
|
|
|
const screenshotPath = await writeNewDraftData(toWrite.screenshotData);
|
|
|
|
|
toWrite = {
|
|
|
|
|
..._.omit(toWrite, ['screenshotData']),
|
|
|
|
|
screenshotPath,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return toWrite;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async maybeAddAttachment(file) {
|
|
|
|
|
if (!file) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MB = 1000 * 1024;
|
|
|
|
|
if (file.size > 100 * MB) {
|
|
|
|
|
this.showFileSizeError({ limit: 100, units: ['MB'], u: 0 });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (window.Signal.Util.isFileDangerous(file.name)) {
|
|
|
|
|
this.showToast(Whisper.DangerousFileTypeToast);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const draftAttachments = this.model.get('draftAttachments') || [];
|
|
|
|
|
if (draftAttachments.length >= 32) {
|
|
|
|
|
this.showToast(Whisper.MaxAttachmentsToast);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const haveNonImage = _.any(
|
|
|
|
|
draftAttachments,
|
|
|
|
|
attachment => !MIME.isImage(attachment.contentType)
|
|
|
|
|
);
|
|
|
|
|
// You can't add another attachment if you already have a non-image staged
|
|
|
|
|
if (haveNonImage) {
|
|
|
|
|
this.showToast(Whisper.OneNonImageAtATimeToast);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// You can't add a non-image attachment if you already have attachments staged
|
|
|
|
|
if (!MIME.isImage(file.type) && draftAttachments.length > 0) {
|
|
|
|
|
this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let attachment;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
|
|
|
|
|
attachment = await this.handleImageAttachment(file);
|
|
|
|
|
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(file.type)) {
|
|
|
|
|
attachment = await this.handleVideoAttachment(file);
|
|
|
|
|
} else {
|
|
|
|
|
const data = await this.arrayBufferFromFile(file);
|
|
|
|
|
attachment = {
|
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
window.log.error(
|
|
|
|
|
`Was unable to generate thumbnail for file type ${file.type}`,
|
|
|
|
|
e && e.stack ? e.stack : e
|
|
|
|
|
);
|
|
|
|
|
const data = await this.arrayBufferFromFile(file);
|
|
|
|
|
attachment = {
|
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (!this.isSizeOkay(attachment)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
window.log.error(
|
|
|
|
|
'Error ensuring that image is properly sized:',
|
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.showToast(Whisper.UnableToLoadToast);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.addAttachment(attachment);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
isSizeOkay(attachment) {
|
|
|
|
|
let limitKb = 1000000;
|
|
|
|
|
const type =
|
|
|
|
|
attachment.contentType === 'image/gif'
|
|
|
|
|
? 'gif'
|
|
|
|
|
: attachment.contentType.split('/')[0];
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'image':
|
|
|
|
|
limitKb = 6000;
|
|
|
|
|
break;
|
|
|
|
|
case 'gif':
|
|
|
|
|
limitKb = 25000;
|
|
|
|
|
break;
|
|
|
|
|
case 'audio':
|
|
|
|
|
limitKb = 100000;
|
|
|
|
|
break;
|
|
|
|
|
case 'video':
|
|
|
|
|
limitKb = 100000;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
limitKb = 100000;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) {
|
|
|
|
|
const units = ['kB', 'MB', 'GB'];
|
|
|
|
|
let u = -1;
|
|
|
|
|
let limit = limitKb * 1000;
|
|
|
|
|
do {
|
|
|
|
|
limit /= 1000;
|
|
|
|
|
u += 1;
|
|
|
|
|
} while (limit >= 1000 && u < units.length - 1);
|
|
|
|
|
this.showFileSizeError({ limit, units, u });
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async handleVideoAttachment(file) {
|
|
|
|
|
const objectUrl = URL.createObjectURL(file);
|
|
|
|
|
if (!objectUrl) {
|
|
|
|
|
throw new Error('Failed to create object url for video!');
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const screenshotContentType = 'image/png';
|
|
|
|
|
const screenshotBlob = await VisualAttachment.makeVideoScreenshot({
|
|
|
|
|
objectUrl,
|
|
|
|
|
contentType: screenshotContentType,
|
|
|
|
|
logger: window.log,
|
|
|
|
|
});
|
|
|
|
|
const screenshotData = await VisualAttachment.blobToArrayBuffer(
|
|
|
|
|
screenshotBlob
|
|
|
|
|
);
|
|
|
|
|
const data = await this.arrayBufferFromFile(file);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
screenshotContentType,
|
|
|
|
|
screenshotData,
|
|
|
|
|
screenshotSize: screenshotData.byteLength,
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
URL.revokeObjectURL(objectUrl);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async handleImageAttachment(file) {
|
|
|
|
|
if (MIME.isJPEG(file.type)) {
|
|
|
|
|
const rotatedDataUrl = await window.autoOrientImage(file);
|
|
|
|
|
const rotatedBlob = VisualAttachment.dataURLToBlobSync(rotatedDataUrl);
|
|
|
|
|
const { contentType, file: resizedBlob } = await this.autoScale({
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
rotatedBlob,
|
|
|
|
|
});
|
|
|
|
|
const data = await await VisualAttachment.blobToArrayBuffer(
|
|
|
|
|
resizedBlob
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
contentType,
|
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { contentType, file: resizedBlob } = await this.autoScale({
|
|
|
|
|
contentType: file.type,
|
|
|
|
|
file,
|
|
|
|
|
});
|
|
|
|
|
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
|
|
|
|
return {
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
contentType,
|
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
autoScale(attachment) {
|
|
|
|
|
const { contentType, file } = attachment;
|
|
|
|
|
if (
|
|
|
|
|
contentType.split('/')[0] !== 'image' ||
|
|
|
|
|
contentType === 'image/tiff'
|
|
|
|
|
) {
|
|
|
|
|
// nothing to do
|
|
|
|
|
return Promise.resolve(attachment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const url = URL.createObjectURL(file);
|
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
img.onerror = reject;
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
const maxSize = 6000 * 1024;
|
|
|
|
|
const maxHeight = 4096;
|
|
|
|
|
const maxWidth = 4096;
|
|
|
|
|
if (
|
|
|
|
|
img.naturalWidth <= maxWidth &&
|
|
|
|
|
img.naturalHeight <= maxHeight &&
|
|
|
|
|
file.size <= maxSize
|
|
|
|
|
) {
|
|
|
|
|
resolve(attachment);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gifMaxSize = 25000 * 1024;
|
|
|
|
|
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
|
|
|
|
|
resolve(attachment);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (file.type === 'image/gif') {
|
|
|
|
|
reject(new Error('GIF is too large'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetContentType = 'image/jpeg';
|
|
|
|
|
const canvas = loadImage.scale(img, {
|
|
|
|
|
canvas: true,
|
|
|
|
|
maxWidth,
|
|
|
|
|
maxHeight,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let quality = 0.95;
|
|
|
|
|
let i = 4;
|
|
|
|
|
let blob;
|
|
|
|
|
do {
|
|
|
|
|
i -= 1;
|
|
|
|
|
blob = window.dataURLToBlobSync(
|
|
|
|
|
canvas.toDataURL(targetContentType, quality)
|
|
|
|
|
);
|
|
|
|
|
quality = quality * maxSize / blob.size;
|
|
|
|
|
// NOTE: During testing with a large image, we observed the
|
|
|
|
|
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
|
|
|
|
|
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
|
|
|
|
|
if (quality < 0.5) {
|
|
|
|
|
quality = 0.5;
|
|
|
|
|
}
|
|
|
|
|
} while (i > 0 && blob.size > maxSize);
|
|
|
|
|
|
|
|
|
|
resolve({
|
|
|
|
|
...attachment,
|
|
|
|
|
fileName: this.fixExtension(attachment.fileName, targetContentType),
|
|
|
|
|
contentType: targetContentType,
|
|
|
|
|
file: blob,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
img.src = url;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getFileName(fileName) {
|
|
|
|
|
if (!fileName) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!fileName.includes('.')) {
|
|
|
|
|
return fileName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fileName
|
|
|
|
|
.split('.')
|
|
|
|
|
.slice(0, -1)
|
|
|
|
|
.join('.');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getType(contentType) {
|
|
|
|
|
if (!contentType) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!contentType.includes('/')) {
|
|
|
|
|
return contentType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return contentType.split('/')[1];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
fixExtension(fileName, contentType) {
|
|
|
|
|
const extension = this.getType(contentType);
|
|
|
|
|
const name = this.getFileName(fileName);
|
|
|
|
|
return `${name}.${extension}`;
|
|
|
|
|
},
|
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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) {
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.showSafetyNumber(unverified.at(0).id);
|
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;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-08-06 19:18:37 +00:00
|
|
|
|
toggleMicrophone() {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.compositionApi.current.setShowMic(!this.hasFiles());
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
2019-08-06 19:18:37 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
captureAudio(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (this.hasFiles()) {
|
|
|
|
|
this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast);
|
2018-12-02 01:48:53 +00:00
|
|
|
|
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'));
|
2019-08-06 19:18:37 +00:00
|
|
|
|
this.compositionApi.current.setMicActive(true);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.disableMessageField();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.microphone').hide();
|
|
|
|
|
},
|
2019-08-07 00:40:25 +00:00
|
|
|
|
async handleAudioCapture(blob) {
|
|
|
|
|
if (this.hasFiles()) {
|
|
|
|
|
throw new Error('A voice note cannot be sent with other attachments');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await this.arrayBufferFromFile(blob);
|
|
|
|
|
|
|
|
|
|
// These aren't persisted to disk; they are meant to be sent immediately
|
|
|
|
|
this.voiceNoteAttachment = {
|
2018-12-02 01:48:53 +00:00
|
|
|
|
contentType: blob.type,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
|
|
|
|
flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
|
|
|
|
};
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.sendMessage();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
endCaptureAudio() {
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.enableMessageField();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.$('.microphone').show();
|
|
|
|
|
this.captureAudioView = null;
|
2019-08-06 19:18:37 +00:00
|
|
|
|
this.compositionApi.current.setMicActive(false);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
async onOpened(messageId) {
|
|
|
|
|
if (messageId) {
|
|
|
|
|
const message = await getMessageById(messageId, {
|
|
|
|
|
Message: Whisper.Message,
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
if (message) {
|
|
|
|
|
this.loadAndScroll(messageId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
window.log.warn(`onOpened: Did not find message ${messageId}`);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.loadNewestMessages();
|
2019-08-07 00:40:25 +00:00
|
|
|
|
|
2019-09-06 19:16:54 +00:00
|
|
|
|
this.focusMessageField();
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const quotedMessageId = this.model.get('quotedMessageId');
|
|
|
|
|
if (quotedMessageId) {
|
|
|
|
|
this.setQuoteMessage(quotedMessageId);
|
|
|
|
|
}
|
2019-09-06 19:16:54 +00:00
|
|
|
|
|
|
|
|
|
this.model.updateLastMessage();
|
|
|
|
|
|
|
|
|
|
const statusPromise = this.model.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;
|
|
|
|
|
})
|
|
|
|
|
);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
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-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
|
2019-06-21 21:06:37 +00:00
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2018-04-25 20:45:02 +00:00
|
|
|
|
|
2019-02-19 23:17:52 +00:00
|
|
|
|
const saveAttachment = async ({ attachment, message } = {}) => {
|
2019-07-15 18:45:15 +00:00
|
|
|
|
const timestamp = message.sent_at;
|
2019-02-19 23:17:52 +00:00
|
|
|
|
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({
|
2019-07-25 16:24:03 +00:00
|
|
|
|
className: 'panel',
|
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
|
|
|
|
focusMessageField() {
|
2018-07-20 23:37:57 +00:00
|
|
|
|
if (this.panels && this.panels.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
const { compositionApi } = this;
|
|
|
|
|
|
|
|
|
|
if (compositionApi && compositionApi.current) {
|
|
|
|
|
compositionApi.current.focusInput();
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
focusMessageFieldAndClearDisabled() {
|
2019-06-27 20:35:21 +00:00
|
|
|
|
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);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
async addMessage(message) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
// 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
|
|
|
|
},
|
|
|
|
|
|
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) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.showToast(Whisper.DangerousFileTypeToast);
|
2018-10-04 01:12:42 +00:00
|
|
|
|
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-06-26 19:33:13 +00:00
|
|
|
|
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(
|
2019-08-05 20:53:15 +00:00
|
|
|
|
`displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message`
|
2019-06-26 19:33:13 +00:00
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2019-08-05 20:53:15 +00:00
|
|
|
|
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`
|
|
|
|
|
);
|
2019-06-26 19:33:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
2019-08-05 20:53:15 +00:00
|
|
|
|
const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path);
|
|
|
|
|
const tempPath = await copyIntoTempDirectory(absolutePath);
|
|
|
|
|
const tempAttachment = {
|
|
|
|
|
...firstAttachment,
|
|
|
|
|
path: tempPath,
|
|
|
|
|
};
|
2019-06-26 19:33:13 +00:00
|
|
|
|
|
2019-08-05 20:53:15 +00:00
|
|
|
|
await message.markViewed();
|
|
|
|
|
|
|
|
|
|
const closeLightbox = async () => {
|
2019-06-26 19:33:13 +00:00
|
|
|
|
if (!this.lightboxView) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { lightboxView } = this;
|
|
|
|
|
this.lightboxView = null;
|
|
|
|
|
|
|
|
|
|
this.stopListening(message);
|
|
|
|
|
Signal.Backbone.Views.Lightbox.hide();
|
|
|
|
|
lightboxView.remove();
|
2019-08-05 20:53:15 +00:00
|
|
|
|
|
|
|
|
|
await deleteTempFile(tempPath);
|
2019-06-26 19:33:13 +00:00
|
|
|
|
};
|
|
|
|
|
this.listenTo(message, 'expired', closeLightbox);
|
|
|
|
|
this.listenTo(message, 'change', () => {
|
|
|
|
|
if (this.lightBoxView) {
|
|
|
|
|
this.lightBoxView.update(getProps());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const getProps = () => {
|
2019-08-05 20:53:15 +00:00
|
|
|
|
const { path, contentType } = tempAttachment;
|
2019-06-26 19:33:13 +00:00
|
|
|
|
|
|
|
|
|
return {
|
2019-08-05 20:53:15 +00:00
|
|
|
|
objectURL: getAbsoluteTempPath(path),
|
2019-06-26 19:33:13 +00:00
|
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
|
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-05-24 01:27:42 +00:00
|
|
|
|
showStickerPackPreview(packId, packKey) {
|
2019-05-16 22:14:06 +00:00
|
|
|
|
if (!window.ENABLE_STICKER_SEND) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-24 01:27:42 +00:00
|
|
|
|
window.Signal.Stickers.downloadEphemeralPack(packId, packKey);
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const props = {
|
|
|
|
|
packId,
|
2019-05-24 01:27:42 +00:00
|
|
|
|
onClose: async () => {
|
2019-05-16 22:32:11 +00:00
|
|
|
|
this.stickerPreviewModalView.remove();
|
2019-05-24 01:27:42 +00:00
|
|
|
|
this.stickerPreviewModalView = null;
|
|
|
|
|
await window.Signal.Stickers.removeEphemeralPack(packId);
|
2019-05-16 22:32:11 +00:00
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.stickerPreviewModalView = new Whisper.ReactWrapperView({
|
|
|
|
|
className: 'sticker-preview-modal-wrapper',
|
|
|
|
|
JSX: Signal.State.Roots.createStickerPreviewModal(
|
|
|
|
|
window.reduxStore,
|
|
|
|
|
props
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
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}`
|
|
|
|
|
);
|
|
|
|
|
}
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const sticker = message.get('sticker');
|
|
|
|
|
if (sticker) {
|
2019-05-24 01:27:42 +00:00
|
|
|
|
const { packId, packKey } = sticker;
|
|
|
|
|
this.showStickerPackPreview(packId, packKey);
|
2019-05-16 22:32:11 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2019-07-15 18:45:15 +00:00
|
|
|
|
onSave: () => {
|
|
|
|
|
const timestamp = message.get('sent_at');
|
|
|
|
|
this.downloadAttachment({ attachment, timestamp, message });
|
|
|
|
|
},
|
2018-11-14 18:47:19 +00:00
|
|
|
|
};
|
|
|
|
|
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({
|
2019-07-25 16:24:03 +00:00
|
|
|
|
className: 'panel message-detail-wrapper',
|
2018-07-09 21:29:13 +00:00
|
|
|
|
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-05-16 22:32:11 +00:00
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
|
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 || [];
|
|
|
|
|
this.panels.unshift(view);
|
2019-07-25 16:24:03 +00:00
|
|
|
|
view.$el.insertAfter(this.$('.panel').last());
|
|
|
|
|
view.$el.one('animationend', () => {
|
|
|
|
|
view.$el.addClass('panel--static');
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
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) {
|
2019-07-25 16:24:03 +00:00
|
|
|
|
this.panels[0].$el.fadeIn(250);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
2019-07-25 16:24:03 +00:00
|
|
|
|
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'));
|
|
|
|
|
}
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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 {
|
2019-04-04 22:33:04 +00:00
|
|
|
|
this.unload('delete messages');
|
2019-05-31 22:42:01 +00:00
|
|
|
|
await this.model.destroyMessages();
|
2019-08-20 19:34:52 +00:00
|
|
|
|
Whisper.events.trigger('unloadConversation', this.model.id);
|
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
|
|
|
|
},
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
showSendAnywayDialog(contacts) {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
let message;
|
|
|
|
|
const isUnverified = this.model.isUnverified();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
if (contacts.length > 1) {
|
|
|
|
|
if (isUnverified) {
|
|
|
|
|
message = i18n('changedSinceVerifiedMultiple');
|
|
|
|
|
} else {
|
|
|
|
|
message = i18n('changedRecentlyMultiple');
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
} else {
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const contactName = contacts.at(0).getTitle();
|
|
|
|
|
if (isUnverified) {
|
|
|
|
|
message = i18n('changedSinceVerified', [contactName, contactName]);
|
|
|
|
|
} else {
|
|
|
|
|
message = i18n('changedRecently', [contactName, contactName]);
|
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const dialog = new Whisper.ConfirmationDialogView({
|
|
|
|
|
message,
|
|
|
|
|
okText: i18n('sendAnyway'),
|
|
|
|
|
resolve: () => resolve(true),
|
|
|
|
|
reject: () => resolve(false),
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
this.$el.prepend(dialog.el);
|
|
|
|
|
dialog.focusCancel();
|
|
|
|
|
});
|
2018-04-17 21:03:31 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
async sendStickerMessage(options = {}) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
try {
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const contacts = await this.getUntrustedContacts(options);
|
|
|
|
|
|
|
|
|
|
if (contacts && contacts.length) {
|
|
|
|
|
const sendAnyway = await this.showSendAnywayDialog(contacts);
|
|
|
|
|
if (sendAnyway) {
|
|
|
|
|
this.sendStickerMessage({ ...options, force: true });
|
|
|
|
|
}
|
2016-04-16 02:17:16 +00:00
|
|
|
|
|
2018-04-17 21:03:31 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
2015-06-19 20:43:24 +00:00
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const { packId, stickerId } = options;
|
|
|
|
|
this.model.sendStickerMessage(packId, stickerId);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
} catch (error) {
|
2018-07-21 19:00:08 +00:00
|
|
|
|
window.log.error(
|
2019-05-16 22:32:11 +00:00
|
|
|
|
'clickSend error:',
|
2018-04-17 21:03:31 +00:00
|
|
|
|
error && error.stack ? error.stack : error
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
|
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
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const existing = this.model.get('quotedMessageId');
|
|
|
|
|
if (existing !== messageId) {
|
|
|
|
|
this.model.set({
|
|
|
|
|
quotedMessageId: messageId,
|
2019-09-14 00:10:00 +00:00
|
|
|
|
draftChanged: true,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
});
|
2019-08-15 14:59:56 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
await this.saveModel();
|
|
|
|
|
}
|
|
|
|
|
|
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-08-07 00:40:25 +00:00
|
|
|
|
if (messageId) {
|
|
|
|
|
const model = await getMessageById(messageId, {
|
|
|
|
|
Message: Whisper.Message,
|
|
|
|
|
});
|
|
|
|
|
if (model) {
|
|
|
|
|
const message = MessageController.register(model.id, model);
|
|
|
|
|
this.quotedMessage = message;
|
2018-04-20 19:04:48 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (message) {
|
|
|
|
|
const quote = await this.model.makeQuote(this.quotedMessage);
|
|
|
|
|
this.quote = quote;
|
2019-03-15 22:18:00 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
}
|
2019-03-15 22:18:00 +00:00
|
|
|
|
}
|
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) {
|
|
|
|
|
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
|
|
|
|
|
2019-05-31 22:42:01 +00:00
|
|
|
|
this.listenTo(message, 'scroll-to-message', () => {
|
|
|
|
|
this.scrollToMessage(message.quotedMessage.id);
|
|
|
|
|
});
|
2018-04-18 20:06:33 +00:00
|
|
|
|
|
|
|
|
|
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-08-06 19:18:37 +00:00
|
|
|
|
elCallback: el =>
|
|
|
|
|
this.$(this.compositionApi.current.attSlotRef.current).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);
|
|
|
|
|
},
|
|
|
|
|
}),
|
2018-04-18 20:06:33 +00:00
|
|
|
|
});
|
2018-04-17 21:31:16 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2018-11-14 19:10:32 +00:00
|
|
|
|
this.model.clearTypingTimers();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
let ToastView;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (extension.expired()) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
ToastView = Whisper.ExpiredToast;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
|
|
|
|
if (this.model.isPrivate() && storage.isBlocked(this.model.id)) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
ToastView = Whisper.BlockedToast;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
2018-09-13 19:57:07 +00:00
|
|
|
|
if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
ToastView = Whisper.BlockedGroupToast;
|
2018-09-13 19:57:07 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
if (!this.model.isPrivate() && this.model.get('left')) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
ToastView = Whisper.LeftGroupToast;
|
2018-04-17 21:03:31 +00:00
|
|
|
|
}
|
2019-04-12 21:54:45 +00:00
|
|
|
|
if (message.length > MAX_MESSAGE_BODY_LENGTH) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
ToastView = Whisper.MessageBodyTooLongToast;
|
2019-04-12 21:54:45 +00:00
|
|
|
|
}
|
2018-04-17 21:03:31 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (ToastView) {
|
|
|
|
|
this.showToast(ToastView);
|
2018-04-17 21:03:31 +00:00
|
|
|
|
this.focusMessageFieldAndClearDisabled();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (!message.length && !this.hasFiles() && !this.voiceNoteAttachment) {
|
2018-04-17 21:03:31 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
2015-09-16 03:50:00 +00:00
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const attachments = await this.getFiles();
|
2018-04-17 21:03:31 +00:00
|
|
|
|
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
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.compositionApi.current.reset();
|
2018-04-19 18:19:23 +00:00
|
|
|
|
this.setQuoteMessage(null);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.resetLinkPreview();
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.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-06-27 20:35:21 +00:00
|
|
|
|
onEditorStateChange(messageText, caretLocation) {
|
|
|
|
|
this.maybeBumpTyping(messageText);
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.debouncedSaveDraft(messageText);
|
2019-06-27 20:35:21 +00:00
|
|
|
|
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-08-07 00:40:25 +00:00
|
|
|
|
async saveDraft(messageText) {
|
2019-08-20 19:34:52 +00:00
|
|
|
|
const trimmed =
|
|
|
|
|
messageText && messageText.length > 0 ? messageText.trim() : '';
|
|
|
|
|
|
2019-09-06 19:16:54 +00:00
|
|
|
|
if (this.model.get('draft') && (!messageText || trimmed.length === 0)) {
|
2019-08-07 00:40:25 +00:00
|
|
|
|
this.model.set({
|
|
|
|
|
draft: null,
|
2019-09-14 00:10:00 +00:00
|
|
|
|
draftChanged: true,
|
2019-08-07 00:40:25 +00:00
|
|
|
|
});
|
|
|
|
|
await this.saveModel();
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-09-14 00:10:00 +00:00
|
|
|
|
if (messageText !== this.model.get('draft')) {
|
|
|
|
|
this.model.set({
|
|
|
|
|
draft: messageText,
|
|
|
|
|
draftChanged: true,
|
|
|
|
|
});
|
|
|
|
|
await this.saveModel();
|
|
|
|
|
}
|
2019-08-07 00:40:25 +00:00
|
|
|
|
},
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
maybeGrabLinkPreview(message, caretLocation) {
|
2019-01-16 03:03:56 +00:00
|
|
|
|
// 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
|
2019-08-07 00:40:25 +00:00
|
|
|
|
if (this.hasFiles()) {
|
2019-01-16 03:03:56 +00:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// If we're behind a user-configured proxy, we don't support link previews
|
|
|
|
|
if (window.isBehindProxy()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-06-27 20:35:21 +00:00
|
|
|
|
if (!message) {
|
2019-01-16 03:03:56 +00:00
|
|
|
|
this.resetLinkPreview();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this.disableLinkPreviews) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2019-02-11 23:10:32 +00:00
|
|
|
|
const links = window.Signal.LinkPreviews.findLinks(
|
2019-06-27 20:35:21 +00:00
|
|
|
|
message,
|
2019-02-11 23:10:32 +00:00
|
|
|
|
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;
|
2019-05-16 22:32:11 +00:00
|
|
|
|
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
|
|
|
|
|
);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
|
|
|
|
|
let results = [];
|
|
|
|
|
const jobs = chunks.map(chunk => async () => {
|
|
|
|
|
const { start, end } = chunk;
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const jobResult = await textsecure.messaging.makeProxiedRequest(url, {
|
2019-01-16 03:03:56 +00:00
|
|
|
|
start,
|
|
|
|
|
end,
|
|
|
|
|
returnArrayBuffer: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...chunk,
|
2019-05-16 22:32:11 +00:00
|
|
|
|
...jobResult.result,
|
2019-01-16 03:03:56 +00:00
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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];
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const data = Signal.LinkPreviews.assembleChunks(
|
|
|
|
|
[firstChunk].concat(results)
|
|
|
|
|
);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
contentType,
|
|
|
|
|
data,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
2019-05-16 22:32:11 +00:00
|
|
|
|
async getStickerPackPreview(url) {
|
2019-05-24 01:27:42 +00:00
|
|
|
|
const isPackDownloaded = pack =>
|
|
|
|
|
pack && (pack.status === 'downloaded' || pack.status === 'installed');
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const isPackValid = pack =>
|
2019-05-24 01:27:42 +00:00
|
|
|
|
pack &&
|
|
|
|
|
(pack.status === 'ephemeral' ||
|
|
|
|
|
pack.status === 'downloaded' ||
|
|
|
|
|
pack.status === 'installed');
|
|
|
|
|
|
|
|
|
|
let id;
|
|
|
|
|
let key;
|
2019-05-16 22:32:11 +00:00
|
|
|
|
|
|
|
|
|
try {
|
2019-05-24 01:27:42 +00:00
|
|
|
|
({ id, key } = window.Signal.Stickers.getDataFromLink(url));
|
2019-05-16 22:32:11 +00:00
|
|
|
|
const keyBytes = window.Signal.Crypto.bytesFromHexString(key);
|
|
|
|
|
const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes);
|
|
|
|
|
|
|
|
|
|
const existing = window.Signal.Stickers.getStickerPack(id);
|
2019-05-24 01:27:42 +00:00
|
|
|
|
if (!isPackDownloaded(existing)) {
|
|
|
|
|
await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64);
|
2019-05-16 22:32:11 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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];
|
2019-05-24 01:27:42 +00:00
|
|
|
|
const data =
|
|
|
|
|
pack.status === 'ephemeral'
|
|
|
|
|
? await window.Signal.Migrations.readTempData(sticker.path)
|
|
|
|
|
: await window.Signal.Migrations.readStickerData(sticker.path);
|
2019-05-16 22:32:11 +00:00
|
|
|
|
|
|
|
|
|
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;
|
2019-05-24 01:27:42 +00:00
|
|
|
|
} finally {
|
|
|
|
|
if (id) {
|
|
|
|
|
await window.Signal.Stickers.removeEphemeralPack(id);
|
|
|
|
|
}
|
2019-05-16 22:32:11 +00:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2019-01-16 03:03:56 +00:00
|
|
|
|
async getPreview(url) {
|
2019-05-16 22:32:11 +00:00
|
|
|
|
if (window.Signal.LinkPreviews.isStickerPack(url)) {
|
|
|
|
|
return this.getStickerPackPreview(url);
|
|
|
|
|
}
|
|
|
|
|
|
2019-01-16 03:03:56 +00:00
|
|
|
|
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}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2019-08-22 21:11:36 +00:00
|
|
|
|
const chunked = await this.makeChunkedRequest(imageUrl);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
|
|
|
|
|
// Ensure that this file is either small enough or is resized to meet our
|
|
|
|
|
// requirements for attachments
|
2019-08-07 00:40:25 +00:00
|
|
|
|
const withBlob = await this.autoScale({
|
2019-08-22 21:11:36 +00:00
|
|
|
|
contentType: chunked.contentType,
|
|
|
|
|
file: new Blob([chunked.data], {
|
|
|
|
|
type: chunked.contentType,
|
2019-01-16 03:03:56 +00:00
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
2019-08-22 21:11:36 +00:00
|
|
|
|
const data = await this.arrayBufferFromFile(withBlob.file);
|
2019-01-16 03:03:56 +00:00
|
|
|
|
objectUrl = URL.createObjectURL(withBlob.file);
|
|
|
|
|
|
|
|
|
|
const dimensions = await Signal.Types.VisualAttachment.getImageDimensions(
|
|
|
|
|
{
|
|
|
|
|
objectUrl,
|
|
|
|
|
logger: window.log,
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
image = {
|
2019-08-22 21:11:36 +00:00
|
|
|
|
data,
|
|
|
|
|
size: data.byteLength,
|
2019-01-16 03:03:56 +00:00
|
|
|
|
...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,
|
2019-08-06 19:18:37 +00:00
|
|
|
|
elCallback: el =>
|
|
|
|
|
this.$(this.compositionApi.current.attSlotRef.current).prepend(el),
|
2019-01-16 03:03:56 +00:00
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
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.
|
2019-06-27 20:35:21 +00:00
|
|
|
|
maybeBumpTyping(messageText) {
|
2018-11-14 19:10:32 +00:00
|
|
|
|
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
|
|
|
|
});
|
2018-04-27 21:25:04 +00:00
|
|
|
|
})();
|