signal-desktop/ts/views/conversation_view.ts

4750 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import nodePath from 'path';
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom';
import { debounce, flatten, omit, pick, reject, throttle } from 'lodash';
import { render } from 'mustache';
import {
AttachmentDraftType,
AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
isGIF,
} from '../types/Attachment';
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
import * as Stickers from '../types/Stickers';
import { BodyRangeType, BodyRangesType } from '../types/Util';
import {
IMAGE_JPEG,
IMAGE_WEBP,
isHeic,
MIMEType,
stringToMIMEType,
} from '../types/MIME';
import { ConversationModel } from '../models/conversations';
import {
GroupV2PendingMemberType,
MessageModelCollectionType,
MessageAttributesType,
ConversationModelCollectionType,
QuotedMessageType,
} from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import {
MediaItemType,
MessageAttributesType as MediaItemMessageType,
} from '../types/MediaItem';
import { MessageModel } from '../models/messages';
import { strictAssert } from '../util/assert';
import { maybeParseUrl } from '../util/url';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
import {
isDirectConversation,
isGroupV1,
isMe,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
import * as Bytes from '../Bytes';
import {
canReply,
getAttachmentsForMessage,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
import { isMessageUnread } from '../util/isMessageUnread';
import {
getConversationSelector,
getMessagesByConversation,
} from '../state/selectors/conversations';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
import {
LinkPreviewResult,
LinkPreviewImage,
LinkPreviewWithDomain,
} from '../types/LinkPreview';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import {
autoScale,
handleImageAttachment,
} from '../util/handleImageAttachment';
import { ReadStatus } from '../messages/MessageReadStatus';
import { markViewed } from '../services/MessageUpdater';
import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue';
import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d';
import { isNotNil } from '../util/isNotNil';
import { dropNull } from '../util/dropNull';
import { CompositionAPIType } from '../components/CompositionArea';
type AttachmentOptions = {
messageId: string;
attachment: AttachmentType;
};
const FIVE_MINUTES = 1000 * 60 * 5;
const LINK_PREVIEW_TIMEOUT = 60 * 1000;
window.Whisper = window.Whisper || {};
const { Whisper } = window;
const { Message, MIME, VisualAttachment } = window.Signal.Types;
const {
copyIntoTempDirectory,
deleteDraftFile,
deleteTempFile,
getAbsoluteAttachmentPath,
getAbsoluteDraftPath,
getAbsoluteTempPath,
loadAttachmentData,
loadPreviewData,
loadStickerData,
openFileInFolder,
readAttachmentData,
readDraftData,
saveAttachmentToDisk,
upgradeMessageSchema,
writeNewDraftData,
} = window.Signal.Migrations;
const {
getOlderMessagesByConversation,
getMessageMetricsForConversation,
getMessageById,
getMessagesBySentAt,
getNewerMessagesByConversation,
} = window.Signal.Data;
type MessageActionsType = {
deleteMessage: (messageId: string) => unknown;
deleteMessageForEveryone: (messageId: string) => unknown;
displayTapToViewMessage: (messageId: string) => unknown;
downloadAttachment: (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => unknown;
downloadNewVersion: () => unknown;
kickOffAttachmentDownload: (
options: Readonly<{ messageId: string }>
) => unknown;
markAttachmentAsCorrupted: (options: AttachmentOptions) => unknown;
markViewed: (messageId: string) => unknown;
openConversation: (conversationId: string, messageId?: string) => unknown;
openLink: (url: string) => unknown;
reactToMessage: (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => unknown;
replyToMessage: (messageId: string) => unknown;
retrySend: (messageId: string) => unknown;
showContactDetail: (options: {
contact: EmbeddedContactType;
signalAccount?: string;
}) => unknown;
showContactModal: (contactId: string) => unknown;
showSafetyNumber: (contactId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
showForwardMessageModal: (messageId: string) => unknown;
showIdentity: (conversationId: string) => unknown;
showMessageDetail: (messageId: string) => unknown;
showVisualAttachment: (options: {
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) => unknown;
};
type MediaType = {
path: string;
objectURL: string;
thumbnailObjectUrl?: string;
contentType: MIMEType;
index: number;
attachment: AttachmentType;
message: {
attachments: Array<AttachmentType>;
conversationId: string;
id: string;
// eslint-disable-next-line camelcase
received_at: number;
// eslint-disable-next-line camelcase
received_at_ms: number;
// eslint-disable-next-line camelcase
sent_at: number;
};
};
Whisper.ExpiredToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('expiredWarning') };
},
});
Whisper.BlockedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('unblockToSend') };
},
});
Whisper.BlockedGroupToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('unblockGroupToSend') };
},
});
Whisper.CaptchaSolvedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('verificationComplete') };
},
});
Whisper.CaptchaFailedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('verificationFailed') };
},
});
Whisper.LeftGroupToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('youLeftTheGroup') };
},
});
Whisper.InvalidConversationToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('invalidConversation') };
},
});
Whisper.OriginalNotFoundToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('originalMessageNotFound') };
},
});
Whisper.OriginalNoLongerAvailableToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('originalMessageNotAvailable') };
},
});
Whisper.FoundButNotLoadedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('messageFoundButNotLoaded') };
},
});
Whisper.VoiceNoteLimit = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('voiceNoteLimit') };
},
});
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('voiceNoteMustBeOnlyAttachment') };
},
});
Whisper.ConversationArchivedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('conversationArchived') };
},
});
Whisper.ConversationUnarchivedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('conversationReturnedToInbox') };
},
});
Whisper.ConversationMarkedUnreadToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('conversationMarkedUnread') };
},
});
Whisper.TapToViewExpiredIncomingToast = Whisper.ToastView.extend({
render_attributes() {
return {
toastMessage: window.i18n(
'Message--tap-to-view--incoming--expired-toast'
),
};
},
});
Whisper.TapToViewExpiredOutgoingToast = Whisper.ToastView.extend({
render_attributes() {
return {
toastMessage: window.i18n(
'Message--tap-to-view--outgoing--expired-toast'
),
};
},
});
Whisper.DecryptionErrorToast = Whisper.ToastView.extend({
className: 'toast toast-clickable decryption-error',
events: {
click: 'onClick',
keydown: 'onKeyDown',
},
render_attributes() {
return {
toastMessage: window.i18n('decryptionErrorToast'),
};
},
// Note: this is the same thing as ToastView, except it's missing the setTimeout, so it
// will stick around until the user interacts with it.
render() {
const toasts = document.getElementsByClassName('decryption-error');
if (toasts.length > 1) {
window.log.info(
'DecryptionErrorToast: We are second decryption error toast. Closing.'
);
this.close();
return;
}
this.$el.html(
window.Mustache.render(
window._.result(this, 'template', ''),
window._.result(this, 'render_attributes', '')
)
);
this.$el.attr('tabIndex', 0);
this.$el.show();
if (window.getInteractionMode() === 'keyboard') {
setTimeout(() => {
this.$el.focus();
}, 1);
}
},
onClick() {
this.close();
window.showDebugLog();
},
onKeyDown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
this.onClick();
},
});
Whisper.FileSavedToast = Whisper.ToastView.extend({
className: 'toast toast-clickable',
initialize(options: Readonly<{ fullPath: string }>) {
if (!options.fullPath) {
throw new Error('FileSavedToast: name option was not provided!');
}
this.fullPath = options.fullPath;
this.timeout = 10000;
if (window.getInteractionMode() === 'keyboard') {
setTimeout(() => {
this.$el.focus();
}, 1);
}
},
events: {
click: 'onClick',
keydown: 'onKeydown',
},
onClick() {
openFileInFolder(this.fullPath);
this.close();
},
onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
openFileInFolder(this.fullPath);
this.close();
},
render_attributes() {
return { toastMessage: window.i18n('attachmentSaved') };
},
});
Whisper.ReactionFailedToast = Whisper.ToastView.extend({
className: 'toast toast-clickable',
initialize() {
this.timeout = 4000;
if (window.getInteractionMode() === 'keyboard') {
setTimeout(() => {
this.$el.focus();
}, 1);
}
},
events: {
click: 'onClick',
keydown: 'onKeydown',
},
onClick() {
this.close();
},
onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
this.close();
},
render_attributes() {
return { toastMessage: window.i18n('Reactions--error') };
},
});
Whisper.DeleteForEveryoneFailedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('deleteForEveryoneFailed') };
},
});
Whisper.GroupLinkCopiedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('GroupLinkManagement--clipboard') };
},
});
Whisper.PinnedConversationsFullToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('pinnedConversationsFull') };
},
});
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('messageBodyTooLong') };
},
});
Whisper.FileSizeToast = Whisper.ToastView.extend({
template: () => $('#file-size-modal').html(),
render_attributes() {
return {
'file-size-warning': window.i18n('fileSizeWarning'),
limit: this.model.limit,
units: this.model.units,
};
},
});
Whisper.UnableToLoadToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: window.i18n('unableToLoadAttachment') };
},
});
Whisper.CannotStartGroupCallToast = Whisper.ToastView.extend({
template: () => window.i18n('GroupV2--cannot-start-group-call'),
});
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: () => window.i18n('dangerousFileType'),
});
Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({
template: () => window.i18n('oneNonImageAtATimeToast'),
});
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
template: () => window.i18n('cannotMixImageAndNonImageAttachments'),
});
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: () => window.i18n('maximumAttachments'),
});
Whisper.AlreadyGroupMemberToast = Whisper.ToastView.extend({
template: () => window.i18n('GroupV2--join--already-in-group'),
});
Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({
template: () => window.i18n('GroupV2--join--already-awaiting-approval'),
});
const ReportedSpamAndBlockedToast = Whisper.ToastView.extend({
template: () =>
window.i18n('MessageRequests--block-and-report-spam-success-toast'),
});
export class ConversationView extends window.Backbone.View<ConversationModel> {
// Debounced functions
private debouncedMaybeGrabLinkPreview: (
message: string,
caretLocation?: number
) => void;
private debouncedSaveDraft: (
messageText: string,
bodyRanges: Array<BodyRangeType>
) => Promise<void>;
private lazyUpdateVerified: () => void;
// Composing messages
private compositionApi: {
current?: CompositionAPIType;
} = { current: undefined };
private sendStart?: number;
private voiceNoteAttachment?: AttachmentType;
// Quotes
private quote?: QuotedMessageType;
private quotedMessage?: MessageModel;
// Previews
private currentlyMatchedLink?: string;
private disableLinkPreviews?: boolean;
private excludedPreviewUrls: Array<string> = [];
private linkPreviewAbortController?: AbortController;
private preview?: Array<LinkPreviewResult>;
// Sub-views
private captionEditorView?: Backbone.View;
private captureAudioView?: Backbone.View;
private compositionAreaView?: Backbone.View;
private contactModalView?: Backbone.View;
private forwardMessageModal?: Backbone.View;
private lightboxView?: BasicReactWrapperViewClass;
private migrationDialog?: Backbone.View;
private stickerPreviewModalView?: Backbone.View;
private timelineView?: Backbone.View;
private titleView?: Backbone.View;
// Panel support
private panels: Array<AnyViewClass> = [];
private previousFocus?: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: Array<any>) {
super(...args);
this.lazyUpdateVerified = debounce(
this.model.updateVerified.bind(this.model),
1000 // one second
);
this.model.throttledGetProfiles =
this.model.throttledGetProfiles ||
throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES);
this.debouncedMaybeGrabLinkPreview = debounce(
this.maybeGrabLinkPreview.bind(this),
200
);
this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200);
// Events on Conversation model
this.listenTo(this.model, 'destroy', this.stopListening);
this.listenTo(this.model, 'newmessage', this.lazyUpdateVerified);
// These are triggered by InboxView
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage);
this.listenTo(this.model, 'unload', (reason: string) =>
this.unload(`model trigger - ${reason}`)
);
// These are triggered by background.ts for keyboard handling
this.listenTo(this.model, 'focus-composer', this.focusMessageField);
this.listenTo(this.model, 'open-all-media', this.showAllMedia);
this.listenTo(this.model, 'begin-recording', this.captureAudio);
this.listenTo(this.model, 'attach-file', this.onChooseAttachment);
this.listenTo(this.model, 'escape-pressed', this.resetPanel);
this.listenTo(this.model, 'show-message-details', this.showMessageDetail);
this.listenTo(this.model, 'show-contact-modal', this.showContactModal);
this.listenTo(
this.model,
'toggle-reply',
(messageId: string | undefined) => {
const target = this.quote || !messageId ? null : messageId;
this.setQuoteMessage(target);
}
);
this.listenTo(
this.model,
'save-attachment',
this.downloadAttachmentWrapper
);
this.listenTo(this.model, 'delete-message', this.deleteMessage);
this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview);
this.listenTo(
this.model,
'remove-all-draft-attachments',
this.clearAttachments
);
this.render();
this.setupHeader();
this.setupTimeline();
this.setupCompositionArea();
this.updateAttachmentsView();
}
// eslint-disable-next-line class-methods-use-this
events(): Record<string, string> {
return {
'click .capture-audio .microphone': 'captureAudio',
'change input.file-input': 'onChoseAttachment',
drop: 'onDrop',
paste: 'onPaste',
};
}
// We need this ignore because the backbone types really want this to be a string
// property, but the property isn't set until after super() is run, meaning that this
// classname wouldn't be applied when Backbone creates our el.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line class-methods-use-this
className(): string {
return 'conversation';
}
// Same situation as className().
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line class-methods-use-this
id(): string {
return `conversation-${this.model.cid}`;
}
// Backbone.View<ConversationModel> is demanded as the return type here, and we can't
// satisfy it because of the above difference in signature: className is a function
// when it should be a plain string property.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
render(): ConversationView {
const template = $('#conversation').html();
this.$el.html(render(template, {}));
return this;
}
getMuteExpirationLabel(): string | undefined {
const muteExpiresAt = this.model.get('muteExpiresAt');
if (!this.model.isMuted()) {
return;
}
const today = window.moment(Date.now());
const expires = window.moment(muteExpiresAt);
if (today.isSame(expires, 'day')) {
// eslint-disable-next-line consistent-return
return expires.format('hh:mm A');
}
// eslint-disable-next-line consistent-return
return expires.format('M/D/YY, hh:mm A');
}
setMuteExpiration(ms = 0): void {
this.model.setMuteExpiration(
ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms
);
}
setPin(value: boolean): void {
if (value) {
const pinnedConversationIds = window.storage.get(
'pinnedConversationIds',
new Array<string>()
);
if (pinnedConversationIds.length >= 4) {
this.showToast(Whisper.PinnedConversationsFullToast);
return;
}
this.model.pin();
} else {
this.model.unpin();
}
}
setupHeader(): void {
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
JSX: window.Signal.State.Roots.createConversationHeader(
window.reduxStore,
{
id: this.model.id,
onShowContactModal: this.showContactModal.bind(this),
onSetDisappearingMessages: (seconds: number) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
const name = isMe(this.model.attributes)
? window.i18n('noteToSelf')
: this.model.getTitle();
searchInConversation(this.model.id, name);
},
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
onOutgoingAudioCallInConversation: async () => {
window.log.info(
'onOutgoingAudioCallInConversation: about to start an audio call'
);
const isVideoCall = false;
if (await this.isCallSafe()) {
window.log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
window.log.info(
'onOutgoingAudioCallInConversation: started the call'
);
} else {
window.log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onOutgoingVideoCallInConversation: async () => {
window.log.info(
'onOutgoingVideoCallInConversation: about to start a video call'
);
const isVideoCall = true;
if (
this.model.get('announcementsOnly') &&
!this.model.areWeAdmin()
) {
this.showToast(Whisper.CannotStartGroupCallToast);
return;
}
if (await this.isCallSafe()) {
window.log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
window.log.info(
'onOutgoingVideoCallInConversation: started the call'
);
} else {
window.log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onShowChatColorEditor: () => {
this.showChatColorEditor();
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
onShowSafetyNumber: () => {
this.showSafetyNumber();
},
onShowAllMedia: () => {
this.showAllMedia();
},
onShowGroupMembers: () => {
this.showGV1Members();
},
onGoBack: () => {
this.resetPanel();
},
onArchive: () => {
this.model.setArchived(true);
this.model.trigger('unload', 'archive');
Whisper.ToastView.show(
Whisper.ConversationArchivedToast,
document.body
);
},
onMarkUnread: () => {
this.model.setMarkedUnread(true);
Whisper.ToastView.show(
Whisper.ConversationMarkedUnreadToast,
document.body
);
},
onMoveToInbox: () => {
this.model.setArchived(false);
Whisper.ToastView.show(
Whisper.ConversationUnarchivedToast,
document.body
);
},
}
),
});
this.$('.conversation-header').append(this.titleView.el);
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
}
setupCompositionArea(): void {
window.reduxActions.composer.resetComposer();
const micCellEl = $(`
<div class="capture-audio">
<button class="microphone"></button>
</div>
`)[0];
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const props = {
id: this.model.id,
compositionApi: this.compositionApi,
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId: string, stickerId: number) =>
this.sendStickerMessage({ packId, stickerId }),
onSubmit: (
message: string,
mentions: BodyRangesType,
timestamp: number
) => this.sendMessage(message, mentions, { timestamp }),
onEditorStateChange: (
msg: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
onChooseAttachment: this.onChooseAttachment.bind(this),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl,
onAccept: () => {
this.syncMessageRequestResponse(
'onAccept',
this.model,
messageRequestEnum.ACCEPT
);
},
onBlock: () => {
this.syncMessageRequestResponse(
'onBlock',
this.model,
messageRequestEnum.BLOCK
);
},
onUnblock: () => {
this.syncMessageRequestResponse(
'onUnblock',
this.model,
messageRequestEnum.ACCEPT
);
},
onDelete: () => {
this.syncMessageRequestResponse(
'onDelete',
this.model,
messageRequestEnum.DELETE
);
},
onBlockAndReportSpam: () => {
this.blockAndReportSpam(this.model);
},
onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
message: window.i18n(
'GroupV2--join--cancel-request-to-join--confirmation'
),
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'onCancelJoinRequest',
task: async () => this.model.cancelJoinRequest(),
});
},
});
},
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.removeDraftAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
onClickQuotedMessage: (id: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
openConversation: this.openConversation.bind(this),
};
this.compositionAreaView = new Whisper.ReactWrapperView({
className: 'composition-area-wrapper',
JSX: window.Signal.State.Roots.createCompositionArea(
window.reduxStore,
props
),
});
// Finally, add it to the DOM
this.$('.CompositionArea__placeholder').append(this.compositionAreaView.el);
}
async longRunningTaskWrapper<T>({
name,
task,
}: {
name: string;
task: () => Promise<T>;
}): Promise<T> {
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
}
getMessageActions(): MessageActionsType {
const reactToMessage = (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => {
this.sendReactionMessage(messageId, reaction);
};
const replyToMessage = (messageId: string) => {
this.setQuoteMessage(messageId);
};
const retrySend = (messageId: string) => {
this.retrySend(messageId);
};
const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId);
};
const deleteMessageForEveryone = (messageId: string) => {
this.deleteMessageForEveryone(messageId);
};
const showMessageDetail = (messageId: string) => {
this.showMessageDetail(messageId);
};
const showContactModal = (contactId: string) => {
this.showContactModal(contactId);
};
const openConversation = (conversationId: string, messageId?: string) => {
this.openConversation(conversationId, messageId);
};
const showContactDetail = (options: {
contact: EmbeddedContactType;
signalAccount?: string;
}) => {
this.showContactDetail(options);
};
const kickOffAttachmentDownload = async (
options: Readonly<{ messageId: string }>
) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
);
}
await message.queueAttachmentDownloads();
};
const markAttachmentAsCorrupted = (options: AttachmentOptions) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!`
);
}
message.markAttachmentAsCorrupted(options.attachment);
};
const onMarkViewed = (messageId: string): void => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`onMarkViewed: Message ${messageId} missing!`);
}
if (message.get('readStatus') === ReadStatus.Viewed) {
return;
}
const senderE164 = message.get('source');
const senderUuid = message.get('sourceUuid');
const timestamp = message.get('sent_at');
message.set(markViewed(message.attributes, Date.now()));
viewedReceiptsJobQueue.add({
viewedReceipt: {
messageId,
senderE164,
senderUuid,
timestamp,
},
});
viewSyncJobQueue.add({
viewSyncs: [
{
messageId,
senderE164,
senderUuid,
timestamp,
},
],
});
};
const showVisualAttachment = (options: {
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) => {
this.showLightbox(options);
};
const downloadAttachment = (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => {
this.downloadAttachment(options);
};
const displayTapToViewMessage = (messageId: string) =>
this.displayTapToViewMessage(messageId);
const showIdentity = (conversationId: string) => {
this.showSafetyNumber(conversationId);
};
const openLink = (url: string) => {
this.navigateTo(url);
};
const downloadNewVersion = () => {
this.downloadNewVersion();
};
const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId);
};
const showExpiredIncomingTapToViewToast = () => {
window.log.info(
'Showing expired tap-to-view toast for an incoming message'
);
this.showToast(Whisper.TapToViewExpiredIncomingToast);
};
const showExpiredOutgoingTapToViewToast = () => {
window.log.info(
'Showing expired tap-to-view toast for an outgoing message'
);
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
};
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
return {
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
downloadNewVersion,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation,
openLink,
reactToMessage,
replyToMessage,
retrySend,
showContactDetail,
showContactModal,
showSafetyNumber,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
};
}
setupTimeline(): void {
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const contactSupport = () => {
const baseUrl =
'https://support.signal.org/hc/LOCALE/requests/new?desktop&chat_refreshed';
const locale = window.getLocale();
const supportLocale = window.Signal.Util.mapToSupportLocale(locale);
const url = baseUrl.replace('LOCALE', supportLocale);
this.navigateTo(url);
};
const learnMoreAboutDeliveryIssue = () => {
this.navigateTo('https://support.signal.org/hc/articles/4404859745690');
};
const scrollToQuotedMessage = async (
options: Readonly<{
authorId: string;
sentAt: number;
}>
) => {
const { authorId, sentAt } = options;
const conversationId = this.model.id;
const messages = await getMessagesBySentAt(sentAt, {
MessageCollection: Whisper.MessageCollection,
});
const message = messages.find(item =>
Boolean(
item.get('conversationId') === conversationId &&
authorId &&
item.getContactId() === authorId
)
);
if (!message) {
this.showToast(Whisper.OriginalNotFoundToast);
return;
}
this.scrollToMessage(message.id);
};
const loadOlderMessages = async (oldestMessageId: string) => {
const {
messagesAdded,
setMessagesLoading,
repairOldestMessage,
} = 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 sentAt = message.get('sent_at');
const models = await getOlderMessagesByConversation(conversationId, {
receivedAt,
sentAt,
messageId: oldestMessageId,
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
if (models.length < 1) {
window.log.warn(
'loadOlderMessages: requested, but loaded no messages'
);
repairOldestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
this.model.id,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
} catch (error) {
setMessagesLoading(conversationId, true);
throw error;
} finally {
finish();
}
};
const loadNewerMessages = async (newestMessageId: string) => {
const {
messagesAdded,
setMessagesLoading,
repairNewestMessage,
} = 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 sentAt = message.get('sent_at');
const models = await getNewerMessagesByConversation(conversationId, {
receivedAt,
sentAt,
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
if (models.length < 1) {
window.log.warn(
'loadNewerMessages: requested, but loaded no messages'
);
repairNewestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
};
const markMessageRead = async (messageId: string) => {
if (!window.isActive()) {
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'));
};
const createMessageRequestResponseHandler = (
name: string,
enumValue: number
): ((conversationId: string) => void) => conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.error(
`createMessageRequestResponseHandler: Expected a conversation to be found in ${name}. Doing nothing`
);
return;
}
this.syncMessageRequestResponse(name, conversation, enumValue);
};
this.timelineView = new Whisper.ReactWrapperView({
className: 'timeline-wrapper',
JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, {
id: this.model.id,
...this.getMessageActions(),
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages,
loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages,
markMessageRead,
onBlock: createMessageRequestResponseHandler(
'onBlock',
messageRequestEnum.BLOCK
),
onBlockAndReportSpam: (conversationId: string) => {
const conversation = window.ConversationController.get(
conversationId
);
if (!conversation) {
window.log.error(
`onBlockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
);
return;
}
this.blockAndReportSpam(conversation);
},
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
messageRequestEnum.ACCEPT
),
onShowContactModal: this.showContactModal.bind(this),
removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({
name: 'removeMember',
task: () => this.model.removeFromGroupV2(conversationId),
});
},
scrollToQuotedMessage,
unblurAvatar: () => {
this.model.unblurAvatar();
},
updateSharedGroups: this.model.throttledUpdateSharedGroups,
}),
});
this.$('.timeline-placeholder').append(this.timelineView.el);
}
private showToast(
ToastView: typeof window.Whisper.ToastView,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: any,
element?: Element
): void {
const toast = new ToastView(options);
if (element) {
toast.$el.appendTo(element);
} else {
const lightboxEl = $('.Lightbox');
if (lightboxEl.length > 0) {
toast.$el.appendTo(lightboxEl);
} else {
toast.$el.appendTo(this.$el);
}
}
toast.render();
}
// eslint-disable-next-line class-methods-use-this
async cleanModels(
collection: MessageModelCollectionType | Array<MessageModel>
): Promise<Array<MessageModel>> {
const result = collection
.filter((message: MessageModel) => Boolean(message.id))
.map((message: MessageModel) =>
window.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);
}
}
return result;
}
async scrollToMessage(messageId: string): Promise<void> {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
});
if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`);
}
const state = window.reduxStore.getState();
let isInMemory = true;
if (!window.MessageController.getById(messageId)) {
isInMemory = false;
}
// Message might be in memory, but not in the redux anymore because
// we call `messageReset()` in `loadAndScroll()`.
const messagesByConversation = getMessagesByConversation(state)[
this.model.id
];
if (!messagesByConversation?.messageIds.includes(messageId)) {
isInMemory = false;
}
if (isInMemory) {
const { scrollToMessage } = window.reduxActions.conversations;
scrollToMessage(this.model.id, messageId);
return;
}
this.loadAndScroll(messageId);
}
setInProgressFetch(): () => unknown {
let resolvePromise: (value?: unknown) => void;
this.model.inProgressFetch = new Promise(resolve => {
resolvePromise = resolve;
});
const finish = () => {
resolvePromise();
this.model.inProgressFetch = undefined;
};
return finish;
}
async loadAndScroll(
messageId: string,
options?: { disableScroll?: boolean }
): Promise<void> {
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 sentAt = message.get('sent_at');
const older = await getOlderMessagesByConversation(conversationId, {
limit: 30,
receivedAt,
sentAt,
messageId,
MessageCollection: Whisper.MessageCollection,
});
const newer = await getNewerMessagesByConversation(conversationId, {
limit: 30,
receivedAt,
sentAt,
MessageCollection: Whisper.MessageCollection,
});
const metrics = await getMessageMetricsForConversation(conversationId);
const all = [...older.models, message, ...newer.models];
const cleaned: Array<MessageModel> = await this.cleanModels(all);
const scrollToMessageId =
options && options.disableScroll ? undefined : messageId;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async loadNewestMessages(
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
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 newestInMemoryMessage = await getMessageById(newestMessageId, {
Message: Whisper.Message,
});
if (newestInMemoryMessage) {
// If newest in-memory message is unread, scrolling down would mean going to
// the very bottom, not the oldest unread.
if (isMessageUnread(newestInMemoryMessage.attributes)) {
scrollToLatestUnread = false;
}
} else {
window.log.warn(
`loadNewestMessages: did not find message ${newestMessageId}`
);
}
}
const metrics = await getMessageMetricsForConversation(conversationId);
// If this is a message request that has not yet been accepted, we always show the
// oldest messages, to ensure that the ConversationHero is shown. We don't want to
// scroll directly to the oldest message, because that could scroll the hero off
// the screen.
if (!newestMessageId && !this.model.getAccepted() && metrics.oldest) {
this.loadAndScroll(metrics.oldest.id, { disableScroll: true });
return;
}
if (scrollToLatestUnread && metrics.oldestUnread) {
this.loadAndScroll(metrics.oldestUnread.id, {
disableScroll: !setFocus,
});
return;
}
const messages = await getOlderMessagesByConversation(conversationId, {
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
const cleaned: Array<MessageModel> = await this.cleanModels(messages);
const scrollToMessageId =
setFocus && metrics.newest ? metrics.newest.id : undefined;
// Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got
// the most recent 30 messages in the conversation. If it has a conflict with
// metrics, fetched a bit before, that's likely a race condition. So we tell our
// reducer to trust the message set we just fetched for determining if we have
// the newest message loaded.
const unboundedFetch = true;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId,
unboundedFetch
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async startMigrationToGV2(): Promise<void> {
const logId = this.model.idForLogging();
if (!isGroupV1(this.model.attributes)) {
throw new Error(
`startMigrationToGV2/${logId}: Cannot start, not a GroupV1 group`
);
}
const onClose = () => {
if (this.migrationDialog) {
this.migrationDialog.remove();
this.migrationDialog = undefined;
}
};
onClose();
const migrate = () => {
onClose();
this.longRunningTaskWrapper({
name: 'initiateMigrationToGroupV2',
task: () => window.Signal.Groups.initiateMigrationToGroupV2(this.model),
});
};
// Note: this call will throw if, after generating member lists, we are no longer a
// member or are in the pending member list.
const {
droppedGV2MemberIds,
pendingMembersV2,
} = await this.longRunningTaskWrapper({
name: 'getGroupMigrationMembers',
task: () => window.Signal.Groups.getGroupMigrationMembers(this.model),
});
const invitedMemberIds = pendingMembersV2.map(
(item: GroupV2PendingMemberType) => item.conversationId
);
this.migrationDialog = new Whisper.ReactWrapperView({
className: 'group-v1-migration-wrapper',
JSX: window.Signal.State.Roots.createGroupV1MigrationModal(
window.reduxStore,
{
areWeInvited: false,
droppedMemberIds: droppedGV2MemberIds,
hasMigrated: false,
invitedMemberIds,
migrate,
onClose,
}
),
});
}
onChooseAttachment(): void {
this.$('input.file-input').click();
}
async onChoseAttachment(): Promise<void> {
const fileField = this.$('input.file-input');
const files = fileField.prop('files');
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
// eslint-disable-next-line no-await-in-loop
await this.maybeAddAttachment(file);
this.toggleMicrophone();
}
fileField.val([]);
}
unload(reason: string): void {
window.log.info(
'unloading conversation',
this.model.idForLogging(),
'due to:',
reason
);
const { conversationUnloaded } = window.reduxActions.conversations;
if (conversationUnloaded) {
conversationUnloaded(this.model.id);
}
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,
});
}
// We don't wait here; we need to take down the view
this.saveModel();
this.model.updateLastMessage();
}
this.titleView?.remove();
this.timelineView?.remove();
this.compositionAreaView?.remove();
if (this.captionEditorView) {
this.captionEditorView.remove();
}
if (this.contactModalView) {
this.contactModalView.remove();
}
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.remove();
}
if (this.captureAudioView) {
this.captureAudioView.remove();
}
if (this.lightboxView) {
this.lightboxView.remove();
}
if (this.panels && this.panels.length) {
for (let i = 0, max = this.panels.length; i < max; i += 1) {
const panel = this.panels[i];
panel.remove();
}
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
}
this.removeLinkPreview();
this.disableLinkPreviews = true;
this.remove();
}
// eslint-disable-next-line class-methods-use-this
navigateTo(url: string): void {
window.location.href = url;
}
downloadNewVersion(): void {
this.navigateTo('https://signal.org/download');
}
async onDrop(e: JQuery.TriggeredEvent): Promise<void> {
if (!e.originalEvent) {
return;
}
const event = e.originalEvent as DragEvent;
if (!event.dataTransfer) {
return;
}
if (event.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
const { files } = event.dataTransfer;
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
this.maybeAddAttachment(file);
}
}
onPaste(e: JQuery.TriggeredEvent): void {
if (!e.originalEvent) {
return;
}
const event = e.originalEvent as ClipboardEvent;
if (!event.clipboardData) {
return;
}
const { items } = event.clipboardData;
const anyImages = [...items].some(
item => item.type.split('/')[0] === 'image'
);
if (!anyImages) {
return;
}
e.stopPropagation();
e.preventDefault();
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
const file = items[i].getAsFile();
if (file) {
this.maybeAddAttachment(file);
}
}
}
}
syncMessageRequestResponse(
name: string,
model: ConversationModel,
messageRequestType: number
): Promise<void> {
return this.longRunningTaskWrapper({
name,
task: model.syncMessageRequestResponse.bind(model, messageRequestType),
});
}
blockAndReportSpam(model: ConversationModel): Promise<void> {
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
return this.longRunningTaskWrapper({
name: 'blockAndReportSpam',
task: async () => {
await Promise.all([
model.syncMessageRequestResponse(messageRequestEnum.BLOCK),
addReportSpamJob({
conversation: model.format(),
getMessageServerGuidsForSpam:
window.Signal.Data.getMessageServerGuidsForSpam,
jobQueue: reportSpamJobQueue,
}),
]);
this.showToast(ReportedSpamAndBlockedToast);
},
});
}
onClickAttachment(attachment: AttachmentDraftType): void {
if (attachment.pending) {
throw new Error(
'onClickAttachment: Cannot click to edit pending attachment'
);
}
const getProps = () => {
if (attachment.pending) {
throw new Error(
'onClickAttachment/onSave: Cannot render pending attachment'
);
}
return {
url: attachment.url,
caption: attachment.caption,
attachment,
onSave,
};
};
const onSave = (caption?: string) => {
const attachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: attachments.map((item: OnDiskAttachmentDraftType) => {
if (item.pending || attachment.pending) {
return item;
}
if (
(item.path && item.path === attachment.path) ||
(item.screenshotPath &&
item.screenshotPath === attachment.screenshotPath)
) {
return {
...attachment,
caption,
};
}
return item;
}),
draftChanged: true,
});
if (this.captionEditorView) {
this.captionEditorView.remove();
this.captionEditorView = undefined;
}
window.Signal.Backbone.Views.Lightbox.hide();
this.updateAttachmentsView();
this.saveModel();
};
this.captionEditorView = new Whisper.ReactWrapperView({
className: 'attachment-list-wrapper',
Component: window.Signal.Components.CaptionEditor,
props: getProps(),
onClose: () => window.Signal.Backbone.Views.Lightbox.hide(),
});
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
}
// eslint-disable-next-line class-methods-use-this
async deleteDraftAttachment(
attachment: Pick<AttachmentType, 'screenshotPath' | 'path'>
): Promise<void> {
if (attachment.screenshotPath) {
await deleteDraftFile(attachment.screenshotPath);
}
if (attachment.path) {
await deleteDraftFile(attachment.path);
}
}
async saveModel(): Promise<void> {
window.Signal.Data.updateConversation(this.model.attributes);
}
async addAttachment(attachment: InMemoryAttachmentDraftType): Promise<void> {
const onDisk = await this.writeDraftAttachment(attachment);
// Remove any pending attachments that were transcoding
const draftAttachments = this.model.get('draftAttachments') || [];
const index = draftAttachments.findIndex(
draftAttachment => draftAttachment.path === attachment.path
);
if (index < 0) {
window.log.warn(
`addAttachment: Failed to find pending attachment with path ${attachment.path}`
);
this.model.set({
draftAttachments: [...draftAttachments, onDisk],
});
} else {
const toUpdate = [...draftAttachments];
toUpdate.splice(index, 1, onDisk);
this.model.set({
draftAttachments: toUpdate,
});
}
this.updateAttachmentsView();
await this.saveModel();
}
// eslint-disable-next-line class-methods-use-this
resolveOnDiskAttachment(
attachment: OnDiskAttachmentDraftType
): AttachmentDraftType {
let url = '';
if (attachment.pending) {
return attachment;
}
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
window.log.warn(
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
...pick(attachment, [
'blurHash',
'caption',
'contentType',
'fileName',
'path',
'size',
]),
pending: false,
url,
};
}
async removeDraftAttachment(
attachment: Pick<AttachmentType, 'path' | 'screenshotPath'>
): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: reject(
draftAttachments,
item => item.path === attachment.path
),
draftChanged: true,
});
this.updateAttachmentsView();
await this.saveModel();
await this.deleteDraftAttachment(attachment);
}
async clearAttachments(): Promise<void> {
this.voiceNoteAttachment = undefined;
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
draftAttachments: [],
draftChanged: true,
});
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(): boolean {
const draftAttachments = this.model.get('draftAttachments') || [];
return draftAttachments.length > 0;
}
async getFiles(): Promise<Array<AttachmentType>> {
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 items = await Promise.all(
draftAttachments.map(attachment => this.getFile(attachment))
);
return items.filter(isNotNil);
}
// eslint-disable-next-line class-methods-use-this
async getFile(
attachment?: OnDiskAttachmentDraftType
): Promise<AttachmentType | undefined> {
if (!attachment || attachment.pending) {
return;
}
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;
}
return {
...attachment,
data,
};
}
// eslint-disable-next-line class-methods-use-this
arrayBufferFromFile(file: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, rejectPromise) => {
const FR = new FileReader();
FR.onload = () => {
if (!FR.result || typeof FR.result === 'string') {
rejectPromise(new Error('arrayBufferFromFile: No result!'));
return;
}
resolve(FR.result);
};
FR.onerror = rejectPromise;
FR.onabort = rejectPromise;
FR.readAsArrayBuffer(file);
});
}
showFileSizeError({
limit,
units,
u,
}: Readonly<{
limit: number;
units: Array<string>;
u: number;
}>): void {
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
}
updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
this.model.get('id'),
draftAttachments.map((att: OnDiskAttachmentDraftType) =>
this.resolveOnDiskAttachment(att)
)
);
this.toggleMicrophone();
if (this.hasFiles()) {
this.removeLinkPreview();
}
}
// eslint-disable-next-line class-methods-use-this
async writeDraftAttachment(
attachment: InMemoryAttachmentDraftType
): Promise<OnDiskAttachmentDraftType> {
if (attachment.pending) {
throw new Error('writeDraftAttachment: Cannot write pending attachment');
}
const result: OnDiskAttachmentDraftType = {
...omit(attachment, ['data', 'screenshotData']),
pending: false,
};
if (attachment.data) {
result.path = await writeNewDraftData(attachment.data);
}
if (attachment.screenshotData) {
result.screenshotPath = await writeNewDraftData(
attachment.screenshotData
);
}
return result;
}
async maybeAddAttachment(file: File): Promise<void> {
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 = draftAttachments.some(
(attachment: OnDiskAttachmentDraftType) =>
!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;
}
const fileType = stringToMIMEType(file.type);
// You can't add a non-image attachment if you already have attachments staged
if (!MIME.isImage(fileType) && draftAttachments.length > 0) {
this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast);
return;
}
// Add a pending attachment since async processing happens below
const path = file.name;
const fileName = nodePath.parse(file.name).name;
this.model.set({
draftAttachments: [
...draftAttachments,
{
contentType: fileType,
fileName,
path,
pending: true,
},
],
});
this.updateAttachmentsView();
let attachment: InMemoryAttachmentDraftType;
try {
if (
window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType) ||
isHeic(fileType)
) {
attachment = await handleImageAttachment(file);
const hasDraftAttachmentPending = (
this.model.get('draftAttachments') || []
).some(
draftAttachment =>
draftAttachment.pending && draftAttachment.path === path
);
// User has canceled the draft so we don't need to continue processing
if (!hasDraftAttachmentPending) {
return;
}
} else if (
window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType)
) {
attachment = await this.handleVideoAttachment(file);
} else {
const data = await this.arrayBufferFromFile(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for fileType ${fileType}`,
e && e.stack ? e.stack : e
);
const data = await this.arrayBufferFromFile(file);
attachment = {
contentType: fileType,
data,
fileName: file.name,
path: file.name,
pending: false,
size: data.byteLength,
};
}
try {
if (!this.isSizeOkay(attachment)) {
this.removeDraftAttachment(attachment);
}
} catch (error) {
window.log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
this.removeDraftAttachment(attachment);
this.showToast(Whisper.UnableToLoadToast);
return;
}
try {
await this.addAttachment(attachment);
} catch (error) {
window.log.error(
'Error saving draft attachment:',
error && error.stack ? error.stack : error
);
this.showToast(Whisper.UnableToLoadToast);
}
}
isSizeOkay(attachment: Readonly<AttachmentType>): boolean {
const limitKb = window.Signal.Types.Attachment.getUploadSizeLimitKb(
attachment.contentType
);
// this needs to be cast properly
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
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: Readonly<File>
): Promise<InMemoryAttachmentDraftType> {
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 {
contentType: stringToMIMEType(file.type),
data,
fileName: file.name,
path: file.name,
pending: false,
screenshotContentType,
screenshotData,
screenshotSize: screenshotData.byteLength,
size: data.byteLength,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
}
// eslint-disable-next-line class-methods-use-this
async markAllAsVerifiedDefault(
unverified: ReadonlyArray<ConversationModel>
): Promise<void> {
await Promise.all(
unverified.map(contact => {
if (contact.isUnverified()) {
return contact.setVerifiedDefault();
}
return null;
})
);
}
// eslint-disable-next-line class-methods-use-this
async markAllAsApproved(
untrusted: ReadonlyArray<ConversationModel>
): Promise<void> {
await Promise.all(untrusted.map(contact => contact.setApproved()));
}
toggleMicrophone(): void {
this.compositionApi.current?.setShowMic(!this.hasFiles());
}
captureAudio(e?: Event): void {
if (e) {
e.preventDefault();
}
if (this.compositionApi.current?.isDirty()) {
return;
}
if (this.hasFiles()) {
this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast);
return;
}
this.showToast(Whisper.VoiceNoteLimit);
// 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 = undefined;
}
this.captureAudioView = new Whisper.RecorderView();
const view = this.captureAudioView;
view.render();
view.on('send', this.handleAudioCapture.bind(this));
view.on('confirm', this.handleAudioConfirm.bind(this));
view.on('closed', this.endCaptureAudio.bind(this));
view.$el.appendTo(this.$('.capture-audio'));
view.$('.finish').focus();
this.compositionApi.current?.setMicActive(true);
this.disableMessageField();
this.$('.microphone').hide();
}
handleAudioConfirm(blob: Blob, lostFocus?: boolean): void {
window.showConfirmationDialog({
confirmStyle: 'negative',
cancelText: window.i18n('discard'),
message: lostFocus
? window.i18n('voiceRecordingInterruptedBlur')
: window.i18n('voiceRecordingInterruptedMax'),
okText: window.i18n('sendAnyway'),
resolve: async () => {
await this.handleAudioCapture(blob);
},
});
}
async handleAudioCapture(blob: Blob): Promise<void> {
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 = {
contentType: stringToMIMEType(blob.type),
data,
size: data.byteLength,
flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE,
};
// Note: The RecorderView removes itself on send
this.captureAudioView = undefined;
this.sendMessage();
}
endCaptureAudio(): void {
this.enableMessageField();
this.$('.microphone').show();
// Note: The RecorderView removes itself on close
this.captureAudioView = undefined;
this.compositionApi.current?.setMicActive(false);
}
async onOpened(messageId: string): Promise<void> {
if (messageId) {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
});
if (message) {
this.loadAndScroll(messageId);
return;
}
window.log.warn(`onOpened: Did not find message ${messageId}`);
}
const { retryPlaceholders } = window.Signal.Services;
if (retryPlaceholders) {
await retryPlaceholders.findByConversationAndMarkOpened(this.model.id);
}
this.loadNewestMessages(undefined, undefined);
this.model.updateLastMessage();
this.focusMessageField();
const quotedMessageId = this.model.get('quotedMessageId');
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
}
this.model.fetchLatestGroupV2Data();
strictAssert(
this.model.throttledMaybeMigrateV1Group !== undefined,
'Conversation model should be initialized'
);
this.model.throttledMaybeMigrateV1Group();
strictAssert(
this.model.throttledFetchSMSOnlyUUID !== undefined,
'Conversation model should be initialized'
);
this.model.throttledFetchSMSOnlyUUID();
strictAssert(
this.model.throttledGetProfiles !== undefined,
'Conversation model should be initialized'
);
await this.model.throttledGetProfiles();
this.model.updateVerified();
}
// eslint-disable-next-line class-methods-use-this
async retrySend(messageId: string): Promise<void> {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`retrySend: Message ${messageId} missing!`);
}
await message.retrySend();
}
async showForwardMessageModal(messageId: string): Promise<void> {
const messageFromCache = window.MessageController.getById(messageId);
if (!messageFromCache) {
window.log.info(
'showForwardMessageModal: Fetching message from database'
);
}
const message =
messageFromCache ||
(await window.Signal.Data.getMessageById(messageId, {
Message: window.Whisper.Message,
}));
if (!message) {
throw new Error(`showForwardMessageModal: Message ${messageId} missing!`);
}
const attachments = getAttachmentsForMessage(message.attributes);
this.forwardMessageModal = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createForwardMessageModal(
window.reduxStore,
{
attachments,
doForwardMessage: async (
conversationIds: Array<string>,
messageBody?: string,
includedAttachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => {
try {
const didForwardSuccessfully = await this.maybeForwardMessage(
message,
conversationIds,
messageBody,
includedAttachments,
linkPreview
);
if (didForwardSuccessfully && this.forwardMessageModal) {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
} catch (err) {
window.log.warn(
'doForwardMessage',
err && err.stack ? err.stack : err
);
}
},
isSticker: Boolean(message.get('sticker')),
messageBody: message.getRawText(),
onClose: () => {
if (this.forwardMessageModal) {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
this.resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
_: Array<BodyRangeType>,
caretLocation?: number
) => {
if (!attachments.length) {
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
}
},
onTextTooLong: () =>
this.showToast(
Whisper.MessageBodyTooLongToast,
{},
document.querySelector('.module-ForwardMessageModal') || undefined
),
}
),
});
this.forwardMessageModal.render();
}
async maybeForwardMessage(
message: MessageModel,
conversationIds: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
window.log.info(
`maybeForwardMessage/${message.idForLogging()}: Starting...`
);
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(
`${attachment.fileName}/${attachment.contentType}`
);
});
}
const conversations = conversationIds.map(id =>
window.ConversationController.get(id)
);
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
throw new Error('Cannot send to group');
}
// Verify that all contacts that we're forwarding
// to are verified and trusted
const unverifiedContacts: Array<ConversationModel> = [];
const untrustedContacts: Array<ConversationModel> = [];
await Promise.all(
conversations.map(async conversation => {
if (conversation) {
await conversation.updateVerified();
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
unverifieds.forEach(unverifiedConversation =>
unverifiedContacts.push(unverifiedConversation)
);
}
const untrusted = conversation.getUntrusted();
if (untrusted.length) {
untrusted.forEach(untrustedConversation =>
untrustedContacts.push(untrustedConversation)
);
}
}
})
);
// If there are any unverified or untrusted contacts, show the
// SendAnywayDialog and if we're fine with sending then mark all as
// verified and trusted and continue the send.
const iffyConversations = [...unverifiedContacts, ...untrustedContacts];
if (iffyConversations.length) {
const forwardMessageModal = document.querySelector<HTMLElement>(
'.module-ForwardMessageModal'
);
if (forwardMessageModal) {
forwardMessageModal.style.display = 'none';
}
const sendAnyway = await this.showSendAnywayDialog(iffyConversations);
if (!sendAnyway) {
if (forwardMessageModal) {
forwardMessageModal.style.display = 'block';
}
return false;
}
let verifyPromise: Promise<void> | undefined;
let approvePromise: Promise<void> | undefined;
if (unverifiedContacts.length) {
verifyPromise = this.markAllAsVerifiedDefault(unverifiedContacts);
}
if (untrustedContacts.length) {
approvePromise = this.markAllAsApproved(untrustedContacts);
}
await Promise.all([verifyPromise, approvePromise]);
}
const sendMessageOptions = { dontClearDraft: true };
const baseTimestamp = Date.now();
// Actually send the message
// load any sticker data, attachments, or link previews that we need to
// send along with the message and do the send to each conversation.
await Promise.all(
conversations.map(async (conversation, offset) => {
const timestamp = baseTimestamp + offset;
if (conversation) {
const sticker = message.get('sticker');
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
const stickerNoPath = stickerWithData
? {
...stickerWithData,
data: {
...stickerWithData.data,
path: undefined,
},
}
: undefined;
conversation.enqueueMessageForSend(
undefined, // body
[],
undefined, // quote
[],
stickerNoPath,
undefined, // BodyRanges
{ ...sendMessageOptions, timestamp }
);
} else {
const preview = linkPreview
? await loadPreviewData([linkPreview])
: [];
const attachmentsWithData = await Promise.all(
(attachments || []).map(async item => ({
...(await loadAttachmentData(item)),
path: undefined,
}))
);
const attachmentsToSend = attachmentsWithData.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
conversation.enqueueMessageForSend(
messageBody || undefined,
attachmentsToSend,
undefined, // quote
preview,
undefined, // sticker
undefined, // BodyRanges
{ ...sendMessageOptions, timestamp }
);
}
}
})
);
// Cancel any link still pending, even if it didn't make it into the message
this.resetLinkPreview();
return true;
}
async showAllMedia(): Promise<void> {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
const conversationId = this.model.get('id');
const getProps = async () => {
const rawMedia = await window.Signal.Data.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit: DEFAULT_MEDIA_FETCH_COUNT,
}
);
const rawDocuments = await window.Signal.Data.getMessagesWithFileAttachments(
conversationId,
{
limit: DEFAULT_DOCUMENTS_FETCH_COUNT,
}
);
// 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 &&
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]);
}
}
const media: Array<MediaType> = flatten(
rawMedia.map(message => {
return (message.attachments || []).map(
(
attachment: AttachmentType,
index: number
): MediaType | undefined => {
if (
!attachment.path ||
!attachment.thumbnail ||
attachment.pending ||
attachment.error
) {
return;
}
const { thumbnail } = attachment;
return {
path: attachment.path,
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
: undefined,
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(
window.ConversationController.ensureContactIds({
uuid: message.sourceUuid,
e164: message.source,
})
)?.id || message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
);
})
).filter(isNotNil);
// Unlike visual media, only one non-image attachment is supported
const documents = rawDocuments
.filter(message =>
Boolean(message.attachments && message.attachments.length)
)
.map(message => {
const attachments = message.attachments || [];
const attachment = attachments[0];
return {
contentType: attachment.contentType,
index: 0,
attachment,
message,
};
});
const saveAttachment = async ({
attachment,
message,
}: {
attachment: AttachmentType;
message: Pick<MessageAttributesType, 'sent_at'>;
}) => {
const timestamp = message.sent_at;
const fullPath = await window.Signal.Types.Attachment.save({
attachment,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
this.showToast(Whisper.FileSavedToast, { fullPath });
}
};
const onItemClick = async ({
message,
attachment,
type,
}: {
message: MessageAttributesType;
attachment: AttachmentType;
type: 'documents' | 'media';
}) => {
switch (type) {
case 'documents': {
saveAttachment({ message, attachment });
break;
}
case 'media': {
const selectedMedia =
media.find(item => attachment.path === item.path) || media[0];
this.showLightboxForMedia(selectedMedia, media);
break;
}
default:
throw new TypeError(`Unknown attachment type: '${type}'`);
}
};
return {
documents,
media,
onItemClick,
};
};
const view = new Whisper.ReactWrapperView({
className: 'panel',
Component: window.Signal.Components.MediaGallery,
props: await getProps(),
onClose: () => {
unsubscribe();
},
});
view.headerTitle = window.i18n('allMedia');
const update = async () => {
view.update(await getProps());
};
function getMessageIds(): Array<string | undefined> | undefined {
const state = window.reduxStore.getState();
const byConversation = state?.conversations?.messagesByConversation;
const messages = byConversation && byConversation[conversationId];
if (!messages || !messages.messageIds) {
return undefined;
}
return messages.messageIds;
}
// Detect message changes in the current conversation
let previousMessageList: Array<string | undefined> | undefined;
previousMessageList = getMessageIds();
const unsubscribe = window.reduxStore.subscribe(() => {
const currentMessageList = getMessageIds();
if (currentMessageList !== previousMessageList) {
update();
previousMessageList = currentMessageList;
}
});
this.listenBack(view);
}
focusMessageField(): void {
if (this.panels && this.panels.length) {
return;
}
this.compositionApi.current?.focusInput();
}
disableMessageField(): void {
this.compositionApi.current?.setDisabled(true);
}
enableMessageField(): void {
this.compositionApi.current?.setDisabled(false);
}
resetEmojiResults(): void {
this.compositionApi.current?.resetEmojiResults();
}
showGV1Members(): void {
const { contactCollection } = this.model;
const memberships =
contactCollection?.map((conversation: ConversationModel) => {
return {
isAdmin: false,
member: conversation.format(),
};
}) || [];
const view = new Whisper.ReactWrapperView({
className: 'group-member-list panel',
Component: ConversationDetailsMembershipList,
props: {
canAddNewMembers: false,
i18n: window.i18n,
maxShownMemberCount: 32,
memberships,
showContactModal: this.showContactModal.bind(this),
},
});
this.listenBack(view);
view.render();
}
showSafetyNumber(id?: string): void {
let conversation: undefined | ConversationModel;
if (!id && isDirectConversation(this.model.attributes)) {
// eslint-disable-next-line prefer-destructuring
conversation = this.model;
} else {
conversation = window.ConversationController.get(id);
}
if (conversation) {
const view = new Whisper.KeyVerificationPanelView({
model: conversation,
});
this.listenBack(view);
}
}
downloadAttachmentWrapper(
messageId: string,
providedAttachment?: AttachmentType
): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`downloadAttachmentWrapper: Message ${messageId} missing!`
);
}
const { attachments, sent_at: timestamp } = message.attributes;
if (!attachments || attachments.length < 1) {
return;
}
const attachment =
providedAttachment && attachments.includes(providedAttachment)
? providedAttachment
: attachments[0];
const { fileName } = attachment;
const isDangerous = window.Signal.Util.isFileDangerous(fileName || '');
this.downloadAttachment({ attachment, timestamp, isDangerous });
}
async downloadAttachment({
attachment,
timestamp,
isDangerous,
}: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}): Promise<void> {
if (isDangerous) {
this.showToast(Whisper.DangerousFileTypeToast);
return;
}
const fullPath = await window.Signal.Types.Attachment.save({
attachment,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
this.showToast(Whisper.FileSavedToast, { fullPath });
}
}
async displayTapToViewMessage(messageId: string): Promise<void> {
window.log.info('displayTapToViewMessage: attempting to display message');
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`displayTapToViewMessage: Message ${messageId} missing!`);
}
if (!isTapToView(message.attributes)) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message`
);
}
if (message.isErased()) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is already erased`
);
}
const firstAttachment = (message.get('attachments') || [])[0];
if (!firstAttachment || !firstAttachment.path) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path`
);
}
const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path);
const tempPath = await copyIntoTempDirectory(absolutePath);
const tempAttachment = {
...firstAttachment,
path: tempPath,
};
await message.markViewOnceMessageViewed();
const closeLightbox = async () => {
window.log.info('displayTapToViewMessage: attempting to close lightbox');
if (!this.lightboxView) {
window.log.info('displayTapToViewMessage: lightbox was already closed');
return;
}
const { lightboxView } = this;
this.lightboxView = undefined;
this.stopListening(message);
window.Signal.Backbone.Views.Lightbox.hide();
lightboxView.remove();
await deleteTempFile(tempPath);
};
this.listenTo(message, 'expired', closeLightbox);
this.listenTo(message, 'change', () => {
if (this.lightboxView) {
this.lightboxView.update(getProps());
}
});
const getProps = () => {
const { path, contentType } = tempAttachment;
return {
media: [
{
attachment: tempAttachment,
objectURL: getAbsoluteTempPath(path),
contentType,
index: 0,
message: {
attachments: message.get('attachments'),
id: message.get('id'),
conversationId: message.get('conversationId'),
received_at: message.get('received_at'),
received_at_ms: Number(message.get('received_at_ms')),
sent_at: message.get('sent_at'),
},
},
],
isViewOnce: true,
};
};
if (this.lightboxView) {
this.lightboxView.remove();
this.lightboxView = undefined;
}
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.Lightbox,
props: getProps(),
onClose: closeLightbox,
});
window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
window.log.info('displayTapToViewMessage: showed lightbox');
}
deleteMessage(messageId: string): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`deleteMessage: Message ${messageId} missing!`);
}
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('deleteWarning'),
okText: window.i18n('delete'),
resolve: () => {
window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message,
});
message.cleanup();
if (isOutgoing(message.attributes)) {
this.model.decrementSentMessageCount();
} else {
this.model.decrementMessageCount();
}
this.resetPanel();
},
});
}
deleteMessageForEveryone(messageId: string): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('deleteForEveryoneWarning'),
okText: window.i18n('delete'),
resolve: async () => {
try {
await this.model.sendDeleteForEveryoneMessage({
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) {
window.log.error(
'Error sending delete-for-everyone',
error && error.stack,
messageId
);
this.showToast(Whisper.DeleteForEveryoneFailedToast);
}
this.resetPanel();
},
});
}
showStickerPackPreview(packId: string, packKey: string): void {
Stickers.downloadEphemeralPack(packId, packKey);
const props = {
packId,
onClose: async () => {
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.remove();
this.stickerPreviewModalView = undefined;
}
await Stickers.removeEphemeralPack(packId);
},
};
this.stickerPreviewModalView = new Whisper.ReactWrapperView({
className: 'sticker-preview-modal-wrapper',
JSX: window.Signal.State.Roots.createStickerPreviewModal(
window.reduxStore,
props
),
});
}
showLightboxForMedia(
selectedMediaItem: MediaItemType,
media: Array<MediaItemType> = []
): void {
const onSave = async ({
attachment,
message,
index,
}: {
attachment: AttachmentType;
message: MediaItemMessageType;
index: number;
}) => {
const fullPath = await window.Signal.Types.Attachment.save({
attachment,
index: index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp: message.sent_at,
});
if (fullPath) {
this.showToast(Whisper.FileSavedToast, { fullPath });
}
};
const selectedIndex = media.findIndex(
mediaItem =>
mediaItem.attachment.path === selectedMediaItem.attachment.path
);
if (this.lightboxView) {
this.lightboxView.remove();
this.lightboxView = undefined;
}
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: window.Signal.Components.Lightbox,
props: {
getConversation: getConversationSelector(window.reduxStore.getState()),
media,
onForward: this.showForwardMessageModal.bind(this),
onSave,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
},
onClose: () => window.Signal.Backbone.Views.Lightbox.hide(),
});
window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
}
showLightbox({
attachment,
messageId,
}: {
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`showLightbox: Message ${messageId} missing!`);
}
const sticker = message.get('sticker');
if (sticker) {
const { packId, packKey } = sticker;
this.showStickerPackPreview(packId, packKey);
return;
}
const { contentType } = attachment;
if (
!window.Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
!window.Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
) {
this.downloadAttachmentWrapper(messageId, attachment);
return;
}
const attachments: Array<AttachmentType> = message.get('attachments') || [];
const loop = isGIF(attachments);
const media = attachments
.filter(item => item.thumbnail && !item.pending && !item.error)
.map((item, index) => ({
objectURL: getAbsoluteAttachmentPath(item.path ?? ''),
path: item.path,
contentType: item.contentType,
loop,
index,
message: {
attachments: message.get('attachments') || [],
id: message.get('id'),
conversationId:
window.ConversationController.get(
window.ConversationController.ensureContactIds({
uuid: message.get('sourceUuid'),
e164: message.get('source'),
})
)?.id || message.get('conversationId'),
received_at: message.get('received_at'),
received_at_ms: Number(message.get('received_at_ms')),
sent_at: message.get('sent_at'),
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl ||
getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''),
}));
const selectedMedia =
media.find(item => attachment.path === item.path) || media[0];
this.showLightboxForMedia(selectedMedia, media);
}
showContactModal(contactId: string): void {
if (this.contactModalView) {
this.contactModalView.remove();
this.contactModalView = undefined;
}
this.previousFocus = document.activeElement as HTMLElement;
const hideContactModal = () => {
if (this.contactModalView) {
this.contactModalView.remove();
this.contactModalView = undefined;
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
this.previousFocus = undefined;
}
}
};
this.contactModalView = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, {
contactId,
currentConversationId: this.model.id,
onClose: hideContactModal,
openConversation: (conversationId: string) => {
hideContactModal();
this.openConversation(conversationId);
},
removeMember: (conversationId: string) => {
hideContactModal();
this.model.removeFromGroupV2(conversationId);
},
showSafetyNumber: (conversationId: string) => {
hideContactModal();
this.showSafetyNumber(conversationId);
},
toggleAdmin: (conversationId: string) => {
hideContactModal();
const isAdmin = this.model.isAdmin(conversationId);
const conversationModel = window.ConversationController.get(
conversationId
);
if (!conversationModel) {
window.log.info(
'conversation_view/toggleAdmin: Could not find conversation to toggle admin privileges'
);
return;
}
window.showConfirmationDialog({
cancelText: window.i18n('cancel'),
message: isAdmin
? window.i18n('ContactModal--rm-admin-info', [
conversationModel.getTitle(),
])
: window.i18n('ContactModal--make-admin-info', [
conversationModel.getTitle(),
]),
okText: isAdmin
? window.i18n('ContactModal--rm-admin')
: window.i18n('ContactModal--make-admin'),
resolve: () => this.model.toggleAdmin(conversationId),
});
},
updateSharedGroups: () => {
const conversation = window.ConversationController.get(contactId);
if (conversation && conversation.throttledUpdateSharedGroups) {
conversation.throttledUpdateSharedGroups();
}
},
}),
});
this.contactModalView.render();
}
showGroupLinkManagement(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupLinkManagement(
window.reduxStore,
{
changeHasGroupLink: this.changeHasGroupLink.bind(this),
conversationId: this.model.id,
copyGroupLink: this.copyGroupLink.bind(this),
generateNewGroupLink: this.generateNewGroupLink.bind(this),
setAccessControlAddFromInviteLinkSetting: this.setAccessControlAddFromInviteLinkSetting.bind(
this
),
}
),
});
view.headerTitle = window.i18n('ConversationDetails--group-link');
this.listenBack(view);
view.render();
}
showGroupV2Permissions(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupV2Permissions(
window.reduxStore,
{
conversationId: this.model.id,
setAccessControlAttributesSetting: this.setAccessControlAttributesSetting.bind(
this
),
setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind(
this
),
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
}
),
});
view.headerTitle = window.i18n('permissions');
this.listenBack(view);
view.render();
}
showPendingInvites(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
conversationId: this.model.id,
ourConversationId: window.ConversationController.getOurConversationId(),
approvePendingMembership: (conversationId: string) => {
this.model.approvePendingMembershipFromGroupV2(conversationId);
},
revokePendingMemberships: conversationIds => {
this.model.revokePendingMembershipsFromGroupV2(conversationIds);
},
}),
});
view.headerTitle = window.i18n('ConversationDetails--requests-and-invites');
this.listenBack(view);
view.render();
}
showConversationNotificationsSettings(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createConversationNotificationsSettings(
window.reduxStore,
{
conversationId: this.model.id,
setDontNotifyForMentionsIfMuted: this.model.setDontNotifyForMentionsIfMuted.bind(
this.model
),
setMuteExpiration: this.setMuteExpiration.bind(this),
}
),
});
view.headerTitle = window.i18n('ConversationDetails--notifications');
this.listenBack(view);
view.render();
}
showChatColorEditor(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createChatColorPicker(window.reduxStore, {
conversationId: this.model.get('id'),
}),
});
view.headerTitle = window.i18n('ChatColorPicker__menu-title');
this.listenBack(view);
view.render();
}
showConversationDetails(): void {
// Run a getProfiles in case member's capabilities have changed
// Redux should cover us on the return here so no need to await this.
if (this.model.throttledGetProfiles) {
this.model.throttledGetProfiles();
}
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
// these methods are used in more than one place and should probably be
// dried up and hoisted to methods on ConversationView
const onLeave = () => {
this.longRunningTaskWrapper({
name: 'onLeave',
task: () => this.model.leaveGroupV2(),
});
};
const onBlock = () => {
this.syncMessageRequestResponse(
'onBlock',
this.model,
messageRequestEnum.BLOCK
);
};
const props = {
addMembers: this.model.addMembersV2.bind(this.model),
conversationId: this.model.get('id'),
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
setDisappearingMessages: this.setDisappearingMessages.bind(this),
showAllMedia: this.showAllMedia.bind(this),
showContactModal: this.showContactModal.bind(this),
showGroupChatColorEditor: this.showChatColorEditor.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
showConversationNotificationsSettings: this.showConversationNotificationsSettings.bind(
this
),
showPendingInvites: this.showPendingInvites.bind(this),
showLightboxForMedia: this.showLightboxForMedia.bind(this),
updateGroupAttributes: this.model.updateGroupAttributesV2.bind(
this.model
),
onLeave,
onBlock,
};
const view = new Whisper.ReactWrapperView({
className: 'conversation-details-pane panel',
JSX: window.Signal.State.Roots.createConversationDetails(
window.reduxStore,
props
),
});
view.headerTitle = '';
this.listenBack(view);
view.render();
}
showMessageDetail(messageId: string): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`showMessageDetail: Message ${messageId} missing!`);
}
if (!message.isNormalBubble()) {
return;
}
const getProps = () => ({
...message.getPropsForMessageDetail(
window.ConversationController.getOurConversationIdOrThrow()
),
...this.getMessageActions(),
});
const onClose = () => {
this.stopListening(message, 'change', update);
this.resetPanel();
};
const view = new Whisper.ReactWrapperView({
className: 'panel message-detail-wrapper',
JSX: window.Signal.State.Roots.createMessageDetail(
window.reduxStore,
getProps()
),
onClose,
});
const update = () => view.update(getProps());
this.listenTo(message, 'change', update);
this.listenTo(message, 'expired', onClose);
// We could listen to all involved contacts, but we'll call that overkill
this.listenBack(view);
view.render();
}
showStickerManager(): void {
const view = new Whisper.ReactWrapperView({
className: ['sticker-manager-wrapper', 'panel'].join(' '),
JSX: window.Signal.State.Roots.createStickerManager(window.reduxStore),
onClose: () => {
this.resetPanel();
},
});
this.listenBack(view);
view.render();
}
showContactDetail({
contact,
signalAccount,
}: {
contact: EmbeddedContactType;
signalAccount?: string;
}): void {
const view = new Whisper.ReactWrapperView({
Component: window.Signal.Components.ContactDetail,
className: 'contact-detail-pane panel',
props: {
contact,
hasSignalAccount: Boolean(signalAccount),
onSendMessage: () => {
if (signalAccount) {
this.openConversation(signalAccount);
}
},
},
onClose: () => {
this.resetPanel();
},
});
this.listenBack(view);
}
// eslint-disable-next-line class-methods-use-this
async openConversation(
conversationId: string,
messageId?: string
): Promise<void> {
window.Whisper.events.trigger(
'showConversation',
conversationId,
messageId
);
}
listenBack(view: AnyViewClass): void {
this.panels = this.panels || [];
if (this.panels.length === 0) {
this.previousFocus = document.activeElement as HTMLElement;
}
this.panels.unshift(view);
view.$el.insertAfter(this.$('.panel').last());
view.$el.one('animationend', () => {
view.$el.addClass('panel--static');
});
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
view.headerTitle
);
}
resetPanel(): void {
if (!this.panels || !this.panels.length) {
return;
}
const view = this.panels.shift();
if (
this.panels.length === 0 &&
this.previousFocus &&
this.previousFocus.focus
) {
this.previousFocus.focus();
this.previousFocus = undefined;
}
if (this.panels.length > 0) {
this.panels[0].$el.fadeIn(250);
}
if (view) {
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'));
}
});
}
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
this.panels[0]?.headerTitle
);
}
endSession(): void {
const { model }: { model: ConversationModel } = this;
model.endSession();
}
async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const messages: Array<MessageAttributesType> = await window.Signal.Data.getMessagesWithVisualMediaAttachments(
model.id,
{
limit,
}
);
const loadedRecentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path || ''),
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
window.reduxActions.conversations.setRecentMediaItems(
model.id,
loadedRecentMediaItems
);
}
async setDisappearingMessages(seconds: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const valueToSet = seconds > 0 ? seconds : undefined;
await this.longRunningTaskWrapper({
name: 'updateExpirationTimer',
task: async () => model.updateExpirationTimer(valueToSet),
});
}
async changeHasGroupLink(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'toggleGroupLink',
task: async () => model.toggleGroupLink(value),
});
}
async copyGroupLink(groupLink: string): Promise<void> {
await navigator.clipboard.writeText(groupLink);
this.showToast(Whisper.GroupLinkCopiedToast);
}
async generateNewGroupLink(): Promise<void> {
const { model }: { model: ConversationModel } = this;
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('GroupLinkManagement--confirm-reset'),
okText: window.i18n('GroupLinkManagement--reset'),
resolve: async () => {
await this.longRunningTaskWrapper({
name: 'refreshGroupLink',
task: async () => model.refreshGroupLink(),
});
},
});
}
async setAccessControlAddFromInviteLinkSetting(
value: boolean
): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAddFromInviteLink',
task: async () => model.updateAccessControlAddFromInviteLink(value),
});
}
async setAccessControlAttributesSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
task: async () => model.updateAccessControlAttributes(value),
});
}
async setAccessControlMembersSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlMembers',
task: async () => model.updateAccessControlMembers(value),
});
}
async setAnnouncementsOnly(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
task: async () => model.updateAnnouncementsOnly(value),
});
}
async destroyMessages(): Promise<void> {
const { model }: { model: ConversationModel } = this;
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('deleteConversationConfirmation'),
okText: window.i18n('delete'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'destroymessages',
task: async () => {
model.trigger('unload', 'delete messages');
await model.destroyMessages();
model.updateLastMessage();
},
});
},
reject: () => {
window.log.info('destroyMessages: User canceled delete');
},
});
}
async isCallSafe(): Promise<boolean> {
const contacts = await this.getUntrustedContacts();
if (contacts && contacts.length) {
const callAnyway = await this.showSendAnywayDialog(
contacts.models,
window.i18n('callAnyway')
);
if (!callAnyway) {
window.log.info(
'Safety number change dialog not accepted, new call not allowed.'
);
return false;
}
}
return true;
}
// eslint-disable-next-line class-methods-use-this
showSendAnywayDialog(
contacts: Array<ConversationModel>,
confirmText?: string
): Promise<boolean> {
return new Promise(resolve => {
showSafetyNumberChangeDialog({
confirmText,
contacts,
reject: () => {
resolve(false);
},
resolve: () => {
resolve(true);
},
});
});
}
async sendReactionMessage(
messageId: string,
reaction: { emoji: string; remove: boolean }
): Promise<void> {
const messageModel = messageId
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: undefined;
try {
if (!messageModel) {
throw new Error('sendReactionMessage: Message not found');
}
const targetAuthorUuid = messageModel.getSourceUuid();
if (!targetAuthorUuid) {
throw new Error(
`sendReactionMessage: Message ${messageModel.idForLogging()} had no source uuid! Cannot send reaction.`
);
}
await this.model.sendReactionMessage(reaction, {
messageId,
targetAuthorUuid,
targetTimestamp: messageModel.get('sent_at'),
});
} catch (error) {
window.log.error('Error sending reaction', error, messageId, reaction);
this.showToast(Whisper.ReactionFailedToast);
}
}
async sendStickerMessage(options: {
packId: string;
stickerId: number;
force?: boolean;
}): Promise<void> {
const { model }: { model: ConversationModel } = this;
try {
const contacts = await this.getUntrustedContacts(options);
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts.models);
if (sendAnyway) {
this.sendStickerMessage({ ...options, force: true });
}
return;
}
if (this.showInvalidMessageToast()) {
return;
}
const { packId, stickerId } = options;
model.sendStickerMessage(packId, stickerId);
} catch (error) {
window.log.error(
'clickSend error:',
error && error.stack ? error.stack : error
);
}
}
async getUntrustedContacts(
options: { force?: boolean } = {}
): Promise<null | ConversationModelCollectionType> {
const { model }: { model: ConversationModel } = this;
// 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 model.updateVerified();
const unverifiedContacts = model.getUnverified();
if (options.force) {
if (unverifiedContacts.length) {
await this.markAllAsVerifiedDefault(unverifiedContacts.models);
// 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 = model.getUntrusted();
if (options.force) {
if (untrustedContacts.length) {
await this.markAllAsApproved(untrustedContacts.models);
}
} else if (untrustedContacts.length) {
return untrustedContacts;
}
return null;
}
async setQuoteMessage(messageId: null | string): Promise<void> {
const { model }: { model: ConversationModel } = this;
const message: MessageModel | undefined = messageId
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: undefined;
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
return;
}
if (message && !message.isNormalBubble()) {
return;
}
this.quote = undefined;
this.quotedMessage = undefined;
const existing = model.get('quotedMessageId');
if (existing !== messageId) {
this.model.set({
quotedMessageId: messageId,
draftChanged: true,
});
await this.saveModel();
}
if (message) {
const quotedMessage = window.MessageController.register(
message.id,
message
);
this.quotedMessage = quotedMessage;
if (quotedMessage) {
this.quote = await model.makeQuote(this.quotedMessage);
this.enableMessageField();
this.focusMessageField();
}
}
this.renderQuotedMessage();
}
renderQuotedMessage(): void {
const { model }: { model: ConversationModel } = this;
if (!this.quotedMessage) {
window.reduxActions.composer.setQuotedMessage(undefined);
return;
}
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote: this.quote,
});
}
showInvalidMessageToast(messageText?: string): boolean {
const { model }: { model: ConversationModel } = this;
let ToastView: undefined | typeof window.Whisper.ToastView;
if (window.reduxStore.getState().expiration.hasExpired) {
ToastView = Whisper.ExpiredToast;
}
if (!model.isValid()) {
ToastView = Whisper.InvalidConversationToast;
}
const e164 = this.model.get('e164');
const uuid = this.model.get('uuid');
if (
isDirectConversation(this.model.attributes) &&
((e164 && window.storage.blocked.isBlocked(e164)) ||
(uuid && window.storage.blocked.isUuidBlocked(uuid)))
) {
ToastView = Whisper.BlockedToast;
}
const groupId = this.model.get('groupId');
if (
!isDirectConversation(this.model.attributes) &&
groupId &&
window.storage.blocked.isGroupBlocked(groupId)
) {
ToastView = Whisper.BlockedGroupToast;
}
if (!isDirectConversation(model.attributes) && model.get('left')) {
ToastView = Whisper.LeftGroupToast;
}
if (messageText && messageText.length > MAX_MESSAGE_BODY_LENGTH) {
ToastView = Whisper.MessageBodyTooLongToast;
}
if (ToastView) {
this.showToast(ToastView);
return true;
}
return false;
}
async sendMessage(
message = '',
mentions: BodyRangesType = [],
options: { timestamp?: number; force?: boolean } = {}
): Promise<void> {
const { model }: { model: ConversationModel } = this;
const timestamp = options.timestamp || Date.now();
this.sendStart = Date.now();
try {
const contacts = await this.getUntrustedContacts(options);
this.disableMessageField();
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts.models);
if (sendAnyway) {
this.sendMessage(message, mentions, { force: true, timestamp });
return;
}
this.enableMessageField();
return;
}
} catch (error) {
this.enableMessageField();
window.log.error(
'sendMessage error:',
error && error.stack ? error.stack : error
);
return;
}
model.clearTypingTimers();
if (this.showInvalidMessageToast(message)) {
this.enableMessageField();
return;
}
try {
if (!message.length && !this.hasFiles() && !this.voiceNoteAttachment) {
return;
}
const attachments = await this.getFiles();
const sendHQImages =
window.reduxStore &&
window.reduxStore.getState().composer.shouldSendHighQualityAttachments;
const sendDelta = Date.now() - this.sendStart;
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
batchedUpdates(() => {
model.enqueueMessageForSend(
message,
attachments,
this.quote,
this.getLinkPreviewForSend(message),
undefined, // sticker
mentions,
{
sendHQImages,
timestamp,
}
);
this.compositionApi.current?.reset();
model.setMarkedUnread(false);
this.setQuoteMessage(null);
this.resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
});
} catch (error) {
window.log.error(
'Error pulling attached files before send',
error && error.stack ? error.stack : error
);
} finally {
this.enableMessageField();
}
}
onEditorStateChange(
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
): void {
this.maybeBumpTyping(messageText);
this.debouncedSaveDraft(messageText, bodyRanges);
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
}
async saveDraft(
messageText: string,
bodyRanges: Array<BodyRangeType>
): Promise<void> {
const { model }: { model: ConversationModel } = this;
const trimmed =
messageText && messageText.length > 0 ? messageText.trim() : '';
if (model.get('draft') && (!messageText || trimmed.length === 0)) {
this.model.set({
draft: null,
draftChanged: true,
draftBodyRanges: [],
});
await this.saveModel();
return;
}
if (messageText !== model.get('draft')) {
this.model.set({
draft: messageText,
draftChanged: true,
draftBodyRanges: bodyRanges,
});
await this.saveModel();
}
}
maybeGrabLinkPreview(message: string, caretLocation?: number): void {
// Don't generate link previews if user has turned them off
if (!window.Events.getLinkPreviewSetting()) {
return;
}
// Do nothing if we're offline
if (!window.textsecure.messaging) {
return;
}
// If we have attachments, don't add link preview
if (this.hasFiles()) {
return;
}
// If we're behind a user-configured proxy, we don't support link previews
if (window.isBehindProxy()) {
return;
}
if (!message) {
this.resetLinkPreview();
return;
}
if (this.disableLinkPreviews) {
return;
}
const links = LinkPreview.findLinks(message, caretLocation);
const { currentlyMatchedLink } = this;
if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) {
return;
}
this.currentlyMatchedLink = undefined;
this.excludedPreviewUrls = this.excludedPreviewUrls || [];
const link = links.find(
item =>
LinkPreview.isLinkSafeToPreview(item) &&
!this.excludedPreviewUrls.includes(item)
);
if (!link) {
this.removeLinkPreview();
return;
}
this.addLinkPreview(link);
}
resetLinkPreview(): void {
this.disableLinkPreviews = false;
this.excludedPreviewUrls = [];
this.removeLinkPreview();
}
removeLinkPreview(): void {
(this.preview || []).forEach((item: LinkPreviewResult) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
this.preview = undefined;
this.currentlyMatchedLink = undefined;
this.linkPreviewAbortController?.abort();
this.linkPreviewAbortController = undefined;
window.reduxActions.linkPreviews.removeLinkPreview();
}
// eslint-disable-next-line class-methods-use-this
async getStickerPackPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | LinkPreviewResult> {
const isPackDownloaded = (
pack?: StickerPackDBType
): pack is StickerPackDBType => {
if (!pack) {
return false;
}
return pack.status === 'downloaded' || pack.status === 'installed';
};
const isPackValid = (
pack?: StickerPackDBType
): pack is StickerPackDBType => {
if (!pack) {
return false;
}
return (
pack.status === 'ephemeral' ||
pack.status === 'downloaded' ||
pack.status === 'installed'
);
};
const dataFromLink = Stickers.getDataFromLink(url);
if (!dataFromLink) {
return null;
}
const { id, key } = dataFromLink;
try {
const keyBytes = window.Signal.Crypto.bytesFromHexString(key);
const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes);
const existing = Stickers.getStickerPack(id);
if (!isPackDownloaded(existing)) {
await Stickers.downloadEphemeralPack(id, keyBase64);
}
if (abortSignal.aborted) {
return null;
}
const pack = Stickers.getStickerPack(id);
if (!isPackValid(pack)) {
return null;
}
if (pack.key !== keyBase64) {
return null;
}
const { title, coverStickerId } = pack;
const sticker = pack.stickers[coverStickerId];
const data =
pack.status === 'ephemeral'
? await window.Signal.Migrations.readTempData(sticker.path)
: await window.Signal.Migrations.readStickerData(sticker.path);
if (abortSignal.aborted) {
return null;
}
return {
date: null,
description: null,
image: {
...sticker,
data,
size: data.byteLength,
contentType: IMAGE_WEBP,
},
title,
url,
};
} catch (error) {
window.log.error(
'getStickerPackPreview error:',
error && error.stack ? error.stack : error
);
return null;
} finally {
if (id) {
await Stickers.removeEphemeralPack(id);
}
}
}
// eslint-disable-next-line class-methods-use-this
async getGroupPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | LinkPreviewResult> {
const urlObject = maybeParseUrl(url);
if (!urlObject) {
return null;
}
const { hash } = urlObject;
if (!hash) {
return null;
}
const groupData = hash.slice(1);
const {
inviteLinkPassword,
masterKey,
} = window.Signal.Groups.parseGroupLink(groupData);
const fields = window.Signal.Groups.deriveGroupFields(
Bytes.fromBase64(masterKey)
);
const id = Bytes.toBase64(fields.id);
const logId = `groupv2(${id})`;
const secretParams = Bytes.toBase64(fields.secretParams);
window.log.info(`getGroupPreview/${logId}: Fetching pre-join state`);
const result = await window.Signal.Groups.getPreJoinGroupInfo(
inviteLinkPassword,
masterKey
);
if (abortSignal.aborted) {
return null;
}
const title =
window.Signal.Groups.decryptGroupTitle(result.title, secretParams) ||
window.i18n('unknownGroup');
const description =
result.memberCount === 1 || result.memberCount === undefined
? window.i18n('GroupV2--join--member-count--single')
: window.i18n('GroupV2--join--member-count--multiple', {
count: result.memberCount.toString(),
});
let image: undefined | LinkPreviewImage;
if (result.avatar) {
try {
const data = await window.Signal.Groups.decryptGroupAvatar(
result.avatar,
secretParams
);
image = {
data,
size: data.byteLength,
contentType: IMAGE_JPEG,
blurHash: await window.imageToBlurHash(
new Blob([data], {
type: IMAGE_JPEG,
})
),
};
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`getGroupPreview/${logId}: Failed to fetch avatar ${errorString}`
);
}
}
if (abortSignal.aborted) {
return null;
}
return {
date: null,
description,
image,
title,
url,
};
}
async getPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | LinkPreviewResult> {
if (LinkPreview.isStickerPack(url)) {
return this.getStickerPackPreview(url, abortSignal);
}
if (LinkPreview.isGroupLink(url)) {
return this.getGroupPreview(url, abortSignal);
}
// This is already checked elsewhere, but we want to be extra-careful.
if (!LinkPreview.isLinkSafeToPreview(url)) {
return null;
}
const linkPreviewMetadata = await window.textsecure.messaging.fetchLinkPreviewMetadata(
url,
abortSignal
);
if (!linkPreviewMetadata || abortSignal.aborted) {
return null;
}
const { title, imageHref, description, date } = linkPreviewMetadata;
let image;
if (imageHref && LinkPreview.isLinkSafeToPreview(imageHref)) {
let objectUrl: void | string;
try {
const fullSizeImage = await window.textsecure.messaging.fetchLinkPreviewImage(
imageHref,
abortSignal
);
if (abortSignal.aborted) {
return null;
}
if (!fullSizeImage) {
throw new Error('Failed to fetch link preview image');
}
// Ensure that this file is either small enough or is resized to meet our
// requirements for attachments
const withBlob = await autoScale({
contentType: fullSizeImage.contentType,
file: new Blob([fullSizeImage.data], {
type: fullSizeImage.contentType,
}),
fileName: title,
});
const data = await this.arrayBufferFromFile(withBlob.file);
objectUrl = URL.createObjectURL(withBlob.file);
const blurHash = await window.imageToBlurHash(withBlob.file);
const dimensions = await VisualAttachment.getImageDimensions({
objectUrl,
logger: window.log,
});
image = {
data,
size: data.byteLength,
...dimensions,
contentType: withBlob.file.type,
blurHash,
};
} 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);
}
}
}
if (abortSignal.aborted) {
return null;
}
return {
date: date || null,
description: description || null,
image,
title,
url,
};
}
async addLinkPreview(url: string): Promise<void> {
if (this.currentlyMatchedLink === url) {
window.log.warn(
'addLinkPreview should not be called with the same URL like this'
);
return;
}
(this.preview || []).forEach((item: LinkPreviewResult) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
window.reduxActions.linkPreviews.removeLinkPreview();
this.preview = undefined;
// Cancel other in-flight link preview requests.
if (this.linkPreviewAbortController) {
window.log.info(
'addLinkPreview: canceling another in-flight link preview request'
);
this.linkPreviewAbortController.abort();
}
const thisRequestAbortController = new AbortController();
this.linkPreviewAbortController = thisRequestAbortController;
const timeout = setTimeout(() => {
thisRequestAbortController.abort();
}, LINK_PREVIEW_TIMEOUT);
this.currentlyMatchedLink = url;
this.renderLinkPreview();
try {
const result = await this.getPreview(
url,
thisRequestAbortController.signal
);
if (!result) {
window.log.info(
'addLinkPreview: failed to load preview (not necessarily a problem)'
);
// This helps us disambiguate between two kinds of failure:
//
// 1. We failed to fetch the preview because of (1) a network failure (2) an
// invalid response (3) a timeout
// 2. We failed to fetch the preview because we aborted the request because the
// user changed the link (e.g., by continuing to type the URL)
const failedToFetch = this.currentlyMatchedLink === url;
if (failedToFetch) {
this.excludedPreviewUrls.push(url);
this.removeLinkPreview();
}
return;
}
if (result.image && result.image.data) {
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;
}
window.reduxActions.linkPreviews.addLinkPreview({
...result,
description: dropNull(result.description),
date: dropNull(result.date),
domain: LinkPreview.getDomain(result.url),
isStickerPack: LinkPreview.isStickerPack(result.url),
});
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();
} finally {
clearTimeout(timeout);
}
}
renderLinkPreview(): void {
if (this.forwardMessageModal) {
return;
}
window.reduxActions.composer.setLinkPreviewResult(
Boolean(this.currentlyMatchedLink),
this.getLinkPreviewWithDomain()
);
}
getLinkPreviewForSend(message: string): Array<LinkPreviewType> {
// Don't generate link previews if user has turned them off
if (!window.storage.get('linkPreviews', false)) {
return [];
}
if (!this.preview) {
return [];
}
const urlsInMessage = new Set<string>(LinkPreview.findLinks(message));
return (
this.preview
// This bullet-proofs against sending link previews for URLs that are no longer in
// the message. This can happen if you have a link preview, then quickly delete
// the link and send the message.
.filter(({ url }: Readonly<{ url: string }>) => urlsInMessage.has(url))
.map((item: LinkPreviewResult) => {
if (item.image) {
// We eliminate the ObjectURL here, unneeded for send or save
return {
...item,
image: omit(item.image, 'url'),
description: dropNull(item.description),
date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url),
};
}
return {
...item,
description: dropNull(item.description),
date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url),
};
})
);
}
getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined {
if (!this.preview || !this.preview.length) {
return undefined;
}
const [preview] = this.preview;
return {
...preview,
domain: LinkPreview.getDomain(preview.url),
};
}
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping(messageText: string): void {
if (messageText.length && this.model.throttledBumpTyping) {
this.model.throttledBumpTyping();
}
}
}
window.Whisper.ConversationView = ConversationView;