signal-desktop/ts/views/conversation_view.tsx

2671 lines
78 KiB
TypeScript
Raw Normal View History

// Copyright 2020-2022 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable camelcase */
import type * as Backbone from 'backbone';
import type { ComponentProps } from 'react';
import * as React from 'react';
2022-06-17 00:48:57 +00:00
import { debounce, flatten, throttle } from 'lodash';
2021-08-30 21:32:56 +00:00
import { render } from 'mustache';
2021-08-11 16:23:21 +00:00
2021-12-03 01:05:32 +00:00
import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment';
2021-07-09 19:36:10 +00:00
import * as Stickers from '../types/Stickers';
import * as Errors from '../types/errors';
2022-11-10 04:59:36 +00:00
import type { DraftBodyRangesType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
import type { ConversationModel } from '../models/conversations';
import type {
GroupV2PendingMemberType,
MessageAttributesType,
2021-08-30 21:32:56 +00:00
QuotedMessageType,
} from '../model-types.d';
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
import type { MessageModel } from '../models/messages';
import { getMessageById } from '../messages/getMessageById';
import { getContactId } from '../messages/helpers';
2021-08-30 21:32:56 +00:00
import { strictAssert } from '../util/assert';
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
import {
isDirectConversation,
isGroup,
isGroupV1,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
2022-11-16 20:18:02 +00:00
import type { DurationInSeconds } from '../util/durations';
import { getPreferredBadgeSelector } from '../state/selectors/badges';
import {
canReply,
isIncoming,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
2021-08-23 23:14:53 +00:00
import {
getConversationSelector,
getMessagesByConversation,
} from '../state/selectors/conversations';
import { getActiveCallState } from '../state/selectors/calling';
import { getTheme } from '../state/selectors/user';
import { ReactWrapperView } from './ReactWrapperView';
2022-06-16 19:12:50 +00:00
import type { Lightbox } from '../components/Lightbox';
2021-06-17 21:15:09 +00:00
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
2021-09-29 20:23:06 +00:00
import * as log from '../logging/log';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
2021-10-05 16:47:06 +00:00
import { createConversationView } from '../state/roots/createConversationView';
2021-09-29 20:23:06 +00:00
import { AttachmentToastType } from '../types/AttachmentToastType';
import type { CompositionAPIType } from '../components/CompositionArea';
2021-09-29 20:23:06 +00:00
import { SignalService as Proto } from '../protobuf';
2021-09-22 20:59:54 +00:00
import { ToastBlocked } from '../components/ToastBlocked';
import { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import { ToastCannotMixMultiAndNonMultiAttachments } from '../components/ToastCannotMixMultiAndNonMultiAttachments';
2021-09-29 20:23:06 +00:00
import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
2021-09-22 20:59:54 +00:00
import { ToastConversationArchived } from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import { ToastExpired } from '../components/ToastExpired';
2021-09-29 20:23:06 +00:00
import { ToastFileSize } from '../components/ToastFileSize';
2021-09-22 20:59:54 +00:00
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
import { ToastLeftGroup } from '../components/ToastLeftGroup';
2021-09-29 20:23:06 +00:00
import { ToastMaxAttachments } from '../components/ToastMaxAttachments';
2021-09-22 20:59:54 +00:00
import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong';
import { ToastUnsupportedMultiAttachment } from '../components/ToastUnsupportedMultiAttachment';
2021-09-22 20:59:54 +00:00
import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound';
import { ToastPinnedConversationsFull } from '../components/ToastPinnedConversationsFull';
import { ToastReactionFailed } from '../components/ToastReactionFailed';
import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndBlocked';
import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming';
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
2021-09-24 20:02:30 +00:00
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
2022-05-11 20:59:58 +00:00
import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge';
2021-09-24 20:02:30 +00:00
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
import { retryMessageSend } from '../util/retryMessageSend';
2021-09-29 20:23:06 +00:00
import { isNotNil } from '../util/isNotNil';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { resolveAttachmentDraftData } from '../util/resolveAttachmentDraftData';
import { showToast } from '../util/showToast';
import { RecordingState } from '../state/ducks/audioRecorder';
import { UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID';
import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
import { ContactDetail } from '../components/conversation/ContactDetail';
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
2022-06-17 00:48:57 +00:00
import {
getLinkPreviewForSend,
hasLinkPreviewLoaded,
maybeGrabLinkPreview,
removeLinkPreview,
resetLinkPreview,
suspendLinkPreviews,
} from '../services/LinkPreview';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { closeLightbox, showLightbox } from '../util/showLightbox';
2022-07-01 00:52:03 +00:00
import { saveAttachment } from '../util/saveAttachment';
import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage';
import { SECOND } from '../util/durations';
import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { getOwn } from '../util/getOwn';
import { CallMode } from '../types/Calling';
import { isAnybodyElseInGroupCall } from '../state/ducks/calling';
2020-09-28 23:46:31 +00:00
type AttachmentOptions = {
messageId: string;
attachment: AttachmentType;
};
type PanelType = { view: Backbone.View; headerTitle?: string };
const FIVE_MINUTES = 1000 * 60 * 5;
2021-09-24 00:49:05 +00:00
const { Message } = window.Signal.Types;
const {
copyIntoTempDirectory,
deleteTempFile,
getAbsoluteAttachmentPath,
getAbsoluteTempPath,
upgradeMessageSchema,
} = window.Signal.Migrations;
const { getMessagesBySentAt } = 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;
openConversation: (conversationId: string, messageId?: string) => unknown;
2022-05-11 20:59:58 +00:00
openGiftBadge: (messageId: string) => unknown;
2021-08-30 21:32:56 +00:00
openLink: (url: string) => unknown;
reactToMessage: (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => unknown;
replyToMessage: (messageId: string) => unknown;
retrySend: (messageId: string) => unknown;
retryDeleteForEveryone: (messageId: string) => unknown;
2021-08-30 21:32:56 +00:00
showContactDetail: (options: {
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
};
2021-08-30 21:32:56 +00:00
}) => 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;
startConversation: (e164: string, uuid: UUIDStringType) => unknown;
2021-08-30 21:32:56 +00:00
};
type MediaType = {
path: string;
objectURL: string;
thumbnailObjectUrl?: string;
contentType: MIMEType;
index: number;
attachment: AttachmentType;
message: {
attachments: Array<AttachmentType>;
conversationId: string;
id: string;
received_at: number;
received_at_ms: number;
sent_at: number;
};
};
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
2021-08-30 21:32:56 +00:00
export class ConversationView extends window.Backbone.View<ConversationModel> {
private debouncedSaveDraft: (
messageText: string,
2022-11-10 04:59:36 +00:00
bodyRanges: DraftBodyRangesType
2021-08-30 21:32:56 +00:00
) => Promise<void>;
private lazyUpdateVerified: () => void;
// Composing messages
private compositionApi: {
2021-10-05 16:47:06 +00:00
current: CompositionAPIType;
2021-08-30 21:32:56 +00:00
} = { current: undefined };
private sendStart?: number;
// Quotes
private quote?: QuotedMessageType;
private quotedMessage?: MessageModel;
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
private lightboxView?: ReactWrapperView;
2021-08-30 21:32:56 +00:00
private migrationDialog?: Backbone.View;
private stickerPreviewModalView?: Backbone.View;
// Panel support
private panels: Array<PanelType> = [];
2021-08-30 21:32:56 +00:00
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.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, '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);
2022-06-17 00:48:57 +00:00
this.listenTo(this.model, 'remove-link-review', removeLinkPreview);
2021-08-30 21:32:56 +00:00
this.listenTo(
this.model,
'remove-all-draft-attachments',
this.clearAttachments
);
this.render();
2021-10-05 16:47:06 +00:00
this.setupConversationView();
2021-06-25 16:08:16 +00:00
this.updateAttachmentsView();
2021-08-30 21:32:56 +00:00
}
override events(): Record<string, string> {
2021-08-30 21:32:56 +00:00
return {
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
className(): string {
return 'conversation';
}
// Same situation as className().
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
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;
}
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) {
2021-09-22 20:59:54 +00:00
showToast(ToastPinnedConversationsFull);
2020-10-02 18:30:43 +00:00
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-10-05 16:47:06 +00:00
setupConversationView(): void {
// setupHeader
const conversationHeaderProps = {
id: this.model.id,
2022-11-16 20:18:02 +00:00
onSetDisappearingMessages: (seconds: DurationInSeconds) =>
2021-10-05 16:47:06 +00:00
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
2021-11-01 18:43:02 +00:00
searchInConversation(this.model.id);
2021-10-05 16:47:06 +00:00
},
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
2021-11-11 22:43:05 +00:00
onOutgoingAudioCallInConversation:
this.onOutgoingAudioCallInConversation.bind(this),
onOutgoingVideoCallInConversation:
this.onOutgoingVideoCallInConversation.bind(this),
2021-10-05 16:47:06 +00:00
onShowConversationDetails: () => {
this.showConversationDetails();
},
onShowAllMedia: () => {
this.showAllMedia();
},
onShowGroupMembers: () => {
this.showGV1Members();
},
onGoBack: () => {
this.resetPanel();
},
2021-06-25 16:08:16 +00:00
2021-10-05 16:47:06 +00:00
onArchive: () => {
this.model.setArchived(true);
this.model.trigger('unload', 'archive');
2021-10-06 21:00:51 +00:00
showToast(ToastConversationArchived, {
undo: () => {
this.model.setArchived(false);
this.openConversation(this.model.get('id'));
},
});
2021-10-05 16:47:06 +00:00
},
onMarkUnread: () => {
this.model.setMarkedUnread(true);
showToast(ToastConversationMarkedUnread);
},
onMoveToInbox: () => {
this.model.setArchived(false);
showToast(ToastConversationUnarchived);
},
};
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
// setupTimeline
2021-07-09 19:36:10 +00:00
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
2021-10-05 16:47:06 +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);
openLinkInWebBrowser(url);
};
const learnMoreAboutDeliveryIssue = () => {
openLinkInWebBrowser(
'https://support.signal.org/hc/articles/4404859745690'
);
};
const scrollToQuotedMessage = async (
options: Readonly<{
authorId: string;
sentAt: number;
}>
) => {
const { authorId, sentAt } = options;
const conversationId = this.model.id;
const messages = await getMessagesBySentAt(sentAt);
2021-10-05 16:47:06 +00:00
const message = messages.find(item =>
Boolean(
item.conversationId === conversationId &&
2021-10-05 16:47:06 +00:00
authorId &&
getContactId(item) === authorId
2021-10-05 16:47:06 +00:00
)
);
if (!message) {
showToast(ToastOriginalMessageNotFound);
return;
}
this.scrollToMessage(message.id);
};
const markMessageRead = async (messageId: string) => {
2022-07-05 16:44:53 +00:00
if (!window.SignalContext.activeWindowService.isActive()) {
2021-10-05 16:47:06 +00:00
return;
}
const activeCall = getActiveCallState(window.reduxStore.getState());
if (activeCall && !activeCall.pip) {
return;
}
const message = await getMessageById(messageId);
2021-10-05 16:47:06 +00:00
if (!message) {
throw new Error(`markMessageRead: failed to load message ${messageId}`);
}
await this.model.markRead(message.get('received_at'), {
newestSentAt: message.get('sent_at'),
sendReadReceipts: true,
});
2021-10-05 16:47:06 +00:00
};
2021-11-11 22:43:05 +00:00
const createMessageRequestResponseHandler =
(name: string, enumValue: number): ((conversationId: string) => void) =>
conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(
`createMessageRequestResponseHandler: Expected a conversation to be found in ${name}. Doing nothing`
);
return;
}
this.syncMessageRequestResponse(name, conversation, enumValue);
};
2021-10-05 16:47:06 +00:00
const timelineProps = {
id: this.model.id,
...this.getMessageActions(),
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
blockGroupLinkRequests: (uuid: UUIDStringType) => {
this.model.blockGroupLinkRequests(uuid);
},
2021-10-05 16:47:06 +00:00
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages: this.model.loadNewerMessages.bind(this.model),
loadNewestMessages: this.model.loadNewestMessages.bind(this.model),
loadOlderMessages: this.model.loadOlderMessages.bind(this.model),
2021-10-05 16:47:06 +00:00
markMessageRead,
onBlock: createMessageRequestResponseHandler(
'onBlock',
messageRequestEnum.BLOCK
),
onBlockAndReportSpam: (conversationId: string) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(
`onBlockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
);
return;
}
this.blockAndReportSpam(conversation);
},
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
messageRequestEnum.ACCEPT
),
removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({
name: 'removeMember',
task: () => this.model.removeFromGroupV2(conversationId),
});
},
scrollToQuotedMessage,
unblurAvatar: () => {
this.model.unblurAvatar();
},
updateSharedGroups: () => this.model.throttledUpdateSharedGroups?.(),
};
// setupCompositionArea
window.reduxActions.composer.resetComposer();
const compositionAreaProps = {
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
onEditorStateChange: (
msg: string,
2022-11-10 04:59:36 +00:00
bodyRanges: DraftBodyRangesType,
2021-04-27 22:35:35 +00:00
caretLocation?: number
2020-11-03 01:19:52 +00:00
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
2021-09-22 20:59:54 +00:00
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
2021-08-30 21:32:56 +00:00
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
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({
2022-09-27 20:24:21 +00:00
dialogName: 'GroupV2CancelRequestToJoin',
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
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
2021-10-05 16:47:06 +00:00
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
2021-06-25 16:08:16 +00:00
onCloseLinkPreview: () => {
2022-06-17 00:48:57 +00:00
suspendLinkPreviews();
removeLinkPreview();
2021-06-25 16:08:16 +00:00
},
2021-07-20 20:18:35 +00:00
openConversation: this.openConversation.bind(this),
2021-09-29 20:23:06 +00:00
onSendMessage: ({
draftAttachments,
mentions = [],
message = '',
timestamp,
voiceNoteAttachment,
}: {
draftAttachments?: ReadonlyArray<AttachmentType>;
2022-11-10 04:59:36 +00:00
mentions?: DraftBodyRangesType;
2021-09-29 20:23:06 +00:00
message?: string;
timestamp?: number;
voiceNoteAttachment?: AttachmentType;
}): void => {
this.sendMessage(message, mentions, {
draftAttachments,
timestamp,
voiceNoteAttachment,
});
},
};
2021-10-05 16:47:06 +00:00
// createConversationView root
const JSX = createConversationView(window.reduxStore, {
compositionAreaProps,
conversationHeaderProps,
timelineProps,
});
this.conversationView = new ReactWrapperView({ JSX });
2021-10-05 16:47:06 +00:00
this.$('.ConversationView__template').append(this.conversationView.el);
2021-08-30 21:32:56 +00:00
}
async onOutgoingVideoCallInConversation(): Promise<void> {
log.info('onOutgoingVideoCallInConversation: about to start a video call');
// if it's a group call on an announcementsOnly group
// only allow join if the call has already been started (presumably by the admin)
if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) {
const call = getOwn(
window.reduxStore.getState().calling.callsByConversation,
this.model.id
);
// technically not necessary, but isAnybodyElseInGroupCall requires it
const ourUuid = window.storage.user.getCheckedUuid().toString();
const isOngoingGroupCall =
call &&
ourUuid &&
call.callMode === CallMode.Group &&
call.peekInfo &&
isAnybodyElseInGroupCall(call.peekInfo, ourUuid);
if (!isOngoingGroupCall) {
showToast(ToastCannotStartGroupCall);
return;
}
}
if (await this.isCallSafe()) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
window.reduxActions.calling.startCallingLobby({
conversationId: this.model.id,
isVideoCall: true,
});
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
}
async onOutgoingAudioCallInConversation(): Promise<void> {
log.info('onOutgoingAudioCallInConversation: about to start an audio call');
if (await this.isCallSafe()) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
window.reduxActions.calling.startCallingLobby({
conversationId: this.model.id,
isVideoCall: false,
});
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
}
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 = async (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => {
const { emoji, remove } = reaction;
try {
await enqueueReactionForSend({
messageId,
emoji,
remove,
});
} catch (error) {
log.error('Error sending reaction', error, messageId, reaction);
showToast(ToastReactionFailed);
}
};
const replyToMessage = (messageId: string) => {
this.setQuoteMessage(messageId);
};
const retrySend = retryMessageSend;
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?: {
phoneNumber: string;
uuid: UUIDStringType;
};
}) => {
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 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);
};
2022-05-11 20:59:58 +00:00
const openGiftBadge = (messageId: string): void => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`openGiftBadge: Message ${messageId} missing!`);
}
showToast(ToastCannotOpenGiftBadge, {
isIncoming: isIncoming(message.attributes),
});
};
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');
2021-09-22 20:59:54 +00:00
showToast(ToastTapToViewExpiredIncoming);
};
const showExpiredOutgoingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an outgoing message');
2021-09-22 20:59:54 +00:00
showToast(ToastTapToViewExpiredOutgoing);
};
2021-08-30 21:32:56 +00:00
2021-04-27 22:35:35 +00:00
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
const startConversation = this.startConversation.bind(this);
return {
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
downloadNewVersion,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
openConversation,
2022-05-11 20:59:58 +00:00
openGiftBadge,
openLink,
reactToMessage,
replyToMessage,
retrySend,
retryDeleteForEveryone,
showContactDetail,
showContactModal,
showSafetyNumber,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
2021-04-27 22:35:35 +00:00
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
startConversation,
};
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);
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-11-11 22:43:05 +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.model.loadAndScroll(messageId);
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.
2021-11-11 22:43:05 +00:00
const { droppedGV2MemberIds, pendingMembersV2 } =
await this.longRunningTaskWrapper({
name: 'getGroupMigrationMembers',
task: () => window.Signal.Groups.getGroupMigrationMembers(this.model),
});
const invitedMemberIds = pendingMembersV2.map(
2021-10-26 22:59:08 +00:00
(item: GroupV2PendingMemberType) => item.uuid
);
this.migrationDialog = new 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-09-24 20:02:30 +00:00
// TODO DESKTOP-2426
async processAttachments(files: Array<File>): Promise<void> {
const state = window.reduxStore.getState();
const isRecording =
state.audioRecorder.recordingState === RecordingState.Recording;
2022-06-17 00:48:57 +00:00
if (hasLinkPreviewLoaded() || isRecording) {
return;
}
2021-09-24 20:02:30 +00:00
const {
addAttachment,
addPendingAttachment,
processAttachments,
removeAttachment,
} = window.reduxActions.composer;
await processAttachments({
addAttachment,
addPendingAttachment,
conversationId: this.model.id,
draftAttachments: this.model.get('draftAttachments') || [],
files,
onShowToast: (toastType: AttachmentToastType) => {
if (toastType === AttachmentToastType.ToastFileSize) {
showToast(ToastFileSize, {
limit: 100,
units: 'MB',
});
} else if (toastType === AttachmentToastType.ToastDangerousFileType) {
showToast(ToastDangerousFileType);
} else if (toastType === AttachmentToastType.ToastMaxAttachments) {
showToast(ToastMaxAttachments);
} else if (
toastType === AttachmentToastType.ToastUnsupportedMultiAttachment
) {
showToast(ToastUnsupportedMultiAttachment);
2021-09-24 20:02:30 +00:00
} else if (
toastType ===
AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments
2021-09-24 20:02:30 +00:00
) {
showToast(ToastCannotMixMultiAndNonMultiAttachments);
2021-09-24 20:02:30 +00:00
} else if (
toastType === AttachmentToastType.ToastUnableToLoadAttachment
) {
showToast(ToastUnableToLoadAttachment);
}
},
removeAttachment,
});
2021-08-30 21:32:56 +00:00
}
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()) {
const now = Date.now();
const active_at = this.model.get('active_at') || now;
this.model.set({
active_at,
draftChanged: false,
draftTimestamp: now,
timestamp: 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-10-05 16:47:06 +00:00
this.conversationView?.remove();
if (this.contactModalView) {
this.contactModalView.remove();
}
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.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.view.remove();
}
2020-10-30 17:52:21 +00:00
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
}
2022-06-17 00:48:57 +00:00
removeLinkPreview();
suspendLinkPreviews();
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;
2021-09-24 20:02:30 +00:00
this.processAttachments(Array.from(files));
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();
2021-09-24 20:02:30 +00:00
const files: Array<File> = [];
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) {
2021-09-24 20:02:30 +00:00
files.push(file);
2021-08-30 21:32:56 +00:00
}
}
}
2021-09-24 20:02:30 +00:00
this.processAttachments(files);
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,
}),
]);
2021-09-22 20:59:54 +00:00
showToast(ToastReportedSpamAndBlocked);
},
});
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 clearAttachments(): Promise<void> {
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(
2021-09-24 20:02:30 +00:00
draftAttachments.map(attachment => 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
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'),
2021-09-24 20:02:30 +00:00
draftAttachments
2021-06-25 16:08:16 +00:00
);
if (this.hasFiles({ includePending: true })) {
2022-06-17 00:48:57 +00:00
removeLinkPreview();
}
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
async onOpened(messageId: string): Promise<void> {
2022-01-20 00:40:29 +00:00
this.model.onOpenStart();
if (messageId) {
const message = await getMessageById(messageId);
if (message) {
this.model.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
}
const loadAndUpdate = async () => {
Promise.all([
this.model.loadNewestMessages(undefined, undefined),
this.model.updateLastMessage(),
this.model.updateUnread(),
]);
};
loadAndUpdate();
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();
const ourUuid = window.textsecure.storage.user.getUuid(UUIDKind.ACI);
if (
!isGroup(this.model.attributes) ||
2022-07-08 20:46:25 +00:00
(ourUuid && this.model.hasMember(ourUuid))
) {
strictAssert(
this.model.throttledGetProfiles !== undefined,
'Conversation model should be initialized'
);
await this.model.throttledGetProfiles();
}
2021-08-30 21:32:56 +00:00
this.model.updateVerified();
}
2021-09-07 16:12:26 +00:00
async showForwardMessageModal(messageId: string): Promise<void> {
2022-07-01 00:52:03 +00:00
window.reduxActions.globalModals.toggleForwardMessageModal(messageId);
2021-08-30 21:32:56 +00:00
}
2021-04-27 22:35:35 +00:00
showAllMedia(): void {
if (document.querySelectorAll('.module-media-gallery').length) {
return;
}
// 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');
2021-12-20 21:04:02 +00:00
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const getProps = async () => {
2021-11-11 22:43:05 +00:00
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;
// We want these message to be cached in memory for other operations like
// listening to 'expired' events when showing the lightbox, and so any other
// code working with this message has the latest updates.
const model = window.MessageController.register(message.id, 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);
model.set(rawMedia[i]);
// eslint-disable-next-line no-await-in-loop
2021-12-20 21:04:02 +00:00
await window.Signal.Data.saveMessage(rawMedia[i], { ourUuid });
}
}
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?.path
? 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.lookupOrCreate({
uuid: message.sourceUuid,
e164: message.source,
})?.id || message.conversationId,
2021-08-24 21:47:14 +00:00
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: Array<MediaItemType> = [];
rawDocuments.forEach(message => {
const attachments = message.attachments || [];
const attachment = attachments[0];
if (!attachment) {
return;
}
documents.push({
contentType: attachment.contentType,
index: 0,
attachment,
// We do this cast because we know there attachments (see the checks above).
message: message as MessageAttributesType & {
attachments: Array<AttachmentType>;
},
});
});
2021-08-30 21:32:56 +00:00
const onItemClick = async ({
message,
attachment,
type,
}: ItemClickEvent) => {
switch (type) {
case 'documents': {
2022-07-01 00:52:03 +00:00
saveAttachment(attachment, message.sent_at);
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,
};
};
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;
}
});
const view = new ReactWrapperView({
className: 'panel',
// We present an empty panel briefly, while we wait for props to load.
2022-11-18 00:45:19 +00:00
// eslint-disable-next-line react/jsx-no-useless-fragment
JSX: <></>,
onClose: () => {
unsubscribe();
},
});
const headerTitle = window.i18n('allMedia');
const update = async () => {
const props = await getProps();
view.update(<MediaGallery i18n={window.i18n} {...props} />);
};
this.addPanel({ view, headerTitle });
update();
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, id } = this.model;
2021-06-17 21:15:09 +00:00
const memberships =
contactCollection?.map((conversation: ConversationModel) => {
return {
isAdmin: false,
member: conversation.format(),
};
}) || [];
const reduxState = window.reduxStore.getState();
const getPreferredBadge = getPreferredBadgeSelector(reduxState);
const theme = getTheme(reduxState);
const view = new ReactWrapperView({
2021-06-17 21:15:09 +00:00
className: 'group-member-list panel',
JSX: (
<ConversationDetailsMembershipList
canAddNewMembers={false}
conversationId={id}
i18n={window.i18n}
getPreferredBadge={getPreferredBadge}
maxShownMemberCount={32}
memberships={memberships}
showContactModal={contactId => {
this.showContactModal(contactId);
}}
theme={theme}
/>
),
});
this.addPanel({ 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)) {
conversation = this.model;
} else {
conversation = window.ConversationController.get(id);
}
if (conversation) {
window.reduxActions.globalModals.toggleSafetyNumberModal(
conversation.get('id')
);
}
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) {
2021-09-22 20:59:54 +00:00
showToast(ToastDangerousFileType);
return;
}
2022-07-01 00:52:03 +00:00
return saveAttachment(attachment, timestamp);
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 { path: tempPath } = await copyIntoTempDirectory(absolutePath);
const tempAttachment = {
...firstAttachment,
path: tempPath,
};
await message.markViewOnceMessageViewed();
const close = (): void => {
try {
this.stopListening(message);
closeLightbox();
} finally {
deleteTempFile(tempPath);
}
};
this.listenTo(message, 'expired', close);
this.listenTo(message, 'change', () => {
2022-06-16 19:12:50 +00:00
showLightbox(getProps());
});
const getProps = (): ComponentProps<typeof Lightbox> => {
const { path, contentType } = tempAttachment;
return {
close,
i18n: window.i18n,
2021-08-24 21:47:14 +00:00
media: [
{
attachment: tempAttachment,
objectURL: getAbsoluteTempPath(path),
contentType,
index: 0,
message: {
attachments: message.get('attachments') || [],
2021-08-24 21:47:14 +00:00
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
2022-06-16 19:12:50 +00:00
showLightbox(getProps());
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({
2022-09-27 20:24:21 +00:00
dialogName: 'deleteMessage',
confirmStyle: 'negative',
message: window.i18n('deleteWarning'),
okText: window.i18n('delete'),
resolve: () => {
window.Signal.Data.removeMessage(message.id);
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({
2022-09-27 20:24:21 +00:00
dialogName: 'deleteMessageForEveryone',
confirmStyle: 'negative',
message: window.i18n('deleteForEveryoneWarning'),
okText: window.i18n('delete'),
resolve: async () => {
try {
2022-07-01 00:52:03 +00:00
await sendDeleteForEveryoneMessage(this.model.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) {
log.error(
'Error sending delete-for-everyone',
Errors.toLogFormat(error),
messageId
);
2021-09-22 20:59:54 +00:00
showToast(ToastDeleteForEveryoneFailed);
}
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 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;
}) => {
2022-07-01 00:52:03 +00:00
return saveAttachment(attachment, message.sent_at, index + 1);
};
const selectedIndex = media.findIndex(
mediaItem =>
mediaItem.attachment.path === selectedMediaItem.attachment.path
);
const mediaMessage = selectedMediaItem.message;
const message = window.MessageController.getById(mediaMessage.id);
if (!message) {
throw new Error(
`showLightboxForMedia: Message ${mediaMessage.id} missing!`
);
}
const close = () => {
closeLightbox();
this.stopListening(message, 'expired', closeLightbox);
};
2022-06-16 19:12:50 +00:00
showLightbox({
close,
2022-06-16 19:12:50 +00:00
i18n: window.i18n,
getConversation: getConversationSelector(window.reduxStore.getState()),
media,
onForward: messageId => {
this.showForwardMessageModal(messageId);
},
onSave,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
});
this.listenTo(message, 'expired', close);
2021-08-30 21:32:56 +00:00
}
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.lookupOrCreate({
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 ?? ''),
}));
if (!media.length) {
log.error(
'showLightbox: unable to load attachment',
attachments.map(x => ({
contentType: x.contentType,
error: x.error,
flags: x.flags,
path: x.path,
size: x.size,
}))
);
showToast(ToastUnableToLoadAttachment);
return;
}
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 {
2021-09-21 22:37:10 +00:00
window.reduxActions.globalModals.showContactModal(contactId, this.model.id);
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showGroupLinkManagement(): void {
const view = new ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupLinkManagement(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
}
),
});
const headerTitle = window.i18n('ConversationDetails--group-link');
this.addPanel({ view, headerTitle });
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showGroupV2Permissions(): void {
const view = new ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createGroupV2Permissions(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
2021-11-11 22:43:05 +00:00
setAccessControlAttributesSetting:
this.setAccessControlAttributesSetting.bind(this),
setAccessControlMembersSetting:
this.setAccessControlMembersSetting.bind(this),
2021-07-20 20:18:35 +00:00
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
}
),
});
const headerTitle = window.i18n('permissions');
this.addPanel({ view, headerTitle });
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showPendingInvites(): void {
const view = new ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
2021-11-10 23:01:06 +00:00
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
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);
},
}),
});
const headerTitle = window.i18n(
'ConversationDetails--requests-and-invites'
);
this.addPanel({ view, headerTitle });
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 {
const view = new ReactWrapperView({
2021-08-05 12:35:33 +00:00
className: 'panel',
JSX: window.Signal.State.Roots.createConversationNotificationsSettings(
window.reduxStore,
{
2021-08-30 21:32:56 +00:00
conversationId: this.model.id,
2021-11-11 22:43:05 +00:00
setDontNotifyForMentionsIfMuted:
this.model.setDontNotifyForMentionsIfMuted.bind(this.model),
2021-08-05 12:35:33 +00:00
setMuteExpiration: this.setMuteExpiration.bind(this),
}
),
});
const headerTitle = window.i18n('ConversationDetails--notifications');
2021-08-05 12:35:33 +00:00
this.addPanel({ view, headerTitle });
2021-08-05 12:35:33 +00:00
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 {
const view = new ReactWrapperView({
2021-05-28 16:15:17 +00:00
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
}),
});
const headerTitle = window.i18n('ChatColorPicker__menu-title');
2021-05-28 16:15:17 +00:00
this.addPanel({ view, headerTitle });
2021-05-28 16:15:17 +00:00
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),
showChatColorEditor: this.showChatColorEditor.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
2021-11-11 22:43:05 +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,
onUnblock: () => {
this.syncMessageRequestResponse(
'onUnblock',
this.model,
messageRequestEnum.ACCEPT
);
},
setMuteExpiration: this.setMuteExpiration.bind(this),
2021-11-11 22:43:05 +00:00
onOutgoingAudioCallInConversation:
this.onOutgoingAudioCallInConversation.bind(this),
onOutgoingVideoCallInConversation:
this.onOutgoingVideoCallInConversation.bind(this),
};
const view = new ReactWrapperView({
className: 'conversation-details-pane panel',
JSX: window.Signal.State.Roots.createConversationDetails(
window.reduxStore,
props
),
});
const headerTitle = '';
this.addPanel({ view, headerTitle });
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 ReactWrapperView({
className: 'panel message-detail-wrapper',
JSX: window.Signal.State.Roots.createMessageDetail(
window.reduxStore,
getProps()
),
onClose,
});
const update = () =>
view.update(
window.Signal.State.Roots.createMessageDetail(
window.reduxStore,
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.addPanel({ view });
view.render();
2021-08-30 21:32:56 +00:00
}
2021-08-30 21:32:56 +00:00
showStickerManager(): void {
const view = new ReactWrapperView({
className: ['sticker-manager-wrapper', 'panel'].join(' '),
JSX: window.Signal.State.Roots.createStickerManager(window.reduxStore),
onClose: () => {
this.resetPanel();
},
});
this.addPanel({ view });
view.render();
2021-08-30 21:32:56 +00:00
}
showContactDetail({
contact,
signalAccount,
}: {
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
};
2021-08-30 21:32:56 +00:00
}): void {
const view = new ReactWrapperView({
className: 'contact-detail-pane panel',
JSX: (
<ContactDetail
i18n={window.i18n}
contact={contact}
hasSignalAccount={Boolean(signalAccount)}
onSendMessage={() => {
if (signalAccount) {
this.startConversation(
signalAccount.phoneNumber,
signalAccount.uuid
);
}
}}
/>
),
onClose: () => {
this.resetPanel();
},
});
this.addPanel({ view });
2021-08-30 21:32:56 +00:00
}
startConversation(e164: string, uuid: UUIDStringType): void {
const conversation = window.ConversationController.lookupOrCreate({
e164,
uuid,
});
strictAssert(
conversation,
`startConversation failed given ${e164}/${uuid} combination`
);
this.openConversation(conversation.id);
}
2021-08-30 21:32:56 +00:00
async openConversation(
conversationId: string,
messageId?: string
): Promise<void> {
window.Whisper.events.trigger(
'showConversation',
conversationId,
messageId
);
}
addPanel(panel: PanelType): 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(panel);
panel.view.$el.insertAfter(this.$('.panel').last());
panel.view.$el.one('animationend', () => {
panel.view.$el.addClass('panel--static');
});
2020-10-30 17:52:21 +00:00
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
panel.headerTitle
);
2021-08-30 21:32:56 +00:00
}
resetPanel(): void {
if (!this.panels || !this.panels.length) {
return;
}
const panel = 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].view.$el.fadeIn(250);
}
if (panel) {
let timeout: ReturnType<typeof setTimeout> | undefined;
const removePanel = () => {
if (!timeout) {
return;
}
clearTimeout(timeout);
timeout = undefined;
panel.view.remove();
};
panel.view.$el
.addClass('panel--remove')
.one('transitionend', removePanel);
// Backup, in case things go wrong with the transitionend event
timeout = setTimeout(removePanel, SECOND);
2021-08-30 21:32:56 +00:00
}
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
}
async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
2021-11-11 22:43:05 +00:00
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(model.id, {
limit,
2021-11-11 22:43:05 +00:00
});
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
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?.path
? 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
}
2022-11-16 20:18:02 +00:00
async setDisappearingMessages(seconds: DurationInSeconds): 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, {
reason: 'setDisappearingMessages',
}),
2020-10-06 17:06:34 +00:00
});
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({
2022-09-27 20:24:21 +00:00
dialogName: 'destroyMessages',
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 recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
const callAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.Calling
);
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
async sendStickerMessage(options: {
packId: string;
stickerId: number;
}): Promise<void> {
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
try {
const sendAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!sendAnyway) {
return;
}
if (this.showInvalidMessageToast()) {
return;
}
const { packId, stickerId } = options;
this.model.sendStickerMessage(packId, stickerId);
} catch (error) {
log.error('clickSend error:', Errors.toLogFormat(error));
}
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 } = this;
const message = messageId ? await getMessageById(messageId) : 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) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at && messageId) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draftChanged: true,
quotedMessageId: messageId,
timestamp,
});
await this.saveModel();
}
if (message) {
this.quotedMessage = message;
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;
2021-09-22 20:59:54 +00:00
let toastView:
| undefined
| typeof ToastBlocked
| typeof ToastBlockedGroup
| typeof ToastExpired
| typeof ToastInvalidConversation
| typeof ToastLeftGroup
| typeof ToastMessageBodyTooLong;
if (window.reduxStore.getState().expiration.hasExpired) {
2021-09-22 20:59:54 +00:00
toastView = ToastExpired;
}
if (!model.isValid()) {
2021-09-22 20:59:54 +00:00
toastView = ToastInvalidConversation;
}
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)))
) {
2021-09-22 20:59:54 +00:00
toastView = ToastBlocked;
}
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)
) {
2021-09-22 20:59:54 +00:00
toastView = ToastBlockedGroup;
}
if (!isDirectConversation(model.attributes) && model.get('left')) {
2021-09-22 20:59:54 +00:00
toastView = ToastLeftGroup;
}
if (messageText && messageText.length > MAX_MESSAGE_BODY_LENGTH) {
2021-09-22 20:59:54 +00:00
toastView = ToastMessageBodyTooLong;
}
2021-09-22 20:59:54 +00:00
if (toastView) {
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 = '',
2022-11-10 04:59:36 +00:00
mentions: DraftBodyRangesType = [],
2021-09-29 20:23:06 +00:00
options: {
draftAttachments?: ReadonlyArray<AttachmentType>;
timestamp?: number;
voiceNoteAttachment?: AttachmentType;
} = {}
2021-08-30 21:32:56 +00:00
): Promise<void> {
2021-07-30 18:37:03 +00:00
const timestamp = options.timestamp || Date.now();
this.sendStart = Date.now();
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
try {
this.disableMessageField();
const sendAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!sendAnyway) {
this.enableMessageField();
return;
}
} catch (error) {
this.enableMessageField();
log.error('sendMessage error:', Errors.toLogFormat(error));
return;
}
this.model.clearTypingTimers();
if (this.showInvalidMessageToast(message)) {
this.enableMessageField();
return;
}
try {
if (
!message.length &&
!this.hasFiles({ includePending: false }) &&
2021-09-29 20:23:06 +00:00
!options.voiceNoteAttachment
) {
return;
}
2021-09-29 20:23:06 +00:00
let attachments: Array<AttachmentType> = [];
if (options.voiceNoteAttachment) {
attachments = [options.voiceNoteAttachment];
} else if (options.draftAttachments) {
attachments = (
await Promise.all(
options.draftAttachments.map(resolveAttachmentDraftData)
)
).filter(isNotNil);
}
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');
await this.model.enqueueMessageForSend(
{
body: message,
attachments,
quote: this.quote,
2022-06-17 00:48:57 +00:00
preview: getLinkPreviewForSend(message),
mentions,
},
{
sendHQImages,
timestamp,
2022-05-23 22:04:33 +00:00
extraReduxActions: () => {
this.compositionApi.current?.reset();
this.model.setMarkedUnread(false);
2022-05-23 22:04:33 +00:00
this.setQuoteMessage(null);
2022-06-17 00:48:57 +00:00
resetLinkPreview();
2022-05-23 22:04:33 +00:00
this.clearAttachments();
window.reduxActions.composer.resetComposer();
},
}
);
} catch (error) {
log.error(
'Error pulling attached files before send',
Errors.toLogFormat(error)
);
} finally {
this.enableMessageField();
}
2021-08-30 21:32:56 +00:00
}
2020-11-03 01:19:52 +00:00
onEditorStateChange(
messageText: string,
2022-11-10 04:59:36 +00:00
bodyRanges: DraftBodyRangesType,
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);
2022-06-17 00:48:57 +00:00
// If we have attachments, don't add link preview
if (!this.hasFiles({ includePending: true })) {
maybeGrabLinkPreview(messageText, LinkPreviewSourceType.Composer, {
caretLocation,
});
2022-06-17 00:48:57 +00:00
}
2021-08-30 21:32:56 +00:00
}
2020-11-03 01:19:52 +00:00
async saveDraft(
messageText: string,
2022-11-10 04:59:36 +00:00
bodyRanges: DraftBodyRangesType
2021-08-30 21:32:56 +00:00
): 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')) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draft: messageText,
2020-11-03 01:19:52 +00:00
draftBodyRanges: bodyRanges,
draftChanged: true,
timestamp,
});
await this.saveModel();
}
2021-08-30 21:32:56 +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;