signal-desktop/ts/views/conversation_view.ts

4727 lines
134 KiB
TypeScript
Raw Normal View History

// Copyright 2020-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2021-08-09 20:06:21 +00:00
import nodePath from 'path';
2021-08-11 16:23:21 +00:00
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom';
2021-08-30 21:32:56 +00:00
import { debounce, flatten, omit, pick, reject, throttle } from 'lodash';
import { render } from 'mustache';
2021-08-11 16:23:21 +00:00
2021-06-25 16:08:16 +00:00
import {
AttachmentDraftType,
AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
2021-07-14 23:39:52 +00:00
isGIF,
2021-06-25 16:08:16 +00:00
} from '../types/Attachment';
2021-07-09 19:36:10 +00:00
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
import * as Stickers from '../types/Stickers';
2021-08-30 21:32:56 +00:00
import { BodyRangeType, BodyRangesType } from '../types/Util';
2021-08-09 20:06:21 +00:00
import {
IMAGE_JPEG,
IMAGE_WEBP,
isHeic,
2021-08-30 21:32:56 +00:00
MIMEType,
2021-08-09 20:06:21 +00:00
stringToMIMEType,
} from '../types/MIME';
2021-04-27 22:35:35 +00:00
import { ConversationModel } from '../models/conversations';
import {
GroupV2PendingMemberType,
MessageModelCollectionType,
MessageAttributesType,
2021-08-30 21:32:56 +00:00
ConversationModelCollectionType,
QuotedMessageType,
} from '../model-types.d';
2021-04-27 22:35:35 +00:00
import { LinkPreviewType } from '../types/message/LinkPreviews';
2021-08-25 21:08:32 +00:00
import {
MediaItemType,
MessageAttributesType as MediaItemMessageType,
} from '../types/MediaItem';
import { MessageModel } from '../models/messages';
2021-08-30 21:32:56 +00:00
import { strictAssert } from '../util/assert';
import { maybeParseUrl } from '../util/url';
import { replaceIndex } from '../util/replaceIndex';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
2021-06-01 23:30:25 +00:00
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
import {
isDirectConversation,
isGroupV1,
isMe,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
2021-06-22 14:46:42 +00:00
import * as Bytes from '../Bytes';
import {
canReply,
getAttachmentsForMessage,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
import { isMessageUnread } from '../util/isMessageUnread';
2021-08-23 23:14:53 +00:00
import {
getConversationSelector,
getMessagesByConversation,
} from '../state/selectors/conversations';
2021-06-17 21:15:09 +00:00
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
2021-06-25 16:08:16 +00:00
import {
LinkPreviewResult,
2021-08-30 21:32:56 +00:00
LinkPreviewImage,
2021-06-25 16:08:16 +00:00
LinkPreviewWithDomain,
} from '../types/LinkPreview';
import * as LinkPreview from '../types/LinkPreview';
2021-07-09 19:36:10 +00:00
import { SignalService as Proto } from '../protobuf';
2021-07-28 00:09:10 +00:00
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';
2021-08-30 21:32:56 +00:00
import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d';
import { isNotNil } from '../util/isNotNil';
import { dropNull } from '../util/dropNull';
import { CompositionAPIType } from '../components/CompositionArea';
import * as log from '../logging/log';
2021-09-21 20:45:25 +00:00
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
2020-09-28 23:46:31 +00:00
type AttachmentOptions = {
messageId: string;
attachment: AttachmentType;
};
const FIVE_MINUTES = 1000 * 60 * 5;
2020-09-28 23:46:31 +00:00
const LINK_PREVIEW_TIMEOUT = 60 * 1000;
window.Whisper = window.Whisper || {};
const { Whisper } = window;
2021-07-14 23:39:52 +00:00
const { Message, MIME, VisualAttachment } = window.Signal.Types;
const {
copyIntoTempDirectory,
deleteDraftFile,
deleteTempFile,
getAbsoluteAttachmentPath,
getAbsoluteDraftPath,
getAbsoluteTempPath,
loadAttachmentData,
2021-04-27 22:35:35 +00:00
loadPreviewData,
loadStickerData,
openFileInFolder,
readAttachmentData,
readDraftData,
saveAttachmentToDisk,
upgradeMessageSchema,
writeNewDraftData,
} = window.Signal.Migrations;
const {
getOlderMessagesByConversation,
getMessageMetricsForConversation,
getMessageById,
getMessagesBySentAt,
getNewerMessagesByConversation,
} = window.Signal.Data;
2021-08-30 21:32:56 +00:00
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) {
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') };
},
});
2020-10-02 18:30:43 +00:00
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') };
},
});
2021-07-20 20:18:35 +00:00
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'),
});
2021-08-30 21:32:56 +00:00
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);
2021-08-30 21:32:56 +00:00
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);
}
);
2021-08-30 21:32:56 +00:00
this.listenTo(
this.model,
'save-attachment',
this.downloadAttachmentWrapper
);
2021-08-30 21:32:56 +00:00
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();
2021-06-25 16:08:16 +00:00
this.setupCompositionArea();
this.updateAttachmentsView();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
events(): Record<string, string> {
return {
'click .capture-audio .microphone': 'captureAudio',
'change input.file-input': 'onChoseAttachment',
2021-08-30 21:32:56 +00:00
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')) {
return expires.format('hh:mm A');
}
return expires.format('M/D/YY, hh:mm A');
2021-08-30 21:32:56 +00:00
}
2021-08-05 12:35:33 +00:00
setMuteExpiration(ms = 0): void {
2021-08-30 21:32:56 +00:00
this.model.setMuteExpiration(
2021-08-05 12:35:33 +00:00
ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
setPin(value: boolean): void {
2020-10-02 18:30:43 +00:00
if (value) {
const pinnedConversationIds = window.storage.get(
2020-11-09 18:30:05 +00:00
'pinnedConversationIds',
new Array<string>()
2020-11-09 18:30:05 +00:00
);
2020-10-10 14:25:17 +00:00
if (pinnedConversationIds.length >= 4) {
2020-10-02 18:30:43 +00:00
this.showToast(Whisper.PinnedConversationsFullToast);
return;
}
2021-08-30 21:32:56 +00:00
this.model.pin();
2020-10-02 18:30:43 +00:00
} else {
2021-08-30 21:32:56 +00:00
this.model.unpin();
2020-10-02 18:30:43 +00:00
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
setupHeader(): void {
2020-10-30 17:52:21 +00:00
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
JSX: window.Signal.State.Roots.createConversationHeader(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
id: this.model.id,
2020-10-30 17:52:21 +00:00
2021-01-28 00:18:50 +00:00
onShowContactModal: this.showContactModal.bind(this),
2020-10-30 17:52:21 +00:00
onSetDisappearingMessages: (seconds: number) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
2021-08-30 21:32:56 +00:00
const name = isMe(this.model.attributes)
2020-10-30 17:52:21 +00:00
? window.i18n('noteToSelf')
2021-08-30 21:32:56 +00:00
: this.model.getTitle();
searchInConversation(this.model.id, name);
2020-10-30 17:52:21 +00:00
},
2021-08-05 12:35:33 +00:00
onSetMuteNotifications: this.setMuteExpiration.bind(this),
2020-10-30 17:52:21 +00:00
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 () => {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingAudioCallInConversation: about to start an audio call'
);
2020-10-30 17:52:21 +00:00
const isVideoCall = false;
if (await this.isCallSafe()) {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
2021-08-30 21:32:56 +00:00
this.model.id,
2020-10-30 17:52:21 +00:00
isVideoCall
);
log.info('onOutgoingAudioCallInConversation: started the call');
2020-10-30 17:52:21 +00:00
} else {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
2020-10-30 17:52:21 +00:00
onOutgoingVideoCallInConversation: async () => {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingVideoCallInConversation: about to start a video call'
);
2020-10-30 17:52:21 +00:00
const isVideoCall = true;
2021-08-30 21:32:56 +00:00
if (
this.model.get('announcementsOnly') &&
!this.model.areWeAdmin()
) {
2021-07-20 20:18:35 +00:00
this.showToast(Whisper.CannotStartGroupCallToast);
return;
}
2020-10-30 17:52:21 +00:00
if (await this.isCallSafe()) {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
2021-08-30 21:32:56 +00:00
this.model.id,
2020-10-30 17:52:21 +00:00
isVideoCall
);
log.info('onOutgoingVideoCallInConversation: started the call');
2020-10-30 17:52:21 +00:00
} else {
log.info(
2020-10-30 17:52:21 +00:00
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
2021-05-28 16:15:17 +00:00
onShowChatColorEditor: () => {
this.showChatColorEditor();
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
2020-10-30 17:52:21 +00:00
onShowSafetyNumber: () => {
this.showSafetyNumber();
},
onShowAllMedia: () => {
this.showAllMedia();
},
2021-06-17 21:15:09 +00:00
onShowGroupMembers: () => {
this.showGV1Members();
2020-10-30 17:52:21 +00:00
},
onGoBack: () => {
this.resetPanel();
},
onArchive: () => {
2021-08-30 21:32:56 +00:00
this.model.setArchived(true);
this.model.trigger('unload', 'archive');
2020-10-30 17:52:21 +00:00
Whisper.ToastView.show(
Whisper.ConversationArchivedToast,
document.body
);
2020-10-30 17:52:21 +00:00
},
onMarkUnread: () => {
2021-08-30 21:32:56 +00:00
this.model.setMarkedUnread(true);
2020-10-30 17:52:21 +00:00
Whisper.ToastView.show(
Whisper.ConversationMarkedUnreadToast,
document.body
);
},
onMoveToInbox: () => {
2021-08-30 21:32:56 +00:00
this.model.setArchived(false);
2020-10-30 17:52:21 +00:00
Whisper.ToastView.show(
Whisper.ConversationUnarchivedToast,
document.body
);
},
}
),
});
this.$('.conversation-header').append(this.titleView.el);
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
setupCompositionArea(): void {
2021-06-25 16:08:16 +00:00
window.reduxActions.composer.resetComposer();
const micCellEl = $(`
<div class="capture-audio">
<button class="microphone"></button>
</div>
`)[0];
2021-07-09 19:36:10 +00:00
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const props = {
2021-08-30 21:32:56 +00:00
id: this.model.id,
compositionApi: this.compositionApi,
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId: string, stickerId: number) =>
this.sendStickerMessage({ packId, stickerId }),
2020-11-03 01:19:52 +00:00
onSubmit: (
message: string,
2021-08-30 21:32:56 +00:00
mentions: BodyRangesType,
2021-07-30 18:37:03 +00:00
timestamp: number
) => this.sendMessage(message, mentions, { timestamp }),
2020-11-03 01:19:52 +00:00
onEditorStateChange: (
msg: string,
2021-08-30 21:32:56 +00:00
bodyRanges: Array<BodyRangeType>,
2021-04-27 22:35:35 +00:00
caretLocation?: number
2020-11-03 01:19:52 +00:00
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
onChooseAttachment: this.onChooseAttachment.bind(this),
2021-08-30 21:32:56 +00:00
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl,
2020-10-06 17:06:34 +00:00
onAccept: () => {
2021-06-01 23:30:25 +00:00
this.syncMessageRequestResponse(
'onAccept',
2021-08-30 21:32:56 +00:00
this.model,
2021-06-01 23:30:25 +00:00
messageRequestEnum.ACCEPT
);
2020-10-06 17:06:34 +00:00
},
onBlock: () => {
2021-06-01 23:30:25 +00:00
this.syncMessageRequestResponse(
'onBlock',
2021-08-30 21:32:56 +00:00
this.model,
2021-06-01 23:30:25 +00:00
messageRequestEnum.BLOCK
);
2020-10-06 17:06:34 +00:00
},
onUnblock: () => {
2021-06-01 23:30:25 +00:00
this.syncMessageRequestResponse(
'onUnblock',
2021-08-30 21:32:56 +00:00
this.model,
2021-06-01 23:30:25 +00:00
messageRequestEnum.ACCEPT
);
2020-10-06 17:06:34 +00:00
},
onDelete: () => {
2021-06-01 23:30:25 +00:00
this.syncMessageRequestResponse(
'onDelete',
2021-08-30 21:32:56 +00:00
this.model,
2021-06-01 23:30:25 +00:00
messageRequestEnum.DELETE
);
2020-10-06 17:06:34 +00:00
},
onBlockAndReportSpam: () => {
2021-08-30 21:32:56 +00:00
this.blockAndReportSpam(this.model);
2020-10-06 17:06:34 +00:00
},
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',
2021-08-30 21:32:56 +00:00
task: async () => this.model.cancelJoinRequest(),
});
},
});
},
2021-06-25 16:08:16 +00:00
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.removeDraftAttachment.bind(this),
2021-06-25 16:08:16 +00:00
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
2021-08-30 21:32:56 +00:00
onClickQuotedMessage: (id: string) => this.scrollToMessage(id),
2021-06-25 16:08:16 +00:00
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
2021-07-20 20:18:35 +00:00
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
2021-07-20 20:18:35 +00:00
this.$('.CompositionArea__placeholder').append(this.compositionAreaView.el);
2021-08-30 21:32:56 +00:00
}
async longRunningTaskWrapper<T>({
2020-10-06 17:06:34 +00:00
name,
task,
}: {
name: string;
task: () => Promise<T>;
}): Promise<T> {
2021-08-30 21:32:56 +00:00
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
2021-08-30 21:32:56 +00:00
}
2020-10-06 17:06:34 +00:00
2021-08-30 21:32:56 +00:00
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);
};
2021-08-30 21:32:56 +00:00
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!`
);
2021-01-29 22:58:28 +00:00
}
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: {
2021-07-14 23:39:52 +00:00
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);
};
2021-09-21 20:45:25 +00:00
const openLink = openLinkInWebBrowser;
const downloadNewVersion = () => {
2021-09-21 20:45:25 +00:00
openLinkInWebBrowser('https://signal.org/download');
};
const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId);
};
const showExpiredIncomingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an incoming message');
this.showToast(Whisper.TapToViewExpiredIncomingToast);
};
const showExpiredOutgoingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an outgoing message');
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
};
2021-08-30 21:32:56 +00:00
2021-04-27 22:35:35 +00:00
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,
2021-04-27 22:35:35 +00:00
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
};
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
setupTimeline(): void {
2021-07-09 19:36:10 +00:00
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
2021-04-21 16:31:12 +00:00
2021-02-18 16:40:26 +00:00
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);
2021-09-21 20:45:25 +00:00
openLinkInWebBrowser(url);
2021-02-18 16:40:26 +00:00
};
const learnMoreAboutDeliveryIssue = () => {
2021-09-21 20:45:25 +00:00
openLinkInWebBrowser(
'https://support.signal.org/hc/articles/4404859745690'
);
};
const scrollToQuotedMessage = async (
options: Readonly<{
authorId: string;
sentAt: number;
}>
) => {
const { authorId, sentAt } = options;
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
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) {
log.warn('loadOlderMessages: requested, but loaded no messages');
repairOldestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
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');
2021-08-30 21:32:56 +00:00
const models = await getNewerMessagesByConversation(conversationId, {
receivedAt,
sentAt,
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
if (models.length < 1) {
log.warn('loadNewerMessages: requested, but loaded no messages');
repairNewestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
2021-08-30 21:32:56 +00:00
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}`);
}
2021-08-30 21:32:56 +00:00
await this.model.markRead(message.get('received_at'));
};
2021-06-01 23:30:25 +00:00
const createMessageRequestResponseHandler = (
name: string,
enumValue: number
): ((conversationId: string) => void) => conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(
2021-08-30 21:32:56 +00:00
`createMessageRequestResponseHandler: Expected a conversation to be found in ${name}. Doing nothing`
2021-06-01 23:30:25 +00:00
);
return;
}
this.syncMessageRequestResponse(name, conversation, enumValue);
};
this.timelineView = new Whisper.ReactWrapperView({
className: 'timeline-wrapper',
JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, {
2021-08-30 21:32:56 +00:00
id: this.model.id,
...this.getMessageActions(),
2021-06-01 23:30:25 +00:00
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
2021-08-30 21:32:56 +00:00
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
2021-06-01 23:30:25 +00:00
},
2021-02-18 16:40:26 +00:00
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages,
loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages,
markMessageRead,
2021-06-01 23:30:25 +00:00
onBlock: createMessageRequestResponseHandler(
'onBlock',
messageRequestEnum.BLOCK
),
onBlockAndReportSpam: (conversationId: string) => {
const conversation = window.ConversationController.get(
conversationId
2021-04-21 16:31:12 +00:00
);
2021-06-01 23:30:25 +00:00
if (!conversation) {
log.error(
2021-08-30 21:32:56 +00:00
`onBlockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
2021-06-01 23:30:25 +00:00
);
return;
}
this.blockAndReportSpam(conversation);
2021-04-21 16:31:12 +00:00
},
2021-06-01 23:30:25 +00:00
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
messageRequestEnum.ACCEPT
),
2021-04-21 16:31:12 +00:00
onShowContactModal: this.showContactModal.bind(this),
2021-06-01 23:30:25 +00:00
removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({
name: 'removeMember',
2021-08-30 21:32:56 +00:00
task: () => this.model.removeFromGroupV2(conversationId),
2021-06-01 23:30:25 +00:00
});
},
scrollToQuotedMessage,
unblurAvatar: () => {
2021-08-30 21:32:56 +00:00
this.model.unblurAvatar();
},
2021-08-30 21:32:56 +00:00
updateSharedGroups: this.model.throttledUpdateSharedGroups,
}),
});
this.$('.timeline-placeholder').append(this.timelineView.el);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
private showToast(
ToastView: typeof window.Whisper.ToastView,
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: any,
element?: Element
): void {
const toast = new ToastView(options);
2021-04-27 22:35:35 +00:00
if (element) {
toast.$el.appendTo(element);
} else {
2021-09-07 16:12:26 +00:00
const lightboxEl = $('.Lightbox');
2021-04-27 22:35:35 +00:00
if (lightboxEl.length > 0) {
toast.$el.appendTo(lightboxEl);
} else {
toast.$el.appendTo(this.$el);
}
}
toast.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// 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) {
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;
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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()`.
2021-08-30 21:32:56 +00:00
const messagesByConversation = getMessagesByConversation(state)[
this.model.id
];
if (!messagesByConversation?.messageIds.includes(messageId)) {
isInMemory = false;
}
if (isInMemory) {
const { scrollToMessage } = window.reduxActions.conversations;
2021-08-30 21:32:56 +00:00
scrollToMessage(this.model.id, messageId);
return;
}
this.loadAndScroll(messageId);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
setInProgressFetch(): () => unknown {
let resolvePromise: (value?: unknown) => void;
2021-08-30 21:32:56 +00:00
this.model.inProgressFetch = new Promise(resolve => {
resolvePromise = resolve;
});
const finish = () => {
resolvePromise();
2021-08-30 21:32:56 +00:00
this.model.inProgressFetch = undefined;
};
return finish;
2021-08-30 21:32:56 +00:00
}
async loadAndScroll(
messageId: string,
options?: { disableScroll?: boolean }
2021-08-30 21:32:56 +00:00
): Promise<void> {
const {
messagesReset,
setMessagesLoading,
} = window.reduxActions.conversations;
2021-08-30 21:32:56 +00:00
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();
}
2021-08-30 21:32:56 +00:00
}
async loadNewestMessages(
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
const {
messagesReset,
setMessagesLoading,
} = window.reduxActions.conversations;
2021-08-30 21:32:56 +00:00
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 {
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.
2021-08-30 21:32:56 +00:00
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();
}
2021-08-30 21:32:56 +00:00
}
async startMigrationToGV2(): Promise<void> {
2021-08-30 21:32:56 +00:00
const logId = this.model.idForLogging();
2021-08-30 21:32:56 +00:00
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',
2021-08-30 21:32:56 +00:00
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',
2021-08-30 21:32:56 +00:00
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,
}
),
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
onChooseAttachment(): void {
this.$('input.file-input').click();
2021-08-30 21:32:56 +00:00
}
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();
}
2021-08-30 21:32:56 +00:00
fileField.val([]);
}
2021-08-30 21:32:56 +00:00
unload(reason: string): void {
log.info(
'unloading conversation',
2021-08-30 21:32:56 +00:00
this.model.idForLogging(),
'due to:',
reason
);
const { conversationUnloaded } = window.reduxActions.conversations;
if (conversationUnloaded) {
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
this.model.updateLastMessage();
}
2021-08-30 21:32:56 +00:00
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();
}
2020-10-30 17:52:21 +00:00
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
}
this.removeLinkPreview();
this.disableLinkPreviews = true;
this.remove();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async onDrop(e: JQuery.TriggeredEvent): Promise<void> {
if (!e.originalEvent) {
return;
}
2021-08-30 21:32:56 +00:00
const event = e.originalEvent as DragEvent;
if (!event.dataTransfer) {
return;
}
2021-08-30 21:32:56 +00:00
if (event.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
2021-08-30 21:32:56 +00:00
const { files } = event.dataTransfer;
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
2021-08-20 20:33:06 +00:00
this.maybeAddAttachment(file);
}
2021-08-30 21:32:56 +00:00
}
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') {
2021-08-30 21:32:56 +00:00
const file = items[i].getAsFile();
if (file) {
this.maybeAddAttachment(file);
}
}
}
2021-08-30 21:32:56 +00:00
}
2021-04-20 14:46:09 +00:00
syncMessageRequestResponse(
name: string,
2021-06-01 23:30:25 +00:00
model: ConversationModel,
2021-04-20 14:46:09 +00:00
messageRequestType: number
): Promise<void> {
return this.longRunningTaskWrapper({
name,
task: model.syncMessageRequestResponse.bind(model, messageRequestType),
});
2021-08-30 21:32:56 +00:00
}
2021-04-20 14:46:09 +00:00
2021-06-01 23:30:25 +00:00
blockAndReportSpam(model: ConversationModel): Promise<void> {
2021-07-09 19:36:10 +00:00
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);
},
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
onClickAttachment(attachment: AttachmentDraftType): void {
if (attachment.pending) {
throw new Error(
'onClickAttachment: Cannot click to edit pending attachment'
);
}
2021-08-30 21:32:56 +00:00
const getProps = () => {
if (attachment.pending) {
throw new Error(
'onClickAttachment/onSave: Cannot render pending attachment'
);
}
return {
url: attachment.url,
caption: attachment.caption,
attachment,
onSave,
};
};
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
}
if (
(item.path && item.path === attachment.path) ||
(item.screenshotPath &&
item.screenshotPath === attachment.screenshotPath)
) {
return {
...attachment,
caption,
};
}
return item;
}),
draftChanged: true,
});
2021-08-30 21:32:56 +00:00
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);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// 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);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async saveModel(): Promise<void> {
window.Signal.Data.updateConversation(this.model.attributes);
}
2021-08-30 21:32:56 +00:00
async addAttachment(attachment: InMemoryAttachmentDraftType): Promise<void> {
const onDisk = await this.writeDraftAttachment(attachment);
2021-08-09 20:06:21 +00:00
// Remove any pending attachments that were transcoding
const draftAttachments = this.model.get('draftAttachments') || [];
const index = draftAttachments.findIndex(
draftAttachment => draftAttachment.path === attachment.path
2021-08-09 20:06:21 +00:00
);
if (index < 0) {
log.warn(
`addAttachment: Failed to find pending attachment with path ${attachment.path}`
);
this.model.set({
draftAttachments: [...draftAttachments, onDisk],
});
} else {
this.model.set({
draftAttachments: replaceIndex(draftAttachments, index, onDisk),
});
}
this.updateAttachmentsView();
2021-02-26 15:08:59 +00:00
await this.saveModel();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
2021-06-25 16:08:16 +00:00
resolveOnDiskAttachment(
attachment: OnDiskAttachmentDraftType
): AttachmentDraftType {
let url = '';
2021-08-30 21:32:56 +00:00
if (attachment.pending) {
return attachment;
}
2021-06-25 16:08:16 +00:00
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
log.warn(
2021-06-25 16:08:16 +00:00
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
2021-08-30 21:32:56 +00:00
...pick(attachment, [
'blurHash',
'caption',
'contentType',
'fileName',
'path',
'size',
]),
pending: false,
2021-06-25 16:08:16 +00:00
url,
};
2021-08-30 21:32:56 +00:00
}
2021-06-25 16:08:16 +00:00
async removeDraftAttachment(
attachment: Pick<AttachmentType, 'path' | 'screenshotPath'>
): Promise<void> {
2021-08-30 21:32:56 +00:00
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({
2021-08-30 21:32:56 +00:00
draftAttachments: reject(
draftAttachments,
item => item.path === attachment.path
),
draftChanged: true,
});
this.updateAttachmentsView();
await this.saveModel();
await this.deleteDraftAttachment(attachment);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async clearAttachments(): Promise<void> {
this.voiceNoteAttachment = undefined;
2021-08-30 21:32:56 +00:00
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)
)
),
]);
2021-08-30 21:32:56 +00:00
}
hasFiles(options: { includePending: boolean }): boolean {
2021-08-30 21:32:56 +00:00
const draftAttachments = this.model.get('draftAttachments') || [];
if (options.includePending) {
return draftAttachments.length > 0;
}
return draftAttachments.some(item => !item.pending);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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];
}
2021-08-30 21:32:56 +00:00
const draftAttachments = this.model.get('draftAttachments') || [];
const items = await Promise.all(
draftAttachments.map(attachment => this.getFile(attachment))
);
2021-08-30 21:32:56 +00:00
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) {
log.error(
`Attachment size from disk ${data.byteLength} did not match attachment size ${attachment.size}`
);
2021-08-30 21:32:56 +00:00
return;
}
return {
2021-08-30 21:32:56 +00:00
...attachment,
data,
};
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
arrayBufferFromFile(file: Blob): Promise<ArrayBuffer> {
2021-08-30 21:32:56 +00:00
return new Promise((resolve, rejectPromise) => {
const FR = new FileReader();
2021-08-30 21:32:56 +00:00
FR.onload = () => {
if (!FR.result || typeof FR.result === 'string') {
rejectPromise(new Error('arrayBufferFromFile: No result!'));
return;
}
resolve(FR.result);
};
2021-08-30 21:32:56 +00:00
FR.onerror = rejectPromise;
FR.onabort = rejectPromise;
FR.readAsArrayBuffer(file);
});
2021-08-30 21:32:56 +00:00
}
showFileSizeError({
limit,
units,
u,
}: Readonly<{
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || [];
2021-06-25 16:08:16 +00:00
window.reduxActions.composer.replaceAttachments(
2021-08-30 21:32:56 +00:00
this.model.get('id'),
draftAttachments.map((att: OnDiskAttachmentDraftType) =>
this.resolveOnDiskAttachment(att)
)
2021-06-25 16:08:16 +00:00
);
this.toggleMicrophone();
if (this.hasFiles({ includePending: true })) {
this.removeLinkPreview();
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
2021-06-25 16:08:16 +00:00
async writeDraftAttachment(
attachment: InMemoryAttachmentDraftType
): Promise<OnDiskAttachmentDraftType> {
2021-08-30 21:32:56 +00:00
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);
2021-08-30 21:32:56 +00:00
}
if (attachment.screenshotData) {
result.screenshotPath = await writeNewDraftData(
attachment.screenshotData
);
2021-08-30 21:32:56 +00:00
}
return result;
2021-08-30 21:32:56 +00:00
}
2021-07-28 00:09:10 +00:00
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;
}
2021-08-30 21:32:56 +00:00
const draftAttachments = this.model.get('draftAttachments') || [];
if (draftAttachments.length >= 32) {
this.showToast(Whisper.MaxAttachmentsToast);
return;
}
2021-08-30 21:32:56 +00:00
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;
}
2021-08-09 20:06:21 +00:00
const fileType = stringToMIMEType(file.type);
2021-07-28 00:09:10 +00:00
// You can't add a non-image attachment if you already have attachments staged
2021-07-28 00:09:10 +00:00
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 {
2021-08-09 20:06:21 +00:00
if (
window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType) ||
isHeic(fileType)
) {
2021-07-28 00:09:10 +00:00
attachment = await handleImageAttachment(file);
2021-08-20 20:33:06 +00:00
const hasDraftAttachmentPending = (
2021-08-30 21:32:56 +00:00
this.model.get('draftAttachments') || []
2021-08-20 20:33:06 +00:00
).some(
draftAttachment =>
2021-08-30 21:32:56 +00:00
draftAttachment.pending && draftAttachment.path === path
2021-08-20 20:33:06 +00:00
);
// User has canceled the draft so we don't need to continue processing
if (!hasDraftAttachmentPending) {
return;
}
} else if (
2021-07-28 00:09:10 +00:00
window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType)
) {
attachment = await this.handleVideoAttachment(file);
} else {
const data = await this.arrayBufferFromFile(file);
attachment = {
2021-07-28 00:09:10 +00:00
contentType: fileType,
2021-08-30 21:32:56 +00:00
data,
fileName: file.name,
2021-08-30 21:32:56 +00:00
path: file.name,
pending: false,
size: data.byteLength,
};
}
} catch (e) {
log.error(
2021-07-28 00:09:10 +00:00
`Was unable to generate thumbnail for fileType ${fileType}`,
e && e.stack ? e.stack : e
);
const data = await this.arrayBufferFromFile(file);
attachment = {
2021-07-28 00:09:10 +00:00
contentType: fileType,
2021-08-30 21:32:56 +00:00
data,
fileName: file.name,
2021-08-30 21:32:56 +00:00
path: file.name,
pending: false,
size: data.byteLength,
};
}
try {
if (!this.isSizeOkay(attachment)) {
this.removeDraftAttachment(attachment);
}
} catch (error) {
log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
this.removeDraftAttachment(attachment);
this.showToast(Whisper.UnableToLoadToast);
return;
}
2021-02-26 15:08:59 +00:00
try {
await this.addAttachment(attachment);
} catch (error) {
log.error(
2021-02-26 15:08:59 +00:00
'Error saving draft attachment:',
error && error.stack ? error.stack : error
);
this.showToast(Whisper.UnableToLoadToast);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
}
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: log,
});
const screenshotData = await VisualAttachment.blobToArrayBuffer(
screenshotBlob
);
const data = await this.arrayBufferFromFile(file);
return {
2021-08-30 21:32:56 +00:00
contentType: stringToMIMEType(file.type),
data,
fileName: file.name,
2021-08-30 21:32:56 +00:00
path: file.name,
pending: false,
screenshotContentType,
screenshotData,
screenshotSize: screenshotData.byteLength,
size: data.byteLength,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// 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;
})
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
async markAllAsApproved(
untrusted: ReadonlyArray<ConversationModel>
): Promise<void> {
await Promise.all(untrusted.map(contact => contact.setApproved()));
}
2021-08-30 21:32:56 +00:00
toggleMicrophone(): void {
this.compositionApi.current?.setShowMic(
!this.hasFiles({ includePending: true })
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
captureAudio(e?: Event): void {
if (e) {
e.preventDefault();
}
2021-08-30 21:32:56 +00:00
if (this.compositionApi.current?.isDirty()) {
return;
}
if (this.hasFiles({ includePending: true })) {
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();
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
this.compositionApi.current?.setMicActive(true);
this.disableMessageField();
this.$('.microphone').hide();
2021-08-30 21:32:56 +00:00
}
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);
},
});
2021-08-30 21:32:56 +00:00
}
async handleAudioCapture(blob: Blob): Promise<void> {
if (this.hasFiles({ includePending: true })) {
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 = {
2021-08-30 21:32:56 +00:00
contentType: stringToMIMEType(blob.type),
data,
size: data.byteLength,
2021-07-09 19:36:10 +00:00
flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE,
};
// Note: The RecorderView removes itself on send
2021-08-30 21:32:56 +00:00
this.captureAudioView = undefined;
this.sendMessage();
2021-08-30 21:32:56 +00:00
}
endCaptureAudio(): void {
this.enableMessageField();
this.$('.microphone').show();
// Note: The RecorderView removes itself on close
2021-08-30 21:32:56 +00:00
this.captureAudioView = undefined;
2021-08-30 21:32:56 +00:00
this.compositionApi.current?.setMicActive(false);
}
2021-05-28 19:11:19 +00:00
2021-08-30 21:32:56 +00:00
async onOpened(messageId: string): Promise<void> {
if (messageId) {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
});
if (message) {
this.loadAndScroll(messageId);
return;
}
log.warn(`onOpened: Did not find message ${messageId}`);
}
2021-05-28 19:11:19 +00:00
const { retryPlaceholders } = window.Signal.Services;
if (retryPlaceholders) {
2021-08-30 21:32:56 +00:00
await retryPlaceholders.findByConversationAndMarkOpened(this.model.id);
2021-05-28 19:11:19 +00:00
}
2021-08-30 21:32:56 +00:00
this.loadNewestMessages(undefined, undefined);
this.model.updateLastMessage();
this.focusMessageField();
2021-08-30 21:32:56 +00:00
const quotedMessageId = this.model.get('quotedMessageId');
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
}
2021-08-30 21:32:56 +00:00
this.model.fetchLatestGroupV2Data();
strictAssert(
this.model.throttledMaybeMigrateV1Group !== undefined,
2021-05-28 19:11:19 +00:00
'Conversation model should be initialized'
);
2021-08-30 21:32:56 +00:00
this.model.throttledMaybeMigrateV1Group();
strictAssert(
this.model.throttledFetchSMSOnlyUUID !== undefined,
'Conversation model should be initialized'
);
2021-08-30 21:32:56 +00:00
this.model.throttledFetchSMSOnlyUUID();
strictAssert(
this.model.throttledGetProfiles !== undefined,
'Conversation model should be initialized'
);
2021-08-30 21:32:56 +00:00
await this.model.throttledGetProfiles();
this.model.updateVerified();
}
2021-08-30 21:32:56 +00:00
// 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();
2021-08-30 21:32:56 +00:00
}
2021-09-07 16:12:26 +00:00
async showForwardMessageModal(messageId: string): Promise<void> {
const messageFromCache = window.MessageController.getById(messageId);
if (!messageFromCache) {
log.info('showForwardMessageModal: Fetching message from database');
2021-09-07 16:12:26 +00:00
}
const message =
messageFromCache ||
(await window.Signal.Data.getMessageById(messageId, {
Message: window.Whisper.Message,
}));
2021-04-27 22:35:35 +00:00
if (!message) {
throw new Error(`showForwardMessageModal: Message ${messageId} missing!`);
2021-04-27 22:35:35 +00:00
}
const attachments = getAttachmentsForMessage(message.attributes);
2021-04-27 22:35:35 +00:00
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
) => {
2021-07-20 20:18:35 +00:00
try {
const didForwardSuccessfully = await this.maybeForwardMessage(
message,
conversationIds,
messageBody,
includedAttachments,
linkPreview
);
2021-04-27 22:35:35 +00:00
2021-08-30 21:32:56 +00:00
if (didForwardSuccessfully && this.forwardMessageModal) {
2021-07-20 20:18:35 +00:00
this.forwardMessageModal.remove();
2021-08-30 21:32:56 +00:00
this.forwardMessageModal = undefined;
2021-07-20 20:18:35 +00:00
}
} catch (err) {
log.warn('doForwardMessage', err && err.stack ? err.stack : err);
2021-04-27 22:35:35 +00:00
}
},
isSticker: Boolean(message.get('sticker')),
messageBody: message.getRawText(),
onClose: () => {
2021-08-30 21:32:56 +00:00
if (this.forwardMessageModal) {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
2021-04-27 22:35:35 +00:00
this.resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
2021-08-30 21:32:56 +00:00
_: Array<BodyRangeType>,
2021-04-27 22:35:35 +00:00
caretLocation?: number
) => {
if (!attachments.length) {
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
}
},
onTextTooLong: () =>
this.showToast(
Whisper.MessageBodyTooLongToast,
{},
2021-08-30 21:32:56 +00:00
document.querySelector('.module-ForwardMessageModal') || undefined
2021-04-27 22:35:35 +00:00
),
}
),
});
this.forwardMessageModal.render();
2021-08-30 21:32:56 +00:00
}
2021-04-27 22:35:35 +00:00
async maybeForwardMessage(
message: MessageModel,
conversationIds: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
log.info(`maybeForwardMessage/${message.idForLogging()}: Starting...`);
2021-04-27 22:35:35 +00:00
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(
`${attachment.fileName}/${attachment.contentType}`
);
});
}
const conversations = conversationIds.map(id =>
window.ConversationController.get(id)
);
2021-07-20 20:18:35 +00:00
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
2021-07-20 20:18:35 +00:00
throw new Error('Cannot send to group');
}
2021-04-27 22:35:35 +00:00
// 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();
2021-04-27 22:35:35 +00:00
// 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;
2021-04-27 22:35:35 +00:00
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(
2021-07-09 19:36:10 +00:00
undefined, // body
2021-04-27 22:35:35 +00:00
[],
2021-07-09 19:36:10 +00:00
undefined, // quote
2021-04-27 22:35:35 +00:00
[],
stickerNoPath,
2021-07-09 19:36:10 +00:00
undefined, // BodyRanges
{ ...sendMessageOptions, timestamp }
2021-04-27 22:35:35 +00:00
);
} 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(
2021-04-27 22:35:35 +00:00
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
conversation.enqueueMessageForSend(
2021-07-09 19:36:10 +00:00
messageBody || undefined,
2021-04-27 22:35:35 +00:00
attachmentsToSend,
2021-07-09 19:36:10 +00:00
undefined, // quote
2021-04-27 22:35:35 +00:00
preview,
2021-07-09 19:36:10 +00:00
undefined, // sticker
2021-04-27 22:35:35 +00:00
undefined, // BodyRanges
{ ...sendMessageOptions, timestamp }
2021-04-27 22:35:35 +00:00
);
}
}
})
);
// Cancel any link still pending, even if it didn't make it into the message
this.resetLinkPreview();
2021-04-27 22:35:35 +00:00
return true;
2021-08-30 21:32:56 +00:00
}
2021-04-27 22:35:35 +00:00
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
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]);
}
}
2021-08-30 21:32:56 +00:00
const media: Array<MediaType> = flatten(
rawMedia.map(message => {
2021-08-30 21:32:56 +00:00
return (message.attachments || []).map(
(
attachment: AttachmentType,
index: number
): MediaType | undefined => {
if (
!attachment.path ||
!attachment.thumbnail ||
attachment.pending ||
attachment.error
) {
return;
}
2021-08-30 21:32:56 +00:00
const { thumbnail } = attachment;
return {
2021-08-30 21:32:56 +00:00
path: attachment.path,
objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path)
2021-08-30 21:32:56 +00:00
: undefined,
contentType: attachment.contentType,
index,
attachment,
2021-08-24 21:47:14 +00:00
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),
2021-08-25 21:08:32 +00:00
sent_at: message.sent_at,
2021-08-24 21:47:14 +00:00
},
};
2021-08-30 21:32:56 +00:00
}
);
})
2021-08-30 21:32:56 +00:00
).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 });
}
};
2021-08-30 21:32:56 +00:00
const onItemClick = async ({
message,
attachment,
type,
}: {
message: MessageAttributesType;
attachment: AttachmentType;
type: 'documents' | 'media';
}) => {
switch (type) {
case 'documents': {
saveAttachment({ message, attachment });
break;
}
case 'media': {
2021-08-24 21:47:14 +00:00
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);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
focusMessageField(): void {
if (this.panels && this.panels.length) {
return;
}
2021-08-30 21:32:56 +00:00
this.compositionApi.current?.focusInput();
}
2021-08-30 21:32:56 +00:00
disableMessageField(): void {
this.compositionApi.current?.setDisabled(true);
}
2021-08-30 21:32:56 +00:00
enableMessageField(): void {
this.compositionApi.current?.setDisabled(false);
}
2021-08-30 21:32:56 +00:00
resetEmojiResults(): void {
this.compositionApi.current?.resetEmojiResults();
}
2021-08-30 21:32:56 +00:00
showGV1Members(): void {
const { contactCollection } = this.model;
2021-06-17 21:15:09 +00:00
const memberships =
contactCollection?.map((conversation: ConversationModel) => {
return {
isAdmin: false,
member: conversation.format(),
};
}) || [];
2021-06-17 21:15:09 +00:00
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);
2021-06-17 21:15:09 +00:00
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showSafetyNumber(id?: string): void {
let conversation: undefined | ConversationModel;
2021-08-30 21:32:56 +00:00
if (!id && isDirectConversation(this.model.attributes)) {
// eslint-disable-next-line prefer-destructuring
2021-08-30 21:32:56 +00:00
conversation = this.model;
} else {
conversation = window.ConversationController.get(id);
}
if (conversation) {
const view = new Whisper.KeyVerificationPanelView({
model: conversation,
});
this.listenBack(view);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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;
}
2021-08-30 21:32:56 +00:00
const attachment =
providedAttachment && attachments.includes(providedAttachment)
? providedAttachment
: attachments[0];
const { fileName } = attachment;
const isDangerous = window.Signal.Util.isFileDangerous(fileName || '');
this.downloadAttachment({ attachment, timestamp, isDangerous });
2021-08-30 21:32:56 +00:00
}
async downloadAttachment({
attachment,
timestamp,
isDangerous,
}: {
2021-07-14 23:39:52 +00:00
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
2021-08-30 21:32:56 +00:00
}): 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 });
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async displayTapToViewMessage(messageId: string): Promise<void> {
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 () => {
log.info('displayTapToViewMessage: attempting to close lightbox');
if (!this.lightboxView) {
log.info('displayTapToViewMessage: lightbox was already closed');
return;
}
const { lightboxView } = this;
2021-08-30 21:32:56 +00:00
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', () => {
2021-08-30 21:32:56 +00:00
if (this.lightboxView) {
this.lightboxView.update(getProps());
}
});
const getProps = () => {
const { path, contentType } = tempAttachment;
return {
2021-08-24 21:47:14 +00:00
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'),
2021-08-30 21:32:56 +00:00
received_at_ms: Number(message.get('received_at_ms')),
2021-08-25 21:08:32 +00:00
sent_at: message.get('sent_at'),
2021-08-24 21:47:14 +00:00
},
},
],
isViewOnce: true,
};
};
2021-08-24 21:47:14 +00:00
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);
log.info('displayTapToViewMessage: showed lightbox');
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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();
},
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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) {
log.error(
'Error sending delete-for-everyone',
2021-06-23 17:01:13 +00:00
error && error.stack,
messageId
);
this.showToast(Whisper.DeleteForEveryoneFailedToast);
}
this.resetPanel();
},
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showStickerPackPreview(packId: string, packKey: string): void {
2021-07-09 19:36:10 +00:00
Stickers.downloadEphemeralPack(packId, packKey);
const props = {
packId,
onClose: async () => {
2021-08-30 21:32:56 +00:00
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.remove();
this.stickerPreviewModalView = undefined;
}
2021-07-09 19:36:10 +00:00
await Stickers.removeEphemeralPack(packId);
},
};
this.stickerPreviewModalView = new Whisper.ReactWrapperView({
className: 'sticker-preview-modal-wrapper',
JSX: window.Signal.State.Roots.createStickerPreviewModal(
window.reduxStore,
props
),
});
2021-08-30 21:32:56 +00:00
}
showLightboxForMedia(
2021-08-23 23:14:53 +00:00
selectedMediaItem: MediaItemType,
2021-08-24 21:47:14 +00:00
media: Array<MediaItemType> = []
2021-08-30 21:32:56 +00:00
): void {
2021-08-25 21:08:32 +00:00
const onSave = async ({
attachment,
message,
index,
}: {
attachment: AttachmentType;
message: MediaItemMessageType;
index: number;
}) => {
const fullPath = await window.Signal.Types.Attachment.save({
2021-08-25 21:08:32 +00:00
attachment,
index: index + 1,
readAttachmentData,
saveAttachmentToDisk,
2021-08-25 21:08:32 +00:00
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;
}
2021-08-30 21:32:56 +00:00
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
2021-08-23 23:14:53 +00:00
Component: window.Signal.Components.Lightbox,
props: {
2021-08-23 23:14:53 +00:00
getConversation: getConversationSelector(window.reduxStore.getState()),
media,
2021-08-23 23:14:53 +00:00
onForward: this.showForwardMessageModal.bind(this),
onSave,
2021-08-23 23:14:53 +00:00
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
},
onClose: () => window.Signal.Backbone.Views.Lightbox.hide(),
});
2021-08-30 21:32:56 +00:00
window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
}
showLightbox({
attachment,
messageId,
}: {
2021-07-14 23:39:52 +00:00
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
2021-08-30 21:32:56 +00:00
}): 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;
}
2021-08-23 23:14:53 +00:00
const { contentType } = attachment;
if (
!window.Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
!window.Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
) {
2021-08-30 21:32:56 +00:00
this.downloadAttachmentWrapper(messageId, attachment);
return;
}
2021-07-14 23:39:52 +00:00
const attachments: Array<AttachmentType> = message.get('attachments') || [];
const loop = isGIF(attachments);
const media = attachments
2021-07-14 23:39:52 +00:00
.filter(item => item.thumbnail && !item.pending && !item.error)
.map((item, index) => ({
objectURL: getAbsoluteAttachmentPath(item.path ?? ''),
path: item.path,
contentType: item.contentType,
2021-07-14 23:39:52 +00:00
loop,
index,
2021-08-23 23:14:53 +00:00
message: {
2021-08-30 21:32:56 +00:00
attachments: message.get('attachments') || [],
2021-08-23 23:14:53 +00:00
id: message.get('id'),
2021-08-24 21:47:14 +00:00
conversationId:
window.ConversationController.get(
window.ConversationController.ensureContactIds({
uuid: message.get('sourceUuid'),
e164: message.get('source'),
})
)?.id || message.get('conversationId'),
2021-08-23 23:14:53 +00:00
received_at: message.get('received_at'),
2021-08-30 21:32:56 +00:00
received_at_ms: Number(message.get('received_at_ms')),
2021-08-25 21:08:32 +00:00
sent_at: message.get('sent_at'),
2021-08-23 23:14:53 +00:00
},
attachment: item,
2021-08-23 23:14:53 +00:00
thumbnailObjectUrl:
item.thumbnail?.objectUrl ||
getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''),
}));
2021-08-23 23:14:53 +00:00
const selectedMedia =
media.find(item => attachment.path === item.path) || media[0];
2021-08-24 21:47:14 +00:00
this.showLightboxForMedia(selectedMedia, media);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showContactModal(contactId: string): void {
if (this.contactModalView) {
this.contactModalView.remove();
2021-08-30 21:32:56 +00:00
this.contactModalView = undefined;
}
2021-08-30 21:32:56 +00:00
this.previousFocus = document.activeElement as HTMLElement;
const hideContactModal = () => {
if (this.contactModalView) {
this.contactModalView.remove();
2021-08-30 21:32:56 +00:00
this.contactModalView = undefined;
if (this.previousFocus && this.previousFocus.focus) {
this.previousFocus.focus();
2021-08-30 21:32:56 +00:00
this.previousFocus = undefined;
}
}
};
this.contactModalView = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createContactModal(window.reduxStore, {
contactId,
2021-08-30 21:32:56 +00:00
currentConversationId: this.model.id,
onClose: hideContactModal,
openConversation: (conversationId: string) => {
hideContactModal();
this.openConversation(conversationId);
},
removeMember: (conversationId: string) => {
hideContactModal();
2021-08-30 21:32:56 +00:00
this.model.removeFromGroupV2(conversationId);
},
showSafetyNumber: (conversationId: string) => {
hideContactModal();
this.showSafetyNumber(conversationId);
},
toggleAdmin: (conversationId: string) => {
hideContactModal();
2021-08-30 21:32:56 +00:00
const isAdmin = this.model.isAdmin(conversationId);
const conversationModel = window.ConversationController.get(
conversationId
);
if (!conversationModel) {
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'),
2021-08-30 21:32:56 +00:00
resolve: () => this.model.toggleAdmin(conversationId),
});
},
updateSharedGroups: () => {
const conversation = window.ConversationController.get(contactId);
if (conversation && conversation.throttledUpdateSharedGroups) {
conversation.throttledUpdateSharedGroups();
}
},
}),
});
this.contactModalView.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showGroupLinkManagement(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupLinkManagement(
window.reduxStore,
{
changeHasGroupLink: this.changeHasGroupLink.bind(this),
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showGroupV2Permissions(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupV2Permissions(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
setAccessControlAttributesSetting: this.setAccessControlAttributesSetting.bind(
this
),
setAccessControlMembersSetting: this.setAccessControlMembersSetting.bind(
this
),
2021-07-20 20:18:35 +00:00
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
}
),
});
view.headerTitle = window.i18n('permissions');
this.listenBack(view);
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showPendingInvites(): void {
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
ourConversationId: window.ConversationController.getOurConversationId(),
approvePendingMembership: (conversationId: string) => {
2021-08-30 21:32:56 +00:00
this.model.approvePendingMembershipFromGroupV2(conversationId);
},
revokePendingMemberships: conversationIds => {
2021-08-30 21:32:56 +00:00
this.model.revokePendingMembershipsFromGroupV2(conversationIds);
},
}),
});
view.headerTitle = window.i18n('ConversationDetails--requests-and-invites');
this.listenBack(view);
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-05 12:35:33 +00:00
2021-08-30 21:32:56 +00:00
showConversationNotificationsSettings(): void {
2021-08-05 12:35:33 +00:00
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createConversationNotificationsSettings(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
setDontNotifyForMentionsIfMuted: this.model.setDontNotifyForMentionsIfMuted.bind(
this.model
2021-08-05 12:35:33 +00:00
),
setMuteExpiration: this.setMuteExpiration.bind(this),
}
),
});
view.headerTitle = window.i18n('ConversationDetails--notifications');
this.listenBack(view);
view.render();
2021-08-30 21:32:56 +00:00
}
2021-05-28 16:15:17 +00:00
2021-08-30 21:32:56 +00:00
showChatColorEditor(): void {
2021-05-28 16:15:17 +00:00
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createChatColorPicker(window.reduxStore, {
2021-08-30 21:32:56 +00:00
conversationId: this.model.get('id'),
2021-05-28 16:15:17 +00:00
}),
});
view.headerTitle = window.i18n('ChatColorPicker__menu-title');
this.listenBack(view);
view.render();
2021-08-30 21:32:56 +00:00
}
2021-05-28 16:15:17 +00:00
2021-08-30 21:32:56 +00:00
showConversationDetails(): void {
2021-07-20 20:18:35 +00:00
// Run a getProfiles in case member's capabilities have changed
// Redux should cover us on the return here so no need to await this.
2021-08-30 21:32:56 +00:00
if (this.model.throttledGetProfiles) {
this.model.throttledGetProfiles();
}
2021-07-09 19:36:10 +00:00
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',
2021-08-30 21:32:56 +00:00
task: () => this.model.leaveGroupV2(),
});
};
const onBlock = () => {
2021-06-01 23:30:25 +00:00
this.syncMessageRequestResponse(
'onBlock',
2021-08-30 21:32:56 +00:00
this.model,
2021-06-01 23:30:25 +00:00
messageRequestEnum.BLOCK
);
};
const props = {
2021-08-30 21:32:56 +00:00
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),
2021-05-28 16:15:17 +00:00
showGroupChatColorEditor: this.showChatColorEditor.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
2021-08-05 12:35:33 +00:00
showConversationNotificationsSettings: this.showConversationNotificationsSettings.bind(
this
),
showPendingInvites: this.showPendingInvites.bind(this),
showLightboxForMedia: this.showLightboxForMedia.bind(this),
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
showContactDetail({
contact,
signalAccount,
}: {
contact: EmbeddedContactType;
signalAccount?: string;
2021-08-30 21:32:56 +00:00
}): 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);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
async openConversation(
conversationId: string,
messageId?: string
): Promise<void> {
window.Whisper.events.trigger(
'showConversation',
conversationId,
messageId
);
}
2021-08-30 21:32:56 +00:00
listenBack(view: AnyViewClass): void {
this.panels = this.panels || [];
if (this.panels.length === 0) {
2021-08-30 21:32:56 +00:00
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');
});
2020-10-30 17:52:21 +00:00
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
view.headerTitle
);
2021-08-30 21:32:56 +00:00
}
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();
2021-08-30 21:32:56 +00:00
this.previousFocus = undefined;
}
if (this.panels.length > 0) {
this.panels[0].$el.fadeIn(250);
}
2021-08-30 21:32:56 +00:00
if (view) {
view.$el.addClass('panel--remove').one('transitionend', () => {
view.remove();
2021-08-30 21:32:56 +00:00
if (this.panels.length === 0) {
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
}
});
}
2020-10-30 17:52:21 +00:00
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
this.panels[0]?.headerTitle
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
endSession(): void {
const { model }: { model: ConversationModel } = this;
model.endSession();
2021-08-30 21:32:56 +00:00
}
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,
2021-08-23 23:14:53 +00:00
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),
2021-08-25 21:08:32 +00:00
sent_at: message.sent_at,
2021-08-23 23:14:53 +00:00
},
};
}
),
],
[] as Array<MediaItemType>
);
window.reduxActions.conversations.setRecentMediaItems(
model.id,
loadedRecentMediaItems
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async setDisappearingMessages(seconds: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const valueToSet = seconds > 0 ? seconds : undefined;
2020-10-06 17:06:34 +00:00
await this.longRunningTaskWrapper({
name: 'updateExpirationTimer',
task: async () => model.updateExpirationTimer(valueToSet),
2020-10-06 17:06:34 +00:00
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async changeHasGroupLink(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'toggleGroupLink',
task: async () => model.toggleGroupLink(value),
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async copyGroupLink(groupLink: string): Promise<void> {
await navigator.clipboard.writeText(groupLink);
this.showToast(Whisper.GroupLinkCopiedToast);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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(),
});
},
});
2021-08-30 21:32:56 +00:00
}
2021-07-20 20:18:35 +00:00
async setAccessControlAddFromInviteLinkSetting(
value: boolean
): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAddFromInviteLink',
task: async () => model.updateAccessControlAddFromInviteLink(value),
});
2021-08-30 21:32:56 +00:00
}
2021-07-20 20:18:35 +00:00
async setAccessControlAttributesSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
task: async () => model.updateAccessControlAttributes(value),
});
2021-08-30 21:32:56 +00:00
}
2021-07-20 20:18:35 +00:00
async setAccessControlMembersSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlMembers',
task: async () => model.updateAccessControlMembers(value),
});
2021-08-30 21:32:56 +00:00
}
2021-07-20 20:18:35 +00:00
async setAnnouncementsOnly(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
task: async () => model.updateAnnouncementsOnly(value),
});
2021-08-30 21:32:56 +00:00
}
2021-07-20 20:18:35 +00:00
2021-08-30 21:32:56 +00:00
async destroyMessages(): Promise<void> {
const { model }: { model: ConversationModel } = this;
window.showConfirmationDialog({
2021-05-28 19:14:15 +00:00
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: () => {
log.info('destroyMessages: User canceled delete');
},
});
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async isCallSafe(): Promise<boolean> {
const contacts = await this.getUntrustedContacts();
if (contacts && contacts.length) {
const callAnyway = await this.showSendAnywayDialog(
2021-08-30 21:32:56 +00:00
contacts.models,
window.i18n('callAnyway')
);
if (!callAnyway) {
log.info(
'Safety number change dialog not accepted, new call not allowed.'
);
return false;
}
}
return true;
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
2021-04-27 22:35:35 +00:00
showSendAnywayDialog(
contacts: Array<ConversationModel>,
confirmText?: string
2021-08-30 21:32:56 +00:00
): Promise<boolean> {
return new Promise(resolve => {
2021-06-17 21:15:09 +00:00
showSafetyNumberChangeDialog({
confirmText,
contacts,
reject: () => {
resolve(false);
},
resolve: () => {
resolve(true);
},
});
});
2021-08-30 21:32:56 +00:00
}
async sendReactionMessage(
messageId: string,
reaction: { emoji: string; remove: boolean }
2021-08-30 21:32:56 +00:00
): Promise<void> {
const messageModel = messageId
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: undefined;
try {
if (!messageModel) {
2021-08-30 21:32:56 +00:00
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,
2021-08-30 21:32:56 +00:00
targetAuthorUuid,
targetTimestamp: messageModel.get('sent_at'),
});
} catch (error) {
log.error('Error sending reaction', error, messageId, reaction);
this.showToast(Whisper.ReactionFailedToast);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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) {
2021-08-30 21:32:56 +00:00
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) {
log.error('clickSend error:', error && error.stack ? error.stack : error);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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) {
2021-08-30 21:32:56 +00:00
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) {
2021-08-30 21:32:56 +00:00
await this.markAllAsApproved(untrustedContacts.models);
}
} else if (untrustedContacts.length) {
return untrustedContacts;
}
return null;
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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;
}
2021-08-30 21:32:56 +00:00
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();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
renderQuotedMessage(): void {
const { model }: { model: ConversationModel } = this;
if (!this.quotedMessage) {
2021-06-25 16:08:16 +00:00
window.reduxActions.composer.setQuotedMessage(undefined);
return;
}
2021-06-25 16:08:16 +00:00
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote: this.quote,
});
2021-08-30 21:32:56 +00:00
}
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;
}
2021-08-30 21:32:56 +00:00
const e164 = this.model.get('e164');
const uuid = this.model.get('uuid');
if (
isDirectConversation(this.model.attributes) &&
2021-08-30 21:32:56 +00:00
((e164 && window.storage.blocked.isBlocked(e164)) ||
(uuid && window.storage.blocked.isUuidBlocked(uuid)))
) {
ToastView = Whisper.BlockedToast;
}
2021-08-30 21:32:56 +00:00
const groupId = this.model.get('groupId');
if (
!isDirectConversation(this.model.attributes) &&
2021-08-30 21:32:56 +00:00
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;
2021-08-30 21:32:56 +00:00
}
2021-07-30 18:37:03 +00:00
async sendMessage(
message = '',
2021-08-30 21:32:56 +00:00
mentions: BodyRangesType = [],
2021-07-30 18:37:03 +00:00
options: { timestamp?: number; force?: boolean } = {}
2021-08-30 21:32:56 +00:00
): Promise<void> {
const { model }: { model: ConversationModel } = this;
2021-07-30 18:37:03 +00:00
const timestamp = options.timestamp || Date.now();
this.sendStart = Date.now();
try {
const contacts = await this.getUntrustedContacts(options);
this.disableMessageField();
if (contacts && contacts.length) {
2021-08-30 21:32:56 +00:00
const sendAnyway = await this.showSendAnywayDialog(contacts.models);
if (sendAnyway) {
2021-07-30 18:37:03 +00:00
this.sendMessage(message, mentions, { force: true, timestamp });
return;
}
this.enableMessageField();
return;
}
} catch (error) {
this.enableMessageField();
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({ includePending: false }) &&
!this.voiceNoteAttachment
) {
return;
}
const attachments = await this.getFiles();
2021-07-30 18:37:03 +00:00
const sendHQImages =
window.reduxStore &&
window.reduxStore.getState().composer.shouldSendHighQualityAttachments;
const sendDelta = Date.now() - this.sendStart;
2021-07-30 18:37:03 +00:00
log.info('Send pre-checks took', sendDelta, 'milliseconds');
2021-08-11 16:23:21 +00:00
batchedUpdates(() => {
model.enqueueMessageForSend(
2021-08-11 16:23:21 +00:00
message,
attachments,
this.quote,
2021-08-13 21:21:14 +00:00
this.getLinkPreviewForSend(message),
2021-08-11 16:23:21 +00:00
undefined, // sticker
mentions,
{
sendHQImages,
timestamp,
}
);
2021-08-30 21:32:56 +00:00
this.compositionApi.current?.reset();
2021-08-11 16:23:21 +00:00
model.setMarkedUnread(false);
this.setQuoteMessage(null);
this.resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
});
} catch (error) {
log.error(
'Error pulling attached files before send',
error && error.stack ? error.stack : error
);
} finally {
this.enableMessageField();
}
2021-08-30 21:32:56 +00:00
}
2020-11-03 01:19:52 +00:00
onEditorStateChange(
messageText: string,
2021-08-30 21:32:56 +00:00
bodyRanges: Array<BodyRangeType>,
2020-11-03 01:19:52 +00:00
caretLocation?: number
2021-08-30 21:32:56 +00:00
): void {
this.maybeBumpTyping(messageText);
2020-11-03 01:19:52 +00:00
this.debouncedSaveDraft(messageText, bodyRanges);
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
2021-08-30 21:32:56 +00:00
}
2020-11-03 01:19:52 +00:00
async saveDraft(
messageText: string,
2021-08-30 21:32:56 +00:00
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,
2020-11-03 01:19:52 +00:00
draftBodyRanges: [],
});
await this.saveModel();
return;
}
if (messageText !== model.get('draft')) {
this.model.set({
draft: messageText,
draftChanged: true,
2020-11-03 01:19:52 +00:00
draftBodyRanges: bodyRanges,
});
await this.saveModel();
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
maybeGrabLinkPreview(message: string, caretLocation?: number): void {
// Don't generate link previews if user has turned them off
2021-08-18 20:08:14 +00:00
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({ includePending: true })) {
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;
2021-08-30 21:32:56 +00:00
if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) {
return;
}
2021-08-30 21:32:56 +00:00
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);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
resetLinkPreview(): void {
this.disableLinkPreviews = false;
this.excludedPreviewUrls = [];
this.removeLinkPreview();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
removeLinkPreview(): void {
(this.preview || []).forEach((item: LinkPreviewResult) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
2021-08-30 21:32:56 +00:00
this.preview = undefined;
this.currentlyMatchedLink = undefined;
2020-09-28 23:46:31 +00:00
this.linkPreviewAbortController?.abort();
2021-08-30 21:32:56 +00:00
this.linkPreviewAbortController = undefined;
2021-08-18 13:34:22 +00:00
window.reduxActions.linkPreviews.removeLinkPreview();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
async getStickerPackPreview(
url: string,
abortSignal: Readonly<AbortSignal>
2021-06-25 16:08:16 +00:00
): Promise<null | LinkPreviewResult> {
2021-07-09 19:36:10 +00:00
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' ||
2021-07-09 19:36:10 +00:00
pack.status === 'installed'
);
};
2021-07-09 19:36:10 +00:00
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);
2021-07-09 19:36:10 +00:00
const existing = Stickers.getStickerPack(id);
if (!isPackDownloaded(existing)) {
2021-07-09 19:36:10 +00:00
await Stickers.downloadEphemeralPack(id, keyBase64);
}
if (abortSignal.aborted) {
return null;
}
2021-07-09 19:36:10 +00:00
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 {
2021-08-30 21:32:56 +00:00
date: null,
description: null,
image: {
...sticker,
data,
size: data.byteLength,
2021-07-09 19:36:10 +00:00
contentType: IMAGE_WEBP,
},
2021-08-30 21:32:56 +00:00
title,
url,
};
} catch (error) {
log.error(
'getStickerPackPreview error:',
error && error.stack ? error.stack : error
);
return null;
} finally {
if (id) {
2021-07-09 19:36:10 +00:00
await Stickers.removeEphemeralPack(id);
}
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
// eslint-disable-next-line class-methods-use-this
async getGroupPreview(
url: string,
abortSignal: Readonly<AbortSignal>
2021-06-25 16:08:16 +00:00
): 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(
2021-06-22 14:46:42 +00:00
Bytes.fromBase64(masterKey)
);
2021-06-22 14:46:42 +00:00
const id = Bytes.toBase64(fields.id);
const logId = `groupv2(${id})`;
2021-06-22 14:46:42 +00:00
const secretParams = Bytes.toBase64(fields.secretParams);
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(),
});
2021-06-25 16:08:16 +00:00
let image: undefined | LinkPreviewImage;
if (result.avatar) {
try {
const data = await window.Signal.Groups.decryptGroupAvatar(
result.avatar,
secretParams
);
image = {
data,
size: data.byteLength,
2021-06-25 16:08:16 +00:00
contentType: IMAGE_JPEG,
blurHash: await window.imageToBlurHash(
new Blob([data], {
2021-06-25 16:08:16 +00:00
type: IMAGE_JPEG,
})
),
};
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
log.error(
`getGroupPreview/${logId}: Failed to fetch avatar ${errorString}`
);
}
}
if (abortSignal.aborted) {
return null;
}
return {
2021-08-30 21:32:56 +00:00
date: null,
description,
image,
2021-08-30 21:32:56 +00:00
title,
url,
};
2021-08-30 21:32:56 +00:00
}
2020-09-28 23:46:31 +00:00
async getPreview(
url: string,
abortSignal: Readonly<AbortSignal>
2021-06-25 16:08:16 +00:00
): Promise<null | LinkPreviewResult> {
if (LinkPreview.isStickerPack(url)) {
return this.getStickerPackPreview(url, abortSignal);
}
if (LinkPreview.isGroupLink(url)) {
return this.getGroupPreview(url, abortSignal);
}
2020-09-28 23:46:31 +00:00
// This is already checked elsewhere, but we want to be extra-careful.
if (!LinkPreview.isLinkSafeToPreview(url)) {
2020-09-28 23:46:31 +00:00
return null;
}
2020-09-28 23:46:31 +00:00
const linkPreviewMetadata = await window.textsecure.messaging.fetchLinkPreviewMetadata(
url,
abortSignal
);
2021-06-03 00:19:40 +00:00
if (!linkPreviewMetadata || abortSignal.aborted) {
2020-09-28 23:46:31 +00:00
return null;
}
const { title, imageHref, description, date } = linkPreviewMetadata;
let image;
if (imageHref && LinkPreview.isLinkSafeToPreview(imageHref)) {
2020-09-28 23:46:31 +00:00
let objectUrl: void | string;
try {
const fullSizeImage = await window.textsecure.messaging.fetchLinkPreviewImage(
imageHref,
abortSignal
);
2021-06-03 00:19:40 +00:00
if (abortSignal.aborted) {
return null;
}
2020-09-28 23:46:31 +00:00
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
2021-07-28 00:09:10 +00:00
const withBlob = await autoScale({
2020-09-28 23:46:31 +00:00
contentType: fullSizeImage.contentType,
file: new Blob([fullSizeImage.data], {
type: fullSizeImage.contentType,
}),
2021-07-28 00:09:10 +00:00
fileName: title,
});
const data = await this.arrayBufferFromFile(withBlob.file);
objectUrl = URL.createObjectURL(withBlob.file);
const blurHash = await window.imageToBlurHash(withBlob.file);
2020-09-28 23:46:31 +00:00
const dimensions = await VisualAttachment.getImageDimensions({
objectUrl,
logger: log,
2020-09-28 23:46:31 +00:00
});
image = {
data,
size: data.byteLength,
...dimensions,
contentType: withBlob.file.type,
blurHash,
};
2020-09-28 23:46:31 +00:00
} catch (error) {
// We still want to show the preview if we failed to get an image
log.error(
2020-09-28 23:46:31 +00:00
'getPreview failed to get image for link preview:',
error.message
);
} finally {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}
}
2021-06-03 00:19:40 +00:00
if (abortSignal.aborted) {
return null;
}
return {
2021-08-30 21:32:56 +00:00
date: date || null,
description: description || null,
image,
title,
url,
};
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async addLinkPreview(url: string): Promise<void> {
2020-09-28 23:46:31 +00:00
if (this.currentlyMatchedLink === url) {
log.warn(
2020-09-28 23:46:31 +00:00
'addLinkPreview should not be called with the same URL like this'
);
return;
}
2021-08-30 21:32:56 +00:00
(this.preview || []).forEach((item: LinkPreviewResult) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
2021-04-27 22:35:35 +00:00
window.reduxActions.linkPreviews.removeLinkPreview();
2021-08-30 21:32:56 +00:00
this.preview = undefined;
2020-09-28 23:46:31 +00:00
// Cancel other in-flight link preview requests.
if (this.linkPreviewAbortController) {
log.info(
2020-09-28 23:46:31 +00:00
'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 {
2020-09-28 23:46:31 +00:00
const result = await this.getPreview(
url,
thisRequestAbortController.signal
);
if (!result) {
log.info(
2020-09-28 23:46:31 +00:00
'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;
}
2021-08-30 21:32:56 +00:00
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;
}
2021-08-30 21:32:56 +00:00
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) {
log.error(
'Problem loading link preview, disabling.',
error && error.stack ? error.stack : error
);
this.disableLinkPreviews = true;
this.removeLinkPreview();
2020-09-28 23:46:31 +00:00
} finally {
clearTimeout(timeout);
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
renderLinkPreview(): void {
2021-04-27 22:35:35 +00:00
if (this.forwardMessageModal) {
return;
}
2021-06-25 16:08:16 +00:00
window.reduxActions.composer.setLinkPreviewResult(
Boolean(this.currentlyMatchedLink),
this.getLinkPreviewWithDomain()
);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
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 [];
}
2021-08-13 15:01:56 +00:00
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))
2021-08-30 21:32:56 +00:00
.map((item: LinkPreviewResult) => {
2021-08-13 15:01:56 +00:00
if (item.image) {
// We eliminate the ObjectURL here, unneeded for send or save
return {
...item,
2021-08-30 21:32:56 +00:00
image: omit(item.image, 'url'),
description: dropNull(item.description),
date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url),
2021-08-13 15:01:56 +00:00
};
}
2021-08-30 21:32:56 +00:00
return {
...item,
description: dropNull(item.description),
date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url),
};
2021-08-13 15:01:56 +00:00
})
);
2021-08-30 21:32:56 +00:00
}
2021-06-25 16:08:16 +00:00
getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined {
if (!this.preview || !this.preview.length) {
return undefined;
}
const [preview] = this.preview;
return {
...preview,
domain: LinkPreview.getDomain(preview.url),
2021-06-25 16:08:16 +00:00
};
2021-08-30 21:32:56 +00:00
}
2021-06-25 16:08:16 +00:00
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
2021-08-30 21:32:56 +00:00
maybeBumpTyping(messageText: string): void {
if (messageText.length && this.model.throttledBumpTyping) {
this.model.throttledBumpTyping();
}
2021-08-30 21:32:56 +00:00
}
}
window.Whisper.ConversationView = ConversationView;