You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1492 lines
46 KiB
TypeScript
1492 lines
46 KiB
TypeScript
import Backbone from 'backbone';
|
|
|
|
import autoBind from 'auto-bind';
|
|
import filesize from 'filesize';
|
|
import { cloneDeep, debounce, isEmpty, size as lodashSize, partition, pick, uniq } from 'lodash';
|
|
import { SignalService } from '../protobuf';
|
|
import { getMessageQueue } from '../session';
|
|
import { getConversationController } from '../session/conversations';
|
|
import { ContentMessage } from '../session/messages/outgoing';
|
|
import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage';
|
|
import { PubKey } from '../session/types';
|
|
import {
|
|
UserUtils,
|
|
uploadAttachmentsToFileServer,
|
|
uploadLinkPreviewToFileServer,
|
|
uploadQuoteThumbnailsToFileServer,
|
|
} from '../session/utils';
|
|
import {
|
|
DataExtractionNotificationMsg,
|
|
MessageAttributes,
|
|
MessageAttributesOptionals,
|
|
MessageGroupUpdate,
|
|
MessageModelType,
|
|
PropsForDataExtractionNotification,
|
|
PropsForMessageRequestResponse,
|
|
fillMessageAttributesWithDefaults,
|
|
} from './messageType';
|
|
|
|
import { Data } from '../data/data';
|
|
import { OpenGroupData } from '../data/opengroups';
|
|
import { SettingsKey } from '../data/settings-key';
|
|
import { isUsAnySogsFromCache } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
|
import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime';
|
|
import { SnodeNamespaces } from '../session/apis/snode_api/namespaces';
|
|
import { DURATION } from '../session/constants';
|
|
import { DisappearingMessages } from '../session/disappearing_messages';
|
|
import { TimerOptions } from '../session/disappearing_messages/timerOptions';
|
|
import {
|
|
OpenGroupVisibleMessage,
|
|
OpenGroupVisibleMessageParams,
|
|
} from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
|
|
import {
|
|
VisibleMessage,
|
|
VisibleMessageParams,
|
|
} from '../session/messages/outgoing/visibleMessage/VisibleMessage';
|
|
import {
|
|
uploadAttachmentsV3,
|
|
uploadLinkPreviewsV3,
|
|
uploadQuoteThumbnailsV3,
|
|
} from '../session/utils/AttachmentsV2';
|
|
import { perfEnd, perfStart } from '../session/utils/Performance';
|
|
import { isUsFromCache } from '../session/utils/User';
|
|
import { buildSyncMessage } from '../session/utils/sync/syncUtils';
|
|
import {
|
|
FindAndFormatContactType,
|
|
MessageModelPropsWithoutConvoProps,
|
|
PropsForAttachment,
|
|
PropsForExpirationTimer,
|
|
PropsForExpiringMessage,
|
|
PropsForGroupInvitation,
|
|
PropsForGroupUpdate,
|
|
PropsForGroupUpdateAdd,
|
|
PropsForGroupUpdateGeneral,
|
|
PropsForGroupUpdateKicked,
|
|
PropsForGroupUpdateLeft,
|
|
PropsForGroupUpdateName,
|
|
PropsForMessageWithoutConvoProps,
|
|
PropsForQuote,
|
|
messagesChanged,
|
|
} from '../state/ducks/conversations';
|
|
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
|
|
import {
|
|
deleteExternalMessageFiles,
|
|
getAbsoluteAttachmentPath,
|
|
loadAttachmentData,
|
|
loadPreviewData,
|
|
loadQuoteData,
|
|
} from '../types/MessageAttachment';
|
|
import { ReactionList } from '../types/Reaction';
|
|
import { getAttachmentMetadata } from '../types/message/initializeAttachmentMetadata';
|
|
import { assertUnreachable, roomHasBlindEnabled } from '../types/sqlSharedTypes';
|
|
import { LinkPreviews } from '../util/linkPreviews';
|
|
import { Notifications } from '../util/notifications';
|
|
import { Storage } from '../util/storage';
|
|
import { ConversationModel } from './conversation';
|
|
import { READ_MESSAGE_STATE } from './conversationAttributes';
|
|
import { ConversationInteractionStatus, ConversationInteractionType } from '../interactions/types';
|
|
import { LastMessageStatusType } from '../state/ducks/types';
|
|
import {
|
|
getJoinedGroupUpdateChangeStr,
|
|
getKickedGroupUpdateStr,
|
|
getLeftGroupUpdateChangeStr,
|
|
} from './groupUpdate';
|
|
import type { GetMessageArgs, LocalizerToken } from '../types/localizer';
|
|
|
|
// tslint:disable: cyclomatic-complexity
|
|
|
|
export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|
constructor(attributes: MessageAttributesOptionals & { skipTimerInit?: boolean }) {
|
|
const filledAttrs = fillMessageAttributesWithDefaults(attributes);
|
|
super(filledAttrs);
|
|
|
|
if (!this.id) {
|
|
throw new Error('A message always needs to have an id.');
|
|
}
|
|
if (!this.get('conversationId')) {
|
|
throw new Error('A message always needs to have an conversationId.');
|
|
}
|
|
|
|
if (!attributes.skipTimerInit) {
|
|
void this.setToExpire();
|
|
}
|
|
autoBind(this);
|
|
|
|
if (window) {
|
|
window.contextMenuShown = false;
|
|
}
|
|
|
|
this.getMessageModelProps();
|
|
}
|
|
|
|
public getMessageModelProps(): MessageModelPropsWithoutConvoProps {
|
|
const propsForDataExtractionNotification = this.getPropsForDataExtractionNotification();
|
|
const propsForGroupInvitation = this.getPropsForGroupInvitation();
|
|
const propsForGroupUpdateMessage = this.getPropsForGroupUpdateMessage();
|
|
const propsForTimerNotification = this.getPropsForTimerNotification();
|
|
const propsForExpiringMessage = this.getPropsForExpiringMessage();
|
|
const propsForMessageRequestResponse = this.getPropsForMessageRequestResponse();
|
|
const propsForQuote = this.getPropsForQuote();
|
|
const callNotificationType = this.get('callNotificationType');
|
|
const interactionNotification = this.getInteractionNotification();
|
|
|
|
const messageProps: MessageModelPropsWithoutConvoProps = {
|
|
propsForMessage: this.getPropsForMessage(),
|
|
};
|
|
if (propsForDataExtractionNotification) {
|
|
messageProps.propsForDataExtractionNotification = propsForDataExtractionNotification;
|
|
}
|
|
if (propsForMessageRequestResponse) {
|
|
messageProps.propsForMessageRequestResponse = propsForMessageRequestResponse;
|
|
}
|
|
if (propsForGroupInvitation) {
|
|
messageProps.propsForGroupInvitation = propsForGroupInvitation;
|
|
}
|
|
if (propsForGroupUpdateMessage) {
|
|
messageProps.propsForGroupUpdateMessage = propsForGroupUpdateMessage;
|
|
}
|
|
if (propsForTimerNotification) {
|
|
messageProps.propsForTimerNotification = propsForTimerNotification;
|
|
}
|
|
if (propsForQuote) {
|
|
messageProps.propsForQuote = propsForQuote;
|
|
}
|
|
|
|
if (propsForExpiringMessage) {
|
|
messageProps.propsForExpiringMessage = propsForExpiringMessage;
|
|
}
|
|
|
|
if (callNotificationType) {
|
|
messageProps.propsForCallNotification = {
|
|
notificationType: callNotificationType,
|
|
receivedAt: this.get('received_at') || Date.now(),
|
|
isUnread: this.isUnread(),
|
|
...this.getPropsForExpiringMessage(),
|
|
};
|
|
}
|
|
|
|
if (interactionNotification) {
|
|
messageProps.propsForInteractionNotification = {
|
|
notificationType: interactionNotification,
|
|
convoId: this.get('conversationId'),
|
|
messageId: this.id,
|
|
receivedAt: this.get('received_at') || Date.now(),
|
|
isUnread: this.isUnread(),
|
|
};
|
|
}
|
|
|
|
return messageProps;
|
|
}
|
|
|
|
public idForLogging() {
|
|
return `${this.get('source')} ${this.get('sent_at')}`;
|
|
}
|
|
|
|
public isExpirationTimerUpdate() {
|
|
const expirationTimerFlag = SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
|
const flags = this.get('flags') || 0;
|
|
|
|
// eslint-disable-next-line no-bitwise
|
|
return Boolean(flags & expirationTimerFlag) && !isEmpty(this.getExpirationTimerUpdate());
|
|
}
|
|
|
|
public isControlMessage() {
|
|
return (
|
|
this.isExpirationTimerUpdate() ||
|
|
this.isDataExtractionNotification() ||
|
|
this.isMessageRequestResponse() ||
|
|
this.isGroupUpdate()
|
|
);
|
|
}
|
|
|
|
public isIncoming() {
|
|
return this.get('type') === 'incoming';
|
|
}
|
|
|
|
public isUnread() {
|
|
return !!this.get('unread');
|
|
}
|
|
|
|
// Important to allow for this.set({ unread}), save to db, then fetch()
|
|
// to propagate. We don't want the unset key in the db so our unread index
|
|
// stays small.
|
|
public merge(model: any) {
|
|
const attributes = model.attributes || model;
|
|
|
|
const { unread } = attributes;
|
|
if (unread === undefined) {
|
|
this.set({ unread: READ_MESSAGE_STATE.read });
|
|
}
|
|
|
|
this.set(attributes);
|
|
}
|
|
|
|
public isGroupInvitation() {
|
|
return !!this.get('groupInvitation');
|
|
}
|
|
|
|
public isMessageRequestResponse() {
|
|
return !!this.get('messageRequestResponse');
|
|
}
|
|
|
|
public isDataExtractionNotification() {
|
|
return !!this.get('dataExtractionNotification');
|
|
}
|
|
|
|
public isCallNotification() {
|
|
return !!this.get('callNotificationType');
|
|
}
|
|
|
|
public isInteractionNotification() {
|
|
return !!this.getInteractionNotification();
|
|
}
|
|
|
|
public getInteractionNotification() {
|
|
return this.get('interactionNotification');
|
|
}
|
|
|
|
public getNotificationText() {
|
|
const groupUpdate = this.getGroupUpdateAsArray();
|
|
if (groupUpdate) {
|
|
const groupName =
|
|
this.getConversation()?.getNicknameOrRealUsernameOrPlaceholder() || window.i18n('unknown');
|
|
|
|
if (groupUpdate.left) {
|
|
// @ts-expect-error -- TODO: Fix by using new i18n builder
|
|
const { token, args } = getLeftGroupUpdateChangeStr(groupUpdate.left, groupName);
|
|
// TODO: clean up this typing
|
|
return window.i18n.stripped(...([token, args] as GetMessageArgs<LocalizerToken>));
|
|
}
|
|
|
|
if (groupUpdate.name) {
|
|
return window.i18n.stripped('groupNameNew', { group_name: groupUpdate.name });
|
|
}
|
|
|
|
if (groupUpdate.joined?.length) {
|
|
// @ts-expect-error -- TODO: Fix by using new i18n builder
|
|
const { token, args } = getJoinedGroupUpdateChangeStr(groupUpdate.joined, groupName);
|
|
// TODO: clean up this typing
|
|
return window.i18n.stripped(...([token, args] as GetMessageArgs<LocalizerToken>));
|
|
}
|
|
|
|
if (groupUpdate.kicked?.length) {
|
|
// @ts-expect-error -- TODO: Fix by using new i18n builder
|
|
const { token, args } = getKickedGroupUpdateStr(groupUpdate.kicked, groupName);
|
|
// TODO: clean up this typing
|
|
return window.i18n.stripped(...([token, args] as GetMessageArgs<LocalizerToken>));
|
|
}
|
|
window.log.warn('did not build a specific change for getDescription of ', groupUpdate);
|
|
|
|
return window.i18n.stripped('groupUpdated');
|
|
}
|
|
|
|
if (this.isGroupInvitation()) {
|
|
return `😎 ${window.i18n.stripped('communityInvitation')}`;
|
|
}
|
|
|
|
if (this.isDataExtractionNotification()) {
|
|
const dataExtraction = this.get(
|
|
'dataExtractionNotification'
|
|
) as DataExtractionNotificationMsg;
|
|
if (dataExtraction.type === SignalService.DataExtractionNotification.Type.SCREENSHOT) {
|
|
return window.i18n.stripped('screenshotTaken', {
|
|
name: getConversationController().getContactProfileNameOrShortenedPubKey(
|
|
dataExtraction.source
|
|
),
|
|
});
|
|
}
|
|
|
|
return window.i18n.stripped('attachmentsMediaSaved', {
|
|
name: getConversationController().getContactProfileNameOrShortenedPubKey(
|
|
dataExtraction.source
|
|
),
|
|
});
|
|
}
|
|
if (this.isCallNotification()) {
|
|
const name = getConversationController().getContactProfileNameOrShortenedPubKey(
|
|
this.get('conversationId')
|
|
);
|
|
const callNotificationType = this.get('callNotificationType');
|
|
if (callNotificationType === 'missed-call') {
|
|
return window.i18n.stripped('callsMissedCallFrom', { name });
|
|
}
|
|
if (callNotificationType === 'started-call') {
|
|
return window.i18n.stripped('callsYouCalled', { name });
|
|
}
|
|
if (callNotificationType === 'answered-a-call') {
|
|
return window.i18n.stripped('callsInProgress');
|
|
}
|
|
}
|
|
|
|
const interactionNotification = this.getInteractionNotification();
|
|
if (interactionNotification) {
|
|
const { interactionType, interactionStatus } = interactionNotification;
|
|
|
|
// NOTE For now we only show interaction errors in the message history
|
|
if (interactionStatus === ConversationInteractionStatus.Error) {
|
|
const convo = getConversationController().get(this.get('conversationId'));
|
|
|
|
if (convo) {
|
|
const isGroup = !convo.isPrivate();
|
|
const isCommunity = convo.isPublic();
|
|
|
|
switch (interactionType) {
|
|
case ConversationInteractionType.Hide:
|
|
// there is no text for hiding changes
|
|
return '';
|
|
case ConversationInteractionType.Leave:
|
|
return isCommunity
|
|
? window.i18n.stripped('communityLeaveError', {
|
|
community_name: convo.getNicknameOrRealUsernameOrPlaceholder(),
|
|
})
|
|
: isGroup
|
|
? window.i18n.stripped('groupLeaveErrorFailed', {
|
|
group_name: convo.getNicknameOrRealUsernameOrPlaceholder(),
|
|
})
|
|
: '';
|
|
default:
|
|
assertUnreachable(
|
|
interactionType,
|
|
`Message.getDescription: Missing case error "${interactionType}"`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.get('reaction')) {
|
|
const reaction = this.get('reaction');
|
|
if (reaction && reaction.emoji && reaction.emoji !== '') {
|
|
return window.i18n.stripped('emojiReactsNotification', { emoji: reaction.emoji });
|
|
}
|
|
}
|
|
if (this.isExpirationTimerUpdate()) {
|
|
const expireTimerUpdate = this.getExpirationTimerUpdate();
|
|
const expireTimer = expireTimerUpdate?.expireTimer;
|
|
const convo = this.getConversation();
|
|
if (!convo) {
|
|
return '';
|
|
}
|
|
|
|
const expirationMode = DisappearingMessages.changeToDisappearingConversationMode(
|
|
convo,
|
|
expireTimerUpdate?.expirationType,
|
|
expireTimer
|
|
);
|
|
|
|
const source = expireTimerUpdate?.source;
|
|
const isUs = UserUtils.isUsFromCache(source);
|
|
|
|
const authorName =
|
|
getConversationController()
|
|
.get(source || '')
|
|
?.getNicknameOrRealUsernameOrPlaceholder() || window.i18n.stripped('unknown');
|
|
|
|
if (!expireTimerUpdate || expirationMode === 'off' || !expireTimer || expireTimer === 0) {
|
|
if (isUs) {
|
|
return window.i18n.stripped('disappearingMessagesTurnedOffYou');
|
|
}
|
|
return window.i18n.stripped('disappearingMessagesTurnedOff', {
|
|
name: authorName,
|
|
});
|
|
}
|
|
|
|
const localizedMode =
|
|
expirationMode === 'deleteAfterRead'
|
|
? window.i18n.stripped('disappearingMessagesTypeRead')
|
|
: window.i18n.stripped('disappearingMessagesTypeSent');
|
|
|
|
if (isUs) {
|
|
return window.i18n.stripped('disappearingMessagesSetYou', {
|
|
time: TimerOptions.getAbbreviated(expireTimerUpdate.expireTimer || 0),
|
|
disappearing_messages_type: localizedMode,
|
|
});
|
|
}
|
|
|
|
return window.i18n.stripped('disappearingMessagesSet', {
|
|
time: TimerOptions.getAbbreviated(expireTimerUpdate.expireTimer || 0),
|
|
name: authorName,
|
|
disappearing_messages_type: localizedMode,
|
|
});
|
|
}
|
|
const body = this.get('body');
|
|
if (body) {
|
|
let bodyMentionsMappedToNames = body;
|
|
// regex with a 'g' to ignore part groups
|
|
const regex = new RegExp(`@${PubKey.regexForPubkeys}`, 'g');
|
|
const pubkeysInDesc = body.match(regex);
|
|
(pubkeysInDesc || []).forEach((pubkeyWithAt: string) => {
|
|
const pubkey = pubkeyWithAt.slice(1);
|
|
const isUS = isUsAnySogsFromCache(pubkey);
|
|
const displayName =
|
|
getConversationController().getContactProfileNameOrShortenedPubKey(pubkey);
|
|
if (isUS) {
|
|
bodyMentionsMappedToNames = bodyMentionsMappedToNames?.replace(
|
|
pubkeyWithAt,
|
|
`@${window.i18n('you')}`
|
|
);
|
|
} else if (displayName && displayName.length) {
|
|
bodyMentionsMappedToNames = bodyMentionsMappedToNames?.replace(
|
|
pubkeyWithAt,
|
|
`@${displayName}`
|
|
);
|
|
}
|
|
});
|
|
return bodyMentionsMappedToNames;
|
|
}
|
|
|
|
// Note: we want this after the check for a body as we want to display the body if we have one.
|
|
if ((this.get('attachments') || []).length) {
|
|
return window.i18n.stripped('contentDescriptionMediaMessage');
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
public onDestroy() {
|
|
void this.cleanup();
|
|
}
|
|
|
|
public async cleanup() {
|
|
await deleteExternalMessageFiles(this.attributes);
|
|
}
|
|
|
|
public getPropsForExpiringMessage(): PropsForExpiringMessage {
|
|
const expirationType = this.getExpirationType();
|
|
const expirationDurationMs = this.getExpireTimerSeconds()
|
|
? this.getExpireTimerSeconds() * DURATION.SECONDS
|
|
: null;
|
|
|
|
const expireTimerStart = this.getExpirationStartTimestamp() || null;
|
|
|
|
const expirationTimestamp =
|
|
expirationType && expireTimerStart && expirationDurationMs
|
|
? expireTimerStart + expirationDurationMs
|
|
: null;
|
|
|
|
const direction =
|
|
this.get('direction') === 'outgoing' || this.get('type') === 'outgoing'
|
|
? 'outgoing'
|
|
: 'incoming';
|
|
|
|
return {
|
|
convoId: this.get('conversationId'),
|
|
messageId: this.get('id'),
|
|
direction,
|
|
expirationDurationMs,
|
|
expirationTimestamp,
|
|
isExpired: this.isExpired(),
|
|
};
|
|
}
|
|
|
|
public getPropsForTimerNotification(): PropsForExpirationTimer | null {
|
|
if (!this.isExpirationTimerUpdate()) {
|
|
return null;
|
|
}
|
|
|
|
const timerUpdate = this.getExpirationTimerUpdate();
|
|
const convo = this.getConversation();
|
|
|
|
if (!timerUpdate || !timerUpdate.source || !convo) {
|
|
return null;
|
|
}
|
|
|
|
const { expireTimer, fromSync, source } = timerUpdate;
|
|
const expirationMode = DisappearingMessages.changeToDisappearingConversationMode(
|
|
convo,
|
|
timerUpdate?.expirationType || 'unknown',
|
|
expireTimer || 0
|
|
);
|
|
|
|
const timespanText = TimerOptions.getName(expireTimer || 0);
|
|
const disabled = !expireTimer;
|
|
|
|
const basicProps: PropsForExpirationTimer = {
|
|
...findAndFormatContact(source),
|
|
timespanText,
|
|
timespanSeconds: expireTimer || 0,
|
|
disabled,
|
|
type: fromSync ? 'fromSync' : UserUtils.isUsFromCache(source) ? 'fromMe' : 'fromOther',
|
|
receivedAt: this.get('received_at'),
|
|
isUnread: this.isUnread(),
|
|
expirationMode: expirationMode || 'off',
|
|
...this.getPropsForExpiringMessage(),
|
|
};
|
|
|
|
return basicProps;
|
|
}
|
|
|
|
public getPropsForGroupInvitation(): PropsForGroupInvitation | null {
|
|
if (!this.isGroupInvitation()) {
|
|
return null;
|
|
}
|
|
const invitation = this.get('groupInvitation');
|
|
let serverAddress = '';
|
|
|
|
try {
|
|
const url = new URL(invitation.url);
|
|
serverAddress = url.origin;
|
|
} catch (e) {
|
|
window?.log?.warn('failed to get hostname from opengroupv2 invitation', invitation);
|
|
}
|
|
|
|
return {
|
|
serverName: invitation.name,
|
|
url: serverAddress,
|
|
acceptUrl: invitation.url,
|
|
receivedAt: this.get('received_at'),
|
|
isUnread: this.isUnread(),
|
|
...this.getPropsForExpiringMessage(),
|
|
};
|
|
}
|
|
|
|
public getPropsForDataExtractionNotification(): PropsForDataExtractionNotification | null {
|
|
if (!this.isDataExtractionNotification()) {
|
|
return null;
|
|
}
|
|
const dataExtractionNotification = this.get('dataExtractionNotification');
|
|
|
|
if (!dataExtractionNotification) {
|
|
window.log.warn('dataExtractionNotification should not happen');
|
|
return null;
|
|
}
|
|
|
|
const contact = findAndFormatContact(dataExtractionNotification.source);
|
|
|
|
return {
|
|
...dataExtractionNotification,
|
|
name: contact.profileName || contact.name || dataExtractionNotification.source,
|
|
receivedAt: this.get('received_at'),
|
|
isUnread: this.isUnread(),
|
|
...this.getPropsForExpiringMessage(),
|
|
};
|
|
}
|
|
|
|
public getPropsForMessageRequestResponse(): PropsForMessageRequestResponse | null {
|
|
if (!this.isMessageRequestResponse()) {
|
|
return null;
|
|
}
|
|
const messageRequestResponse = this.get('messageRequestResponse');
|
|
|
|
if (!messageRequestResponse) {
|
|
window.log.warn('messageRequestResponse should not happen');
|
|
return null;
|
|
}
|
|
|
|
const contact = findAndFormatContact(messageRequestResponse.source);
|
|
|
|
return {
|
|
...messageRequestResponse,
|
|
name: contact.profileName || contact.name || messageRequestResponse.source,
|
|
messageId: this.id,
|
|
receivedAt: this.get('received_at'),
|
|
isUnread: this.isUnread(),
|
|
conversationId: this.get('conversationId'),
|
|
source: this.get('source'),
|
|
};
|
|
}
|
|
|
|
public getPropsForGroupUpdateMessage(): PropsForGroupUpdate | null {
|
|
const groupUpdate = this.getGroupUpdateAsArray();
|
|
|
|
if (!groupUpdate || isEmpty(groupUpdate)) {
|
|
return null;
|
|
}
|
|
|
|
const sharedProps = {
|
|
isUnread: this.isUnread(),
|
|
receivedAt: this.get('received_at'),
|
|
...this.getPropsForExpiringMessage(),
|
|
};
|
|
|
|
if (groupUpdate.joined?.length) {
|
|
const change: PropsForGroupUpdateAdd = {
|
|
type: 'add',
|
|
added: groupUpdate.joined,
|
|
};
|
|
return { change, ...sharedProps };
|
|
}
|
|
|
|
if (groupUpdate.kicked?.length) {
|
|
const change: PropsForGroupUpdateKicked = {
|
|
type: 'kicked',
|
|
kicked: groupUpdate.kicked,
|
|
};
|
|
return { change, ...sharedProps };
|
|
}
|
|
|
|
if (groupUpdate.left?.length) {
|
|
const change: PropsForGroupUpdateLeft = {
|
|
type: 'left',
|
|
left: groupUpdate.left,
|
|
};
|
|
return { change, ...sharedProps };
|
|
}
|
|
|
|
if (groupUpdate.name) {
|
|
const change: PropsForGroupUpdateName = {
|
|
type: 'name',
|
|
newName: groupUpdate.name,
|
|
};
|
|
return { change, ...sharedProps };
|
|
}
|
|
|
|
// Just show a "Group Updated" message, not sure what was changed
|
|
const changeGeneral: PropsForGroupUpdateGeneral = {
|
|
type: 'general',
|
|
};
|
|
return { change: changeGeneral, ...sharedProps };
|
|
}
|
|
|
|
public getMessagePropStatus(): LastMessageStatusType {
|
|
if (this.hasErrors()) {
|
|
return 'error';
|
|
}
|
|
|
|
// Only return the status on outgoing messages
|
|
if (!this.isOutgoing()) {
|
|
return undefined;
|
|
}
|
|
|
|
// some incoming legacy group updates are outgoing, but when synced to our other devices have just the received_at field set.
|
|
// when that is the case, we don't want to render the spinning 'sending' state
|
|
if (
|
|
(this.isExpirationTimerUpdate() || this.isDataExtractionNotification()) &&
|
|
this.get('received_at')
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
if (
|
|
this.isDataExtractionNotification() ||
|
|
this.isCallNotification() ||
|
|
this.isInteractionNotification()
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
if (this.getConversation()?.get('left')) {
|
|
return 'sent';
|
|
}
|
|
|
|
const readBy = this.get('read_by') || [];
|
|
if (Storage.get(SettingsKey.settingsReadReceipt) && readBy.length > 0) {
|
|
return 'read';
|
|
}
|
|
const sent = this.get('sent');
|
|
// control messages we've sent, synced from the network appear to just have the
|
|
// sent_at field set, but our current devices also have this field set when we are just sending it... So idk how to have behavior work fine.,
|
|
// TODOLATER
|
|
// const sentAt = this.get('sent_at');
|
|
const sentTo = this.get('sent_to') || [];
|
|
|
|
if (sent || sentTo.length > 0) {
|
|
return 'sent';
|
|
}
|
|
|
|
return 'sending';
|
|
}
|
|
|
|
public getPropsForMessage(): PropsForMessageWithoutConvoProps {
|
|
const sender = this.getSource();
|
|
const expirationType = this.getExpirationType();
|
|
const expirationDurationMs = this.getExpireTimerSeconds() * DURATION.SECONDS;
|
|
const expireTimerStart = this.getExpirationStartTimestamp();
|
|
const expirationTimestamp =
|
|
expirationType && expireTimerStart && expirationDurationMs
|
|
? expireTimerStart + expirationDurationMs
|
|
: null;
|
|
|
|
const attachments = this.get('attachments') || [];
|
|
const isTrustedForAttachmentDownload = this.isTrustedForAttachmentDownload();
|
|
const body = this.get('body');
|
|
const props: PropsForMessageWithoutConvoProps = {
|
|
id: this.id,
|
|
direction: (this.isIncoming() ? 'incoming' : 'outgoing') as MessageModelType,
|
|
timestamp: this.get('sent_at') || 0,
|
|
sender,
|
|
convoId: this.get('conversationId'),
|
|
};
|
|
if (body) {
|
|
props.text = body;
|
|
}
|
|
if (this.get('isDeleted')) {
|
|
props.isDeleted = this.get('isDeleted');
|
|
}
|
|
|
|
if (this.getMessageHash()) {
|
|
props.messageHash = this.getMessageHash();
|
|
}
|
|
if (this.get('received_at')) {
|
|
props.receivedAt = this.get('received_at');
|
|
}
|
|
if (this.get('serverTimestamp')) {
|
|
props.serverTimestamp = this.get('serverTimestamp');
|
|
}
|
|
if (this.get('serverId')) {
|
|
props.serverId = this.get('serverId');
|
|
}
|
|
if (expirationType) {
|
|
props.expirationType = expirationType;
|
|
}
|
|
if (expirationDurationMs) {
|
|
props.expirationDurationMs = expirationDurationMs;
|
|
}
|
|
if (expirationTimestamp) {
|
|
props.expirationTimestamp = expirationTimestamp;
|
|
}
|
|
if (isTrustedForAttachmentDownload) {
|
|
props.isTrustedForAttachmentDownload = isTrustedForAttachmentDownload;
|
|
}
|
|
const isUnread = this.isUnread();
|
|
if (isUnread) {
|
|
props.isUnread = isUnread;
|
|
}
|
|
const isExpired = this.isExpired();
|
|
if (isExpired) {
|
|
props.isExpired = isExpired;
|
|
}
|
|
const previews = this.getPropsForPreview();
|
|
if (previews && previews.length) {
|
|
props.previews = previews;
|
|
}
|
|
const reacts = this.getPropsForReacts();
|
|
if (reacts && Object.keys(reacts).length) {
|
|
props.reacts = reacts;
|
|
}
|
|
const quote = this.getPropsForQuote();
|
|
if (quote) {
|
|
props.quote = quote;
|
|
}
|
|
const status = this.getMessagePropStatus();
|
|
if (status) {
|
|
props.status = status;
|
|
}
|
|
|
|
const attachmentsProps = attachments.map(this.getPropsForAttachment);
|
|
if (attachmentsProps && attachmentsProps.length) {
|
|
props.attachments = attachmentsProps;
|
|
}
|
|
|
|
return props;
|
|
}
|
|
|
|
public getPropsForPreview(): Array<any> | null {
|
|
const previews = this.get('preview') || null;
|
|
|
|
if (!previews || previews.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return previews.map((preview: any) => {
|
|
let image: PropsForAttachment | null = null;
|
|
try {
|
|
if (preview.image) {
|
|
image = this.getPropsForAttachment(preview.image);
|
|
}
|
|
} catch (e) {
|
|
window?.log?.info('Failed to show preview');
|
|
}
|
|
|
|
return {
|
|
...preview,
|
|
domain: LinkPreviews.getDomain(preview.url),
|
|
image,
|
|
};
|
|
});
|
|
}
|
|
|
|
public getPropsForReacts(): ReactionList | null {
|
|
return this.get('reacts') || null;
|
|
}
|
|
|
|
public getPropsForQuote(): PropsForQuote | null {
|
|
return this.get('quote') || null;
|
|
}
|
|
|
|
public getPropsForAttachment(attachment: AttachmentTypeWithPath): PropsForAttachment | null {
|
|
if (!attachment) {
|
|
return null;
|
|
}
|
|
|
|
const {
|
|
id,
|
|
path,
|
|
contentType,
|
|
width,
|
|
height,
|
|
pending,
|
|
flags,
|
|
size,
|
|
screenshot,
|
|
thumbnail,
|
|
fileName,
|
|
caption,
|
|
} = attachment;
|
|
|
|
const isVoiceMessageBool =
|
|
// eslint-disable-next-line no-bitwise
|
|
Boolean(flags && flags & SignalService.AttachmentPointer.Flags.VOICE_MESSAGE) || false;
|
|
|
|
return {
|
|
id,
|
|
contentType,
|
|
caption,
|
|
size: size || 0,
|
|
width: width || 0,
|
|
height: height || 0,
|
|
path,
|
|
fileName,
|
|
fileSize: size ? filesize(size, { base: 10 }) : null,
|
|
isVoiceMessage: isVoiceMessageBool,
|
|
pending: Boolean(pending),
|
|
url: path ? getAbsoluteAttachmentPath(path) : '',
|
|
screenshot: screenshot
|
|
? {
|
|
...screenshot,
|
|
url: getAbsoluteAttachmentPath(screenshot.path),
|
|
}
|
|
: null,
|
|
thumbnail: thumbnail
|
|
? {
|
|
...thumbnail,
|
|
url: getAbsoluteAttachmentPath(thumbnail.path),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Uploads attachments, previews and quotes.
|
|
*
|
|
* @returns The uploaded data which includes: body, attachments, preview and quote.
|
|
* Also returns the uploaded ids to include in the message post so that those attachments are linked to that message.
|
|
*/
|
|
public async uploadData() {
|
|
const start = Date.now();
|
|
const finalAttachments = await Promise.all(
|
|
(this.get('attachments') || []).map(loadAttachmentData)
|
|
);
|
|
const body = this.get('body');
|
|
|
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
|
const previewWithData = await loadPreviewData(this.get('preview'));
|
|
|
|
const { hasAttachments, hasVisualMediaAttachments, hasFileAttachments } =
|
|
getAttachmentMetadata(this);
|
|
this.set({ hasAttachments, hasVisualMediaAttachments, hasFileAttachments });
|
|
await this.commit();
|
|
|
|
const conversation = this.getConversation();
|
|
|
|
let attachmentPromise;
|
|
let linkPreviewPromise;
|
|
let quotePromise;
|
|
const fileIdsToLink: Array<number> = [];
|
|
|
|
// we can only send a single preview
|
|
const firstPreviewWithData = previewWithData?.[0] || null;
|
|
|
|
// we want to go for the v1, if this is an OpenGroupV1 or not an open group at all
|
|
if (conversation?.isPublic()) {
|
|
const openGroupV2 = conversation.toOpenGroupV2();
|
|
attachmentPromise = uploadAttachmentsV3(finalAttachments, openGroupV2);
|
|
linkPreviewPromise = uploadLinkPreviewsV3(firstPreviewWithData, openGroupV2);
|
|
quotePromise = uploadQuoteThumbnailsV3(openGroupV2, quoteWithData);
|
|
} else {
|
|
// if that's not an sogs, the file is uploaded to the fileserver instead
|
|
attachmentPromise = uploadAttachmentsToFileServer(finalAttachments);
|
|
linkPreviewPromise = uploadLinkPreviewToFileServer(firstPreviewWithData);
|
|
quotePromise = uploadQuoteThumbnailsToFileServer(quoteWithData);
|
|
}
|
|
|
|
const [attachments, preview, quote] = await Promise.all([
|
|
attachmentPromise,
|
|
linkPreviewPromise,
|
|
quotePromise,
|
|
]);
|
|
fileIdsToLink.push(...attachments.map(m => m.id));
|
|
if (preview) {
|
|
fileIdsToLink.push(preview.id);
|
|
}
|
|
|
|
if (quote && quote.attachments?.length) {
|
|
// typing for all of this Attachment + quote + preview + send or unsend is pretty bad
|
|
const firstQuoteAttachmentId = (quote.attachments[0].thumbnail as any)?.id;
|
|
if (firstQuoteAttachmentId) {
|
|
fileIdsToLink.push(firstQuoteAttachmentId);
|
|
}
|
|
}
|
|
|
|
const isFirstAttachmentVoiceMessage = finalAttachments?.[0]?.isVoiceMessage;
|
|
if (isFirstAttachmentVoiceMessage) {
|
|
attachments[0].flags = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
|
|
}
|
|
|
|
window.log.info(
|
|
`Upload of message data for message ${this.idForLogging()} is finished in ${
|
|
Date.now() - start
|
|
}ms.`
|
|
);
|
|
return {
|
|
body,
|
|
attachments,
|
|
preview,
|
|
quote,
|
|
fileIdsToLink: uniq(fileIdsToLink),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Marks the message as deleted to show the author has deleted this message for everyone.
|
|
* Sets isDeleted property to true. Set message body text to deletion placeholder for conversation list items.
|
|
*/
|
|
public async markAsDeleted() {
|
|
this.set({
|
|
isDeleted: true,
|
|
body: window.i18n('deleteMessageDeleted', { count: 1 }),
|
|
quote: undefined,
|
|
groupInvitation: undefined,
|
|
dataExtractionNotification: undefined,
|
|
hasAttachments: 0,
|
|
hasFileAttachments: 0,
|
|
hasVisualMediaAttachments: 0,
|
|
attachments: undefined,
|
|
preview: undefined,
|
|
reacts: undefined,
|
|
reactsIndex: undefined,
|
|
});
|
|
// we can ignore the result of that markMessageReadNoCommit as it would only be used
|
|
// to refresh the expiry of it(but it is already marked as "deleted", so we don't care)
|
|
this.markMessageReadNoCommit(Date.now());
|
|
await this.commit();
|
|
// the line below makes sure that getNextExpiringMessage will find this message as expiring.
|
|
// getNextExpiringMessage is used on app start to clean already expired messages which should have been removed already, but are not
|
|
await this.setToExpire();
|
|
await this.getConversation()?.refreshInMemoryDetails();
|
|
}
|
|
|
|
// One caller today: event handler for the 'Retry Send' entry on right click of a failed send message
|
|
public async retrySend() {
|
|
if (!window.isOnline) {
|
|
window?.log?.error('retrySend: Cannot retry since we are offline!');
|
|
return null;
|
|
}
|
|
|
|
this.set({ errors: null, sent: false, sent_to: [] });
|
|
await this.commit();
|
|
try {
|
|
const conversation: ConversationModel | undefined = this.getConversation();
|
|
if (!conversation) {
|
|
window?.log?.info(
|
|
'[retrySend] Cannot retry send message, the corresponding conversation was not found.'
|
|
);
|
|
return null;
|
|
}
|
|
const { body, attachments, preview, quote, fileIdsToLink } = await this.uploadData();
|
|
|
|
if (conversation.isPublic()) {
|
|
const openGroupParams: OpenGroupVisibleMessageParams = {
|
|
identifier: this.id,
|
|
timestamp: GetNetworkTime.getNowWithNetworkOffset(),
|
|
lokiProfile: UserUtils.getOurProfile(),
|
|
body,
|
|
attachments,
|
|
preview: preview ? [preview] : [],
|
|
quote,
|
|
};
|
|
const roomInfos = OpenGroupData.getV2OpenGroupRoom(conversation.id);
|
|
if (!roomInfos) {
|
|
throw new Error('[retrySend] Could not find roomInfos for this conversation');
|
|
}
|
|
|
|
const openGroupMessage = new OpenGroupVisibleMessage(openGroupParams);
|
|
const openGroup = OpenGroupData.getV2OpenGroupRoom(conversation.id);
|
|
|
|
return getMessageQueue().sendToOpenGroupV2({
|
|
message: openGroupMessage,
|
|
roomInfos,
|
|
blinded: roomHasBlindEnabled(openGroup),
|
|
filesToLink: fileIdsToLink,
|
|
});
|
|
}
|
|
|
|
const timestamp = Date.now(); // force a new timestamp to handle user fixed his clock;
|
|
|
|
const chatParams: VisibleMessageParams = {
|
|
identifier: this.id,
|
|
body,
|
|
timestamp,
|
|
attachments,
|
|
preview: preview ? [preview] : [],
|
|
quote,
|
|
lokiProfile: UserUtils.getOurProfile(),
|
|
// Note: we should have the fields set on that object when we've added it to the DB.
|
|
// We don't want to reuse the conversation setting, as it might change since this message was sent.
|
|
expirationType: this.getExpirationType() || null,
|
|
expireTimer: this.getExpireTimerSeconds(),
|
|
};
|
|
if (!chatParams.lokiProfile) {
|
|
delete chatParams.lokiProfile;
|
|
}
|
|
|
|
const chatMessage = new VisibleMessage(chatParams);
|
|
|
|
// Special-case the self-send case - we send only a sync message
|
|
if (conversation.isMe()) {
|
|
return this.sendSyncMessageOnly(chatMessage);
|
|
}
|
|
|
|
if (conversation.isPrivate()) {
|
|
return getMessageQueue().sendToPubKey(
|
|
PubKey.cast(conversation.id),
|
|
chatMessage,
|
|
SnodeNamespaces.UserMessages
|
|
);
|
|
}
|
|
|
|
// Here, the convo is neither an open group, a private convo or ourself. It can only be a closed group.
|
|
// For a closed group, retry send only means trigger a send again to all recipients
|
|
// as they are all polling from the same group swarm pubkey
|
|
if (!conversation.isClosedGroup()) {
|
|
throw new Error(
|
|
'[retrySend] We should only end up with a closed group here. Anything else is an error'
|
|
);
|
|
}
|
|
|
|
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
|
|
identifier: this.id,
|
|
groupId: PubKey.cast(this.get('conversationId')),
|
|
timestamp,
|
|
chatMessage,
|
|
});
|
|
|
|
return getMessageQueue().sendToGroup({
|
|
message: closedGroupVisibleMessage,
|
|
namespace: SnodeNamespaces.ClosedGroupMessage,
|
|
});
|
|
} catch (e) {
|
|
await this.saveErrors(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public removeOutgoingErrors(number: string) {
|
|
const errors = partition(
|
|
this.get('errors'),
|
|
e => e.number === number && e.name === 'SendMessageNetworkError'
|
|
);
|
|
this.set({ errors: errors[1] });
|
|
return errors[0][0];
|
|
}
|
|
|
|
public getConversation(): ConversationModel | undefined {
|
|
// This needs to be an unsafe call, because this method is called during
|
|
// initial module setup. We may be in the middle of the initial fetch to
|
|
// the database.
|
|
return getConversationController().getUnsafe(this.get('conversationId'));
|
|
}
|
|
|
|
public getQuoteContact() {
|
|
const quote = this.get('quote');
|
|
if (!quote) {
|
|
return null;
|
|
}
|
|
const { author } = quote;
|
|
if (!author) {
|
|
return null;
|
|
}
|
|
|
|
return getConversationController().get(author);
|
|
}
|
|
|
|
public getSource() {
|
|
if (this.isIncoming()) {
|
|
return this.get('source');
|
|
}
|
|
|
|
return UserUtils.getOurPubKeyStrFromCache();
|
|
}
|
|
|
|
public isOutgoing() {
|
|
return this.get('type') === 'outgoing';
|
|
}
|
|
|
|
public hasErrors() {
|
|
return lodashSize(this.get('errors')) > 0;
|
|
}
|
|
|
|
/**
|
|
* Update the messageHash field of that message instance. Does not call commit()
|
|
*
|
|
* @param messageHash
|
|
*/
|
|
public async updateMessageHash(messageHash: string) {
|
|
if (!messageHash) {
|
|
window?.log?.error('Message hash not provided to update message hash');
|
|
}
|
|
this.set({
|
|
messageHash,
|
|
});
|
|
}
|
|
|
|
public async sendSyncMessageOnly(contentMessage: ContentMessage) {
|
|
const now = GetNetworkTime.getNowWithNetworkOffset();
|
|
|
|
this.set({
|
|
sent_to: [UserUtils.getOurPubKeyStrFromCache()],
|
|
sent: true,
|
|
});
|
|
|
|
await this.commit();
|
|
|
|
const content =
|
|
contentMessage instanceof ContentMessage ? contentMessage.contentProto() : contentMessage;
|
|
await this.sendSyncMessage(content, now);
|
|
}
|
|
|
|
public async sendSyncMessage(content: SignalService.Content, sentTimestamp: number) {
|
|
if (this.get('synced') || this.get('sentSync')) {
|
|
return;
|
|
}
|
|
const { dataMessage } = content;
|
|
|
|
if (
|
|
dataMessage &&
|
|
(dataMessage.body?.length ||
|
|
dataMessage.attachments?.length ||
|
|
dataMessage.flags === SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE)
|
|
) {
|
|
const conversation = this.getConversation();
|
|
if (!conversation) {
|
|
throw new Error('Cannot trigger syncMessage with unknown convo.');
|
|
}
|
|
|
|
const expireUpdate = await DisappearingMessages.checkForExpireUpdateInContentMessage(
|
|
content,
|
|
conversation,
|
|
null
|
|
);
|
|
|
|
const syncMessage = buildSyncMessage(
|
|
this.id,
|
|
dataMessage as SignalService.DataMessage,
|
|
conversation.id,
|
|
sentTimestamp,
|
|
expireUpdate
|
|
);
|
|
|
|
if (syncMessage) {
|
|
await getMessageQueue().sendSyncMessage({
|
|
namespace: SnodeNamespaces.UserMessages,
|
|
message: syncMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
this.set({ sentSync: true });
|
|
await this.commit();
|
|
}
|
|
|
|
public async saveErrors(providedErrors: any) {
|
|
let errors = providedErrors;
|
|
|
|
if (!(errors instanceof Array)) {
|
|
errors = [errors];
|
|
}
|
|
errors.forEach((e: any) => {
|
|
window?.log?.error(
|
|
'Message.saveErrors:',
|
|
e && e.reason ? e.reason : null,
|
|
e && e.stack ? e.stack : e
|
|
);
|
|
});
|
|
errors = errors.map((e: any) => {
|
|
if (
|
|
e.constructor === Error ||
|
|
e.constructor === TypeError ||
|
|
e.constructor === ReferenceError
|
|
) {
|
|
return pick(e, 'name', 'message', 'code', 'number', 'reason');
|
|
}
|
|
return e;
|
|
});
|
|
errors = errors.concat(this.get('errors') || []);
|
|
|
|
this.set({ errors });
|
|
await this.commit();
|
|
}
|
|
|
|
public async commit(triggerUIUpdate = true) {
|
|
if (!this.id) {
|
|
throw new Error('A message always needs an id');
|
|
}
|
|
|
|
perfStart(`messageCommit-${this.id}`);
|
|
// because the saving to db calls _cleanData which mutates the field for cleaning, we need to save a copy
|
|
const id = await Data.saveMessage(cloneDeep(this.attributes));
|
|
if (triggerUIUpdate) {
|
|
this.dispatchMessageUpdate();
|
|
}
|
|
perfEnd(`messageCommit-${this.id}`, 'messageCommit');
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Mark a message as read if it was not already read.
|
|
* @param readAt the timestamp at which this message was read
|
|
* @returns true if the message was marked as read, and if its expiry should be updated on the swarm, false otherwise
|
|
*/
|
|
public markMessageReadNoCommit(readAt: number): boolean {
|
|
if (!this.isUnread()) {
|
|
return false;
|
|
}
|
|
|
|
this.set({ unread: READ_MESSAGE_STATE.read });
|
|
|
|
const convo = this.getConversation();
|
|
const canBeDeleteAfterRead = convo && !convo.isMe() && convo.isPrivate();
|
|
const expirationType = this.getExpirationType();
|
|
const expireTimer = this.getExpireTimerSeconds();
|
|
|
|
if (canBeDeleteAfterRead && expirationType && expireTimer > 0) {
|
|
const expirationMode = DisappearingMessages.changeToDisappearingConversationMode(
|
|
convo,
|
|
expirationType,
|
|
expireTimer
|
|
);
|
|
|
|
if (expirationMode === 'legacy' || expirationMode === 'deleteAfterRead') {
|
|
if (this.isIncoming() && !this.isExpiring()) {
|
|
// only if that message has not started to expire already, set its "start expiry".
|
|
// this is because a message can have a expire start timestamp set when receiving it, if the convo volatile said that the message was read by another device.
|
|
if (!this.getExpirationStartTimestamp()) {
|
|
this.set({
|
|
expirationStartTimestamp: DisappearingMessages.setExpirationStartTimestamp(
|
|
expirationMode,
|
|
readAt,
|
|
'markMessageReadNoCommit',
|
|
this.get('id')
|
|
),
|
|
});
|
|
// return true, we want to update/refresh the real expiry of this message from the swarm
|
|
return true;
|
|
}
|
|
// return true, we want to update/refresh the real expiry of this message from the swarm
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
Notifications.clearByMessageId(this.id);
|
|
return false;
|
|
}
|
|
|
|
public isExpiring() {
|
|
return this.getExpireTimerSeconds() && this.getExpirationStartTimestamp();
|
|
}
|
|
|
|
public isExpired() {
|
|
if (!this.isExpiring()) {
|
|
return false;
|
|
}
|
|
const now = Date.now();
|
|
const start = this.getExpirationStartTimestamp();
|
|
if (!start) {
|
|
return false;
|
|
}
|
|
const delta = this.getExpireTimerSeconds() * 1000;
|
|
const msFromNow = start + delta - now;
|
|
return msFromNow < 0;
|
|
}
|
|
|
|
public async setToExpire() {
|
|
if (this.isExpiring() && !this.getExpiresAt()) {
|
|
const start = this.getExpirationStartTimestamp();
|
|
const delta = this.getExpireTimerSeconds() * 1000;
|
|
if (!start) {
|
|
return;
|
|
}
|
|
|
|
// NOTE we use the locally calculated TTL here until we get the server TTL response
|
|
const expiresAt = start + delta;
|
|
|
|
this.set({
|
|
expires_at: expiresAt,
|
|
});
|
|
|
|
if (this.get('id')) {
|
|
await this.commit();
|
|
}
|
|
|
|
window?.log?.debug('Set message expiration', {
|
|
expiresAt,
|
|
sentAt: this.get('sent_at'),
|
|
});
|
|
}
|
|
}
|
|
|
|
public isTrustedForAttachmentDownload() {
|
|
try {
|
|
const senderConvoId = this.getSource();
|
|
const isClosedGroup = this.getConversation()?.isClosedGroup() || false;
|
|
const isOpengroup = this.getConversation()?.isOpenGroupV2() || false;
|
|
if (isOpengroup || isClosedGroup || isUsFromCache(senderConvoId)) {
|
|
return true;
|
|
}
|
|
// check the convo from this user
|
|
// we want the convo of the sender of this message
|
|
const senderConvo = getConversationController().get(senderConvoId);
|
|
if (!senderConvo) {
|
|
return false;
|
|
}
|
|
return senderConvo.get('isTrustedForAttachmentDownload') || false;
|
|
} catch (e) {
|
|
window.log.warn('isTrustedForAttachmentDownload: error; ', e.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private dispatchMessageUpdate() {
|
|
updatesToDispatch.set(this.id, this.getMessageModelProps());
|
|
throttledAllMessagesDispatch();
|
|
}
|
|
|
|
private isGroupUpdate() {
|
|
return !isEmpty(this.get('group_update'));
|
|
}
|
|
|
|
/**
|
|
* Before, group_update attributes could be just the string 'You' and not an array.
|
|
* Using this method to get the group update makes sure than the joined, kicked, or left are always an array of string, or undefined
|
|
*/
|
|
private getGroupUpdateAsArray() {
|
|
const groupUpdate = this.get('group_update');
|
|
if (!groupUpdate || isEmpty(groupUpdate)) {
|
|
return undefined;
|
|
}
|
|
|
|
const left: Array<string> | undefined = Array.isArray(groupUpdate.left)
|
|
? groupUpdate.left
|
|
: groupUpdate.left
|
|
? [groupUpdate.left]
|
|
: undefined;
|
|
const kicked: Array<string> | undefined = Array.isArray(groupUpdate.kicked)
|
|
? groupUpdate.kicked
|
|
: groupUpdate.kicked
|
|
? [groupUpdate.kicked]
|
|
: undefined;
|
|
const joined: Array<string> | undefined = Array.isArray(groupUpdate.joined)
|
|
? groupUpdate.joined
|
|
: groupUpdate.joined
|
|
? [groupUpdate.joined]
|
|
: undefined;
|
|
|
|
const forcedArrayUpdate: MessageGroupUpdate = {};
|
|
|
|
if (left) {
|
|
forcedArrayUpdate.left = left;
|
|
}
|
|
if (joined) {
|
|
forcedArrayUpdate.joined = joined;
|
|
}
|
|
if (kicked) {
|
|
forcedArrayUpdate.kicked = kicked;
|
|
}
|
|
if (groupUpdate.name) {
|
|
forcedArrayUpdate.name = groupUpdate.name;
|
|
}
|
|
return forcedArrayUpdate;
|
|
}
|
|
|
|
// NOTE We want to replace Backbone .get() calls with these getters as we migrate to Redux completely eventually
|
|
// #region Start of getters
|
|
public getExpirationType() {
|
|
return this.get('expirationType');
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns the expireTimer (in seconds) for this message
|
|
*/
|
|
public getExpireTimerSeconds() {
|
|
return this.get('expireTimer');
|
|
}
|
|
|
|
public getExpirationStartTimestamp() {
|
|
return this.get('expirationStartTimestamp');
|
|
}
|
|
|
|
public getExpiresAt() {
|
|
return this.get('expires_at');
|
|
}
|
|
|
|
public getMessageHash() {
|
|
return this.get('messageHash');
|
|
}
|
|
|
|
public getExpirationTimerUpdate() {
|
|
return this.get('expirationTimerUpdate');
|
|
}
|
|
|
|
// #endregion
|
|
}
|
|
|
|
const throttledAllMessagesDispatch = debounce(
|
|
() => {
|
|
if (updatesToDispatch.size === 0) {
|
|
return;
|
|
}
|
|
window.inboxStore?.dispatch(messagesChanged([...updatesToDispatch.values()]));
|
|
updatesToDispatch.clear();
|
|
},
|
|
500,
|
|
{ trailing: true, leading: true, maxWait: 1000 }
|
|
);
|
|
|
|
const updatesToDispatch: Map<string, MessageModelPropsWithoutConvoProps> = new Map();
|
|
|
|
export class MessageCollection extends Backbone.Collection<MessageModel> {}
|
|
|
|
MessageCollection.prototype.model = MessageModel;
|
|
|
|
export function findAndFormatContact(pubkey: string): FindAndFormatContactType {
|
|
const contactModel = getConversationController().get(pubkey);
|
|
let profileName: string | null = null;
|
|
let isMe = false;
|
|
|
|
if (
|
|
pubkey === UserUtils.getOurPubKeyStrFromCache() ||
|
|
(pubkey && PubKey.isBlinded(pubkey) && isUsAnySogsFromCache(pubkey))
|
|
) {
|
|
profileName = window.i18n('you');
|
|
isMe = true;
|
|
} else {
|
|
profileName = contactModel?.getNicknameOrRealUsername() || null;
|
|
}
|
|
|
|
return {
|
|
pubkey,
|
|
avatarPath: contactModel ? contactModel.getAvatarPath() : null,
|
|
name: contactModel?.getRealSessionUsername() || null,
|
|
profileName,
|
|
isMe,
|
|
};
|
|
}
|
|
|
|
export function processQuoteAttachment(attachment: any) {
|
|
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: isVoiceMessage(attachment),
|
|
thumbnail: thumbnailWithObjectUrl,
|
|
};
|
|
}
|