You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2289 lines
71 KiB
TypeScript
2289 lines
71 KiB
TypeScript
import autoBind from 'auto-bind';
|
|
import Backbone from 'backbone';
|
|
import {
|
|
debounce,
|
|
defaults,
|
|
filter,
|
|
includes,
|
|
isArray,
|
|
isEmpty,
|
|
isEqual,
|
|
isNumber,
|
|
isString,
|
|
map,
|
|
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,
|
|
isUsAnySogsFromCache,
|
|
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
|
import { SogsBlinding } from '../session/apis/open_group_api/sogsv3/sogsBlinding';
|
|
import {
|
|
roomHasBlindEnabled,
|
|
roomHasReactionsEnabled,
|
|
} from '../session/apis/open_group_api/sogsv3/sogsV3Capabilities';
|
|
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 { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
|
|
import { getOurProfile } from '../session/utils/User';
|
|
import { createLastMessageUpdate } from '../types/Conversation';
|
|
import {
|
|
deleteExternalFilesOfConversation,
|
|
getAbsoluteAttachmentPath,
|
|
loadAttachmentData,
|
|
} from '../types/MessageAttachment';
|
|
import { IMAGE_JPEG } from '../types/MIME';
|
|
import { Reaction } from '../types/Reaction';
|
|
import { Notifications } from '../util/notifications';
|
|
import { Reactions } from '../util/reactions';
|
|
import { Storage } from '../util/storage';
|
|
import {
|
|
ConversationAttributes,
|
|
ConversationNotificationSetting,
|
|
ConversationTypeEnum,
|
|
fillConvoAttributesWithDefaults,
|
|
isDirectConversation,
|
|
isOpenOrClosedGroup,
|
|
} from './conversationAttributes';
|
|
import { SessionUtilUserGroups } from '../session/utils/libsession/libsession_utils_user_groups';
|
|
import { Registration } from '../util/registration';
|
|
import { SessionUtilConvoInfoVolatile } from '../session/utils/libsession/libsession_utils_convo_info_volatile';
|
|
|
|
export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|
public updateLastMessage: () => any;
|
|
public throttledBumpTyping: () => void;
|
|
public throttledNotify: (message: MessageModel) => void;
|
|
public markRead: (newestUnreadDate: number, providedOptions?: any) => void;
|
|
public initialPromise: any;
|
|
|
|
private typingRefreshTimer?: NodeJS.Timeout | null;
|
|
private typingPauseTimer?: NodeJS.Timeout | null;
|
|
private typingTimer?: NodeJS.Timeout | null;
|
|
private lastReadTimestamp: number;
|
|
|
|
private pending?: Promise<any>;
|
|
|
|
constructor(attributes: ConversationAttributes) {
|
|
super(fillConvoAttributesWithDefaults(attributes));
|
|
|
|
// This may be overridden by getConversationController().getOrCreate, and signify
|
|
// our first save to the database. Or first fetch from the database.
|
|
this.initialPromise = Promise.resolve();
|
|
autoBind(this);
|
|
|
|
this.throttledBumpTyping = throttle(this.bumpTyping, 300);
|
|
this.updateLastMessage = throttle(this.bouncyUpdateLastMessage.bind(this), 1000, {
|
|
trailing: true,
|
|
leading: true,
|
|
});
|
|
|
|
this.throttledNotify = debounce(this.notify, 2000, { maxWait: 2000, trailing: true });
|
|
//start right away the function is called, and wait 1sec before calling it again
|
|
const markReadDebounced = debounce(this.markReadBouncy, 1000, {
|
|
leading: true,
|
|
trailing: true,
|
|
});
|
|
this.markRead = (newestUnreadDate: number) => {
|
|
const lastReadTimestamp = this.lastReadTimestamp;
|
|
if (newestUnreadDate > lastReadTimestamp) {
|
|
this.lastReadTimestamp = newestUnreadDate;
|
|
}
|
|
|
|
if (newestUnreadDate !== lastReadTimestamp) {
|
|
void markReadDebounced(newestUnreadDate);
|
|
}
|
|
};
|
|
// Listening for out-of-band data updates
|
|
|
|
this.typingRefreshTimer = null;
|
|
this.typingPauseTimer = null;
|
|
this.lastReadTimestamp = 0;
|
|
window.inboxStore?.dispatch(
|
|
conversationChanged({ id: this.id, data: this.getConversationModelProps() })
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Method to evaluate if a convo contains the right values
|
|
* @param values Required properties to evaluate if this is a message request
|
|
*/
|
|
public static hasValidIncomingRequestValues({
|
|
isMe,
|
|
isApproved,
|
|
isBlocked,
|
|
isPrivate,
|
|
activeAt,
|
|
}: {
|
|
isMe?: boolean;
|
|
isApproved?: boolean;
|
|
isBlocked?: boolean;
|
|
isPrivate?: boolean;
|
|
activeAt?: number;
|
|
}): boolean {
|
|
// if a convo is not active, it means we didn't get any messages nor sent any.
|
|
const isActive = activeAt && isFinite(activeAt) && activeAt > 0;
|
|
return Boolean(isPrivate && !isMe && !isApproved && !isBlocked && isActive);
|
|
}
|
|
|
|
public static hasValidOutgoingRequestValues({
|
|
isMe,
|
|
didApproveMe,
|
|
isApproved,
|
|
isBlocked,
|
|
isPrivate,
|
|
}: {
|
|
isMe?: boolean;
|
|
isApproved?: boolean;
|
|
didApproveMe?: boolean;
|
|
isBlocked?: boolean;
|
|
isPrivate?: boolean;
|
|
}): boolean {
|
|
return Boolean(!isMe && isApproved && isPrivate && !isBlocked && !didApproveMe);
|
|
}
|
|
|
|
public idForLogging() {
|
|
if (this.isPrivate()) {
|
|
return this.id;
|
|
}
|
|
|
|
if (this.isPublic()) {
|
|
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() {
|
|
return (
|
|
(this.get('type') === ConversationTypeEnum.GROUP && !this.isPublic()) ||
|
|
this.get('type') === ConversationTypeEnum.GROUPV3
|
|
);
|
|
}
|
|
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() || this.isMediumGroup()) {
|
|
return BlockedNumberController.isBlocked(this.id);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public isMediumGroup() {
|
|
return this.get('is_medium_group');
|
|
}
|
|
|
|
/**
|
|
* Returns true if this conversation is active
|
|
* i.e. the conversation is visibie on the left pane. (Either we or another user created this convo).
|
|
* This is useful because we do not want bumpTyping on the first message typing to a new convo to
|
|
* send a message.
|
|
*/
|
|
public isActive() {
|
|
return Boolean(this.get('active_at'));
|
|
}
|
|
|
|
public isHidden() {
|
|
return Boolean(this.get('hidden'));
|
|
}
|
|
|
|
public async cleanup() {
|
|
await deleteExternalFilesOfConversation(this.attributes);
|
|
}
|
|
|
|
public getGroupAdmins(): Array<string> {
|
|
const groupAdmins = this.get('groupAdmins');
|
|
|
|
return groupAdmins && groupAdmins?.length > 0 ? groupAdmins : [];
|
|
}
|
|
|
|
/**
|
|
* Get the list of moderators in that room, or an empty array
|
|
* Only to be called for opengroup conversations.
|
|
* This makes no sense for a private chat or an closed group, as closed group admins must be stored with getGroupAdmins
|
|
* @returns the list of moderators for the conversation if the conversation is public, or []
|
|
*/
|
|
public getGroupModerators(): Array<string> {
|
|
const groupModerators = this.get('groupModerators') as Array<string> | undefined;
|
|
|
|
return this.isPublic() && groupModerators && groupModerators?.length > 0 ? groupModerators : [];
|
|
}
|
|
|
|
// tslint:disable-next-line: cyclomatic-complexity max-func-body-length
|
|
public getConversationModelProps(): ReduxConversationType {
|
|
const groupAdmins = this.getGroupAdmins();
|
|
const groupModerators = this.getGroupModerators();
|
|
const isPublic = this.isPublic();
|
|
|
|
const members = this.isClosedGroup() ? this.get('members') : [];
|
|
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 unreadCount = this.get('unreadCount') || undefined;
|
|
const mentionedUs = this.get('mentionedUs') || undefined;
|
|
const subscriberCount = this.get('subscriberCount');
|
|
const isKickedFromGroup = !!this.get('isKickedFromGroup');
|
|
const left = !!this.get('left');
|
|
const expireTimer = this.get('expireTimer');
|
|
const currentNotificationSetting = this.get('triggerNotificationsFor');
|
|
|
|
// To reduce the redux store size, only set fields which cannot be undefined.
|
|
// For instance, a boolean can usually be not set if false, etc
|
|
const toRet: ReduxConversationType = {
|
|
id: this.id as string,
|
|
activeAt: this.get('active_at'),
|
|
type: this.get('type'),
|
|
isHidden: !!this.get('hidden'),
|
|
};
|
|
|
|
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.getMappedValue(this.id);
|
|
const foundCommunity = SessionUtilUserGroups.getCommunityMappedValueByConvoId(this.id);
|
|
|
|
if (foundContact) {
|
|
if (foundContact.name) {
|
|
toRet.displayNameInProfile = foundContact.name;
|
|
}
|
|
|
|
if (foundContact.nickname) {
|
|
toRet.nickname = foundContact.nickname;
|
|
toRet.hasNickname = !!toRet.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; // TODO priority also handles sorting
|
|
}
|
|
} else {
|
|
if (this.get('displayNameInProfile')) {
|
|
toRet.displayNameInProfile = this.get('displayNameInProfile');
|
|
}
|
|
|
|
if (this.get('nickname')) {
|
|
toRet.nickname = this.get('nickname');
|
|
toRet.hasNickname = !!toRet.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 (foundCommunity) {
|
|
if (foundCommunity.priority > 0) {
|
|
toRet.isPinned = true; // TODO priority also handles sorting
|
|
}
|
|
}
|
|
|
|
if (unreadCount) {
|
|
toRet.unreadCount = unreadCount;
|
|
}
|
|
|
|
if (mentionedUs) {
|
|
toRet.mentionedUs = mentionedUs;
|
|
}
|
|
|
|
if (isKickedFromGroup) {
|
|
toRet.isKickedFromGroup = isKickedFromGroup;
|
|
}
|
|
if (left) {
|
|
toRet.left = left;
|
|
}
|
|
|
|
if (subscriberCount) {
|
|
toRet.subscriberCount = subscriberCount;
|
|
}
|
|
|
|
if (groupAdmins && groupAdmins.length) {
|
|
toRet.groupAdmins = uniq(groupAdmins);
|
|
}
|
|
|
|
if (groupModerators && groupModerators.length) {
|
|
toRet.groupModerators = uniq(groupModerators);
|
|
}
|
|
|
|
if (members && members.length) {
|
|
toRet.members = uniq(members);
|
|
}
|
|
|
|
if (zombies && zombies.length) {
|
|
toRet.zombies = uniq(zombies);
|
|
}
|
|
|
|
if (expireTimer) {
|
|
toRet.expireTimer = expireTimer;
|
|
}
|
|
|
|
if (
|
|
currentNotificationSetting &&
|
|
currentNotificationSetting !== ConversationNotificationSetting[0]
|
|
) {
|
|
toRet.currentNotificationSetting = currentNotificationSetting;
|
|
}
|
|
|
|
if (this.isOpenGroupV2()) {
|
|
const room = OpenGroupData.getV2OpenGroupRoom(this.id);
|
|
if (room && isArray(room.capabilities) && room.capabilities.length) {
|
|
toRet.capabilities = room.capabilities;
|
|
}
|
|
|
|
if (this.get('writeCapability')) {
|
|
toRet.writeCapability = this.get('writeCapability');
|
|
}
|
|
if (this.get('readCapability')) {
|
|
toRet.readCapability = this.get('readCapability');
|
|
}
|
|
if (this.get('uploadCapability')) {
|
|
toRet.uploadCapability = this.get('uploadCapability');
|
|
}
|
|
}
|
|
|
|
const lastMessageText = this.get('lastMessage');
|
|
if (lastMessageText && lastMessageText.length) {
|
|
const lastMessageStatus = this.get('lastMessageStatus');
|
|
|
|
toRet.lastMessage = {
|
|
status: lastMessageStatus,
|
|
text: lastMessageText,
|
|
};
|
|
}
|
|
return toRet;
|
|
}
|
|
|
|
public async updateGroupAdmins(groupAdmins: Array<string>, shouldCommit: boolean) {
|
|
const existingAdmins = uniq(sortBy(this.getGroupAdmins()));
|
|
const newAdmins = uniq(sortBy(groupAdmins));
|
|
|
|
if (isEqual(existingAdmins, newAdmins)) {
|
|
return false;
|
|
}
|
|
this.set({ groupAdmins });
|
|
if (shouldCommit) {
|
|
await this.commit();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async updateGroupModerators(groupModerators: Array<string>, shouldCommit: boolean) {
|
|
if (!this.isPublic()) {
|
|
throw new Error('group moderators are only possible on SOGS');
|
|
}
|
|
const existingModerators = uniq(sortBy(this.getGroupModerators()));
|
|
const newModerators = uniq(sortBy(groupModerators));
|
|
|
|
if (isEqual(existingModerators, newModerators)) {
|
|
return false;
|
|
}
|
|
this.set({ groupModerators: newModerators });
|
|
if (shouldCommit) {
|
|
await this.commit();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public async getUnreadCount() {
|
|
const unreadCount = await Data.getUnreadCountByConversation(this.id);
|
|
|
|
return unreadCount;
|
|
}
|
|
|
|
public async queueJob(callback: () => Promise<void>) {
|
|
// tslint:disable-next-line: no-promise-as-boolean
|
|
const previous = this.pending || Promise.resolve();
|
|
|
|
const taskWithTimeout = createTaskWithTimeout(callback, `conversation ${this.idForLogging()}`);
|
|
|
|
this.pending = previous.then(taskWithTimeout, taskWithTimeout);
|
|
const current = this.pending;
|
|
|
|
void current.then(() => {
|
|
if (this.pending === current) {
|
|
delete this.pending;
|
|
}
|
|
});
|
|
|
|
return current;
|
|
}
|
|
|
|
public async makeQuote(quotedMessage: MessageModel): Promise<ReplyingToMessageProps | null> {
|
|
const attachments = quotedMessage.get('attachments');
|
|
const preview = quotedMessage.get('preview');
|
|
|
|
const body = quotedMessage.get('body');
|
|
const quotedAttachments = await this.getQuoteAttachment(attachments, preview);
|
|
|
|
if (!quotedMessage.get('sent_at')) {
|
|
window.log.warn('tried to make a quote without a sent_at timestamp');
|
|
return null;
|
|
}
|
|
let msgSource = quotedMessage.getSource();
|
|
if (this.isPublic()) {
|
|
const room = OpenGroupData.getV2OpenGroupRoom(this.id);
|
|
if (room && roomHasBlindEnabled(room) && msgSource === UserUtils.getOurPubKeyStrFromCache()) {
|
|
// this room should send message with blinded pubkey, so we need to make the quote with them too.
|
|
// when we make a quote to ourself on a blind sogs, that message has a sender being our naked pubkey
|
|
const sodium = await getSodiumRenderer();
|
|
msgSource = await findCachedOurBlindedPubkeyOrLookItUp(room.serverPublicKey, sodium);
|
|
}
|
|
}
|
|
return {
|
|
author: msgSource,
|
|
id: `${quotedMessage.get('sent_at')}` || '',
|
|
// no need to quote the full message length.
|
|
text: sliceQuoteText(body),
|
|
attachments: quotedAttachments,
|
|
timestamp: quotedMessage.get('sent_at') || 0,
|
|
convoId: this.id,
|
|
};
|
|
}
|
|
|
|
public toOpenGroupV2(): OpenGroupRequestCommonType {
|
|
if (!this.isOpenGroupV2()) {
|
|
throw new Error('tried to run toOpenGroup for not public group v2');
|
|
}
|
|
return getOpenGroupV2FromConversationId(this.id);
|
|
}
|
|
|
|
public async sendMessageJob(message: MessageModel, expireTimer: number | undefined) {
|
|
try {
|
|
const { body, attachments, preview, quote, fileIdsToLink } = await message.uploadData();
|
|
const { id } = message;
|
|
const destination = this.id;
|
|
|
|
const sentAt = message.get('sent_at');
|
|
if (!sentAt) {
|
|
throw new Error('sendMessageJob() sent_at must be set.');
|
|
}
|
|
|
|
if (this.isPublic() && !this.isOpenGroupV2()) {
|
|
throw new Error('Only opengroupv2 are supported now');
|
|
}
|
|
|
|
// 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.isMediumGroup()) {
|
|
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
|
|
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
|
|
chatMessage: chatMessageMediumGroup,
|
|
groupId: destination,
|
|
});
|
|
|
|
// we need the return await so that errors are caught in the catch {}
|
|
await getMessageQueue().sendToGroup({
|
|
message: closedGroupVisibleMessage,
|
|
namespace: SnodeNamespaces.ClosedGroupMessage,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this.isClosedGroup()) {
|
|
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
|
|
}
|
|
|
|
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
|
|
} catch (e) {
|
|
await message.saveErrors(e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async sendReactionJob(sourceMessage: MessageModel, reaction: Reaction) {
|
|
try {
|
|
const destination = this.id;
|
|
|
|
const sentAt = sourceMessage.get('sent_at');
|
|
if (!sentAt) {
|
|
throw new Error('sendReactMessageJob() sent_at must be set.');
|
|
}
|
|
|
|
if (this.isPublic() && !this.isOpenGroupV2()) {
|
|
throw new Error('Only opengroupv2 are supported now');
|
|
}
|
|
|
|
// an OpenGroupV2 message is just a visible message
|
|
const chatMessageParams: VisibleMessageParams = {
|
|
body: '',
|
|
timestamp: sentAt,
|
|
reaction,
|
|
lokiProfile: UserUtils.getOurProfile(),
|
|
};
|
|
|
|
const shouldApprove = !this.isApproved() && this.isPrivate();
|
|
const incomingMessageCount = await Data.getMessageCountByType(
|
|
this.id,
|
|
MessageDirection.incoming
|
|
);
|
|
const hasIncomingMessages = incomingMessageCount > 0;
|
|
|
|
if (this.id.startsWith('15')) {
|
|
window.log.info('Sending a blinded message to this user: ', this.id);
|
|
await this.sendBlindedMessageRequest(chatMessageParams);
|
|
return;
|
|
}
|
|
|
|
if (shouldApprove) {
|
|
await this.setIsApproved(true);
|
|
if (hasIncomingMessages) {
|
|
// have to manually add approval for local client here as DB conditional approval check in config msg handling will prevent this from running
|
|
await this.addOutgoingApprovalMessage(Date.now());
|
|
if (!this.didApproveMe()) {
|
|
await this.setDidApproveMe(true);
|
|
}
|
|
// should only send once
|
|
await this.sendMessageRequestResponse();
|
|
void forceSyncConfigurationNowIfNeeded();
|
|
}
|
|
}
|
|
|
|
if (this.isOpenGroupV2()) {
|
|
const chatMessageOpenGroupV2 = new OpenGroupVisibleMessage(chatMessageParams);
|
|
const roomInfos = this.toOpenGroupV2();
|
|
if (!roomInfos) {
|
|
throw new Error('Could not find this room in db');
|
|
}
|
|
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
|
|
const blinded = Boolean(roomHasBlindEnabled(openGroup));
|
|
|
|
// send with blinding if we need to
|
|
await getMessageQueue().sendToOpenGroupV2({
|
|
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.isMediumGroup()) {
|
|
const chatMessageMediumGroup = new VisibleMessage(chatMessageParams);
|
|
const closedGroupVisibleMessage = new ClosedGroupVisibleMessage({
|
|
chatMessage: chatMessageMediumGroup,
|
|
groupId: destination,
|
|
});
|
|
// we need the return await so that errors are caught in the catch {}
|
|
await getMessageQueue().sendToGroup({
|
|
message: closedGroupVisibleMessage,
|
|
namespace: SnodeNamespaces.ClosedGroupMessage,
|
|
});
|
|
await Reactions.handleMessageReaction({
|
|
reaction,
|
|
sender: UserUtils.getOurPubKeyStrFromCache(),
|
|
you: true,
|
|
isOpenGroup: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this.isClosedGroup()) {
|
|
throw new Error('Legacy group are not supported anymore. You need to recreate this group.');
|
|
}
|
|
|
|
throw new TypeError(`Invalid conversation type: '${this.get('type')}'`);
|
|
} catch (e) {
|
|
window.log.error(`Reaction job failed id:${reaction.id} error:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does this conversation contain the properties to be considered a message request
|
|
*/
|
|
public isIncomingRequest(): boolean {
|
|
return ConversationModel.hasValidIncomingRequestValues({
|
|
isMe: this.isMe(),
|
|
isApproved: this.isApproved(),
|
|
isBlocked: this.isBlocked(),
|
|
isPrivate: this.isPrivate(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Is this conversation an outgoing message request
|
|
*/
|
|
public isOutgoingRequest(): boolean {
|
|
return ConversationModel.hasValidOutgoingRequestValues({
|
|
isMe: this.isMe(),
|
|
isApproved: this.isApproved(),
|
|
didApproveMe: this.didApproveMe(),
|
|
isBlocked: this.isBlocked(),
|
|
isPrivate: this.isPrivate(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* When you have accepted another users message request
|
|
* @param timestamp for determining the order for this message to appear like a regular message
|
|
*/
|
|
public async addOutgoingApprovalMessage(timestamp: number) {
|
|
await this.addSingleOutgoingMessage({
|
|
sent_at: timestamp,
|
|
messageRequestResponse: {
|
|
isApproved: 1,
|
|
},
|
|
expireTimer: 0,
|
|
});
|
|
|
|
this.updateLastMessage();
|
|
}
|
|
|
|
/**
|
|
* When the other user has accepted your message request
|
|
* @param timestamp For determining message order in conversation
|
|
* @param source For determining the conversation name used in the message.
|
|
*/
|
|
public async addIncomingApprovalMessage(timestamp: number, source: string) {
|
|
await this.addSingleIncomingMessage({
|
|
sent_at: timestamp, // TODO: maybe add timestamp to messageRequestResponse? confirm it doesn't exist first
|
|
source,
|
|
messageRequestResponse: {
|
|
isApproved: 1,
|
|
},
|
|
unread: 1, // 1 means unread
|
|
expireTimer: 0,
|
|
});
|
|
this.updateLastMessage();
|
|
}
|
|
|
|
public async sendBlindedMessageRequest(messageParams: VisibleMessageParams) {
|
|
const ourSignKeyBytes = await UserUtils.getUserED25519KeyPairBytes();
|
|
const groupUrl = this.getSogsOriginMessage();
|
|
|
|
if (!PubKey.hasBlindedPrefix(this.id)) {
|
|
window?.log?.warn('sendBlindedMessageRequest - convo is not a blinded one');
|
|
return;
|
|
}
|
|
|
|
if (!messageParams.body) {
|
|
window?.log?.warn('sendBlindedMessageRequest - needs a body');
|
|
return;
|
|
}
|
|
|
|
// include our profile (displayName + avatar url + key for the recipient)
|
|
messageParams.lokiProfile = getOurProfile();
|
|
|
|
if (!ourSignKeyBytes || !groupUrl) {
|
|
window?.log?.error(
|
|
'sendBlindedMessageRequest - Cannot get required information for encrypting blinded message.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const roomInfo = OpenGroupData.getV2OpenGroupRoom(groupUrl);
|
|
|
|
if (!roomInfo || !roomInfo.serverPublicKey) {
|
|
ToastUtils.pushToastError('no-sogs-matching', window.i18n('couldntFindServerMatching'));
|
|
window?.log?.error('Could not find room with matching server url', groupUrl);
|
|
throw new Error(`Could not find room with matching server url: ${groupUrl}`);
|
|
}
|
|
|
|
const sogsVisibleMessage = new OpenGroupVisibleMessage(messageParams);
|
|
const paddedBody = addMessagePadding(sogsVisibleMessage.plainTextBuffer());
|
|
|
|
const serverPubKey = roomInfo.serverPublicKey;
|
|
|
|
const encryptedMsg = await SogsBlinding.encryptBlindedMessage({
|
|
rawData: paddedBody,
|
|
senderSigningKey: ourSignKeyBytes,
|
|
serverPubKey: from_hex(serverPubKey),
|
|
recipientBlindedPublicKey: from_hex(this.id.slice(2)),
|
|
});
|
|
|
|
if (!encryptedMsg) {
|
|
throw new Error('encryptBlindedMessage failed');
|
|
}
|
|
if (!messageParams.identifier) {
|
|
throw new Error('encryptBlindedMessage messageParams needs an identifier');
|
|
}
|
|
|
|
this.set({ active_at: Date.now(), isApproved: true });
|
|
|
|
await getMessageQueue().sendToOpenGroupV2BlindedRequest({
|
|
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 bouncyUpdateLastMessage() {
|
|
if (!this.id || !this.get('active_at')) {
|
|
return;
|
|
}
|
|
const messages = await Data.getLastMessagesByConversation(this.id, 1, true);
|
|
|
|
if (!messages || !messages.length) {
|
|
return;
|
|
}
|
|
const lastMessageModel = messages.at(0);
|
|
const lastMessageStatusModel = lastMessageModel
|
|
? lastMessageModel.getMessagePropStatus()
|
|
: undefined;
|
|
const lastMessageUpdate = createLastMessageUpdate({
|
|
lastMessageStatus: lastMessageStatusModel,
|
|
lastMessageNotificationText: lastMessageModel
|
|
? lastMessageModel.getNotificationText()
|
|
: undefined,
|
|
});
|
|
|
|
if (
|
|
lastMessageUpdate.lastMessage !== this.get('lastMessage') ||
|
|
lastMessageUpdate.lastMessageStatus !== this.get('lastMessageStatus')
|
|
) {
|
|
const lastMessageAttribute = this.get('lastMessage');
|
|
if (
|
|
lastMessageUpdate.lastMessageStatus === this.get('lastMessageStatus') &&
|
|
lastMessageUpdate.lastMessage &&
|
|
lastMessageUpdate.lastMessage.length > 40 &&
|
|
lastMessageAttribute &&
|
|
lastMessageAttribute.length > 40 &&
|
|
lastMessageUpdate.lastMessage.startsWith(lastMessageAttribute)
|
|
) {
|
|
// if status is the same, and text has a long length which starts with the db status, do not trigger an update.
|
|
// we only store the first 60 chars in the db for the lastMessage attributes (see sql.ts)
|
|
return;
|
|
}
|
|
this.set(lastMessageUpdate);
|
|
await this.commit();
|
|
}
|
|
}
|
|
|
|
public async updateExpireTimer(
|
|
providedExpireTimer: number | null,
|
|
providedSource?: string,
|
|
receivedAt?: number, // is set if it comes from outside
|
|
options: {
|
|
fromSync?: boolean;
|
|
} = {},
|
|
shouldCommit = true
|
|
): Promise<void> {
|
|
let expireTimer = providedExpireTimer;
|
|
let source = providedSource;
|
|
|
|
defaults(options, { fromSync: false });
|
|
|
|
if (!expireTimer) {
|
|
expireTimer = 0;
|
|
}
|
|
if (this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer'))) {
|
|
return;
|
|
}
|
|
|
|
window?.log?.info("Update conversation 'expireTimer'", {
|
|
id: this.idForLogging(),
|
|
expireTimer,
|
|
source,
|
|
});
|
|
|
|
const isOutgoing = Boolean(!receivedAt);
|
|
|
|
source = source || UserUtils.getOurPubKeyStrFromCache();
|
|
|
|
// When we add a disappearing messages notification to the conversation, we want it
|
|
// to be above the message that initiated that change, hence the subtraction.
|
|
const timestamp = (receivedAt || Date.now()) - 1;
|
|
|
|
this.set({ expireTimer });
|
|
|
|
const commonAttributes = {
|
|
flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
expirationTimerUpdate: {
|
|
expireTimer,
|
|
source,
|
|
fromSync: options.fromSync,
|
|
},
|
|
expireTimer: 0,
|
|
};
|
|
|
|
let message: MessageModel | undefined;
|
|
|
|
if (isOutgoing) {
|
|
message = await this.addSingleOutgoingMessage({
|
|
...commonAttributes,
|
|
sent_at: timestamp,
|
|
});
|
|
} else {
|
|
message = await this.addSingleIncomingMessage({
|
|
...commonAttributes,
|
|
// Even though this isn't reflected to the user, we want to place the last seen
|
|
// indicator above it. We set it to 'unread' to trigger that placement.
|
|
unread: 1,
|
|
source,
|
|
sent_at: timestamp,
|
|
received_at: timestamp,
|
|
});
|
|
}
|
|
|
|
if (this.isActive()) {
|
|
this.set('active_at', timestamp);
|
|
}
|
|
|
|
if (shouldCommit) {
|
|
// tell the UI this conversation was updated
|
|
await this.commit();
|
|
}
|
|
// if change was made remotely, don't send it to the number/group
|
|
if (receivedAt) {
|
|
return;
|
|
}
|
|
|
|
const expireUpdate = {
|
|
identifier: message.id,
|
|
timestamp,
|
|
expireTimer: expireTimer ? expireTimer : (null as number | null),
|
|
};
|
|
|
|
if (this.isMe()) {
|
|
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
|
|
return message.sendSyncMessageOnly(expirationTimerMessage);
|
|
}
|
|
|
|
if (this.isPrivate()) {
|
|
const expirationTimerMessage = new ExpirationTimerUpdateMessage(expireUpdate);
|
|
const pubkey = new PubKey(this.get('id'));
|
|
await getMessageQueue().sendToPubKey(
|
|
pubkey,
|
|
expirationTimerMessage,
|
|
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.attributes.id}`);
|
|
await commitConversationAndRefreshWrapper(this.id);
|
|
perfEnd(`conversationCommit-${this.attributes.id}`, 'conversationCommit');
|
|
}
|
|
|
|
public async addSingleOutgoingMessage(
|
|
messageAttributes: Omit<
|
|
MessageAttributesOptionals,
|
|
'conversationId' | 'source' | 'type' | 'direction' | 'received_at' | 'unread'
|
|
>
|
|
) {
|
|
let sender = UserUtils.getOurPubKeyStrFromCache();
|
|
if (this.isPublic()) {
|
|
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
|
|
if (openGroup && openGroup.serverPublicKey && roomHasBlindEnabled(openGroup)) {
|
|
const signingKeys = await UserUtils.getUserED25519KeyPairBytes();
|
|
|
|
if (!signingKeys) {
|
|
throw new Error('addSingleOutgoingMessage: getUserED25519KeyPairBytes returned nothing');
|
|
}
|
|
|
|
const sodium = await getSodiumRenderer();
|
|
|
|
const ourBlindedPubkeyForCurrentSogs = await findCachedOurBlindedPubkeyOrLookItUp(
|
|
openGroup.serverPublicKey,
|
|
sodium
|
|
);
|
|
|
|
if (ourBlindedPubkeyForCurrentSogs) {
|
|
sender = ourBlindedPubkeyForCurrentSogs;
|
|
}
|
|
}
|
|
}
|
|
return this.addSingleMessage({
|
|
...messageAttributes,
|
|
conversationId: this.id,
|
|
source: sender,
|
|
type: 'outgoing',
|
|
direction: 'outgoing',
|
|
unread: 0, // an outgoing message must be read right?
|
|
received_at: messageAttributes.sent_at, // make sure to set an received_at timestamp for an outgoing message, so the order are right.
|
|
});
|
|
}
|
|
|
|
public async addSingleIncomingMessage(
|
|
messageAttributes: Omit<MessageAttributesOptionals, 'conversationId' | 'type' | 'direction'>
|
|
) {
|
|
// if there's a message by the other user, they've replied to us which we consider an accepted convo
|
|
if (!this.didApproveMe() && this.isPrivate()) {
|
|
await this.setDidApproveMe(true);
|
|
}
|
|
|
|
return this.addSingleMessage({
|
|
...messageAttributes,
|
|
conversationId: this.id,
|
|
type: 'incoming',
|
|
direction: 'outgoing',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
if (this.isOpenGroupV2() || !expireTimerSet) {
|
|
// for opengroups, we batch everything as there is no expiration timer to take care (and potentially a lot of messages)
|
|
|
|
const isOpenGroup = this.isOpenGroupV2();
|
|
// if this is an opengroup there is no need to send read receipt, and so no need to fetch messages updated.
|
|
const allReadMessages = await Data.markAllAsReadByConversationNoExpiration(
|
|
this.id,
|
|
!isOpenGroup
|
|
);
|
|
this.set({ mentionedUs: false, unreadCount: 0 });
|
|
|
|
await this.commit();
|
|
if (!this.isOpenGroupV2() && allReadMessages.length) {
|
|
await this.sendReadReceiptsIfNeeded(uniq(allReadMessages));
|
|
}
|
|
Notifications.clearByConversationID(this.id);
|
|
window.inboxStore?.dispatch(markConversationFullyRead(this.id));
|
|
|
|
return;
|
|
}
|
|
// otherwise, do it the slow way
|
|
await this.markReadBouncy(Date.now());
|
|
}
|
|
|
|
// tslint:disable-next-line: cyclomatic-complexity
|
|
public async markReadBouncy(
|
|
newestUnreadDate: number,
|
|
providedOptions: { sendReadReceipts?: boolean; readAt?: number } = {}
|
|
) {
|
|
const lastReadTimestamp = this.lastReadTimestamp;
|
|
if (newestUnreadDate < lastReadTimestamp) {
|
|
return;
|
|
}
|
|
|
|
const readAt = providedOptions?.readAt || Date.now();
|
|
const sendReadReceipts = providedOptions?.sendReadReceipts || true;
|
|
|
|
const conversationId = this.id;
|
|
Notifications.clearByConversationID(conversationId);
|
|
let allUnreadMessagesInConvo = (await this.getUnread()).models;
|
|
|
|
const oldUnreadNowRead = allUnreadMessagesInConvo.filter(
|
|
message => (message.get('received_at') as number) <= newestUnreadDate
|
|
);
|
|
|
|
let read = [];
|
|
|
|
// Build the list of updated message models so we can mark them all as read on a single sqlite call
|
|
for (const nowRead of oldUnreadNowRead) {
|
|
nowRead.markReadNoCommit(readAt);
|
|
|
|
const errors = nowRead.get('errors');
|
|
read.push({
|
|
sender: nowRead.get('source'),
|
|
timestamp: nowRead.get('sent_at'),
|
|
hasErrors: Boolean(errors && errors.length),
|
|
});
|
|
}
|
|
const oldUnreadNowReadAttrs = oldUnreadNowRead.map(m => m.attributes);
|
|
if (oldUnreadNowReadAttrs?.length) {
|
|
await Data.saveMessages(oldUnreadNowReadAttrs);
|
|
}
|
|
const allProps: Array<MessageModelPropsWithoutConvoProps> = [];
|
|
|
|
for (const nowRead of oldUnreadNowRead) {
|
|
allProps.push(nowRead.getMessageModelProps());
|
|
}
|
|
|
|
if (allProps.length) {
|
|
window.inboxStore?.dispatch(conversationActions.messagesChanged(allProps));
|
|
}
|
|
// Some messages we're marking read are local notifications with no sender
|
|
read = filter(read, m => Boolean(m.sender));
|
|
const realUnreadCount = await this.getUnreadCount();
|
|
if (read.length === 0) {
|
|
const cachedUnreadCountOnConvo = this.get('unreadCount');
|
|
if (cachedUnreadCountOnConvo !== realUnreadCount) {
|
|
// reset the unreadCount on the convo to the real one coming from markRead messages on the db
|
|
this.set({ unreadCount: realUnreadCount });
|
|
await this.commit();
|
|
} else {
|
|
// window?.log?.info('markRead(): nothing newly read.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
allUnreadMessagesInConvo = allUnreadMessagesInConvo.filter((m: any) => Boolean(m.isIncoming()));
|
|
|
|
this.set({ unreadCount: realUnreadCount });
|
|
|
|
const mentionRead = (() => {
|
|
const stillUnread = allUnreadMessagesInConvo.filter(
|
|
(m: any) => m.get('received_at') > newestUnreadDate
|
|
);
|
|
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
|
|
return !stillUnread.some(m => m.get('body')?.indexOf(`@${ourNumber}`) !== -1);
|
|
})();
|
|
|
|
if (mentionRead) {
|
|
this.set({ mentionedUs: false });
|
|
}
|
|
|
|
await this.commit();
|
|
|
|
// If a message has errors, we don't want to send anything out about it.
|
|
// read syncs - let's wait for a client that really understands the message
|
|
// to mark it read. we'll mark our local error read locally, though.
|
|
// read receipts - here we can run into infinite loops, where each time the
|
|
// conversation is viewed, another error message shows up for the contact
|
|
read = read.filter(item => !item.hasErrors);
|
|
|
|
if (read.length && sendReadReceipts) {
|
|
const timestamps = map(read, 'timestamp').filter(t => !!t) as Array<number>;
|
|
await this.sendReadReceiptsIfNeeded(timestamps);
|
|
}
|
|
}
|
|
|
|
public async sendReadReceiptsIfNeeded(timestamps: Array<number>) {
|
|
if (!this.isPrivate() || !timestamps.length) {
|
|
return;
|
|
}
|
|
const settingsReadReceiptEnabled = Storage.get(SettingsKey.settingsReadReceipt) || false;
|
|
const sendReceipt =
|
|
settingsReadReceiptEnabled && !this.isBlocked() && !this.isIncomingRequest();
|
|
|
|
if (sendReceipt) {
|
|
window?.log?.info(`Sending ${timestamps.length} read receipts.`);
|
|
// we should probably stack read receipts and send them every 5 seconds for instance per conversation
|
|
|
|
const receiptMessage = new ReadReceiptMessage({
|
|
timestamp: Date.now(),
|
|
timestamps,
|
|
});
|
|
|
|
const device = new PubKey(this.id);
|
|
await getMessageQueue().sendToPubKey(device, receiptMessage, 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 = this.getGroupModerators();
|
|
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 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();
|
|
}
|
|
|
|
public async setSubscriberCount(count: number) {
|
|
if (this.get('subscriberCount') !== count) {
|
|
this.set({ subscriberCount: count });
|
|
await this.commit();
|
|
}
|
|
// Not sure if we care about updating the database
|
|
}
|
|
|
|
/**
|
|
* Saves the infos of that room directly on the conversation table.
|
|
* This does not write anything to the db if no changes are detected
|
|
*/
|
|
// tslint:disable-next-line: cyclomatic-complexity
|
|
public async setPollInfo(infos?: {
|
|
subscriberCount: number;
|
|
read: boolean;
|
|
write: boolean;
|
|
upload: boolean;
|
|
details: {
|
|
admins?: Array<string>;
|
|
image_id?: number;
|
|
name?: string;
|
|
moderators?: Array<string>;
|
|
hidden_admins?: Array<string>;
|
|
hidden_moderators?: Array<string>;
|
|
};
|
|
}) {
|
|
if (!infos || isEmpty(infos)) {
|
|
return;
|
|
}
|
|
let hasChange = false;
|
|
const { read, write, upload, subscriberCount, details } = infos;
|
|
if (
|
|
isNumber(infos.subscriberCount) &&
|
|
infos.subscriberCount !== 0 &&
|
|
this.get('subscriberCount') !== subscriberCount
|
|
) {
|
|
hasChange = true;
|
|
this.set('subscriberCount', subscriberCount);
|
|
}
|
|
|
|
if (Boolean(this.get('readCapability')) !== Boolean(read)) {
|
|
hasChange = true;
|
|
this.set('readCapability', Boolean(read));
|
|
}
|
|
|
|
if (Boolean(this.get('writeCapability')) !== Boolean(write)) {
|
|
hasChange = true;
|
|
this.set('writeCapability', Boolean(write));
|
|
}
|
|
|
|
if (Boolean(this.get('uploadCapability')) !== Boolean(upload)) {
|
|
hasChange = true;
|
|
this.set('uploadCapability', Boolean(upload));
|
|
}
|
|
|
|
const adminChanged = await this.handleModsOrAdminsChanges({
|
|
modsOrAdmins: details.admins,
|
|
hiddenModsOrAdmins: details.hidden_admins,
|
|
type: 'admins',
|
|
});
|
|
|
|
hasChange = hasChange || adminChanged;
|
|
|
|
const modsChanged = await this.handleModsOrAdminsChanges({
|
|
modsOrAdmins: details.moderators,
|
|
hiddenModsOrAdmins: details.hidden_moderators,
|
|
type: 'mods',
|
|
});
|
|
|
|
if (details.name && details.name !== this.getRealSessionUsername()) {
|
|
hasChange = hasChange || true;
|
|
this.setSessionDisplayNameNoCommit(details.name);
|
|
}
|
|
|
|
hasChange = hasChange || modsChanged;
|
|
|
|
if (this.isOpenGroupV2() && details.image_id && isNumber(details.image_id)) {
|
|
const roomInfos = OpenGroupData.getV2OpenGroupRoom(this.id);
|
|
if (roomInfos) {
|
|
void sogsV3FetchPreviewAndSaveIt({ ...roomInfos, imageID: `${details.image_id}` });
|
|
}
|
|
}
|
|
|
|
// only trigger a write to the db if a change is detected
|
|
if (hasChange) {
|
|
await this.commit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* profileKey MUST be a hex string
|
|
* @param profileKey MUST be a hex string
|
|
*/
|
|
public async setProfileKey(profileKey?: Uint8Array, 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<string>);
|
|
const mentionMe = mentions && mentions.some(m => isUsAnySogsFromCache(m.slice(1)));
|
|
|
|
const quotedMessageAuthor = message.get('quote')?.author;
|
|
|
|
const isReplyToOurMessage =
|
|
quotedMessageAuthor && UserUtils.isUsFromCache(quotedMessageAuthor);
|
|
if (!mentionMe && !isReplyToOurMessage) {
|
|
window?.log?.info(
|
|
'notifications disabled for non mentions or reply for convo',
|
|
conversationId
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
const convo = await getConversationController().getOrCreateAndWait(
|
|
message.get('source'),
|
|
ConversationTypeEnum.PRIVATE
|
|
);
|
|
|
|
const iconUrl = await this.getNotificationIcon();
|
|
|
|
const messageJSON = message.toJSON();
|
|
const messageSentAt = messageJSON.sent_at;
|
|
const messageId = message.id;
|
|
const isExpiringMessage = this.isExpiringMessage(messageJSON);
|
|
|
|
Notifications.addNotification({
|
|
conversationId,
|
|
iconUrl,
|
|
isExpiringMessage,
|
|
message: friendRequestText ? friendRequestText : message.getNotificationText(),
|
|
messageId,
|
|
messageSentAt,
|
|
title: friendRequestText ? '' : convo.getTitle(),
|
|
});
|
|
}
|
|
|
|
public async notifyIncomingCall() {
|
|
if (!this.isPrivate()) {
|
|
window?.log?.info('notifyIncomingCall: not a private convo', this.idForLogging());
|
|
return;
|
|
}
|
|
const conversationId = this.id;
|
|
|
|
// make sure the notifications are not muted for this convo (and not the source convo)
|
|
const convNotif = this.get('triggerNotificationsFor');
|
|
if (convNotif === 'disabled') {
|
|
window?.log?.info(
|
|
'notifyIncomingCall: notifications disabled for convo',
|
|
this.idForLogging()
|
|
);
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const iconUrl = await this.getNotificationIcon();
|
|
|
|
Notifications.addNotification({
|
|
conversationId,
|
|
iconUrl,
|
|
isExpiringMessage: false,
|
|
message: window.i18n('incomingCallFrom', [
|
|
this.getNicknameOrRealUsername() || window.i18n('anonymous'),
|
|
]),
|
|
messageSentAt: now,
|
|
title: this.getNicknameOrRealUsernameOrPlaceholder(),
|
|
});
|
|
}
|
|
|
|
public async notifyTypingNoCommit({ isTyping, sender }: { isTyping: boolean; sender: string }) {
|
|
// We don't do anything with typing messages from our other devices
|
|
if (UserUtils.isUsFromCache(sender)) {
|
|
return;
|
|
}
|
|
|
|
// typing only works for private chats for now
|
|
if (!this.isPrivate()) {
|
|
return;
|
|
}
|
|
|
|
if (this.typingTimer) {
|
|
global.clearTimeout(this.typingTimer);
|
|
this.typingTimer = null;
|
|
}
|
|
|
|
// we do not trigger a state change here, instead we rely on the caller to do the commit once it is done with the queue of messages
|
|
this.typingTimer = isTyping
|
|
? global.setTimeout(this.clearContactTypingTimer.bind(this, sender), 15 * 1000)
|
|
: null;
|
|
}
|
|
|
|
private async getUnread() {
|
|
return Data.getUnreadByConversation(this.id);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns The open group conversationId this conversation originated from
|
|
*/
|
|
private getSogsOriginMessage() {
|
|
return this.get('conversationIdOrigin');
|
|
}
|
|
|
|
private async addSingleMessage(messageAttributes: MessageAttributesOptionals) {
|
|
const voiceMessageFlags = messageAttributes.attachments?.[0]?.isVoiceMessage
|
|
? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE
|
|
: undefined;
|
|
const model = new MessageModel({ ...messageAttributes, flags: voiceMessageFlags });
|
|
|
|
// no need to trigger a UI update now, we trigger a messagesAdded just below
|
|
const messageId = await model.commit(false);
|
|
model.set({ id: messageId });
|
|
|
|
await model.setToExpire();
|
|
|
|
const messageModelProps = model.getMessageModelProps();
|
|
window.inboxStore?.dispatch(conversationActions.messagesChanged([messageModelProps]));
|
|
const unreadCount = await this.getUnreadCount();
|
|
this.set({ unreadCount });
|
|
this.updateLastMessage();
|
|
|
|
await this.commit();
|
|
return model;
|
|
}
|
|
|
|
private async clearContactTypingTimer(_sender: string) {
|
|
if (!!this.typingTimer) {
|
|
global.clearTimeout(this.typingTimer);
|
|
this.typingTimer = null;
|
|
|
|
// User was previously typing, but timed out or we received message. State change!
|
|
await this.commit();
|
|
}
|
|
}
|
|
|
|
private isExpiringMessage(json: any) {
|
|
if (json.type === 'incoming') {
|
|
return false;
|
|
}
|
|
|
|
const { expireTimer } = json;
|
|
|
|
return isFinite(expireTimer) && expireTimer > 0;
|
|
}
|
|
|
|
private shouldDoTyping() {
|
|
// for typing to happen, this must be a private unblocked active convo, and the settings to be on
|
|
if (
|
|
!this.isActive() ||
|
|
!Storage.get(SettingsKey.settingsTypingIndicator) ||
|
|
this.isBlocked() ||
|
|
!this.isPrivate()
|
|
) {
|
|
return false;
|
|
}
|
|
return Boolean(this.get('isApproved'));
|
|
}
|
|
|
|
private async bumpTyping() {
|
|
if (!this.shouldDoTyping()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.typingRefreshTimer) {
|
|
const isTyping = true;
|
|
this.setTypingRefreshTimer();
|
|
this.sendTypingMessage(isTyping);
|
|
}
|
|
|
|
this.setTypingPauseTimer();
|
|
}
|
|
|
|
private setTypingRefreshTimer() {
|
|
if (this.typingRefreshTimer) {
|
|
global.clearTimeout(this.typingRefreshTimer);
|
|
}
|
|
this.typingRefreshTimer = global.setTimeout(this.onTypingRefreshTimeout.bind(this), 10 * 1000);
|
|
}
|
|
|
|
private onTypingRefreshTimeout() {
|
|
const isTyping = true;
|
|
this.sendTypingMessage(isTyping);
|
|
|
|
// This timer will continue to reset itself until the pause timer stops it
|
|
this.setTypingRefreshTimer();
|
|
}
|
|
|
|
private setTypingPauseTimer() {
|
|
if (this.typingPauseTimer) {
|
|
global.clearTimeout(this.typingPauseTimer);
|
|
}
|
|
this.typingPauseTimer = global.setTimeout(this.onTypingPauseTimeout.bind(this), 10 * 1000);
|
|
}
|
|
|
|
private onTypingPauseTimeout() {
|
|
const isTyping = false;
|
|
this.sendTypingMessage(isTyping);
|
|
|
|
this.clearTypingTimers();
|
|
}
|
|
|
|
private clearTypingTimers() {
|
|
if (this.typingPauseTimer) {
|
|
global.clearTimeout(this.typingPauseTimer);
|
|
this.typingPauseTimer = null;
|
|
}
|
|
if (this.typingRefreshTimer) {
|
|
global.clearTimeout(this.typingRefreshTimer);
|
|
this.typingRefreshTimer = null;
|
|
}
|
|
}
|
|
|
|
private sendTypingMessage(isTyping: boolean) {
|
|
if (!this.isPrivate()) {
|
|
return;
|
|
}
|
|
|
|
const recipientId = this.id;
|
|
|
|
if (!recipientId) {
|
|
throw new Error('Need to provide either recipientId');
|
|
}
|
|
|
|
if (!this.isApproved()) {
|
|
return;
|
|
}
|
|
|
|
if (this.isMe()) {
|
|
// note to self
|
|
return;
|
|
}
|
|
|
|
const typingParams = {
|
|
timestamp: Date.now(),
|
|
isTyping,
|
|
typingTimestamp: Date.now(),
|
|
};
|
|
const typingMessage = new TypingMessage(typingParams);
|
|
|
|
// send the message to a single recipient if this is a session chat
|
|
const device = new PubKey(recipientId);
|
|
getMessageQueue()
|
|
.sendToPubKey(device, typingMessage, 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 handleModsOrAdminsChanges({
|
|
modsOrAdmins,
|
|
hiddenModsOrAdmins,
|
|
type,
|
|
}: {
|
|
modsOrAdmins?: Array<string>;
|
|
hiddenModsOrAdmins?: Array<string>;
|
|
type: 'mods' | 'admins';
|
|
}) {
|
|
if (modsOrAdmins && isArray(modsOrAdmins)) {
|
|
const localModsOrAdmins = [...modsOrAdmins];
|
|
if (hiddenModsOrAdmins && isArray(hiddenModsOrAdmins)) {
|
|
localModsOrAdmins.push(...hiddenModsOrAdmins);
|
|
}
|
|
|
|
const replacedWithOurRealSessionId = await this.replaceWithOurRealSessionId(
|
|
uniq(localModsOrAdmins)
|
|
);
|
|
|
|
const moderatorsOrAdminsChanged =
|
|
type === 'admins'
|
|
? await this.updateGroupAdmins(replacedWithOurRealSessionId, false)
|
|
: await this.updateGroupModerators(replacedWithOurRealSessionId, false);
|
|
return moderatorsOrAdminsChanged;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async getQuoteAttachment(attachments: any, preview: any) {
|
|
if (attachments?.length) {
|
|
return Promise.all(
|
|
attachments
|
|
.filter(
|
|
(attachment: any) =>
|
|
attachment && attachment.contentType && !attachment.pending && !attachment.error
|
|
)
|
|
.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
|
|
// TODO remove duplicates between db and wrapper (except nickname&name as we need them for search)
|
|
// TODO when deleting a contact from the ConversationController, we still need to keep it in the wrapper but mark it as hidden (and we might need to add an hidden convo model field for it)
|
|
|
|
await Data.saveConversation(convo.attributes);
|
|
|
|
const shouldBeSavedToContactsWrapper = SessionUtilContact.isContactToStoreInContactsWrapper(
|
|
convo
|
|
);
|
|
const shouldBeSavedToUserGroupsWrapper = SessionUtilUserGroups.isUserGroupToStoreInWrapper(convo);
|
|
const shouldBeSavedToConvoInfoVolatileWrapper = SessionUtilConvoInfoVolatile.isConvoToStoreInWrapper(
|
|
convo
|
|
);
|
|
|
|
console.warn(
|
|
`should be saved to wrapper ${id}: contacts:${shouldBeSavedToContactsWrapper}; usergroups:${shouldBeSavedToUserGroupsWrapper}, volatile:${shouldBeSavedToConvoInfoVolatileWrapper}`
|
|
);
|
|
|
|
if (shouldBeSavedToContactsWrapper) {
|
|
await SessionUtilContact.insertContactFromDBIntoWrapperAndRefresh(convo.id);
|
|
} else if (shouldBeSavedToUserGroupsWrapper) {
|
|
await SessionUtilUserGroups.insertGroupsFromDBIntoWrapperAndRefresh(convo.id);
|
|
}
|
|
|
|
if (shouldBeSavedToConvoInfoVolatileWrapper) {
|
|
await SessionUtilConvoInfoVolatile.insertConvoFromDBIntoWrapperAndRefresh(convo.id);
|
|
}
|
|
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;
|