import Backbone from 'backbone'; import _ 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 { UserUtils } from '../session/utils'; import { BlockedNumberController } from '../util'; import { leaveClosedGroup } from '../session/group'; import { SignalService } from '../protobuf'; import { MessageModel } from './message'; import { MessageAttributesOptionals, MessageModelType } from './messageType'; import autoBind from 'auto-bind'; import { getMessagesByConversation, getUnreadByConversation, getUnreadCountByConversation, removeMessage as dataRemoveMessage, saveMessages, updateConversation, } from '../../ts/data/data'; import { toHex } from '../session/utils/String'; import { actions as conversationActions, conversationChanged, LastMessageStatusType, 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 '../opengroup/utils'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupRequestCommonType } from '../opengroup/opengroupV2/ApiUtil'; import { getOpenGroupV2FromConversationId } from '../opengroup/utils/OpenGroupUtils'; import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { ReplyingToMessageProps, SendMessageType, } from '../components/session/conversation/composition/CompositionBox'; 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 { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI'; import { createLastMessageUpdate } from '../types/Conversation'; export enum ConversationTypeEnum { GROUP = 'group', PRIVATE = 'private', } /** * all: all notifications enabled, the default * disabled: no notifications at all * mentions_only: trigger a notification only on mentions of ourself */ export const ConversationNotificationSetting = ['all', 'disabled', 'mentions_only'] as const; export type ConversationNotificationSettingType = typeof ConversationNotificationSetting[number]; export interface ConversationAttributes { profileName?: string; id: string; name?: string; // members are all members for this group. zombies excluded members: Array; zombies: Array; // only used for closed groups. Zombies are users which left but not yet removed by the admin left: boolean; expireTimer: number; mentionedUs: boolean; unreadCount: number; lastMessageStatus: LastMessageStatusType; lastMessage: string | null; active_at: number; lastJoinedTimestamp: number; // ClosedGroup: last time we were added to this group groupAdmins?: Array; moderators?: Array; // TODO to merge to groupAdmins with a migration on the db isKickedFromGroup?: boolean; avatarPath?: string; isMe?: boolean; subscriberCount?: number; sessionRestoreSeen?: boolean; is_medium_group?: boolean; type: string; avatarPointer?: string; avatar?: any; /* Avatar hash is currently used for opengroupv2. it's sha256 hash of the base64 avatar data. */ avatarHash?: string; server?: any; nickname?: string; profile?: any; profileAvatar?: any; /** * Consider this being a hex string if it set */ profileKey?: string; triggerNotificationsFor: ConversationNotificationSettingType; isTrustedForAttachmentDownload: boolean; isPinned: boolean; isApproved: boolean; } export interface ConversationAttributesOptionals { profileName?: string; id: string; name?: string; members?: Array; zombies?: Array; left?: boolean; expireTimer?: number; mentionedUs?: boolean; unreadCount?: number; lastMessageStatus?: LastMessageStatusType; lastMessage: string | null; active_at?: number; timestamp?: number; // timestamp of what? lastJoinedTimestamp?: number; groupAdmins?: Array; moderators?: Array; isKickedFromGroup?: boolean; avatarPath?: string; isMe?: boolean; subscriberCount?: number; sessionRestoreSeen?: boolean; is_medium_group?: boolean; type: string; avatarPointer?: string; avatar?: any; avatarHash?: string; server?: any; nickname?: string; profile?: any; profileAvatar?: any; /** * Consider this being a hex string if it set */ profileKey?: string; triggerNotificationsFor?: ConversationNotificationSettingType; isTrustedForAttachmentDownload?: boolean; isPinned: boolean; isApproved?: boolean; } /** * This function mutates optAttributes * @param optAttributes the entry object attributes to set the defaults to. */ export const fillConvoAttributesWithDefaults = ( optAttributes: ConversationAttributesOptionals ): ConversationAttributes => { return _.defaults(optAttributes, { members: [], zombies: [], left: false, unreadCount: 0, lastMessageStatus: null, lastJoinedTimestamp: new Date('1970-01-01Z00:00:00:000').getTime(), groupAdmins: [], moderators: [], isKickedFromGroup: false, isMe: false, subscriberCount: 0, sessionRestoreSeen: false, is_medium_group: false, lastMessage: null, expireTimer: 0, mentionedUs: false, active_at: 0, triggerNotificationsFor: 'all', // if the settings is not set in the db, this is the default isTrustedForAttachmentDownload: false, // we don't trust a contact until we say so isPinned: false, isApproved: false, }); }; export class ConversationModel extends Backbone.Model { public updateLastMessage: () => any; public throttledBumpTyping: () => void; public throttledNotify: (message: MessageModel) => void; public markRead: (newestUnreadDate: number, providedOptions?: any) => Promise; public initialPromise: any; private typingRefreshTimer?: NodeJS.Timeout | null; private typingPauseTimer?: NodeJS.Timeout | null; private typingTimer?: NodeJS.Timeout | null; private lastReadTimestamp: number; private pending?: Promise; constructor(attributes: ConversationAttributesOptionals) { 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.triggerUIRefresh = _.throttle(this.triggerUIRefresh, 1000, { trailing: true, leading: true, }); this.throttledNotify = _.debounce(this.notify, 500, { maxWait: 5000, 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, }); // tslint:disable-next-line: no-async-without-await this.markRead = async (newestUnreadDate: number) => { const lastReadTimestamp = this.lastReadTimestamp; if (newestUnreadDate > lastReadTimestamp) { this.lastReadTimestamp = newestUnreadDate; } 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() }) ); } public idForLogging() { if (this.isPrivate()) { return this.id; } if (this.isPublic()) { return `opengroup(${this.id})`; } 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 bumpTyping() { // We don't send typing messages if the setting is disabled // or we blocked that user if ( this.isPublic() || this.isMediumGroup() || !this.isActive() || !window.storage.get('typing-indicators-setting') || this.isBlocked() ) { return; } if (!this.typingRefreshTimer) { const isTyping = true; this.setTypingRefreshTimer(); this.sendTypingMessage(isTyping); } this.setTypingPauseTimer(); } public setTypingRefreshTimer() { if (this.typingRefreshTimer) { global.clearTimeout(this.typingRefreshTimer); } this.typingRefreshTimer = global.setTimeout(this.onTypingRefreshTimeout.bind(this), 10 * 1000); } public onTypingRefreshTimeout() { const isTyping = true; this.sendTypingMessage(isTyping); // This timer will continue to reset itself until the pause timer stops it this.setTypingRefreshTimer(); } public setTypingPauseTimer() { if (this.typingPauseTimer) { global.clearTimeout(this.typingPauseTimer); } this.typingPauseTimer = global.setTimeout(this.onTypingPauseTimeout.bind(this), 10 * 1000); } public onTypingPauseTimeout() { const isTyping = false; this.sendTypingMessage(isTyping); this.clearTypingTimers(); } public clearTypingTimers() { if (this.typingPauseTimer) { global.clearTimeout(this.typingPauseTimer); this.typingPauseTimer = null; } if (this.typingRefreshTimer) { global.clearTimeout(this.typingRefreshTimer); this.typingRefreshTimer = null; } } public sendTypingMessage(isTyping: boolean) { if (!this.isPrivate()) { return; } const recipientId = this.id; if (!recipientId) { throw new Error('Need to provide either recipientId'); } const primaryDevicePubkey = window.storage.get('primaryDevicePubKey'); if (recipientId && primaryDevicePubkey === recipientId) { // 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); } public async cleanup() { const { deleteAttachmentData } = window.Signal.Migrations; await window.Signal.Types.Conversation.deleteExternalFiles(this.attributes, { deleteAttachmentData, }); window.profileImages.removeImage(this.id); } public async updateProfileAvatar() { if (this.isPublic()) { return; } // Remove old identicons if (window.profileImages.hasImage(this.id)) { window.profileImages.removeImage(this.id); await this.setProfileAvatar(null); } } public async onExpired(_message: MessageModel) { await this.updateLastMessage(); // removeMessage(); } public getGroupAdmins() { const groupAdmins = this.get('groupAdmins'); if (groupAdmins?.length) { return groupAdmins; } return this.get('moderators'); } // tslint:disable-next-line: cyclomatic-complexity public getConversationModelProps(): ReduxConversationType { const groupAdmins = this.getGroupAdmins(); const isPublic = this.isPublic(); const members = this.isGroup() && !isPublic ? this.get('members') : []; const ourNumber = UserUtils.getOurPubKeyStrFromCache(); const avatarPath = this.getAvatarPath(); const isPrivate = this.isPrivate(); const isGroup = !isPrivate; const weAreAdmin = this.isAdmin(ourNumber); const isMe = this.isMe(); const isTyping = !!this.typingTimer; const name = this.getName(); const profileName = this.getProfileName(); 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 hasNickname = !!this.getNickname(); const isKickedFromGroup = !!this.get('isKickedFromGroup'); const left = !!this.get('left'); const expireTimer = this.get('expireTimer'); const currentNotificationSetting = this.get('triggerNotificationsFor'); // 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 (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 (name) { toRet.name = name; } if (profileName) { toRet.profileName = profileName; } 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 (isApproved) { toRet.isApproved = isApproved; } if (subscriberCount) { toRet.subscriberCount = subscriberCount; } if (groupAdmins && groupAdmins.length) { toRet.groupAdmins = groupAdmins; } if (members && members.length) { toRet.members = members; } if (members && members.length) { toRet.members = members; } 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) { const existingAdmins = _.uniq(_.sortBy(this.getGroupAdmins())); const newAdmins = _.uniq(_.sortBy(groupAdmins)); if (_.isEqual(existingAdmins, newAdmins)) { // window?.log?.info( // 'Skipping updates of groupAdmins/moderators. No change detected.' // ); return; } this.set({ groupAdmins }); await this.commit(); } 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 getUnread() { return getUnreadByConversation(this.id); } public async getUnreadCount() { const unreadCount = await getUnreadCountByConversation(this.id); return unreadCount; } public async queueJob(callback: () => Promise) { // 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 getRecipients() { if (this.isPrivate()) { return [this.id]; } const me = UserUtils.getOurPubKeyStrFromCache(); return _.without(this.get('members'), me); } public async getQuoteAttachment(attachments: any, preview: any) { const { loadAttachmentData, getAbsoluteAttachmentPath } = window.Signal.Migrations; if (attachments && attachments.length) { return Promise.all( attachments .filter( (attachment: any) => attachment && attachment.contentType && !attachment.pending && !attachment.error ) .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 && preview.length) { return Promise.all( preview .filter((item: any) => item && item.image) .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 []; } public async makeQuote(quotedMessage: MessageModel): Promise { 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; } return { author: quotedMessage.getSource(), id: `${quotedMessage.get('sent_at')}` || '', text: 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 uploads = 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: uploads.body, identifier: id, timestamp: sentAt, attachments: uploads.attachments, expireTimer, preview: uploads.preview, quote: uploads.quote, lokiProfile: UserUtils.getOurProfile(), }; const updateApprovalNeeded = !this.isApproved() && (this.isPrivate() || this.isMediumGroup() || this.isClosedGroup()); if (updateApprovalNeeded) { await this.setIsApproved(true); 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'); } // we need the return await so that errors are caught in the catch {} await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos); 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; } // Handle Group Invitation Message 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 sendMessage(msg: SendMessageType) { const { attachments, body, groupInvitation, preview, quote } = msg; this.clearTypingTimers(); const destination = this.id; const isPrivate = this.isPrivate(); const expireTimer = this.get('expireTimer'); const recipients = this.getRecipients(); const now = Date.now(); window?.log?.info( 'Sending message to conversation', this.idForLogging(), 'with timestamp', now ); // be sure an empty quote is marked as undefined rather than being empty // otherwise upgradeMessageSchema() will return an object with an empty array // and this.get('quote') will be true, even if there is no quote. const editedQuote = _.isEmpty(quote) ? undefined : quote; const { upgradeMessageSchema } = window.Signal.Migrations; const diffTimestamp = Date.now() - getLatestTimestampOffset(); const messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, conversationId: destination, quote: editedQuote, preview, attachments, sent_at: diffTimestamp, received_at: now, expireTimer, recipients, isDeleted: false, }); if (!this.isPublic()) { messageWithSchema.destination = destination; } else { // set the serverTimestamp only if this conversation is a public one. messageWithSchema.serverTimestamp = Date.now(); } messageWithSchema.source = UserUtils.getOurPubKeyStrFromCache(); messageWithSchema.sourceDevice = 1; const attributes: MessageAttributesOptionals = { ...messageWithSchema, groupInvitation, conversationId: this.id, destination: isPrivate ? destination : undefined, }; const messageModel = await this.addSingleMessage(attributes); // We're offline! if (!window.textsecure.messaging) { 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: now, }); await this.commit(); await this.queueJob(async () => { await this.sendMessageJob(messageModel, expireTimer); }); } public async bouncyUpdateLastMessage() { if (!this.id) { return; } if (!this.get('active_at')) { return; } const messages = await getMessagesByConversation(this.id, { limit: 1, skipTimerInit: true, }); const lastMessageModel = messages.at(0); const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null; const lastMessageStatusModel = lastMessageModel ? lastMessageModel.getMessagePropStatus() : undefined; const lastMessageUpdate = createLastMessageUpdate({ currentTimestamp: this.get('active_at'), lastMessage: lastMessageJSON, lastMessageStatus: lastMessageStatusModel, lastMessageNotificationText: lastMessageModel ? lastMessageModel.getNotificationText() : null, }); this.set(lastMessageUpdate); await this.commit(); } public async updateExpireTimer( providedExpireTimer: any, providedSource?: string, receivedAt?: number, // is set if it comes from outside options: { fromSync?: boolean; } = {} ) { let expireTimer = providedExpireTimer; let source = providedSource; _.defaults(options, { fromSync: false }); if (!expireTimer) { expireTimer = 0; } if (this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer'))) { return null; } 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 messageAttributes = { // 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: isOutgoing ? 0 : 1, conversationId: this.id, source, // No type; 'incoming' messages are specially treated by conversation.markRead() sent_at: timestamp, received_at: timestamp, flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { expireTimer, source, fromSync: options.fromSync, }, expireTimer: 0, type: isOutgoing ? 'outgoing' : ('incoming' as MessageModelType), destination: this.id, recipients: isOutgoing ? this.getRecipients() : undefined, }; const message = await this.addSingleMessage(messageAttributes); // 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 message; } const expireUpdate = { identifier: message.id, timestamp, expireTimer, }; if (!expireUpdate.expireTimer) { delete expireUpdate.expireTimer; } 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 message; } public triggerUIRefresh() { window.inboxStore?.dispatch( conversationChanged({ id: this.id, data: { ...this.getConversationModelProps(), }, }) ); } public async commit() { perfStart(`conversationCommit-${this.attributes.id}`); // write to DB await updateConversation(this.attributes); this.triggerUIRefresh(); perfEnd(`conversationCommit-${this.attributes.id}`, 'conversationCommit'); } public async addSingleMessage(messageAttributes: MessageAttributesOptionals, setToExpire = true) { const model = new MessageModel(messageAttributes); const isMe = messageAttributes.source === UserUtils.getOurPubKeyStrFromCache(); if ( isMe && window.lokiFeatureFlags.useMessageRequests && window.inboxStore?.getState().userConfig.messageRequests ) { await this.setIsApproved(true); } // no need to trigger a UI update now, we trigger a messageAdded just below const messageId = await model.commit(false); model.set({ id: messageId }); if (setToExpire) { await model.setToExpire(); } window.inboxStore?.dispatch( conversationActions.messageAdded({ conversationKey: this.id, messageModelProps: model.getMessageModelProps(), }) ); const unreadCount = await this.getUnreadCount(); this.set({ unreadCount }); this.updateLastMessage(); await this.commit(); return model; } 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.' ); } } 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; window.Whisper.Notifications.remove( window.Whisper.Notifications.where({ 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 saveMessages(oldUnreadNowReadAttrs); } const allProps: Array = []; 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 !== read.length) { // 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 (this.isPublic()) { return; } if (this.isPrivate() && read.length && options.sendReadReceipts) { window?.log?.info(`Sending ${read.length} read receipts`); if (window.storage.get('read-receipt-setting')) { await Promise.all( _.map(_.groupBy(read, 'sender'), async (receipts, sender) => { const timestamps = _.map(receipts, 'timestamp').filter(t => !!t) as Array; const receiptMessage = new ReadReceiptMessage({ timestamp: Date.now(), timestamps, }); const device = new PubKey(sender); await getMessageQueue().sendToPubKey(device, receiptMessage); }) ); } } } // LOKI PROFILES public async setNickname(nickname: string) { const trimmed = nickname && nickname.trim(); if (this.get('nickname') === trimmed) { return; } this.set({ nickname: trimmed }); await this.commit(); await this.updateProfileName(); } public async setLokiProfile(newProfile: { displayName?: string | null; avatar?: string; avatarHash?: string; }) { if (!_.isEqual(this.get('profile'), newProfile)) { this.set({ profile: newProfile }); await this.commit(); } // a user cannot remove an avatar. Only change it // if you change this behavior, double check all setLokiProfile calls (especially the one in EditProfileDialog) if (newProfile.avatar) { await this.setProfileAvatar({ path: newProfile.avatar }, newProfile.avatarHash); } await this.updateProfileName(); } public async updateProfileName() { // Prioritise nickname over the profile display name const nickname = this.getNickname(); const profile = this.getLokiProfile(); const displayName = profile && profile.displayName; const profileName = nickname || displayName || null; await this.setProfileName(profileName); } public getLokiProfile() { return this.get('profile'); } public getNickname() { return this.get('nickname'); } 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); } // SIGNAL PROFILES public async getProfiles() { // request all conversation members' keys let ids = []; if (this.isPrivate()) { ids = [this.id]; } else { ids = this.get('members'); } return Promise.all(_.map(ids, this.getProfile)); } // This function is wrongly named by signal // This is basically an `update` function and thus we have overwritten it with such public async getProfile(id: string) { const c = await getConversationController().getOrCreateAndWait( id, ConversationTypeEnum.PRIVATE ); // We only need to update the profile as they are all stored inside the conversation await c.updateProfileName(); } public async setProfileName(name: string) { const profileName = this.get('profileName'); if (profileName !== name) { this.set({ profileName: name }); await this.commit(); } } public async setIsPinned(value: boolean) { if (value !== this.get('isPinned')) { this.set({ isPinned: value, }); await this.commit(); } } public async setIsApproved(value: boolean) { if (value !== this.get('isApproved')) { window?.log?.info(`Setting ${this.attributes.profileName} isApproved to:: ${value}`); this.set({ isApproved: value, }); await this.commit(); } } public async setGroupName(name: string) { const profileName = this.get('name'); if (profileName !== name) { this.set({ name }); 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 } public async setProfileAvatar(avatar: null | { path: string }, avatarHash?: string) { const profileAvatar = this.get('avatar'); const existingHash = this.get('avatarHash'); let shouldCommit = false; if (!_.isEqual(profileAvatar, avatar)) { this.set({ avatar }); shouldCommit = true; } if (existingHash !== avatarHash) { this.set({ avatarHash }); shouldCommit = true; } if (shouldCommit) { 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 async upgradeMessages(messages: any) { // tslint:disable-next-line: one-variable-per-declaration for (let max = messages.length, i = 0; i < max; i += 1) { const message = messages.at(i); const { attributes } = message; const { schemaVersion } = attributes; if (schemaVersion < window.Signal.Types.Message.VERSION_NEEDED_FOR_DISPLAY) { // Yep, we really do want to wait for each of these // eslint-disable-next-line no-await-in-loop const { upgradeMessageSchema } = window.Signal.Migrations; const upgradedMessage = await upgradeMessageSchema(attributes); message.set(upgradedMessage); // eslint-disable-next-line no-await-in-loop await upgradedMessage.commit(); } } } public hasMember(pubkey: string) { return _.includes(this.get('members'), pubkey); } // returns true if this is a closed/medium or open group public isGroup() { return this.get('type') === 'group'; } public async removeMessage(messageId: any) { await dataRemoveMessage(messageId); this.updateLastMessage(); window.inboxStore?.dispatch( conversationActions.messageDeleted({ conversationKey: this.id, messageId, }) ); } public getName() { if (this.isPrivate()) { return this.get('name'); } return this.get('name') || window.i18n('unknown'); } public isPinned() { return this.get('isPinned'); } public isApproved() { return this.get('isApproved'); } public getTitle() { if (this.isPrivate()) { const profileName = this.getProfileName(); const number = this.getNumber(); const name = profileName ? `${profileName} (${PubKey.shorten(number)})` : number; return this.get('name') || name; } return this.get('name') || 'Unknown group'; } /** * 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 profileName = this.get('profileName'); const pubkey = this.id; if (UserUtils.isUsFromCache(pubkey)) { return window.i18n('you'); } return profileName || PubKey.shorten(pubkey); } /** * For a private convo, returns the loki profilename if set, or a full length * version of the contact pubkey. * Throws an error if called on a group convo. */ public getContactProfileNameOrFullPubKey() { if (!this.isPrivate()) { throw new Error( 'getContactProfileNameOrFullPubKey() cannot be called with a non private convo.' ); } const profileName = this.get('profileName'); const pubkey = this.id; if (UserUtils.isUsFromCache(pubkey)) { return window.i18n('you'); } return profileName || pubkey; } public getProfileName() { if (this.isPrivate() && !this.get('name')) { return this.get('profileName'); } return undefined; } public getNumber() { if (!this.isPrivate()) { return ''; } return this.id; } public isPrivate() { return this.get('type') === ConversationTypeEnum.PRIVATE; } public getAvatarPath() { const avatar = this.get('avatar') || this.get('profileAvatar'); if (typeof avatar === 'string') { return avatar; } if (typeof avatar?.path === 'string') { const { getAbsoluteAttachmentPath } = window.Signal.Migrations; return getAbsoluteAttachmentPath(avatar.path) as string; } return null; } public async getNotificationIcon() { const avatarUrl = this.getAvatarPath(); const noIconUrl = 'images/session/session_icon_32.png'; if (avatarUrl) { 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; } else { return noIconUrl; } } public async notify(message: MessageModel) { if (!message.isIncoming()) { 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('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); const mentionMe = mentions && mentions.some(m => UserUtils.isUsFromCache(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); // window?.log?.info('Add notification', { // conversationId: this.idForLogging(), // isExpiringMessage, // messageSentAt, // }); window.Whisper.Notifications.add({ conversationId, iconUrl, isExpiringMessage, message: message.getNotificationText(), messageId, messageSentAt, title: 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(); window.Whisper.Notifications.add({ conversationId, iconUrl, isExpiringMessage: false, message: window.i18n('incomingCallFrom', this.getTitle()), messageSentAt: now, title: this.getTitle(), }); } public async notifyTyping({ isTyping, sender }: any) { // 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; } const wasTyping = !!this.typingTimer; if (this.typingTimer) { global.clearTimeout(this.typingTimer); this.typingTimer = null; } // Note: We trigger two events because: // 'change' causes a re-render of this conversation's list item in the left pane if (isTyping) { this.typingTimer = global.setTimeout( this.clearContactTypingTimer.bind(this, sender), 15 * 1000 ); if (!wasTyping) { // User was not previously typing before. State change! await this.commit(); } } else { // tslint:disable-next-line: no-dynamic-delete this.typingTimer = null; if (wasTyping) { // User was previously typing, and is no longer. State change! await this.commit(); } } } public 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 typeof expireTimer === 'number' && expireTimer > 0; } } export class ConversationCollection extends Backbone.Collection { constructor(models?: Array) { super(models); this.comparator = (m: ConversationModel) => { return -m.get('active_at'); }; } } ConversationCollection.prototype.model = ConversationModel;