import autoBind from 'auto-bind'; import Backbone from 'backbone'; import { debounce, defaults, includes, isArray, isEmpty, isEqual, isNumber, isString, sortBy, throttle, uniq, } from 'lodash'; import { SignalService } from '../protobuf'; 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 { MessageModel, sliceQuoteText } from './message'; import { MessageAttributesOptionals, MessageDirection } from './messageType'; import { Data } from '../../ts/data/data'; import { OpenGroupRequestCommonType } from '../session/apis/open_group_api/opengroupV2/ApiUtil'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { getOpenGroupV2FromConversationId } from '../session/apis/open_group_api/utils/OpenGroupUtils'; import { ExpirationTimerUpdateMessage } from '../session/messages/outgoing/controlMessage/ExpirationTimerUpdateMessage'; import { ReadReceiptMessage } from '../session/messages/outgoing/controlMessage/receipt/ReadReceiptMessage'; import { TypingMessage } from '../session/messages/outgoing/controlMessage/TypingMessage'; import { GroupInvitationMessage } from '../session/messages/outgoing/visibleMessage/GroupInvitationMessage'; import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { VisibleMessage, VisibleMessageParams, } from '../session/messages/outgoing/visibleMessage/VisibleMessage'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { toHex } from '../session/utils/String'; import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; import { actions as conversationActions, conversationChanged, conversationsChanged, markConversationFullyRead, MessageModelPropsWithoutConvoProps, ReduxConversationType, } from '../state/ducks/conversations'; import { from_hex } from 'libsodium-wrappers-sumo'; import { ReplyingToMessageProps, SendMessageType, } from '../components/conversation/composition/CompositionBox'; import { OpenGroupData } from '../data/opengroups'; import { SettingsKey } from '../data/settings-key'; import { findCachedOurBlindedPubkeyOrLookItUp, getUsBlindedInThatServer, isUsAnySogsFromCache, } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding'; import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; import { SnodeNamespaces } from '../session/apis/snode_api/namespaces'; import { getSodiumRenderer } from '../session/crypto'; import { addMessagePadding } from '../session/crypto/BufferPadding'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { MessageRequestResponse, MessageRequestResponseParams, } from '../session/messages/outgoing/controlMessage/MessageRequestResponse'; import { ed25519Str } from '../session/onions/onionPath'; import { ConfigurationDumpSync } from '../session/utils/job_runners/jobs/ConfigurationSyncDumpJob'; import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob'; import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts'; import { SessionUtilConvoInfoVolatile } from '../session/utils/libsession/libsession_utils_convo_info_volatile'; import { SessionUtilUserGroups } from '../session/utils/libsession/libsession_utils_user_groups'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils'; import { getOurProfile } from '../session/utils/User'; import { deleteExternalFilesOfConversation, getAbsoluteAttachmentPath, loadAttachmentData, } from '../types/MessageAttachment'; import { IMAGE_JPEG } from '../types/MIME'; import { Reaction } from '../types/Reaction'; import { assertUnreachable, roomHasBlindEnabled, roomHasReactionsEnabled, SaveConversationReturn, } from '../types/sqlSharedTypes'; import { Notifications } from '../util/notifications'; import { Reactions } from '../util/reactions'; import { Registration } from '../util/registration'; import { Storage } from '../util/storage'; import { ConversationAttributes, ConversationNotificationSetting, ConversationTypeEnum, fillConvoAttributesWithDefaults, isDirectConversation, isOpenOrClosedGroup, } from './conversationAttributes'; import { LibSessionUtil } from '../session/utils/libsession/libsession_utils'; import { SessionUtilUserProfile } from '../session/utils/libsession/libsession_utils_user_profile'; import { ReduxSogsRoomInfos } from '../state/ducks/sogsRoomInfo'; import { getCanWriteOutsideRedux, getModeratorsOutsideRedux, getSubscriberCountOutsideRedux, } from '../state/selectors/sogsRoomInfo'; type InMemoryConvoInfos = { mentionedUs: boolean; unreadCount: number; }; /** * Some fields are not stored in the database, but are kept in memory. * We use this map to keep track of them. The key is the conversation id. */ const inMemoryConvoInfos: Map = new Map(); // decide it it makes sense to move this to a redux slice? export class ConversationModel extends Backbone.Model { public updateLastMessage: () => any; public throttledBumpTyping: () => void; public throttledNotify: (message: MessageModel) => void; public markConversationRead: (newestUnreadDate: number, readAt?: number) => void; public initialPromise: any; private typingRefreshTimer?: NodeJS.Timeout | null; private typingPauseTimer?: NodeJS.Timeout | null; private typingTimer?: NodeJS.Timeout | null; private pending?: Promise; 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 this.markConversationRead = debounce(this.markConversationReadBouncy, 1000, { leading: true, trailing: true, }); this.typingRefreshTimer = null; this.typingPauseTimer = null; 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()) { return this.id; } return `group(${ed25519Str(this.id)})`; } public isMe() { return UserUtils.isUsFromCache(this.id); } public isPublic(): boolean { return this.isOpenGroupV2(); } public isOpenGroupV2(): boolean { return OpenGroupUtils.isOpenGroupV2(this.id); } public isClosedGroup(): boolean { return Boolean( (this.get('type') === ConversationTypeEnum.GROUP && this.id.startsWith('05')) || (this.get('type') === ConversationTypeEnum.GROUPV3 && this.id.startsWith('03')) ); } public isPrivate() { return isDirectConversation(this.get('type')); } // returns true if this is a closed/medium or open group public isGroup() { return isOpenOrClosedGroup(this.get('type')); } public isBlocked() { if (!this.id || this.isMe()) { return false; } if (this.isPrivate() || this.isClosedGroup()) { return BlockedNumberController.isBlocked(this.id); } return false; } /** * 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 isHidden() { return Boolean(this.get('hidden')); } public async cleanup() { await deleteExternalFilesOfConversation(this.attributes); } public getGroupAdmins(): Array { const groupAdmins = this.get('groupAdmins'); return groupAdmins && groupAdmins.length > 0 ? groupAdmins : []; } // tslint:disable-next-line: cyclomatic-complexity max-func-body-length public getConversationModelProps(): ReduxConversationType { const isPublic = this.isPublic(); const zombies = this.isClosedGroup() ? 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 isKickedFromGroup = !!this.get('isKickedFromGroup'); const left = !!this.get('left'); 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: this.get('type'), isHidden: !!this.get('hidden'), isMarkedUnread: !!this.get('markedAsUnread'), }; 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 (avatarPath) { toRet.avatarPath = avatarPath; } const foundContact = SessionUtilContact.getContactCached(this.id); const foundCommunity = SessionUtilUserGroups.getCommunityByConvoIdCached(this.id); const foundLegacyGroup = SessionUtilUserGroups.getLegacyGroupCached(this.id); const foundVolatileInfo = SessionUtilConvoInfoVolatile.getVolatileInfoCached(this.id); // rely on the wrapper values rather than the DB ones if they exist in the wrapper if (foundContact) { if (foundContact.name) { toRet.displayNameInProfile = foundContact.name; } if (foundContact.nickname) { toRet.nickname = foundContact.nickname; } if (foundContact.blocked) { toRet.isBlocked = foundContact.blocked; } if (foundContact.approvedMe) { toRet.didApproveMe = foundContact.approvedMe; } if (foundContact.approved) { toRet.isApproved = foundContact.approved; } toRet.isHidden = foundContact.hidden; if (foundContact.priority > 0) { toRet.isPinned = true; } if (foundContact.expirationTimerSeconds > 0) { toRet.expireTimer = foundContact.expirationTimerSeconds; } } else { if (this.get('displayNameInProfile')) { toRet.displayNameInProfile = this.get('displayNameInProfile'); } if (this.get('nickname')) { toRet.nickname = this.get('nickname'); } if (BlockedNumberController.isBlocked(this.id)) { toRet.isBlocked = true; } if (this.get('didApproveMe')) { toRet.didApproveMe = this.get('didApproveMe'); } if (this.get('isApproved')) { toRet.isApproved = this.get('isApproved'); } if (!this.get('active_at')) { toRet.isHidden = true; } if (this.get('isPinned')) { toRet.isPinned = true; } if (this.get('expireTimer')) { toRet.expireTimer = this.get('expireTimer'); } } if (foundLegacyGroup) { toRet.members = foundLegacyGroup.members.map(m => m.pubkeyHex) || []; toRet.groupAdmins = foundLegacyGroup.members.filter(m => m.isAdmin).map(m => m.pubkeyHex) || []; toRet.displayNameInProfile = isEmpty(foundLegacyGroup.name) ? undefined : foundLegacyGroup.name; toRet.expireTimer = foundLegacyGroup.disappearingTimerSeconds; if (foundLegacyGroup.priority > 0) { toRet.isPinned = Boolean(foundLegacyGroup.priority > 0); } } if (foundCommunity) { if (foundCommunity.priority > 0) { toRet.isPinned = true; } } if (foundVolatileInfo) { if (foundVolatileInfo.unread) { toRet.isMarkedUnread = foundVolatileInfo.unread; } } const inMemoryConvoInfo = inMemoryConvoInfos.get(this.id); if (inMemoryConvoInfo) { if (inMemoryConvoInfo.unreadCount) { toRet.unreadCount = inMemoryConvoInfo.unreadCount; } if (inMemoryConvoInfo.mentionedUs) { toRet.mentionedUs = inMemoryConvoInfo.mentionedUs; } } if (isKickedFromGroup) { toRet.isKickedFromGroup = isKickedFromGroup; } if (left) { toRet.left = left; } if ( currentNotificationSetting && currentNotificationSetting !== ConversationNotificationSetting[0] ) { toRet.currentNotificationSetting = currentNotificationSetting; } if (zombies && zombies.length) { toRet.zombies = uniq(zombies); } const lastMessageText = this.get('lastMessage'); if (lastMessageText && lastMessageText.length) { const lastMessageStatus = this.get('lastMessageStatus'); toRet.lastMessage = { status: lastMessageStatus, text: lastMessageText, }; } return toRet; } /** * * @param groupAdmins the Array of group admins, where, if we are a group admin, we are present unblinded. * @param shouldCommit set this to true to auto commit changes * @returns true if the groupAdmins where not the same (and thus updated) */ public async updateGroupAdmins(groupAdmins: Array, shouldCommit: boolean) { const sortedExistingAdmins = uniq(sortBy(this.getGroupAdmins())); const sortedNewAdmins = uniq(sortBy(groupAdmins)); if (isEqual(sortedExistingAdmins, sortedNewAdmins)) { return false; } this.set({ groupAdmins }); if (shouldCommit) { await this.commit(); } return true; } /** * Fetches from the Database an update of what are the memory only informations like mentionedUs and the unreadCount, etc */ public async refreshInMemoryDetails(providedMemoryDetails?: SaveConversationReturn) { if (!SessionUtilConvoInfoVolatile.isConvoToStoreInWrapper(this)) { return; } const memoryDetails = providedMemoryDetails || (await Data.fetchConvoMemoryDetails(this.id)); if (!memoryDetails) { inMemoryConvoInfos.delete(this.id); return; } if (!inMemoryConvoInfos.get(this.id)) { inMemoryConvoInfos.set(this.id, { mentionedUs: false, unreadCount: 0, }); } const existing = inMemoryConvoInfos.get(this.id); if (!existing) { return; } let changes = false; if (existing.unreadCount !== memoryDetails.unreadCount) { existing.unreadCount = memoryDetails.unreadCount; changes = true; } if (existing.mentionedUs !== memoryDetails.mentionedUs) { existing.mentionedUs = memoryDetails.mentionedUs; changes = true; } if (changes) { this.triggerUIRefresh(); } } 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 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; } 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'); } // we are trying to send a message to someone. If that convo is hidden in the list, make sure it is not if (this.isHidden()) { this.set({ hidden: false }); await this.commit(); } // 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({ message: chatMessageOpenGroupV2, roomInfos, blinded: Boolean(roomHasBlindEnabled(openGroup)), filesToLink: 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({ namespace: SnodeNamespaces.UserMessages, message: 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, SnodeNamespaces.UserMessages ); return; } const chatMessagePrivate = new VisibleMessage(chatMessageParams); await getMessageQueue().sendToPubKey( destinationPubkey, chatMessagePrivate, SnodeNamespaces.UserMessages ); return; } if (this.isClosedGroup()) { 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({ message: closedGroupVisibleMessage, namespace: SnodeNamespaces.ClosedGroupMessage, }); return; } 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({ message: chatMessageOpenGroupV2, roomInfos, blinded, filesToLink: [], }); return; } const destinationPubkey = new PubKey(destination); if (this.isPrivate()) { const chatMessageMe = new VisibleMessage({ ...chatMessageParams, syncTarget: this.id, }); await getMessageQueue().sendSyncMessage({ namespace: SnodeNamespaces.UserMessages, message: chatMessageMe, }); const chatMessagePrivate = new VisibleMessage(chatMessageParams); await getMessageQueue().sendToPubKey( destinationPubkey, chatMessagePrivate, SnodeNamespaces.UserMessages ); await Reactions.handleMessageReaction({ reaction, sender: UserUtils.getOurPubKeyStrFromCache(), you: true, isOpenGroup: false, }); return; } if (this.isClosedGroup()) { 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({ message: closedGroupVisibleMessage, namespace: SnodeNamespaces.ClosedGroupMessage, }); await Reactions.handleMessageReaction({ reaction, sender: UserUtils.getOurPubKeyStrFromCache(), you: true, isOpenGroup: false, }); return; } 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, 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({ encryptedContent: encryptedMsg, roomInfos: roomInfo, message: sogsVisibleMessage, recipientBlindedId: 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, SnodeNamespaces.UserMessages) .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 = GetNetworkTime.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 updateExpireTimer( providedExpireTimer: number | null, providedSource?: string, receivedAt?: number, // is set if it comes from outside options: { fromSync?: boolean; } = {}, shouldCommit = true ): Promise { 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, SnodeNamespaces.UserMessages ); } 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({ message: expirationTimerMessage, namespace: SnodeNamespaces.ClosedGroupMessage, }); } return; } public triggerUIRefresh() { updatesToDispatch.set(this.id, this.getConversationModelProps()); throttledAllConversationsDispatch(); } public async commit() { perfStart(`conversationCommit-${this.id}`); await commitConversationAndRefreshWrapper(this.id); perfEnd(`conversationCommit-${this.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 ) { // if there's a message by the other user, they've replied to us which we consider an accepted convo if (this.isPrivate()) { await this.setDidApproveMe(true); } return this.addSingleMessage({ ...messageAttributes, conversationId: this.id, type: 'incoming', direction: 'outgoing', }); } /** * Mark everything as read efficiently if possible. * * For convos with a expiration timer enable, start the timer as of now. * Send read receipt if needed. */ public async markAllAsRead() { /** * when marking all as read, there is a bunch of things we need to do. * - we need to update all the messages in the DB not read yet for that conversation * - we need to send the read receipts if there is one needed for those messages * - we need to trigger a change on the redux store, so those messages are read AND mark the whole convo as read. * - we need to remove any notifications related to this conversation ID. * * * (if there is an expireTimer, we do it the slow way, handling each message separately) */ const expireTimerSet = !!this.get('expireTimer'); const isOpenGroup = this.isOpenGroupV2(); if (isOpenGroup || !expireTimerSet) { // for opengroups, we batch everything as there is no expiration timer to take care of (and potentially a lot of messages) // if this is an opengroup there is no need to send read receipt, and so no need to fetch messages updated. const allReadMessagesIds = await Data.markAllAsReadByConversationNoExpiration( this.id, !isOpenGroup ); await this.markAsUnread(false, false); await this.commit(); if (allReadMessagesIds.length) { await this.sendReadReceiptsIfNeeded(uniq(allReadMessagesIds)); } Notifications.clearByConversationID(this.id); window.inboxStore?.dispatch(markConversationFullyRead(this.id)); return; } // otherwise, do it the slow and expensive way await this.markConversationReadBouncy(Date.now()); } public getUsInThatConversation() { const usInThatConversation = getUsBlindedInThatServer(this) || UserUtils.getOurPubKeyStrFromCache(); return usInThatConversation; } public async sendReadReceiptsIfNeeded(timestamps: Array) { 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, SnodeNamespaces.UserMessages); } } public async setNickname(nickname: string | null, shouldCommit = false) { 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 }); } if (shouldCommit) { 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 : 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 = getModeratorsOutsideRedux(this.id as string); return Array.isArray(groupModerators) && groupModerators.includes(pubKey); } public async setIsPinned(value: boolean, shouldCommit: boolean = true) { const valueForced = Boolean(value); if (valueForced !== Boolean(this.isPinned())) { this.set({ isPinned: valueForced, }); if (shouldCommit) { await this.commit(); } } } public async markAsUnread(forcedValue: boolean, shouldCommit: boolean = true) { if (!!forcedValue !== !!this.get('markedAsUnread')) { this.set({ markedAsUnread: !!forcedValue, }); if (shouldCommit) { await this.commit(); } } } public isMarkedUnread(): boolean { return !!this.get('markedAsUnread'); } public async setIsApproved(value: boolean, shouldCommit: boolean = true) { const valueForced = Boolean(value); if (valueForced !== Boolean(this.isApproved())) { window?.log?.info(`Setting ${ed25519Str(this.id)} isApproved to: ${value}`); this.set({ isApproved: valueForced, }); if (shouldCommit) { await this.commit(); } } } public async setDidApproveMe(value: boolean, shouldCommit: boolean = true) { const valueForced = Boolean(value); if (valueForced !== Boolean(this.didApproveMe())) { window?.log?.info(`Setting ${ed25519Str(this.id)} didApproveMe to: ${value}`); this.set({ didApproveMe: valueForced, }); if (shouldCommit) { await this.commit(); } } } public async setOriginConversationID(conversationIdOrigin: string) { if (conversationIdOrigin === this.get('conversationIdOrigin')) { return; } this.set({ conversationIdOrigin, }); await this.commit(); } /** * 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?: { active_users: number; read: boolean; write: boolean; upload: boolean; details: { admins?: Array; image_id?: number; name?: string; moderators?: Array; hidden_admins?: Array; hidden_moderators?: Array; }; }) { if (!infos || isEmpty(infos)) { return; } let hasChange = false; const { write, active_users, details } = infos; if ( isFinite(infos.active_users) && infos.active_users !== 0 && getSubscriberCountOutsideRedux(this.id) !== active_users ) { ReduxSogsRoomInfos.setSubscriberCountOutsideRedux(this.id, active_users); } if (getCanWriteOutsideRedux(this.id) !== !!write) { ReduxSogsRoomInfos.setCanWriteOutsideRedux(this.id, !!write); } const adminChanged = await this.handleSogsModsOrAdminsChanges({ modsOrAdmins: details.admins, hiddenModsOrAdmins: details.hidden_admins, type: 'admins', }); hasChange = hasChange || adminChanged; const modsChanged = await this.handleSogsModsOrAdminsChanges({ 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, shouldCommit = 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 (shouldCommit) { 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; } } public async removeMessage(messageId: string) { await Data.removeMessage(messageId); this.updateLastMessage(); window.inboxStore?.dispatch( conversationActions.messagesDeleted([ { 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 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); 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; } /** * This call is not debounced and can be quite heavy, so only call it when handling config messages updates */ public async markReadFromConfigMessage(newestUnreadDate: number) { return this.markConversationReadBouncy(newestUnreadDate); } private async bouncyUpdateLastMessage() { if (!this.id || !this.get('active_at') || this.isHidden()) { return; } const messages = await Data.getLastMessagesByConversation(this.id, 1, true); if (!messages || !messages.length) { return; } const lastMessageModel = messages.at(0); const lastMessageStatus = lastMessageModel.getMessagePropStatus() || undefined; const lastMessageNotificationText = lastMessageModel.getNotificationText() || undefined; // we just want to set the `status` to `undefined` if there are no `lastMessageNotificationText` const lastMessageUpdate = !!lastMessageNotificationText && !isEmpty(lastMessageNotificationText) ? { lastMessage: lastMessageNotificationText || '', lastMessageStatus, } : { lastMessage: '', lastMessageStatus: undefined }; const existingLastMessageAttribute = this.get('lastMessage'); const existingLastMessageStatus = this.get('lastMessageStatus'); if ( lastMessageUpdate.lastMessage !== existingLastMessageAttribute || lastMessageUpdate.lastMessageStatus !== existingLastMessageStatus ) { if ( lastMessageUpdate.lastMessageStatus === existingLastMessageStatus && lastMessageUpdate.lastMessage && lastMessageUpdate.lastMessage.length > 40 && existingLastMessageAttribute && existingLastMessageAttribute.length > 40 && lastMessageUpdate.lastMessage.startsWith(existingLastMessageAttribute) ) { // 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(); } } private async markConversationReadBouncy(newestUnreadDate: number, readAt: number = Date.now()) { const conversationId = this.id; Notifications.clearByConversationID(conversationId); const oldUnreadNowRead = (await this.getUnreadByConversation(newestUnreadDate)).models; if (!oldUnreadNowRead.length) { //no new messages where read, no need to do anything return; } // Build the list of updated message models so we can mark them all as read on a single sqlite call const readDetails = []; for (const nowRead of oldUnreadNowRead) { nowRead.markMessageReadNoCommit(readAt); const validTimestamp = nowRead.get('sent_at') || nowRead.get('serverTimestamp'); if (nowRead.get('source') && validTimestamp && isFinite(validTimestamp)) { readDetails.push({ sender: nowRead.get('source'), timestamp: validTimestamp, }); } } const oldUnreadNowReadAttrs = oldUnreadNowRead.map(m => m.attributes); if (oldUnreadNowReadAttrs?.length) { await Data.saveMessages(oldUnreadNowReadAttrs); } const allProps: Array = []; for (const nowRead of oldUnreadNowRead) { allProps.push(nowRead.getMessageModelProps()); } if (allProps.length) { window.inboxStore?.dispatch(conversationActions.messagesChanged(allProps)); } await this.commit(); if (readDetails.length) { const us = UserUtils.getOurPubKeyStrFromCache(); const timestamps = readDetails.filter(m => m.sender !== us).map(m => m.timestamp); await this.sendReadReceiptsIfNeeded(timestamps); } } private async getUnreadByConversation(sentBeforeTs: number) { return Data.getUnreadByConversation(this.id, sentBeforeTs); } /** * * @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])); 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) { // we can only send typing messages to approved contacts if (!this.isPrivate() || this.isMe() || !this.isApproved()) { return; } const recipientId = this.id as string; if (isEmpty(recipientId)) { throw new Error('Need to provide either recipientId'); } const typingParams = { timestamp: GetNetworkTime.getNowWithNetworkOffset(), isTyping, typingTimestamp: GetNetworkTime.getNowWithNetworkOffset(), }; const typingMessage = new TypingMessage(typingParams); const device = new PubKey(recipientId); getMessageQueue() .sendToPubKey(device, typingMessage, SnodeNamespaces.UserMessages) .catch(window?.log?.error); } private async replaceWithOurRealSessionId(toReplace: Array) { 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 handleSogsModsOrAdminsChanges({ modsOrAdmins, hiddenModsOrAdmins, type, }: { modsOrAdmins?: Array; hiddenModsOrAdmins?: Array; type: 'mods' | 'admins'; }) { if (modsOrAdmins && isArray(modsOrAdmins)) { const localModsOrAdmins = [...modsOrAdmins]; if (hiddenModsOrAdmins && isArray(hiddenModsOrAdmins)) { localModsOrAdmins.push(...hiddenModsOrAdmins); } const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId( uniq(localModsOrAdmins) ); switch (type) { case 'admins': return this.updateGroupAdmins(replacedWithOurRealSessionId, false); case 'mods': ReduxSogsRoomInfos.setModeratorsOutsideRedux(this.id, replacedWithOurRealSessionId); return false; default: assertUnreachable(type, `handleSogsModsOrAdminsChanges: unhandled switch case: ${type}`); } } 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 ) .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: attachment?.thumbnail?.path // loadAttachmentData throws if the thumbnail.path is not set ? { ...(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 []; } } export async function commitConversationAndRefreshWrapper(id: string) { const convo = getConversationController().get(id); if (!convo) { return; } // write to DB // TODOLATER remove duplicates between db and wrapper (except nickname&name as we need them for search, or move search to wrapper too) const savedDetails = await Data.saveConversation(convo.attributes); await convo.refreshInMemoryDetails(savedDetails); for (let index = 0; index < LibSessionUtil.requiredUserVariants.length; index++) { const variant = LibSessionUtil.requiredUserVariants[index]; switch (variant) { case 'UserConfig': if (SessionUtilUserProfile.isUserProfileToStoreInContactsWrapper(convo.id)) { await SessionUtilUserProfile.insertUserProfileIntoWrapper(convo.id); } break; case 'ContactsConfig': if (SessionUtilContact.isContactToStoreInContactsWrapper(convo)) { await SessionUtilContact.insertContactFromDBIntoWrapperAndRefresh(convo.id); } break; case 'UserGroupsConfig': if (SessionUtilUserGroups.isUserGroupToStoreInWrapper(convo)) { await SessionUtilUserGroups.insertGroupsFromDBIntoWrapperAndRefresh(convo.id); } break; case 'ConvoInfoVolatileConfig': if (SessionUtilConvoInfoVolatile.isConvoToStoreInWrapper(convo)) { await SessionUtilConvoInfoVolatile.insertConvoFromDBIntoWrapperAndRefresh(convo.id); } break; default: assertUnreachable( variant, `commitConversationAndRefreshWrapper unhandled case "${variant}"` ); } } if (Registration.isDone()) { // save the new dump if needed to the DB asap // this call throttled so we do not run this too often (and not for every .commit()) await ConfigurationDumpSync.queueNewJobIfNeeded(); await ConfigurationSync.queueNewJobIfNeeded(); } convo.triggerUIRefresh(); } 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 = new Map(); export class ConversationCollection extends Backbone.Collection { constructor(models?: Array) { super(models); this.comparator = (m: ConversationModel) => { return -(m.get('active_at') || 0); }; } } ConversationCollection.prototype.model = ConversationModel;