session-desktop/ts/models/conversation.ts

2356 lines
74 KiB
TypeScript

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 {
CONVERSATION_PRIORITIES,
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<string, InMemoryConvoInfos> = new Map(); // decide it it makes sense to move this to a redux slice?
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
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<any>;
constructor(attributes: ConversationAttributes) {
super(fillConvoAttributesWithDefaults(attributes));
// This may be overridden by getConversationController().getOrCreate, and signify
// our first save to the database. Or first fetch from the database.
this.initialPromise = Promise.resolve();
autoBind(this);
this.throttledBumpTyping = throttle(this.bumpTyping, 300);
this.updateLastMessage = throttle(this.bouncyUpdateLastMessage.bind(this), 1000, {
trailing: true,
leading: true,
});
this.throttledNotify = debounce(this.notify, 2000, { maxWait: 2000, trailing: true });
// start right away the function is called, and wait 1sec before calling it again
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() })
);
}
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);
}
/**
* Same as this.isOpenGroupV2().
*
* // TODOLATER merge them together
*/
public isPublic(): boolean {
return this.isOpenGroupV2();
}
/**
* Same as this.isPublic().
*
* // TODOLATER merge them together
*/
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() {
const priority = this.get('priority') || CONVERSATION_PRIORITIES.default;
return this.isPrivate() && priority === CONVERSATION_PRIORITIES.hidden;
}
public async cleanup() {
await deleteExternalFilesOfConversation(this.attributes);
}
public getGroupAdmins(): Array<string> {
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 ourNumber = UserUtils.getOurPubKeyStrFromCache();
const avatarPath = this.getAvatarPath();
const isPrivate = this.isPrivate();
const weAreAdmin = this.isAdmin(ourNumber);
const weAreModerator = this.isModerator(ourNumber); // only used for sogs
const currentNotificationSetting = this.get('triggerNotificationsFor');
const priorityFromDb = this.get('priority');
// 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'),
};
if (isFinite(priorityFromDb) && priorityFromDb !== CONVERSATION_PRIORITIES.default) {
toRet.priority = priorityFromDb;
}
if (this.get('markedAsUnread')) {
toRet.isMarkedUnread = !!this.get('markedAsUnread');
}
if (isPrivate) {
toRet.isPrivate = true;
if (this.typingTimer) {
toRet.isTyping = true;
}
if (this.isMe()) {
toRet.isMe = true;
}
const foundContact = SessionUtilContact.getContactCached(this.id);
if (!toRet.activeAt && foundContact && isFinite(foundContact.createdAtSeconds)) {
toRet.activeAt = foundContact.createdAtSeconds * 1000; // active at is in ms
}
}
if (weAreAdmin) {
toRet.weAreAdmin = true;
}
if (weAreModerator) {
toRet.weAreModerator = true;
}
if (isPublic) {
toRet.isPublic = true;
}
if (avatarPath) {
toRet.avatarPath = avatarPath;
}
if (
currentNotificationSetting &&
currentNotificationSetting !== ConversationNotificationSetting[0]
) {
toRet.currentNotificationSetting = currentNotificationSetting;
}
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('expireTimer')) {
toRet.expireTimer = this.get('expireTimer');
}
if (this.get('markedAsUnread')) {
toRet.isMarkedUnread = this.get('markedAsUnread');
}
// 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;
// }
// if (foundContact.priority) {
// toRet.priority = foundContact.priority;
// }
// if (foundContact.expirationTimerSeconds > 0) {
// toRet.expireTimer = foundContact.expirationTimerSeconds;
// }
// } else {
// // -- Handle the group fields from the wrapper and the database --
// 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) {
// toRet.priority = foundLegacyGroup.priority;
// }
// }
// those are values coming only from both the DB or the wrapper. Currently we display the data from the DB
if (this.isClosedGroup()) {
toRet.members = this.get('members') || [];
toRet.groupAdmins = this.getGroupAdmins();
}
// those are values coming only from the DB when this is a closed group
if (this.isClosedGroup()) {
if (this.get('isKickedFromGroup')) {
toRet.isKickedFromGroup = this.get('isKickedFromGroup');
}
if (this.get('left')) {
toRet.left = this.get('left');
}
// to be dropped once we get rid of the legacy closed groups
const zombies = this.get('zombies') || [];
if (zombies?.length) {
toRet.zombies = uniq(zombies);
}
}
// -- Handle the communities fields from the wrapper and the database --
// if (foundCommunity) {
// if (foundCommunity.priority) {
// toRet.priority = foundCommunity.priority;
// } // the priorty field is the only one currently in the wrapper community. and we already pre apply the one from the DB on the top of this function
// }
// if (foundVolatileInfo) {
// if (foundVolatileInfo.unread) {
// toRet.isMarkedUnread = foundVolatileInfo.unread;
// }
// }
// -- Handle the field stored only in memory for all types of conversation--
const inMemoryConvoInfo = inMemoryConvoInfos.get(this.id);
if (inMemoryConvoInfo) {
if (inMemoryConvoInfo.unreadCount) {
toRet.unreadCount = inMemoryConvoInfo.unreadCount;
}
if (inMemoryConvoInfo.mentionedUs) {
toRet.mentionedUs = inMemoryConvoInfo.mentionedUs;
}
}
// -- Handle the last message status, if present --
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<string>, 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<void>) {
// tslint:disable-next-line: no-promise-as-boolean
const previous = this.pending || Promise.resolve();
const taskWithTimeout = createTaskWithTimeout(callback, `conversation ${this.idForLogging()}`);
this.pending = previous.then(taskWithTimeout, taskWithTimeout);
const current = this.pending;
void current.then(() => {
if (this.pending === current) {
delete this.pending;
}
});
return current;
}
public async makeQuote(quotedMessage: MessageModel): Promise<ReplyingToMessageProps | null> {
const attachments = quotedMessage.get('attachments');
const preview = quotedMessage.get('preview');
const body = quotedMessage.get('body');
const quotedAttachments = await this.getQuoteAttachment(attachments, preview);
if (!quotedMessage.get('sent_at')) {
window.log.warn('tried to make a quote without a sent_at timestamp');
return null;
}
let msgSource = quotedMessage.getSource();
if (this.isPublic()) {
const room = OpenGroupData.getV2OpenGroupRoom(this.id);
if (room && roomHasBlindEnabled(room) && msgSource === UserUtils.getOurPubKeyStrFromCache()) {
// this room should send message with blinded pubkey, so we need to make the quote with them too.
// when we make a quote to ourself on a blind sogs, that message has a sender being our naked pubkey
const sodium = await getSodiumRenderer();
msgSource = await findCachedOurBlindedPubkeyOrLookItUp(room.serverPublicKey, sodium);
}
}
return {
author: msgSource,
id: `${quotedMessage.get('sent_at')}` || '',
// no need to quote the full message length.
text: sliceQuoteText(body),
attachments: quotedAttachments,
timestamp: quotedMessage.get('sent_at') || 0,
convoId: this.id,
};
}
public toOpenGroupV2(): OpenGroupRequestCommonType {
if (!this.isOpenGroupV2()) {
throw new Error('tried to run toOpenGroup for not public group v2');
}
return getOpenGroupV2FromConversationId(this.id);
}
public async 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.');
}
// 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,
});
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,
});
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 hasValidIncomingRequestValues({
isMe: this.isMe(),
isApproved: this.isApproved(),
isBlocked: this.isBlocked(),
isPrivate: this.isPrivate(),
activeAt: this.get('active_at'),
didApproveMe: this.didApproveMe(),
});
}
/**
* Is this conversation an outgoing message request
*/
public isOutgoingRequest(): boolean {
return hasValidOutgoingRequestValues({
isMe: this.isMe() || false,
isApproved: this.isApproved() || false,
didApproveMe: this.didApproveMe() || false,
isBlocked: this.isBlocked() || false,
isPrivate: this.isPrivate() || false,
activeAt: this.get('active_at') || 0,
});
}
/**
* 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();
}
/**
* 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<void> {
let expireTimer = providedExpireTimer;
let source = providedSource;
defaults(options, { fromSync: false });
if (!expireTimer) {
expireTimer = 0;
}
if (this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer'))) {
return;
}
window?.log?.info("Update conversation 'expireTimer'", {
id: this.idForLogging(),
expireTimer,
source,
});
const isOutgoing = Boolean(!receivedAt);
source = source || UserUtils.getOurPubKeyStrFromCache();
// When we add a disappearing messages notification to the conversation, we want it
// to be above the message that initiated that change, hence the subtraction.
const timestamp = (receivedAt || Date.now()) - 1;
this.set({ expireTimer });
const commonAttributes = {
flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer,
source,
fromSync: options.fromSync,
},
expireTimer: 0,
};
let message: MessageModel | undefined;
if (isOutgoing) {
message = await this.addSingleOutgoingMessage({
...commonAttributes,
sent_at: timestamp,
});
} else {
message = await this.addSingleIncomingMessage({
...commonAttributes,
// Even though this isn't reflected to the user, we want to place the last seen
// indicator above it. We set it to 'unread' to trigger that placement.
unread: 1,
source,
sent_at: timestamp,
received_at: timestamp,
});
}
if (this.isActive()) {
this.set('active_at', timestamp);
}
if (shouldCommit) {
// tell the UI this conversation was updated
await this.commit();
}
// if change was made remotely, don't send it to the number/group
if (receivedAt) {
return;
}
const expireUpdate = {
identifier: message.id,
timestamp,
expireTimer: expireTimer ? expireTimer : (null as number | null),
};
if (this.isMe()) {
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
return message.sendSyncMessageOnly(expirationTimerMessage);
}
if (this.isPrivate()) {
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
const pubkey = new PubKey(this.get('id'));
await getMessageQueue().sendToPubKey(
pubkey,
expirationTimerMessage,
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 already read
received_at: messageAttributes.sent_at, // make sure to set a received_at timestamp for an outgoing message, so the order are right.
});
}
public async addSingleIncomingMessage(
messageAttributes: Omit<MessageAttributesOptionals, 'conversationId' | 'type' | 'direction'>
) {
// if there's a message by the other user, they've replied to us which we consider an accepted convo
if (this.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<number>) {
if (!this.isPrivate() || !timestamps.length) {
return;
}
const settingsReadReceiptEnabled = Storage.get(SettingsKey.settingsReadReceipt) || false;
const sendReceipt =
settingsReadReceiptEnabled && !this.isBlocked() && !this.isIncomingRequest();
if (sendReceipt) {
window?.log?.info(`Sending ${timestamps.length} read receipts.`);
// we should probably stack read receipts and send them every 5 seconds for instance per conversation
const receiptMessage = new ReadReceiptMessage({
timestamp: Date.now(),
timestamps,
});
const device = new PubKey(this.id);
await getMessageQueue().sendToPubKey(device, receiptMessage, 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);
}
/**
* When receiving a shared config message, we need to apply the change after the merge happened to our database.
* This is done with this function.
* There are other actions to change the priority from the UI (or from )
* @param priority
* @param shouldCommit
*/
public async setPriorityFromWrapper(
priority: number,
shouldCommit: boolean = true
): Promise<boolean> {
if (priority !== this.get('priority')) {
this.set({
priority,
});
if (shouldCommit) {
await this.commit();
}
return true;
}
return false;
}
/**
* Toggle the pinned state of a conversation.
* Any conversation can be pinned and the higher the priority, the higher it will be in the list.
* Note: Currently, we do not have an order in the list of pinned conversation, but the libsession util wrapper can handle the order.
*/
public async togglePinned(shouldCommit: boolean = true) {
this.set({ priority: this.isPinned() ? 0 : 1 });
if (shouldCommit) {
await this.commit();
}
return true;
}
/**
* Force the priority to be -1 (PRIORITY_DEFAULT_HIDDEN) so this conversation is hidden in the list. Currently only works for private chats.
*/
public async setHidden(shouldCommit: boolean = true) {
if (!this.isPrivate()) {
return;
}
const priority = this.get('priority');
if (priority >= CONVERSATION_PRIORITIES.default) {
this.set({ priority: CONVERSATION_PRIORITIES.hidden });
if (shouldCommit) {
await this.commit();
}
}
}
/**
* Reset the priority of this conversation to 0 if it was < 0, but keep anything > 0 as is.
* So if the conversation was pinned, we keep it pinned with its current priority.
* A pinned cannot be hidden, as the it is all based on the same priority values.
*/
public async unhideIfNeeded(shouldCommit: boolean = true) {
const priority = this.get('priority');
if (isFinite(priority) && priority < CONVERSATION_PRIORITIES.default) {
this.set({ priority: CONVERSATION_PRIORITIES.default });
if (shouldCommit) {
await this.commit();
}
}
}
public async markAsUnread(forcedValue: boolean, shouldCommit: boolean = true) {
if (!!forcedValue !== this.isMarkedUnread()) {
this.set({
markedAsUnread: !!forcedValue,
});
if (shouldCommit) {
await this.commit();
}
}
}
public isMarkedUnread(): boolean {
return !!this.get('markedAsUnread');
}
/**
* Mark a private conversation as approved to the specified value.
* Does not do anything on non private chats.
*/
public async setIsApproved(value: boolean, shouldCommit: boolean = true) {
const valueForced = Boolean(value);
if (!this.isPrivate()) {
return;
}
if (valueForced !== Boolean(this.isApproved())) {
window?.log?.info(`Setting ${ed25519Str(this.id)} isApproved to: ${value}`);
this.set({
isApproved: valueForced,
});
if (shouldCommit) {
await this.commit();
}
}
}
/**
* Mark a private conversation as approved_me to the specified value
* Does not do anything on non private chats.
*/
public async setDidApproveMe(value: boolean, shouldCommit: boolean = true) {
if (!this.isPrivate()) {
return;
}
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();
}
/**
* Save the pollInfo to the Database or to the in memory redux slice depending on the data.
* things stored to the redux slice of the sogs (ReduxSogsRoomInfos) are:
* - subscriberCount
* - canWrite
* - moderators
*
* things stored in the database are
* - admins (as they are also stored for groups we just reuse the same field, saved in the DB for now)
* - display name of that room
*
* This function also triggers the download of the new avatar if needed.
*
* Does not do anything for non public chats.
*/
// tslint:disable-next-line: cyclomatic-complexity
public async setPollInfo(infos?: {
active_users: number;
read: boolean;
write: boolean;
upload: boolean;
details: {
admins?: Array<string>;
image_id?: number;
name?: string;
moderators?: Array<string>;
hidden_admins?: Array<string>;
hidden_moderators?: Array<string>;
};
}) {
if (!this.isPublic()) {
return;
}
if (!infos || isEmpty(infos)) {
return;
}
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);
}
let hasChange = await this.handleSogsModsOrAdminsChanges({
modsOrAdmins: details.admins,
hiddenModsOrAdmins: details.hidden_admins,
type: 'admins',
});
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.isPublic() && 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() {
const priority = this.get('priority');
return isFinite(priority) && priority > CONVERSATION_PRIORITIES.default;
}
public didApproveMe() {
return Boolean(this.get('didApproveMe'));
}
public isApproved() {
return Boolean(this.get('isApproved'));
}
/**
* 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<string>);
const mentionMe = mentions && mentions.some(m => isUsAnySogsFromCache(m.slice(1)));
const quotedMessageAuthor = message.get('quote')?.author;
const isReplyToOurMessage =
quotedMessageAuthor && UserUtils.isUsFromCache(quotedMessageAuthor);
if (!mentionMe && !isReplyToOurMessage) {
window?.log?.info(
'notifications disabled for non mentions or reply for convo',
conversationId
);
return;
}
}
const convo = await getConversationController().getOrCreateAndWait(
message.get('source'),
ConversationTypeEnum.PRIVATE
);
const iconUrl = await this.getNotificationIcon();
const messageJSON = message.toJSON();
const messageSentAt = messageJSON.sent_at;
const messageId = message.id;
const isExpiringMessage = this.isExpiringMessage(messageJSON);
Notifications.addNotification({
conversationId,
iconUrl,
isExpiringMessage,
message: friendRequestText ? friendRequestText : message.getNotificationText(),
messageId,
messageSentAt,
title: friendRequestText ? '' : convo.getNicknameOrRealUsernameOrPlaceholder(),
});
}
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 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.');
}
// we are trying to send a message to someone. Make sure this convo is not hidden
this.unhideIfNeeded(true);
// 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;
}
}
private 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,
});
}
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<MessageModelPropsWithoutConvoProps> = [];
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<string>) {
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
const sodium = await getSodiumRenderer();
const ourBlindedPubkeyForThisSogs =
roomInfos && roomHasBlindEnabled(roomInfos)
? await findCachedOurBlindedPubkeyOrLookItUp(roomInfos?.serverPublicKey, sodium)
: UserUtils.getOurPubKeyStrFromCache();
const replacedWithOurRealSessionId = toReplace.map(m =>
m === ourBlindedPubkeyForThisSogs ? UserUtils.getOurPubKeyStrFromCache() : m
);
return replacedWithOurRealSessionId;
}
private async handleSogsModsOrAdminsChanges({
modsOrAdmins,
hiddenModsOrAdmins,
type,
}: {
modsOrAdmins?: Array<string>;
hiddenModsOrAdmins?: Array<string>;
type: 'mods' | 'admins';
}) {
if (modsOrAdmins && isArray(modsOrAdmins)) {
const localModsOrAdmins = [...modsOrAdmins];
if (hiddenModsOrAdmins && isArray(hiddenModsOrAdmins)) {
localModsOrAdmins.push(...hiddenModsOrAdmins);
}
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
uniq(localModsOrAdmins)
);
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;
}
// TODOLATER remove duplicates between db and wrapper (and move search by name or nickname to wrapper)
// TODOLATER insertConvoFromDBIntoWrapperAndRefresh and insertContactFromDBIntoWrapperAndRefresh both fetches the same data from the DB. Might be worth fetching it and providing it to both?
// write to db
const savedDetails = await Data.saveConversation(convo.attributes);
await convo.refreshInMemoryDetails(savedDetails);
// Performance impact on this is probably to be pretty bad. We might want to push for that DB refactor to be done sooner so we do not need to fetch info from the DB anymore
for (let index = 0; index < LibSessionUtil.requiredUserVariants.length; index++) {
const variant = LibSessionUtil.requiredUserVariants[index];
switch (variant) {
case 'UserConfig':
if (SessionUtilUserProfile.isUserProfileToStoreInWrapper(convo.id)) {
await SessionUtilUserProfile.insertUserProfileIntoWrapper(convo.id);
}
break;
case 'ContactsConfig':
if (SessionUtilContact.isContactToStoreInWrapper(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<string, ReduxConversationType> = new Map();
export class ConversationCollection extends Backbone.Collection<ConversationModel> {
constructor(models?: Array<ConversationModel>) {
super(models);
this.comparator = (m: ConversationModel) => {
return -(m.get('active_at') || 0);
};
}
}
ConversationCollection.prototype.model = ConversationModel;
export function hasValidOutgoingRequestValues({
isMe,
didApproveMe,
isApproved,
isBlocked,
isPrivate,
activeAt,
}: {
isMe: boolean;
isApproved: boolean;
didApproveMe: boolean;
isBlocked: boolean;
isPrivate: boolean;
activeAt: number;
}): boolean {
const isActive = activeAt && isFinite(activeAt) && activeAt > 0;
return Boolean(!isMe && isApproved && isPrivate && !isBlocked && !didApproveMe && isActive);
}
/**
* Method to evaluate if a convo contains the right values
* @param values Required properties to evaluate if this is a message request
*/
export function hasValidIncomingRequestValues({
isMe,
isApproved,
isBlocked,
isPrivate,
activeAt,
didApproveMe,
}: {
isMe: boolean;
isApproved: boolean;
isBlocked: boolean;
isPrivate: boolean;
didApproveMe: 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 && didApproveMe);
}