From c4e17d082581f368a9bf322d9baebcaddd3e140e Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 8 Mar 2023 17:39:29 +1100 Subject: [PATCH] fix: add mgmt of members in user groups wrapper --- .../AvatarPlaceHolder/ClosedGroupAvatar.tsx | 5 +- .../conversation/TimerNotification.tsx | 4 +- .../message-item/GroupUpdateMessage.tsx | 6 +- .../dialog/AdminLeaveClosedGroupDialog.tsx | 15 +- .../dialog/SessionPasswordDialog.tsx | 4 +- ts/components/lightbox/Lightbox.tsx | 3 +- ts/components/menu/Menu.tsx | 2 +- .../settings/SessionSettingsHeader.tsx | 9 +- ts/interactions/conversationInteractions.ts | 3 +- ts/mains/main_renderer.tsx | 12 +- ts/models/conversation.ts | 38 +- ts/node/migration/sessionMigrations.ts | 207 +++++++--- ts/node/sql.ts | 21 +- ts/receiver/cache.ts | 10 +- ts/receiver/closedGroups.ts | 1 - ts/receiver/configMessage.ts | 374 ++++++++++++++---- ts/receiver/contentMessage.ts | 86 ++-- ts/receiver/receiver.ts | 24 +- .../opengroupV2/JoinOpenGroupV2.ts | 3 +- .../opengroupV2/OpenGroupManagerV2.ts | 7 +- .../apis/open_group_api/sogsv3/sogsApiV3.ts | 16 +- .../open_group_api/sogsv3/sogsV3BatchPoll.ts | 14 +- ts/session/apis/snode_api/swarmPolling.ts | 153 ++++--- .../conversations/ConversationController.ts | 50 ++- ts/session/conversations/createClosedGroup.ts | 6 +- ts/session/group/closed-group.ts | 7 +- ts/session/sending/MessageQueue.ts | 1 - ts/session/sending/MessageSender.ts | 1 - .../jobs/ConfigurationSyncDumpJob.ts | 36 +- .../job_runners/jobs/ConfigurationSyncJob.ts | 31 +- .../utils/libsession/libsession_utils.ts | 22 +- .../libsession/libsession_utils_contacts.ts | 8 +- .../libsession_utils_user_groups.ts | 189 ++++++--- ts/session/utils/sync/syncUtils.ts | 1 + .../libsession_wrapper_contacts_test.ts | 41 +- .../libsession_wrapper_usergroups_test.ts | 113 +++++- ts/test/session/unit/utils/SyncUtils_test.ts | 22 -- ts/types/sqlSharedTypes.ts | 74 +++- .../browser/libsession_worker_interface.ts | 20 + .../node/libsession/libsession.worker.ts | 4 +- 40 files changed, 1135 insertions(+), 508 deletions(-) delete mode 100644 ts/test/session/unit/utils/SyncUtils_test.ts diff --git a/ts/components/avatar/AvatarPlaceHolder/ClosedGroupAvatar.tsx b/ts/components/avatar/AvatarPlaceHolder/ClosedGroupAvatar.tsx index 486993064..ba7525f07 100644 --- a/ts/components/avatar/AvatarPlaceHolder/ClosedGroupAvatar.tsx +++ b/ts/components/avatar/AvatarPlaceHolder/ClosedGroupAvatar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useMembersAvatars } from '../../../hooks/useMembersAvatars'; +import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { Avatar, AvatarSize } from '../Avatar'; type Props = { @@ -11,6 +12,8 @@ type Props = { function getClosedGroupAvatarsSize(size: AvatarSize): AvatarSize { // Always use the size directly under the one requested switch (size) { + case AvatarSize.XS: + throw new Error('AvatarSize.XS is not supported for closed group avatar sizes'); case AvatarSize.S: return AvatarSize.XS; case AvatarSize.M: @@ -22,7 +25,7 @@ function getClosedGroupAvatarsSize(size: AvatarSize): AvatarSize { case AvatarSize.HUGE: return AvatarSize.XL; default: - throw new Error(`Invalid size request for closed group avatar: ${size}`); + assertUnreachable(size, `Invalid size request for closed group avatar "${size}"`); } } diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 698740657..0aeb232ce 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { missingCaseError } from '../../util/missingCaseError'; import { PropsForExpirationTimer } from '../../state/ducks/conversations'; import { NotificationBubble } from './message/message-item/notification-bubble/NotificationBubble'; import { ReadableMessage } from './message/message-item/ReadableMessage'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; export const TimerNotification = (props: PropsForExpirationTimer) => { const { messageId, receivedAt, isUnread, pubkey, profileName, timespan, type, disabled } = props; @@ -28,7 +28,7 @@ export const TimerNotification = (props: PropsForExpirationTimer) => { : window.i18n('timerSetOnSync', [timespan]); break; default: - throw missingCaseError(type); + assertUnreachable(type, `TimerNotification: Missing case error "${type}"`); } if (!textToRender || textToRender.length === 0) { diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index 96afe7bc3..723555936 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -8,6 +8,7 @@ import { NotificationBubble } from './notification-bubble/NotificationBubble'; import { ReadableMessage } from './ReadableMessage'; import { arrayContainsUsOnly } from '../../../../models/message'; import { useConversationsUsernameWithQuoteOrFullPubkey } from '../../../../hooks/useParamSelector'; +import { assertUnreachable } from '../../../../types/sqlSharedTypes'; // This component is used to display group updates in the conversation view. @@ -51,7 +52,8 @@ const ChangeItemLeft = (left: Array): string => { // tslint:disable-next-line: cyclomatic-complexity const ChangeItem = (change: PropsForGroupUpdateType): string => { - switch (change.type) { + const type = change.type; + switch (type) { case 'name': return window.i18n('titleIsNow', [change.newName || '']); case 'add': @@ -66,7 +68,7 @@ const ChangeItem = (change: PropsForGroupUpdateType): string => { case 'general': return window.i18n('updatedTheGroup'); default: - throw new Error('Missing case error'); + assertUnreachable(type, `ChangeItem: Missing case error "${type}"`); } }; diff --git a/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx b/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx index abb95aeda..36c433593 100644 --- a/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx +++ b/ts/components/dialog/AdminLeaveClosedGroupDialog.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; -import { SpacerLG } from '../basic/Text'; +import React from 'react'; +import styled from 'styled-components'; import { getConversationController } from '../../session/conversations'; +import { leaveClosedGroup } from '../../session/group/closed-group'; import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; +import { SpacerLG } from '../basic/Text'; import { SessionWrapperModal } from '../SessionWrapperModal'; -import styled from 'styled-components'; type Props = { conversationId: string; @@ -21,15 +22,9 @@ export const AdminLeaveClosedGroupDialog = (props: Props) => { const warningAsAdmin = `${window.i18n('leaveGroupConfirmationAdmin')}`; const okText = window.i18n('leaveAndRemoveForEveryone'); const cancelText = window.i18n('cancel'); - const [_isLoading, setIsLoading] = useState(false); const onClickOK = async () => { - setIsLoading(true); - await getConversationController() - .get(props.conversationId) - .leaveClosedGroup(); - setIsLoading(false); - + await leaveClosedGroup(props.conversationId); closeDialog(); }; diff --git a/ts/components/dialog/SessionPasswordDialog.tsx b/ts/components/dialog/SessionPasswordDialog.tsx index e085431fe..dcb94b658 100644 --- a/ts/components/dialog/SessionPasswordDialog.tsx +++ b/ts/components/dialog/SessionPasswordDialog.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { missingCaseError } from '../../util'; import { ToastUtils } from '../../session/utils'; import { Data } from '../../data/data'; import { SpacerSM } from '../basic/Text'; @@ -10,6 +9,7 @@ import { LocalizerKeys } from '../../types/LocalizerKeys'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; import { SessionWrapperModal } from '../SessionWrapperModal'; import { matchesHash, validatePassword } from '../../util/passwordUtils'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; export type PasswordAction = 'set' | 'change' | 'remove' | 'enter'; @@ -329,7 +329,7 @@ export class SessionPasswordDialog extends React.Component { return; } default: - throw missingCaseError(passwordAction); + assertUnreachable(passwordAction, 'passwordAction'); } } diff --git a/ts/components/lightbox/Lightbox.tsx b/ts/components/lightbox/Lightbox.tsx index c8d8f1d34..22a1afa03 100644 --- a/ts/components/lightbox/Lightbox.tsx +++ b/ts/components/lightbox/Lightbox.tsx @@ -15,6 +15,7 @@ import { SessionIconButton, SessionIconType } from '../icon'; import * as MIME from '../../types/MIME'; import { isUndefined } from 'lodash'; import styled from 'styled-components'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; const colorSVG = (url: string, color: string) => { return { @@ -166,7 +167,7 @@ const IconButton = ({ onClick, type }: IconButtonProps) => { break; default: - throw new TypeError(`Invalid button type: ${type}`); + assertUnreachable(type, `Invalid button type: ${type}`); } return ( diff --git a/ts/components/menu/Menu.tsx b/ts/components/menu/Menu.tsx index 62f846460..e671e0add 100644 --- a/ts/components/menu/Menu.tsx +++ b/ts/components/menu/Menu.tsx @@ -225,7 +225,7 @@ export const DeleteContactMenuItem = () => { onClickClose, okTheme: SessionButtonColor.Danger, onClickOk: async () => { - await getConversationController().deleteContact(convoId); + await getConversationController().deleteContact(convoId, false); }, }) ); diff --git a/ts/components/settings/SessionSettingsHeader.tsx b/ts/components/settings/SessionSettingsHeader.tsx index f08924bca..2f6f35772 100644 --- a/ts/components/settings/SessionSettingsHeader.tsx +++ b/ts/components/settings/SessionSettingsHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import styled from 'styled-components'; -import { missingCaseError } from '../../util'; +import { assertUnreachable } from '../../types/sqlSharedTypes'; import { SessionSettingCategory, SettingsViewProps } from './SessionSettings'; type Props = Pick; @@ -44,8 +44,13 @@ export const SettingsHeader = (props: Props) => { case SessionSettingCategory.Privacy: categoryTitle = window.i18n('privacySettingsTitle'); break; + case SessionSettingCategory.ClearData: + case SessionSettingCategory.MessageRequests: + case SessionSettingCategory.RecoveryPhrase: + throw new Error(`no header for should be tried to be rendered for "${category}"`); + default: - throw missingCaseError('SettingsHeader' as never); + assertUnreachable(category, `SettingsHeader "${category}"`); } return ( diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 153452356..afeeaaa38 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -36,6 +36,7 @@ import { encryptProfile } from '../util/crypto/profileEncrypter'; import { setLastProfileUpdateTimestamp } from '../util/storage'; import { OpenGroupUtils } from '../session/apis/open_group_api/utils'; import { SessionUtilUserGroups } from '../session/utils/libsession/libsession_utils_user_groups'; +import { leaveClosedGroup } from '../session/group/closed-group'; export function copyPublicKeyByConvoId(convoId: string) { if (OpenGroupUtils.isOpenGroupV2(convoId)) { @@ -203,7 +204,7 @@ export function showLeaveGroupByConvoId(conversationId: string) { title, message, onClickOk: async () => { - await conversation.leaveClosedGroup(); + await leaveClosedGroup(conversation.id); onClickClose(); }, onClickClose, diff --git a/ts/mains/main_renderer.tsx b/ts/mains/main_renderer.tsx index 95e96faa0..a66573cc5 100644 --- a/ts/mains/main_renderer.tsx +++ b/ts/mains/main_renderer.tsx @@ -188,10 +188,14 @@ Storage.onready(async () => { await window.Events.setThemeSetting(newThemeSetting); try { - try { - await LibSessionUtil.initializeLibSessionUtilWrappers(); - } catch (e) { - window.log.warn('LibSessionUtil.initializeLibSessionUtilWrappers failed with', e.message); + if (Registration.isDone()) { + try { + await LibSessionUtil.initializeLibSessionUtilWrappers(); + } catch (e) { + window.log.warn('LibSessionUtil.initializeLibSessionUtilWrappers failed with', e.message); + // TODO what should we do if this happens? + throw e; + } } await initialiseEmojiData(nativeEmojiData); await AttachmentDownloads.initAttachmentPaths(); diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 14c1e2c6d..d8fcbf893 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -18,7 +18,6 @@ import { import { SignalService } from '../protobuf'; import { getMessageQueue } from '../session'; import { getConversationController } from '../session/conversations'; -import { leaveClosedGroup } from '../session/group/closed-group'; import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleMessage/ClosedGroupVisibleMessage'; import { PubKey } from '../session/types'; import { ToastUtils, UserUtils } from '../session/utils'; @@ -103,6 +102,7 @@ import { isOpenOrClosedGroup, } from './conversationAttributes'; import { SessionUtilUserGroups } from '../session/utils/libsession/libsession_utils_user_groups'; +import { Registration } from '../util/registration'; export class ConversationModel extends Backbone.Model { public updateLastMessage: () => any; @@ -343,10 +343,6 @@ export class ConversationModel extends Backbone.Model { toRet.isTyping = true; } - if (isTyping) { - toRet.isTyping = true; - } - if (avatarPath) { toRet.avatarPath = avatarPath; } @@ -1279,16 +1275,6 @@ export class ConversationModel extends Backbone.Model { }); } - public async leaveClosedGroup() { - if (this.isMediumGroup()) { - await leaveClosedGroup(this.id); - } else { - throw new Error( - 'Legacy group are not supported anymore. You need to create this group again.' - ); - } - } - /** * Mark everything as read efficiently if possible. * @@ -2241,31 +2227,31 @@ export async function commitConversationAndRefreshWrapper(id: string) { } // 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.filterContactsToStoreInContactsWrapper( + const shouldBeSavedToContactsWrapper = SessionUtilContact.isContactToStoreInContactsWrapper( convo ); - const shouldBeSavedToUserGroupsWrapper = SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( - convo + const shouldBeSavedToUserGroupsWrapper = SessionUtilUserGroups.isUserGroupToStoreInWrapper(convo); + + console.warn( + `should be saved to wrapper ${id}: contacts:${shouldBeSavedToContactsWrapper}; usergroups:${shouldBeSavedToUserGroupsWrapper}` ); - console.warn(`should be saved to contacts wrapper ${id}: ${shouldBeSavedToContactsWrapper}`); - console.warn(`should be saved to usergroups wrapper ${id}: ${shouldBeSavedToUserGroupsWrapper}`); if (shouldBeSavedToContactsWrapper) { await SessionUtilContact.insertContactFromDBIntoWrapperAndRefresh(convo.id); } else if (shouldBeSavedToUserGroupsWrapper) { await SessionUtilUserGroups.insertGroupsFromDBIntoWrapperAndRefresh(convo.id); } - // 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(); + 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(); - // if we need to sync the dump, also send add a job for syncing - if (window.sessionFeatureFlags.useSharedUtilForUserConfig) { + // if we need to sync the dump, also send add a job for syncing await ConfigurationSync.queueNewJobIfNeeded(); } convo.triggerUIRefresh(); diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 649308747..ae6eb73ca 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -7,11 +7,13 @@ import { UserGroupsWrapperInsideWorker, } from 'session_util_wrapper'; import { ConversationAttributes } from '../../models/conversationAttributes'; +import { HexKeyPair } from '../../receiver/keypairs'; import { fromHexToArray } from '../../session/utils/String'; import { CONFIG_DUMP_TABLE, getCommunityInfoFromDBValues, getContactInfoFromDBValues, + getLegacyGroupInfoFromDBValues, } from '../../types/sqlSharedTypes'; import { CLOSED_GROUP_V2_KEY_PAIRS_TABLE, @@ -1361,6 +1363,51 @@ function insertCommunityIntoWrapper( } } +function insertLegacyGroupIntoWrapper( + legacyGroup: Pick< + ConversationAttributes, + 'hidden' | 'id' | 'isPinned' | 'expireTimer' | 'displayNameInProfile' + > & { members: string; groupAdmins: string }, // members and groupAdmins are still stringified here + userGroupConfigWrapper: UserGroupsWrapperInsideWorker, + db: BetterSqlite3.Database +) { + const { + isPinned, + id, + hidden, + expireTimer, + groupAdmins, + members, + displayNameInProfile, + } = legacyGroup; + + const latestEncryptionKeyPairHex = sqlNode.getLatestClosedGroupEncryptionKeyPair( + legacyGroup.id, + db + ) as HexKeyPair | undefined; + + const wrapperLegacyGroup = getLegacyGroupInfoFromDBValues({ + id, + hidden, + isPinned, + expireTimer, + groupAdmins, + members, + displayNameInProfile, + encPubkeyHex: latestEncryptionKeyPairHex?.publicHex || '', + encSeckeyHex: latestEncryptionKeyPairHex?.privateHex || '', + }); + + try { + console.info('Inserting legacy group into wrapper: ', wrapperLegacyGroup); + userGroupConfigWrapper.setLegacyGroup(wrapperLegacyGroup); + } catch (e) { + console.error( + `userGroupConfigWrapper.set during migration failed with ${e.message} for legacyGroup.id: "${legacyGroup.id}". Skipping that legacy group entirely` + ); + } +} + function getBlockedNumbersDuringMigration(db: BetterSqlite3.Database) { try { const blockedItem = sqlNode.getItemById('blocked', db); @@ -1368,13 +1415,13 @@ function getBlockedNumbersDuringMigration(db: BetterSqlite3.Database) { throw new Error('no blocked contacts at all'); } const foundBlocked = blockedItem?.value; - console.warn('foundBlockedNumbers during migration', foundBlocked); + console.info('foundBlockedNumbers during migration', foundBlocked); if (isArray(foundBlocked)) { return foundBlocked; } return []; } catch (e) { - console.warn('failed to read blocked numbers. Considering no blocked numbers', e.stack); + console.info('failed to read blocked numbers. Considering no blocked numbers', e.stack); return []; } } @@ -1411,7 +1458,7 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite WHERE type <> 'private' AND active_at > 0;` ).run({}); - db.exec(`CREATE TABLE configDump( + db.exec(`CREATE TABLE ${CONFIG_DUMP_TABLE}( variant TEXT NOT NULL, publicKey TEXT NOT NULL, data BLOB, @@ -1419,10 +1466,58 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite ); `); + const allOpengroupsConvo = db + .prepare( + `SELECT id FROM ${CONVERSATIONS_TABLE} WHERE + type = 'group' AND + id LIKE 'publicChat:%' + ORDER BY id ASC;` + ) + .all(); + + const allValidOpengroupsDetails = allOpengroupsConvo + .filter(m => isString(m.id) && m.id.indexOf('@') > 0) + .map(row => { + const roomNameStart = (row.id.indexOf(':') as number) + 1; + const roomNameEnd = row.id.indexOf('@'); + const roomName = row.id.substring(roomNameStart, roomNameEnd); + const baseUrl = row.id.substring((roomNameEnd as number) + 1); + + return { roomName, baseUrl, oldConvoId: row.id }; + }); + + allValidOpengroupsDetails.forEach(convoDetails => { + const newId = `${convoDetails.baseUrl}/${convoDetails.roomName}`; + db.prepare( + `UPDATE ${CONVERSATIONS_TABLE} SET + id = $newId + WHERE id = $oldId;` + ).run({ + newId, + oldId: convoDetails.oldConvoId, + }); + // do the same for messages and where else? + + db.prepare( + `UPDATE ${MESSAGES_TABLE} SET + conversationId = $newId, + json = json_set(json,'$.conversationId', $newId) + WHERE conversationId = $oldConvoId;` + ).run({ oldConvoId: convoDetails.oldConvoId, newId }); + + db.prepare( + `UPDATE ${OPEN_GROUP_ROOMS_V2_TABLE} SET + conversationId = $newId, + json = json_set(json, '$.conversationId', $newId);` + ).run({ newId }); + }); + try { const keys = getIdentityKeysDuringMigration(db); - if (!keys || !keys.privateEd25519 || isEmpty(keys.privateEd25519)) { + const userAlreadyCreated = !!keys && !isEmpty(keys.privateEd25519); + + if (!userAlreadyCreated) { throw new Error('privateEd25519 was empty. Considering no users are logged in'); } const { privateEd25519, publicKeyHex } = keys; @@ -1475,17 +1570,17 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite */ const blockedNumbers = getBlockedNumbersDuringMigration(db); - // this filter is based on the `filterContactsToStoreInContactsWrapper` function. + // this filter is based on the `isContactToStoreInContactsWrapper` function. const contactsToWriteInWrapper = db .prepare( - `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'private' AND active_at > 0 AND NOT hidden AND (didApproveMe OR isApproved) AND id <> $us AND id NOT LIKE '15%' ;` + `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'private' AND active_at > 0 AND NOT hidden AND (didApproveMe OR isApproved) AND id <> '$us' AND id NOT LIKE '15%' ;` ) .all({ us: publicKeyHex, }); if (isArray(contactsToWriteInWrapper) && contactsToWriteInWrapper.length) { - console.warn( + console.info( '===================== Starting contact inserting into wrapper =======================' ); @@ -1498,7 +1593,7 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite insertContactIntoWrapper(contact, blockedNumbers, contactsConfigWrapper); }); - console.warn('===================== Done with contact inserting ======================='); + console.info('===================== Done with contact inserting ======================='); } const contactsDump = contactsConfigWrapper.dump(); @@ -1518,80 +1613,64 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite data: contactsDump, }); - const allOpengroupsConvo = db + /** + * Setup up the UserGroups Wrapper with all the comunities details which needs to be stored in it. + */ + + // this filter is based on the `isCommunityToStoreInWrapper` function. + const communitiesToWriteInWrapper = db .prepare( - `SELECT id FROM ${CONVERSATIONS_TABLE} WHERE - type = 'group' AND - id LIKE 'publicChat:%' - ORDER BY id ASC;` + `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'group' AND active_at > 0 AND id LIKE 'http%' ;` ) - .all(); + .all({}); - const allValidOpengroupsDetails = allOpengroupsConvo - .filter(m => isString(m.id) && m.id.indexOf('@') > 0) - .map(row => { - const roomNameStart = (row.id.indexOf(':') as number) + 1; - const roomNameEnd = row.id.indexOf('@'); - const roomName = row.id.substring(roomNameStart, roomNameEnd); - const baseUrl = row.id.substring((roomNameEnd as number) + 1); + if (isArray(communitiesToWriteInWrapper) && communitiesToWriteInWrapper.length) { + console.info( + `===================== Starting communities inserting into wrapper ${communitiesToWriteInWrapper?.length} =======================` + ); - return { roomName, baseUrl, oldConvoId: row.id }; + communitiesToWriteInWrapper.forEach(community => { + try { + console.info('Writing community: ', JSON.stringify(community)); + insertCommunityIntoWrapper(community, userGroupsConfigWrapper, db); + } catch (e) { + console.info(`failed to insert community with ${e.message}`, community); + } }); - allValidOpengroupsDetails.forEach(convoDetails => { - const newId = `${convoDetails.baseUrl}/${convoDetails.roomName}`; - db.prepare( - `UPDATE ${CONVERSATIONS_TABLE} SET - id = $newId - WHERE id = $oldId;` - ).run({ - newId, - oldId: convoDetails.oldConvoId, - }); - // do the same for messages and where else? - - db.prepare( - `UPDATE ${MESSAGES_TABLE} SET - conversationId = $newId, - json = json_set(json,'$.conversationId', $newId) - WHERE conversationId = $oldConvoId;` - ).run({ oldConvoId: convoDetails.oldConvoId, newId }); - - db.prepare( - `UPDATE ${OPEN_GROUP_ROOMS_V2_TABLE} SET - conversationId = $newId, - json = json_set(json, '$.conversationId', $newId);` - ).run({ newId }); - }); + console.info( + '===================== Done with communinities inserting =======================' + ); + } - // this filter is based on the `filterUserGroupsToStoreInWrapper` function. - const communitiesToWriteInWrapper = db + // this filter is based on the `isLegacyGroupToStoreInWrapper` function. + const legacyGroupsToWriteInWrapper = db .prepare( - `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'group' AND active_at > 0 AND id LIKE 'http%' ;` + `SELECT * FROM ${CONVERSATIONS_TABLE} WHERE type = 'group' AND active_at > 0 AND id LIKE '05%' AND NOT isKickedFromGroup AND NOT left ;` ) .all({}); - if (isArray(communitiesToWriteInWrapper) && communitiesToWriteInWrapper.length) { - console.warn( - '===================== Starting communities inserting into wrapper =======================' - ); - + if (isArray(legacyGroupsToWriteInWrapper) && legacyGroupsToWriteInWrapper.length) { console.info( - 'Writing communities to wrapper during migration. length: ', - communitiesToWriteInWrapper?.length + `===================== Starting legacy group inserting into wrapper length: ${legacyGroupsToWriteInWrapper?.length} =======================` ); - communitiesToWriteInWrapper.forEach(community => { - insertCommunityIntoWrapper(community, userGroupsConfigWrapper, db); + legacyGroupsToWriteInWrapper.forEach(legacyGroup => { + try { + console.info('Writing legacy group: ', JSON.stringify(legacyGroup)); - console.info('Writing community: ', JSON.stringify(community)); + insertLegacyGroupIntoWrapper(legacyGroup, userGroupsConfigWrapper, db); + } catch (e) { + console.info(`failed to insert legacy group with ${e.message}`, legacyGroup); + } }); - console.warn( - '===================== Done with communinities inserting =======================' + console.info( + '===================== Done with legacy group inserting =======================' ); - // TODO we need to do the same for closed groups } + // TODO we need to do the same for new groups once they are available + const userGroupsDump = userGroupsConfigWrapper.dump(); db.prepare( @@ -1627,8 +1706,6 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite // Didn't find any reference to this serverTimestamp in the unprocessed table needed. db.exec(`ALTER TABLE unprocessed DROP COLUMN serverTimestamp;`); - // we need to populate those fields with the current state of the conversation so let's throw null until this is done - // throw new Error('update me'); writeSessionSchemaVersion(targetVersion, db); })(); diff --git a/ts/node/sql.ts b/ts/node/sql.ts index 18ec179ce..4a1318cb4 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -1809,17 +1809,23 @@ function getMessagesCountByConversation( * The returned array is ordered based on the timestamp, the latest is at the end. * @param groupPublicKey string | PubKey */ -function getAllEncryptionKeyPairsForGroup(groupPublicKey: string | PubKey) { - const rows = getAllEncryptionKeyPairsForGroupRaw(groupPublicKey); +function getAllEncryptionKeyPairsForGroup( + groupPublicKey: string | PubKey, + db?: BetterSqlite3.Database +) { + const rows = getAllEncryptionKeyPairsForGroupRaw(groupPublicKey, db); return map(rows, row => jsonToObject(row.json)); } -function getAllEncryptionKeyPairsForGroupRaw(groupPublicKey: string | PubKey) { +function getAllEncryptionKeyPairsForGroupRaw( + groupPublicKey: string | PubKey, + db?: BetterSqlite3.Database +) { const pubkeyAsString = (groupPublicKey as PubKey).key ? (groupPublicKey as PubKey).key : groupPublicKey; - const rows = assertGlobalInstance() + const rows = assertGlobalInstanceOrInstance(db) .prepare( `SELECT * FROM ${CLOSED_GROUP_V2_KEY_PAIRS_TABLE} WHERE groupPublicKey = $groupPublicKey ORDER BY timestamp ASC;` ) @@ -1830,8 +1836,11 @@ function getAllEncryptionKeyPairsForGroupRaw(groupPublicKey: string | PubKey) { return rows; } -function getLatestClosedGroupEncryptionKeyPair(groupPublicKey: string) { - const rows = getAllEncryptionKeyPairsForGroup(groupPublicKey); +function getLatestClosedGroupEncryptionKeyPair( + groupPublicKey: string, + db?: BetterSqlite3.Database +) { + const rows = getAllEncryptionKeyPairsForGroup(groupPublicKey, db); if (!rows || rows.length === 0) { return undefined; } diff --git a/ts/receiver/cache.ts b/ts/receiver/cache.ts index 5e334c1a8..67b3fd8d3 100644 --- a/ts/receiver/cache.ts +++ b/ts/receiver/cache.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import { Data } from '../data/data'; import { UnprocessedParameter } from '../types/sqlSharedTypes'; -export async function removeFromCache(envelope: EnvelopePlus) { +export async function removeFromCache(envelope: Pick) { return Data.removeUnprocessed(envelope.id); } @@ -91,10 +91,10 @@ export async function getAllFromCacheForSource(source: string) { } export async function updateCacheWithDecryptedContent( - envelope: EnvelopePlus, + envelope: Pick, plaintext: ArrayBuffer ): Promise { - const { id } = envelope; + const { id, senderIdentity, source } = envelope; const item = await Data.getUnprocessedById(id); if (!item) { window?.log?.error( @@ -103,11 +103,11 @@ export async function updateCacheWithDecryptedContent( return; } - item.source = envelope.source; + item.source = source; // For medium-size closed groups if (envelope.senderIdentity) { - item.senderIdentity = envelope.senderIdentity; + item.senderIdentity = senderIdentity; } item.decrypted = StringUtils.decode(plaintext, 'base64'); diff --git a/ts/receiver/closedGroups.ts b/ts/receiver/closedGroups.ts index 92e58ea18..28b6698f1 100644 --- a/ts/receiver/closedGroups.ts +++ b/ts/receiver/closedGroups.ts @@ -319,7 +319,6 @@ export async function handleNewClosedGroup( convo.updateLastMessage(); await convo.commit(); - // sanity checks validate this // tslint:disable: no-non-null-assertion const ecKeyPair = new ECKeyPair(encryptionKeyPair!.publicKey, encryptionKeyPair!.privateKey); window?.log?.info(`Received the encryptionKeyPair for new group ${groupId}`); diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 4f6c692c8..b0b240a5e 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -4,6 +4,7 @@ import { Data, hasSyncedInitialConfigurationItem } from '../data/data'; import { ConversationInteraction } from '../interactions'; import { ConversationTypeEnum } from '../models/conversationAttributes'; import { SignalService } from '../protobuf'; +import { ClosedGroup } from '../session'; import { joinOpenGroupV2WithUIEvents, parseOpenGroupV2, @@ -20,8 +21,10 @@ import { IncomingConfResult, LibSessionUtil } from '../session/utils/libsession/ import { SessionUtilUserGroups } from '../session/utils/libsession/libsession_utils_user_groups'; import { toHex } from '../session/utils/String'; import { configurationMessageReceived, trigger } from '../shims/events'; +import { assertUnreachable } from '../types/sqlSharedTypes'; import { BlockedNumberController } from '../util'; import { getLastProfileUpdateTimestamp, setLastProfileUpdateTimestamp } from '../util/storage'; +import { ConfigWrapperObjectTypes } from '../webworker/workers/browser/libsession_worker_functions'; import { ContactsWrapperActions, GenericWrapperActions, @@ -29,33 +32,75 @@ import { UserGroupsWrapperActions, } from '../webworker/workers/browser/libsession_worker_interface'; import { removeFromCache } from './cache'; -import { handleNewClosedGroup } from './closedGroups'; +import { addKeyPairToCacheAndDBIfNeeded, handleNewClosedGroup } from './closedGroups'; +import { HexKeyPair } from './keypairs'; import { EnvelopePlus } from './types'; +function groupByVariant( + incomingConfigs: Array> +) { + const groupedByVariant: Map< + ConfigWrapperObjectTypes, + Array> + > = new Map(); + + incomingConfigs.forEach(incomingConfig => { + const { kind } = incomingConfig.message; + + const wrapperId = LibSessionUtil.kindToVariant(kind); + + if (!groupedByVariant.has(wrapperId)) { + groupedByVariant.set(wrapperId, []); + } + + groupedByVariant.get(wrapperId)?.push(incomingConfig); + }); + return groupedByVariant; +} + async function mergeConfigsWithIncomingUpdates( - incomingConfig: IncomingMessage -): Promise<{ kind: SignalService.SharedConfigMessage.Kind; result: IncomingConfResult }> { - const { kind } = incomingConfig.message; + incomingConfigs: Array> +): Promise> { + // first, group by variant so we do a single merge call + const groupedByVariant = groupByVariant(incomingConfigs); + + const groupedResults: Map = new Map(); + + // TODO currently we only poll for user config messages, so this can be hardcoded + const publicKey = UserUtils.getOurPubKeyStrFromCache(); - const toMerge = [{ data: incomingConfig.message.data, hash: incomingConfig.messageHash }]; - const wrapperId = LibSessionUtil.kindToVariant(kind); try { - await GenericWrapperActions.merge(wrapperId, toMerge); - const needsPush = await GenericWrapperActions.needsPush(wrapperId); - const needsDump = await GenericWrapperActions.needsDump(wrapperId); - window.log.info(`${wrapperId} needsPush:${needsPush} needsDump:${needsDump} `); - - const messageHashes = [incomingConfig.messageHash]; - const latestSentTimestamp = incomingConfig.envelopeTimestamp; - - const incomingConfResult: IncomingConfResult = { - latestSentTimestamp, - messageHashes, - needsDump, - needsPush, - }; + for (let index = 0; index < groupedByVariant.size; index++) { + const variant = [...groupedByVariant.keys()][index]; + const sameVariant = groupedByVariant.get(variant); + if (!sameVariant?.length) { + continue; + } + const toMerge = sameVariant.map(msg => ({ + data: msg.message.data, + hash: msg.messageHash, + })); + + await GenericWrapperActions.merge(variant, toMerge); + const needsPush = await GenericWrapperActions.needsPush(variant); + const needsDump = await GenericWrapperActions.needsDump(variant); + window.log.info(`${variant}: "${publicKey}" needsPush:${needsPush} needsDump:${needsDump} `); + + const messageHashes = toMerge.map(m => m.hash); + const latestEnvelopeTimestamp = Math.max(...sameVariant.map(m => m.envelopeTimestamp)); + + const incomingConfResult: IncomingConfResult = { + messageHashes, + needsDump, + needsPush, + kind: LibSessionUtil.variantToKind(variant), + publicKey, + latestEnvelopeTimestamp: latestEnvelopeTimestamp ? latestEnvelopeTimestamp : Date.now(), + }; + groupedResults.set(variant, incomingConfResult); + } - return { kind, result: incomingConfResult }; + return groupedResults; } catch (e) { window.log.error('mergeConfigsWithIncomingUpdates failed with', e); throw e; @@ -155,16 +200,13 @@ async function handleContactsUpdate(result: IncomingConfResult): Promise { - if (!result.needsDump) { - return result; - } - +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(); const allCommunitiesConversation = getConversationController() .getConversations() - .filter(SessionUtilUserGroups.filterUserCommunitiesToStoreInWrapper); + .filter(SessionUtilUserGroups.isCommunityToStoreInWrapper); const allCommunitiesIdsInDB = allCommunitiesConversation.map(m => m.id as string); @@ -189,11 +231,7 @@ async function handleUserGroupsUpdate(result: IncomingConfResult): Promise `${m.fullUrl}`) - ); - for (let index = 0; index < communitiesToLeaveInDB.length; index++) { const toLeave = communitiesToLeaveInDB[index]; console.warn('leaving community with convoId ', toLeave.id); - await getConversationController().deleteContact(toLeave.id); + await getConversationController().deleteContact(toLeave.id, true); } // this call can take quite a long time and should not cause issues to not be awaited @@ -254,86 +287,255 @@ async function handleUserGroupsUpdate(result: IncomingConfResult): Promise m.members.length === 0)) { + debugger; + } + const allLegacyGroupsInDb = getConversationController() + .getConversations() + .filter(SessionUtilUserGroups.isLegacyGroupToStoreInWrapper); - if (!incomingResult) { - await removeFromCache(envelope); - return; + 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); + }); + + 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]; + console.warn('leaving legacy group from configuration sync message with convoId ', toLeave.id); + await getConversationController().deleteContact(toLeave.id, true); } - try { - let finalResult = incomingResult; + for (let index = 0; index < legacyGroupsToJoinInDB.length; index++) { + const toJoin = legacyGroupsToJoinInDB[index]; + console.warn( + 'joining legacy group from configuration sync message with convoId ', + toJoin.pubkeyHex + ); - switch (kind) { - case SignalService.SharedConfigMessage.Kind.USER_PROFILE: - finalResult = await handleUserProfileUpdate(incomingResult); - break; - case SignalService.SharedConfigMessage.Kind.CONTACTS: - finalResult = await handleContactsUpdate(incomingResult); + // let's just create the required convo here, as we update the fields right below + await getConversationController().getOrCreateAndWait( + toJoin.pubkeyHex, + ConversationTypeEnum.GROUP + ); + } + for (let index = 0; index < allLegacyGroupsInWrapper.length; index++) { + const fromWrapper = allLegacyGroupsInWrapper[index]; + + if (fromWrapper.members.length === 0) { + debugger; + } + const convo = getConversationController().get(fromWrapper.pubkeyHex); + if (!convo) { + // 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); + // 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: ClosedGroup.GroupInfo = { + id: fromWrapper.pubkeyHex, + name: fromWrapper.name, + members, + admins, + activeAt: + !!convo.get('active_at') && convo.get('active_at') < latestEnvelopeTimestamp + ? convo.get('active_at') + : latestEnvelopeTimestamp, + weWereJustAdded: false, // TODO to remove + }; + + await ClosedGroup.updateOrCreateClosedGroup(groupDetails); + + let changes = false; + if (convo.isPinned() !== fromWrapper.priority > 0) { + await convo.setIsPinned(fromWrapper.priority > 0, false); + changes = true; + } + if (!!convo.isHidden() !== !!fromWrapper.hidden) { + convo.set({ hidden: !!fromWrapper.hidden }); + changes = true; + } + if (convo.get('expireTimer') !== fromWrapper.disappearingTimerSeconds) { + await convo.updateExpireTimer( + fromWrapper.disappearingTimerSeconds, + undefined, + latestEnvelopeTimestamp, + { + fromSync: true, + } + ); + changes = true; + } + + if (changes) { + await convo.commit(); + } + + // save the encryption keypair if needed + if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) { + const inWrapperKeypair: HexKeyPair = { + publicHex: toHex(fromWrapper.encPubkey), + privateHex: toHex(fromWrapper.encSeckey), + }; + + await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair); + } + } + + // // 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 existingConvo = getConversationController().get(convoId); + // if (fromWrapper && existingConvo) { + // let changes = false; + + // //TODO priority means more than just isPinned but has an order logic in it too + // const shouldBePinned = fromWrapper.priority > 0; + // if (shouldBePinned !== Boolean(existingConvo.isPinned())) { + // await existingConvo.setIsPinned(shouldBePinned, false); + // changes = true; + // } + + // // make sure to write the changes to the database now as the `AvatarDownloadJob` below might take some time before getting run + // if (changes) { + // await existingConvo.commit(); + // } + // } + // } +} + +async function handleUserGroupsUpdate(result: IncomingConfResult): Promise { + if (!result.needsDump) { + return result; + } + + const toHandle = SessionUtilUserGroups.getUserGroupTypes(); + for (let index = 0; index < toHandle.length; index++) { + const typeToHandle = toHandle[index]; + switch (typeToHandle) { + case 'Community': + await handleCommunitiesUpdate(); break; - case SignalService.SharedConfigMessage.Kind.USER_GROUPS: - finalResult = await handleUserGroupsUpdate(incomingResult); + + case 'LegacyGroup': + await handleLegacyGroupUpdate(result.latestEnvelopeTimestamp); break; + default: - throw new Error(`processMergingResults unknown kind of contact : ${kind}`); + assertUnreachable(typeToHandle, `handleUserGroupsUpdate unhandled type "${typeToHandle}"`); } - const variant = LibSessionUtil.kindToVariant(kind); - - if (finalResult.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: pubkey, - variant, - }); - } + return result; +} - await removeFromCache(envelope); - } catch (e) { - window.log.error(`processMergingResults failed with ${e.message}`); - await removeFromCache(envelope); +async function processMergingResults(results: Map) { + 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 { kind } = incomingResult; + switch (kind) { + case SignalService.SharedConfigMessage.Kind.USER_PROFILE: + await handleUserProfileUpdate(incomingResult); + break; + case SignalService.SharedConfigMessage.Kind.CONTACTS: + await handleContactsUpdate(incomingResult); + break; + case SignalService.SharedConfigMessage.Kind.USER_GROUPS: + await handleUserGroupsUpdate(incomingResult); + break; + default: + assertUnreachable(kind, `processMergingResults unsupported kind: "${kind}"`); + } + const variant = LibSessionUtil.kindToVariant(kind); + + 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 (result.result.needsPush) { + if (anyNeedsPush) { await ConfigurationSync.queueNewJobIfNeeded(); } } -async function handleConfigMessageViaLibSession( - envelope: EnvelopePlus, - configMessage: IncomingMessage +async function handleConfigMessagesViaLibSession( + configMessages: Array> ) { // TODO: Remove this once `useSharedUtilForUserConfig` is permanent if (!window.sessionFeatureFlags.useSharedUtilForUserConfig) { - await removeFromCache(envelope); return; } - if (!configMessage) { - await removeFromCache(envelope); - + if (isEmpty(configMessages)) { return; } - window?.log?.info('Handling our sharedConfig message via libsession_util.'); + window?.log?.info( + `Handling our sharedConfig message via libsession_util: ${configMessages.length}` + ); - const incomingMergeResult = await mergeConfigsWithIncomingUpdates(configMessage); + const incomingMergeResult = await mergeConfigsWithIncomingUpdates(configMessages); - await processMergingResults(envelope, incomingMergeResult); + await processMergingResults(incomingMergeResult); } async function handleOurProfileUpdateLegacy( @@ -564,5 +766,5 @@ async function handleConfigurationMessageLegacy( export const ConfigMessageHandler = { handleConfigurationMessageLegacy, - handleConfigMessageViaLibSession, + handleConfigMessagesViaLibSession, }; diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index 3676ab3f1..ba7f169cb 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -17,22 +17,22 @@ import { findCachedBlindedMatchOrLookupOnAllServers } from '../session/apis/open import { getConversationController } from '../session/conversations'; import { concatUInt8Array, getSodiumRenderer } from '../session/crypto'; import { removeMessagePadding } from '../session/crypto/BufferPadding'; -import { IncomingMessage } from '../session/messages/incoming/IncomingMessage'; import { ProfileManager } from '../session/profile_manager/ProfileManager'; import { GroupUtils, UserUtils } from '../session/utils'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { fromHexToArray, toHex } from '../session/utils/String'; +import { assertUnreachable } from '../types/sqlSharedTypes'; +import { BlockedNumberController } from '../util'; import { ReadReceipts } from '../util/readReceipts'; import { Storage } from '../util/storage'; import { handleCallMessage } from './callMessage'; import { getAllCachedECKeyPair } from './closedGroups'; import { ConfigMessageHandler } from './configMessage'; import { ECKeyPair } from './keypairs'; -import { BlockedNumberController } from '../util'; export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageHash: string) { try { - const plaintext = await decrypt(envelope, envelope.content); + const plaintext = await decrypt(envelope); if (!plaintext) { return; @@ -48,7 +48,7 @@ export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageH } } -async function decryptForClosedGroup(envelope: EnvelopePlus, ciphertext: ArrayBuffer) { +async function decryptForClosedGroup(envelope: EnvelopePlus) { // case .closedGroupCiphertext: for ios window?.log?.info('received closed group message'); try { @@ -82,7 +82,7 @@ async function decryptForClosedGroup(envelope: EnvelopePlus, ciphertext: ArrayBu decryptedContent = await decryptWithSessionProtocol( envelope, - ciphertext, + envelope.content, encryptionKeyPair, true ); @@ -205,11 +205,15 @@ export async function decryptWithSessionProtocol( return plaintext; } -async function decryptUnidentifiedSender( - envelope: EnvelopePlus, - ciphertext: ArrayBuffer +/** + * This function is used to decrypt any messages send to our own pubkey. + * Either messages deposited into our swarm by other people, or messages we sent to ourselves, or config messages stored on the user namespaces. + * @param envelope the envelope contaning an encrypted .content field to decrypt + * @returns the decrypted content, or null + */ +export async function decryptEnvelopeWithOurKey( + envelope: EnvelopePlus ): Promise { - // window?.log?.info('received unidentified sender message'); try { const userX25519KeyPair = await UserUtils.getIdentityKeyPair(); @@ -225,7 +229,11 @@ async function decryptUnidentifiedSender( // keep the await so the try catch works as expected perfStart(`decryptUnidentifiedSender-${envelope.id}`); - const retSessionProtocol = await decryptWithSessionProtocol(envelope, ciphertext, ecKeyPair); + const retSessionProtocol = await decryptWithSessionProtocol( + envelope, + envelope.content, + ecKeyPair + ); const ret = removeMessagePadding(retSessionProtocol); perfEnd(`decryptUnidentifiedSender-${envelope.id}`, 'decryptUnidentifiedSender'); @@ -237,32 +245,28 @@ async function decryptUnidentifiedSender( } } -async function doDecrypt( - envelope: EnvelopePlus, - ciphertext: ArrayBuffer -): Promise { - if (ciphertext.byteLength === 0) { - throw new Error('Received an empty envelope.'); // Error.noData - } - - switch (envelope.type) { - // Only SESSION_MESSAGE and CLOSED_GROUP_MESSAGE are supported - case SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE: - return decryptForClosedGroup(envelope, ciphertext); - case SignalService.Envelope.Type.SESSION_MESSAGE: { - return decryptUnidentifiedSender(envelope, ciphertext); - } - default: - throw new Error(`Unknown message type:${envelope.type}`); - } -} - // tslint:disable-next-line: max-func-body-length -async function decrypt(envelope: EnvelopePlus, ciphertext: ArrayBuffer): Promise { +async function decrypt(envelope: EnvelopePlus): Promise { try { - const plaintext = await doDecrypt(envelope, ciphertext); + if (envelope.content.byteLength === 0) { + throw new Error('Received an empty envelope.'); + } + + let plaintext: ArrayBuffer | null = null; + switch (envelope.type) { + // Only SESSION_MESSAGE and CLOSED_GROUP_MESSAGE are supported + case SignalService.Envelope.Type.SESSION_MESSAGE: + plaintext = await decryptEnvelopeWithOurKey(envelope); + break; + case SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE: + plaintext = await decryptForClosedGroup(envelope); + break; + default: + assertUnreachable(envelope.type, `Unknown message type:${envelope.type}`); + } if (!plaintext) { + // content could not be decrypted. await removeFromCache(envelope); return null; } @@ -431,20 +435,10 @@ export async function innerHandleSwarmContentMessage( return; } if (content.sharedConfigMessage) { - if (window.sessionFeatureFlags.useSharedUtilForUserConfig) { - const asIncomingMsg: IncomingMessage = { - envelopeTimestamp: sentAtTimestamp, - message: content.sharedConfigMessage, - messageHash: messageHash, - authorOrGroupPubkey: envelope.source, - authorInGroup: envelope.senderIdentity, - }; - await ConfigMessageHandler.handleConfigMessageViaLibSession(envelope, asIncomingMsg); - return; - } else { - await removeFromCache(envelope); - return; - } + window.log.warn('content.sharedConfigMessage are handled outside of the receiving pipeline'); + // this should never happen, but remove it from cache just in case something is messed up + await removeFromCache(envelope); + return; } if (content.dataExtractionNotification) { perfStart(`handleDataExtractionNotification-${envelope.id}`); diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index 1382c9dee..58c5d737a 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -16,10 +16,6 @@ import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { UnprocessedParameter } from '../types/sqlSharedTypes'; -export type ReqOptions = { - conversationId: string; -}; - const incomingMessagePromises: Array> = []; async function handleSwarmEnvelope(envelope: EnvelopePlus, messageHash: string) { @@ -73,7 +69,7 @@ function queueSwarmEnvelope(envelope: EnvelopePlus, messageHash: string) { async function handleRequestDetail( plaintext: Uint8Array, - options: ReqOptions, + inConversation: string | null, lastPromise: Promise, messageHash: string ): Promise { @@ -83,7 +79,7 @@ async function handleRequestDetail( // fault, and we should handle them gracefully and tell the // user they received an invalid message // The message is for a medium size group - if (options.conversationId) { + if (inConversation) { const ourNumber = UserUtils.getOurPubKeyStrFromCache(); const senderIdentity = envelope.source; @@ -93,7 +89,7 @@ async function handleRequestDetail( // Sender identity will be lost if we load from cache, because // plaintext (and protobuf.Envelope) does not have that field... - envelope.source = options.conversationId; + envelope.source = inConversation; // tslint:disable-next-line no-parameter-reassignment plaintext = SignalService.Envelope.encode(envelope).finish(); envelope.senderIdentity = senderIdentity; @@ -124,17 +120,23 @@ async function handleRequestDetail( } } +/** + * + * @param inConversation if the request is related to a group, this will be set to the group pubkey. Otherwise, it is set to null + */ export function handleRequest( plaintext: Uint8Array, - options: ReqOptions, + inConversation: string | null, messageHash: string ): void { // tslint:disable-next-line no-promise-as-boolean const lastPromise = _.last(incomingMessagePromises) || Promise.resolve(); - const promise = handleRequestDetail(plaintext, options, lastPromise, messageHash).catch(e => { - window?.log?.error('Error handling incoming message:', e && e.stack ? e.stack : e); - }); + const promise = handleRequestDetail(plaintext, inConversation, lastPromise, messageHash).catch( + e => { + window?.log?.error('Error handling incoming message:', e && e.stack ? e.stack : e); + } + ); incomingMessagePromises.push(promise); } diff --git a/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts b/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts index 9432857dd..7fe71ce71 100644 --- a/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts +++ b/ts/session/apis/open_group_api/opengroupV2/JoinOpenGroupV2.ts @@ -81,7 +81,8 @@ async function joinOpenGroupV2( } else if (existingConvo) { // we already have a convo associated with it. Remove everything related to it so we start fresh window?.log?.warn('leaving before rejoining open group v2 room', conversationId); - await getConversationController().deleteContact(conversationId); + + await getConversationController().deleteContact(conversationId, true); } // Try to connect to server diff --git a/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts b/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts index 0c254db83..0f850220a 100644 --- a/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts +++ b/ts/session/apis/open_group_api/opengroupV2/OpenGroupManagerV2.ts @@ -115,14 +115,15 @@ export class OpenGroupManagerV2 { } const inWrapperCommunities = SessionUtilUserGroups.getAllCommunities(); + const inWrapperIds = inWrapperCommunities.map(m => getOpenGroupV2ConversationId(m.baseUrl, m.roomCasePreserved) ); let allRoomInfos = OpenGroupData.getAllV2OpenGroupRoomsMap(); - // this is time for some cleanup! - // We consider the wrapper is our source-of-truth, + // Itis time for some cleanup! + // We consider the wrapper to be our source-of-truth, // so if there is a roomInfos without an associated entry in the wrapper, we remove it from the map of opengroups rooms if (allRoomInfos?.size) { const roomInfosAsArray = [...allRoomInfos.values()]; @@ -135,7 +136,7 @@ export class OpenGroupManagerV2 { await OpenGroupData.removeV2OpenGroupRoom(roomConvoId); getOpenGroupManager().removeRoomFromPolledRooms(infos); - await getConversationController().deleteContact(roomConvoId); + await getConversationController().deleteContact(roomConvoId, false); } } catch (e) { window?.log?.warn('cleanup roomInfos error', e); diff --git a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts index 29fbaa735..e337c8648 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsApiV3.ts @@ -37,6 +37,7 @@ import { Data } from '../../../../data/data'; import { processMessagesUsingCache } from './sogsV3MutationCache'; import { destroyMessagesAndUpdateRedux } from '../../../../util/expiringMessages'; import { sogsRollingDeletions } from './sogsRollingDeletions'; +import { assertUnreachable } from '../../../../types/sqlSharedTypes'; /** * Get the convo matching those criteria and make sure it is an opengroup convo, or return null. @@ -550,8 +551,21 @@ export const handleBatchPollResults = async ( case 'outbox': await handleInboxOutboxMessages(subResponse.body, serverUrl, true); break; + + case 'addRemoveModerators': + case 'deleteMessage': + case 'banUnbanUser': + case 'deleteAllPosts': + case 'updateRoom': + case 'deleteReaction': + // we do nothing for all of those, but let's make sure if we ever add something batch polled for, we include it's handling here. + // the assertUnreachable will fail to compile everytime we add a new batch poll endpoint without taking care of it. + break; default: - window.log.error('No matching subrequest response body for type: ', responseType); + assertUnreachable( + responseType, + `No matching subrequest response body for type: "${responseType}"` + ); } } } diff --git a/ts/session/apis/open_group_api/sogsv3/sogsV3BatchPoll.ts b/ts/session/apis/open_group_api/sogsv3/sogsV3BatchPoll.ts index 7461b16f4..9d880b8d3 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsV3BatchPoll.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsV3BatchPoll.ts @@ -1,14 +1,15 @@ +import { AbortSignal } from 'abort-controller'; +import { flatten, isEmpty, isNumber, isObject } from 'lodash'; import { OpenGroupData } from '../../../../data/opengroups'; -import _, { flatten, isEmpty, isNumber, isObject } from 'lodash'; +import { assertUnreachable } from '../../../../types/sqlSharedTypes'; +import { Reactions } from '../../../../util/reactions'; import { OnionSending, OnionV4JSONSnodeResponse } from '../../../onions/onionSend'; import { OpenGroupPollingUtils, OpenGroupRequestHeaders, } from '../opengroupV2/OpenGroupPollingUtils'; -import { addJsonContentTypeToHeaders } from './sogsV3SendMessage'; -import { AbortSignal } from 'abort-controller'; import { roomHasBlindEnabled } from './sogsV3Capabilities'; -import { Reactions } from '../../../../util/reactions'; +import { addJsonContentTypeToHeaders } from './sogsV3SendMessage'; type BatchFetchRequestOptions = { method: 'POST' | 'PUT' | 'GET' | 'DELETE'; @@ -228,7 +229,8 @@ export type OpenGroupBatchRow = const makeBatchRequestPayload = ( options: OpenGroupBatchRow ): BatchSubRequest | Array | null => { - switch (options.type) { + const type = options.type; + switch (type) { case 'capabilities': return { method: 'GET', @@ -323,7 +325,7 @@ const makeBatchRequestPayload = ( path: `/room/${options.deleteReaction.roomId}/reactions/${options.deleteReaction.messageId}/${options.deleteReaction.reaction}`, }; default: - throw new Error('Invalid batch request row'); + assertUnreachable(type, 'Invalid batch request row'); } return null; diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 6a5bd3138..1ffe7d78f 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -1,31 +1,33 @@ -import { PubKey } from '../../types'; -import * as snodePool from './snodePool'; -import { ERROR_CODE_NO_CONNECT } from './SNodeAPI'; +import { compact, concat, difference, flatten, last, sample, toNumber, uniqBy } from 'lodash'; +import { Data, Snode } from '../../../data/data'; import { SignalService } from '../../../protobuf'; import * as Receiver from '../../../receiver/receiver'; -import _, { compact, concat, difference, flatten, last, sample, uniqBy } from 'lodash'; -import { Data, Snode } from '../../../data/data'; +import { PubKey } from '../../types'; +import { ERROR_CODE_NO_CONNECT } from './SNodeAPI'; +import * as snodePool from './snodePool'; -import { StringUtils, UserUtils } from '../../utils'; +import pRetry from 'p-retry'; import { ConversationModel } from '../../../models/conversation'; +import { ConfigMessageHandler } from '../../../receiver/configMessage'; +import { decryptEnvelopeWithOurKey } from '../../../receiver/contentMessage'; +import { EnvelopePlus } from '../../../receiver/types'; +import { updateIsOnline } from '../../../state/ducks/onion'; import { DURATION, SWARM_POLLING_TIMEOUT } from '../../constants'; import { getConversationController } from '../../conversations'; -import { perfEnd, perfStart } from '../../utils/Performance'; +import { IncomingMessage } from '../../messages/incoming/IncomingMessage'; import { ed25519Str } from '../../onions/onionPath'; -import { updateIsOnline } from '../../../state/ducks/onion'; -import pRetry from 'p-retry'; -import { SnodeAPIRetrieve } from './retrieveRequest'; +import { StringUtils, UserUtils } from '../../utils'; +import { perfEnd, perfStart } from '../../utils/Performance'; import { SnodeNamespace, SnodeNamespaces } from './namespaces'; +import { SnodeAPIRetrieve } from './retrieveRequest'; import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types'; export function extractWebSocketContent( message: string, - options: any = {}, messageHash: string ): null | { body: Uint8Array; messageHash: string; - options: any; } { try { const dataPlaintext = new Uint8Array(StringUtils.encode(message, 'base64')); @@ -37,12 +39,11 @@ export function extractWebSocketContent( return { body: messageBuf.request.body, messageHash, - options, }; } return null; } catch (error) { - window?.log?.warn('processMessage Failed to handle message:', error.message); + window?.log?.warn('extractWebSocketContent from message failed with:', error.message); return null; } } @@ -196,9 +197,9 @@ export class SwarmPolling { isGroup: boolean, namespaces: Array ) { - const pkStr = pubkey.key; + const polledPubkey = pubkey.key; - const swarmSnodes = await snodePool.getSwarmFor(pkStr); + const swarmSnodes = await snodePool.getSwarmFor(polledPubkey); // Select nodes for which we already have lastHashes const alreadyPolled = swarmSnodes.filter((n: Snode) => this.lastHashes[n.pubkey_ed25519]); @@ -220,7 +221,6 @@ export class SwarmPolling { resultsFromAllNamespaces = null; } - let userConfigMessagesMerged: Array = []; let allNamespacesWithoutUserConfigIfNeeded: Array = []; // check if we just fetched the details from the config namespaces. @@ -237,14 +237,29 @@ export class SwarmPolling { .map(r => r.messages.messages) ) ); - userConfigMessagesMerged = flatten(compact(userConfigMessages)); + + if (!isGroup) { + const userConfigMessagesMerged = flatten(compact(userConfigMessages)); + + window.log.info( + `received userConfigMessagesMerged: ${userConfigMessagesMerged.length} for key ${pubkey.key}` + ); + try { + await this.handleSharedConfigMessages(userConfigMessagesMerged); + } catch (e) { + window.log.warn( + `handleSharedConfigMessages of ${userConfigMessagesMerged.length} failed with ${e.message}` + ); + // not rethrowing + } + } + + // first make sure to handle the shared user config message first } else { allNamespacesWithoutUserConfigIfNeeded = flatten( compact(resultsFromAllNamespaces?.map(m => m.messages.messages)) ); } - - window.log.info(`received userConfigMessagesMerged: ${userConfigMessagesMerged.length}`); window.log.info( `received allNamespacesWithoutUserConfigIfNeeded: ${allNamespacesWithoutUserConfigIfNeeded.length}` ); @@ -274,40 +289,76 @@ export class SwarmPolling { } return group; }); - } else if (isGroup) { - window?.log?.info( - `Polled for group(${ed25519Str( - pubkey.key - )}):, but no snode returned something else than null.` - ); } - perfStart(`handleSeenMessages-${pkStr}`); + perfStart(`handleSeenMessages-${polledPubkey}`); const newMessages = await this.handleSeenMessages(messages); - perfEnd(`handleSeenMessages-${pkStr}`, 'handleSeenMessages'); + perfEnd(`handleSeenMessages-${polledPubkey}`, 'handleSeenMessages'); - if (window.sessionFeatureFlags.useSharedUtilForUserConfig) { - const extractedUserConfigMessage = compact( - userConfigMessagesMerged.map((m: RetrieveMessageItem) => { - return extractWebSocketContent(m.data, {}, m.hash); - }) - ); + // trigger the handling of all the other messages, not shared config related + newMessages.forEach(m => { + const content = extractWebSocketContent(m.data, m.hash); + if (!content) { + return; + } - extractedUserConfigMessage.forEach(m => { - Receiver.handleRequest(m.body, m.options, m.messageHash); - }); - } + Receiver.handleRequest(content.body, isGroup ? polledPubkey : null, content.messageHash); + }); + } - const extractedContentMessage = compact( - newMessages.map((m: RetrieveMessageItem) => { - const options = isGroup ? { conversationId: pkStr } : {}; - return extractWebSocketContent(m.data, options, m.hash); + private async handleSharedConfigMessages(userConfigMessagesMerged: Array) { + const extractedUserConfigMessage = compact( + userConfigMessagesMerged.map((m: RetrieveMessageItem) => { + return extractWebSocketContent(m.data, m.hash); }) ); - extractedContentMessage.forEach(m => { - Receiver.handleRequest(m.body, m.options, m.messageHash); - }); + const allDecryptedConfigMessages: Array> = []; + + for (let index = 0; index < extractedUserConfigMessage.length; index++) { + const userConfigMessage = extractedUserConfigMessage[index]; + + try { + const envelope: EnvelopePlus = SignalService.Envelope.decode(userConfigMessage.body) as any; + const decryptedEnvelope = await decryptEnvelopeWithOurKey(envelope); + if (!decryptedEnvelope?.byteLength) { + continue; + } + const content = SignalService.Content.decode(new Uint8Array(decryptedEnvelope)); + if (content.sharedConfigMessage) { + const asIncomingMsg: IncomingMessage = { + envelopeTimestamp: toNumber(envelope.timestamp), + message: content.sharedConfigMessage, + messageHash: userConfigMessage.messageHash, + authorOrGroupPubkey: envelope.source, + authorInGroup: envelope.senderIdentity, + }; + allDecryptedConfigMessages.push(asIncomingMsg); + } else { + throw new Error( + 'received a message to a namespace reserved for user config but not containign a sharedConfigMessage' + ); + } + } catch (e) { + window.log.warn( + `failed to decrypt message with hash "${userConfigMessage.messageHash}": ${e.message}` + ); + } + } + + try { + window.log.info( + `handleConfigMessagesViaLibSession of "${allDecryptedConfigMessages.length}" messages with libsession` + ); + await ConfigMessageHandler.handleConfigMessagesViaLibSession(allDecryptedConfigMessages); + } catch (e) { + const allMessageHases = allDecryptedConfigMessages.map(m => m.messageHash).join(','); + window.log.warn( + `failed to handle messages hashes "${allMessageHases}" with libsession. Error: "${e.message}"` + ); + } } // Fetches messages for `pubkey` from `node` potentially updating @@ -458,9 +509,9 @@ export class SwarmPolling { hash: string; expiration: number; }): Promise { - if (!SnodeNamespace.isNamespaceAlwaysPolled(namespace)) { - return; - } + // if (!SnodeNamespace.isNamespaceAlwaysPolled(namespace)) { + // return; + // } const pkStr = pubkey.key; const cached = await this.getLastHash(edkey, pubkey.key, namespace); @@ -484,9 +535,9 @@ export class SwarmPolling { } private async getLastHash(nodeEdKey: string, pubkey: string, namespace: number): Promise { - if (!SnodeNamespace.isNamespaceAlwaysPolled(namespace)) { - return ''; - } + // if (!SnodeNamespace.isNamespaceAlwaysPolled(namespace)) { + // return ''; + // } if (!this.lastHashes[nodeEdKey]?.[pubkey]?.[namespace]) { const lastHash = await Data.getLastHashBySnode(pubkey, nodeEdKey, namespace); diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index b800ecad1..604ac61d4 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -11,6 +11,8 @@ import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/con import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { SessionUtilContact } from '../utils/libsession/libsession_utils_contacts'; import { SessionUtilUserGroups } from '../utils/libsession/libsession_utils_user_groups'; +import { leaveClosedGroup } from '../group/closed-group'; +import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJob'; let instance: ConversationController | null; @@ -197,7 +199,7 @@ export class ConversationController { await conversation.commit(); } - public async deleteContact(id: string) { + public async deleteContact(id: string, fromSyncMessage: boolean) { if (!this._initialFetchComplete) { throw new Error('getConversationController().deleteContact() needs complete initial fetch'); } @@ -210,12 +212,17 @@ export class ConversationController { return; } + // those are the stuff to do for all conversation types + window.log.info(`deleteContact destroyingMessages: ${id}`); + await deleteAllMessagesByConvoIdNoConfirmation(id); + window.log.info(`deleteContact messages destroyed: ${id}`); + // Closed/Medium group leaving if (conversation.isClosedGroup()) { window.log.info(`deleteContact ClosedGroup case: ${id}`); - await conversation.leaveClosedGroup(); + await leaveClosedGroup(conversation.id); // open group v2 - } else if (conversation.isOpenGroupV2()) { + } else if (conversation.isPublic()) { window?.log?.info('leaving open group v2', conversation.id); const roomInfos = OpenGroupData.getV2OpenGroupRoom(conversation.id); if (roomInfos) { @@ -229,24 +236,17 @@ export class ConversationController { window?.log?.info('removeV2OpenGroupRoom failed:', e); } try { - console.error(' plop1', SessionUtilUserGroups.getAllCommunities()); await SessionUtilUserGroups.removeCommunityFromWrapper(conversation.id, conversation.id); - console.error(' plop2', SessionUtilUserGroups.getAllCommunities()); } catch (e) { window?.log?.info('SessionUtilUserGroups.removeCommunityFromWrapper failed:', e); } } - // those are the stuff to do for all contact types - window.log.info(`deleteContact destroyingMessages: ${id}`); - - await deleteAllMessagesByConvoIdNoConfirmation(conversation.id); - window.log.info(`deleteContact messages destroyed: ${id}`); - // if this conversation is a private conversation it's in fact a `contact` for desktop. - // we just want to remove everything related to it, set the active_at to undefined - // so conversation still exists (useful for medium groups members or opengroups) but is not shown on the UI if (conversation.isPrivate()) { - window.log.info(`deleteContact isPrivate, marking as inactive: ${id}`); + // if this conversation is a private conversation it's in fact a `contact` for desktop. + // we just want to remove everything related to it and set the hidden field to true + // so the conversation still exists (needed for that user's profile in groups) but is not shown on the list of conversation + window.log.info(`deleteContact isPrivate, marking as hidden: ${id}`); conversation.set({ hidden: true, @@ -256,11 +256,11 @@ export class ConversationController { await conversation.commit(); // TODO the call above won't mark the conversation as hidden in the wrapper, it will just stop being updated (which is a bad thing) } else { - window.log.info(`deleteContact !isPrivate, removing convo from DB: ${id}`); + window.log.info(`deleteContact NOT private, removing convo from DB: ${id}`); // not a private conversation, so not a contact for the ContactWrapper await Data.removeConversation(id); - window.log.info(`deleteContact !isPrivate, convo removed from DB: ${id}`); + window.log.info(`deleteContact NOT private, convo removed from DB: ${id}`); // TODO remove group related entries from their corresponding wrappers here this.conversations.remove(conversation); @@ -273,7 +273,11 @@ export class ConversationController { ); window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id)); - window.log.info(`deleteContact !isPrivate, convo removed from store: ${id}`); + window.log.info(`deleteContact NOT private, convo removed from store: ${id}`); + } + + if (!fromSyncMessage) { + await ConfigurationSync.queueNewJobIfNeeded(); } } @@ -306,18 +310,22 @@ export class ConversationController { console.time('refreshAllWrapperContactsData'); for (let index = 0; index < collection.models.length; index++) { const convo = collection.models[index]; - if (SessionUtilContact.filterContactsToStoreInContactsWrapper(convo)) { + if (SessionUtilContact.isContactToStoreInContactsWrapper(convo)) { await SessionUtilContact.refreshMappedValue(convo.id, true); } - if (SessionUtilUserGroups.filterUserCommunitiesToStoreInWrapper(convo)) { - await SessionUtilUserGroups.refreshCommunityMappedValue(convo.id, true); + if (SessionUtilUserGroups.isUserGroupToStoreInWrapper(convo)) { + await SessionUtilUserGroups.refreshMappedValue(convo.id, true); } } console.timeEnd('refreshAllWrapperContactsData'); this._initialFetchComplete = true; this.conversations.forEach((conversation: ConversationModel) => { - if (conversation.isActive() && !conversation.get('lastMessage')) { + if ( + conversation.isActive() && + !conversation.isHidden() && + !conversation.get('lastMessage') + ) { conversation.updateLastMessage(); } }); diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts index affce6fb9..e99cd7c1a 100644 --- a/ts/session/conversations/createClosedGroup.ts +++ b/ts/session/conversations/createClosedGroup.ts @@ -106,11 +106,7 @@ export async function createClosedGroup(groupName: string, members: Array { diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index 977eff96e..372484749 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -223,7 +223,6 @@ async function sendMessagesDataToSnode( }) : null; - console.error('oldMessageHashes', oldMessageHashes); const snode = sample(swarm); if (!snode) { throw new EmptySwarmError(destination, 'Ran out of swarm nodes to query'); diff --git a/ts/session/utils/job_runners/jobs/ConfigurationSyncDumpJob.ts b/ts/session/utils/job_runners/jobs/ConfigurationSyncDumpJob.ts index 91b309bff..2d97ba0d3 100644 --- a/ts/session/utils/job_runners/jobs/ConfigurationSyncDumpJob.ts +++ b/ts/session/utils/job_runners/jobs/ConfigurationSyncDumpJob.ts @@ -2,7 +2,7 @@ import { isNumber } from 'lodash'; import { v4 } from 'uuid'; import { UserUtils } from '../..'; import { ConfigDumpData } from '../../../../data/configDump/configDump'; -import { ConfigWrapperObjectTypes } from '../../../../webworker/workers/browser/libsession_worker_functions'; +import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { GenericWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; import { DURATION } from '../../../constants'; import { getConversationController } from '../../../conversations'; @@ -25,16 +25,10 @@ const defaultMaxAttempts = 2; */ let lastRunConfigSyncJobDumpTimestamp: number | null = null; -const variantsToSaveRegularly: Array = [ - 'UserConfig', - 'ContactsConfig', - 'UserGroupsConfig', -]; - async function saveDumpsNeededToDB(): Promise { let savedAtLeastOne = false; - for (let i = 0; i < variantsToSaveRegularly.length; i++) { - const variant = variantsToSaveRegularly[i]; + for (let i = 0; i < LibSessionUtil.userVariants.length; i++) { + const variant = LibSessionUtil.userVariants[i]; const needsDump = await GenericWrapperActions.needsDump(variant); if (!needsDump) { @@ -100,9 +94,24 @@ class ConfigurationSyncDumpJob extends PersistedJob): Promise< for (let i = 0; i < changes.length; i++) { const change = changes[i]; const variant = LibSessionUtil.kindToVariant(change.message.kind); - console.warn('buildAndSaveDumpsToDB: change.updatedHash: ', change.updatedHash); + console.warn( + `ConfigurationSyncJob.saveDumpToDB: "${variant}" updatedHash: "${ + change.updatedHash + }:${change.message.seqno.toNumber()}"` + ); const needsDump = await LibSessionUtil.markAsPushed( variant, change.publicKey, @@ -186,9 +191,23 @@ class ConfigurationSyncJob extends PersistedJob window.log.warn('did not find our own conversation'); return RunJobResult.PermanentFailure; } - await LibSessionUtil.insertUserProfileIntoWrapper(); - await LibSessionUtil.insertAllContactsIntoContactsWrapper(); - await LibSessionUtil.insertAllUserGroupsIntoWrapper(); + for (let index = 0; index < LibSessionUtil.userVariants.length; index++) { + const variant = LibSessionUtil.userVariants[index]; + switch (variant) { + case 'UserConfig': + await LibSessionUtil.insertUserProfileIntoWrapper(); + break; + case 'ContactsConfig': + await LibSessionUtil.insertAllContactsIntoContactsWrapper(); + break; + case 'UserGroupsConfig': + await LibSessionUtil.insertAllUserGroupsIntoWrapper(); + break; + + default: + assertUnreachable(variant, `ConfigurationSyncDumpJob unhandled variant: "${variant}"`); + } + } const singleDestChanges = await retrieveSingleDestinationChanges(); @@ -211,7 +230,6 @@ class ConfigurationSyncJob extends PersistedJob }; }); const asSet = new Set(dest.allOldHashes); - console.warn('asSet', [...asSet]); return MessageSender.sendMessagesToSnode(msgs, dest.destination, asSet); }) ); @@ -271,6 +289,9 @@ class ConfigurationSyncJob extends PersistedJob * A ConfigurationSyncJob can only be added if there is none of the same type queued already. */ async function queueNewJobIfNeeded() { + if (!window.sessionFeatureFlags.useSharedUtilForUserConfig) { + return; + } if ( !lastRunConfigSyncJobTimestamp || lastRunConfigSyncJobTimestamp < Date.now() - defaultMsBetweenRetries diff --git a/ts/session/utils/libsession/libsession_utils.ts b/ts/session/utils/libsession/libsession_utils.ts index 8294750c6..8273541ec 100644 --- a/ts/session/utils/libsession/libsession_utils.ts +++ b/ts/session/utils/libsession/libsession_utils.ts @@ -3,6 +3,7 @@ import Long from 'long'; import { UserUtils } from '..'; import { ConfigDumpData } from '../../../data/configDump/configDump'; import { SignalService } from '../../../protobuf'; +import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { ConfigWrapperObjectTypes } from '../../../webworker/workers/browser/libsession_worker_functions'; import { GenericWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { GetNetworkTime } from '../../apis/snode_api/getNetworkTime'; @@ -14,7 +15,7 @@ import { SessionUtilUserGroups } from './libsession_utils_user_groups'; import { SessionUtilUserProfile } from './libsession_utils_user_profile'; // TODO complete this list -const requiredUserDumpVariants: Array = [ +const userVariants: Array = [ 'UserConfig', 'ContactsConfig', 'UserGroupsConfig', @@ -24,7 +25,9 @@ export type IncomingConfResult = { needsPush: boolean; needsDump: boolean; messageHashes: Array; - latestSentTimestamp: number; + kind: SignalService.SharedConfigMessage.Kind; + publicKey: string; + latestEnvelopeTimestamp: number; }; export type OutgoingConfResult = { @@ -42,7 +45,7 @@ async function initializeLibSessionUtilWrappers() { const privateKeyEd25519 = keypair.privKeyBytes; // let's plan a sync on start for now // TODO is this causing any issues? - setTimeout(() => ConfigurationSync.queueNewJobIfNeeded, 3000); + setTimeout(() => ConfigurationSync.queueNewJobIfNeeded, 20000); // fetch the dumps we already have from the database const dumps = await ConfigDumpData.getAllDumpsWithData(); @@ -75,13 +78,13 @@ async function initializeLibSessionUtilWrappers() { console.warn('requiredVariants: FIXME add conversation volatile wrapper as required '); const missingRequiredVariants: Array = difference( - requiredUserDumpVariants, + LibSessionUtil.userVariants, [...userVariantsBuildWithoutErrors.values()] ); if (missingRequiredVariants.length) { // TODO this is only needed for debugging. Should be removed as we force them created right below with an empty dump - throw new Error(`missingRequiredVariants: ${JSON.stringify(missingRequiredVariants)}`); + // throw new Error(`missingRequiredVariants: ${JSON.stringify(missingRequiredVariants)}`); } for (let index = 0; index < missingRequiredVariants.length; index++) { @@ -97,7 +100,7 @@ async function pendingChangesForPubkey(pubkey: string): Promise { + LibSessionUtil.userVariants.forEach(requiredVariant => { if (!dumps.find(m => m.publicKey === us && m.variant === requiredVariant)) { dumps.push({ publicKey: us, @@ -146,7 +149,7 @@ function kindToVariant(kind: SignalService.SharedConfigMessage.Kind): ConfigWrap case SignalService.SharedConfigMessage.Kind.USER_GROUPS: return 'UserGroupsConfig'; default: - throw new Error(`kindToVariant: Unsupported variant: "${kind}"`); + assertUnreachable(kind, `kindToVariant: Unsupported variant: "${kind}"`); } } @@ -159,7 +162,7 @@ function variantToKind(variant: ConfigWrapperObjectTypes): SignalService.SharedC case 'UserGroupsConfig': return SignalService.SharedConfigMessage.Kind.USER_GROUPS; default: - throw new Error(`variantToKind: Unsupported kind: "${variant}"`); + assertUnreachable(variant, `variantToKind: Unsupported kind: "${variant}"`); } } @@ -181,12 +184,13 @@ async function markAsPushed( export const LibSessionUtil = { initializeLibSessionUtilWrappers, - requiredUserDumpVariants, + userVariants, pendingChangesForPubkey, insertUserProfileIntoWrapper: SessionUtilUserProfile.insertUserProfileIntoWrapper, insertAllContactsIntoContactsWrapper: SessionUtilContact.insertAllContactsIntoContactsWrapper, insertAllUserGroupsIntoWrapper: SessionUtilUserGroups.insertAllUserGroupsIntoWrapper, removeCommunityFromWrapper: SessionUtilUserGroups.removeCommunityFromWrapper, kindToVariant, + variantToKind, markAsPushed, }; diff --git a/ts/session/utils/libsession/libsession_utils_contacts.ts b/ts/session/utils/libsession/libsession_utils_contacts.ts index dea1b494a..dee864ddc 100644 --- a/ts/session/utils/libsession/libsession_utils_contacts.ts +++ b/ts/session/utils/libsession/libsession_utils_contacts.ts @@ -31,7 +31,7 @@ const mappedContactWrapperValues = new Map(); async function insertAllContactsIntoContactsWrapper() { const idsToInsert = getConversationController() .getConversations() - .filter(filterContactsToStoreInContactsWrapper) + .filter(isContactToStoreInContactsWrapper) .map(m => m.id); window.log.debug(`ContactsWrapper keep tracks of ${idsToInsert.length} contacts`); @@ -48,7 +48,7 @@ async function insertAllContactsIntoContactsWrapper() { * `isApproved` or `didApproveMe` field set. * So that would be all the private conversations we either sent or receive a message from, not blinded */ -function filterContactsToStoreInContactsWrapper(convo: ConversationModel): boolean { +function isContactToStoreInContactsWrapper(convo: ConversationModel): boolean { return ( !convo.isMe() && convo.isPrivate() && @@ -69,7 +69,7 @@ async function insertContactFromDBIntoWrapperAndRefresh(id: string): Promise(); +/** + * The key of this map is the convoId as stored in the database. So the legacy group 05 sessionID + */ +const mappedLegacyGroupWrapperValues = new Map(); + /** * Update the UserGroupsWrapper with all the data is cares about from the database. */ @@ -20,19 +28,12 @@ async function insertAllUserGroupsIntoWrapper() { const convoIdsToInsert = uniq( getConversationController() .getConversations() - .filter(filterUserGroupsToStoreInWrapper) - .map(m => m.id) - ); - - const communitiesIdsToInsert = uniq( - getConversationController() - .getConversations() - .filter(filterUserCommunitiesToStoreInWrapper) + .filter(isUserGroupToStoreInWrapper) .map(m => m.id) ); window.log.debug( - `UserGroupsWrapper keep tracks of ${convoIdsToInsert.length} usergroups of which ${communitiesIdsToInsert.length} are communities` + `UserGroupsWrapper keep tracks of ${convoIdsToInsert.length} usergroups including groups and communities` ); for (let index = 0; index < convoIdsToInsert.length; index++) { @@ -45,17 +46,29 @@ async function insertAllUserGroupsIntoWrapper() { /** * Returns true if that conversation is an active group */ -function filterUserGroupsToStoreInWrapper(convo: ConversationModel): boolean { - return convo.isGroup() && convo.isActive(); +function isUserGroupToStoreInWrapper(convo: ConversationModel): boolean { + return isCommunityToStoreInWrapper(convo) || isLegacyGroupToStoreInWrapper(convo); +} + +function isCommunityToStoreInWrapper(convo: ConversationModel): boolean { + return convo.isGroup() && convo.isPublic() && convo.isActive(); } -function filterUserCommunitiesToStoreInWrapper(convo: ConversationModel): boolean { - return convo.isPublic() && convo.isActive(); +function isLegacyGroupToStoreInWrapper(convo: ConversationModel): boolean { + return ( + convo.isGroup() && + !convo.isPublic() && + convo.id.startsWith('05') && // new closed groups won't start with 05 + convo.isActive() && + !convo.get('isKickedFromGroup') && + !convo.get('left') + ); } /** * Fetches the specified convo and updates the required field in the wrapper. * If that community does not exist in the wrapper, it is created before being updated. + * Same applies for a legacy group. */ async function insertGroupsFromDBIntoWrapperAndRefresh(convoId: string): Promise { const foundConvo = getConversationController().get(convoId); @@ -63,13 +76,12 @@ async function insertGroupsFromDBIntoWrapperAndRefresh(convoId: string): Promise return; } - if (!filterUserGroupsToStoreInWrapper(foundConvo)) { + if (!isUserGroupToStoreInWrapper(foundConvo)) { return; } - if (foundConvo.isOpenGroupV2()) { + if (isCommunityToStoreInWrapper(foundConvo)) { const asOpengroup = foundConvo.toOpenGroupV2(); - const isPinned = !!foundConvo.get('isPinned'); const roomDetails = OpenGroupData.getV2OpenGroupRoomByRoomId(asOpengroup); if (!roomDetails) { @@ -84,64 +96,85 @@ async function insertGroupsFromDBIntoWrapperAndRefresh(convoId: string): Promise ); const wrapperComm = getCommunityInfoFromDBValues({ - isPinned, + isPinned: !!foundConvo.get('isPinned'), fullUrl, }); try { - console.info(`inserting into usergroup wrapper ${convoId}...`); + console.info(`inserting into usergroup wrapper "${wrapperComm.fullUrl}"...`); // this does the create or the update of the matching existing community await UserGroupsWrapperActions.setCommunityByFullUrl( wrapperComm.fullUrl, wrapperComm.priority ); + await refreshMappedValue(convoId); } catch (e) { window.log.warn(`UserGroupsWrapperActions.set of ${convoId} failed with ${e.message}`); // we still let this go through } + } else if (isLegacyGroupToStoreInWrapper(foundConvo)) { + const encryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair(convoId); + const wrapperLegacyGroup = getLegacyGroupInfoFromDBValues({ + id: foundConvo.id, + isPinned: !!foundConvo.get('isPinned'), + members: foundConvo.get('members') || [], + groupAdmins: foundConvo.get('groupAdmins') || [], + expireTimer: foundConvo.get('expireTimer'), + displayNameInProfile: foundConvo.get('displayNameInProfile'), + hidden: false, + encPubkeyHex: encryptionKeyPair?.publicHex || '', + encSeckeyHex: encryptionKeyPair?.privateHex || '', + }); + if (wrapperLegacyGroup.members.length === 0) { + debugger; + } + + try { + console.info(`inserting into usergroup wrapper "${foundConvo.id}"...`); + // this does the create or the update of the matching existing legacy group - await refreshCommunityMappedValue(convoId); - } else { - // TODO - // throw new Error('insertGroupsFromDBIntoWrapperAndRefresh group and legacy todo'); + await UserGroupsWrapperActions.setLegacyGroup(wrapperLegacyGroup); + await refreshMappedValue(convoId); + } catch (e) { + window.log.warn(`UserGroupsWrapperActions.set of ${convoId} failed with ${e.message}`); + // we still let this go through + } } } /** * refreshMappedValue is used to query the UserGroups Wrapper for the details of that group and update the cached in-memory entry representing its content. - * @param id the pubkey to re fresh the cached value from + * @param id the pubkey to re fresh the cached value from1 * @param duringAppStart set this to true if we should just fetch the cached value but not trigger a UI refresh of the corresponding conversation */ -async function refreshCommunityMappedValue(convoId: string, duringAppStart = false) { +async function refreshMappedValue(convoId: string, duringAppStart = false) { try { - if (!OpenGroupUtils.isOpenGroupV2(convoId)) { - throw new Error(`Not an opengroupv2: "${convoId}"`); - } - const fromWrapper = await UserGroupsWrapperActions.getCommunityByFullUrl(convoId); - if (fromWrapper) { - SessionUtilUserGroups.setCommunityMappedValue(convoId, fromWrapper); - if (!duringAppStart) { - getConversationController() - .get(convoId) - ?.triggerUIRefresh(); + let refreshed = false; + if (OpenGroupUtils.isOpenGroupV2(convoId)) { + const fromWrapper = await UserGroupsWrapperActions.getCommunityByFullUrl(convoId); + if (fromWrapper && fromWrapper.fullUrl) { + mappedCommunityWrapperValues.set(convoId, fromWrapper); + } + refreshed = true; + } else if (convoId.startsWith('05')) { + // currently this should only be a legacy group here + const fromWrapper = await UserGroupsWrapperActions.getLegacyGroup(convoId); + if (fromWrapper) { + mappedLegacyGroupWrapperValues.set(convoId, fromWrapper); } + refreshed = true; } - return; - } catch (e) { - window.log.info(`refreshCommunityMappedValue: not an opengroup convoID: ${convoId}`, e); - } - // TODO - // throw new Error('refreshMappedValue group and legacy todo'); -} - -function setCommunityMappedValue(convoId: string, info: CommunityInfo) { - if (isEmpty(info.fullUrl)) { - throw new Error(`setCommunityMappedValue needs a valid info.fullUrl ${info.fullUrl}`); + if (refreshed && !duringAppStart) { + getConversationController() + .get(convoId) + ?.triggerUIRefresh(); + } + } catch (e) { + window.log.info(`refreshMappedValue: not an opengroup convoID: ${convoId}`, e); } - // this has the pubkey associated with it - mappedCommunityWrapperValues.set(convoId, info); + // TODO handle the new closed groups once we got them ready } function getCommunityMappedValueByConvoId(convoId: string) { @@ -153,7 +186,7 @@ function getAllCommunities(): Array { } /** - * Remove the matching community from the wrapper and from the cached list of communities + * Removes the matching community from the wrapper and from the cached list of communities */ async function removeCommunityFromWrapper(convoId: string, fullUrlWithOrWithoutPubkey: string) { const fromWrapper = await UserGroupsWrapperActions.getCommunityByFullUrl( @@ -163,18 +196,58 @@ async function removeCommunityFromWrapper(convoId: string, fullUrlWithOrWithoutP if (fromWrapper) { await UserGroupsWrapperActions.eraseCommunityByFullUrl(fromWrapper.fullUrl); } - // might not be there but better make sure mappedCommunityWrapperValues.delete(convoId); } +function getLegacyGroupMappedValueByConvoId(convoId: string) { + return mappedLegacyGroupWrapperValues.get(convoId); +} + +function getAllLegacyGroups(): Array { + return [...mappedLegacyGroupWrapperValues.values()]; +} + +/** + * Remove the matching legacy group from the wrapper and from the cached list of legacy groups + */ +async function removeLegacyGroupFromWrapper(groupPk: string) { + const fromWrapper = await UserGroupsWrapperActions.getLegacyGroup(groupPk); + + if (fromWrapper) { + await UserGroupsWrapperActions.eraseLegacyGroup(groupPk); + } + + mappedLegacyGroupWrapperValues.delete(groupPk); +} + +/** + * This function can be used where there are things to do for all the types handled by this wrapper. + * You can do a loop on all the types handled by this wrapper and have a switch using assertUnreachable to get errors when not every case is handled. + * + * + * Note: Ideally, we'd like to have this type in the wrapper index.d.ts, but it would require it to be a index.ts instead, which causes a whole other bunch of issues because it is a native node module. + */ +function getUserGroupTypes(): Array { + return ['Community', 'LegacyGroup']; +} + export const SessionUtilUserGroups = { - filterUserGroupsToStoreInWrapper, - filterUserCommunitiesToStoreInWrapper, - getAllCommunities, + // shared + isUserGroupToStoreInWrapper, insertAllUserGroupsIntoWrapper, insertGroupsFromDBIntoWrapperAndRefresh, - setCommunityMappedValue, + refreshMappedValue, + getUserGroupTypes, + + // communities + isCommunityToStoreInWrapper, + getAllCommunities, getCommunityMappedValueByConvoId, - refreshCommunityMappedValue, removeCommunityFromWrapper, + + // legacy group + isLegacyGroupToStoreInWrapper, + getLegacyGroupMappedValueByConvoId, + getAllLegacyGroups, + removeLegacyGroupFromWrapper, // a group can be removed but also just marked hidden, so only call this function when the group is completely removed // TODO }; diff --git a/ts/session/utils/sync/syncUtils.ts b/ts/session/utils/sync/syncUtils.ts index c28fc5b48..277c164a9 100644 --- a/ts/session/utils/sync/syncUtils.ts +++ b/ts/session/utils/sync/syncUtils.ts @@ -76,6 +76,7 @@ export const syncConfigurationIfNeeded = async () => { export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = false) => new Promise(resolve => { + // TODO this should check for feature flag and queue a ConfigurationSyncJob const allConvos = getConversationController().getConversations(); // if we hang for more than 10sec, force resolve this promise. setTimeout(() => { diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_contacts_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_contacts_test.ts index 04dcae4cb..56e2acebd 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_contacts_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_contacts_test.ts @@ -309,6 +309,7 @@ describe('libsession_contacts', () => { id: '051111567890acbdef', type: ConversationTypeEnum.PRIVATE, isApproved: true, + active_at: 123, didApproveMe: true, }; beforeEach(() => { @@ -320,7 +321,7 @@ describe('libsession_contacts', () => { it('excludes ourselves', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, id: ourNumber } as any) ) ).to.be.eq(false); @@ -328,7 +329,7 @@ describe('libsession_contacts', () => { it('excludes non private', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, type: ConversationTypeEnum.GROUP } as any) ) ).to.be.eq(false); @@ -336,15 +337,27 @@ describe('libsession_contacts', () => { it('includes private', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, type: ConversationTypeEnum.PRIVATE } as any) ) ).to.be.eq(true); }); + it('includes hidden private', () => { + expect( + SessionUtilContact.isContactToStoreInContactsWrapper( + new ConversationModel({ + ...validArgs, + type: ConversationTypeEnum.PRIVATE, + hidden: true, + } as any) + ) + ).to.be.eq(true); + }); + it('excludes blinded', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, type: ConversationTypeEnum.PRIVATE, @@ -356,7 +369,7 @@ describe('libsession_contacts', () => { it('excludes non approved by us nor did approveme', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, didApproveMe: false, @@ -368,7 +381,7 @@ describe('libsession_contacts', () => { it('includes approved only by us ', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, didApproveMe: false, @@ -377,9 +390,23 @@ describe('libsession_contacts', () => { ) ).to.be.eq(true); }); + + it('excludes not active ', () => { + expect( + SessionUtilContact.isContactToStoreInContactsWrapper( + new ConversationModel({ + ...validArgs, + didApproveMe: false, + isApproved: true, + active_at: undefined, + } as any) + ) + ).to.be.eq(false); + }); + it('includes approved only by them ', () => { expect( - SessionUtilContact.filterContactsToStoreInContactsWrapper( + SessionUtilContact.isContactToStoreInContactsWrapper( new ConversationModel({ ...validArgs, didApproveMe: true, diff --git a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_usergroups_test.ts b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_usergroups_test.ts index cd42140b8..2d5933029 100644 --- a/ts/test/session/unit/libsession_wrapper/libsession_wrapper_usergroups_test.ts +++ b/ts/test/session/unit/libsession_wrapper/libsession_wrapper_usergroups_test.ts @@ -25,49 +25,120 @@ describe('libsession_groups', () => { Sinon.restore(); }); - it('includes public group/community', () => { - expect( - SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( - new ConversationModel({ ...validArgs } as any) - ) - ).to.be.eq(true); + describe('communities', () => { + it('includes public group/community', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ ...validArgs } as any) + ) + ).to.be.eq(true); + }); + + it('excludes public group/community inactive', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ ...validArgs, active_at: undefined } as any) + ) + ).to.be.eq(false); + }); + }); + + describe('legacy closed groups', () => { + const validLegacyGroupArgs = { + ...validArgs, + type: ConversationTypeEnum.GROUP, + id: '05123456564', + } as any; + + it('includes legacy group', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validLegacyGroupArgs, + }) + ) + ).to.be.eq(true); + }); + + it('exclude legacy group left', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validLegacyGroupArgs, + left: true, + }) + ) + ).to.be.eq(false); + }); + it('exclude legacy group kicked', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validLegacyGroupArgs, + isKickedFromGroup: true, + }) + ) + ).to.be.eq(false); + }); + + it('exclude legacy group not active', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validLegacyGroupArgs, + active_at: undefined, + }) + ) + ).to.be.eq(false); + }); + + it('include hidden legacy group', () => { + expect( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validLegacyGroupArgs, + hidden: true, + }) + ) + ).to.be.eq(true); + }); }); - it('excludes public group/community inactive', () => { + it('excludes closed group v3 (for now)', () => { expect( - SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( - new ConversationModel({ ...validArgs, active_at: undefined } as any) + SessionUtilUserGroups.isUserGroupToStoreInWrapper( + new ConversationModel({ + ...validArgs, + type: ConversationTypeEnum.GROUPV3, + id: '03123456564', + } as any) ) ).to.be.eq(false); }); - it('includes closed group', () => { + it('excludes empty id', () => { expect( - SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( new ConversationModel({ ...validArgs, - type: ConversationTypeEnum.GROUP, - id: '05123456564', + id: '', } as any) ) - ).to.be.eq(true); - }); + ).to.be.eq(false); - it('includes closed group v3', () => { expect( - SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( new ConversationModel({ ...validArgs, - type: ConversationTypeEnum.GROUPV3, - id: '03123456564', + id: '9871', } as any) ) - ).to.be.eq(true); + ).to.be.eq(false); }); it('excludes private', () => { expect( - SessionUtilUserGroups.filterUserGroupsToStoreInWrapper( + SessionUtilUserGroups.isUserGroupToStoreInWrapper( new ConversationModel({ ...validArgs, id: '0511111', diff --git a/ts/test/session/unit/utils/SyncUtils_test.ts b/ts/test/session/unit/utils/SyncUtils_test.ts deleted file mode 100644 index 76fecfc9b..000000000 --- a/ts/test/session/unit/utils/SyncUtils_test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// tslint:disable: no-implicit-dependencies -import chai from 'chai'; - -import chaiAsPromised from 'chai-as-promised'; -import Sinon from 'sinon'; -chai.use(chaiAsPromised as any); - -describe('SyncUtils', () => { - afterEach(() => { - Sinon.restore(); - }); - - describe('syncConfigurationIfNeeded', () => { - it.skip('sync if last sync undefined', () => { - // TestUtils.stubData('getItemById').resolves(undefined); - // sandbox.stub(ConversationController, 'getConversations').returns([]); - // const getCurrentConfigurationMessageSpy = sandbox.spy(MessageUtils, 'getCurrentConfigurationMessage'); - // await syncConfigurationIfNeeded(); - // expect(getCurrentConfigurationMessageSpy.callCount).equal(1); - }); - }); -}); diff --git a/ts/types/sqlSharedTypes.ts b/ts/types/sqlSharedTypes.ts index d084689e8..4d2c2467b 100644 --- a/ts/types/sqlSharedTypes.ts +++ b/ts/types/sqlSharedTypes.ts @@ -1,5 +1,7 @@ -import { isEmpty, isEqual } from 'lodash'; -import { ContactInfo } from 'session_util_wrapper'; +import { from_hex } from 'libsodium-wrappers-sumo'; +import { isArray, isEmpty, isEqual } from 'lodash'; +import { ContactInfo, LegacyGroupInfo, LegacyGroupMemberInfo } from 'session_util_wrapper'; +import { ConversationAttributes } from '../models/conversationAttributes'; import { OpenGroupRequestCommonType } from '../session/apis/open_group_api/opengroupV2/ApiUtil'; import { fromHexToArray } from '../session/utils/String'; import { ConfigWrapperObjectTypes } from '../webworker/workers/browser/libsession_worker_functions'; @@ -162,3 +164,71 @@ export function getCommunityInfoFromDBValues({ return community; } + +function maybeArrayJSONtoArray(arr: string | Array): Array { + try { + if (isArray(arr)) { + return arr; + } + + const parsed = JSON.parse(arr); + if (isArray(parsed)) { + return parsed; + } + return []; + } catch (e) { + return []; + } +} + +export function getLegacyGroupInfoFromDBValues({ + id, + hidden, + isPinned, + members: maybeMembers, + displayNameInProfile, + expireTimer, + encPubkeyHex, + encSeckeyHex, + groupAdmins: maybeAdmins, +}: Pick< + ConversationAttributes, + 'hidden' | 'id' | 'isPinned' | 'displayNameInProfile' | 'expireTimer' +> & { + encPubkeyHex: string; + encSeckeyHex: string; + members: string | Array; + groupAdmins: string | Array; +}) { + const admins: Array = maybeArrayJSONtoArray(maybeAdmins); + const members: Array = maybeArrayJSONtoArray(maybeMembers); + + const wrappedMembers: Array = (members || []).map(m => { + return { + isAdmin: admins.includes(m), + pubkeyHex: m, + }; + }); + const legacyGroup: LegacyGroupInfo = { + pubkeyHex: id, + disappearingTimerSeconds: !expireTimer ? 0 : expireTimer, + hidden: !!hidden, + name: displayNameInProfile || '', + priority: !!isPinned ? 1 : 0, // TODO the priority handling is not that simple + members: wrappedMembers, + encPubkey: !isEmpty(encPubkeyHex) ? from_hex(encPubkeyHex) : new Uint8Array(), + encSeckey: !isEmpty(encSeckeyHex) ? from_hex(encSeckeyHex) : new Uint8Array(), + }; + + console.warn('legacyGroup', legacyGroup); + return legacyGroup; +} + +/** + * This function can be used to make sure all the possible values as input of a switch as taken care off, without having a default case. + * + */ +export function assertUnreachable(_x: never, message: string): never { + console.info(`assertUnreachable: Didn't expect to get here with "${message}"`); + throw new Error("Didn't expect to get here"); +} diff --git a/ts/webworker/workers/browser/libsession_worker_interface.ts b/ts/webworker/workers/browser/libsession_worker_interface.ts index 77c0f9dd5..e8fbac2ed 100644 --- a/ts/webworker/workers/browser/libsession_worker_interface.ts +++ b/ts/webworker/workers/browser/libsession_worker_interface.ts @@ -7,6 +7,7 @@ import { BaseWrapperActionsCalls, ContactInfo, ContactsWrapperActionsCalls, + LegacyGroupInfo, UserConfigWrapperActionsCalls, UserGroupsWrapperActionsCalls, } from 'session_util_wrapper'; @@ -222,6 +223,25 @@ export const UserGroupsWrapperActions: UserGroupsWrapperActionsCalls = { roomId, pubkeyHex, ]) as Promise>, + + getLegacyGroup: async (pubkeyHex: string) => + callLibSessionWorker(['UserGroupsConfig', 'getLegacyGroup', pubkeyHex]) as Promise< + ReturnType + >, + getAllLegacyGroups: async () => + callLibSessionWorker(['UserGroupsConfig', 'getAllLegacyGroups']) as Promise< + ReturnType + >, + + setLegacyGroup: async (info: LegacyGroupInfo) => + callLibSessionWorker(['UserGroupsConfig', 'setLegacyGroup', info]) as Promise< + ReturnType + >, + + eraseLegacyGroup: async (pubkeyHex: string) => + callLibSessionWorker(['UserGroupsConfig', 'eraseLegacyGroup', pubkeyHex]) as Promise< + ReturnType + >, }; const callLibSessionWorker = async (callToMake: LibSessionWorkerFunctions): Promise => { diff --git a/ts/webworker/workers/node/libsession/libsession.worker.ts b/ts/webworker/workers/node/libsession/libsession.worker.ts index 157898b0e..124df4eb4 100644 --- a/ts/webworker/workers/node/libsession/libsession.worker.ts +++ b/ts/webworker/workers/node/libsession/libsession.worker.ts @@ -1,4 +1,4 @@ -import _, { isEmpty, isNull } from 'lodash'; +import { isEmpty, isNull } from 'lodash'; import { BaseConfigWrapper, BaseConfigWrapperInsideWorker, @@ -64,7 +64,7 @@ function assertUserWrapperType(wrapperType: ConfigWrapperObjectTypes): UserWrapp */ function initUserWrapper(options: Array, wrapperType: UserWrapperType): BaseConfigWrapper { const wrapper = getUserWrapper(wrapperType); - console.warn('initUserWrapper: ', wrapperType); + console.warn('initUserWrapper: ', wrapperType, options); if (wrapper) { throw new Error(`${wrapperType} already init`); }