![Fedor Indutny](/assets/img/avatar_default.png)
Introduce new UI and behavior for playing audio attachments in conversations. Previously, playback stopped unexpectedly during window resizes and scrolling through the messages due to the row height recomputation in `react-virtualized`. With this commit we introduce `<GlobalAudioContext/>` instance that wraps whole conversation and provides an `<audio/>` element that doesn't get re-rendered (or destroyed) whenever `react-virtualized` recomputes messages. The audio players (with a freshly designed UI) now share this global `<audio/>` instance and manage access to it using `audioPlayer.owner` state from the redux. New UI computes on the fly, caches, and displays waveforms for each audio attachment. Storybook had to be slightly modified to accomodate testing of Android bubbles by introducing the new knob for `authorColor`.
3961 lines
118 KiB
TypeScript
3961 lines
118 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import {
|
|
WhatIsThis,
|
|
MessageAttributesType,
|
|
CustomError,
|
|
} from '../model-types.d';
|
|
import { DataMessageClass } from '../textsecure.d';
|
|
import { ConversationModel } from './conversations';
|
|
import {
|
|
LastMessageStatus,
|
|
ConversationType,
|
|
} from '../state/ducks/conversations';
|
|
import { getActiveCall } from '../state/ducks/calling';
|
|
import { getCallSelector, isInCall } from '../state/selectors/calling';
|
|
import { PropsData } from '../components/conversation/Message';
|
|
import { CallbackResultType } from '../textsecure/SendMessage';
|
|
import { ExpirationTimerOptions } from '../util/ExpirationTimerOptions';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { CallMode } from '../types/Calling';
|
|
import { BodyRangesType } from '../types/Util';
|
|
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
|
|
import {
|
|
PropsData as TimerNotificationProps,
|
|
TimerNotificationType,
|
|
} from '../components/conversation/TimerNotification';
|
|
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
|
|
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
|
|
import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration';
|
|
import {
|
|
PropsData as GroupNotificationProps,
|
|
ChangeType,
|
|
} from '../components/conversation/GroupNotification';
|
|
import { Props as ResetSessionNotificationProps } from '../components/conversation/ResetSessionNotification';
|
|
import {
|
|
CallingNotificationType,
|
|
getCallingNotificationText,
|
|
} from '../util/callingNotification';
|
|
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
|
|
import { isImage, isVideo } from '../types/Attachment';
|
|
|
|
/* eslint-disable camelcase */
|
|
/* eslint-disable more/no-then */
|
|
|
|
declare const _: typeof window._;
|
|
|
|
window.Whisper = window.Whisper || {};
|
|
|
|
const {
|
|
Message: TypedMessage,
|
|
Attachment,
|
|
MIME,
|
|
Contact,
|
|
PhoneNumber,
|
|
Errors,
|
|
} = window.Signal.Types;
|
|
const {
|
|
deleteExternalMessageFiles,
|
|
getAbsoluteAttachmentPath,
|
|
loadAttachmentData,
|
|
loadQuoteData,
|
|
loadPreviewData,
|
|
loadStickerData,
|
|
upgradeMessageSchema,
|
|
} = window.Signal.Migrations;
|
|
const {
|
|
copyStickerToAttachments,
|
|
deletePackReference,
|
|
savePackMetadata,
|
|
getStickerPackStatus,
|
|
} = window.Signal.Stickers;
|
|
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
|
|
|
|
const { addStickerPackReference, getMessageBySender } = window.Signal.Data;
|
|
const { bytesFromString } = window.Signal.Crypto;
|
|
const PLACEHOLDER_CONTACT: Pick<ConversationType, 'title' | 'type' | 'id'> = {
|
|
id: 'placeholder-contact',
|
|
type: 'direct',
|
|
title: window.i18n('unknownContact'),
|
|
};
|
|
|
|
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
|
|
|
window.AccountCache = Object.create(null);
|
|
window.AccountJobs = Object.create(null);
|
|
|
|
window.doesAccountCheckJobExist = number => Boolean(window.AccountJobs[number]);
|
|
window.checkForSignalAccount = number => {
|
|
if (window.AccountJobs[number]) {
|
|
return window.AccountJobs[number];
|
|
}
|
|
|
|
let job;
|
|
if (window.textsecure.messaging) {
|
|
// eslint-disable-next-line more/no-then
|
|
job = window.textsecure.messaging
|
|
.getProfile(number)
|
|
.then(() => {
|
|
window.AccountCache[number] = true;
|
|
})
|
|
.catch(() => {
|
|
window.AccountCache[number] = false;
|
|
});
|
|
} else {
|
|
// We're offline!
|
|
job = Promise.resolve().then(() => {
|
|
window.AccountCache[number] = false;
|
|
});
|
|
}
|
|
|
|
window.AccountJobs[number] = job;
|
|
|
|
return job;
|
|
};
|
|
|
|
window.isSignalAccountCheckComplete = number =>
|
|
window.AccountCache[number] !== undefined;
|
|
window.hasSignalAccount = number => window.AccountCache[number];
|
|
|
|
const includesAny = <T>(haystack: Array<T>, ...needles: Array<T>) =>
|
|
needles.some(needle => haystack.includes(needle));
|
|
|
|
export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|
static updateTimers: () => void;
|
|
|
|
static getLongMessageAttachment: (
|
|
attachment: typeof window.WhatIsThis
|
|
) => typeof window.WhatIsThis;
|
|
|
|
static LONG_MESSAGE_CONTENT_TYPE: string;
|
|
|
|
CURRENT_PROTOCOL_VERSION?: number;
|
|
|
|
// Set when sending some sync messages, so we get the functionality of
|
|
// send(), without zombie messages going into the database.
|
|
doNotSave?: boolean;
|
|
|
|
INITIAL_PROTOCOL_VERSION?: number;
|
|
|
|
OUR_NUMBER?: string;
|
|
|
|
OUR_UUID?: string;
|
|
|
|
isSelected?: boolean;
|
|
|
|
hasExpired?: boolean;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
quotedMessage: any;
|
|
|
|
syncPromise?: Promise<unknown>;
|
|
|
|
initialize(attributes: unknown): void {
|
|
if (_.isObject(attributes)) {
|
|
this.set(
|
|
TypedMessage.initializeSchemaVersion({
|
|
message: attributes,
|
|
logger: window.log,
|
|
})
|
|
);
|
|
}
|
|
|
|
this.CURRENT_PROTOCOL_VERSION =
|
|
window.textsecure.protobuf.DataMessage.ProtocolVersion.CURRENT;
|
|
this.INITIAL_PROTOCOL_VERSION =
|
|
window.textsecure.protobuf.DataMessage.ProtocolVersion.INITIAL;
|
|
this.OUR_NUMBER = window.textsecure.storage.user.getNumber();
|
|
this.OUR_UUID = window.textsecure.storage.user.getUuid();
|
|
|
|
this.on('destroy', this.onDestroy);
|
|
this.on('change:expirationStartTimestamp', this.setToExpire);
|
|
this.on('change:expireTimer', this.setToExpire);
|
|
this.on('unload', this.unload);
|
|
this.on('expired', this.onExpired);
|
|
this.setToExpire();
|
|
|
|
this.on('change', this.notifyRedux);
|
|
}
|
|
|
|
notifyRedux(): void {
|
|
const { messageChanged } = window.reduxActions.conversations;
|
|
|
|
if (messageChanged) {
|
|
const conversationId = this.get('conversationId');
|
|
// Note: The clone is important for triggering a re-run of selectors
|
|
messageChanged(this.id, conversationId, this.getReduxData());
|
|
}
|
|
}
|
|
|
|
getReduxData(): WhatIsThis {
|
|
const contact = this.getPropsForEmbeddedContact();
|
|
|
|
return {
|
|
...this.attributes,
|
|
// We need this in the reducer to detect if the message's height has changed
|
|
hasSignalAccount: contact ? Boolean(contact.signalAccount) : null,
|
|
};
|
|
}
|
|
|
|
getSenderIdentifier(): string {
|
|
const sentAt = this.get('sent_at');
|
|
const source = this.get('source');
|
|
const sourceUuid = this.get('sourceUuid');
|
|
const sourceDevice = this.get('sourceDevice');
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const sourceId = window.ConversationController.ensureContactIds({
|
|
e164: source,
|
|
uuid: sourceUuid,
|
|
})!;
|
|
|
|
return `${sourceId}.${sourceDevice}-${sentAt}`;
|
|
}
|
|
|
|
getReceivedAt(): number {
|
|
// We would like to get the received_at_ms ideally since received_at is
|
|
// now an incrementing counter for messages and not the actual time that
|
|
// the message was received. If this field doesn't exist on the message
|
|
// then we can trust received_at.
|
|
return Number(this.get('received_at_ms') || this.get('received_at'));
|
|
}
|
|
|
|
isNormalBubble(): boolean {
|
|
return (
|
|
!this.isCallHistory() &&
|
|
!this.isChatSessionRefreshed() &&
|
|
!this.isEndSession() &&
|
|
!this.isExpirationTimerUpdate() &&
|
|
!this.isGroupUpdate() &&
|
|
!this.isGroupV2Change() &&
|
|
!this.isGroupV1Migration() &&
|
|
!this.isKeyChange() &&
|
|
!this.isMessageHistoryUnsynced() &&
|
|
!this.isProfileChange() &&
|
|
!this.isUnsupportedMessage() &&
|
|
!this.isVerifiedChange()
|
|
);
|
|
}
|
|
|
|
// Top-level prop generation for the message bubble
|
|
getPropsForBubble(): WhatIsThis {
|
|
if (this.isUnsupportedMessage()) {
|
|
return {
|
|
type: 'unsupportedMessage',
|
|
data: this.getPropsForUnsupportedMessage(),
|
|
};
|
|
}
|
|
if (this.isGroupV2Change()) {
|
|
return {
|
|
type: 'groupV2Change',
|
|
data: this.getPropsForGroupV2Change(),
|
|
};
|
|
}
|
|
if (this.isGroupV1Migration()) {
|
|
return {
|
|
type: 'groupV1Migration',
|
|
data: this.getPropsForGroupV1Migration(),
|
|
};
|
|
}
|
|
if (this.isMessageHistoryUnsynced()) {
|
|
return {
|
|
type: 'linkNotification',
|
|
data: null,
|
|
};
|
|
}
|
|
if (this.isExpirationTimerUpdate()) {
|
|
return {
|
|
type: 'timerNotification',
|
|
data: this.getPropsForTimerNotification(),
|
|
};
|
|
}
|
|
if (this.isKeyChange()) {
|
|
return {
|
|
type: 'safetyNumberNotification',
|
|
data: this.getPropsForSafetyNumberNotification(),
|
|
};
|
|
}
|
|
if (this.isVerifiedChange()) {
|
|
return {
|
|
type: 'verificationNotification',
|
|
data: this.getPropsForVerificationNotification(),
|
|
};
|
|
}
|
|
if (this.isGroupUpdate()) {
|
|
return {
|
|
type: 'groupNotification',
|
|
data: this.getPropsForGroupNotification(),
|
|
};
|
|
}
|
|
if (this.isEndSession()) {
|
|
return {
|
|
type: 'resetSessionNotification',
|
|
data: this.getPropsForResetSessionNotification(),
|
|
};
|
|
}
|
|
if (this.isCallHistory()) {
|
|
return {
|
|
type: 'callHistory',
|
|
data: this.getPropsForCallHistory(),
|
|
};
|
|
}
|
|
if (this.isProfileChange()) {
|
|
return {
|
|
type: 'profileChange',
|
|
data: this.getPropsForProfileChange(),
|
|
};
|
|
}
|
|
if (this.isChatSessionRefreshed()) {
|
|
return {
|
|
type: 'chatSessionRefreshed',
|
|
data: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'message',
|
|
data: this.getPropsForMessage(),
|
|
};
|
|
}
|
|
|
|
getPropsForMessageDetail(): WhatIsThis {
|
|
const newIdentity = window.i18n('newIdentity');
|
|
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
|
|
|
|
const unidentifiedLookup = (
|
|
this.get('unidentifiedDeliveries') || []
|
|
).reduce((accumulator: Record<string, boolean>, identifier: string) => {
|
|
accumulator[
|
|
window.ConversationController.getConversationId(identifier) as string
|
|
] = true;
|
|
return accumulator;
|
|
}, Object.create(null) as Record<string, boolean>);
|
|
|
|
// We include numbers we didn't successfully send to so we can display errors.
|
|
// Older messages don't have the recipients included on the message, so we fall
|
|
// back to the conversation's current recipients
|
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
const conversationIds = this.isIncoming()
|
|
? [this.getContactId()!]
|
|
: _.union(
|
|
(this.get('sent_to') || []).map(
|
|
(id: string) => window.ConversationController.getConversationId(id)!
|
|
),
|
|
(
|
|
this.get('recipients') || this.getConversation()!.getRecipients()
|
|
).map(
|
|
(id: string) => window.ConversationController.getConversationId(id)!
|
|
)
|
|
);
|
|
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
|
|
|
// This will make the error message for outgoing key errors a bit nicer
|
|
const allErrors = (this.get('errors') || []).map(error => {
|
|
if (error.name === OUTGOING_KEY_ERROR) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
error.message = newIdentity;
|
|
}
|
|
|
|
return error;
|
|
});
|
|
|
|
// If an error has a specific number it's associated with, we'll show it next to
|
|
// that contact. Otherwise, it will be a standalone entry.
|
|
const errors = _.reject(allErrors, error =>
|
|
Boolean(error.identifier || error.number)
|
|
);
|
|
const errorsGroupedById = _.groupBy(allErrors, error => {
|
|
const identifier = error.identifier || error.number;
|
|
if (!identifier) {
|
|
return null;
|
|
}
|
|
|
|
return window.ConversationController.getConversationId(identifier);
|
|
});
|
|
const finalContacts = (conversationIds || []).map(id => {
|
|
const errorsForContact = errorsGroupedById[id];
|
|
const isOutgoingKeyError = Boolean(
|
|
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
|
|
);
|
|
const isUnidentifiedDelivery =
|
|
window.storage.get('unidentifiedDeliveryIndicators') &&
|
|
this.isUnidentifiedDelivery(id, unidentifiedLookup);
|
|
|
|
return {
|
|
...this.findAndFormatContact(id),
|
|
|
|
status: this.getStatus(id),
|
|
errors: errorsForContact,
|
|
isOutgoingKeyError,
|
|
isUnidentifiedDelivery,
|
|
onSendAnyway: () =>
|
|
this.trigger('force-send', { contactId: id, messageId: this.id }),
|
|
onShowSafetyNumber: () => this.trigger('show-identity', id),
|
|
};
|
|
});
|
|
|
|
// The prefix created here ensures that contacts with errors are listed
|
|
// first; otherwise it's alphabetical
|
|
const sortedContacts = _.sortBy(
|
|
finalContacts,
|
|
contact => `${contact.errors ? '0' : '1'}${contact.title}`
|
|
);
|
|
|
|
return {
|
|
sentAt: this.get('sent_at'),
|
|
receivedAt: this.getReceivedAt(),
|
|
message: {
|
|
...this.getPropsForMessage(),
|
|
disableMenu: true,
|
|
disableScroll: true,
|
|
// To ensure that group avatar doesn't show up
|
|
conversationType: 'direct',
|
|
downloadNewVersion: () => {
|
|
this.trigger('download-new-version');
|
|
},
|
|
deleteMessage: (messageId: string) => {
|
|
this.trigger('delete', messageId);
|
|
},
|
|
deleteMessageForEveryone: (messageId: string) => {
|
|
this.trigger('delete-for-everyone', messageId);
|
|
},
|
|
showVisualAttachment: (options: unknown) => {
|
|
this.trigger('show-visual-attachment', options);
|
|
},
|
|
displayTapToViewMessage: (messageId: string) => {
|
|
this.trigger('display-tap-to-view-message', messageId);
|
|
},
|
|
openLink: (url: string) => {
|
|
this.trigger('navigate-to', url);
|
|
},
|
|
reactWith: (emoji: string) => {
|
|
this.trigger('react-with', emoji);
|
|
},
|
|
},
|
|
errors,
|
|
contacts: sortedContacts,
|
|
};
|
|
}
|
|
|
|
// Bucketing messages
|
|
isUnsupportedMessage(): boolean {
|
|
const versionAtReceive = this.get('supportedVersionAtReceive');
|
|
const requiredVersion = this.get('requiredProtocolVersion');
|
|
|
|
return (
|
|
_.isNumber(versionAtReceive) &&
|
|
_.isNumber(requiredVersion) &&
|
|
versionAtReceive < requiredVersion
|
|
);
|
|
}
|
|
|
|
isGroupV2Change(): boolean {
|
|
return Boolean(this.get('groupV2Change'));
|
|
}
|
|
|
|
isGroupV1Migration(): boolean {
|
|
return this.get('type') === 'group-v1-migration';
|
|
}
|
|
|
|
isExpirationTimerUpdate(): boolean {
|
|
const flag =
|
|
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
|
// eslint-disable-next-line no-bitwise, @typescript-eslint/no-non-null-assertion
|
|
return Boolean(this.get('flags')! & flag);
|
|
}
|
|
|
|
isKeyChange(): boolean {
|
|
return this.get('type') === 'keychange';
|
|
}
|
|
|
|
isVerifiedChange(): boolean {
|
|
return this.get('type') === 'verified-change';
|
|
}
|
|
|
|
isMessageHistoryUnsynced(): boolean {
|
|
return this.get('type') === 'message-history-unsynced';
|
|
}
|
|
|
|
isGroupUpdate(): boolean {
|
|
return !!this.get('group_update');
|
|
}
|
|
|
|
isEndSession(): boolean {
|
|
const flag = window.textsecure.protobuf.DataMessage.Flags.END_SESSION;
|
|
// eslint-disable-next-line no-bitwise, @typescript-eslint/no-non-null-assertion
|
|
return !!(this.get('flags')! & flag);
|
|
}
|
|
|
|
isCallHistory(): boolean {
|
|
return this.get('type') === 'call-history';
|
|
}
|
|
|
|
isChatSessionRefreshed(): boolean {
|
|
return this.get('type') === 'chat-session-refreshed';
|
|
}
|
|
|
|
isProfileChange(): boolean {
|
|
return this.get('type') === 'profile-change';
|
|
}
|
|
|
|
// Props for each message type
|
|
getPropsForUnsupportedMessage(): WhatIsThis {
|
|
const requiredVersion = this.get('requiredProtocolVersion');
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const canProcessNow = this.CURRENT_PROTOCOL_VERSION! >= requiredVersion!;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const sourceId = this.getContactId()!;
|
|
|
|
return {
|
|
canProcessNow,
|
|
contact: this.findAndFormatContact(sourceId),
|
|
};
|
|
}
|
|
|
|
getPropsForGroupV2Change(): GroupsV2Props {
|
|
const { protobuf } = window.textsecure;
|
|
|
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
const change = this.get('groupV2Change');
|
|
|
|
if (ourConversationId === undefined) {
|
|
throw new Error('ourConversationId is undefined');
|
|
}
|
|
|
|
if (change === undefined) {
|
|
throw new Error('change is undefined');
|
|
}
|
|
|
|
return {
|
|
AccessControlEnum: protobuf.AccessControl.AccessRequired,
|
|
RoleEnum: protobuf.Member.Role,
|
|
ourConversationId,
|
|
change,
|
|
};
|
|
}
|
|
|
|
getPropsForGroupV1Migration(): GroupV1MigrationPropsType {
|
|
const migration = this.get('groupMigration');
|
|
if (!migration) {
|
|
// Backwards-compatibility with data schema in early betas
|
|
const invitedGV2Members = this.get('invitedGV2Members') || [];
|
|
const droppedGV2MemberIds = this.get('droppedGV2MemberIds') || [];
|
|
|
|
const invitedMembers = invitedGV2Members.map(item =>
|
|
this.findAndFormatContact(item.conversationId)
|
|
);
|
|
const droppedMembers = droppedGV2MemberIds.map(conversationId =>
|
|
this.findAndFormatContact(conversationId)
|
|
);
|
|
|
|
return {
|
|
areWeInvited: false,
|
|
droppedMembers,
|
|
invitedMembers,
|
|
};
|
|
}
|
|
|
|
const {
|
|
areWeInvited,
|
|
droppedMemberIds,
|
|
invitedMembers: rawInvitedMembers,
|
|
} = migration;
|
|
const invitedMembers = rawInvitedMembers.map(item =>
|
|
this.findAndFormatContact(item.conversationId)
|
|
);
|
|
const droppedMembers = droppedMemberIds.map(conversationId =>
|
|
this.findAndFormatContact(conversationId)
|
|
);
|
|
|
|
return {
|
|
areWeInvited,
|
|
droppedMembers,
|
|
invitedMembers,
|
|
};
|
|
}
|
|
|
|
getPropsForTimerNotification(): TimerNotificationProps | undefined {
|
|
const timerUpdate = this.get('expirationTimerUpdate');
|
|
if (!timerUpdate) {
|
|
return undefined;
|
|
}
|
|
|
|
const { expireTimer, fromSync, source, sourceUuid } = timerUpdate;
|
|
const timespan = ExpirationTimerOptions.getName(
|
|
window.i18n,
|
|
expireTimer || 0
|
|
);
|
|
const disabled = !expireTimer;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const sourceId = window.ConversationController.ensureContactIds({
|
|
e164: source,
|
|
uuid: sourceUuid,
|
|
})!;
|
|
const ourId = window.ConversationController.getOurConversationId();
|
|
const formattedContact = this.findAndFormatContact(sourceId);
|
|
|
|
const basicProps = {
|
|
...formattedContact,
|
|
type: 'fromOther' as TimerNotificationType,
|
|
timespan,
|
|
disabled,
|
|
};
|
|
|
|
if (fromSync) {
|
|
return {
|
|
...basicProps,
|
|
type: 'fromSync' as TimerNotificationType,
|
|
};
|
|
}
|
|
if (sourceId && sourceId === ourId) {
|
|
return {
|
|
...basicProps,
|
|
type: 'fromMe' as TimerNotificationType,
|
|
};
|
|
}
|
|
if (!sourceId) {
|
|
return {
|
|
...basicProps,
|
|
type: 'fromMember' as TimerNotificationType,
|
|
};
|
|
}
|
|
|
|
return basicProps;
|
|
}
|
|
|
|
getPropsForSafetyNumberNotification(): SafetyNumberNotificationProps {
|
|
const conversation = this.getConversation();
|
|
const isGroup = Boolean(conversation && !conversation.isPrivate());
|
|
const identifier = this.get('key_changed');
|
|
const contact = this.findAndFormatContact(identifier);
|
|
|
|
if (contact.id === undefined) {
|
|
throw new Error('contact id is undefined');
|
|
}
|
|
|
|
return {
|
|
isGroup,
|
|
contact,
|
|
} as SafetyNumberNotificationProps;
|
|
}
|
|
|
|
getPropsForVerificationNotification(): VerificationNotificationProps {
|
|
const type = this.get('verified') ? 'markVerified' : 'markNotVerified';
|
|
const isLocal = this.get('local');
|
|
const identifier = this.get('verifiedChanged');
|
|
|
|
return {
|
|
type,
|
|
isLocal,
|
|
contact: this.findAndFormatContact(identifier),
|
|
};
|
|
}
|
|
|
|
getPropsForGroupNotification(): GroupNotificationProps {
|
|
const groupUpdate = this.get('group_update');
|
|
const changes = [];
|
|
|
|
if (
|
|
!groupUpdate.avatarUpdated &&
|
|
!groupUpdate.left &&
|
|
!groupUpdate.joined &&
|
|
!groupUpdate.name
|
|
) {
|
|
changes.push({
|
|
type: 'general' as ChangeType,
|
|
});
|
|
}
|
|
|
|
if (groupUpdate.joined) {
|
|
changes.push({
|
|
type: 'add' as ChangeType,
|
|
contacts: _.map(
|
|
Array.isArray(groupUpdate.joined)
|
|
? groupUpdate.joined
|
|
: [groupUpdate.joined],
|
|
identifier => this.findAndFormatContact(identifier)
|
|
),
|
|
});
|
|
}
|
|
|
|
if (groupUpdate.left === 'You') {
|
|
changes.push({
|
|
type: 'remove' as ChangeType,
|
|
});
|
|
} else if (groupUpdate.left) {
|
|
changes.push({
|
|
type: 'remove' as ChangeType,
|
|
contacts: _.map(
|
|
Array.isArray(groupUpdate.left)
|
|
? groupUpdate.left
|
|
: [groupUpdate.left],
|
|
identifier => this.findAndFormatContact(identifier)
|
|
),
|
|
});
|
|
}
|
|
|
|
if (groupUpdate.name) {
|
|
changes.push({
|
|
type: 'name' as ChangeType,
|
|
newName: groupUpdate.name,
|
|
});
|
|
}
|
|
|
|
if (groupUpdate.avatarUpdated) {
|
|
changes.push({
|
|
type: 'avatar' as ChangeType,
|
|
});
|
|
}
|
|
|
|
const sourceId = this.getContactId();
|
|
const from = this.findAndFormatContact(sourceId);
|
|
|
|
return {
|
|
from,
|
|
changes,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
getPropsForResetSessionNotification(): ResetSessionNotificationProps {
|
|
return {
|
|
i18n: window.i18n,
|
|
};
|
|
}
|
|
|
|
getPropsForCallHistory(): CallingNotificationType | undefined {
|
|
const callHistoryDetails = this.get('callHistoryDetails');
|
|
if (!callHistoryDetails) {
|
|
return undefined;
|
|
}
|
|
|
|
switch (callHistoryDetails.callMode) {
|
|
// Old messages weren't saved with a call mode.
|
|
case undefined:
|
|
case CallMode.Direct:
|
|
return {
|
|
...callHistoryDetails,
|
|
callMode: CallMode.Direct,
|
|
};
|
|
case CallMode.Group: {
|
|
const conversationId = this.get('conversationId');
|
|
if (!conversationId) {
|
|
window.log.error(
|
|
'Message.prototype.getPropsForCallHistory: missing conversation ID; assuming there is no call'
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const creatorConversation = this.findContact(
|
|
window.ConversationController.ensureContactIds({
|
|
uuid: callHistoryDetails.creatorUuid,
|
|
})
|
|
);
|
|
if (!creatorConversation) {
|
|
window.log.error(
|
|
'Message.prototype.getPropsForCallHistory: could not find creator by UUID; bailing'
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const reduxState = window.reduxStore.getState();
|
|
|
|
let call = getCallSelector(reduxState)(conversationId);
|
|
if (call && call.callMode !== CallMode.Group) {
|
|
window.log.error(
|
|
'Message.prototype.getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist'
|
|
);
|
|
call = undefined;
|
|
}
|
|
|
|
return {
|
|
activeCallConversationId: getActiveCall(reduxState.calling)
|
|
?.conversationId,
|
|
callMode: CallMode.Group,
|
|
conversationId,
|
|
creator: creatorConversation.format(),
|
|
deviceCount: call?.peekInfo.deviceCount ?? 0,
|
|
ended: callHistoryDetails.eraId !== call?.peekInfo.eraId,
|
|
maxDevices: call?.peekInfo.maxDevices ?? Infinity,
|
|
startedTime: callHistoryDetails.startedTime,
|
|
};
|
|
}
|
|
default:
|
|
window.log.error(missingCaseError(callHistoryDetails));
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
getPropsForProfileChange(): ProfileChangeNotificationPropsType {
|
|
const change = this.get('profileChange');
|
|
const changedId = this.get('changedId');
|
|
const changedContact = this.findAndFormatContact(changedId);
|
|
|
|
if (!changedContact.id) {
|
|
throw new Error('changed contact id is undefined');
|
|
}
|
|
|
|
if (!change) {
|
|
throw new Error('change is undefined');
|
|
}
|
|
|
|
return {
|
|
changedContact,
|
|
change,
|
|
} as ProfileChangeNotificationPropsType;
|
|
}
|
|
|
|
getAttachmentsForMessage(): Array<WhatIsThis> {
|
|
const sticker = this.get('sticker');
|
|
if (sticker && sticker.data) {
|
|
const { data } = sticker;
|
|
|
|
// We don't show anything if we don't have the sticker or the blurhash...
|
|
if (!data.blurHash && (data.pending || !data.path)) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
...data,
|
|
// We want to show the blurhash for stickers, not the spinner
|
|
pending: false,
|
|
url: data.path ? getAbsoluteAttachmentPath(data.path) : undefined,
|
|
},
|
|
];
|
|
}
|
|
|
|
const attachments = this.get('attachments') || [];
|
|
return attachments
|
|
.filter(attachment => !attachment.error)
|
|
.map(attachment => this.getPropsForAttachment(attachment));
|
|
}
|
|
|
|
// Note: interactionMode is mixed in via selectors/conversations._messageSelector
|
|
getPropsForMessage(): Omit<
|
|
PropsData,
|
|
'interactionMode' | 'renderAudioAttachment'
|
|
> {
|
|
const sourceId = this.getContactId();
|
|
const contact = this.findAndFormatContact(sourceId);
|
|
const contactModel = this.findContact(sourceId);
|
|
|
|
const authorColor = contactModel ? contactModel.getColor() : undefined;
|
|
const authorAvatarPath = contactModel
|
|
? contactModel.getAvatarPath()
|
|
: undefined;
|
|
|
|
const expirationLength = this.get('expireTimer') * 1000;
|
|
const expireTimerStart = this.get('expirationStartTimestamp');
|
|
const expirationTimestamp =
|
|
expirationLength && expireTimerStart
|
|
? expireTimerStart + expirationLength
|
|
: undefined;
|
|
|
|
const conversation = this.getConversation();
|
|
const isGroup = conversation && !conversation.isPrivate();
|
|
const sticker = this.get('sticker');
|
|
|
|
const isTapToView = this.isTapToView();
|
|
|
|
const reactions = (this.get('reactions') || []).map(re => {
|
|
const c = this.findAndFormatContact(re.fromId);
|
|
|
|
return {
|
|
emoji: re.emoji,
|
|
timestamp: re.timestamp,
|
|
from: c,
|
|
};
|
|
});
|
|
|
|
const selectedReaction = (
|
|
(this.get('reactions') || []).find(
|
|
re => re.fromId === window.ConversationController.getOurConversationId()
|
|
) || {}
|
|
).emoji;
|
|
|
|
return {
|
|
text: this.createNonBreakingLastSeparator(this.get('body')),
|
|
textPending: this.get('bodyPending'),
|
|
id: this.id,
|
|
conversationId: this.get('conversationId'),
|
|
isSticker: Boolean(sticker),
|
|
direction: this.isIncoming() ? 'incoming' : 'outgoing',
|
|
timestamp: this.get('sent_at'),
|
|
status: this.getMessagePropStatus(),
|
|
contact: this.getPropsForEmbeddedContact(),
|
|
canReply: this.canReply(),
|
|
canDeleteForEveryone: this.canDeleteForEveryone(),
|
|
canDownload: this.canDownload(),
|
|
authorId: contact.id,
|
|
authorTitle: contact.title,
|
|
authorColor,
|
|
authorName: contact.name,
|
|
authorProfileName: contact.profileName,
|
|
authorPhoneNumber: contact.phoneNumber,
|
|
conversationType: isGroup ? 'group' : 'direct',
|
|
attachments: this.getAttachmentsForMessage(),
|
|
previews: this.getPropsForPreview(),
|
|
quote: this.getPropsForQuote(),
|
|
authorAvatarPath,
|
|
isExpired: this.hasExpired,
|
|
expirationLength,
|
|
expirationTimestamp,
|
|
reactions,
|
|
selectedReaction,
|
|
|
|
isTapToView,
|
|
isTapToViewExpired: isTapToView && this.get('isErased'),
|
|
isTapToViewError:
|
|
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
|
|
|
|
deletedForEveryone: this.get('deletedForEveryone') || false,
|
|
bodyRanges: this.processBodyRanges(),
|
|
|
|
isMessageRequestAccepted: conversation
|
|
? conversation.getAccepted()
|
|
: true,
|
|
isBlocked: Boolean(conversation?.isBlocked()),
|
|
};
|
|
}
|
|
|
|
processBodyRanges(
|
|
bodyRanges = this.get('bodyRanges')
|
|
): BodyRangesType | undefined {
|
|
if (!bodyRanges) {
|
|
return undefined;
|
|
}
|
|
|
|
return bodyRanges
|
|
.filter(range => range.mentionUuid)
|
|
.map(range => {
|
|
const contactID = window.ConversationController.ensureContactIds({
|
|
uuid: range.mentionUuid,
|
|
});
|
|
const conversation = this.findContact(contactID);
|
|
|
|
return {
|
|
...range,
|
|
conversationID: contactID,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
replacementText: conversation!.getTitle(),
|
|
};
|
|
})
|
|
.sort((a, b) => b.start - a.start);
|
|
}
|
|
|
|
// Dependencies of prop-generation functions
|
|
findAndFormatContact(
|
|
identifier?: string
|
|
): Partial<ConversationType> &
|
|
Pick<ConversationType, 'title' | 'id' | 'type'> {
|
|
if (!identifier) {
|
|
return PLACEHOLDER_CONTACT;
|
|
}
|
|
|
|
const contactModel = this.findContact(identifier);
|
|
if (contactModel) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return contactModel.format()!;
|
|
}
|
|
|
|
const { format, isValidNumber } = PhoneNumber;
|
|
const regionCode = window.storage.get('regionCode');
|
|
|
|
if (!isValidNumber(identifier, { regionCode })) {
|
|
return PLACEHOLDER_CONTACT;
|
|
}
|
|
|
|
const phoneNumber = format(identifier, {
|
|
ourRegionCode: regionCode,
|
|
});
|
|
|
|
return {
|
|
id: 'phone-only',
|
|
type: 'direct',
|
|
title: phoneNumber,
|
|
phoneNumber,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
findContact(identifier?: string): ConversationModel | undefined {
|
|
return window.ConversationController.get(identifier);
|
|
}
|
|
|
|
getConversation(): ConversationModel | undefined {
|
|
return window.ConversationController.get(this.get('conversationId'));
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
createNonBreakingLastSeparator(text: string): string | undefined {
|
|
if (!text) {
|
|
return undefined;
|
|
}
|
|
|
|
const nbsp = '\xa0';
|
|
const regex = /(\S)( +)(\S+\s*)$/;
|
|
return text.replace(regex, (_match, start, spaces, end) => {
|
|
const newSpaces =
|
|
end.length < 12
|
|
? _.reduce(spaces, accumulator => accumulator + nbsp, '')
|
|
: spaces;
|
|
return `${start}${newSpaces}${end}`;
|
|
});
|
|
}
|
|
|
|
isIncoming(): boolean {
|
|
return this.get('type') === 'incoming';
|
|
}
|
|
|
|
getMessagePropStatus(): LastMessageStatus | undefined {
|
|
const sent = this.get('sent');
|
|
const sentTo = this.get('sent_to') || [];
|
|
|
|
if (this.hasErrors()) {
|
|
if (sent || sentTo.length > 0) {
|
|
return 'partial-sent';
|
|
}
|
|
return 'error';
|
|
}
|
|
if (!this.isOutgoing()) {
|
|
return undefined;
|
|
}
|
|
|
|
const readBy = this.get('read_by') || [];
|
|
if (window.storage.get('read-receipt-setting') && readBy.length > 0) {
|
|
return 'read';
|
|
}
|
|
const delivered = this.get('delivered');
|
|
const deliveredTo = this.get('delivered_to') || [];
|
|
if (delivered || deliveredTo.length > 0) {
|
|
return 'delivered';
|
|
}
|
|
if (sent || sentTo.length > 0) {
|
|
return 'sent';
|
|
}
|
|
|
|
return 'sending';
|
|
}
|
|
|
|
getPropsForEmbeddedContact(): WhatIsThis {
|
|
const contacts = this.get('contact');
|
|
if (!contacts || !contacts.length) {
|
|
return null;
|
|
}
|
|
|
|
const regionCode = window.storage.get('regionCode');
|
|
const { contactSelector } = Contact;
|
|
const contact = contacts[0];
|
|
const firstNumber =
|
|
contact.number && contact.number[0] && contact.number[0].value;
|
|
|
|
// Would be nice to do this before render, on initial load of message
|
|
if (!window.isSignalAccountCheckComplete(firstNumber)) {
|
|
window.checkForSignalAccount(firstNumber).then(() => {
|
|
this.trigger('change', this);
|
|
});
|
|
}
|
|
|
|
return contactSelector(contact, {
|
|
regionCode,
|
|
getAbsoluteAttachmentPath,
|
|
signalAccount: window.hasSignalAccount(firstNumber) ? firstNumber : null,
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
getPropsForAttachment(attachment: typeof Attachment): WhatIsThis {
|
|
if (!attachment) {
|
|
return null;
|
|
}
|
|
|
|
const { path, pending, flags, size, screenshot, thumbnail } = attachment;
|
|
|
|
return {
|
|
...attachment,
|
|
fileSize: size ? window.filesize(size) : null,
|
|
isVoiceMessage:
|
|
flags &&
|
|
// eslint-disable-next-line no-bitwise
|
|
flags &
|
|
window.textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
|
pending,
|
|
url: path ? getAbsoluteAttachmentPath(path) : null,
|
|
screenshot: screenshot
|
|
? {
|
|
...screenshot,
|
|
url: getAbsoluteAttachmentPath(screenshot.path),
|
|
}
|
|
: null,
|
|
thumbnail: thumbnail
|
|
? {
|
|
...thumbnail,
|
|
url: getAbsoluteAttachmentPath(thumbnail.path),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
getPropsForPreview(): WhatIsThis {
|
|
const previews = this.get('preview') || [];
|
|
|
|
return previews.map(preview => ({
|
|
...preview,
|
|
isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url),
|
|
domain: window.Signal.LinkPreviews.getDomain(preview.url),
|
|
image: preview.image ? this.getPropsForAttachment(preview.image) : null,
|
|
}));
|
|
}
|
|
|
|
getPropsForQuote(): WhatIsThis {
|
|
const quote = this.get('quote');
|
|
if (!quote) {
|
|
return null;
|
|
}
|
|
|
|
const { format } = PhoneNumber;
|
|
const regionCode = window.storage.get('regionCode');
|
|
|
|
const {
|
|
author,
|
|
authorUuid,
|
|
bodyRanges,
|
|
id: sentAt,
|
|
referencedMessageNotFound,
|
|
text,
|
|
} = quote;
|
|
|
|
const contact =
|
|
(author || authorUuid) &&
|
|
window.ConversationController.get(
|
|
window.ConversationController.ensureContactIds({
|
|
e164: author,
|
|
uuid: authorUuid,
|
|
})
|
|
);
|
|
const authorColor = contact ? contact.getColor() : 'grey';
|
|
|
|
let reallyNotFound = referencedMessageNotFound;
|
|
// Is the quote really without a reference? Check with our in memory store
|
|
// first to make sure it's not there.
|
|
if (referencedMessageNotFound) {
|
|
const messageId = this.get('sent_at');
|
|
window.log.info(
|
|
`getPropsForQuote: Verifying that ${messageId} referencing ${sentAt} is really not found`
|
|
);
|
|
const inMemoryMessage = window.MessageController.findBySentAt(
|
|
Number(sentAt)
|
|
);
|
|
reallyNotFound = !inMemoryMessage;
|
|
|
|
// We found the quote in memory so update the message in the database
|
|
// so we don't have to do this check again
|
|
if (!reallyNotFound) {
|
|
window.log.info(
|
|
`getPropsForQuote: Found ${sentAt}, scheduling an update to ${messageId}`
|
|
);
|
|
this.set({
|
|
quote: {
|
|
...quote,
|
|
referencedMessageNotFound: false,
|
|
},
|
|
});
|
|
window.Signal.Util.updateMessageBatcher.add(this.attributes);
|
|
}
|
|
}
|
|
|
|
const authorPhoneNumber = format(author, {
|
|
ourRegionCode: regionCode,
|
|
});
|
|
const authorProfileName = contact ? contact.getProfileName() : null;
|
|
const authorName = contact ? contact.get('name') : null;
|
|
const authorTitle = contact ? contact.getTitle() : null;
|
|
const isFromMe = contact ? contact.isMe() : false;
|
|
const firstAttachment = quote.attachments && quote.attachments[0];
|
|
|
|
return {
|
|
text: this.createNonBreakingLastSeparator(text),
|
|
attachment: firstAttachment
|
|
? this.processQuoteAttachment(firstAttachment)
|
|
: null,
|
|
bodyRanges: this.processBodyRanges(bodyRanges),
|
|
isFromMe,
|
|
sentAt,
|
|
authorId: contact ? contact.id : undefined,
|
|
authorPhoneNumber,
|
|
authorProfileName,
|
|
authorTitle,
|
|
authorName,
|
|
authorColor,
|
|
referencedMessageNotFound: reallyNotFound,
|
|
onClick: () => this.trigger('scroll-to-message'),
|
|
};
|
|
}
|
|
|
|
getStatus(identifier: string): string | null {
|
|
const conversation = window.ConversationController.get(identifier);
|
|
|
|
if (!conversation) {
|
|
return null;
|
|
}
|
|
|
|
const e164 = conversation.get('e164');
|
|
const uuid = conversation.get('uuid');
|
|
const conversationId = conversation.get('id');
|
|
|
|
const readBy = this.get('read_by') || [];
|
|
if (includesAny(readBy, conversationId, e164, uuid)) {
|
|
return 'read';
|
|
}
|
|
const deliveredTo = this.get('delivered_to') || [];
|
|
if (includesAny(deliveredTo, conversationId, e164, uuid)) {
|
|
return 'delivered';
|
|
}
|
|
const sentTo = this.get('sent_to') || [];
|
|
if (includesAny(sentTo, conversationId, e164, uuid)) {
|
|
return 'sent';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
processQuoteAttachment(
|
|
attachment: typeof window.Signal.Types.Attachment
|
|
): WhatIsThis {
|
|
const { thumbnail } = attachment;
|
|
const path =
|
|
thumbnail && thumbnail.path && getAbsoluteAttachmentPath(thumbnail.path);
|
|
const objectUrl = thumbnail && thumbnail.objectUrl;
|
|
|
|
const thumbnailWithObjectUrl =
|
|
!path && !objectUrl
|
|
? null
|
|
: { ...(attachment.thumbnail || {}), objectUrl: path || objectUrl };
|
|
|
|
return {
|
|
...attachment,
|
|
isVoiceMessage: window.Signal.Types.Attachment.isVoiceMessage(attachment),
|
|
thumbnail: thumbnailWithObjectUrl,
|
|
};
|
|
}
|
|
|
|
getNotificationData(): { emoji?: string; text: string } {
|
|
if (this.isChatSessionRefreshed()) {
|
|
return {
|
|
emoji: '🔁',
|
|
text: window.i18n('ChatRefresh--notification'),
|
|
};
|
|
}
|
|
|
|
if (this.isUnsupportedMessage()) {
|
|
return {
|
|
text: window.i18n('message--getDescription--unsupported-message'),
|
|
};
|
|
}
|
|
|
|
if (this.isGroupV1Migration()) {
|
|
return {
|
|
text: window.i18n('GroupV1--Migration--was-upgraded'),
|
|
};
|
|
}
|
|
|
|
if (this.isProfileChange()) {
|
|
const change = this.get('profileChange');
|
|
const changedId = this.get('changedId');
|
|
const changedContact = this.findAndFormatContact(changedId);
|
|
|
|
return {
|
|
text: window.Signal.Util.getStringForProfileChange(
|
|
change,
|
|
changedContact,
|
|
window.i18n
|
|
),
|
|
};
|
|
}
|
|
|
|
if (this.isGroupV2Change()) {
|
|
const { protobuf } = window.textsecure;
|
|
const change = this.get('groupV2Change');
|
|
|
|
const lines = window.Signal.GroupChange.renderChange(change, {
|
|
AccessControlEnum: protobuf.AccessControl.AccessRequired,
|
|
i18n: window.i18n,
|
|
ourConversationId: window.ConversationController.getOurConversationId(),
|
|
renderContact: (conversationId: string) => {
|
|
const conversation = window.ConversationController.get(
|
|
conversationId
|
|
);
|
|
return conversation
|
|
? conversation.getTitle()
|
|
: window.i18n('unknownUser');
|
|
},
|
|
renderString: (
|
|
key: string,
|
|
_i18n: unknown,
|
|
placeholders: Array<string>
|
|
) => window.i18n(key, placeholders),
|
|
RoleEnum: protobuf.Member.Role,
|
|
});
|
|
|
|
return { text: lines.join(' ') };
|
|
}
|
|
|
|
const attachments = this.get('attachments') || [];
|
|
|
|
if (this.isTapToView()) {
|
|
if (this.isErased()) {
|
|
return {
|
|
text: window.i18n('message--getDescription--disappearing-media'),
|
|
};
|
|
}
|
|
|
|
if (Attachment.isImage(attachments)) {
|
|
return {
|
|
text: window.i18n('message--getDescription--disappearing-photo'),
|
|
emoji: '📷',
|
|
};
|
|
}
|
|
if (Attachment.isVideo(attachments)) {
|
|
return {
|
|
text: window.i18n('message--getDescription--disappearing-video'),
|
|
emoji: '🎥',
|
|
};
|
|
}
|
|
// There should be an image or video attachment, but we have a fallback just in
|
|
// case.
|
|
return { text: window.i18n('mediaMessage'), emoji: '📎' };
|
|
}
|
|
|
|
if (this.isGroupUpdate()) {
|
|
const groupUpdate = this.get('group_update');
|
|
const fromContact = this.getContact();
|
|
const messages = [];
|
|
|
|
if (groupUpdate.left === 'You') {
|
|
return { text: window.i18n('youLeftTheGroup') };
|
|
}
|
|
if (groupUpdate.left) {
|
|
return {
|
|
text: window.i18n('leftTheGroup', [
|
|
this.getNameForNumber(groupUpdate.left),
|
|
]),
|
|
};
|
|
}
|
|
|
|
if (!fromContact) {
|
|
return { text: '' };
|
|
}
|
|
|
|
if (fromContact.isMe()) {
|
|
messages.push(window.i18n('youUpdatedTheGroup'));
|
|
} else {
|
|
messages.push(window.i18n('updatedTheGroup', [fromContact.getTitle()]));
|
|
}
|
|
|
|
if (groupUpdate.joined && groupUpdate.joined.length) {
|
|
const joinedContacts = _.map(groupUpdate.joined, item =>
|
|
window.ConversationController.getOrCreate(item, 'private')
|
|
);
|
|
const joinedWithoutMe = joinedContacts.filter(
|
|
contact => !contact.isMe()
|
|
);
|
|
|
|
if (joinedContacts.length > 1) {
|
|
messages.push(
|
|
window.i18n('multipleJoinedTheGroup', [
|
|
_.map(joinedWithoutMe, contact => contact.getTitle()).join(', '),
|
|
])
|
|
);
|
|
|
|
if (joinedWithoutMe.length < joinedContacts.length) {
|
|
messages.push(window.i18n('youJoinedTheGroup'));
|
|
}
|
|
} else {
|
|
const joinedContact = window.ConversationController.getOrCreate(
|
|
groupUpdate.joined[0],
|
|
'private'
|
|
);
|
|
if (joinedContact.isMe()) {
|
|
messages.push(window.i18n('youJoinedTheGroup'));
|
|
} else {
|
|
messages.push(
|
|
window.i18n('joinedTheGroup', [joinedContacts[0].getTitle()])
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (groupUpdate.name) {
|
|
messages.push(window.i18n('titleIsNow', [groupUpdate.name]));
|
|
}
|
|
if (groupUpdate.avatarUpdated) {
|
|
messages.push(window.i18n('updatedGroupAvatar'));
|
|
}
|
|
|
|
return { text: messages.join(' ') };
|
|
}
|
|
if (this.isEndSession()) {
|
|
return { text: window.i18n('sessionEnded') };
|
|
}
|
|
if (this.isIncoming() && this.hasErrors()) {
|
|
return { text: window.i18n('incomingError') };
|
|
}
|
|
|
|
const body = (this.get('body') || '').trim();
|
|
|
|
if (attachments.length) {
|
|
// This should never happen but we want to be extra-careful.
|
|
const attachment = attachments[0] || {};
|
|
const { contentType } = attachment;
|
|
|
|
if (contentType === MIME.IMAGE_GIF) {
|
|
return {
|
|
text: body || window.i18n('message--getNotificationText--gif'),
|
|
emoji: '🎡',
|
|
};
|
|
}
|
|
if (Attachment.isImage(attachments)) {
|
|
return {
|
|
text: body || window.i18n('message--getNotificationText--photo'),
|
|
emoji: '📷',
|
|
};
|
|
}
|
|
if (Attachment.isVideo(attachments)) {
|
|
return {
|
|
text: body || window.i18n('message--getNotificationText--video'),
|
|
emoji: '🎥',
|
|
};
|
|
}
|
|
if (Attachment.isVoiceMessage(attachment)) {
|
|
return {
|
|
text:
|
|
body || window.i18n('message--getNotificationText--voice-message'),
|
|
emoji: '🎤',
|
|
};
|
|
}
|
|
if (Attachment.isAudio(attachments)) {
|
|
return {
|
|
text:
|
|
body || window.i18n('message--getNotificationText--audio-message'),
|
|
emoji: '🔈',
|
|
};
|
|
}
|
|
return {
|
|
text: body || window.i18n('message--getNotificationText--file'),
|
|
emoji: '📎',
|
|
};
|
|
}
|
|
|
|
const stickerData = this.get('sticker');
|
|
if (stickerData) {
|
|
const sticker = window.Signal.Stickers.getSticker(
|
|
stickerData.packId,
|
|
stickerData.stickerId
|
|
);
|
|
const { emoji } = sticker || {};
|
|
if (!emoji) {
|
|
window.log.warn('Unable to get emoji for sticker');
|
|
}
|
|
return {
|
|
text: window.i18n('message--getNotificationText--stickers'),
|
|
emoji,
|
|
};
|
|
}
|
|
|
|
if (this.isCallHistory()) {
|
|
const callingNotification = this.getPropsForCallHistory();
|
|
if (callingNotification) {
|
|
return {
|
|
text: getCallingNotificationText(callingNotification, window.i18n),
|
|
};
|
|
}
|
|
|
|
window.log.error(
|
|
"This call history message doesn't have valid call history"
|
|
);
|
|
}
|
|
if (this.isExpirationTimerUpdate()) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const { expireTimer } = this.get('expirationTimerUpdate')!;
|
|
if (!expireTimer) {
|
|
return { text: window.i18n('disappearingMessagesDisabled') };
|
|
}
|
|
|
|
return {
|
|
text: window.i18n('timerSetTo', [
|
|
ExpirationTimerOptions.getAbbreviated(window.i18n, expireTimer || 0),
|
|
]),
|
|
};
|
|
}
|
|
|
|
if (this.isKeyChange()) {
|
|
const identifier = this.get('key_changed');
|
|
const conversation = this.findContact(identifier);
|
|
return {
|
|
text: window.i18n('safetyNumberChangedGroup', [
|
|
conversation ? conversation.getTitle() : null,
|
|
]),
|
|
};
|
|
}
|
|
const contacts = this.get('contact');
|
|
if (contacts && contacts.length) {
|
|
return {
|
|
text: Contact.getName(contacts[0]) || window.i18n('unknownContact'),
|
|
emoji: '👤',
|
|
};
|
|
}
|
|
|
|
if (body) {
|
|
return { text: body };
|
|
}
|
|
|
|
return { text: '' };
|
|
}
|
|
|
|
getNotificationText(): string {
|
|
const { text, emoji } = this.getNotificationData();
|
|
|
|
let modifiedText = text;
|
|
|
|
const hasMentions = Boolean(this.get('bodyRanges'));
|
|
|
|
if (hasMentions) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const bodyRanges = this.processBodyRanges()!;
|
|
modifiedText = getTextWithMentions(bodyRanges, modifiedText);
|
|
}
|
|
|
|
// Linux emoji support is mixed, so we disable it. (Note that this doesn't touch
|
|
// the `text`, which can contain emoji.)
|
|
const shouldIncludeEmoji = Boolean(emoji) && !window.Signal.OS.isLinux();
|
|
if (shouldIncludeEmoji) {
|
|
return window.i18n('message--getNotificationText--text-with-emoji', {
|
|
text: modifiedText,
|
|
emoji,
|
|
});
|
|
}
|
|
return modifiedText;
|
|
}
|
|
|
|
// General
|
|
idForLogging(): string {
|
|
const account = this.getSourceUuid() || this.getSource();
|
|
const device = this.getSourceDevice();
|
|
const timestamp = this.get('sent_at');
|
|
|
|
return `${account}.${device} ${timestamp}`;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
defaults(): Partial<MessageAttributesType> {
|
|
return {
|
|
timestamp: new Date().getTime(),
|
|
attachments: [],
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
validate(attributes: Record<string, unknown>): void {
|
|
const required = ['conversationId', 'received_at', 'sent_at'];
|
|
const missing = _.filter(required, attr => !attributes[attr]);
|
|
if (missing.length) {
|
|
window.log.warn(`Message missing attributes: ${missing}`);
|
|
}
|
|
}
|
|
|
|
isUnread(): boolean {
|
|
return !!this.get('unread');
|
|
}
|
|
|
|
merge(model: MessageModel): void {
|
|
const attributes = model.attributes || model;
|
|
this.set(attributes);
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
getNameForNumber(number: string): string {
|
|
const conversation = window.ConversationController.get(number);
|
|
if (!conversation) {
|
|
return number;
|
|
}
|
|
return conversation.getTitle();
|
|
}
|
|
|
|
onDestroy(): void {
|
|
this.cleanup();
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
const { messageDeleted } = window.reduxActions.conversations;
|
|
messageDeleted(this.id, this.get('conversationId'));
|
|
window.MessageController.unregister(this.id);
|
|
this.unload();
|
|
await this.deleteData();
|
|
}
|
|
|
|
async deleteData(): Promise<void> {
|
|
await deleteExternalMessageFiles(this.attributes);
|
|
|
|
const sticker = this.get('sticker');
|
|
if (!sticker) {
|
|
return;
|
|
}
|
|
|
|
const { packId } = sticker;
|
|
if (packId) {
|
|
await deletePackReference(this.id, packId);
|
|
}
|
|
}
|
|
|
|
isTapToView(): boolean {
|
|
// If a message is deleted for everyone, that overrides all other styling
|
|
if (this.get('deletedForEveryone')) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(this.get('isViewOnce') || this.get('messageTimer'));
|
|
}
|
|
|
|
isValidTapToView(): boolean {
|
|
const body = this.get('body');
|
|
if (body) {
|
|
return false;
|
|
}
|
|
|
|
const attachments = this.get('attachments');
|
|
if (!attachments || attachments.length !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const firstAttachment = attachments[0];
|
|
if (
|
|
!window.Signal.Util.GoogleChrome.isImageTypeSupported(
|
|
firstAttachment.contentType
|
|
) &&
|
|
!window.Signal.Util.GoogleChrome.isVideoTypeSupported(
|
|
firstAttachment.contentType
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const quote = this.get('quote');
|
|
const sticker = this.get('sticker');
|
|
const contact = this.get('contact');
|
|
const preview = this.get('preview');
|
|
|
|
if (
|
|
quote ||
|
|
sticker ||
|
|
(contact && contact.length > 0) ||
|
|
(preview && preview.length > 0)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async markViewed(options?: { fromSync?: boolean }): Promise<void> {
|
|
const { fromSync } = options || {};
|
|
|
|
if (!this.isValidTapToView()) {
|
|
window.log.warn(
|
|
`markViewed: Message ${this.idForLogging()} is not a valid tap to view message!`
|
|
);
|
|
return;
|
|
}
|
|
if (this.isErased()) {
|
|
window.log.warn(
|
|
`markViewed: Message ${this.idForLogging()} is already erased!`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (this.get('unread')) {
|
|
await this.markRead();
|
|
}
|
|
|
|
await this.eraseContents();
|
|
|
|
if (!fromSync) {
|
|
const sender = this.getSource();
|
|
|
|
if (sender === undefined) {
|
|
throw new Error('sender is undefined');
|
|
}
|
|
|
|
const senderUuid = this.getSourceUuid();
|
|
|
|
if (senderUuid === undefined) {
|
|
throw new Error('senderUuid is undefined');
|
|
}
|
|
|
|
const timestamp = this.get('sent_at');
|
|
const ourNumber = window.textsecure.storage.user.getNumber();
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const ourUuid = window.textsecure.storage.user.getUuid()!;
|
|
const {
|
|
wrap,
|
|
sendOptions,
|
|
} = window.ConversationController.prepareForSend(ourNumber || ourUuid, {
|
|
syncMessage: true,
|
|
});
|
|
|
|
await wrap(
|
|
window.textsecure.messaging.syncViewOnceOpen(
|
|
sender,
|
|
senderUuid,
|
|
timestamp,
|
|
sendOptions
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
isErased(): boolean {
|
|
return Boolean(this.get('isErased'));
|
|
}
|
|
|
|
async eraseContents(
|
|
additionalProperties = {},
|
|
shouldPersist = true
|
|
): Promise<void> {
|
|
window.log.info(`Erasing data for message ${this.idForLogging()}`);
|
|
|
|
// Note: There are cases where we want to re-erase a given message. For example, when
|
|
// a viewed (or outgoing) View-Once message is deleted for everyone.
|
|
|
|
try {
|
|
await this.deleteData();
|
|
} catch (error) {
|
|
window.log.error(
|
|
`Error erasing data for message ${this.idForLogging()}:`,
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}
|
|
|
|
this.set({
|
|
isErased: true,
|
|
body: '',
|
|
bodyRanges: undefined,
|
|
attachments: [],
|
|
quote: null,
|
|
contact: [],
|
|
sticker: null,
|
|
preview: [],
|
|
...additionalProperties,
|
|
});
|
|
this.trigger('content-changed');
|
|
|
|
if (shouldPersist) {
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
}
|
|
|
|
isEmpty(): boolean {
|
|
// Core message types - we check for all four because they can each stand alone
|
|
const hasBody = Boolean(this.get('body'));
|
|
const hasAttachment = (this.get('attachments') || []).length > 0;
|
|
const hasEmbeddedContact = (this.get('contact') || []).length > 0;
|
|
const isSticker = Boolean(this.get('sticker'));
|
|
|
|
// Rendered sync messages
|
|
const isCallHistory = this.isCallHistory();
|
|
const isChatSessionRefreshed = this.isChatSessionRefreshed();
|
|
const isGroupUpdate = this.isGroupUpdate();
|
|
const isGroupV2Change = this.isGroupV2Change();
|
|
const isEndSession = this.isEndSession();
|
|
const isExpirationTimerUpdate = this.isExpirationTimerUpdate();
|
|
const isVerifiedChange = this.isVerifiedChange();
|
|
|
|
// Placeholder messages
|
|
const isUnsupportedMessage = this.isUnsupportedMessage();
|
|
const isTapToView = this.isTapToView();
|
|
|
|
// Errors
|
|
const hasErrors = this.hasErrors();
|
|
|
|
// Locally-generated notifications
|
|
const isKeyChange = this.isKeyChange();
|
|
const isMessageHistoryUnsynced = this.isMessageHistoryUnsynced();
|
|
const isProfileChange = this.isProfileChange();
|
|
|
|
// Note: not all of these message types go through message.handleDataMessage
|
|
|
|
const hasSomethingToDisplay =
|
|
// Core message types
|
|
hasBody ||
|
|
hasAttachment ||
|
|
hasEmbeddedContact ||
|
|
isSticker ||
|
|
// Rendered sync messages
|
|
isCallHistory ||
|
|
isChatSessionRefreshed ||
|
|
isGroupUpdate ||
|
|
isGroupV2Change ||
|
|
isEndSession ||
|
|
isExpirationTimerUpdate ||
|
|
isVerifiedChange ||
|
|
// Placeholder messages
|
|
isUnsupportedMessage ||
|
|
isTapToView ||
|
|
// Errors
|
|
hasErrors ||
|
|
// Locally-generated notifications
|
|
isKeyChange ||
|
|
isMessageHistoryUnsynced ||
|
|
isProfileChange;
|
|
|
|
return !hasSomethingToDisplay;
|
|
}
|
|
|
|
unload(): void {
|
|
if (this.quotedMessage) {
|
|
this.quotedMessage = null;
|
|
}
|
|
}
|
|
|
|
onExpired(): void {
|
|
this.hasExpired = true;
|
|
}
|
|
|
|
isUnidentifiedDelivery(
|
|
contactId: string,
|
|
lookup: Record<string, unknown>
|
|
): boolean {
|
|
if (this.isIncoming()) {
|
|
return this.get('unidentifiedDeliveryReceived');
|
|
}
|
|
|
|
return Boolean(lookup[contactId]);
|
|
}
|
|
|
|
getSource(): string | undefined {
|
|
if (this.isIncoming()) {
|
|
return this.get('source');
|
|
}
|
|
|
|
return this.OUR_NUMBER;
|
|
}
|
|
|
|
getSourceDevice(): string | number | undefined {
|
|
const sourceDevice = this.get('sourceDevice');
|
|
|
|
if (this.isIncoming()) {
|
|
return sourceDevice;
|
|
}
|
|
|
|
return sourceDevice || window.textsecure.storage.user.getDeviceId();
|
|
}
|
|
|
|
getSourceUuid(): string | undefined {
|
|
if (this.isIncoming()) {
|
|
return this.get('sourceUuid');
|
|
}
|
|
|
|
return this.OUR_UUID;
|
|
}
|
|
|
|
getContactId(): string | undefined {
|
|
const source = this.getSource();
|
|
const sourceUuid = this.getSourceUuid();
|
|
|
|
if (!source && !sourceUuid) {
|
|
return window.ConversationController.getOurConversationId();
|
|
}
|
|
|
|
return window.ConversationController.ensureContactIds({
|
|
e164: source,
|
|
uuid: sourceUuid,
|
|
});
|
|
}
|
|
|
|
getContact(): ConversationModel | undefined {
|
|
const id = this.getContactId();
|
|
return window.ConversationController.get(id);
|
|
}
|
|
|
|
isOutgoing(): boolean {
|
|
return this.get('type') === 'outgoing';
|
|
}
|
|
|
|
hasErrors(): boolean {
|
|
return _.size(this.get('errors')) > 0;
|
|
}
|
|
|
|
async saveErrors(
|
|
providedErrors: Error | Array<Error>,
|
|
options: { skipSave?: boolean } = {}
|
|
): Promise<void> {
|
|
const { skipSave } = options;
|
|
|
|
let errors: Array<CustomError>;
|
|
|
|
if (!(providedErrors instanceof Array)) {
|
|
errors = [providedErrors];
|
|
} else {
|
|
errors = providedErrors;
|
|
}
|
|
|
|
errors.forEach(e => {
|
|
window.log.error(
|
|
'Message.saveErrors:',
|
|
e && e.reason ? e.reason : null,
|
|
e && e.stack ? e.stack : e
|
|
);
|
|
});
|
|
errors = errors.map(e => {
|
|
// Note: in our environment, instanceof can be scary, so we have a backup check
|
|
// (Node.js vs Browser context).
|
|
// We check instanceof second because typescript believes that anything that comes
|
|
// through here must be an instance of Error, so e is 'never' after that check.
|
|
if ((e.message && e.stack) || e instanceof Error) {
|
|
return _.pick(
|
|
e,
|
|
'name',
|
|
'message',
|
|
'code',
|
|
'number',
|
|
'identifier',
|
|
'reason'
|
|
) as Required<Error>;
|
|
}
|
|
return e;
|
|
});
|
|
errors = errors.concat(this.get('errors') || []);
|
|
|
|
this.set({ errors });
|
|
|
|
if (!skipSave && !this.doNotSave) {
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
}
|
|
|
|
async markRead(
|
|
readAt?: number,
|
|
options: { skipSave?: boolean } = {}
|
|
): Promise<void> {
|
|
const { skipSave } = options;
|
|
|
|
this.unset('unread');
|
|
|
|
if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) {
|
|
const expirationStartTimestamp = Math.min(
|
|
Date.now(),
|
|
readAt || Date.now()
|
|
);
|
|
this.set({ expirationStartTimestamp });
|
|
}
|
|
|
|
window.Whisper.Notifications.removeBy({ messageId: this.id });
|
|
|
|
if (!skipSave) {
|
|
window.Signal.Util.updateMessageBatcher.add(this.attributes);
|
|
}
|
|
}
|
|
|
|
isExpiring(): number | null {
|
|
return this.get('expireTimer') && this.get('expirationStartTimestamp');
|
|
}
|
|
|
|
isExpired(): boolean {
|
|
return this.msTilExpire() <= 0;
|
|
}
|
|
|
|
msTilExpire(): number {
|
|
if (!this.isExpiring()) {
|
|
return Infinity;
|
|
}
|
|
const now = Date.now();
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const start = this.get('expirationStartTimestamp')!;
|
|
const delta = this.get('expireTimer') * 1000;
|
|
let msFromNow = start + delta - now;
|
|
if (msFromNow < 0) {
|
|
msFromNow = 0;
|
|
}
|
|
return msFromNow;
|
|
}
|
|
|
|
async setToExpire(
|
|
force = false,
|
|
options: { skipSave?: boolean } = {}
|
|
): Promise<void> {
|
|
const { skipSave } = options || {};
|
|
|
|
if (this.isExpiring() && (force || !this.get('expires_at'))) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const start = this.get('expirationStartTimestamp')!;
|
|
const delta = this.get('expireTimer') * 1000;
|
|
const expiresAt = start + delta;
|
|
|
|
this.set({ expires_at: expiresAt });
|
|
|
|
window.log.info('Set message expiration', {
|
|
start,
|
|
expiresAt,
|
|
sentAt: this.get('sent_at'),
|
|
});
|
|
|
|
const id = this.get('id');
|
|
if (id && !skipSave) {
|
|
window.Signal.Util.updateMessageBatcher.add(this.attributes);
|
|
}
|
|
}
|
|
}
|
|
|
|
getIncomingContact(): ConversationModel | undefined | null {
|
|
if (!this.isIncoming()) {
|
|
return null;
|
|
}
|
|
const source = this.get('source');
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
|
|
return window.ConversationController.getOrCreate(source, 'private');
|
|
}
|
|
|
|
getQuoteContact(): ConversationModel | undefined | null {
|
|
const quote = this.get('quote');
|
|
if (!quote) {
|
|
return null;
|
|
}
|
|
const { author } = quote;
|
|
if (!author) {
|
|
return null;
|
|
}
|
|
|
|
return window.ConversationController.get(author);
|
|
}
|
|
|
|
// Send infrastructure
|
|
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
|
|
async retrySend(): Promise<string | null | void | Array<void>> {
|
|
if (!window.textsecure.messaging) {
|
|
window.log.error('retrySend: Cannot retry since we are offline!');
|
|
return null;
|
|
}
|
|
|
|
this.set({ errors: null });
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const conversation = this.getConversation()!;
|
|
const exists = (v: string | null): v is string => Boolean(v);
|
|
const intendedRecipients = (this.get('recipients') || [])
|
|
.map(identifier =>
|
|
window.ConversationController.getConversationId(identifier)
|
|
)
|
|
.filter(exists);
|
|
const successfulRecipients = (this.get('sent_to') || [])
|
|
.map(identifier =>
|
|
window.ConversationController.getConversationId(identifier)
|
|
)
|
|
.filter(exists);
|
|
const currentRecipients = conversation
|
|
.getRecipients()
|
|
.map(identifier =>
|
|
window.ConversationController.getConversationId(identifier)
|
|
)
|
|
.filter(exists);
|
|
|
|
const profileKey = conversation.get('profileSharing')
|
|
? window.storage.get('profileKey')
|
|
: null;
|
|
|
|
// Determine retry recipients and get their most up-to-date addressing information
|
|
let recipients = _.intersection(intendedRecipients, currentRecipients);
|
|
recipients = _.without(recipients, ...successfulRecipients)
|
|
.map(id => {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const c = window.ConversationController.get(id)!;
|
|
return c.getSendTarget();
|
|
})
|
|
.filter((recipient): recipient is string => recipient !== undefined);
|
|
|
|
if (!recipients.length) {
|
|
window.log.warn('retrySend: Nobody to send to!');
|
|
|
|
return window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
|
|
const attachmentsWithData = await Promise.all(
|
|
(this.get('attachments') || []).map(loadAttachmentData)
|
|
);
|
|
const {
|
|
body,
|
|
attachments,
|
|
} = window.Whisper.Message.getLongMessageAttachment({
|
|
body: this.get('body'),
|
|
attachments: attachmentsWithData,
|
|
now: this.get('sent_at'),
|
|
});
|
|
|
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
|
const previewWithData = await loadPreviewData(this.get('preview'));
|
|
const stickerWithData = await loadStickerData(this.get('sticker'));
|
|
|
|
// Special-case the self-send case - we send only a sync message
|
|
if (
|
|
recipients.length === 1 &&
|
|
(recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID)
|
|
) {
|
|
const [identifier] = recipients;
|
|
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
identifier,
|
|
body,
|
|
attachments,
|
|
quoteWithData,
|
|
previewWithData,
|
|
stickerWithData,
|
|
null,
|
|
this.get('deletedForEveryoneTimestamp'),
|
|
this.get('sent_at'),
|
|
this.get('expireTimer'),
|
|
profileKey,
|
|
undefined, // flags
|
|
this.get('bodyRanges')
|
|
);
|
|
return this.sendSyncMessageOnly(dataMessage);
|
|
}
|
|
|
|
let promise;
|
|
const options = conversation.getSendOptions();
|
|
|
|
if (conversation.isPrivate()) {
|
|
const [identifier] = recipients;
|
|
promise = window.textsecure.messaging.sendMessageToIdentifier(
|
|
identifier,
|
|
body,
|
|
attachments,
|
|
quoteWithData,
|
|
previewWithData,
|
|
stickerWithData,
|
|
null,
|
|
this.get('deletedForEveryoneTimestamp'),
|
|
this.get('sent_at'),
|
|
this.get('expireTimer'),
|
|
profileKey,
|
|
options
|
|
);
|
|
} else {
|
|
// Because this is a partial group send, we manually construct the request like
|
|
// sendMessageToGroup does.
|
|
|
|
const groupV2 = conversation.getGroupV2Info();
|
|
|
|
promise = window.textsecure.messaging.sendMessage(
|
|
{
|
|
recipients,
|
|
body,
|
|
timestamp: this.get('sent_at'),
|
|
attachments,
|
|
quote: quoteWithData,
|
|
preview: previewWithData,
|
|
sticker: stickerWithData,
|
|
expireTimer: this.get('expireTimer'),
|
|
mentions: this.get('bodyRanges'),
|
|
profileKey,
|
|
groupV2,
|
|
group: groupV2
|
|
? undefined
|
|
: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
id: this.getConversation()!.get('groupId')!,
|
|
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
|
|
},
|
|
},
|
|
options
|
|
);
|
|
}
|
|
|
|
return this.send(conversation.wrapSend(promise));
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
isReplayableError(e: Error): boolean {
|
|
return (
|
|
e.name === 'MessageError' ||
|
|
e.name === 'OutgoingMessageError' ||
|
|
e.name === 'SendMessageNetworkError' ||
|
|
e.name === 'SignedPreKeyRotationError' ||
|
|
e.name === 'OutgoingIdentityKeyError'
|
|
);
|
|
}
|
|
|
|
canDeleteForEveryone(): boolean {
|
|
// is someone else's message
|
|
if (this.isIncoming()) {
|
|
return false;
|
|
}
|
|
|
|
// has already been deleted for everyone
|
|
if (this.get('deletedForEveryone')) {
|
|
return false;
|
|
}
|
|
|
|
// is too old to delete
|
|
if (Date.now() - this.get('sent_at') > THREE_HOURS) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
canDownload(): boolean {
|
|
if (this.isOutgoing()) {
|
|
return true;
|
|
}
|
|
|
|
const conversation = this.getConversation();
|
|
const isAccepted = Boolean(conversation && conversation.getAccepted());
|
|
if (!isAccepted) {
|
|
return false;
|
|
}
|
|
|
|
// Ensure that all attachments are downloadable
|
|
const attachments = this.get('attachments');
|
|
if (attachments && attachments.length) {
|
|
return attachments.every(attachment => Boolean(attachment.path));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
canReply(): boolean {
|
|
const conversation = this.getConversation();
|
|
const errors = this.get('errors');
|
|
const isOutgoing = this.get('type') === 'outgoing';
|
|
const numDelivered = this.get('delivered');
|
|
|
|
if (!conversation) {
|
|
return false;
|
|
}
|
|
|
|
// If GroupV1 groups have been disabled, we can't reply.
|
|
if (conversation.isGroupV1AndDisabled()) {
|
|
return false;
|
|
}
|
|
|
|
// If mandatory profile sharing is enabled, and we haven't shared yet, then
|
|
// we can't reply.
|
|
if (conversation.isMissingRequiredProfileSharing()) {
|
|
return false;
|
|
}
|
|
|
|
// We cannot reply if we haven't accepted the message request
|
|
if (!conversation.getAccepted()) {
|
|
return false;
|
|
}
|
|
|
|
// We cannot reply if this message is deleted for everyone
|
|
if (this.get('deletedForEveryone')) {
|
|
return false;
|
|
}
|
|
|
|
// We can reply if this is outgoing and delievered to at least one recipient
|
|
if (isOutgoing && numDelivered > 0) {
|
|
return true;
|
|
}
|
|
|
|
// We can reply if there are no errors
|
|
if (!errors || (errors && errors.length === 0)) {
|
|
return true;
|
|
}
|
|
|
|
// Fail safe.
|
|
return false;
|
|
}
|
|
|
|
// Called when the user ran into an error with a specific user, wants to send to them
|
|
// One caller today: ConversationView.forceSend()
|
|
async resend(identifier: string): Promise<void | null | Array<void>> {
|
|
const error = this.removeOutgoingErrors(identifier);
|
|
if (!error) {
|
|
window.log.warn('resend: requested number was not present in errors');
|
|
return null;
|
|
}
|
|
|
|
const profileKey = undefined;
|
|
const attachmentsWithData = await Promise.all(
|
|
(this.get('attachments') || []).map(loadAttachmentData)
|
|
);
|
|
const {
|
|
body,
|
|
attachments,
|
|
} = window.Whisper.Message.getLongMessageAttachment({
|
|
body: this.get('body'),
|
|
attachments: attachmentsWithData,
|
|
now: this.get('sent_at'),
|
|
});
|
|
|
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
|
const previewWithData = await loadPreviewData(this.get('preview'));
|
|
const stickerWithData = await loadStickerData(this.get('sticker'));
|
|
|
|
// Special-case the self-send case - we send only a sync message
|
|
if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) {
|
|
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
identifier,
|
|
body,
|
|
attachments,
|
|
quoteWithData,
|
|
previewWithData,
|
|
stickerWithData,
|
|
null,
|
|
this.get('deletedForEveryoneTimestamp'),
|
|
this.get('sent_at'),
|
|
this.get('expireTimer'),
|
|
profileKey,
|
|
undefined, // flags
|
|
this.get('bodyRanges')
|
|
);
|
|
return this.sendSyncMessageOnly(dataMessage);
|
|
}
|
|
|
|
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
|
|
identifier
|
|
);
|
|
const promise = window.textsecure.messaging.sendMessageToIdentifier(
|
|
identifier,
|
|
body,
|
|
attachments,
|
|
quoteWithData,
|
|
previewWithData,
|
|
stickerWithData,
|
|
null,
|
|
this.get('deletedForEveryoneTimestamp'),
|
|
this.get('sent_at'),
|
|
this.get('expireTimer'),
|
|
profileKey,
|
|
sendOptions
|
|
);
|
|
|
|
return this.send(wrap(promise));
|
|
}
|
|
|
|
removeOutgoingErrors(incomingIdentifier: string): CustomError {
|
|
const incomingConversationId = window.ConversationController.getConversationId(
|
|
incomingIdentifier
|
|
);
|
|
const errors = _.partition(
|
|
this.get('errors'),
|
|
e =>
|
|
window.ConversationController.getConversationId(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
e.identifier || e.number!
|
|
) === incomingConversationId &&
|
|
(e.name === 'MessageError' ||
|
|
e.name === 'OutgoingMessageError' ||
|
|
e.name === 'SendMessageNetworkError' ||
|
|
e.name === 'SignedPreKeyRotationError' ||
|
|
e.name === 'OutgoingIdentityKeyError')
|
|
);
|
|
this.set({ errors: errors[1] });
|
|
return errors[0][0];
|
|
}
|
|
|
|
async send(
|
|
promise: Promise<CallbackResultType | void | null>
|
|
): Promise<void | Array<void>> {
|
|
this.trigger('pending');
|
|
return (promise as Promise<CallbackResultType>)
|
|
.then(async result => {
|
|
this.trigger('done');
|
|
|
|
// This is used by sendSyncMessage, then set to null
|
|
if (result.dataMessage) {
|
|
this.set({ dataMessage: result.dataMessage });
|
|
}
|
|
|
|
const sentTo = this.get('sent_to') || [];
|
|
this.set({
|
|
sent_to: _.union(sentTo, result.successfulIdentifiers),
|
|
sent: true,
|
|
expirationStartTimestamp: Date.now(),
|
|
unidentifiedDeliveries: result.unidentifiedDeliveries,
|
|
});
|
|
|
|
if (!this.doNotSave) {
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
|
|
this.trigger('sent', this);
|
|
this.sendSyncMessage();
|
|
})
|
|
.catch((result: CustomError | CallbackResultType) => {
|
|
this.trigger('done');
|
|
|
|
if ('dataMessage' in result && result.dataMessage) {
|
|
this.set({ dataMessage: result.dataMessage });
|
|
}
|
|
|
|
let promises = [];
|
|
|
|
// If we successfully sent to a user, we can remove our unregistered flag.
|
|
let successfulIdentifiers: Array<string>;
|
|
if ('successfulIdentifiers' in result) {
|
|
({ successfulIdentifiers = [] } = result);
|
|
} else {
|
|
successfulIdentifiers = [];
|
|
}
|
|
successfulIdentifiers.forEach((identifier: string) => {
|
|
const c = window.ConversationController.get(identifier);
|
|
if (c && c.isEverUnregistered()) {
|
|
c.setRegistered();
|
|
}
|
|
});
|
|
|
|
const isError = (e: unknown): e is CustomError => e instanceof Error;
|
|
|
|
if (isError(result)) {
|
|
this.saveErrors(result);
|
|
if (result.name === 'SignedPreKeyRotationError') {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
promises.push(window.getAccountManager()!.rotateSignedPreKey());
|
|
} else if (result.name === 'OutgoingIdentityKeyError') {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const c = window.ConversationController.get(result.number)!;
|
|
promises.push(c.getProfiles());
|
|
}
|
|
} else {
|
|
if (successfulIdentifiers.length > 0) {
|
|
const sentTo = this.get('sent_to') || [];
|
|
|
|
// If we just found out that we couldn't send to a user because they are no
|
|
// longer registered, we will update our unregistered flag. In groups we
|
|
// will not event try to send to them for 6 hours. And we will never try
|
|
// to fetch them on startup again.
|
|
// The way to discover registration once more is:
|
|
// 1) any attempt to send to them in 1:1 conversation
|
|
// 2) the six-hour time period has passed and we send in a group again
|
|
const unregisteredUserErrors = _.filter(
|
|
result.errors,
|
|
error => error.name === 'UnregisteredUserError'
|
|
);
|
|
unregisteredUserErrors.forEach(error => {
|
|
const c = window.ConversationController.get(error.identifier);
|
|
if (c) {
|
|
c.setUnregistered();
|
|
}
|
|
});
|
|
|
|
// In groups, we don't treat unregistered users as a user-visible
|
|
// error. The message will look successful, but the details
|
|
// screen will show that we didn't send to these unregistered users.
|
|
const filteredErrors = _.reject(
|
|
result.errors,
|
|
error => error.name === 'UnregisteredUserError'
|
|
);
|
|
|
|
// We don't start the expiration timer if there are real errors
|
|
// left after filtering out all of the unregistered user errors.
|
|
const expirationStartTimestamp = filteredErrors.length
|
|
? null
|
|
: Date.now();
|
|
|
|
this.saveErrors(filteredErrors);
|
|
|
|
this.set({
|
|
sent_to: _.union(sentTo, result.successfulIdentifiers),
|
|
sent: true,
|
|
expirationStartTimestamp,
|
|
unidentifiedDeliveries: result.unidentifiedDeliveries,
|
|
});
|
|
promises.push(this.sendSyncMessage());
|
|
} else if (result.errors) {
|
|
this.saveErrors(result.errors);
|
|
}
|
|
promises = promises.concat(
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
_.map(result.errors, error => {
|
|
if (error.name === 'OutgoingIdentityKeyError') {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const c = window.ConversationController.get(
|
|
error.identifier || error.number
|
|
)!;
|
|
promises.push(c.getProfiles());
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
this.trigger('send-error', this.get('errors'));
|
|
|
|
return Promise.all(promises);
|
|
});
|
|
}
|
|
|
|
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const conv = this.getConversation()!;
|
|
this.set({ dataMessage });
|
|
|
|
try {
|
|
this.set({
|
|
// These are the same as a normal send()
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
sent_to: [conv.getSendTarget()!],
|
|
sent: true,
|
|
expirationStartTimestamp: Date.now(),
|
|
});
|
|
const result: typeof window.WhatIsThis = await this.sendSyncMessage();
|
|
this.set({
|
|
// We have to do this afterward, since we didn't have a previous send!
|
|
unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null,
|
|
|
|
// These are unique to a Note to Self message - immediately read/delivered
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
delivered_to: [window.ConversationController.getOurConversationId()!],
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
read_by: [window.ConversationController.getOurConversationId()!],
|
|
});
|
|
} catch (result) {
|
|
const errors = (result && result.errors) || [new Error('Unknown error')];
|
|
this.set({ errors });
|
|
} finally {
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
this.trigger('done');
|
|
|
|
const errors = this.get('errors');
|
|
if (errors) {
|
|
this.trigger('send-error', errors);
|
|
} else {
|
|
this.trigger('sent');
|
|
}
|
|
}
|
|
}
|
|
|
|
async sendSyncMessage(): Promise<WhatIsThis> {
|
|
const ourNumber = window.textsecure.storage.user.getNumber();
|
|
const ourUuid = window.textsecure.storage.user.getUuid();
|
|
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
|
|
ourUuid || ourNumber,
|
|
{
|
|
syncMessage: true,
|
|
}
|
|
);
|
|
|
|
this.syncPromise = this.syncPromise || Promise.resolve();
|
|
const next = async () => {
|
|
const dataMessage = this.get('dataMessage');
|
|
if (!dataMessage) {
|
|
return Promise.resolve();
|
|
}
|
|
const isUpdate = Boolean(this.get('synced'));
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const conv = this.getConversation()!;
|
|
|
|
return wrap(
|
|
window.textsecure.messaging.sendSyncMessage(
|
|
dataMessage,
|
|
this.get('sent_at'),
|
|
conv.get('e164'),
|
|
conv.get('uuid'),
|
|
this.get('expirationStartTimestamp'),
|
|
this.get('sent_to'),
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('unidentifiedDeliveries')!,
|
|
isUpdate,
|
|
sendOptions
|
|
)
|
|
).then(async (result: unknown) => {
|
|
this.set({
|
|
synced: true,
|
|
dataMessage: null,
|
|
});
|
|
|
|
// Return early, skip the save
|
|
if (this.doNotSave) {
|
|
return result;
|
|
}
|
|
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
return result;
|
|
});
|
|
};
|
|
|
|
this.syncPromise = this.syncPromise.then(next, next);
|
|
|
|
return this.syncPromise;
|
|
}
|
|
|
|
// NOTE: If you're modifying this function then you'll likely also need
|
|
// to modify queueAttachmentDownloads since it contains the logic below
|
|
hasAttachmentDownloads(): boolean {
|
|
const attachments = this.get('attachments') || [];
|
|
|
|
const [longMessageAttachments, normalAttachments] = _.partition(
|
|
attachments,
|
|
attachment =>
|
|
attachment.contentType ===
|
|
window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
|
|
);
|
|
|
|
if (longMessageAttachments.length > 0) {
|
|
return true;
|
|
}
|
|
|
|
const hasNormalAttachments = normalAttachments.some(attachment => {
|
|
if (!attachment) {
|
|
return false;
|
|
}
|
|
// We've already downloaded this!
|
|
if (attachment.path) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (hasNormalAttachments) {
|
|
return true;
|
|
}
|
|
|
|
const previews = this.get('preview') || [];
|
|
const hasPreviews = previews.some(item => {
|
|
if (!item.image) {
|
|
return false;
|
|
}
|
|
// We've already downloaded this!
|
|
if (item.image.path) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (hasPreviews) {
|
|
return true;
|
|
}
|
|
|
|
const contacts = this.get('contact') || [];
|
|
const hasContacts = contacts.some(item => {
|
|
if (!item.avatar || !item.avatar.avatar) {
|
|
return false;
|
|
}
|
|
if (item.avatar.avatar.path) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (hasContacts) {
|
|
return true;
|
|
}
|
|
|
|
const quote = this.get('quote');
|
|
const quoteAttachments =
|
|
quote && quote.attachments ? quote.attachments : [];
|
|
const hasQuoteAttachments = quoteAttachments.some(item => {
|
|
if (!item.thumbnail) {
|
|
return false;
|
|
}
|
|
// We've already downloaded this!
|
|
if (item.thumbnail.path) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (hasQuoteAttachments) {
|
|
return true;
|
|
}
|
|
|
|
const sticker = this.get('sticker');
|
|
if (sticker) {
|
|
return !sticker.data || (sticker.data && !sticker.data.path);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Receive logic
|
|
// NOTE: If you're changing any logic in this function that deals with the
|
|
// count then you'll also have to modify the above function
|
|
// hasAttachmentDownloads
|
|
async queueAttachmentDownloads(): Promise<boolean> {
|
|
const attachmentsToQueue = this.get('attachments') || [];
|
|
const messageId = this.id;
|
|
let count = 0;
|
|
let bodyPending;
|
|
|
|
window.log.info(
|
|
`Queueing ${
|
|
attachmentsToQueue.length
|
|
} attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
|
|
const [longMessageAttachments, normalAttachments] = _.partition(
|
|
attachmentsToQueue,
|
|
attachment =>
|
|
attachment.contentType ===
|
|
window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE
|
|
);
|
|
|
|
if (longMessageAttachments.length > 1) {
|
|
window.log.error(
|
|
`Received more than one long message attachment in message ${this.idForLogging()}`
|
|
);
|
|
}
|
|
|
|
window.log.info(
|
|
`Queueing ${
|
|
longMessageAttachments.length
|
|
} long message attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
|
|
if (longMessageAttachments.length > 0) {
|
|
count += 1;
|
|
bodyPending = true;
|
|
await window.Signal.AttachmentDownloads.addJob(
|
|
longMessageAttachments[0],
|
|
{
|
|
messageId,
|
|
type: 'long-message',
|
|
index: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
window.log.info(
|
|
`Queueing ${
|
|
normalAttachments.length
|
|
} normal attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
const attachments = await Promise.all(
|
|
normalAttachments.map((attachment, index) => {
|
|
if (!attachment) {
|
|
return attachment;
|
|
}
|
|
// We've already downloaded this!
|
|
if (attachment.path) {
|
|
window.log.info(
|
|
`Normal attachment already downloaded for message ${this.idForLogging()}`
|
|
);
|
|
return attachment;
|
|
}
|
|
|
|
count += 1;
|
|
|
|
return window.Signal.AttachmentDownloads.addJob<
|
|
typeof window.WhatIsThis
|
|
>(attachment, {
|
|
messageId,
|
|
type: 'attachment',
|
|
index,
|
|
});
|
|
})
|
|
);
|
|
|
|
const previewsToQueue = this.get('preview') || [];
|
|
window.log.info(
|
|
`Queueing ${
|
|
previewsToQueue.length
|
|
} preview attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
const preview = await Promise.all(
|
|
previewsToQueue.map(async (item, index) => {
|
|
if (!item.image) {
|
|
return item;
|
|
}
|
|
// We've already downloaded this!
|
|
if (item.image.path) {
|
|
window.log.info(
|
|
`Preview attachment already downloaded for message ${this.idForLogging()}`
|
|
);
|
|
return item;
|
|
}
|
|
|
|
count += 1;
|
|
return {
|
|
...item,
|
|
image: await window.Signal.AttachmentDownloads.addJob(item.image, {
|
|
messageId,
|
|
type: 'preview',
|
|
index,
|
|
}),
|
|
};
|
|
})
|
|
);
|
|
|
|
const contactsToQueue = this.get('contact') || [];
|
|
window.log.info(
|
|
`Queueing ${
|
|
contactsToQueue.length
|
|
} contact attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
const contact = await Promise.all(
|
|
contactsToQueue.map(async (item, index) => {
|
|
if (!item.avatar || !item.avatar.avatar) {
|
|
return item;
|
|
}
|
|
// We've already downloaded this!
|
|
if (item.avatar.avatar.path) {
|
|
window.log.info(
|
|
`Contact attachment already downloaded for message ${this.idForLogging()}`
|
|
);
|
|
return item;
|
|
}
|
|
|
|
count += 1;
|
|
return {
|
|
...item,
|
|
avatar: {
|
|
...item.avatar,
|
|
avatar: await window.Signal.AttachmentDownloads.addJob(
|
|
item.avatar.avatar,
|
|
{
|
|
messageId,
|
|
type: 'contact',
|
|
index,
|
|
}
|
|
),
|
|
},
|
|
};
|
|
})
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
let quote = this.get('quote')!;
|
|
const quoteAttachmentsToQueue =
|
|
quote && quote.attachments ? quote.attachments : [];
|
|
window.log.info(
|
|
`Queueing ${
|
|
quoteAttachmentsToQueue.length
|
|
} quote attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
if (quoteAttachmentsToQueue.length > 0) {
|
|
quote = {
|
|
...quote,
|
|
attachments: await Promise.all(
|
|
(quote.attachments || []).map(async (item, index) => {
|
|
if (!item.thumbnail) {
|
|
return item;
|
|
}
|
|
// We've already downloaded this!
|
|
if (item.thumbnail.path) {
|
|
window.log.info(
|
|
`Quote attachment already downloaded for message ${this.idForLogging()}`
|
|
);
|
|
return item;
|
|
}
|
|
|
|
count += 1;
|
|
return {
|
|
...item,
|
|
thumbnail: await window.Signal.AttachmentDownloads.addJob(
|
|
item.thumbnail,
|
|
{
|
|
messageId,
|
|
type: 'quote',
|
|
index,
|
|
}
|
|
),
|
|
};
|
|
})
|
|
),
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
let sticker = this.get('sticker')!;
|
|
if (sticker && sticker.data && sticker.data.path) {
|
|
window.log.info(
|
|
`Sticker attachment already downloaded for message ${this.idForLogging()}`
|
|
);
|
|
} else if (sticker) {
|
|
window.log.info(
|
|
`Queueing sticker download for message ${this.idForLogging()}`
|
|
);
|
|
count += 1;
|
|
const { packId, stickerId, packKey } = sticker;
|
|
|
|
const status = getStickerPackStatus(packId);
|
|
let data;
|
|
|
|
if (status && (status === 'downloaded' || status === 'installed')) {
|
|
try {
|
|
const copiedSticker = await copyStickerToAttachments(
|
|
packId,
|
|
stickerId
|
|
);
|
|
data = {
|
|
...copiedSticker,
|
|
contentType: 'image/webp',
|
|
};
|
|
} catch (error) {
|
|
window.log.error(
|
|
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}
|
|
}
|
|
if (!data) {
|
|
data = await window.Signal.AttachmentDownloads.addJob(sticker.data, {
|
|
messageId,
|
|
type: 'sticker',
|
|
index: 0,
|
|
});
|
|
}
|
|
if (!status) {
|
|
// Save the packId/packKey for future download/install
|
|
savePackMetadata(packId, packKey, { messageId });
|
|
} else {
|
|
await addStickerPackReference(messageId, packId);
|
|
}
|
|
|
|
sticker = {
|
|
...sticker,
|
|
packId,
|
|
data,
|
|
};
|
|
}
|
|
|
|
window.log.info(
|
|
`Queued ${count} total attachment downloads for message ${this.idForLogging()}`
|
|
);
|
|
|
|
if (count > 0) {
|
|
this.set({
|
|
bodyPending,
|
|
attachments,
|
|
preview,
|
|
contact,
|
|
quote,
|
|
sticker,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
async copyFromQuotedMessage(message: WhatIsThis): Promise<boolean> {
|
|
const { quote } = message;
|
|
if (!quote) {
|
|
return message;
|
|
}
|
|
|
|
const { attachments, id, author, authorUuid } = quote;
|
|
const firstAttachment = attachments[0];
|
|
const authorConversationId = window.ConversationController.ensureContactIds(
|
|
{
|
|
e164: author,
|
|
uuid: authorUuid,
|
|
}
|
|
);
|
|
|
|
const inMemoryMessage = window.MessageController.findBySentAt(id);
|
|
|
|
let queryMessage;
|
|
|
|
if (inMemoryMessage) {
|
|
queryMessage = inMemoryMessage;
|
|
} else {
|
|
window.log.info('copyFromQuotedMessage: db lookup needed', id);
|
|
const collection = await window.Signal.Data.getMessagesBySentAt(id, {
|
|
MessageCollection: window.Whisper.MessageCollection,
|
|
});
|
|
const found = collection.find(item => {
|
|
const messageAuthorId = item.getContactId();
|
|
|
|
return authorConversationId === messageAuthorId;
|
|
});
|
|
|
|
if (!found) {
|
|
quote.referencedMessageNotFound = true;
|
|
return message;
|
|
}
|
|
|
|
queryMessage = window.MessageController.register(found.id, found);
|
|
}
|
|
|
|
if (queryMessage.isTapToView()) {
|
|
quote.text = null;
|
|
quote.attachments = [
|
|
{
|
|
contentType: 'image/jpeg',
|
|
},
|
|
];
|
|
|
|
return message;
|
|
}
|
|
|
|
quote.text = queryMessage.get('body');
|
|
if (firstAttachment) {
|
|
firstAttachment.thumbnail = null;
|
|
}
|
|
|
|
if (
|
|
!firstAttachment ||
|
|
(!GoogleChrome.isImageTypeSupported(firstAttachment.contentType) &&
|
|
!GoogleChrome.isVideoTypeSupported(firstAttachment.contentType))
|
|
) {
|
|
return message;
|
|
}
|
|
|
|
try {
|
|
if (
|
|
queryMessage.get('schemaVersion') <
|
|
TypedMessage.VERSION_NEEDED_FOR_DISPLAY
|
|
) {
|
|
const upgradedMessage = await upgradeMessageSchema(
|
|
queryMessage.attributes
|
|
);
|
|
queryMessage.set(upgradedMessage);
|
|
await window.Signal.Data.saveMessage(upgradedMessage, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
window.log.error(
|
|
'Problem upgrading message quoted message from database',
|
|
Errors.toLogFormat(error)
|
|
);
|
|
return message;
|
|
}
|
|
|
|
const queryAttachments = queryMessage.get('attachments') || [];
|
|
if (queryAttachments.length > 0) {
|
|
const queryFirst = queryAttachments[0];
|
|
const { thumbnail } = queryFirst;
|
|
|
|
if (thumbnail && thumbnail.path) {
|
|
firstAttachment.thumbnail = {
|
|
...thumbnail,
|
|
copied: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const queryPreview = queryMessage.get('preview') || [];
|
|
if (queryPreview.length > 0) {
|
|
const queryFirst = queryPreview[0];
|
|
const { image } = queryFirst;
|
|
|
|
if (image && image.path) {
|
|
firstAttachment.thumbnail = {
|
|
...image,
|
|
copied: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const sticker = queryMessage.get('sticker');
|
|
if (sticker && sticker.data && sticker.data.path) {
|
|
firstAttachment.thumbnail = {
|
|
...sticker.data,
|
|
copied: true,
|
|
};
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
handleDataMessage(
|
|
initialMessage: DataMessageClass,
|
|
confirm: () => void,
|
|
options: { data?: typeof window.WhatIsThis } = {}
|
|
): WhatIsThis {
|
|
const { data } = options;
|
|
|
|
// This function is called from the background script in a few scenarios:
|
|
// 1. on an incoming message
|
|
// 2. on a sent message sync'd from another device
|
|
// 3. in rare cases, an incoming message can be retried, though it will
|
|
// still go through one of the previous two codepaths
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
const message = this;
|
|
const source = message.get('source');
|
|
const sourceUuid = message.get('sourceUuid');
|
|
const type = message.get('type');
|
|
const conversationId = message.get('conversationId');
|
|
const GROUP_TYPES = window.textsecure.protobuf.GroupContext.Type;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const conversation = window.ConversationController.get(conversationId)!;
|
|
return conversation.queueJob(async () => {
|
|
window.log.info(
|
|
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
|
|
);
|
|
|
|
// First, check for duplicates. If we find one, stop processing here.
|
|
const inMemoryMessage = window.MessageController.findBySender(
|
|
this.getSenderIdentifier()
|
|
);
|
|
if (inMemoryMessage) {
|
|
window.log.info(
|
|
'handleDataMessage: cache hit',
|
|
this.getSenderIdentifier()
|
|
);
|
|
} else {
|
|
window.log.info(
|
|
'handleDataMessage: duplicate check db lookup needed',
|
|
this.getSenderIdentifier()
|
|
);
|
|
}
|
|
const existingMessage =
|
|
inMemoryMessage ||
|
|
(await getMessageBySender(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
}));
|
|
const isUpdate = Boolean(data && data.isRecipientUpdate);
|
|
|
|
if (existingMessage && type === 'incoming') {
|
|
window.log.warn('Received duplicate message', this.idForLogging());
|
|
confirm();
|
|
return;
|
|
}
|
|
if (type === 'outgoing') {
|
|
if (isUpdate && existingMessage) {
|
|
window.log.info(
|
|
`handleDataMessage: Updating message ${message.idForLogging()} with received transcript`
|
|
);
|
|
|
|
let sentTo = [];
|
|
let unidentifiedDeliveries = [];
|
|
if (Array.isArray(data.unidentifiedStatus)) {
|
|
sentTo = data.unidentifiedStatus.map(
|
|
(item: typeof window.WhatIsThis) => item.destination
|
|
);
|
|
|
|
const unidentified = _.filter(data.unidentifiedStatus, item =>
|
|
Boolean(item.unidentified)
|
|
);
|
|
unidentifiedDeliveries = unidentified.map(item => item.destination);
|
|
}
|
|
|
|
const toUpdate = window.MessageController.register(
|
|
existingMessage.id,
|
|
existingMessage
|
|
);
|
|
toUpdate.set({
|
|
sent_to: _.union(toUpdate.get('sent_to'), sentTo),
|
|
unidentifiedDeliveries: _.union(
|
|
toUpdate.get('unidentifiedDeliveries'),
|
|
unidentifiedDeliveries
|
|
),
|
|
});
|
|
await window.Signal.Data.saveMessage(toUpdate.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
|
|
confirm();
|
|
return;
|
|
}
|
|
if (isUpdate) {
|
|
window.log.warn(
|
|
`handleDataMessage: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.`
|
|
);
|
|
|
|
confirm();
|
|
return;
|
|
}
|
|
if (existingMessage) {
|
|
window.log.warn(
|
|
`handleDataMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.`
|
|
);
|
|
|
|
confirm();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// GroupV2
|
|
|
|
if (initialMessage.groupV2) {
|
|
if (conversation.isGroupV1()) {
|
|
// If we received a GroupV2 message in a GroupV1 group, we migrate!
|
|
|
|
const { revision, groupChange } = initialMessage.groupV2;
|
|
await window.Signal.Groups.respondToGroupV2Migration({
|
|
conversation,
|
|
groupChangeBase64: groupChange,
|
|
newRevision: revision,
|
|
receivedAt: message.get('received_at'),
|
|
sentAt: message.get('sent_at'),
|
|
});
|
|
} else if (
|
|
initialMessage.groupV2.masterKey &&
|
|
initialMessage.groupV2.secretParams &&
|
|
initialMessage.groupV2.publicParams
|
|
) {
|
|
// Repair core GroupV2 data if needed
|
|
await conversation.maybeRepairGroupV2({
|
|
masterKey: initialMessage.groupV2.masterKey,
|
|
secretParams: initialMessage.groupV2.secretParams,
|
|
publicParams: initialMessage.groupV2.publicParams,
|
|
});
|
|
|
|
// Standard GroupV2 modification codepath
|
|
const existingRevision = conversation.get('revision');
|
|
const isV2GroupUpdate =
|
|
initialMessage.groupV2 &&
|
|
_.isNumber(initialMessage.groupV2.revision) &&
|
|
(!existingRevision ||
|
|
initialMessage.groupV2.revision > existingRevision);
|
|
|
|
if (isV2GroupUpdate && initialMessage.groupV2) {
|
|
const { revision, groupChange } = initialMessage.groupV2;
|
|
try {
|
|
await window.Signal.Groups.maybeUpdateGroup({
|
|
conversation,
|
|
groupChangeBase64: groupChange,
|
|
newRevision: revision,
|
|
receivedAt: message.get('received_at'),
|
|
sentAt: message.get('sent_at'),
|
|
});
|
|
} catch (error) {
|
|
const errorText = error && error.stack ? error.stack : error;
|
|
window.log.error(
|
|
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}`
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const ourConversationId = window.ConversationController.getOurConversationId()!;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const senderId = window.ConversationController.ensureContactIds({
|
|
e164: source,
|
|
uuid: sourceUuid,
|
|
})!;
|
|
const isGroupV2 = Boolean(initialMessage.groupV2);
|
|
const isV1GroupUpdate =
|
|
initialMessage.group &&
|
|
initialMessage.group.type !==
|
|
window.textsecure.protobuf.GroupContext.Type.DELIVER;
|
|
|
|
// Drop an incoming GroupV2 message if we or the sender are not part of the group
|
|
// after applying the message's associated group chnages.
|
|
if (
|
|
type === 'incoming' &&
|
|
!conversation.isPrivate() &&
|
|
isGroupV2 &&
|
|
(conversation.get('left') ||
|
|
!conversation.hasMember(ourConversationId) ||
|
|
!conversation.hasMember(senderId))
|
|
) {
|
|
window.log.warn(
|
|
`Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.`
|
|
);
|
|
confirm();
|
|
return;
|
|
}
|
|
|
|
// We drop incoming messages for v1 groups we already know about, which we're not
|
|
// a part of, except for group updates. Because group v1 updates haven't been
|
|
// applied by this point.
|
|
// Note: if we have no information about a group at all, we will accept those
|
|
// messages. We detect that via a missing 'members' field.
|
|
if (
|
|
type === 'incoming' &&
|
|
!conversation.isPrivate() &&
|
|
!isGroupV2 &&
|
|
!isV1GroupUpdate &&
|
|
conversation.get('members') &&
|
|
(conversation.get('left') || !conversation.hasMember(ourConversationId))
|
|
) {
|
|
window.log.warn(
|
|
`Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
|
|
);
|
|
confirm();
|
|
return;
|
|
}
|
|
|
|
// Because GroupV1 messages can now be multiplexed into GroupV2 conversations, we
|
|
// drop GroupV1 updates in GroupV2 groups.
|
|
if (isV1GroupUpdate && conversation.isGroupV2()) {
|
|
window.log.warn(
|
|
`Received GroupV1 update in GroupV2 conversation ${conversation.idForLogging()}. Dropping.`
|
|
);
|
|
confirm();
|
|
return;
|
|
}
|
|
|
|
// Send delivery receipts, but only for incoming sealed sender messages
|
|
// and not for messages from unaccepted conversations
|
|
if (
|
|
type === 'incoming' &&
|
|
this.get('unidentifiedDeliveryReceived') &&
|
|
!this.hasErrors() &&
|
|
conversation.getAccepted()
|
|
) {
|
|
// Note: We both queue and batch because we want to wait until we are done
|
|
// processing incoming messages to start sending outgoing delivery receipts.
|
|
// The queue can be paused easily.
|
|
window.Whisper.deliveryReceiptQueue.add(() => {
|
|
window.Whisper.deliveryReceiptBatcher.add({
|
|
source,
|
|
sourceUuid,
|
|
timestamp: this.get('sent_at'),
|
|
});
|
|
});
|
|
}
|
|
|
|
const withQuoteReference = await this.copyFromQuotedMessage(
|
|
initialMessage
|
|
);
|
|
const dataMessage = await upgradeMessageSchema(withQuoteReference);
|
|
|
|
try {
|
|
const now = new Date().getTime();
|
|
|
|
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
|
|
const incomingPreview = dataMessage.preview || [];
|
|
const preview = incomingPreview.filter(
|
|
(item: typeof window.WhatIsThis) =>
|
|
(item.image || item.title) &&
|
|
urls.includes(item.url) &&
|
|
window.Signal.LinkPreviews.isLinkSafeToPreview(item.url)
|
|
);
|
|
if (preview.length < incomingPreview.length) {
|
|
window.log.info(
|
|
`${message.idForLogging()}: Eliminated ${
|
|
preview.length - incomingPreview.length
|
|
} previews with invalid urls'`
|
|
);
|
|
}
|
|
|
|
message.set({
|
|
id: window.getGuid(),
|
|
attachments: dataMessage.attachments,
|
|
body: dataMessage.body,
|
|
bodyRanges: dataMessage.bodyRanges,
|
|
contact: dataMessage.contact,
|
|
conversationId: conversation.id,
|
|
decrypted_at: now,
|
|
errors: [],
|
|
flags: dataMessage.flags,
|
|
hasAttachments: dataMessage.hasAttachments,
|
|
hasFileAttachments: dataMessage.hasFileAttachments,
|
|
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
|
isViewOnce: Boolean(dataMessage.isViewOnce),
|
|
preview,
|
|
requiredProtocolVersion:
|
|
dataMessage.requiredProtocolVersion ||
|
|
this.INITIAL_PROTOCOL_VERSION,
|
|
supportedVersionAtReceive: this.CURRENT_PROTOCOL_VERSION,
|
|
quote: dataMessage.quote,
|
|
schemaVersion: dataMessage.schemaVersion,
|
|
sticker: dataMessage.sticker,
|
|
});
|
|
|
|
const isSupported = !message.isUnsupportedMessage();
|
|
if (!isSupported) {
|
|
await message.eraseContents();
|
|
}
|
|
|
|
if (isSupported) {
|
|
let attributes = {
|
|
...conversation.attributes,
|
|
};
|
|
|
|
// GroupV1
|
|
if (!isGroupV2 && dataMessage.group) {
|
|
const pendingGroupUpdate = [];
|
|
const memberConversations: Array<typeof window.WhatIsThis> = await Promise.all(
|
|
dataMessage.group.membersE164.map((e164: string) =>
|
|
window.ConversationController.getOrCreateAndWait(
|
|
e164,
|
|
'private'
|
|
)
|
|
)
|
|
);
|
|
const members = memberConversations.map(c => c.get('id'));
|
|
attributes = {
|
|
...attributes,
|
|
type: 'group',
|
|
groupId: dataMessage.group.id,
|
|
};
|
|
if (dataMessage.group.type === GROUP_TYPES.UPDATE) {
|
|
attributes = {
|
|
...attributes,
|
|
name: dataMessage.group.name,
|
|
members: _.union(members, conversation.get('members')),
|
|
};
|
|
|
|
if (dataMessage.group.name !== conversation.get('name')) {
|
|
pendingGroupUpdate.push(['name', dataMessage.group.name]);
|
|
}
|
|
|
|
const avatarAttachment = dataMessage.group.avatar;
|
|
|
|
let downloadedAvatar;
|
|
let hash;
|
|
if (avatarAttachment) {
|
|
try {
|
|
downloadedAvatar = await window.Signal.Util.downloadAttachment(
|
|
avatarAttachment
|
|
);
|
|
|
|
if (downloadedAvatar) {
|
|
const loadedAttachment = await window.Signal.Migrations.loadAttachmentData(
|
|
downloadedAvatar
|
|
);
|
|
|
|
hash = await window.Signal.Types.Conversation.computeHash(
|
|
loadedAttachment.data
|
|
);
|
|
}
|
|
} catch (err) {
|
|
window.log.info(
|
|
'handleDataMessage: group avatar download failed'
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingAvatar = conversation.get('avatar');
|
|
|
|
if (
|
|
// Avatar added
|
|
(!existingAvatar && avatarAttachment) ||
|
|
// Avatar changed
|
|
(existingAvatar && existingAvatar.hash !== hash) ||
|
|
// Avatar removed
|
|
(existingAvatar && !avatarAttachment)
|
|
) {
|
|
// Removes existing avatar from disk
|
|
if (existingAvatar && existingAvatar.path) {
|
|
await window.Signal.Migrations.deleteAttachmentData(
|
|
existingAvatar.path
|
|
);
|
|
}
|
|
|
|
let avatar = null;
|
|
if (downloadedAvatar && avatarAttachment !== null) {
|
|
const onDiskAttachment = await window.Signal.Types.Attachment.migrateDataToFileSystem(
|
|
downloadedAvatar,
|
|
{
|
|
writeNewAttachmentData:
|
|
window.Signal.Migrations.writeNewAttachmentData,
|
|
}
|
|
);
|
|
avatar = {
|
|
...onDiskAttachment,
|
|
hash,
|
|
};
|
|
}
|
|
|
|
attributes.avatar = avatar;
|
|
|
|
pendingGroupUpdate.push(['avatarUpdated', true]);
|
|
} else {
|
|
window.log.info(
|
|
'handleDataMessage: Group avatar hash matched; not replacing group avatar'
|
|
);
|
|
}
|
|
|
|
const difference = _.difference(
|
|
members,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
conversation.get('members')!
|
|
);
|
|
if (difference.length > 0) {
|
|
// Because GroupV1 groups are based on e164 only
|
|
const e164s = difference.map(id => {
|
|
const c = window.ConversationController.get(id);
|
|
return c ? c.get('e164') : null;
|
|
});
|
|
pendingGroupUpdate.push(['joined', e164s]);
|
|
}
|
|
if (conversation.get('left')) {
|
|
window.log.warn('re-added to a left group');
|
|
attributes.left = false;
|
|
conversation.set({ addedBy: message.getContactId() });
|
|
}
|
|
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const sender = window.ConversationController.get(senderId)!;
|
|
const inGroup = Boolean(
|
|
sender &&
|
|
(conversation.get('members') || []).includes(sender.id)
|
|
);
|
|
if (!inGroup) {
|
|
const senderString = sender ? sender.idForLogging() : null;
|
|
window.log.info(
|
|
`Got 'left' message from someone not in group: ${senderString}. Dropping.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (sender.isMe()) {
|
|
attributes.left = true;
|
|
pendingGroupUpdate.push(['left', 'You']);
|
|
} else {
|
|
pendingGroupUpdate.push(['left', sender.get('id')]);
|
|
}
|
|
attributes.members = _.without(
|
|
conversation.get('members'),
|
|
sender.get('id')
|
|
);
|
|
}
|
|
|
|
if (pendingGroupUpdate.length) {
|
|
const groupUpdate = pendingGroupUpdate.reduce(
|
|
(acc, [key, value]) => {
|
|
acc[key] = value;
|
|
return acc;
|
|
},
|
|
{} as typeof window.WhatIsThis
|
|
);
|
|
message.set({ group_update: groupUpdate });
|
|
}
|
|
}
|
|
|
|
// Drop empty messages after. This needs to happen after the initial
|
|
// message.set call and after GroupV1 processing to make sure all possible
|
|
// properties are set before we determine that a message is empty.
|
|
if (message.isEmpty()) {
|
|
window.log.info(
|
|
`handleDataMessage: Dropping empty message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
|
|
);
|
|
confirm();
|
|
return;
|
|
}
|
|
|
|
if (type === 'outgoing') {
|
|
const receipts = window.Whisper.DeliveryReceipts.forMessage(
|
|
conversation,
|
|
message
|
|
);
|
|
receipts.forEach(receipt =>
|
|
message.set({
|
|
delivered: (message.get('delivered') || 0) + 1,
|
|
delivered_to: _.union(message.get('delivered_to') || [], [
|
|
receipt.get('deliveredTo'),
|
|
]),
|
|
})
|
|
);
|
|
}
|
|
|
|
attributes.active_at = now;
|
|
conversation.set(attributes);
|
|
|
|
if (dataMessage.expireTimer) {
|
|
message.set({ expireTimer: dataMessage.expireTimer });
|
|
}
|
|
|
|
if (!isGroupV2) {
|
|
if (message.isExpirationTimerUpdate()) {
|
|
message.set({
|
|
expirationTimerUpdate: {
|
|
source,
|
|
sourceUuid,
|
|
expireTimer: dataMessage.expireTimer,
|
|
},
|
|
});
|
|
conversation.set({ expireTimer: dataMessage.expireTimer });
|
|
}
|
|
|
|
// NOTE: Remove once the above calls this.model.updateExpirationTimer()
|
|
const { expireTimer } = dataMessage;
|
|
const shouldLogExpireTimerChange =
|
|
message.isExpirationTimerUpdate() || expireTimer;
|
|
if (shouldLogExpireTimerChange) {
|
|
window.log.info("Update conversation 'expireTimer'", {
|
|
id: conversation.idForLogging(),
|
|
expireTimer,
|
|
source: 'handleDataMessage',
|
|
});
|
|
}
|
|
|
|
if (!message.isEndSession()) {
|
|
if (dataMessage.expireTimer) {
|
|
if (
|
|
dataMessage.expireTimer !== conversation.get('expireTimer')
|
|
) {
|
|
conversation.updateExpirationTimer(
|
|
dataMessage.expireTimer,
|
|
source,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
message.getReceivedAt()!,
|
|
{
|
|
fromGroupUpdate: message.isGroupUpdate(),
|
|
}
|
|
);
|
|
}
|
|
} else if (
|
|
conversation.get('expireTimer') &&
|
|
// We only turn off timers if it's not a group update
|
|
!message.isGroupUpdate()
|
|
) {
|
|
conversation.updateExpirationTimer(
|
|
undefined,
|
|
source,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
message.getReceivedAt()!
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === 'incoming') {
|
|
const readSync = window.Whisper.ReadSyncs.forMessage(message);
|
|
if (readSync) {
|
|
if (
|
|
message.get('expireTimer') &&
|
|
!message.get('expirationStartTimestamp')
|
|
) {
|
|
message.set(
|
|
'expirationStartTimestamp',
|
|
Math.min(readSync.get('read_at'), Date.now())
|
|
);
|
|
}
|
|
}
|
|
if (readSync || message.isExpirationTimerUpdate()) {
|
|
message.unset('unread');
|
|
// This is primarily to allow the conversation to mark all older
|
|
// messages as read, as is done when we receive a read sync for
|
|
// a message we already know about.
|
|
const c = message.getConversation();
|
|
if (c) {
|
|
c.onReadMessage(message);
|
|
}
|
|
} else {
|
|
conversation.set({
|
|
unreadCount: (conversation.get('unreadCount') || 0) + 1,
|
|
isArchived: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (type === 'outgoing') {
|
|
const reads = window.Whisper.ReadReceipts.forMessage(
|
|
conversation,
|
|
message
|
|
);
|
|
if (reads.length) {
|
|
const readBy = reads.map(receipt => receipt.get('reader'));
|
|
message.set({
|
|
read_by: _.union(message.get('read_by'), readBy),
|
|
});
|
|
}
|
|
|
|
// A sync'd message to ourself is automatically considered read/delivered
|
|
if (conversation.isMe()) {
|
|
message.set({
|
|
read_by: conversation.getRecipients(),
|
|
delivered_to: conversation.getRecipients(),
|
|
});
|
|
}
|
|
|
|
message.set({ recipients: conversation.getRecipients() });
|
|
}
|
|
|
|
if (dataMessage.profileKey) {
|
|
const profileKey = dataMessage.profileKey.toString('base64');
|
|
if (
|
|
source === window.textsecure.storage.user.getNumber() ||
|
|
sourceUuid === window.textsecure.storage.user.getUuid()
|
|
) {
|
|
conversation.set({ profileSharing: true });
|
|
} else if (conversation.isPrivate()) {
|
|
conversation.setProfileKey(profileKey);
|
|
} else {
|
|
const localId = window.ConversationController.ensureContactIds({
|
|
e164: source,
|
|
uuid: sourceUuid,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
window.ConversationController.get(localId)!.setProfileKey(
|
|
profileKey
|
|
);
|
|
}
|
|
}
|
|
|
|
if (message.isTapToView() && type === 'outgoing') {
|
|
await message.eraseContents();
|
|
}
|
|
|
|
if (
|
|
type === 'incoming' &&
|
|
message.isTapToView() &&
|
|
!message.isValidTapToView()
|
|
) {
|
|
window.log.warn(
|
|
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
|
|
);
|
|
message.set({
|
|
isTapToViewInvalid: true,
|
|
});
|
|
await message.eraseContents();
|
|
}
|
|
// Check for out-of-order view syncs
|
|
if (type === 'incoming' && message.isTapToView()) {
|
|
const viewSync = window.Whisper.ViewSyncs.forMessage(message);
|
|
if (viewSync) {
|
|
await message.markViewed({ fromSync: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const conversationTimestamp = conversation.get('timestamp');
|
|
if (
|
|
!conversationTimestamp ||
|
|
message.get('sent_at') > conversationTimestamp
|
|
) {
|
|
conversation.set({
|
|
lastMessage: message.getNotificationText(),
|
|
timestamp: message.get('sent_at'),
|
|
});
|
|
}
|
|
|
|
window.MessageController.register(
|
|
message.id,
|
|
message as typeof window.WhatIsThis
|
|
);
|
|
conversation.incrementMessageCount();
|
|
window.Signal.Data.updateConversation(conversation.attributes);
|
|
|
|
// Only queue attachments for downloads if this is an outgoing message
|
|
// or we've accepted the conversation
|
|
const reduxState = window.reduxStore.getState();
|
|
const attachments = this.get('attachments') || [];
|
|
const shouldHoldOffDownload =
|
|
(isImage(attachments) || isVideo(attachments)) &&
|
|
isInCall(reduxState);
|
|
if (
|
|
this.hasAttachmentDownloads() &&
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
(this.getConversation()!.getAccepted() || message.isOutgoing()) &&
|
|
!shouldHoldOffDownload
|
|
) {
|
|
if (window.attachmentDownloadQueue) {
|
|
window.attachmentDownloadQueue.unshift(message);
|
|
window.log.info(
|
|
'Adding to attachmentDownloadQueue',
|
|
message.get('sent_at')
|
|
);
|
|
} else {
|
|
await message.queueAttachmentDownloads();
|
|
}
|
|
}
|
|
|
|
// Does this message have any pending, previously-received associated reactions?
|
|
const reactions = window.Whisper.Reactions.forMessage(message);
|
|
await Promise.all(
|
|
reactions.map(reaction => message.handleReaction(reaction, false))
|
|
);
|
|
|
|
// Does this message have any pending, previously-received associated
|
|
// delete for everyone messages?
|
|
const deletes = window.Whisper.Deletes.forMessage(message);
|
|
await Promise.all(
|
|
deletes.map(del =>
|
|
window.Signal.Util.deleteForEveryone(message, del, false)
|
|
)
|
|
);
|
|
|
|
window.log.info(
|
|
'handleDataMessage: Batching save for',
|
|
message.get('sent_at')
|
|
);
|
|
this.saveAndNotify(conversation, confirm);
|
|
} catch (error) {
|
|
const errorForLog = error && error.stack ? error.stack : error;
|
|
window.log.error(
|
|
'handleDataMessage',
|
|
message.idForLogging(),
|
|
'error:',
|
|
errorForLog
|
|
);
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
async saveAndNotify(
|
|
conversation: ConversationModel,
|
|
confirm: () => void
|
|
): Promise<void> {
|
|
await window.Signal.Util.saveNewMessageBatcher.add(this.attributes);
|
|
|
|
window.log.info('Message saved', this.get('sent_at'));
|
|
|
|
conversation.trigger('newmessage', this);
|
|
|
|
if (this.get('unread')) {
|
|
await conversation.notify(this);
|
|
}
|
|
|
|
// Increment the sent message count if this is an outgoing message
|
|
if (this.get('type') === 'outgoing') {
|
|
conversation.incrementSentMessageCount();
|
|
}
|
|
|
|
window.Whisper.events.trigger('incrementProgress');
|
|
confirm();
|
|
}
|
|
|
|
async handleReaction(
|
|
reaction: typeof window.WhatIsThis,
|
|
shouldPersist = true
|
|
): Promise<void> {
|
|
if (this.get('deletedForEveryone')) {
|
|
return;
|
|
}
|
|
|
|
// We allow you to react to messages with outgoing errors only if it has sent
|
|
// successfully to at least one person.
|
|
if (
|
|
this.hasErrors() &&
|
|
(this.isIncoming() || this.getMessagePropStatus() !== 'partial-sent')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const reactions = this.get('reactions') || [];
|
|
const messageId = this.idForLogging();
|
|
const count = reactions.length;
|
|
|
|
const conversation = window.ConversationController.get(
|
|
this.get('conversationId')
|
|
);
|
|
|
|
let staleReactionFromId: string | undefined;
|
|
|
|
if (reaction.get('remove')) {
|
|
window.log.info('Removing reaction for message', messageId);
|
|
const newReactions = reactions.filter(
|
|
re =>
|
|
re.emoji !== reaction.get('emoji') ||
|
|
re.fromId !== reaction.get('fromId')
|
|
);
|
|
this.set({ reactions: newReactions });
|
|
|
|
staleReactionFromId = reaction.get('fromId');
|
|
} else {
|
|
window.log.info('Adding reaction for message', messageId);
|
|
const newReactions = reactions.filter(
|
|
re => re.fromId !== reaction.get('fromId')
|
|
);
|
|
newReactions.push(reaction.toJSON());
|
|
this.set({ reactions: newReactions });
|
|
|
|
const oldReaction = reactions.find(
|
|
re => re.fromId === reaction.get('fromId')
|
|
);
|
|
if (oldReaction) {
|
|
staleReactionFromId = oldReaction.fromId;
|
|
}
|
|
|
|
// Only notify for reactions to our own messages
|
|
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
|
|
conversation.notify(this, reaction);
|
|
}
|
|
}
|
|
|
|
if (staleReactionFromId) {
|
|
this.clearNotifications(reaction.get('fromId'));
|
|
}
|
|
|
|
const newCount = this.get('reactions').length;
|
|
window.log.info(
|
|
`Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.`
|
|
);
|
|
|
|
if (shouldPersist) {
|
|
await window.Signal.Data.saveMessage(this.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
}
|
|
|
|
async handleDeleteForEveryone(
|
|
del: typeof window.WhatIsThis,
|
|
shouldPersist = true
|
|
): Promise<void> {
|
|
window.log.info('Handling DOE.', {
|
|
fromId: del.get('fromId'),
|
|
targetSentTimestamp: del.get('targetSentTimestamp'),
|
|
messageServerTimestamp: this.get('serverTimestamp'),
|
|
deleteServerTimestamp: del.get('serverTimestamp'),
|
|
});
|
|
|
|
// Remove any notifications for this message
|
|
window.Whisper.Notifications.removeBy({ messageId: this.get('id') });
|
|
|
|
// Erase the contents of this message
|
|
await this.eraseContents(
|
|
{ deletedForEveryone: true, reactions: [] },
|
|
shouldPersist
|
|
);
|
|
|
|
// Update the conversation's last message in case this was the last message
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.getConversation()!.updateLastMessage();
|
|
}
|
|
|
|
clearNotifications(reactionFromId?: string): void {
|
|
window.Whisper.Notifications.removeBy({
|
|
messageId: this.id,
|
|
reactionFromId,
|
|
});
|
|
}
|
|
}
|
|
|
|
window.Whisper.Message = MessageModel as typeof window.WhatIsThis;
|
|
|
|
// Receive will be enabled before we enable send
|
|
window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain';
|
|
|
|
window.Whisper.Message.getLongMessageAttachment = ({
|
|
body,
|
|
attachments,
|
|
now,
|
|
}) => {
|
|
if (!body || body.length <= 2048) {
|
|
return {
|
|
body,
|
|
attachments,
|
|
};
|
|
}
|
|
|
|
const data = bytesFromString(body);
|
|
const attachment = {
|
|
contentType: window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE,
|
|
fileName: `long-message-${now}.txt`,
|
|
data,
|
|
size: data.byteLength,
|
|
};
|
|
|
|
return {
|
|
body: body.slice(0, 2048),
|
|
attachments: [attachment, ...attachments],
|
|
};
|
|
};
|
|
|
|
window.Whisper.Message.updateTimers = () => {
|
|
window.Whisper.ExpiringMessagesListener.update();
|
|
window.Whisper.TapToViewMessagesListener.update();
|
|
};
|
|
|
|
window.Whisper.MessageCollection = window.Backbone.Collection.extend({
|
|
model: window.Whisper.Message,
|
|
comparator(left: typeof window.WhatIsThis, right: typeof window.WhatIsThis) {
|
|
if (left.get('received_at') === right.get('received_at')) {
|
|
return (left.get('sent_at') || 0) - (right.get('sent_at') || 0);
|
|
}
|
|
|
|
return (left.get('received_at') || 0) - (right.get('received_at') || 0);
|
|
},
|
|
});
|