From b53264593b486f25c926dda8f4f9d4ed298b59d9 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 21 Sep 2023 13:30:33 +1000 Subject: [PATCH] feat: preload name of group from usergroup wrapper until we get the groupinfo name from polling --- preload.js | 2 +- ts/components/SessionInboxView.tsx | 2 +- .../leftpane/LeftPaneMessageSection.tsx | 8 +- .../conversation-list-item/UserItem.tsx | 5 +- .../leftpane/overlay/OverlayClosedGroup.tsx | 139 +++++++- ts/hooks/useParamSelector.ts | 5 +- ts/models/conversation.ts | 7 +- ts/receiver/configMessage.ts | 107 +++++-- ts/session/apis/snode_api/batchRequest.ts | 6 +- ts/session/apis/snode_api/swarmPolling.ts | 10 +- .../SwarmPollingGroupConfig.ts | 37 +-- .../conversations/ConversationController.ts | 13 +- ts/session/conversations/createClosedGroup.ts | 20 +- .../utils/job_runners/jobs/GroupConfigJob.ts | 105 +++--- .../utils/libsession/libsession_utils.ts | 72 +---- ts/state/ducks/groups.ts | 302 ++++++++++++++---- ts/state/selectors/groups.ts | 8 + ts/types/sqlSharedTypes.ts | 4 +- ts/util/releaseFeature.ts | 6 - .../node/libsession/libsession.worker.ts | 3 +- ts/window.d.ts | 2 +- 21 files changed, 560 insertions(+), 303 deletions(-) diff --git a/preload.js b/preload.js index f8339fd4b..cb1acea9c 100644 --- a/preload.js +++ b/preload.js @@ -34,7 +34,7 @@ window.sessionFeatureFlags = { integrationTestEnv: Boolean( process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('test-integration') ), - useClosedGroupV3: true, + useClosedGroupV2: true, debug: { debugLogging: !_.isEmpty(process.env.SESSION_DEBUG), debugLibsessionDumps: !_.isEmpty(process.env.SESSION_DEBUG_LIBSESSION_DUMPS), diff --git a/ts/components/SessionInboxView.tsx b/ts/components/SessionInboxView.tsx index bc44721ed..2dad1c993 100644 --- a/ts/components/SessionInboxView.tsx +++ b/ts/components/SessionInboxView.tsx @@ -99,7 +99,7 @@ function setupLeftPane(forceUpdateInboxComponent: () => void) { window.openConversationWithMessages = openConversationWithMessages; window.inboxStore = createSessionInboxStore(); window.inboxStore.dispatch(updateAllOnStorageReady()); - window.inboxStore.dispatch(groupInfoActions.loadDumpsFromDB()); + window.inboxStore.dispatch(groupInfoActions.loadMetaDumpsFromDB()); // this loads the dumps from DB and fills the 03-groups slice with the corresponding details forceUpdateInboxComponent(); } diff --git a/ts/components/leftpane/LeftPaneMessageSection.tsx b/ts/components/leftpane/LeftPaneMessageSection.tsx index 0e5075a2d..a065fbd94 100644 --- a/ts/components/leftpane/LeftPaneMessageSection.tsx +++ b/ts/components/leftpane/LeftPaneMessageSection.tsx @@ -13,7 +13,7 @@ import { assertUnreachable } from '../../types/sqlSharedTypes'; import { SessionSearchInput } from '../SessionSearchInput'; import { StyledLeftPaneList } from './LeftPaneList'; import { ConversationListItem } from './conversation-list-item/ConversationListItem'; -import { OverlayClosedGroup } from './overlay/OverlayClosedGroup'; +import { OverlayLegacyClosedGroup, OverlayClosedGroupV2 } from './overlay/OverlayClosedGroup'; import { OverlayCommunity } from './overlay/OverlayCommunity'; import { OverlayMessage } from './overlay/OverlayMessage'; import { OverlayMessageRequest } from './overlay/OverlayMessageRequest'; @@ -50,7 +50,11 @@ const ClosableOverlay = () => { case 'open-group': return ; case 'closed-group': - return ; + return window.sessionFeatureFlags.useClosedGroupV2 ? ( + + ) : ( + + ); case 'message': return ; case 'message-requests': diff --git a/ts/components/leftpane/conversation-list-item/UserItem.tsx b/ts/components/leftpane/conversation-list-item/UserItem.tsx index 0526bcb2f..9e6e6f8e5 100644 --- a/ts/components/leftpane/conversation-list-item/UserItem.tsx +++ b/ts/components/leftpane/conversation-list-item/UserItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import { isEmpty } from 'lodash'; import { useConversationRealName, useConversationUsername, @@ -18,8 +19,8 @@ export const UserItem = () => { const isSearchResultsMode = useSelector(isSearching); const shortenedPubkey = PubKey.shorten(conversationId); - const isMe = useIsMe(conversationId); const username = useConversationUsername(conversationId); + const isMe = useIsMe(conversationId); const realName = useConversationRealName(conversationId); const hasNickname = useHasNickname(conversationId); @@ -31,7 +32,7 @@ export const UserItem = () => { : username; let shouldShowPubkey = false; - if ((!username || username.length === 0) && (!displayName || displayName.length === 0)) { + if (isEmpty(username) && isEmpty(displayName)) { shouldShowPubkey = true; } diff --git a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx index 62c1f4bd6..35c5ae8e7 100644 --- a/ts/components/leftpane/overlay/OverlayClosedGroup.tsx +++ b/ts/components/leftpane/overlay/OverlayClosedGroup.tsx @@ -4,21 +4,25 @@ import { useDispatch, useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; import styled from 'styled-components'; +import { concat } from 'lodash'; +import { MemberListItem } from '../../MemberListItem'; import { SessionButton } from '../../basic/SessionButton'; import { SessionIdEditable } from '../../basic/SessionIdEditable'; import { SessionSpinner } from '../../basic/SessionSpinner'; -import { MemberListItem } from '../../MemberListItem'; import { OverlayHeader } from './OverlayHeader'; -import { resetOverlayMode } from '../../../state/ducks/section'; -import { getPrivateContactsPubkeys } from '../../../state/selectors/conversations'; -import { SpacerLG } from '../../basic/Text'; -import { SessionSearchInput } from '../../SessionSearchInput'; -import { getSearchResultsContactOnly, isSearching } from '../../../state/selectors/search'; import { useSet } from '../../../hooks/useSet'; import { VALIDATION } from '../../../session/constants'; -import { ToastUtils } from '../../../session/utils'; import { createClosedGroup } from '../../../session/conversations/createClosedGroup'; +import { ToastUtils } from '../../../session/utils'; +import { groupInfoActions } from '../../../state/ducks/groups'; +import { resetOverlayMode } from '../../../state/ducks/section'; +import { getPrivateContactsPubkeys } from '../../../state/selectors/conversations'; +import { useIsCreatingGroupFromUIPending } from '../../../state/selectors/groups'; +import { getSearchResultsContactOnly, isSearching } from '../../../state/selectors/search'; +import { useOurPkStr } from '../../../state/selectors/user'; +import { SessionSearchInput } from '../../SessionSearchInput'; +import { SpacerLG } from '../../basic/Text'; const StyledMemberListNoContacts = styled.div` font-family: var(--font-mono), var(--font-default); @@ -82,12 +86,128 @@ async function createClosedGroupWithToasts( return false; } - await createClosedGroup(groupName, groupMemberIds, window.sessionFeatureFlags.useClosedGroupV3); + await createClosedGroup(groupName, groupMemberIds); return true; } -export const OverlayClosedGroup = () => { +// duplicated form the legacy one below because this one is a lot more tightly linked with redux async thunks logic +export const OverlayClosedGroupV2 = () => { + const dispatch = useDispatch(); + const us = useOurPkStr(); + const privateContactsPubkeys = useSelector(getPrivateContactsPubkeys); + const isCreatingGroup = useIsCreatingGroupFromUIPending(); + const [groupName, setGroupName] = useState(''); + const { + uniqueValues: members, + addTo: addToSelected, + removeFrom: removeFromSelected, + } = useSet([]); + const isSearch = useSelector(isSearching); + const searchResultContactsOnly = useSelector(getSearchResultsContactOnly); + + function closeOverlay() { + dispatch(resetOverlayMode()); + } + + async function onEnterPressed() { + if (isCreatingGroup) { + window?.log?.warn('Closed group creation already in progress'); + return; + } + // Validate groupName and groupMembers length + if (groupName.length === 0) { + ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooShort')); + return; + } + if (groupName.length > VALIDATION.MAX_GROUP_NAME_LENGTH) { + ToastUtils.pushToastError('invalidGroupName', window.i18n('invalidGroupNameTooLong')); + return; + } + + // >= because we add ourself as a member AFTER this. so a 10 group is already invalid as it will be 11 with ourself + // the same is valid with groups count < 1 + + if (members.length < 1) { + ToastUtils.pushToastError('pickClosedGroupMember', window.i18n('pickClosedGroupMember')); + return; + } + if (members.length >= VALIDATION.CLOSED_GROUP_SIZE_LIMIT) { + ToastUtils.pushToastError('closedGroupMaxSize', window.i18n('closedGroupMaxSize')); + return; + } + // trigger the add through redux. + dispatch( + groupInfoActions.initNewGroupInWrapper({ + members: concat(members, [us]), + groupName, + us, + }) as any + ); + } + + useKey('Escape', closeOverlay); + + const title = window.i18n('createGroup'); + const buttonText = window.i18n('create'); + const subtitle = window.i18n('createClosedGroupNamePrompt'); + const placeholder = window.i18n('createClosedGroupPlaceholder'); + + const noContactsForClosedGroup = privateContactsPubkeys.length === 0; + + const contactsToRender = isSearch ? searchResultContactsOnly : privateContactsPubkeys; + + const disableCreateButton = !members.length && !groupName.length; + + return ( +
+ +
+ +
+ + + + + {noContactsForClosedGroup ? ( + + ) : ( + + {contactsToRender.map((memberPubkey: string) => ( + m === memberPubkey)} + key={memberPubkey} + onSelect={addToSelected} + onUnselect={removeFromSelected} + disableBg={true} + /> + ))} + + )} + + + +
+ ); +}; + +export const OverlayLegacyClosedGroup = () => { const dispatch = useDispatch(); const privateContactsPubkeys = useSelector(getPrivateContactsPubkeys); const [groupName, setGroupName] = useState(''); @@ -111,6 +231,7 @@ export const OverlayClosedGroup = () => { } setLoading(true); const groupCreated = await createClosedGroupWithToasts(groupName, selectedMemberIds); + if (groupCreated) { closeOverlay(); return; diff --git a/ts/hooks/useParamSelector.ts b/ts/hooks/useParamSelector.ts index f73c28e8f..f40b5c7f2 100644 --- a/ts/hooks/useParamSelector.ts +++ b/ts/hooks/useParamSelector.ts @@ -31,7 +31,10 @@ export function useConversationUsername(convoId?: string) { const convoProps = useConversationPropsById(convoId); const groupName = useLibGroupName(convoId); - if (convoId && PubKey.isClosedGroupV2(convoId)) { + if (convoId && PubKey.isClosedGroupV2(convoId) && groupName) { + // when getting a new 03 group from the usergroup wrapper, + // we set the displayNameInProfile with the name from the wrapper. + // So let's keep falling back to convoProps?.displayNameInProfile if groupName is not set yet (it comes later through the groupInfos namespace) return groupName; } return convoProps?.nickname || convoProps?.displayNameInProfile || convoId; diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 43e06366a..537c9af0c 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -1728,7 +1728,7 @@ export class ConversationModel extends Backbone.Model { public isKickedFromGroup(): boolean { if (this.isClosedGroup()) { if (this.isClosedGroupV3()) { - console.info('isKickedFromGroup using lib todo'); // debugger + // console.info('isKickedFromGroup using lib todo'); // debugger } return !!this.get('isKickedFromGroup'); } @@ -1738,7 +1738,7 @@ export class ConversationModel extends Backbone.Model { public isLeft(): boolean { if (this.isClosedGroup()) { if (this.isClosedGroupV3()) { - console.info('isLeft using lib todo'); // debugger + // console.info('isLeft using lib todo'); // debugger } return !!this.get('left'); } @@ -2340,9 +2340,6 @@ export async function commitConversationAndRefreshWrapper(id: string) { return; } - // TODOLATER remove duplicates between db and wrapper (and move search by name or nickname to wrapper) - // TODOLATER insertConvoFromDBIntoWrapperAndRefresh and insertContactFromDBIntoWrapperAndRefresh both fetches the same data from the DB. Might be worth fetching it and providing it to both? - // write to db const savedDetails = await Data.saveConversation(convo.attributes); await convo.refreshInMemoryDetails(savedDetails); diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 321b3b83d..a992c77e1 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop */ -import { ContactInfo } from 'libsession_util_nodejs'; +import { ContactInfo, UserGroupsGet } from 'libsession_util_nodejs'; import { compact, difference, isEmpty, isNil, isNumber, toNumber } from 'lodash'; import { ConfigDumpData } from '../data/configDump/configDump'; import { SettingsKey } from '../data/settings-key'; @@ -47,6 +47,7 @@ import { addKeyPairToCacheAndDBIfNeeded } from './closedGroups'; import { HexKeyPair } from './keypairs'; import { queueAllCachedFromSource } from './receiver'; import { HexString } from '../node/hexStrings'; +import { groupInfoActions } from '../state/ducks/groups'; type IncomingUserResult = { needsPush: boolean; @@ -125,7 +126,7 @@ async function mergeUserConfigsWithIncomingUpdates( const latestEnvelopeTimestamp = Math.max(...sameVariant.map(m => m.envelopeTimestamp)); window.log.debug( - `${variant}: "${publicKey}" needsPush:${needsPush} needsDump:${needsDump}; mergedCount:${mergedCount} ` + `${variant}: needsPush:${needsPush} needsDump:${needsDump}; mergedCount:${mergedCount} ` ); if (window.sessionFeatureFlags.debug.debugLibsessionDumps) { @@ -609,12 +610,77 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) { } } +async function handleSingleGroupUpdate({ + groupInWrapper, + userEdKeypair, +}: { + groupInWrapper: UserGroupsGet; + latestEnvelopeTimestamp: number; + userEdKeypair: UserUtils.ByteKeyPair; +}) { + const groupPk = groupInWrapper.pubkeyHex; + try { + // dump is always empty when creating a new groupInfo + await MetaGroupWrapperActions.init(groupPk, { + metaDumped: null, + userEd25519Secretkey: toFixedUint8ArrayOfLength(userEdKeypair.privKeyBytes, 64), + groupEd25519Secretkey: groupInWrapper.secretKey, + groupEd25519Pubkey: toFixedUint8ArrayOfLength(HexString.fromHexString(groupPk.slice(2)), 32), + }); + } catch (e) { + window.log.warn( + `handleSingleGroupUpdate metawrapper init of "${groupPk}" failed with`, + e.message + ); + } + + if (!getConversationController().get(groupPk)) { + const created = await getConversationController().getOrCreateAndWait( + groupPk, + ConversationTypeEnum.GROUPV3 + ); + const joinedAt = groupInWrapper.joinedAtSeconds * 1000 || Date.now(); + created.set({ + active_at: joinedAt, + displayNameInProfile: groupInWrapper.name || undefined, + priority: groupInWrapper.priority, + lastJoinedTimestamp: joinedAt, + }); + await created.commit(); + getSwarmPollingInstance().addGroupId(PubKey.cast(groupPk)); + } +} + +async function handleSingleGroupUpdateToLeave(toLeave: string) { + // that group is not in the wrapper but in our local DB. it must be removed and cleaned + try { + window.log.debug( + `About to deleteGroup ${toLeave} via handleSingleGroupUpdateToLeave as in DB but not in wrapper` + ); + + await getConversationController().deleteClosedGroup(toLeave, { + fromSyncMessage: true, + sendLeaveMessage: false, + }); + } catch (e) { + window.log.info('Failed to deleteClosedGroup with: ', e.message); + } +} + +/** + * Called when we just got a userGroups merge from the network. We need to apply the changes to our local state. (i.e. DB and redux slice of 03 groups) + */ async function handleGroupUpdate(latestEnvelopeTimestamp: number) { // first let's check which groups needs to be joined or left by doing a diff of what is in the wrapper and what is in the DB const allGoupsInWrapper = await UserGroupsWrapperActions.getAllGroups(); + const allGoupsInDb = getConversationController() + .getConversations() + .filter(m => PubKey.isClosedGroupV2(m.id)); const allGoupsIdsInWrapper = allGoupsInWrapper.map(m => m.pubkeyHex); + const allGoupsIdsInDb = allGoupsInDb.map(m => m.id as string); console.warn('allGoupsIdsInWrapper', stringify(allGoupsIdsInWrapper)); + console.warn('allGoupsIdsInDb', stringify(allGoupsIdsInDb)); const userEdKeypair = await UserUtils.getUserED25519KeyPairBytes(); if (!userEdKeypair) { @@ -623,30 +689,19 @@ async function handleGroupUpdate(latestEnvelopeTimestamp: number) { for (let index = 0; index < allGoupsInWrapper.length; index++) { const groupInWrapper = allGoupsInWrapper[index]; - const groupPk = groupInWrapper.pubkeyHex; - if (!getConversationController().get(groupPk)) { - try { - // dump is always empty when creating a new groupInfo - await MetaGroupWrapperActions.init(groupPk, { - metaDumped: null, - userEd25519Secretkey: toFixedUint8ArrayOfLength(userEdKeypair.privKeyBytes, 64), - groupEd25519Secretkey: groupInWrapper.secretKey, - groupEd25519Pubkey: toFixedUint8ArrayOfLength( - HexString.fromHexString(groupPk.slice(2)), - 32 - ), - }); - } catch (e) { - window.log.warn(`MetaGroupWrapperActions.init of "${groupPk}" failed with`, e.message); - } - const created = await getConversationController().getOrCreateAndWait( - groupPk, - ConversationTypeEnum.GROUPV3 - ); - created.set({ active_at: latestEnvelopeTimestamp }); - await created.commit(); - getSwarmPollingInstance().addGroupId(PubKey.cast(groupPk)); - } + window.inboxStore.dispatch(groupInfoActions.handleUserGroupUpdate(groupInWrapper)); + + await handleSingleGroupUpdate({ groupInWrapper, latestEnvelopeTimestamp, userEdKeypair }); + } + + const groupsInDbButNotInWrapper = difference(allGoupsIdsInDb, allGoupsIdsInWrapper); + window.log.info( + `we have to leave ${groupsInDbButNotInWrapper.length} 03 groups in DB compared to what is in the wrapper` + ); + + for (let index = 0; index < groupsInDbButNotInWrapper.length; index++) { + const toRemove = groupsInDbButNotInWrapper[index]; + await handleSingleGroupUpdateToLeave(toRemove); } } diff --git a/ts/session/apis/snode_api/batchRequest.ts b/ts/session/apis/snode_api/batchRequest.ts index a5212ecf1..7a493fe22 100644 --- a/ts/session/apis/snode_api/batchRequest.ts +++ b/ts/session/apis/snode_api/batchRequest.ts @@ -27,11 +27,7 @@ export async function doSnodeBatchRequest( associatedWith: string | null, method: 'batch' | 'sequence' = 'batch' ): Promise { - console.warn( - `doSnodeBatchRequest "${method}":`, - JSON.stringify(logSubRequests(subRequests)) - // subRequests - ); + window.log.debug(`doSnodeBatchRequest "${method}":`, JSON.stringify(logSubRequests(subRequests))); const result = await snodeRpc({ method, params: { requests: subRequests }, diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 4e4b48e75..96a1be92d 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -320,7 +320,7 @@ export class SwarmPolling { await this.notPollingForGroupAsNotInWrapper(pubkey, 'not in wrapper after poll'); return; } - if (PubKey.isClosedGroupV2(pubkey) && allGroupsInWrapper.some(m => m.pubkeyHex === pubkey)) { + if (PubKey.isClosedGroupV2(pubkey) && !allGroupsInWrapper.some(m => m.pubkeyHex === pubkey)) { // not tracked anymore in the wrapper. Discard messages and stop polling await this.notPollingForGroupAsNotInWrapper(pubkey, 'not in wrapper after poll'); return; @@ -456,7 +456,13 @@ export class SwarmPolling { } private async notPollingForGroupAsNotInWrapper(pubkey: string, reason: string) { - this.removePubkey(pubkey, reason); + window.log.debug( + `notPollingForGroupAsNotInWrapper ${ed25519Str(pubkey)} with reason:"${reason}"` + ); + await getConversationController().deleteClosedGroup(pubkey, { + fromSyncMessage: true, + sendLeaveMessage: false, + }); return Promise.resolve(); } diff --git a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts index 2fd1cb360..790bd7002 100644 --- a/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts +++ b/ts/session/apis/snode_api/swarm_polling_config/SwarmPollingGroupConfig.ts @@ -1,12 +1,11 @@ import { GroupPubkeyType } from 'libsession_util_nodejs'; -import { stringify } from '../../../../types/sqlSharedTypes'; +import { groupInfoActions } from '../../../../state/ducks/groups'; import { MetaGroupWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; import { ed25519Str } from '../../../onions/onionPath'; import { fromBase64ToArray } from '../../../utils/String'; +import { LibSessionUtil } from '../../../utils/libsession/libsession_utils'; import { SnodeNamespaces } from '../namespaces'; import { RetrieveMessageItemWithNamespace } from '../types'; -import { groupInfoActions } from '../../../../state/ducks/groups'; -import { LibSessionUtil } from '../../../utils/libsession/libsession_utils'; async function handleGroupSharedConfigMessages( groupConfigMessagesMerged: Array, @@ -43,41 +42,17 @@ async function handleGroupSharedConfigMessages( groupMember: members, }; + // do the merge with our current state await MetaGroupWrapperActions.metaMerge(groupPk, toMerge); + // save updated dumps to the DB right away await LibSessionUtil.saveMetaGroupDumpToDb(groupPk); - const updatedInfos = await MetaGroupWrapperActions.infoGet(groupPk); - const updatedMembers = await MetaGroupWrapperActions.memberGetAll(groupPk); - console.info(`groupInfo after merge: ${stringify(updatedInfos)}`); - console.info(`groupMembers after merge: ${stringify(updatedMembers)}`); - if (!updatedInfos || !updatedMembers) { - throw new Error('updatedInfos or updatedMembers is null but we just created them'); - } - + // refresh the redux slice with the merged result window.inboxStore.dispatch( - groupInfoActions.updateGroupDetailsAfterMerge({ + groupInfoActions.refreshGroupDetailsFromWrapper({ groupPk, - infos: updatedInfos, - members: updatedMembers, }) ); - - // if (allDecryptedConfigMessages.length) { - // try { - // window.log.info( - // `handleGroupSharedConfigMessages of "${allDecryptedConfigMessages.length}" messages with libsession` - // ); - // console.warn('HANDLING OF INCOMING GROUP TODO '); - // // await ConfigMessageHandler.handleUserConfigMessagesViaLibSession( - // // allDecryptedConfigMessages - // // ); - // } catch (e) { - // const allMessageHases = allDecryptedConfigMessages.map(m => m.messageHash).join(','); - // window.log.warn( - // `failed to handle group messages hashes "${allMessageHases}" with libsession. Error: "${e.message}"` - // ); - // } - // } } catch (e) { window.log.warn( `handleGroupSharedConfigMessages of ${groupConfigMessagesMerged.length} failed with ${e.message}` diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 4e4b1bdfd..1d91c56cb 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -19,6 +19,7 @@ import { getMessageQueue } from '..'; import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/conversationInteractions'; import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/conversationAttributes'; import { removeAllClosedGroupEncryptionKeyPairs } from '../../receiver/closedGroups'; +import { groupInfoActions } from '../../state/ducks/groups'; import { getCurrentlySelectedConversationOutsideRedux } from '../../state/selectors/conversations'; import { assertUnreachable } from '../../types/sqlSharedTypes'; import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; @@ -27,6 +28,7 @@ import { getSwarmPollingInstance } from '../apis/snode_api'; import { GetNetworkTime } from '../apis/snode_api/getNetworkTime'; import { SnodeNamespaces } from '../apis/snode_api/namespaces'; import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage'; +import { ed25519Str } from '../onions/onionPath'; import { UserUtils } from '../utils'; import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJob'; import { LibSessionUtil } from '../utils/libsession/libsession_utils'; @@ -532,12 +534,13 @@ async function removeLegacyGroupFromWrappers(groupId: string) { await removeAllClosedGroupEncryptionKeyPairs(groupId); } -async function remove03GroupFromWrappers(groupId: GroupPubkeyType) { - getSwarmPollingInstance().removePubkey(groupId, 'remove03GroupFromWrappers'); +async function remove03GroupFromWrappers(groupPk: GroupPubkeyType) { + getSwarmPollingInstance().removePubkey(groupPk, 'remove03GroupFromWrappers'); - await UserGroupsWrapperActions.eraseGroup(groupId); - await SessionUtilConvoInfoVolatile.removeGroupFromWrapper(groupId); - window.log.warn('remove 03 from metagroup wrapper'); + await UserGroupsWrapperActions.eraseGroup(groupPk); + await SessionUtilConvoInfoVolatile.removeGroupFromWrapper(groupPk); + window?.inboxStore?.dispatch(groupInfoActions.destroyGroupDetails({ groupPk })); + window.log.info(`removed 03 from metagroup wrapper ${ed25519Str(groupPk)}`); } async function removeCommunityFromWrappers(conversationId: string) { diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts index 0a46c480f..db2ee0165 100644 --- a/ts/session/conversations/createClosedGroup.ts +++ b/ts/session/conversations/createClosedGroup.ts @@ -1,4 +1,4 @@ -import _, { concat } from 'lodash'; +import _ from 'lodash'; import { ClosedGroup, getMessageQueue } from '..'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { addKeyPairToCacheAndDBIfNeeded } from '../../receiver/closedGroups'; @@ -16,27 +16,13 @@ import { PubKey } from '../types'; import { UserUtils } from '../utils'; import { forceSyncConfigurationNowIfNeeded } from '../utils/sync/syncUtils'; import { getConversationController } from './ConversationController'; -import { groupInfoActions } from '../../state/ducks/groups'; /** * Creates a brand new closed group from user supplied details. This function generates a new identityKeyPair so cannot be used to restore a closed group. * @param groupName the name of this closed group * @param members the initial members of this closed group - * @param isV3 if this closed group is a v3 closed group or not (has a 03 prefix in the identity keypair) */ -export async function createClosedGroup(groupName: string, members: Array, isV3: boolean) { - if (isV3) { - const us = UserUtils.getOurPubKeyStrFromCache(); - - // we need to send a group info and encryption keys message to the batch endpoint with both seqno being 0 - console.error('isV3 send invite to group TODO'); // FIXME - // FIXME we should save the key to the wrapper right away? or even to the DB idk - window.inboxStore.dispatch( - groupInfoActions.initNewGroupInWrapper({ members: concat(members, [us]), groupName, us }) - ); - return; - } - +export async function createClosedGroup(groupName: string, members: Array) { // this is all legacy group logic. // TODO: To be removed @@ -64,8 +50,6 @@ export async function createClosedGroup(groupName: string, members: Array { + // save the dumps to DB even before trying to push them, so at least we have an up to date dumps in the DB in case of crash, no network etc + await LibSessionUtil.saveMetaGroupDumpToDb(groupPk); + + const singleDestChanges = await LibSessionUtil.pendingChangesForGroup(groupPk); + + // If there are no pending changes then the job can just complete (next time something + // is updated we want to try and run immediately so don't scuedule another run in this case) + if (isEmpty(singleDestChanges?.messages)) { + return RunJobResult.Success; + } + const oldHashesToDelete = new Set(singleDestChanges.allOldHashes); + + const msgs: Array = singleDestChanges.messages.map(item => { + return { + namespace: item.namespace, + pubkey: groupPk, + networkTimestamp: GetNetworkTime.getNowWithNetworkOffset(), + ttl: TTL_DEFAULT.TTL_CONFIG, + data: item.data, + }; + }); + + const result = await MessageSender.sendEncryptedDataToSnode(msgs, groupPk, oldHashesToDelete); + + const expectedReplyLength = singleDestChanges.messages.length + (oldHashesToDelete.size ? 1 : 0); + // we do a sequence call here. If we do not have the right expected number of results, consider it a failure + if (!isArray(result) || result.length !== expectedReplyLength) { + window.log.info( + `GroupSyncJob: unexpected result length: expected ${expectedReplyLength} but got ${result?.length}` + ); + // this might be a 421 error (already handled) so let's retry this request a little bit later + return RunJobResult.RetryJobIfPossible; + } + + const changes = resultsToSuccessfulChange(result, singleDestChanges); + if (isEmpty(changes)) { + return RunJobResult.RetryJobIfPossible; + } + // Now that we have the successful changes, we need to mark them as pushed and + // generate any config dumps which need to be stored + + await buildAndSaveDumpsToDB(changes, groupPk); + return RunJobResult.Success; +} + class GroupSyncJob extends PersistedJob { constructor({ identifier, // this has to be the pubkey to which we @@ -162,59 +203,8 @@ class GroupSyncJob extends PersistedJob { return RunJobResult.PermanentFailure; } - // save the dumps to DB even before trying to push them, so at least we have an up to date dumps in the DB in case of crash, no network etc - await LibSessionUtil.saveMetaGroupDumpToDb(thisJobDestination); - const newGroupsReleased = await ReleasedFeatures.checkIsNewGroupsReleased(); - - // if the feature flag is not enabled, we want to keep updating the dumps, but just not sync them. - if (!newGroupsReleased) { - return RunJobResult.Success; - } - const singleDestChanges = await LibSessionUtil.pendingChangesForGroup(thisJobDestination); - - // If there are no pending changes then the job can just complete (next time something - // is updated we want to try and run immediately so don't scuedule another run in this case) - if (isEmpty(singleDestChanges?.messages)) { - return RunJobResult.Success; - } - const oldHashesToDelete = new Set(singleDestChanges.allOldHashes); - - const msgs: Array = singleDestChanges.messages.map(item => { - return { - namespace: item.namespace, - pubkey: thisJobDestination, - networkTimestamp: GetNetworkTime.getNowWithNetworkOffset(), - ttl: TTL_DEFAULT.TTL_CONFIG, - data: item.data, - }; - }); - - const result = await MessageSender.sendEncryptedDataToSnode( - msgs, - thisJobDestination, - oldHashesToDelete - ); - - const expectedReplyLength = - singleDestChanges.messages.length + (oldHashesToDelete.size ? 1 : 0); - // we do a sequence call here. If we do not have the right expected number of results, consider it a failure - if (!isArray(result) || result.length !== expectedReplyLength) { - window.log.info( - `GroupSyncJob: unexpected result length: expected ${expectedReplyLength} but got ${result?.length}` - ); - // this might be a 421 error (already handled) so let's retry this request a little bit later - return RunJobResult.RetryJobIfPossible; - } - - const changes = resultsToSuccessfulChange(result, singleDestChanges); - if (isEmpty(changes)) { - return RunJobResult.RetryJobIfPossible; - } - // Now that we have the successful changes, we need to mark them as pushed and - // generate any config dumps which need to be stored + return await pushChangesToGroupSwarmIfNeeded(thisJobDestination); - await buildAndSaveDumpsToDB(changes, thisJobDestination); - return RunJobResult.Success; // eslint-disable-next-line no-useless-catch } catch (e) { throw e; @@ -289,6 +279,7 @@ async function queueNewJobIfNeeded(groupPk: GroupPubkeyType) { export const GroupSync = { GroupSyncJob, + pushChangesToGroupSwarmIfNeeded, queueNewJobIfNeeded: (groupPk: GroupPubkeyType) => allowOnlyOneAtATime(`GroupSyncJob-oneAtAtTime-${groupPk}`, () => queueNewJobIfNeeded(groupPk)), }; diff --git a/ts/session/utils/libsession/libsession_utils.ts b/ts/session/utils/libsession/libsession_utils.ts index 366ae2331..9c4b7406f 100644 --- a/ts/session/utils/libsession/libsession_utils.ts +++ b/ts/session/utils/libsession/libsession_utils.ts @@ -6,25 +6,17 @@ import { compact, difference, omit } from 'lodash'; import Long from 'long'; import { UserUtils } from '..'; import { ConfigDumpData } from '../../../data/configDump/configDump'; -import { HexString } from '../../../node/hexStrings'; import { SignalService } from '../../../protobuf'; import { UserConfigKind } from '../../../types/ProtobufKind'; -import { - ConfigDumpRow, - assertUnreachable, - toFixedUint8ArrayOfLength, -} from '../../../types/sqlSharedTypes'; +import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { ConfigWrapperGroupDetailed, ConfigWrapperUser, - getGroupPubkeyFromWrapperType, - isMetaWrapperType, isUserConfigWrapperType, } from '../../../webworker/workers/browser/libsession_worker_functions'; import { GenericWrapperActions, MetaGroupWrapperActions, - UserGroupsWrapperActions, } from '../../../webworker/workers/browser/libsession_worker_interface'; import { GetNetworkTime } from '../../apis/snode_api/getNetworkTime'; import { SnodeNamespaces } from '../../apis/snode_api/namespaces'; @@ -34,7 +26,6 @@ import { } from '../../messages/outgoing/controlMessage/SharedConfigMessage'; import { ed25519Str } from '../../onions/onionPath'; import { PubKey } from '../../types'; -import { getUserED25519KeyPairBytes } from '../User'; import { ConfigurationSync } from '../job_runners/jobs/ConfigurationSyncJob'; const requiredUserVariants: Array = [ @@ -114,61 +105,7 @@ async function initializeLibSessionUtilWrappers() { ); } - await loadMetaGroupWrappers(dumps); -} - -async function loadMetaGroupWrappers(dumps: Array) { - const ed25519KeyPairBytes = await getUserED25519KeyPairBytes(); - if (!ed25519KeyPairBytes?.privKeyBytes) { - throw new Error('user has no ed25519KeyPairBytes.'); - } - // load the dumps retrieved from the database into their corresponding wrappers - for (let index = 0; index < dumps.length; index++) { - const dump = dumps[index]; - const { variant } = dump; - if (!isMetaWrapperType(variant)) { - continue; - } - const groupPk = getGroupPubkeyFromWrapperType(variant); - const groupPkNoPrefix = groupPk.substring(2); - const groupEd25519Pubkey = HexString.fromHexString(groupPkNoPrefix); - - try { - const foundInUserGroups = await UserGroupsWrapperActions.getGroup(groupPk); - - // remove it right away, and skip it entirely - if (!foundInUserGroups) { - try { - window.log.info( - 'metaGroup not found in userGroups. Deleting the corresponding dumps:', - groupPk - ); - - await ConfigDumpData.deleteDumpFor(groupPk); - } catch (e) { - window.log.warn('deleteDumpFor failed with ', e.message); - } - // await UserGroupsWrapperActions.eraseGroup(groupPk); - continue; - } - - window.log.debug('initializeLibSessionUtilWrappers initing from metagroup dump', variant); - // TODO we need to fetch the admin key here if we have it, maybe from the usergroup wrapper? - await MetaGroupWrapperActions.init(groupPk, { - groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd25519Pubkey, 32), - groupEd25519Secretkey: foundInUserGroups?.secretKey || null, - userEd25519Secretkey: toFixedUint8ArrayOfLength(ed25519KeyPairBytes.privKeyBytes, 64), - metaDumped: dump.data, - }); - - // Annoyingly, the redux store is not initialized when this current funciton is called, - // so we need to init the group wrappers here, but load them in their redux slice later - } catch (e) { - // TODO should not throw in this case? we should probably just try to load what we manage to load - window.log.warn(`initGroup of Group wrapper of variant ${variant} failed with ${e.message} `); - // throw new Error(`initializeLibSessionUtilWrappers failed with ${e.message}`); - } - } + // No need to load the meta group wrapper here. We will load them once the SessionInbox is loaded with a redux action } async function pendingChangesForUs(): Promise< @@ -304,7 +241,6 @@ async function pendingChangesForGroup( const infoHashes = compact(groupInfo?.hashes) || []; const allOldHashes = new Set([...infoHashes, ...memberHashes]); - console.error('compactedHashes', [...allOldHashes]); return { messages: results, allOldHashes }; } @@ -361,9 +297,9 @@ async function saveMetaGroupDumpToDb(groupPk: GroupPubkeyType) { publicKey: groupPk, variant: `MetaGroupConfig-${groupPk}`, }); - window.log.debug(`Saved dumps for metagroup ${groupPk}`); + window.log.debug(`Saved dumps for metagroup ${ed25519Str(groupPk)}`); } else { - window.log.debug(`meta did not dumps saving for metagroup ${groupPk}`); + window.log.debug(`No need to update local dumps for metagroup ${ed25519Str(groupPk)}`); } } diff --git a/ts/state/ducks/groups.ts b/ts/state/ducks/groups.ts index e80e110fb..0924ce692 100644 --- a/ts/state/ducks/groups.ts +++ b/ts/state/ducks/groups.ts @@ -1,14 +1,21 @@ /* eslint-disable no-await-in-loop */ -import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { GroupInfoGet, GroupMemberGet, GroupPubkeyType } from 'libsession_util_nodejs'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { + GroupInfoGet, + GroupMemberGet, + GroupPubkeyType, + UserGroupsGet, +} from 'libsession_util_nodejs'; import { isEmpty, uniq } from 'lodash'; import { ConfigDumpData } from '../../data/configDump/configDump'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { HexString } from '../../node/hexStrings'; import { getConversationController } from '../../session/conversations'; import { UserUtils } from '../../session/utils'; +import { getUserED25519KeyPairBytes } from '../../session/utils/User'; +import { PreConditionFailed } from '../../session/utils/errors'; import { GroupSync } from '../../session/utils/job_runners/jobs/GroupConfigJob'; -import { toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes'; +import { stringify, toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes'; import { getGroupPubkeyFromWrapperType, isMetaWrapperType, @@ -17,16 +24,22 @@ import { MetaGroupWrapperActions, UserGroupsWrapperActions, } from '../../webworker/workers/browser/libsession_worker_interface'; -import { PreConditionFailed } from '../../session/utils/errors'; +import { getSwarmPollingInstance } from '../../session/apis/snode_api'; +import { StateType } from '../reducer'; +import { RunJobResult } from '../../session/utils/job_runners/PersistedJob'; +import { resetOverlayMode } from './section'; +import { openConversationWithMessages } from './conversations'; export type GroupState = { infos: Record; members: Record>; + creationFromUIPending: boolean; }; export const initialGroupState: GroupState = { infos: {}, members: {}, + creationFromUIPending: false, }; type GroupDetailsUpdate = { @@ -35,17 +48,25 @@ type GroupDetailsUpdate = { members: Array; }; +/** + * Create a brand new group with a 03 prefix. + * To be called only when our current logged in user, through the UI, creates a brand new closed group given a name and a list of members. + * + */ const initNewGroupInWrapper = createAsyncThunk( 'group/initNewGroupInWrapper', - async ({ - groupName, - members, - us, - }: { - groupName: string; - members: Array; - us: string; - }): Promise => { + async ( + { + groupName, + members, + us, + }: { + groupName: string; + members: Array; + us: string; + }, + { dispatch } + ): Promise => { if (!members.includes(us)) { throw new PreConditionFailed('initNewGroupInWrapper needs us to be a member'); } @@ -54,8 +75,8 @@ const initNewGroupInWrapper = createAsyncThunk( const groupPk = newGroup.pubkeyHex; newGroup.name = groupName; // this will be used by the linked devices until they fetch the info from the groups swarm + // the `GroupSync` below will need the secretKey of the group to be saved in the wrapper. So save it! await UserGroupsWrapperActions.setGroup(newGroup); - const ourEd25519KeypairBytes = await UserUtils.getUserED25519KeyPairBytes(); if (!ourEd25519KeypairBytes) { throw new Error('Current user has no priv ed25519 key?'); @@ -100,70 +121,135 @@ const initNewGroupInWrapper = createAsyncThunk( await convo.setIsApproved(true, false); - // console.warn('store the v3 identityPrivatekeypair as part of the wrapper only?'); - // // the sync below will need the secretKey of the group to be saved in the wrapper. So save it! - await UserGroupsWrapperActions.setGroup(newGroup); - - await GroupSync.queueNewJobIfNeeded(groupPk); - - // const updateGroupDetails: ClosedGroup.GroupInfo = { - // id: newGroup.pubkeyHex, - // name: groupDetails.groupName, - // members, - // admins: [us], - // activeAt: Date.now(), - // expireTimer: 0, - // }; + const result = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk); + if (result !== RunJobResult.Success) { + window.log.warn('GroupSync.pushChangesToGroupSwarmIfNeeded during create failed'); + } - // // be sure to call this before sending the message. - // // the sending pipeline needs to know from GroupUtils when a message is for a medium group - // await ClosedGroup.updateOrCreateClosedGroup(updateGroupDetails); await convo.unhideIfNeeded(); convo.set({ active_at: Date.now() }); await convo.commit(); convo.updateLastMessage(); + dispatch(resetOverlayMode()); + await openConversationWithMessages({ conversationKey: groupPk, messageId: null }); return { groupPk: newGroup.pubkeyHex, infos, members: membersFromWrapper }; } ); -const loadDumpsFromDB = createAsyncThunk( - 'group/loadDumpsFromDB', +/** + * Create a brand new group with a 03 prefix. + * To be called only when our current logged in user, through the UI, creates a brand new closed group given a name and a list of members. + * + */ +const handleUserGroupUpdate = createAsyncThunk( + 'group/handleUserGroupUpdate', + async (userGroup: UserGroupsGet, payloadCreator): Promise => { + // if we already have a state for that group here, it means that group was already init, and the data should come from the groupInfos after. + const state = payloadCreator.getState() as StateType; + const groupPk = userGroup.pubkeyHex; + if (state.groups.infos[groupPk] && state.groups.members[groupPk]) { + throw new Error('handleUserGroupUpdate group already present in redux slice'); + } + + const ourEd25519KeypairBytes = await UserUtils.getUserED25519KeyPairBytes(); + if (!ourEd25519KeypairBytes) { + throw new Error('Current user has no priv ed25519 key?'); + } + const userEd25519Secretkey = ourEd25519KeypairBytes.privKeyBytes; + const groupEd2519Pk = HexString.fromHexString(groupPk).slice(1); // remove the 03 prefix (single byte once in hex form) + + // dump is always empty when creating a new groupInfo + try { + await MetaGroupWrapperActions.init(groupPk, { + metaDumped: null, + userEd25519Secretkey: toFixedUint8ArrayOfLength(userEd25519Secretkey, 64), + groupEd25519Secretkey: userGroup.secretKey, + groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd2519Pk, 32), + }); + } catch (e) { + window.log.warn(`failed to init metawrapper ${groupPk}`); + } + + const convo = await getConversationController().getOrCreateAndWait( + groupPk, + ConversationTypeEnum.GROUPV3 + ); + + await convo.setIsApproved(true, false); + + await convo.setPriorityFromWrapper(userGroup.priority, false); + convo.set({ + active_at: Date.now(), + displayNameInProfile: userGroup.name || undefined, + }); + await convo.commit(); + + return { + groupPk, + infos: await MetaGroupWrapperActions.infoGet(groupPk), + members: await MetaGroupWrapperActions.memberGetAll(groupPk), + }; + } +); + +/** + * Called only when the app just loaded the SessionInbox (i.e. user logged in and fully loaded). + * This function populates the slice with any meta-dumps we have in the DB, if they also are part of what is the usergroup wrapper tracking. + * + */ +const loadMetaDumpsFromDB = createAsyncThunk( + 'group/loadMetaDumpsFromDB', async (): Promise> => { - const variantsWithoutData = await ConfigDumpData.getAllDumpsWithoutData(); + const ed25519KeyPairBytes = await getUserED25519KeyPairBytes(); + if (!ed25519KeyPairBytes?.privKeyBytes) { + throw new Error('user has no ed25519KeyPairBytes.'); + } + + const variantsWithData = await ConfigDumpData.getAllDumpsWithData(); const allUserGroups = await UserGroupsWrapperActions.getAllGroups(); const toReturn: Array = []; - for (let index = 0; index < variantsWithoutData.length; index++) { - const { variant } = variantsWithoutData[index]; + for (let index = 0; index < variantsWithData.length; index++) { + const { variant, data } = variantsWithData[index]; if (!isMetaWrapperType(variant)) { continue; } const groupPk = getGroupPubkeyFromWrapperType(variant); + const groupEd25519Pubkey = HexString.fromHexString(groupPk.substring(2)); const foundInUserWrapper = allUserGroups.find(m => m.pubkeyHex === groupPk); if (!foundInUserWrapper) { + try { + window.log.info( + 'metaGroup not found in userGroups. Deleting the corresponding dumps:', + groupPk + ); + + await ConfigDumpData.deleteDumpFor(groupPk); + } catch (e) { + window.log.warn(`ConfigDumpData.deleteDumpFor for ${groupPk} failed with `, e.message); + } continue; } try { - window.log.debug( - 'loadDumpsFromDB loading from metagroup variant: ', - variant, - foundInUserWrapper.pubkeyHex - ); + window.log.debug('loadMetaDumpsFromDB initing from metagroup dump', variant); + + await MetaGroupWrapperActions.init(groupPk, { + groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd25519Pubkey, 32), + groupEd25519Secretkey: foundInUserWrapper?.secretKey || null, + userEd25519Secretkey: toFixedUint8ArrayOfLength(ed25519KeyPairBytes.privKeyBytes, 64), + metaDumped: data, + }); const infos = await MetaGroupWrapperActions.infoGet(groupPk); const members = await MetaGroupWrapperActions.memberGetAll(groupPk); toReturn.push({ groupPk, infos, members }); - - // Annoyingly, the redux store is not initialized when this current funciton is called, - // so we need to init the group wrappers here, but load them in their redux slice later } catch (e) { - // TODO should not throw in this case? we should probably just try to load what we manage to load - window.log.warn( + // Note: Don't retrow here, we want to load everything we can + window.log.error( `initGroup of Group wrapper of variant ${variant} failed with ${e.message} ` ); - // throw new Error(`initializeLibSessionUtilWrappers failed with ${e.message}`); } } @@ -171,45 +257,141 @@ const loadDumpsFromDB = createAsyncThunk( } ); +/** + * This action is to be called when we get a merge event from the network. + * It refreshes the state of that particular group (info & members) with the state from the wrapper after the merge is done. + * + */ +const refreshGroupDetailsFromWrapper = createAsyncThunk( + 'group/refreshGroupDetailsFromWrapper', + async ({ + groupPk, + }: { + groupPk: GroupPubkeyType; + }): Promise< + GroupDetailsUpdate | ({ groupPk: GroupPubkeyType } & Partial) + > => { + try { + const infos = await MetaGroupWrapperActions.infoGet(groupPk); + const members = await MetaGroupWrapperActions.memberGetAll(groupPk); + + return { groupPk, infos, members }; + } catch (e) { + window.log.warn('refreshGroupDetailsFromWrapper failed with ', e.message); + return { groupPk }; + } + } +); + +const destroyGroupDetails = createAsyncThunk( + 'group/destroyGroupDetails', + async ({ groupPk }: { groupPk: GroupPubkeyType }) => { + try { + await UserGroupsWrapperActions.eraseGroup(groupPk); + await ConfigDumpData.deleteDumpFor(groupPk); + await MetaGroupWrapperActions.infoDestroy(groupPk); + getSwarmPollingInstance().removePubkey(groupPk, 'destroyGroupDetails'); + } catch (e) { + window.log.warn(`destroyGroupDetails for ${groupPk} failed with ${e.message}`); + } + return { groupPk }; + } +); + /** * This slice is the one holding the default joinable rooms fetched once in a while from the default opengroup v2 server. */ const groupSlice = createSlice({ name: 'group', initialState: initialGroupState, - reducers: { - updateGroupDetailsAfterMerge(state, action: PayloadAction) { - const { groupPk, infos, members } = action.payload; - state.infos[groupPk] = infos; - state.members[groupPk] = members; - }, - }, + reducers: {}, extraReducers: builder => { builder.addCase(initNewGroupInWrapper.fulfilled, (state, action) => { const { groupPk, infos, members } = action.payload; state.infos[groupPk] = infos; state.members[groupPk] = members; + state.creationFromUIPending = false; }); - builder.addCase(initNewGroupInWrapper.rejected, () => { + builder.addCase(initNewGroupInWrapper.rejected, state => { window.log.error('a initNewGroupInWrapper was rejected'); - // FIXME delete the wrapper completely & correspondign dumps, and usergroups entry? + state.creationFromUIPending = false; + throw new Error('initNewGroupInWrapper.rejected'); + + // FIXME delete the wrapper completely & corresponding dumps, and usergroups entry? + }); + builder.addCase(initNewGroupInWrapper.pending, (state, _action) => { + state.creationFromUIPending = true; + + window.log.error('a initNewGroupInWrapper is pending'); }); - builder.addCase(loadDumpsFromDB.fulfilled, (state, action) => { + builder.addCase(loadMetaDumpsFromDB.fulfilled, (state, action) => { const loaded = action.payload; loaded.forEach(element => { state.infos[element.groupPk] = element.infos; state.members[element.groupPk] = element.members; }); }); - builder.addCase(loadDumpsFromDB.rejected, () => { - window.log.error('a loadDumpsFromDB was rejected'); + builder.addCase(loadMetaDumpsFromDB.rejected, () => { + window.log.error('a loadMetaDumpsFromDB was rejected'); + }); + builder.addCase(refreshGroupDetailsFromWrapper.fulfilled, (state, action) => { + const { infos, members, groupPk } = action.payload; + if (infos && members) { + state.infos[groupPk] = infos; + state.members[groupPk] = members; + + window.log.debug(`groupInfo after merge: ${stringify(infos)}`); + window.log.debug(`groupMembers after merge: ${stringify(members)}`); + } else { + window.log.debug( + `refreshGroupDetailsFromWrapper no details found, removing from slice: ${groupPk}}` + ); + + delete state.infos[groupPk]; + delete state.members[groupPk]; + } + }); + builder.addCase(refreshGroupDetailsFromWrapper.rejected, () => { + window.log.error('a refreshGroupDetailsFromWrapper was rejected'); + }); + builder.addCase(destroyGroupDetails.fulfilled, (state, action) => { + const { groupPk } = action.payload; + // FIXME destroyGroupDetails marks the info as destroyed, but does not really remove the wrapper currently + delete state.infos[groupPk]; + delete state.members[groupPk]; + }); + builder.addCase(destroyGroupDetails.rejected, () => { + window.log.error('a destroyGroupDetails was rejected'); + }); + builder.addCase(handleUserGroupUpdate.fulfilled, (state, action) => { + const { infos, members, groupPk } = action.payload; + if (infos && members) { + state.infos[groupPk] = infos; + state.members[groupPk] = members; + + window.log.debug(`groupInfo after handleUserGroupUpdate: ${stringify(infos)}`); + window.log.debug(`groupMembers after handleUserGroupUpdate: ${stringify(members)}`); + } else { + window.log.debug( + `handleUserGroupUpdate no details found, removing from slice: ${groupPk}}` + ); + + delete state.infos[groupPk]; + delete state.members[groupPk]; + } + }); + builder.addCase(handleUserGroupUpdate.rejected, () => { + window.log.error('a handleUserGroupUpdate was rejected'); }); }, }); export const groupInfoActions = { initNewGroupInWrapper, - loadDumpsFromDB, + loadMetaDumpsFromDB, + destroyGroupDetails, + refreshGroupDetailsFromWrapper, + handleUserGroupUpdate, ...groupSlice.actions, }; export const groupReducer = groupSlice.reducer; diff --git a/ts/state/selectors/groups.ts b/ts/state/selectors/groups.ts index 154f2b008..11e6d13f8 100644 --- a/ts/state/selectors/groups.ts +++ b/ts/state/selectors/groups.ts @@ -23,6 +23,10 @@ export function getLibMembersPubkeys(state: StateType, convo?: string): Array m.pubkeyHex); } +function getIsCreatingGroupFromUI(state: StateType): boolean { + return getLibGroupsState(state).creationFromUIPending; +} + export function getLibAdminsPubkeys(state: StateType, convo?: string): Array { if (!convo) { return []; @@ -81,3 +85,7 @@ export function getLibGroupAdminsOutsideRedux(convoId: string): Array { const state = window.inboxStore?.getState(); return state ? getLibAdminsPubkeys(state, convoId) : []; } + +export function useIsCreatingGroupFromUIPending() { + return useSelector(getIsCreatingGroupFromUI); +} diff --git a/ts/types/sqlSharedTypes.ts b/ts/types/sqlSharedTypes.ts index 1f4358328..3b87dfa35 100644 --- a/ts/types/sqlSharedTypes.ts +++ b/ts/types/sqlSharedTypes.ts @@ -279,8 +279,8 @@ export function toFixedUint8ArrayOfLength( length: T ): FixedSizeUint8Array { if (data.length === length) { -return data as any as FixedSizeUint8Array; -} + return data as any as FixedSizeUint8Array; + } throw new Error( `toFixedUint8ArrayOfLength invalid. Expected length ${length} but got: ${data.length}` ); diff --git a/ts/util/releaseFeature.ts b/ts/util/releaseFeature.ts index 332866249..76f2e972d 100644 --- a/ts/util/releaseFeature.ts +++ b/ts/util/releaseFeature.ts @@ -101,17 +101,11 @@ async function checkIsUserConfigFeatureReleased() { return checkIsFeatureReleased('user_config_libsession'); } -// TODO AUDRIC maybe we want this, maybe we don't? -async function checkIsNewGroupsReleased() { - return true; -} - function isUserConfigFeatureReleasedCached(): boolean { return !!isUserConfigLibsessionFeatureReleased; } export const ReleasedFeatures = { checkIsUserConfigFeatureReleased, - checkIsNewGroupsReleased, isUserConfigFeatureReleasedCached, }; diff --git a/ts/webworker/workers/node/libsession/libsession.worker.ts b/ts/webworker/workers/node/libsession/libsession.worker.ts index 206ea5c06..59f2cdda9 100644 --- a/ts/webworker/workers/node/libsession/libsession.worker.ts +++ b/ts/webworker/workers/node/libsession/libsession.worker.ts @@ -178,7 +178,8 @@ function initGroupWrapper(options: Array, wrapperType: ConfigWrapperGroup) const wrapper = getGroupWrapper(wrapperType); if (wrapper) { - throw new Error(`group: "${wrapperType}" already init`); + // console.warn(`group: "${wrapperType}" already init`); + return; } if (options.length !== 1) { diff --git a/ts/window.d.ts b/ts/window.d.ts index 2e5da8494..6ccc54c19 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -33,7 +33,7 @@ declare global { sessionFeatureFlags: { useOnionRequests: boolean; useTestNet: boolean; - useClosedGroupV3: boolean; + useClosedGroupV2: boolean; integrationTestEnv: boolean; debug: { debugLogging: boolean;