session-desktop/ts/models/conversation.ts

2134 lines
66 KiB
TypeScript

import Backbone from 'backbone';
import {
debounce,
defaults,
filter,
includes,
isArray,
isEmpty,
isEqual,
isNumber,
isString,
map,
sortBy,
throttle,
uniq,
} from 'lodash';
import { getMessageQueue } from '../session';
import { getConversationController } from '../session/conversations';
import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage';
import { PubKey } from '../session/types';
import { ToastUtils, UserUtils } from '../session/utils';
import { BlockedNumberController } from '../util';
import { leaveClosedGroup } from '../session/group/closed-group';
import { SignalService } from '../protobuf';
import { MessageModel, sliceQuoteText } from './message';
import { MessageAttributesOptionals, MessageDirection } from './messageType';
import autoBind from 'auto-bind';
import { Data } from '../../ts/data/data';
import { toHex } from '../session/utils/String';
import {
actions as conversationActions,
conversationChanged,
conversationsChanged,
MessageModelPropsWithoutConvoProps,
ReduxConversationType,
} from '../state/ducks/conversations';
import { ExpirationTimerUpdateMessage } from '../session/messages/outgoing/controlMessage/ExpirationTimerUpdateMessage';
import { TypingMessage } from '../session/messages/outgoing/controlMessage/TypingMessage';
import {
VisibleMessage,
VisibleMessageParams,
} from '../session/messages/outgoing/visibleMessage/VisibleMessage';
import { GroupInvitationMessage } from '../session/messages/outgoing/visibleMessage/GroupInvitationMessage';
import { ReadReceiptMessage } from '../session/messages/outgoing/controlMessage/receipt/ReadReceiptMessage';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { OpenGroupRequestCommonType } from '../session/apis/open_group_api/opengroupV2/ApiUtil';
import { getOpenGroupV2FromConversationId } from '../session/apis/open_group_api/utils/OpenGroupUtils';
import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout';
import { perfEnd, perfStart } from '../session/utils/Performance';
import { ed25519Str } from '../session/onions/onionPath';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
import { IMAGE_JPEG } from '../types/MIME';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils';
import { getNowWithNetworkOffset } from '../session/apis/snode_api/SNodeAPI';
import { createLastMessageUpdate } from '../types/Conversation';
import {
ReplyingToMessageProps,
SendMessageType,
} from '../components/conversation/composition/CompositionBox';
import { SettingsKey } from '../data/settings-key';
import {
deleteExternalFilesOfConversation,
getAbsoluteAttachmentPath,
loadAttachmentData,
} from '../types/MessageAttachment';
import { getOurProfile } from '../session/utils/User';
import {
MessageRequestResponse,
MessageRequestResponseParams,
} from '../session/messages/outgoing/controlMessage/MessageRequestResponse';
import { Notifications } from '../util/notifications';
import { Storage } from '../util/storage';
import {
ConversationAttributes,
ConversationNotificationSetting,
ConversationTypeEnum,
fillConvoAttributesWithDefaults,
} from './conversationAttributes';
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
import { from_hex } from 'libsodium-wrappers-sumo';
import { OpenGroupData } from '../data/opengroups';
import {
roomHasBlindEnabled,
roomHasReactionsEnabled,
} from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
import { addMessagePadding } from '../session/crypto/BufferPadding';
import { getSodiumRenderer } from '../session/crypto';
import {
findCachedOurBlindedPubkeyOrLookItUp,
isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
import { Reaction } from '../types/Reaction';
import { Reactions } from '../util/reactions';
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any;
public throttledBumpTyping: () => void;
public throttledNotify: (message: MessageModel) => void;
public markRead: (newestUnreadDate: number, providedOptions?: any) => void;
public initialPromise: any;
private typingRefreshTimer?: NodeJS.Timeout | null;
private typingPauseTimer?: NodeJS.Timeout | null;
private typingTimer?: NodeJS.Timeout | null;
private lastReadTimestamp: number;
private pending?: Promise<any>;
constructor(attributes: ConversationAttributes) {
super(fillConvoAttributesWithDefaults(attributes));
// This may be overridden by getConversationController().getOrCreate, and signify
// our first save to the database. Or first fetch from the database.
this.initialPromise = Promise.resolve();
autoBind(this);
this.throttledBumpTyping = throttle(this.bumpTyping, 300);
this.updateLastMessage = throttle(this.bouncyUpdateLastMessage.bind(this), 1000, {
trailing: true,
leading: true,
});
this.throttledNotify = debounce(this.notify, 2000, { maxWait: 2000, trailing: true });
//start right away the function is called, and wait 1sec before calling it again
const markReadDebounced = debounce(this.markReadBouncy, 1000, {
leading: true,
trailing: true,
});
this.markRead = (newestUnreadDate: number) => {
const lastReadTimestamp = this.lastReadTimestamp;
if (newestUnreadDate > lastReadTimestamp) {
this.lastReadTimestamp = newestUnreadDate;
}
if (newestUnreadDate !== lastReadTimestamp) {
void markReadDebounced(newestUnreadDate);
}
};
// Listening for out-of-band data updates
this.typingRefreshTimer = null;
this.typingPauseTimer = null;
this.lastReadTimestamp = 0;
window.inboxStore?.dispatch(
conversationChanged({ id: this.id, data: this.getConversationModelProps() })
);
}
/**
* Method to evaluate if a convo contains the right values
* @param values Required properties to evaluate if this is a message request
*/
public static hasValidIncomingRequestValues({
isMe,
isApproved,
isBlocked,
isPrivate,
activeAt,
}: {
isMe?: boolean;
isApproved?: boolean;
isBlocked?: boolean;
isPrivate?: boolean;
activeAt?: number;
}): boolean {
// if a convo is not active, it means we didn't get any messages nor sent any.
const isActive = activeAt && isFinite(activeAt) && activeAt > 0;
return Boolean(isPrivate && !isMe && !isApproved && !isBlocked && isActive);
}
public static hasValidOutgoingRequestValues({
isMe,
didApproveMe,
isApproved,
isBlocked,
isPrivate,
}: {
isMe?: boolean;
isApproved?: boolean;
didApproveMe?: boolean;
isBlocked?: boolean;
isPrivate?: boolean;
}): boolean {
return Boolean(!isMe && isApproved && isPrivate && !isBlocked && !didApproveMe);
}
public idForLogging() {
if (this.isPrivate()) {
return this.id;
}
if (this.isPublic()) {
const opengroup = this.toOpenGroupV2();
return `${opengroup.serverUrl}/${opengroup.roomId}`;
}
return `group(${ed25519Str(this.id)})`;
}
public isMe() {
return UserUtils.isUsFromCache(this.id);
}
public isPublic(): boolean {
return Boolean(this.id && this.id.match(OpenGroupUtils.openGroupPrefixRegex));
}
public isOpenGroupV2(): boolean {
return OpenGroupUtils.isOpenGroupV2(this.id);
}
public isClosedGroup() {
return this.get('type') === ConversationTypeEnum.GROUP && !this.isPublic();
}
public isBlocked() {
if (!this.id || this.isMe()) {
return false;
}
if (this.isClosedGroup()) {
return BlockedNumberController.isGroupBlocked(this.id);
}
if (this.isPrivate()) {
return BlockedNumberController.isBlocked(this.id);
}
return false;
}
public isMediumGroup() {
return this.get('is_medium_group');
}
/**
* Returns true if this conversation is active
* i.e. the conversation is visibie on the left pane. (Either we or another user created this convo).
* This is useful because we do not want bumpTyping on the first message typing to a new convo to
* send a message.
*/
public isActive() {
return Boolean(this.get('active_at'));
}
public async cleanup() {
await deleteExternalFilesOfConversation(this.attributes);
}
public async onExpired(_message: MessageModel) {
await this.updateLastMessage();
// removeMessage();
}
public getGroupAdmins(): Array<string> {
const groupAdmins = this.get('groupAdmins');
return groupAdmins && groupAdmins?.length > 0 ? groupAdmins : [];
}
/**
* Get the list of moderators in that room, or an empty array
* Only to be called for opengroup conversations.
* This makes no sense for a private chat or an closed group, as closed group admins must be stored with getGroupAdmins
* @returns the list of moderators for the conversation if the conversation is public, or []
*/
public getGroupModerators(): Array<string> {
const groupModerators = this.get('groupModerators') as Array<string> | undefined;
return this.isPublic() && groupModerators && groupModerators?.length > 0 ? groupModerators : [];
}
// tslint:disable-next-line: cyclomatic-complexity max-func-body-length
public getConversationModelProps(): ReduxConversationType {
const groupAdmins = this.getGroupAdmins();
const groupModerators = this.getGroupModerators();
// tslint:disable-next-line: cyclomatic-complexity
const isPublic = this.isPublic();
const members = this.isGroup() && !isPublic ? this.get('members') : [];
const zombies = this.isGroup() && !isPublic ? this.get('zombies') : [];
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
const avatarPath = this.getAvatarPath();
const isPrivate = this.isPrivate();
const isGroup = !isPrivate;
const weAreAdmin = this.isAdmin(ourNumber);
const weAreModerator = this.isModerator(ourNumber); // only used for sogs
const isMe = this.isMe();
const isTyping = !!this.typingTimer;
const unreadCount = this.get('unreadCount') || undefined;
const mentionedUs = this.get('mentionedUs') || undefined;
const isBlocked = this.isBlocked();
const subscriberCount = this.get('subscriberCount');
const isPinned = this.isPinned();
const isApproved = this.isApproved();
const didApproveMe = this.didApproveMe();
const hasNickname = !!this.getNickname();
const isKickedFromGroup = !!this.get('isKickedFromGroup');
const left = !!this.get('left');
const expireTimer = this.get('expireTimer');
const currentNotificationSetting = this.get('triggerNotificationsFor');
const displayNameInProfile = this.get('displayNameInProfile');
const nickname = this.get('nickname');
// To reduce the redux store size, only set fields which cannot be undefined.
// For instance, a boolean can usually be not set if false, etc
const toRet: ReduxConversationType = {
id: this.id as string,
activeAt: this.get('active_at'),
type: isPrivate ? ConversationTypeEnum.PRIVATE : ConversationTypeEnum.GROUP,
};
if (isPrivate) {
toRet.isPrivate = true;
}
if (isGroup) {
toRet.isGroup = true;
}
if (weAreAdmin) {
toRet.weAreAdmin = true;
}
if (weAreModerator) {
toRet.weAreModerator = true;
}
if (isMe) {
toRet.isMe = true;
}
if (isPublic) {
toRet.isPublic = true;
}
if (isTyping) {
toRet.isTyping = true;
}
if (isTyping) {
toRet.isTyping = true;
}
if (avatarPath) {
toRet.avatarPath = avatarPath;
}
if (displayNameInProfile) {
toRet.displayNameInProfile = displayNameInProfile;
}
if (nickname) {
toRet.nickname = nickname;
}
if (unreadCount) {
toRet.unreadCount = unreadCount;
}
if (mentionedUs) {
toRet.mentionedUs = mentionedUs;
}
if (isBlocked) {
toRet.isBlocked = isBlocked;
}
if (hasNickname) {
toRet.hasNickname = hasNickname;
}
if (isKickedFromGroup) {
toRet.isKickedFromGroup = isKickedFromGroup;
}
if (left) {
toRet.left = left;
}
if (isPinned) {
toRet.isPinned = isPinned;
}
if (didApproveMe) {
toRet.didApproveMe = didApproveMe;
}
if (isApproved) {
toRet.isApproved = isApproved;
}
if (subscriberCount) {
toRet.subscriberCount = subscriberCount;
}
if (groupAdmins && groupAdmins.length) {
toRet.groupAdmins = uniq(groupAdmins);
}
if (groupModerators && groupModerators.length) {
toRet.groupModerators = uniq(groupModerators);
}
if (members && members.length) {
toRet.members = uniq(members);
}
if (zombies && zombies.length) {
toRet.zombies = uniq(zombies);
}
if (expireTimer) {
toRet.expireTimer = expireTimer;
}
if (
currentNotificationSetting &&
currentNotificationSetting !== ConversationNotificationSetting[0]
) {
toRet.currentNotificationSetting = currentNotificationSetting;
}
const lastMessageText = this.get('lastMessage');
if (lastMessageText && lastMessageText.length) {
const lastMessageStatus = this.get('lastMessageStatus');
toRet.lastMessage = {
status: lastMessageStatus,
text: lastMessageText,
};
}
return toRet;
}
public async updateGroupAdmins(groupAdmins: Array<string>, shouldCommit: boolean) {
const existingAdmins = uniq(sortBy(this.getGroupAdmins()));
const newAdmins = uniq(sortBy(groupAdmins));
if (isEqual(existingAdmins, newAdmins)) {
return false;
}
this.set({ groupAdmins });
if (shouldCommit) {
await this.commit();
}
return true;
}
public async updateGroupModerators(groupModerators: Array<string>, shouldCommit: boolean) {
if (!this.isPublic()) {
throw new Error('group moderators are only possible on SOGS');
}
const existingModerators = uniq(sortBy(this.getGroupModerators()));
const newModerators = uniq(sortBy(groupModerators));
if (isEqual(existingModerators, newModerators)) {
return false;
}
this.set({ groupModerators: newModerators });
if (shouldCommit) {
await this.commit();
}
return true;
}
public async onReadMessage(message: MessageModel, readAt: number) {
// We mark as read everything older than this message - to clean up old stuff
// still marked unread in the database. If the user generally doesn't read in
// the desktop app, so the desktop app only gets read syncs, we can very
// easily end up with messages never marked as read (our previous early read
// sync handling, read syncs never sent because app was offline)
// We queue it because we often get a whole lot of read syncs at once, and
// their markRead calls could very easily overlap given the async pull from DB.
// Lastly, we don't send read syncs for any message marked read due to a read
// sync. That's a notification explosion we don't need.
return this.queueJob(() =>
this.markReadBouncy(message.get('received_at') as any, {
sendReadReceipts: false,
readAt,
})
);
}
public async getUnreadCount() {
const unreadCount = await Data.getUnreadCountByConversation(this.id);
return unreadCount;
}
public async queueJob(callback: () => Promise<void>) {
// tslint:disable-next-line: no-promise-as-boolean
const previous = this.pending || Promise.resolve();
const taskWithTimeout = createTaskWithTimeout(callback, `conversation ${this.idForLogging()}`);
this.pending = previous.then(taskWithTimeout, taskWithTimeout);
const current = this.pending;
void current.then(() => {
if (this.pending === current) {
delete this.pending;
}
});
return current;
}
public async makeQuote(quotedMessage: MessageModel): Promise<ReplyingToMessageProps | null> {
const attachments = quotedMessage.get('attachments');
const preview = quotedMessage.get('preview');
const body = quotedMessage.get('body');
const quotedAttachments = await this.getQuoteAttachment(attachments, preview);
if (!quotedMessage.get('sent_at')) {
window.log.warn('tried to make a quote without a sent_at timestamp');
return null;
}
let msgSource = quotedMessage.getSource();
if (this.isPublic()) {
const room = OpenGroupData.getV2OpenGroupRoom(this.id);
if (room && roomHasBlindEnabled(room) && msgSource === UserUtils.getOurPubKeyStrFromCache()) {
// this room should send message with blinded pubkey, so we need to make the quote with them too.
// when we make a quote to ourself on a blind sogs, that message has a sender being our naked pubkey
const sodium = await getSodiumRenderer();
msgSource = await findCachedOurBlindedPubkeyOrLookItUp(room.serverPublicKey, sodium);
}
}
return {
author: msgSource,
id: `${quotedMessage.get('sent_at')}` || '',
// no need to quote the full message length.
text: sliceQuoteText(body),
attachments: quotedAttachments,
timestamp: quotedMessage.get('sent_at') || 0,
convoId: this.id,
};
}
public toOpenGroupV2(): OpenGroupRequestCommonType {
if (!this.isOpenGroupV2()) {
throw new Error('tried to run toOpenGroup for not public group v2');
}
return getOpenGroupV2FromConversationId(this.id);
}
public async sendMessageJob(message: MessageModel, expireTimer: number | undefined) {
try {
const { body, attachments, preview, quote, fileIdsToLink } = await message.uploadData();
const { id } = message;
const destination = this.id;
const sentAt = message.get('sent_at');
if (!sentAt) {
throw new Error('sendMessageJob() sent_at must be set.');
}
if (this.isPublic() && !this.isOpenGroupV2()) {
throw new Error('Only opengroupv2 are supported now');
}
// an OpenGroupV2 message is just a visible message
const chatMessageParams: VisibleMessageParams = {
body,
identifier: id,
timestamp: sentAt,
attachments,
expireTimer,
preview: preview ? [preview] : [],
quote,
lokiProfile: UserUtils.getOurProfile(),
};
const shouldApprove = !this.isApproved() && this.isPrivate();
const incomingMessageCount = await Data.getMessageCountByType(
this.id,
MessageDirection.incoming
);
const hasIncomingMessages = incomingMessageCount > 0;
if (this.id.startsWith('15')) {
window.log.info('Sending a blinded message to this user: ', this.id);
await this.sendBlindedMessageRequest(chatMessageParams);
return;
}
if (shouldApprove) {
await this.setIsApproved(true);
if (hasIncomingMessages) {
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
await this.addOutgoingApprovalMessage(Date.now());
if (!this.didApproveMe()) {
await this.setDidApproveMe(true);
}
// should only send once
await this.sendMessageRequestResponse();
void forceSyncConfigurationNowIfNeeded();
}
}
if (this.isOpenGroupV2()) {
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
const roomInfos = this.toOpenGroupV2();
if (!roomInfos) {
throw new Error('Could not find this room in db');
}
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
// send with blinding if we need to
await getMessageQueue().sendToOpenGroupV2(
chatMessageOpenGroupV2,
roomInfos,
Boolean(roomHasBlindEnabled(openGroup)),
fileIdsToLink
);
return;
}
const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) {
if (this.isMe()) {
chatMessageParams.syncTarget = this.id;
const chatMessageMe = new VisibleMessage(chatMessageParams);
await getMessageQueue().sendSyncMessage(chatMessageMe);
return;
}
if (message.get('groupInvitation')) {
const groupInvitation = message.get('groupInvitation');
const groupInvitMessage = new GroupInvitationMessage({
identifier: id,
timestamp: sentAt,
name: groupInvitation.name,
url: groupInvitation.url,
expireTimer: this.get('expireTimer'),
});
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToPubKey(destinationPubkey, groupInvitMessage);
return;
}
const chatMessagePrivate = new VisibleMessage(chatMessageParams);
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
return;
}
if (this.isMediumGroup()) {
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
chatMessage: chatMessageMediumGroup,
groupId: destination,
});
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
return;
}
if (this.isClosedGroup()) {
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
}
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
} catch (e) {
await message.saveErrors(e);
return null;
}
}
public async sendReactionJob(sourceMessage: MessageModel, reaction: Reaction) {
try {
const destination = this.id;
const sentAt = sourceMessage.get('sent_at');
if (!sentAt) {
throw new Error('sendReactMessageJob() sent_at must be set.');
}
if (this.isPublic() && !this.isOpenGroupV2()) {
throw new Error('Only opengroupv2 are supported now');
}
// an OpenGroupV2 message is just a visible message
const chatMessageParams: VisibleMessageParams = {
body: '',
timestamp: sentAt,
reaction,
lokiProfile: UserUtils.getOurProfile(),
};
const shouldApprove = !this.isApproved() && this.isPrivate();
const incomingMessageCount = await Data.getMessageCountByType(
this.id,
MessageDirection.incoming
);
const hasIncomingMessages = incomingMessageCount > 0;
if (this.id.startsWith('15')) {
window.log.info('Sending a blinded message to this user: ', this.id);
await this.sendBlindedMessageRequest(chatMessageParams);
return;
}
if (shouldApprove) {
await this.setIsApproved(true);
if (hasIncomingMessages) {
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
await this.addOutgoingApprovalMessage(Date.now());
if (!this.didApproveMe()) {
await this.setDidApproveMe(true);
}
// should only send once
await this.sendMessageRequestResponse();
void forceSyncConfigurationNowIfNeeded();
}
}
if (this.isOpenGroupV2()) {
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
const roomInfos = this.toOpenGroupV2();
if (!roomInfos) {
throw new Error('Could not find this room in db');
}
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
const blinded = Boolean(roomHasBlindEnabled(openGroup));
// send with blinding if we need to
await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos, blinded, []);
return;
}
const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) {
const chatMessageMe = new VisibleMessage({
...chatMessageParams,
syncTarget: this.id,
});
await getMessageQueue().sendSyncMessage(chatMessageMe);
const chatMessagePrivate = new VisibleMessage(chatMessageParams);
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
await Reactions.handleMessageReaction({
reaction,
sender: UserUtils.getOurPubKeyStrFromCache(),
you: true,
isOpenGroup: false,
});
return;
}
if (this.isMediumGroup()) {
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
chatMessage: chatMessageMediumGroup,
groupId: destination,
});
// we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
await Reactions.handleMessageReaction({
reaction,
sender: UserUtils.getOurPubKeyStrFromCache(),
you: true,
isOpenGroup: false,
});
return;
}
if (this.isClosedGroup()) {
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
}
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
} catch (e) {
window.log.error(`Reaction job failed id:${reaction.id} error:`, e);
return null;
}
}
/**
* Does this conversation contain the properties to be considered a message request
*/
public isIncomingRequest(): boolean {
return ConversationModel.hasValidIncomingRequestValues({
isMe: this.isMe(),
isApproved: this.isApproved(),
isBlocked: this.isBlocked(),
isPrivate: this.isPrivate(),
});
}
/**
* Is this conversation an outgoing message request
*/
public isOutgoingRequest(): boolean {
return ConversationModel.hasValidOutgoingRequestValues({
isMe: this.isMe(),
isApproved: this.isApproved(),
didApproveMe: this.didApproveMe(),
isBlocked: this.isBlocked(),
isPrivate: this.isPrivate(),
});
}
/**
* When you have accepted another users message request
* @param timestamp for determining the order for this message to appear like a regular message
*/
public async addOutgoingApprovalMessage(timestamp: number) {
await this.addSingleOutgoingMessage({
sent_at: timestamp,
messageRequestResponse: {
isApproved: 1,
},
expireTimer: 0,
});
this.updateLastMessage();
}
/**
* When the other user has accepted your message request
* @param timestamp For determining message order in conversation
* @param source For determining the conversation name used in the message.
*/
public async addIncomingApprovalMessage(timestamp: number, source: string) {
await this.addSingleIncomingMessage({
sent_at: timestamp, // TODO: maybe add timestamp to messageRequestResponse? confirm it doesn't exist first
source,
messageRequestResponse: {
isApproved: 1,
},
unread: 1, // 1 means unread
expireTimer: 0,
});
this.updateLastMessage();
}
public async sendBlindedMessageRequest(messageParams: VisibleMessageParams) {
const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes();
const groupUrl = this.getSogsOriginMessage();
if (!PubKey.hasBlindedPrefix(this.id)) {
window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one');
return;
}
if (!messageParams.body) {
window?.log?.warn('sendBlindedMessageRequest - needs a body');
return;
}
// include our profile (displayName + avatar url + key for the recipient)
messageParams.lokiProfile = getOurProfile();
if (!ourSignKeyBytes || !groupUrl) {
window?.log?.error(
'sendBlindedMessageRequest - Cannot get required information for encrypting blinded message.'
);
return;
}
const roomInfo = OpenGroupData.getV2OpenGroupRoom(groupUrl);
if (!roomInfo || !roomInfo.serverPublicKey) {
ToastUtils.pushToastError('no-sogs-matching', window.i18n('couldntFindServerMatching'));
window?.log?.error('Could not find room with matching server url', groupUrl);
throw new Error(`Could not find room with matching server url: ${groupUrl}`);
}
const sogsVisibleMessage = new OpenGroupVisibleMessage(messageParams);
const paddedBody = addMessagePadding(sogsVisibleMessage.plainTextBuffer());
const serverPubKey = roomInfo.serverPublicKey;
const encryptedMsg = await SogsBlinding.encryptBlindedMessage({
rawData: paddedBody,
senderSigningKey: ourSignKeyBytes,
serverPubKey: from_hex(serverPubKey),
recipientBlindedPublicKey: from_hex(this.id.slice(2)),
});
if (!encryptedMsg) {
throw new Error('encryptBlindedMessage failed');
}
if (!messageParams.identifier) {
throw new Error('encryptBlindedMessage messageParams needs an identifier');
}
this.set({ active_at: Date.now(), isApproved: true });
await getMessageQueue().sendToOpenGroupV2BlindedRequest(
encryptedMsg,
roomInfo,
sogsVisibleMessage,
this.id
);
}
/**
* Sends an accepted message request response.
* Currently, we never send anything for denied message requests.
*/
public async sendMessageRequestResponse() {
if (!this.isPrivate()) {
return;
}
const timestamp = Date.now();
const messageRequestResponseParams: MessageRequestResponseParams = {
timestamp,
lokiProfile: UserUtils.getOurProfile(),
};
const messageRequestResponse = new MessageRequestResponse(messageRequestResponseParams);
const pubkeyForSending = new PubKey(this.id);
await getMessageQueue()
.sendToPubKey(pubkeyForSending, messageRequestResponse)
.catch(window?.log?.error);
}
public async sendMessage(msg: SendMessageType) {
const { attachments, body, groupInvitation, preview, quote } = msg;
this.clearTypingTimers();
const expireTimer = this.get('expireTimer');
const networkTimestamp = getNowWithNetworkOffset();
window?.log?.info(
'Sending message to conversation',
this.idForLogging(),
'with networkTimestamp: ',
networkTimestamp
);
const messageModel = await this.addSingleOutgoingMessage({
body,
quote: isEmpty(quote) ? undefined : quote,
preview,
attachments,
sent_at: networkTimestamp,
expireTimer,
serverTimestamp: this.isPublic() ? networkTimestamp : undefined,
groupInvitation,
});
// We're offline!
if (!window.isOnline) {
const error = new Error('Network is not available');
error.name = 'SendMessageNetworkError';
(error as any).number = this.id;
await messageModel.saveErrors([error]);
await this.commit();
return;
}
this.set({
lastMessage: messageModel.getNotificationText(),
lastMessageStatus: 'sending',
active_at: networkTimestamp,
});
await this.commit();
void this.queueJob(async () => {
await this.sendMessageJob(messageModel, expireTimer);
});
}
public async sendReaction(sourceId: string, reaction: Reaction) {
const sourceMessage = await Data.getMessageById(sourceId);
if (!sourceMessage) {
return;
}
void this.queueJob(async () => {
await this.sendReactionJob(sourceMessage, reaction);
});
}
public async bouncyUpdateLastMessage() {
if (!this.id || !this.get('active_at')) {
return;
}
const messages = await Data.getLastMessagesByConversation(this.id, 1, true);
if (!messages || !messages.length) {
return;
}
const lastMessageModel = messages.at(0);
const lastMessageStatusModel = lastMessageModel
? lastMessageModel.getMessagePropStatus()
: undefined;
const lastMessageUpdate = createLastMessageUpdate({
lastMessageStatus: lastMessageStatusModel,
lastMessageNotificationText: lastMessageModel
? lastMessageModel.getNotificationText()
: undefined,
});
if (
lastMessageUpdate.lastMessage !== this.get('lastMessage') ||
lastMessageUpdate.lastMessageStatus !== this.get('lastMessageStatus')
) {
const lastMessageAttribute = this.get('lastMessage');
if (
lastMessageUpdate.lastMessageStatus === this.get('lastMessageStatus') &&
lastMessageUpdate.lastMessage &&
lastMessageUpdate.lastMessage.length > 40 &&
lastMessageAttribute &&
lastMessageAttribute.length > 40 &&
lastMessageUpdate.lastMessage.startsWith(lastMessageAttribute)
) {
// if status is the same, and text has a long length which starts with the db status, do not trigger an update.
// we only store the first 60 chars in the db for the lastMessage attributes (see sql.ts)
return;
}
this.set(lastMessageUpdate);
await this.commit();
}
}
public async updateExpireTimer(
providedExpireTimer: number | null,
providedSource?: string,
receivedAt?: number, // is set if it comes from outside
options: {
fromSync?: boolean;
} = {},
shouldCommit = true
): Promise<void> {
let expireTimer = providedExpireTimer;
let source = providedSource;
defaults(options, { fromSync: false });
if (!expireTimer) {
expireTimer = 0;
}
if (this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer'))) {
return;
}
window?.log?.info("Update conversation 'expireTimer'", {
id: this.idForLogging(),
expireTimer,
source,
});
const isOutgoing = Boolean(!receivedAt);
source = source || UserUtils.getOurPubKeyStrFromCache();
// When we add a disappearing messages notification to the conversation, we want it
// to be above the message that initiated that change, hence the subtraction.
const timestamp = (receivedAt || Date.now()) - 1;
this.set({ expireTimer });
const commonAttributes = {
flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer,
source,
fromSync: options.fromSync,
},
expireTimer: 0,
};
let message: MessageModel | undefined;
if (isOutgoing) {
message = await this.addSingleOutgoingMessage({
...commonAttributes,
sent_at: timestamp,
});
} else {
message = await this.addSingleIncomingMessage({
...commonAttributes,
// Even though this isn't reflected to the user, we want to place the last seen
// indicator above it. We set it to 'unread' to trigger that placement.
unread: 1,
source,
sent_at: timestamp,
received_at: timestamp,
});
}
if (this.isActive()) {
this.set('active_at', timestamp);
}
if (shouldCommit) {
// tell the UI this conversation was updated
await this.commit();
}
// if change was made remotely, don't send it to the number/group
if (receivedAt) {
return;
}
const expireUpdate = {
identifier: message.id,
timestamp,
expireTimer: expireTimer ? expireTimer : (null as number | null),
};
if (this.isMe()) {
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
return message.sendSyncMessageOnly(expirationTimerMessage);
}
if (this.isPrivate()) {
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
const pubkey = new PubKey(this.get('id'));
await getMessageQueue().sendToPubKey(pubkey, expirationTimerMessage);
} else {
window?.log?.warn('TODO: Expiration update for closed groups are to be updated');
const expireUpdateForGroup = {
...expireUpdate,
groupId: this.get('id'),
};
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdateForGroup);
await getMessageQueue().sendToGroup(expirationTimerMessage);
}
return;
}
public triggerUIRefresh() {
updatesToDispatch.set(this.id, this.getConversationModelProps());
throttledAllConversationsDispatch();
}
public async commit() {
perfStart(`conversationCommit-${this.attributes.id}`);
// write to DB
await Data.saveConversation(this.attributes);
this.triggerUIRefresh();
perfEnd(`conversationCommit-${this.attributes.id}`, 'conversationCommit');
}
public async addSingleOutgoingMessage(
messageAttributes: Omit<
MessageAttributesOptionals,
'conversationId' | 'source' | 'type' | 'direction' | 'received_at' | 'unread'
>
) {
let sender = UserUtils.getOurPubKeyStrFromCache();
if (this.isPublic()) {
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
if (openGroup && openGroup.serverPublicKey && roomHasBlindEnabled(openGroup)) {
const signingKeys = await UserUtils.getUserED25519KeyPairBytes();
if (!signingKeys) {
throw new Error('addSingleOutgoingMessage: getUserED25519KeyPairBytes returned nothing');
}
const sodium = await getSodiumRenderer();
const ourBlindedPubkeyForCurrentSogs = await findCachedOurBlindedPubkeyOrLookItUp(
openGroup.serverPublicKey,
sodium
);
if (ourBlindedPubkeyForCurrentSogs) {
sender = ourBlindedPubkeyForCurrentSogs;
}
}
}
return this.addSingleMessage({
...messageAttributes,
conversationId: this.id,
source: sender,
type: 'outgoing',
direction: 'outgoing',
unread: 0, // an outgoing message must be read right?
received_at: messageAttributes.sent_at, // make sure to set an received_at timestamp for an outgoing message, so the order are right.
});
}
public async addSingleIncomingMessage(
messageAttributes: Omit<MessageAttributesOptionals, 'conversationId' | 'type' | 'direction'>
) {
// if there's a message by the other user, they've replied to us which we consider an accepted convo
if (!this.didApproveMe() && this.isPrivate()) {
await this.setDidApproveMe(true);
}
return this.addSingleMessage({
...messageAttributes,
conversationId: this.id,
type: 'incoming',
direction: 'outgoing',
});
}
public async leaveClosedGroup() {
if (this.isMediumGroup()) {
await leaveClosedGroup(this.id);
} else {
window?.log?.error('Cannot leave a non-medium group conversation');
throw new Error(
'Legacy group are not supported anymore. You need to create this group again.'
);
}
}
// tslint:disable-next-line: cyclomatic-complexity
public async markReadBouncy(newestUnreadDate: number, providedOptions: any = {}) {
const lastReadTimestamp = this.lastReadTimestamp;
if (newestUnreadDate < lastReadTimestamp) {
return;
}
const options = providedOptions || {};
defaults(options, { sendReadReceipts: true });
const conversationId = this.id;
Notifications.clearByConversationID(conversationId);
let allUnreadMessagesInConvo = (await this.getUnread()).models;
const oldUnreadNowRead = allUnreadMessagesInConvo.filter(
message => (message.get('received_at') as number) <= newestUnreadDate
);
let read = [];
// Build the list of updated message models so we can mark them all as read on a single sqlite call
for (const nowRead of oldUnreadNowRead) {
nowRead.markReadNoCommit(options.readAt);
const errors = nowRead.get('errors');
read.push({
sender: nowRead.get('source'),
timestamp: nowRead.get('sent_at'),
hasErrors: Boolean(errors && errors.length),
});
}
const oldUnreadNowReadAttrs = oldUnreadNowRead.map(m => m.attributes);
if (oldUnreadNowReadAttrs?.length) {
await Data.saveMessages(oldUnreadNowReadAttrs);
}
const allProps: Array<MessageModelPropsWithoutConvoProps> = [];
for (const nowRead of oldUnreadNowRead) {
allProps.push(nowRead.getMessageModelProps());
}
if (allProps.length) {
window.inboxStore?.dispatch(conversationActions.messagesChanged(allProps));
}
// Some messages we're marking read are local notifications with no sender
read = filter(read, m => Boolean(m.sender));
const realUnreadCount = await this.getUnreadCount();
if (read.length === 0) {
const cachedUnreadCountOnConvo = this.get('unreadCount');
if (cachedUnreadCountOnConvo !== realUnreadCount) {
// reset the unreadCount on the convo to the real one coming from markRead messages on the db
this.set({ unreadCount: realUnreadCount });
await this.commit();
} else {
// window?.log?.info('markRead(): nothing newly read.');
}
return;
}
allUnreadMessagesInConvo = allUnreadMessagesInConvo.filter((m: any) => Boolean(m.isIncoming()));
this.set({ unreadCount: realUnreadCount });
const mentionRead = (() => {
const stillUnread = allUnreadMessagesInConvo.filter(
(m: any) => m.get('received_at') > newestUnreadDate
);
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
return !stillUnread.some(m => m.get('body')?.indexOf(`@${ourNumber}`) !== -1);
})();
if (mentionRead) {
this.set({ mentionedUs: false });
}
await this.commit();
// If a message has errors, we don't want to send anything out about it.
// read syncs - let's wait for a client that really understands the message
// to mark it read. we'll mark our local error read locally, though.
// read receipts - here we can run into infinite loops, where each time the
// conversation is viewed, another error message shows up for the contact
read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) {
const timestamps = map(read, 'timestamp').filter(t => !!t) as Array<number>;
await this.sendReadReceiptsIfNeeded(timestamps);
}
}
public async sendReadReceiptsIfNeeded(timestamps: Array<number>) {
if (!this.isPrivate() || !timestamps.length) {
return;
}
const settingsReadReceiptEnabled = Storage.get(SettingsKey.settingsReadReceipt) || false;
const sendReceipt =
settingsReadReceiptEnabled && !this.isBlocked() && !this.isIncomingRequest();
if (sendReceipt) {
window?.log?.info(`Sending ${timestamps.length} read receipts.`);
// we should probably stack read receipts and send them every 5 seconds for instance per conversation
const receiptMessage = new ReadReceiptMessage({
timestamp: Date.now(),
timestamps,
});
const device = new PubKey(this.id);
await getMessageQueue().sendToPubKey(device, receiptMessage);
}
}
public async setNickname(nickname: string | null) {
if (!this.isPrivate()) {
window.log.info('cannot setNickname to a non private conversation.');
return;
}
const trimmed = nickname && nickname.trim();
if (this.get('nickname') === trimmed) {
return;
}
// make sure to save the lokiDisplayName as name in the db. so a search of conversation returns it.
// (we look for matches in name too)
const realUserName = this.getRealSessionUsername();
if (!trimmed || !trimmed.length) {
this.set({ nickname: undefined, displayNameInProfile: realUserName });
} else {
this.set({ nickname: trimmed, displayNameInProfile: realUserName });
}
await this.commit();
}
public async setSessionProfile(newProfile: {
displayName?: string | null;
avatarPath?: string | null;
avatarImageId?: number;
}) {
let changes = false;
const existingSessionName = this.getRealSessionUsername();
if (newProfile.displayName !== existingSessionName && newProfile.displayName) {
this.set({
displayNameInProfile: newProfile.displayName,
});
changes = true;
}
// a user cannot remove an avatar. Only change it
// if you change this behavior, double check all setSessionProfile calls (especially the one in EditProfileDialog)
if (newProfile.avatarPath) {
const originalAvatar = this.get('avatarInProfile');
if (!isEqual(originalAvatar, newProfile.avatarPath)) {
this.set({ avatarInProfile: newProfile.avatarPath });
changes = true;
}
const existingImageId = this.get('avatarImageId');
if (existingImageId !== newProfile.avatarImageId) {
this.set({ avatarImageId: newProfile.avatarImageId });
changes = true;
}
}
if (changes) {
await this.commit();
}
}
public setSessionDisplayNameNoCommit(newDisplayName?: string | null) {
const existingSessionName = this.getRealSessionUsername();
if (newDisplayName !== existingSessionName && newDisplayName) {
this.set({ displayNameInProfile: newDisplayName });
}
}
/**
* @returns `displayNameInProfile` so the real username as defined by that user/group
*/
public getRealSessionUsername(): string | undefined {
return this.get('displayNameInProfile');
}
/**
* @returns `nickname` so the nickname we forced for that user. For a group, this returns `undefined`
*/
public getNickname(): string | undefined {
return this.isPrivate() ? this.get('nickname') : undefined;
}
/**
* @returns `getNickname` if a private convo and a nickname is set, or `getRealSessionUsername`
*/
public getNicknameOrRealUsername(): string | undefined {
return this.getNickname() || this.getRealSessionUsername();
}
/**
* @returns `getNickname` if a private convo and a nickname is set, or `getRealSessionUsername`
*
* Can also a localized 'Anonymous' for an unknown private chat and localized 'Unknown' for an unknown group (open/closed)
*/
public getNicknameOrRealUsernameOrPlaceholder(): string {
const nickOrReal = this.getNickname() || this.getRealSessionUsername();
if (nickOrReal) {
return nickOrReal;
}
if (this.isPrivate()) {
return window.i18n('anonymous');
}
return window.i18n('unknown');
}
public isAdmin(pubKey?: string) {
if (!this.isPublic() && !this.isGroup()) {
return false;
}
if (!pubKey) {
throw new Error('isAdmin() pubKey is falsy');
}
const groupAdmins = this.getGroupAdmins();
return Array.isArray(groupAdmins) && groupAdmins.includes(pubKey);
}
/**
* Check if the provided pubkey is a moderator.
* Being a moderator only makes sense for a sogs as closed groups have their admin under the groupAdmins property
*/
public isModerator(pubKey?: string) {
if (!pubKey) {
throw new Error('isModerator() pubKey is falsy');
}
if (!this.isPublic()) {
return false;
}
const groupModerators = this.getGroupModerators();
return Array.isArray(groupModerators) && groupModerators.includes(pubKey);
}
public async setIsPinned(value: boolean) {
if (value !== this.isPinned()) {
this.set({
isPinned: value,
});
await this.commit();
}
}
public async setIsApproved(value: boolean, shouldCommit: boolean = true) {
if (value !== this.isApproved()) {
window?.log?.info(`Setting ${ed25519Str(this.id)} isApproved to: ${value}`);
this.set({
isApproved: value,
});
if (shouldCommit) {
await this.commit();
}
}
}
public async setDidApproveMe(value: boolean, shouldCommit: boolean = true) {
if (value !== this.didApproveMe()) {
window?.log?.info(`Setting ${ed25519Str(this.id)} didApproveMe to: ${value}`);
this.set({
didApproveMe: value,
});
if (shouldCommit) {
await this.commit();
}
}
}
public async setOriginConversationID(conversationIdOrigin: string) {
if (conversationIdOrigin === this.get('conversationIdOrigin')) {
return;
}
this.set({
conversationIdOrigin,
});
await this.commit();
}
public async setSubscriberCount(count: number) {
if (this.get('subscriberCount') !== count) {
this.set({ subscriberCount: count });
await this.commit();
}
// Not sure if we care about updating the database
}
/**
* Saves the infos of that room directly on the conversation table.
* This does not write anything to the db if no changes are detected
*/
// tslint:disable-next-line: cyclomatic-complexity
public async setPollInfo(infos?: {
subscriberCount: number;
read: boolean;
write: boolean;
upload: boolean;
details: {
admins?: Array<string>;
image_id?: number;
name?: string;
moderators?: Array<string>;
hidden_admins?: Array<string>;
hidden_moderators?: Array<string>;
};
}) {
if (!infos || isEmpty(infos)) {
return;
}
let hasChange = false;
const { read, write, upload, subscriberCount, details } = infos;
if (
isNumber(infos.subscriberCount) &&
infos.subscriberCount !== 0 &&
this.get('subscriberCount') !== subscriberCount
) {
hasChange = true;
this.set('subscriberCount', subscriberCount);
}
if (Boolean(this.get('readCapability')) !== Boolean(read)) {
hasChange = true;
this.set('readCapability', Boolean(read));
}
if (Boolean(this.get('writeCapability')) !== Boolean(write)) {
hasChange = true;
this.set('writeCapability', Boolean(write));
}
if (Boolean(this.get('uploadCapability')) !== Boolean(upload)) {
hasChange = true;
this.set('uploadCapability', Boolean(upload));
}
const adminChanged = await this.handleModsOrAdminsChanges({
modsOrAdmins: details.admins,
hiddenModsOrAdmins: details.hidden_admins,
type: 'admins',
});
hasChange = hasChange || adminChanged;
const modsChanged = await this.handleModsOrAdminsChanges({
modsOrAdmins: details.moderators,
hiddenModsOrAdmins: details.hidden_moderators,
type: 'mods',
});
if (details.name && details.name !== this.getRealSessionUsername()) {
hasChange = hasChange || true;
this.setSessionDisplayNameNoCommit(details.name);
}
hasChange = hasChange || modsChanged;
if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) {
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
if (roomInfos) {
void sogsV3FetchPreviewAndSaveIt({ ...roomInfos, imageID: `${details.image_id}` });
}
}
// only trigger a write to the db if a change is detected
if (hasChange) {
await this.commit();
}
}
/**
* profileKey MUST be a hex string
* @param profileKey MUST be a hex string
*/
public async setProfileKey(profileKey?: Uint8Array, autoCommit = true) {
if (!profileKey) {
return;
}
const profileKeyHex = toHex(profileKey);
// profileKey is a string so we can compare it directly
if (this.get('profileKey') !== profileKeyHex) {
this.set({
profileKey: profileKeyHex,
});
if (autoCommit) {
await this.commit();
}
}
}
public hasMember(pubkey: string) {
return includes(this.get('members'), pubkey);
}
public hasReactions() {
// message requests should not have reactions
if (this.isPrivate() && !this.isApproved()) {
return false;
}
// older open group conversations won't have reaction support
if (this.isOpenGroupV2()) {
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
return roomHasReactionsEnabled(openGroup);
} else {
return true;
}
}
// returns true if this is a closed/medium or open group
public isGroup() {
return this.get('type') === ConversationTypeEnum.GROUP;
}
public async removeMessage(messageId: any) {
await Data.removeMessage(messageId);
this.updateLastMessage();
window.inboxStore?.dispatch(
conversationActions.messageDeleted({
conversationKey: this.id,
messageId,
})
);
}
public isPinned() {
return Boolean(this.get('isPinned'));
}
public didApproveMe() {
return Boolean(this.get('didApproveMe'));
}
public isApproved() {
return Boolean(this.get('isApproved'));
}
public getTitle() {
return this.getNicknameOrRealUsernameOrPlaceholder();
}
/**
* For a private convo, returns the loki profilename if set, or a shortened
* version of the contact pubkey.
* Throws an error if called on a group convo.
*
*/
public getContactProfileNameOrShortenedPubKey() {
if (!this.isPrivate()) {
throw new Error(
'getContactProfileNameOrShortenedPubKey() cannot be called with a non private convo.'
);
}
const pubkey = this.id;
if (UserUtils.isUsFromCache(pubkey)) {
return window.i18n('you');
}
const profileName = this.get('displayNameInProfile');
return profileName || PubKey.shorten(pubkey);
}
public isPrivate() {
return this.get('type') === ConversationTypeEnum.PRIVATE;
}
public getAvatarPath(): string | null {
const avatar = this.get('avatarInProfile');
if (isString(avatar)) {
return avatar;
}
if (avatar) {
throw new Error('avatarInProfile must be a string as we do not allow the {path: xxx} syntax');
}
return null;
}
public async getNotificationIcon() {
const avatarUrl = this.getAvatarPath();
const noIconUrl = 'images/session/session_icon_32.png';
if (!avatarUrl) {
return noIconUrl;
}
const decryptedAvatarUrl = await getDecryptedMediaUrl(avatarUrl, IMAGE_JPEG, true);
if (!decryptedAvatarUrl) {
window.log.warn('Could not decrypt avatar stored locally for getNotificationIcon..');
return noIconUrl;
}
return decryptedAvatarUrl;
}
public async notify(message: MessageModel) {
if (!message.isIncoming()) {
return;
}
const conversationId = this.id;
let friendRequestText;
if (!this.isApproved()) {
window?.log?.info('notification cancelled for unapproved convo', this.idForLogging());
const hadNoRequestsPrior =
getConversationController()
.getConversations()
.filter(conversation => {
return (
!conversation.isApproved() &&
!conversation.isBlocked() &&
conversation.isPrivate() &&
!conversation.isMe()
);
}).length === 1;
const isFirstMessageOfConvo =
(await Data.getMessagesByConversation(this.id, { messageId: null })).length === 1;
if (hadNoRequestsPrior && isFirstMessageOfConvo) {
friendRequestText = window.i18n('youHaveANewFriendRequest');
} else {
window?.log?.info(
'notification cancelled for as pending requests already exist',
this.idForLogging()
);
return;
}
}
// make sure the notifications are not muted for this convo (and not the source convo)
const convNotif = this.get('triggerNotificationsFor');
if (convNotif === 'disabled') {
window?.log?.info('notifications disabled for convo', this.idForLogging());
return;
}
if (convNotif === 'mentions_only') {
// check if the message has ourselves as mentions
const regex = new RegExp(`@${PubKey.regexForPubkeys}`, 'g');
const text = message.get('body');
const mentions = text?.match(regex) || ([] as Array<string>);
const mentionMe = mentions && mentions.some(m => isUsAnySogsFromCache(m.slice(1)));
const quotedMessageAuthor = message.get('quote')?.author;
const isReplyToOurMessage =
quotedMessageAuthor && UserUtils.isUsFromCache(quotedMessageAuthor);
if (!mentionMe && !isReplyToOurMessage) {
window?.log?.info(
'notifications disabled for non mentions or reply for convo',
conversationId
);
return;
}
}
const convo = await getConversationController().getOrCreateAndWait(
message.get('source'),
ConversationTypeEnum.PRIVATE
);
const iconUrl = await this.getNotificationIcon();
const messageJSON = message.toJSON();
const messageSentAt = messageJSON.sent_at;
const messageId = message.id;
const isExpiringMessage = this.isExpiringMessage(messageJSON);
Notifications.addNotification({
conversationId,
iconUrl,
isExpiringMessage,
message: friendRequestText ? friendRequestText : message.getNotificationText(),
messageId,
messageSentAt,
title: friendRequestText ? '' : convo.getTitle(),
});
}
public async notifyIncomingCall() {
if (!this.isPrivate()) {
window?.log?.info('notifyIncomingCall: not a private convo', this.idForLogging());
return;
}
const conversationId = this.id;
// make sure the notifications are not muted for this convo (and not the source convo)
const convNotif = this.get('triggerNotificationsFor');
if (convNotif === 'disabled') {
window?.log?.info(
'notifyIncomingCall: notifications disabled for convo',
this.idForLogging()
);
return;
}
const now = Date.now();
const iconUrl = await this.getNotificationIcon();
Notifications.addNotification({
conversationId,
iconUrl,
isExpiringMessage: false,
message: window.i18n('incomingCallFrom', [
this.getNicknameOrRealUsername() || window.i18n('anonymous'),
]),
messageSentAt: now,
title: this.getNicknameOrRealUsernameOrPlaceholder(),
});
}
public async notifyTypingNoCommit({ isTyping, sender }: { isTyping: boolean; sender: string }) {
// We don't do anything with typing messages from our other devices
if (UserUtils.isUsFromCache(sender)) {
return;
}
// typing only works for private chats for now
if (!this.isPrivate()) {
return;
}
if (this.typingTimer) {
global.clearTimeout(this.typingTimer);
this.typingTimer = null;
}
// we do not trigger a state change here, instead we rely on the caller to do the commit once it is done with the queue of messages
this.typingTimer = isTyping
? global.setTimeout(this.clearContactTypingTimer.bind(this, sender), 15 * 1000)
: null;
}
private async getUnread() {
return Data.getUnreadByConversation(this.id);
}
/**
*
* @returns The open group conversationId this conversation originated from
*/
private getSogsOriginMessage() {
return this.get('conversationIdOrigin');
}
private async addSingleMessage(messageAttributes: MessageAttributesOptionals) {
const voiceMessageFlags = messageAttributes.attachments?.[0]?.isVoiceMessage
? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE
: undefined;
const model = new MessageModel({ ...messageAttributes, flags: voiceMessageFlags });
// no need to trigger a UI update now, we trigger a messagesAdded just below
const messageId = await model.commit(false);
model.set({ id: messageId });
await model.setToExpire();
const messageModelProps = model.getMessageModelProps();
window.inboxStore?.dispatch(conversationActions.messagesChanged([messageModelProps]));
const unreadCount = await this.getUnreadCount();
this.set({ unreadCount });
this.updateLastMessage();
await this.commit();
return model;
}
private async clearContactTypingTimer(_sender: string) {
if (!!this.typingTimer) {
global.clearTimeout(this.typingTimer);
this.typingTimer = null;
// User was previously typing, but timed out or we received message. State change!
await this.commit();
}
}
private isExpiringMessage(json: any) {
if (json.type === 'incoming') {
return false;
}
const { expireTimer } = json;
return isFinite(expireTimer) && expireTimer > 0;
}
private shouldDoTyping() {
// for typing to happen, this must be a private unblocked active convo, and the settings to be on
if (
!this.isActive() ||
!Storage.get(SettingsKey.settingsTypingIndicator) ||
this.isBlocked() ||
!this.isPrivate()
) {
return false;
}
return Boolean(this.get('isApproved'));
}
private async bumpTyping() {
if (!this.shouldDoTyping()) {
return;
}
if (!this.typingRefreshTimer) {
const isTyping = true;
this.setTypingRefreshTimer();
this.sendTypingMessage(isTyping);
}
this.setTypingPauseTimer();
}
private setTypingRefreshTimer() {
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
}
this.typingRefreshTimer = global.setTimeout(this.onTypingRefreshTimeout.bind(this), 10 * 1000);
}
private onTypingRefreshTimeout() {
const isTyping = true;
this.sendTypingMessage(isTyping);
// This timer will continue to reset itself until the pause timer stops it
this.setTypingRefreshTimer();
}
private setTypingPauseTimer() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
}
this.typingPauseTimer = global.setTimeout(this.onTypingPauseTimeout.bind(this), 10 * 1000);
}
private onTypingPauseTimeout() {
const isTyping = false;
this.sendTypingMessage(isTyping);
this.clearTypingTimers();
}
private clearTypingTimers() {
if (this.typingPauseTimer) {
global.clearTimeout(this.typingPauseTimer);
this.typingPauseTimer = null;
}
if (this.typingRefreshTimer) {
global.clearTimeout(this.typingRefreshTimer);
this.typingRefreshTimer = null;
}
}
private sendTypingMessage(isTyping: boolean) {
if (!this.isPrivate()) {
return;
}
const recipientId = this.id;
if (!recipientId) {
throw new Error('Need to provide either recipientId');
}
if (!this.isApproved()) {
return;
}
if (this.isMe()) {
// note to self
return;
}
const typingParams = {
timestamp: Date.now(),
isTyping,
typingTimestamp: Date.now(),
};
const typingMessage = new TypingMessage(typingParams);
// send the message to a single recipient if this is a session chat
const device = new PubKey(recipientId);
getMessageQueue()
.sendToPubKey(device, typingMessage)
.catch(window?.log?.error);
}
private async replaceWithOurRealSessionId(toReplace: Array<string>) {
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
const sodium = await getSodiumRenderer();
const ourBlindedPubkeyForThisSogs =
roomInfos && roomHasBlindEnabled(roomInfos)
? await findCachedOurBlindedPubkeyOrLookItUp(roomInfos?.serverPublicKey, sodium)
: UserUtils.getOurPubKeyStrFromCache();
const replacedWithOurRealSessionId = toReplace.map(m =>
m === ourBlindedPubkeyForThisSogs ? UserUtils.getOurPubKeyStrFromCache() : m
);
return replacedWithOurRealSessionId;
}
private async handleModsOrAdminsChanges({
modsOrAdmins,
hiddenModsOrAdmins,
type,
}: {
modsOrAdmins?: Array<string>;
hiddenModsOrAdmins?: Array<string>;
type: 'mods' | 'admins';
}) {
if (modsOrAdmins && isArray(modsOrAdmins)) {
const localModsOrAdmins = [...modsOrAdmins];
if (hiddenModsOrAdmins && isArray(hiddenModsOrAdmins)) {
localModsOrAdmins.push(...hiddenModsOrAdmins);
}
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
uniq(localModsOrAdmins)
);
const moderatorsOrAdminsChanged =
type === 'admins'
? await this.updateGroupAdmins(replacedWithOurRealSessionId, false)
: await this.updateGroupModerators(replacedWithOurRealSessionId, false);
return moderatorsOrAdminsChanged;
}
return false;
}
private async getQuoteAttachment(attachments: any, preview: any) {
if (attachments?.length) {
return Promise.all(
attachments
.filter(
(attachment: any) =>
attachment &&
attachment.contentType &&
!attachment.pending &&
!attachment.error &&
attachment?.thumbnail?.path // loadAttachmentData throws if the thumbnail.path is not set
)
.slice(0, 1)
.map(async (attachment: any) => {
const { fileName, thumbnail, contentType } = attachment;
return {
contentType,
// Our protos library complains about this field being undefined, so we
// force it to null
fileName: fileName || null,
thumbnail: thumbnail
? {
...(await loadAttachmentData(thumbnail)),
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
}
: null,
};
})
);
}
if (preview?.length) {
return Promise.all(
preview
.filter((attachment: any) => attachment?.image?.path) // loadAttachmentData throws if the image.path is not set
.slice(0, 1)
.map(async (attachment: any) => {
const { image } = attachment;
const { contentType } = image;
return {
contentType,
// Our protos library complains about this field being undefined, so we
// force it to null
fileName: null,
thumbnail: image
? {
...(await loadAttachmentData(image)),
objectUrl: getAbsoluteAttachmentPath(image.path),
}
: null,
};
})
);
}
return [];
}
}
const throttledAllConversationsDispatch = debounce(
() => {
if (updatesToDispatch.size === 0) {
return;
}
window.inboxStore?.dispatch(conversationsChanged([...updatesToDispatch.values()]));
updatesToDispatch.clear();
},
500,
{ trailing: true, leading: true, maxWait: 1000 }
);
const updatesToDispatch: Map<string, ReduxConversationType> = new Map();
export class ConversationCollection extends Backbone.Collection<ConversationModel> {
constructor(models?: Array<ConversationModel>) {
super(models);
this.comparator = (m: ConversationModel) => {
return -(m.get('active_at') || 0);
};
}
}
ConversationCollection.prototype.model = ConversationModel;