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.
session-desktop/ts/receiver/configMessage.ts

1076 lines
39 KiB
TypeScript

/* eslint-disable no-await-in-loop */
import { ContactInfo, GroupPubkeyType, UserGroupsGet } from 'libsession_util_nodejs';
import { base64_variants, from_base64 } from 'libsodium-wrappers-sumo';
import { compact, difference, isEmpty, isNil, isNumber, toNumber } from 'lodash';
import { ConfigDumpData } from '../data/configDump/configDump';
import { SettingsKey } from '../data/settings-key';
import { deleteAllMessagesByConvoIdNoConfirmation } from '../interactions/conversationInteractions';
import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../models/conversationAttributes';
import { getOpenGroupManager } from '../session/apis/open_group_api/opengroupV2/OpenGroupManagerV2';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
import { getOpenGroupV2ConversationId } from '../session/apis/open_group_api/utils/OpenGroupUtils';
import { getSwarmPollingInstance } from '../session/apis/snode_api';
import { ConvoHub } from '../session/conversations';
import { ProfileManager } from '../session/profile_manager/ProfileManager';
import { PubKey } from '../session/types';
import { StringUtils, UserUtils } from '../session/utils';
import { toHex } from '../session/utils/String';
import { FetchMsgExpirySwarm } from '../session/utils/job_runners/jobs/FetchMsgExpirySwarmJob';
import { UserSync } from '../session/utils/job_runners/jobs/UserSyncJob';
import { LibSessionUtil } from '../session/utils/libsession/libsession_utils';
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 { configurationMessageReceived, trigger } from '../shims/events';
import { getCurrentlySelectedConversationOutsideRedux } from '../state/selectors/conversations';
import { assertUnreachable, stringify, toFixedUint8ArrayOfLength } from '../types/sqlSharedTypes';
import { BlockedNumberController } from '../util';
import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
// eslint-disable-next-line import/no-unresolved, import/extensions
import { HexString } from '../node/hexStrings';
import {
SnodeNamespace,
SnodeNamespaces,
SnodeNamespacesUserConfig,
} from '../session/apis/snode_api/namespaces';
import { RetrieveMessageItemWithNamespace } from '../session/apis/snode_api/types';
import { ClosedGroup, GroupInfo } from '../session/group/closed-group';
import { groupInfoActions } from '../state/ducks/metaGroups';
import {
ConfigWrapperObjectTypesMeta,
ConfigWrapperUser,
getGroupPubkeyFromWrapperType,
isUserConfigWrapperType,
} from '../webworker/workers/browser/libsession_worker_functions';
// eslint-disable-next-line import/no-unresolved, import/extensions
import { Data } from '../data/data';
import { ReleasedFeatures } from '../util/releaseFeature';
// eslint-disable-next-line import/no-unresolved
import {
ContactsWrapperActions,
ConvoInfoVolatileWrapperActions,
GenericWrapperActions,
MetaGroupWrapperActions,
UserConfigWrapperActions,
UserGroupsWrapperActions,
} from '../webworker/workers/browser/libsession_worker_interface';
import { addKeyPairToCacheAndDBIfNeeded } from './closedGroups';
import { HexKeyPair } from './keypairs';
import { queueAllCachedFromSource } from './receiver';
type IncomingUserResult = {
needsPush: boolean;
needsDump: boolean;
publicKey: string;
latestEnvelopeTimestamp: number;
namespace: SnodeNamespacesUserConfig;
};
function byUserNamespace(incomingConfigs: Array<RetrieveMessageItemWithNamespace>) {
const groupedByVariant: Map<
SnodeNamespacesUserConfig,
Array<RetrieveMessageItemWithNamespace>
> = new Map();
incomingConfigs.forEach(incomingConfig => {
const { namespace } = incomingConfig;
if (!SnodeNamespace.isUserConfigNamespace(namespace)) {
throw new Error(`Invalid namespace on byUserNamespace: ${namespace}`);
}
if (!groupedByVariant.has(namespace)) {
groupedByVariant.set(namespace, []);
}
groupedByVariant.get(namespace)?.push(incomingConfig);
});
return groupedByVariant;
}
async function printDumpForDebug(prefix: string, variant: ConfigWrapperObjectTypesMeta) {
if (isUserConfigWrapperType(variant)) {
window.log.info(prefix, StringUtils.toHex(await GenericWrapperActions.makeDump(variant)));
return;
}
const metaGroupDumps = await MetaGroupWrapperActions.metaMakeDump(
getGroupPubkeyFromWrapperType(variant)
);
window.log.info(prefix, StringUtils.toHex(metaGroupDumps));
}
async function mergeUserConfigsWithIncomingUpdates(
incomingConfigs: Array<RetrieveMessageItemWithNamespace>
): Promise<Map<ConfigWrapperUser, IncomingUserResult>> {
// first, group by namesapces so we do a single merge call
// Note: this call throws if given a non user kind as this function should only handle user variants/kinds
const groupedByNamespaces = byUserNamespace(incomingConfigs);
const groupedResults: Map<ConfigWrapperUser, IncomingUserResult> = new Map();
const us = UserUtils.getOurPubKeyStrFromCache();
try {
for (let index = 0; index < groupedByNamespaces.size; index++) {
const namespace = [...groupedByNamespaces.keys()][index];
const sameVariant = groupedByNamespaces.get(namespace);
if (!sameVariant?.length) {
continue;
}
const toMerge = sameVariant.map(msg => ({
data: from_base64(msg.data, base64_variants.ORIGINAL),
hash: msg.hash,
}));
const variant = LibSessionUtil.userNamespaceToVariant(namespace);
if (window.sessionFeatureFlags.debug.debugLibsessionDumps) {
await printDumpForDebug(
`printDumpsForDebugging: before merge of ${toMerge.length}, ${variant}:`,
variant
);
}
const hashesMerged = await GenericWrapperActions.merge(variant, toMerge);
const needsDump = await GenericWrapperActions.needsDump(variant);
const needsPush = await GenericWrapperActions.needsPush(variant);
const mergedTimestamps = sameVariant
.filter(m => hashesMerged.includes(m.hash))
.map(m => m.storedAt);
const latestEnvelopeTimestamp = Math.max(...mergedTimestamps);
window.log.debug(
`${variant}: needsPush:${needsPush} needsDump:${needsDump}; mergedCount:${hashesMerged.length} `
);
if (window.sessionFeatureFlags.debug.debugLibsessionDumps) {
await printDumpForDebug(`printDumpsForDebugging: after merge of ${variant}:`, variant);
}
const incomingConfResult: IncomingUserResult = {
needsDump,
needsPush,
publicKey: us,
namespace,
latestEnvelopeTimestamp: latestEnvelopeTimestamp || Date.now(),
};
groupedResults.set(variant, incomingConfResult);
}
return groupedResults;
} catch (e) {
window.log.error('mergeConfigsWithIncomingUpdates failed with', e);
throw e;
}
}
export function getSettingsKeyFromLibsessionWrapper(
wrapperType: ConfigWrapperObjectTypesMeta
): string | null {
if (!isUserConfigWrapperType(wrapperType)) {
throw new Error(
`getSettingsKeyFromLibsessionWrapper only cares about uservariants but got ${wrapperType}`
);
}
switch (wrapperType) {
case 'UserConfig':
return SettingsKey.latestUserProfileEnvelopeTimestamp;
case 'ContactsConfig':
return SettingsKey.latestUserContactsEnvelopeTimestamp;
case 'UserGroupsConfig':
return SettingsKey.latestUserGroupEnvelopeTimestamp;
case 'ConvoInfoVolatileConfig':
return null; // we don't really care about the convo info volatile one
default:
try {
assertUnreachable(
wrapperType,
`getSettingsKeyFromLibsessionWrapper unknown type: ${wrapperType}`
);
} catch (e) {
window.log.warn('assertUnreachable:', e.message);
}
return null;
}
}
async function updateLibsessionLatestProcessedUserTimestamp(
wrapperType: ConfigWrapperUser,
latestEnvelopeTimestamp: number
) {
const settingsKey = getSettingsKeyFromLibsessionWrapper(wrapperType);
if (!settingsKey) {
return;
}
const currentLatestEnvelopeProcessed = Storage.get(settingsKey) || 0;
const newLatestProcessed = Math.max(
latestEnvelopeTimestamp,
isNumber(currentLatestEnvelopeProcessed) ? currentLatestEnvelopeProcessed : 0
);
if (newLatestProcessed !== currentLatestEnvelopeProcessed || currentLatestEnvelopeProcessed) {
await Storage.put(settingsKey, newLatestProcessed);
}
}
/**
* NOTE When adding new properties to the wrapper, don't update the conversation model here because the merge has not been done yet.
* Instead you will need to updateOurProfileLegacyOrViaLibSession() to support them
*/
async function handleUserProfileUpdate(result: IncomingUserResult): Promise<void> {
const updateUserInfo = await UserConfigWrapperActions.getUserInfo();
if (!updateUserInfo) {
return;
}
const currentBlindedMsgRequest = Storage.get(SettingsKey.hasBlindedMsgRequestsEnabled);
const newBlindedMsgRequest = await UserConfigWrapperActions.getEnableBlindedMsgRequest();
if (!isNil(newBlindedMsgRequest) && newBlindedMsgRequest !== currentBlindedMsgRequest) {
await window.setSettingValue(SettingsKey.hasBlindedMsgRequestsEnabled, newBlindedMsgRequest); // this does the dispatch to redux
}
const picUpdate =
!isEmpty(updateUserInfo.key) &&
!isEmpty(updateUserInfo.url) &&
updateUserInfo.key.length === 32;
// NOTE: if you do any changes to the user's settings which are synced, it should be done above the `updateOurProfileViaLibSession` call
await updateOurProfileViaLibSession({
sentAt: result.latestEnvelopeTimestamp,
displayName: updateUserInfo.name,
profileUrl: picUpdate ? updateUserInfo.url : null,
profileKey: picUpdate ? updateUserInfo.key : null,
priority: updateUserInfo.priority,
});
// NOTE: If we want to update the conversation in memory with changes from the updated user profile we need to wait untl the profile has been updated to prevent multiple merge conflicts
const ourConvo = ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache());
if (ourConvo) {
let changes = false;
const expireTimer = ourConvo.getExpireTimer();
const wrapperNoteToSelfExpirySeconds = await UserConfigWrapperActions.getNoteToSelfExpiry();
if (wrapperNoteToSelfExpirySeconds !== expireTimer) {
// TODO legacy messages support will be removed in a future release
const success = await ourConvo.updateExpireTimer({
providedDisappearingMode:
wrapperNoteToSelfExpirySeconds && wrapperNoteToSelfExpirySeconds > 0
? ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached()
? 'deleteAfterSend'
: 'legacy'
: 'off',
providedExpireTimer: wrapperNoteToSelfExpirySeconds,
providedSource: ourConvo.id,
sentAt: result.latestEnvelopeTimestamp,
fromSync: true,
shouldCommitConvo: false,
fromCurrentDevice: false,
fromConfigMessage: true,
});
changes = success;
}
// make sure to write the changes to the database now as the `AvatarDownloadJob` triggered by updateOurProfileLegacyOrViaLibSession might take some time before getting run
if (changes) {
await ourConvo.commit();
}
}
const settingsKey = SettingsKey.latestUserProfileEnvelopeTimestamp;
const currentLatestEnvelopeProcessed = Storage.get(settingsKey) || 0;
const newLatestProcessed = Math.max(
result.latestEnvelopeTimestamp,
isNumber(currentLatestEnvelopeProcessed) ? currentLatestEnvelopeProcessed : 0
);
if (newLatestProcessed !== currentLatestEnvelopeProcessed) {
await Storage.put(settingsKey, newLatestProcessed);
}
}
function getContactsToRemoveFromDB(contactsInWrapper: Array<ContactInfo>) {
const allContactsInDBWhichShouldBeInWrapperIds = ConvoHub.use()
.getConversations()
.filter(SessionUtilContact.isContactToStoreInWrapper)
.map(m => m.id as string);
const currentlySelectedConversationId = getCurrentlySelectedConversationOutsideRedux();
const currentlySelectedConvo = currentlySelectedConversationId
? ConvoHub.use().get(currentlySelectedConversationId)
: undefined;
// we might have some contacts not in the wrapper anymore, so let's clean things up.
const convoIdsInDbButNotWrapper = difference(
allContactsInDBWhichShouldBeInWrapperIds,
contactsInWrapper.map(m => m.id)
);
// When starting a conversation with a new user, it is not in the wrapper yet, only when we send the first message.
// We do not want to forcefully remove that contact as the user might be typing a message to him.
// So let's check if that currently selected conversation should be forcefully closed or not
if (
currentlySelectedConversationId &&
currentlySelectedConvo &&
convoIdsInDbButNotWrapper.includes(currentlySelectedConversationId)
) {
if (
currentlySelectedConvo.isPrivate() &&
!currentlySelectedConvo.isApproved() &&
!currentlySelectedConvo.didApproveMe()
) {
const foundIndex = convoIdsInDbButNotWrapper.findIndex(
m => m === currentlySelectedConversationId
);
if (foundIndex !== -1) {
convoIdsInDbButNotWrapper.splice(foundIndex, 1);
}
}
}
return convoIdsInDbButNotWrapper;
}
async function deleteContactsFromDB(contactsToRemove: Array<string>) {
window.log.debug('contacts to fully remove after wrapper merge', contactsToRemove);
for (let index = 0; index < contactsToRemove.length; index++) {
const contactToRemove = contactsToRemove[index];
try {
await ConvoHub.use().delete1o1(contactToRemove, {
fromSyncMessage: true,
justHidePrivate: false,
keepMessages: false,
});
} catch (e) {
window.log.warn(
`after merge: deleteContactsFromDB ${contactToRemove} failed with `,
e.message
);
}
}
}
async function handleContactsUpdate(result: IncomingUserResult) {
const us = UserUtils.getOurPubKeyStrFromCache();
const allContactsInWrapper = await ContactsWrapperActions.getAll();
const contactsToRemoveFromDB = getContactsToRemoveFromDB(allContactsInWrapper);
await deleteContactsFromDB(contactsToRemoveFromDB);
// create new contact conversation here, and update their state with what is part of the wrapper
for (let index = 0; index < allContactsInWrapper.length; index++) {
const wrapperConvo = allContactsInWrapper[index];
if (wrapperConvo.id === us) {
// our profile update comes from our userProfile, not from the contacts wrapper.
continue;
}
const contactConvo = await ConvoHub.use().getOrCreateAndWait(
wrapperConvo.id,
ConversationTypeEnum.PRIVATE
);
if (wrapperConvo.id && contactConvo) {
let changes = false;
// the display name set is handled in `updateProfileOfContact`
if (wrapperConvo.nickname !== contactConvo.getNickname()) {
await contactConvo.setNickname(wrapperConvo.nickname || null, false);
changes = true;
}
const currentPriority = contactConvo.getPriority();
if (wrapperConvo.priority !== currentPriority) {
if (wrapperConvo.priority === CONVERSATION_PRIORITIES.hidden) {
window.log.info(
'contact marked as hidden and was not before. Deleting all messages from that user'
);
await deleteAllMessagesByConvoIdNoConfirmation(wrapperConvo.id);
}
await contactConvo.setPriorityFromWrapper(wrapperConvo.priority);
changes = true;
}
if (Boolean(wrapperConvo.approved) !== contactConvo.isApproved()) {
await contactConvo.setIsApproved(Boolean(wrapperConvo.approved), false);
changes = true;
}
if (Boolean(wrapperConvo.approvedMe) !== contactConvo.didApproveMe()) {
await contactConvo.setDidApproveMe(Boolean(wrapperConvo.approvedMe), false);
changes = true;
}
if (
wrapperConvo.expirationTimerSeconds !== contactConvo.getExpireTimer() ||
wrapperConvo.expirationMode !== contactConvo.getExpirationMode()
) {
const success = await contactConvo.updateExpireTimer({
providedDisappearingMode: wrapperConvo.expirationMode,
providedExpireTimer: wrapperConvo.expirationTimerSeconds,
providedSource: wrapperConvo.id,
sentAt: result.latestEnvelopeTimestamp, // this is most likely incorrect, but that's all we have
fromSync: true,
fromCurrentDevice: false,
shouldCommitConvo: false,
fromConfigMessage: true,
});
changes = changes || success;
}
// we want to set the active_at to the created_at timestamp if active_at is unset, so that it shows up in our list.
if (!contactConvo.getActiveAt() && wrapperConvo.createdAtSeconds) {
contactConvo.set({ active_at: wrapperConvo.createdAtSeconds * 1000 });
changes = true;
}
const convoBlocked = wrapperConvo.blocked || false;
await BlockedNumberController.setBlocked(wrapperConvo.id, convoBlocked);
// make sure to write the changes to the database now as the `AvatarDownloadJob` below might take some time before getting run
if (changes) {
await contactConvo.commit();
}
// we still need to handle the `name` (synchronous) and the `profilePicture` (asynchronous)
await ProfileManager.updateProfileOfContact(
contactConvo.id,
wrapperConvo.name,
wrapperConvo.profilePicture?.url || null,
wrapperConvo.profilePicture?.key || null
);
}
}
}
async function handleCommunitiesUpdate() {
// first let's check which communities needs to be joined or left by doing a diff of what is in the wrapper and what is in the DB
const allCommunitiesInWrapper = await UserGroupsWrapperActions.getAllCommunities();
window.log.debug(
'allCommunitiesInWrapper',
allCommunitiesInWrapper.map(m => m.fullUrlWithPubkey)
);
const allCommunitiesConversation = ConvoHub.use()
.getConversations()
.filter(SessionUtilUserGroups.isCommunityToStoreInWrapper);
const allCommunitiesIdsInDB = allCommunitiesConversation.map(m => m.id as string);
window.log.debug('allCommunitiesIdsInDB', allCommunitiesIdsInDB);
const communitiesIdsInWrapper = compact(
allCommunitiesInWrapper.map(m => {
try {
const builtConvoId = OpenGroupUtils.getOpenGroupV2ConversationId(
m.baseUrl,
m.roomCasePreserved
);
return builtConvoId;
} catch (e) {
return null;
}
})
);
const communitiesToJoinInDB = compact(
allCommunitiesInWrapper.map(m => {
try {
const builtConvoId = OpenGroupUtils.getOpenGroupV2ConversationId(
m.baseUrl,
m.roomCasePreserved
);
return allCommunitiesIdsInDB.includes(builtConvoId) ? null : m;
} catch (e) {
return null;
}
})
);
const communitiesToLeaveInDB = compact(
allCommunitiesConversation.map(m => {
return communitiesIdsInWrapper.includes(m.id) ? null : m;
})
);
for (let index = 0; index < communitiesToLeaveInDB.length; index++) {
const toLeave = communitiesToLeaveInDB[index];
window.log.info('leaving community with convoId ', toLeave.id);
await ConvoHub.use().deleteCommunity(toLeave.id, {
fromSyncMessage: true,
});
}
// this call can take quite a long time but must be awaited (as it is async and create the entry in the DB, used as a diff)
try {
await Promise.all(
communitiesToJoinInDB.map(async toJoin => {
window.log.info('joining community with convoId ', toJoin.fullUrlWithPubkey);
return getOpenGroupManager().attemptConnectionV2OneAtATime(
toJoin.baseUrl,
toJoin.roomCasePreserved,
toJoin.pubkeyHex
);
})
);
} catch (e) {
window.log.warn(
`joining community with failed with one of ${communitiesToJoinInDB}`,
e.message
);
}
// if the convos already exists, make sure to update the fields if needed
for (let index = 0; index < allCommunitiesInWrapper.length; index++) {
const fromWrapper = allCommunitiesInWrapper[index];
const convoId = OpenGroupUtils.getOpenGroupV2ConversationId(
fromWrapper.baseUrl,
fromWrapper.roomCasePreserved
);
const communityConvo = ConvoHub.use().get(convoId);
if (fromWrapper && communityConvo) {
let changes = false;
changes =
(await communityConvo.setPriorityFromWrapper(fromWrapper.priority, false)) || changes;
// make sure to write the changes to the database now as the `AvatarDownloadJob` below might take some time before getting run
if (changes) {
await communityConvo.commit();
}
}
}
}
async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
// first let's check which closed groups needs to be joined or left by doing a diff of what is in the wrapper and what is in the DB
const allLegacyGroupsInWrapper = await UserGroupsWrapperActions.getAllLegacyGroups();
const allLegacyGroupsInDb = ConvoHub.use()
.getConversations()
.filter(SessionUtilUserGroups.isLegacyGroupToRemoveFromDBIfNotInWrapper);
const allLegacyGroupsIdsInDB = allLegacyGroupsInDb.map(m => m.id as string);
const allLegacyGroupsIdsInWrapper = allLegacyGroupsInWrapper.map(m => m.pubkeyHex);
const legacyGroupsToJoinInDB = allLegacyGroupsInWrapper.filter(m => {
return !allLegacyGroupsIdsInDB.includes(m.pubkeyHex);
});
window.log.debug(`allLegacyGroupsInWrapper: ${allLegacyGroupsInWrapper.map(m => m.pubkeyHex)} `);
window.log.debug(`allLegacyGroupsIdsInDB: ${allLegacyGroupsIdsInDB} `);
const legacyGroupsToLeaveInDB = allLegacyGroupsInDb.filter(m => {
return !allLegacyGroupsIdsInWrapper.includes(m.id);
});
window.log.info(
`we have to join ${legacyGroupsToJoinInDB.length} legacy groups in DB compared to what is in the wrapper`
);
window.log.info(
`we have to leave ${legacyGroupsToLeaveInDB.length} legacy groups in DB compared to what is in the wrapper`
);
for (let index = 0; index < legacyGroupsToLeaveInDB.length; index++) {
const toLeave = legacyGroupsToLeaveInDB[index];
window.log.info(
'leaving legacy group from configuration sync message with convoId ',
toLeave.id
);
const toLeaveFromDb = ConvoHub.use().get(toLeave.id);
// the wrapper told us that this group is not tracked, so even if we left/got kicked from it, remove it from the DB completely
await ConvoHub.use().deleteClosedGroup(toLeaveFromDb.id, {
fromSyncMessage: true,
sendLeaveMessage: false, // this comes from the wrapper, so we must have left/got kicked from that group already and our device already handled it.
});
}
for (let index = 0; index < legacyGroupsToJoinInDB.length; index++) {
const toJoin = legacyGroupsToJoinInDB[index];
window.log.info(
'joining legacy group from configuration sync message with convoId ',
toJoin.pubkeyHex
);
// let's just create the required convo here, as we update the fields right below
await ConvoHub.use().getOrCreateAndWait(toJoin.pubkeyHex, ConversationTypeEnum.GROUP);
}
for (let index = 0; index < allLegacyGroupsInWrapper.length; index++) {
const fromWrapper = allLegacyGroupsInWrapper[index];
const legacyGroupConvo = ConvoHub.use().get(fromWrapper.pubkeyHex);
if (!legacyGroupConvo) {
// this should not happen as we made sure to create them before
window.log.warn(
'could not find legacy group which should already be there:',
fromWrapper.pubkeyHex
);
continue;
}
const members = fromWrapper.members.map(m => m.pubkeyHex);
const admins = fromWrapper.members.filter(m => m.isAdmin).map(m => m.pubkeyHex);
const activeAt = legacyGroupConvo.getActiveAt();
// then for all the existing legacy group in the wrapper, we need to override the field of what we have in the DB with what is in the wrapper
// We only set group admins on group creation
const groupDetails: GroupInfo = {
id: fromWrapper.pubkeyHex,
name: fromWrapper.name,
members,
admins,
activeAt:
!!activeAt && activeAt < latestEnvelopeTimestamp
? legacyGroupConvo.getActiveAt()
: latestEnvelopeTimestamp,
};
await ClosedGroup.updateOrCreateClosedGroup(groupDetails);
let changes = await legacyGroupConvo.setPriorityFromWrapper(fromWrapper.priority, false);
if (fromWrapper.disappearingTimerSeconds !== legacyGroupConvo.getExpireTimer()) {
// TODO legacy messages support will be removed in a future release
const success = await legacyGroupConvo.updateExpireTimer({
providedDisappearingMode:
fromWrapper.disappearingTimerSeconds && fromWrapper.disappearingTimerSeconds > 0
? ReleasedFeatures.isDisappearMessageV2FeatureReleasedCached()
? 'deleteAfterSend'
: 'legacy'
: 'off',
providedExpireTimer: fromWrapper.disappearingTimerSeconds,
providedSource: legacyGroupConvo.id,
sentAt: latestEnvelopeTimestamp,
fromSync: true,
shouldCommitConvo: false,
fromCurrentDevice: false,
fromConfigMessage: true,
});
changes = success;
}
const existingTimestampMs = legacyGroupConvo.getLastJoinedTimestamp();
const existingJoinedAtSeconds = Math.floor(existingTimestampMs / 1000);
if (existingJoinedAtSeconds !== fromWrapper.joinedAtSeconds) {
legacyGroupConvo.set({
lastJoinedTimestamp: fromWrapper.joinedAtSeconds * 1000,
});
changes = true;
}
// start polling for this group if we are still part of it.
if (!legacyGroupConvo.isKickedFromGroup()) {
getSwarmPollingInstance().addGroupId(PubKey.cast(fromWrapper.pubkeyHex));
// save the encryption keypair if needed
if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) {
try {
const inWrapperKeypair: HexKeyPair = {
publicHex: toHex(fromWrapper.encPubkey),
privateHex: toHex(fromWrapper.encSeckey),
};
await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair);
} catch (e) {
window.log.warn('failed to save keypair for legacugroup', fromWrapper.pubkeyHex);
}
}
}
if (changes) {
// this commit will grab the latest encryption keypair and add it to the user group wrapper if needed
await legacyGroupConvo.commit();
}
// trigger decrypting of all this group messages we did not decrypt successfully yet.
await queueAllCachedFromSource(fromWrapper.pubkeyHex);
}
}
async function handleSingleGroupUpdate({
groupInWrapper,
userEdKeypair,
}: {
groupInWrapper: UserGroupsGet;
latestEnvelopeTimestamp: number;
userEdKeypair: UserUtils.ByteKeyPair;
}) {
const groupPk = groupInWrapper.pubkeyHex;
try {
// dump is always empty when creating a new groupInfo
await MetaGroupWrapperActions.init(groupPk, {
metaDumped: null,
userEd25519Secretkey: toFixedUint8ArrayOfLength(userEdKeypair.privKeyBytes, 64).buffer,
groupEd25519Secretkey: groupInWrapper.secretKey,
groupEd25519Pubkey: toFixedUint8ArrayOfLength(HexString.fromHexString(groupPk.slice(2)), 32)
.buffer,
});
} catch (e) {
window.log.warn(
`handleSingleGroupUpdate metawrapper init of "${groupPk}" failed with`,
e.message
);
}
if (!ConvoHub.use().get(groupPk)) {
const created = await ConvoHub.use().getOrCreateAndWait(groupPk, ConversationTypeEnum.GROUPV2);
const joinedAt = groupInWrapper.joinedAtSeconds * 1000 || Date.now();
created.set({
active_at: joinedAt,
displayNameInProfile: groupInWrapper.name || undefined,
priority: groupInWrapper.priority,
lastJoinedTimestamp: joinedAt,
});
await created.commit();
getSwarmPollingInstance().addGroupId(PubKey.cast(groupPk));
}
}
async function handleSingleGroupUpdateToLeave(toLeave: GroupPubkeyType) {
// that group is not in the wrapper but in our local DB. it must be removed and cleaned
try {
window.log.debug(
`About to deleteGroup ${toLeave} via handleSingleGroupUpdateToLeave as in DB but not in wrapper`
);
await ConvoHub.use().deleteClosedGroup(toLeave, {
fromSyncMessage: true,
sendLeaveMessage: false,
});
} catch (e) {
window.log.info('Failed to deleteClosedGroup with: ', e.message);
}
}
/**
* Called when we just got a userGroups merge from the network. We need to apply the changes to our local state. (i.e. DB and redux slice of 03 groups)
*/
async function handleGroupUpdate(latestEnvelopeTimestamp: number) {
// first let's check which groups needs to be joined or left by doing a diff of what is in the wrapper and what is in the DB
const allGroupsInWrapper = await UserGroupsWrapperActions.getAllGroups();
const allGoupsIdsInDb = ConvoHub.use()
.getConversations()
.map(m => m.id)
.filter(PubKey.is03Pubkey);
const allGoupsIdsInWrapper = allGroupsInWrapper.map(m => m.pubkeyHex);
window.log.debug('allGoupsIdsInWrapper', stringify(allGoupsIdsInWrapper));
window.log.debug('allGoupsIdsInDb', stringify(allGoupsIdsInDb));
const userEdKeypair = await UserUtils.getUserED25519KeyPairBytes();
if (!userEdKeypair) {
throw new Error('userEdKeypair is not set');
}
for (let index = 0; index < allGroupsInWrapper.length; index++) {
const groupInWrapper = allGroupsInWrapper[index];
window.inboxStore.dispatch(groupInfoActions.handleUserGroupUpdate(groupInWrapper));
await handleSingleGroupUpdate({ groupInWrapper, latestEnvelopeTimestamp, userEdKeypair });
}
const groupsInDbButNotInWrapper = difference(allGoupsIdsInDb, allGoupsIdsInWrapper);
window.log.info(
`we have to leave ${groupsInDbButNotInWrapper.length} 03 groups in DB compared to what is in the wrapper`
);
for (let index = 0; index < groupsInDbButNotInWrapper.length; index++) {
const toRemove = groupsInDbButNotInWrapper[index];
await handleSingleGroupUpdateToLeave(toRemove);
}
}
async function handleUserGroupsUpdate(result: IncomingUserResult) {
const toHandle = SessionUtilUserGroups.getUserGroupTypes();
for (let index = 0; index < toHandle.length; index++) {
const typeToHandle = toHandle[index];
switch (typeToHandle) {
case 'Community':
await handleCommunitiesUpdate();
break;
case 'LegacyGroup':
await handleLegacyGroupUpdate(result.latestEnvelopeTimestamp);
break;
case 'Group':
await handleGroupUpdate(result.latestEnvelopeTimestamp);
break;
default:
assertUnreachable(typeToHandle, `handleUserGroupsUpdate unhandled type "${typeToHandle}"`);
}
}
}
async function applyConvoVolatileUpdateFromWrapper(
convoId: string,
forcedUnread: boolean,
lastReadMessageTimestamp: number
) {
const foundConvo = ConvoHub.use().get(convoId);
if (!foundConvo) {
return;
}
try {
// TODO legacy messages support will be removed in a future release
if (foundConvo.isPrivate() && !foundConvo.isMe() && foundConvo.getExpireTimer() > 0) {
const messagesExpiring = await Data.getUnreadDisappearingByConversation(
convoId,
lastReadMessageTimestamp
);
const messagesExpiringAfterRead = messagesExpiring.filter(
m => m.getExpirationType() === 'deleteAfterRead' && m.getExpireTimerSeconds() > 0
);
const messageIdsToFetchExpiriesFor = compact(messagesExpiringAfterRead.map(m => m.id));
if (messageIdsToFetchExpiriesFor.length) {
await FetchMsgExpirySwarm.queueNewJobIfNeeded(messageIdsToFetchExpiriesFor);
}
}
// this mark all the messages sent before fromWrapper.lastRead as read and update the unreadCount
await foundConvo.markReadFromConfigMessage(lastReadMessageTimestamp);
// this commits to the DB, if needed
await foundConvo.markAsUnread(forcedUnread, true);
if (SessionUtilConvoInfoVolatile.isConvoToStoreInWrapper(foundConvo)) {
await SessionUtilConvoInfoVolatile.refreshConvoVolatileCached(
foundConvo.id,
foundConvo.isClosedGroup(),
false
);
await foundConvo.refreshInMemoryDetails();
}
} catch (e) {
window.log.warn(
`applyConvoVolatileUpdateFromWrapper of "${convoId}" failed with error ${e.message}`
);
}
}
async function handleConvoInfoVolatileUpdate() {
const types = SessionUtilConvoInfoVolatile.getConvoInfoVolatileTypes();
for (let typeIndex = 0; typeIndex < types.length; typeIndex++) {
const type = types[typeIndex];
switch (type) {
case '1o1':
try {
// Note: "Note to Self" comes here too
const wrapper1o1s = await ConvoInfoVolatileWrapperActions.getAll1o1();
for (let index = 0; index < wrapper1o1s.length; index++) {
const fromWrapper = wrapper1o1s[index];
await applyConvoVolatileUpdateFromWrapper(
fromWrapper.pubkeyHex,
fromWrapper.unread,
fromWrapper.lastRead
);
}
} catch (e) {
window.log.warn('handleConvoInfoVolatileUpdate of "1o1" failed with error: ', e.message);
}
break;
case 'Community':
try {
const wrapperComms = await ConvoInfoVolatileWrapperActions.getAllCommunities();
for (let index = 0; index < wrapperComms.length; index++) {
const fromWrapper = wrapperComms[index];
const convoId = getOpenGroupV2ConversationId(
fromWrapper.baseUrl,
fromWrapper.roomCasePreserved
);
await applyConvoVolatileUpdateFromWrapper(
convoId,
fromWrapper.unread,
fromWrapper.lastRead
);
}
} catch (e) {
window.log.warn(
'handleConvoInfoVolatileUpdate of "Community" failed with error: ',
e.message
);
}
break;
case 'LegacyGroup':
try {
const legacyGroups = await ConvoInfoVolatileWrapperActions.getAllLegacyGroups();
for (let index = 0; index < legacyGroups.length; index++) {
const fromWrapper = legacyGroups[index];
await applyConvoVolatileUpdateFromWrapper(
fromWrapper.pubkeyHex,
fromWrapper.unread,
fromWrapper.lastRead
);
}
} catch (e) {
window.log.warn(
'handleConvoInfoVolatileUpdate of "LegacyGroup" failed with error: ',
e.message
);
}
break;
case 'Group':
try {
const groupsV2 = await ConvoInfoVolatileWrapperActions.getAllGroups();
for (let index = 0; index < groupsV2.length; index++) {
const fromWrapper = groupsV2[index];
try {
await applyConvoVolatileUpdateFromWrapper(
fromWrapper.pubkeyHex,
fromWrapper.unread,
fromWrapper.lastRead
);
} catch (e) {
window.log.warn(
'handleConvoInfoVolatileUpdate of "Group" failed with error: ',
e.message
);
}
}
} catch (e) {
window.log.warn('getAllGroups of "Group" failed with error: ', e.message);
}
break;
default:
assertUnreachable(type, `handleConvoInfoVolatileUpdate: unhandeld switch case: ${type}`);
}
}
}
async function processUserMergingResults(results: Map<ConfigWrapperUser, IncomingUserResult>) {
if (!results || !results.size) {
return;
}
const keys = [...results.keys()];
let anyNeedsPush = false;
for (let index = 0; index < keys.length; index++) {
const wrapperType = keys[index];
const incomingResult = results.get(wrapperType);
if (!incomingResult) {
continue;
}
try {
const { namespace } = incomingResult;
switch (namespace) {
case SnodeNamespaces.UserProfile:
await handleUserProfileUpdate(incomingResult);
break;
case SnodeNamespaces.UserContacts:
await handleContactsUpdate(incomingResult);
break;
case SnodeNamespaces.UserGroups:
await handleUserGroupsUpdate(incomingResult);
break;
case SnodeNamespaces.ConvoInfoVolatile:
await handleConvoInfoVolatileUpdate();
break;
default:
try {
// we catch errors here because an old client knowing about a new type of config coming from the network should not just crash
assertUnreachable(
namespace,
`processUserMergingResults unsupported namespace: "${namespace}"`
);
} catch (e) {
window.log.warn('assertUnreachable failed', e.message);
}
}
const variant = LibSessionUtil.userNamespaceToVariant(namespace);
try {
await updateLibsessionLatestProcessedUserTimestamp(
variant,
incomingResult.latestEnvelopeTimestamp
);
} catch (e) {
window.log.error(`updateLibsessionLatestProcessedUserTimestamp failed with "${e.message}"`);
}
if (incomingResult.needsDump) {
// The config data had changes so regenerate the dump and save it
const dump = await GenericWrapperActions.dump(variant);
await ConfigDumpData.saveConfigDump({
data: dump,
publicKey: incomingResult.publicKey,
variant,
});
}
if (incomingResult.needsPush) {
anyNeedsPush = true;
}
} catch (e) {
window.log.error(`processMergingResults failed with ${e.message}`);
return;
}
}
// Now that the local state has been updated, trigger a config sync (this will push any
// pending updates and properly update the state)
if (anyNeedsPush) {
await UserSync.queueNewJobIfNeeded();
}
}
async function handleUserConfigMessagesViaLibSession(
configMessages: Array<RetrieveMessageItemWithNamespace>
) {
if (isEmpty(configMessages)) {
return;
}
window?.log?.debug(
`Handling our sharedConfig message via libsession_util ${JSON.stringify(
configMessages.map(m => ({
hash: m.hash,
namespace: m.namespace,
}))
)}`
);
const incomingMergeResult = await mergeUserConfigsWithIncomingUpdates(configMessages);
await processUserMergingResults(incomingMergeResult);
}
async function updateOurProfileViaLibSession(
{
displayName,
priority,
profileKey,
profileUrl,
sentAt,
}: {
sentAt: number;
displayName: string;
profileUrl: string | null;
profileKey: Uint8Array | null;
priority: number | null;
} // passing null means to not update the priority at all (used for legacy config message for now)
) {
await ProfileManager.updateOurProfileSync({ displayName, profileUrl, profileKey, priority });
await setLastProfileUpdateTimestamp(toNumber(sentAt));
// do not trigger a signin by linking if the display name is empty
if (!isEmpty(displayName)) {
trigger(configurationMessageReceived, displayName);
} else {
window?.log?.warn('Got a configuration message but the display name is empty');
}
}
export const ConfigMessageHandler = {
handleUserConfigMessagesViaLibSession,
};