From 77a62e82e7c56c340b71267b8c88e8b3a4a059f2 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 19 Jan 2024 11:27:10 +1100 Subject: [PATCH] fix: add avatar change message handling still needs to be able to send one, but that's chunk3 --- .../message-item/GroupUpdateMessage.tsx | 12 ++- ts/models/conversation.ts | 2 +- ts/models/message.ts | 7 ++ ts/models/messageType.ts | 1 + ts/receiver/configMessage.ts | 10 +- ts/receiver/groupv2/handleGroupV2Message.ts | 29 +++++- ts/session/apis/snode_api/swarmPolling.ts | 15 ++- .../conversations/ConversationController.ts | 96 +++++++++++++------ ts/session/conversations/createClosedGroup.ts | 2 +- ts/session/group/closed-group.ts | 10 +- .../DataExtractionNotificationMessage.ts | 2 +- .../to_group/GroupUpdateInfoChangeMessage.ts | 3 +- .../to_user/GroupUpdateDeleteMessage.ts | 2 + ts/session/sending/MessageQueue.ts | 72 ++++++++++++-- ts/session/utils/calling/CallManager.ts | 12 +-- .../utils/job_runners/jobs/GroupInviteJob.ts | 2 +- .../utils/job_runners/jobs/GroupPromoteJob.ts | 2 +- .../libsession_utils_convo_info_volatile.ts | 14 +-- ts/state/ducks/conversations.ts | 5 + ts/state/ducks/metaGroups.ts | 89 ++++++++++++++--- 20 files changed, 302 insertions(+), 85 deletions(-) diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index 2e8a93131..359d196d4 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -143,6 +143,14 @@ const ChangeItemPromoted = (promoted: Array): string => { throw new PreConditionFailed('ChangeItemPromoted only applies to groupv2'); }; +const ChangeItemAvatar = (): string => { + const isGroupV2 = useSelectedIsGroupV2(); + if (isGroupV2) { + return window.i18n('groupAvatarChange'); + } + throw new PreConditionFailed('ChangeItemAvatar only applies to groupv2'); +}; + const ChangeItemLeft = (left: Array): string => { if (!left.length) { throw new Error('Group update remove is missing contacts'); @@ -175,14 +183,14 @@ const ChangeItem = (change: PropsForGroupUpdateType): string => { return ChangeItemName(change.newName); case 'add': return ChangeItemJoined(change.added); - case 'left': return ChangeItemLeft(change.left); - case 'kicked': return ChangeItemKicked(change.kicked); case 'promoted': return ChangeItemPromoted(change.promoted); + case 'avatarChange': + return ChangeItemAvatar(); default: assertUnreachable(type, `ChangeItem: Missing case error "${type}"`); return ''; diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 6ec7ee9cd..80ac25da8 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -2459,7 +2459,7 @@ export class ConversationModel extends Backbone.Model { const pubkey = new PubKey(recipientId); void getMessageQueue() - .sendToPubKeyNonDurably({ + .sendTo1o1NonDurably({ pubkey, message: typingMessage, namespace: SnodeNamespaces.Default, diff --git a/ts/models/message.ts b/ts/models/message.ts index f42602142..1f7317c27 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -77,6 +77,7 @@ import { PropsForGroupInvitation, PropsForGroupUpdate, PropsForGroupUpdateAdd, + PropsForGroupUpdateAvatarChange, PropsForGroupUpdateKicked, PropsForGroupUpdateLeft, PropsForGroupUpdateName, @@ -488,6 +489,12 @@ export class MessageModel extends Backbone.Model { }; return { change, ...sharedProps }; } + if (groupUpdate.avatarChange) { + const change: PropsForGroupUpdateAvatarChange = { + type: 'avatarChange', + }; + return { change, ...sharedProps }; + } return null; } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index e4a94eb88..f851afc0e 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -159,6 +159,7 @@ export type MessageGroupUpdate = { kicked?: Array; promoted?: Array; name?: string; + avatarChange?: boolean; }; export interface MessageAttributesOptionals { diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 7e8e1bbe7..553817d58 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop */ -import { ContactInfo, UserGroupsGet } from 'libsession_util_nodejs'; +import { ContactInfo, GroupPubkeyType, UserGroupsGet } from 'libsession_util_nodejs'; import { base64_variants, from_base64 } from 'libsodium-wrappers-sumo'; import { compact, difference, isEmpty, isNil, isNumber, toNumber } from 'lodash'; import { ConfigDumpData } from '../data/configDump/configDump'; @@ -725,7 +725,7 @@ async function handleSingleGroupUpdate({ } } -async function handleSingleGroupUpdateToLeave(toLeave: string) { +async function handleSingleGroupUpdateToLeave(toLeave: GroupPubkeyType) { // that group is not in the wrapper but in our local DB. it must be removed and cleaned try { window.log.debug( @@ -747,12 +747,12 @@ async function handleSingleGroupUpdateToLeave(toLeave: string) { async function handleGroupUpdate(latestEnvelopeTimestamp: number) { // first let's check which groups needs to be joined or left by doing a diff of what is in the wrapper and what is in the DB const allGroupsInWrapper = await UserGroupsWrapperActions.getAllGroups(); - const allGroupsInDb = ConvoHub.use() + const allGoupsIdsInDb = ConvoHub.use() .getConversations() - .filter(m => PubKey.is03Pubkey(m.id)); + .map(m => m.id) + .filter(PubKey.is03Pubkey); const allGoupsIdsInWrapper = allGroupsInWrapper.map(m => m.pubkeyHex); - const allGoupsIdsInDb = allGroupsInDb.map(m => m.id as string); window.log.debug('allGoupsIdsInWrapper', stringify(allGoupsIdsInWrapper)); window.log.debug('allGoupsIdsInDb', stringify(allGoupsIdsInDb)); diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index af3d7a8f3..94c7b5118 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -202,7 +202,6 @@ async function handleGroupInfoChangeMessage({ await ClosedGroup.addUpdateMessage({ convo, diff: { newName: change.updatedName }, - sender: author, sentAt: envelopeTimestamp, expireUpdate: null, @@ -211,8 +210,14 @@ async function handleGroupInfoChangeMessage({ break; } case SignalService.GroupUpdateInfoChangeMessage.Type.AVATAR: { - console.warn('Not implemented'); - throw new Error('Not implemented'); + await ClosedGroup.addUpdateMessage({ + convo, + diff: { avatarChange: true }, + sender: author, + sentAt: envelopeTimestamp, + expireUpdate: null, + }); + break; } case SignalService.GroupUpdateInfoChangeMessage.Type.DISAPPEARING_MESSAGES: { if ( @@ -221,6 +226,13 @@ async function handleGroupInfoChangeMessage({ isFinite(change.updatedExpiration) && change.updatedExpiration >= 0 ) { + await ClosedGroup.addUpdateMessage({ + convo, + diff: { newName: change.updatedName }, + sender: author, + sentAt: envelopeTimestamp, + expireUpdate: null, + }); await convo.updateExpireTimer({ providedExpireTimer: change.updatedExpiration, providedSource: author, @@ -307,6 +319,14 @@ async function handleGroupMemberLeftMessage({ return; } + // this does nothing if we are not an admin + window.inboxStore.dispatch( + groupInfoActions.handleMemberLeftMessage({ + groupPk, + memberLeft: author, + }) + ); + await ClosedGroup.addUpdateMessage({ convo, diff: { leavingMembers: [author] }, @@ -314,10 +334,11 @@ async function handleGroupMemberLeftMessage({ sentAt: envelopeTimestamp, expireUpdate: null, }); + convo.set({ active_at: envelopeTimestamp, }); - // TODO We should process this message type even if the sender is blocked + // debugger TODO We should process this message type even if the sender is blocked } async function handleGroupDeleteMemberContentMessage({ diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index e058e5f91..d17c5078f 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -440,6 +440,10 @@ export class SwarmPolling { type: ConversationTypeEnum; pubkey: string; }) { + const correctlyTypedPk = PubKey.is03Pubkey(pubkey) || PubKey.is05Pubkey(pubkey) ? pubkey : null; + if (!correctlyTypedPk) { + return false; + } const allLegacyGroupsInWrapper = await UserGroupsWrapperActions.getAllLegacyGroups(); const allGroupsInWrapper = await UserGroupsWrapperActions.getAllGroups(); @@ -447,15 +451,16 @@ export class SwarmPolling { // this can happen when a group is removed from the wrapper while we were polling const newGroupButNotInWrapper = - PubKey.is03Pubkey(pubkey) && !allGroupsInWrapper.some(m => m.pubkeyHex === pubkey); + PubKey.is03Pubkey(correctlyTypedPk) && + !allGroupsInWrapper.some(m => m.pubkeyHex === correctlyTypedPk); const legacyGroupButNoInWrapper = type === ConversationTypeEnum.GROUP && - pubkey.startsWith('05') && + PubKey.is05Pubkey(correctlyTypedPk) && !allLegacyGroupsInWrapper.some(m => m.pubkeyHex === pubkey); if (newGroupButNotInWrapper || legacyGroupButNoInWrapper) { // not tracked anymore in the wrapper. Discard messages and stop polling - await this.notPollingForGroupAsNotInWrapper(pubkey, 'not in wrapper after poll'); + await this.notPollingForGroupAsNotInWrapper(correctlyTypedPk, 'not in wrapper after poll'); return true; } return false; @@ -574,6 +579,9 @@ export class SwarmPolling { } private async notPollingForGroupAsNotInWrapper(pubkey: string, reason: string) { + if (!PubKey.is03Pubkey(pubkey) && !PubKey.is05Pubkey(pubkey)) { + return; + } window.log.debug( `notPollingForGroupAsNotInWrapper ${ed25519Str(pubkey)} with reason:"${reason}"` ); @@ -581,7 +589,6 @@ export class SwarmPolling { fromSyncMessage: true, sendLeaveMessage: false, }); - return Promise.resolve(); } private loadGroupIds() { diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index d961fa3df..fb3e361e8 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -1,6 +1,6 @@ /* eslint-disable no-await-in-loop */ /* eslint-disable more/no-then */ -import { ConvoVolatileType, GroupPubkeyType } from 'libsession_util_nodejs'; +import { ConvoVolatileType, GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; import { isEmpty, isNil } from 'lodash'; import { Data } from '../../data/data'; @@ -27,6 +27,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 { GroupUpdateMemberLeftMessage } from '../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateMemberLeftMessage'; import { ed25519Str } from '../onions/onionPath'; import { UserUtils } from '../utils'; import { UserSync } from '../utils/job_runners/jobs/UserSyncJob'; @@ -204,16 +205,21 @@ class ConvoController { public async deleteClosedGroup( groupId: string, - options: { fromSyncMessage: boolean; sendLeaveMessage: boolean } + { fromSyncMessage, sendLeaveMessage }: { fromSyncMessage: boolean; sendLeaveMessage: boolean } ) { - const conversation = await this.deleteConvoInitialChecks(groupId, 'LegacyGroup'); + if (!PubKey.is03Pubkey(groupId) && !PubKey.is05Pubkey(groupId)) { + return; + } + const typeOfDelete: ConvoVolatileType = PubKey.is03Pubkey(groupId) ? 'Group' : 'LegacyGroup'; + const conversation = await this.deleteConvoInitialChecks(groupId, typeOfDelete); if (!conversation || !conversation.isClosedGroup()) { return; } - window.log.info(`deleteClosedGroup: ${groupId}, sendLeaveMessage?:${options.sendLeaveMessage}`); + window.log.info(`deleteClosedGroup: ${groupId}, sendLeaveMessage?:${sendLeaveMessage}`); getSwarmPollingInstance().removePubkey(groupId, 'deleteClosedGroup'); // we don't need to keep polling anymore. - if (options.sendLeaveMessage) { - await leaveClosedGroup(groupId, options.fromSyncMessage); + // send the leave message before we delete everything for this group (including the key!) + if (sendLeaveMessage) { + await leaveClosedGroup(groupId, fromSyncMessage); } // if we were kicked or sent our left message, we have nothing to do more with that group. @@ -225,7 +231,7 @@ class ConvoController { await removeLegacyGroupFromWrappers(groupId); } - if (!options.fromSyncMessage) { + if (!fromSyncMessage) { await UserSync.queueNewJobIfNeeded(); } } @@ -390,23 +396,23 @@ class ConvoController { throw new Error(`ConvoHub.${deleteType} needs complete initial fetch`); } - window.log.info(`${deleteType} with ${convoId}`); + window.log.info(`${deleteType} with ${ed25519Str(convoId)}`); const conversation = this.conversations.get(convoId); if (!conversation) { - window.log.warn(`${deleteType} no such convo ${convoId}`); + window.log.warn(`${deleteType} no such convo ${ed25519Str(convoId)}`); return null; } // those are the stuff to do for all conversation types - window.log.info(`${deleteType} destroyingMessages: ${convoId}`); + window.log.info(`${deleteType} destroyingMessages: ${ed25519Str(convoId)}`); await deleteAllMessagesByConvoIdNoConfirmation(convoId); - window.log.info(`${deleteType} messages destroyed: ${convoId}`); + window.log.info(`${deleteType} messages destroyed: ${ed25519Str(convoId)}`); return conversation; } private async removeGroupOrCommunityFromDBAndRedux(convoId: string) { - window.log.info(`cleanUpGroupConversation, removing convo from DB: ${convoId}`); + window.log.info(`cleanUpGroupConversation, removing convo from DB: ${ed25519Str(convoId)}`); // not a private conversation, so not a contact for the ContactWrapper await Data.removeConversation(convoId); @@ -420,7 +426,7 @@ class ConvoController { } } - window.log.info(`cleanUpGroupConversation, convo removed from DB: ${convoId}`); + window.log.info(`cleanUpGroupConversation, convo removed from DB: ${ed25519Str(convoId)}`); const conversation = this.conversations.get(convoId); if (conversation) { @@ -432,7 +438,7 @@ class ConvoController { } window.inboxStore?.dispatch(conversationActions.conversationRemoved(convoId)); - window.log.info(`cleanUpGroupConversation, convo removed from store: ${convoId}`); + window.log.info(`cleanUpGroupConversation, convo removed from store: ${ed25519Str(convoId)}`); } } @@ -442,8 +448,8 @@ class ConvoController { * Note: `fromSyncMessage` is used to know if we need to send a leave group message to the group first. * So if the user made the action on this device, fromSyncMessage should be false, but if it happened from a linked device polled update, set this to true. */ -async function leaveClosedGroup(groupId: string, fromSyncMessage: boolean) { - const convo = ConvoHub.use().get(groupId); +async function leaveClosedGroup(groupPk: PubkeyType | GroupPubkeyType, fromSyncMessage: boolean) { + const convo = ConvoHub.use().get(groupPk); if (!convo || !convo.isClosedGroup()) { window?.log?.error('Cannot leave non-existing group'); @@ -472,14 +478,49 @@ async function leaveClosedGroup(groupId: string, fromSyncMessage: boolean) { await convo.updateGroupAdmins(admins, false); await convo.commit(); - getSwarmPollingInstance().removePubkey(groupId, 'leaveClosedGroup'); + getSwarmPollingInstance().removePubkey(groupPk, 'leaveClosedGroup'); if (fromSyncMessage) { // no need to send our leave message as our other device should already have sent it. return; } - const keypair = await Data.getLatestClosedGroupEncryptionKeyPair(groupId); + if (PubKey.is03Pubkey(groupPk)) { + // Send the update to the 03 group + const ourLeavingMessage = new GroupUpdateMemberLeftMessage({ + createAtNetworkTimestamp: GetNetworkTime.now(), + groupPk, + expirationType: null, // we keep that one **not** expiring + expireTimer: null, + }); + + window?.log?.info( + `We are leaving the group ${ed25519Str(groupPk)}. Sending our leaving message.` + ); + + // We might not be able to send our leaving messages (no encryption keypair, we were already removed, no network, etc). + // If that happens, we should just remove everything from our current user. + try { + const wasSent = await getMessageQueue().sendToGroupV2NonDurably({ + message: ourLeavingMessage, + }); + if (!wasSent) { + throw new Error( + `Even with the retries, leaving message for group ${ed25519Str( + groupPk + )} failed to be sent... Still deleting everything` + ); + } + } catch (e) { + window.log.warn('leaving groupv2 error:', e.message); + } + // the rest of the cleaning of that conversation is done in the `deleteClosedGroup()` + + return; + } + + // TODO remove legacy group support + const keypair = await Data.getLatestClosedGroupEncryptionKeyPair(groupPk); if (!keypair || isEmpty(keypair) || isEmpty(keypair.publicHex) || isEmpty(keypair.privateHex)) { // if we do not have a keypair, we won't be able to send our leaving message neither, so just skip sending it. // this can happen when getting a group from a broken libsession usergroup wrapper, but not only. @@ -489,31 +530,32 @@ async function leaveClosedGroup(groupId: string, fromSyncMessage: boolean) { // Send the update to the group const ourLeavingMessage = new ClosedGroupMemberLeftMessage({ createAtNetworkTimestamp: GetNetworkTime.now(), - groupId, + groupId: groupPk, expirationType: null, // we keep that one **not** expiring expireTimer: null, }); - window?.log?.info(`We are leaving the group ${groupId}. Sending our leaving message.`); + window?.log?.info(`We are leaving the legacygroup ${groupPk}. Sending our leaving message.`); // if we do not have a keypair for that group, we can't send our leave message, so just skip the message sending part - const wasSent = await getMessageQueue().sendToPubKeyNonDurably({ + const wasSent = await getMessageQueue().sendToLegacyGroupNonDurably({ message: ourLeavingMessage, namespace: SnodeNamespaces.LegacyClosedGroup, - pubkey: PubKey.cast(groupId), + destination: groupPk, }); - // TODO our leaving message might fail to be sent for some specific reason we want to still delete the group. - // for instance, if we do not have the encryption keypair anymore, we cannot send our left message, but we should still delete it's content + // The leaving message might fail to be sent for some specific reason we want to still delete the group. + // For instance, if we do not have the encryption keypair anymore, we cannot send our left message, but we should still delete its content if (wasSent) { window?.log?.info( - `Leaving message sent ${groupId}. Removing everything related to this group.` + `Leaving message sent ${ed25519Str(groupPk)}. Removing everything related to this group.` ); } else { window?.log?.info( - `Leaving message failed to be sent for ${groupId}. But still removing everything related to this group....` + `Leaving message failed to be sent for ${ed25519Str( + groupPk + )}. But still removing everything related to this group....` ); } - // the rest of the cleaning of that conversation is done in the `deleteClosedGroup()` } async function removeLegacyGroupFromWrappers(groupId: string) { diff --git a/ts/session/conversations/createClosedGroup.ts b/ts/session/conversations/createClosedGroup.ts index 52933b6e2..4a0c0aa4f 100644 --- a/ts/session/conversations/createClosedGroup.ts +++ b/ts/session/conversations/createClosedGroup.ts @@ -197,7 +197,7 @@ function createInvitePromises( expireTimer: 0, }; const message = new ClosedGroupNewMessage(messageParams); - return getMessageQueue().sendToPubKeyNonDurably({ + return getMessageQueue().sendTo1o1NonDurably({ pubkey: PubKey.cast(m), message, namespace: SnodeNamespaces.Default, diff --git a/ts/session/group/closed-group.ts b/ts/session/group/closed-group.ts index 5066397f3..492430851 100644 --- a/ts/session/group/closed-group.ts +++ b/ts/session/group/closed-group.ts @@ -42,16 +42,14 @@ export type GroupInfo = { admins?: Array; }; -export interface MemberChanges { +export type GroupDiff = { joiningMembers?: Array; leavingMembers?: Array; kickedMembers?: Array; promotedMembers?: Array; -} - -export interface GroupDiff extends MemberChanges { newName?: string; -} + avatarChange?: boolean; +}; /** * This function is only called when the local user makes a change to a group. @@ -181,6 +179,8 @@ export async function addUpdateMessage({ groupUpdate.kicked = diff.kickedMembers; } else if (diff.promotedMembers) { groupUpdate.promoted = diff.promotedMembers as Array; + } else if (diff.avatarChange) { + groupUpdate.avatarChange = true; } else { throw new Error('addUpdateMessage with unknown type of change'); } diff --git a/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts b/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts index 6cf91fcdc..ee8bd51c6 100644 --- a/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/DataExtractionNotificationMessage.ts @@ -76,7 +76,7 @@ export const sendDataExtractionNotification = async ( ); try { - await getMessageQueue().sendToPubKeyNonDurably({ + await getMessageQueue().sendTo1o1NonDurably({ pubkey, message: dataExtractionNotificationMessage, namespace: SnodeNamespaces.Default, diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage.ts index 564c75f9f..84457c734 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage.ts @@ -3,7 +3,6 @@ import { SignalService } from '../../../../../../protobuf'; import { SnodeNamespaces } from '../../../../../apis/snode_api/namespaces'; import { LibSodiumWrappers } from '../../../../../crypto'; import { stringToUint8Array } from '../../../../../utils/String'; -import { PreConditionFailed } from '../../../../../utils/errors'; import { AdminSigDetails, GroupUpdateMessage, @@ -56,7 +55,6 @@ export class GroupUpdateInfoChangeMessage extends GroupUpdateMessage { } case types.AVATAR: // nothing to do for avatar - throw new PreConditionFailed('not implemented'); break; case types.DISAPPEARING_MESSAGES: { if (!isFinite(params.updatedExpirationSeconds) || params.updatedExpirationSeconds < 0) { @@ -89,6 +87,7 @@ export class GroupUpdateInfoChangeMessage extends GroupUpdateMessage { break; case SignalService.GroupUpdateInfoChangeMessage.Type.AVATAR: + default: break; } diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts index d731e85dd..9652a8d05 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage.ts @@ -1,5 +1,6 @@ import { PubkeyType } from 'libsession_util_nodejs'; import { SignalService } from '../../../../../../protobuf'; +import { SnodeNamespaces } from '../../../../../apis/snode_api/namespaces'; import { Preconditions } from '../../../preconditions'; import { GroupUpdateMessage, GroupUpdateMessageParams } from '../GroupUpdateMessage'; @@ -12,6 +13,7 @@ interface Params extends GroupUpdateMessageParams { * GroupUpdateDeleteMessage is sent to the group's swarm on the `revokedRetrievableGroupMessages` namespace */ export class GroupUpdateDeleteMessage extends GroupUpdateMessage { + public readonly namespace = SnodeNamespaces.ClosedGroupRevokedRetrievableMessages; public readonly adminSignature: Params['adminSignature']; public readonly memberSessionIds: Params['memberSessionIds']; diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index a0f9b2ba9..b33fb65cd 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -12,7 +12,6 @@ import { ExpirationTimerUpdateMessage } from '../messages/outgoing/controlMessag import { ClosedGroupAddedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupAddedMembersMessage'; import { ClosedGroupEncryptionPairMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupEncryptionPairMessage'; import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage'; -import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage'; import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupRemovedMembersMessage'; import { ClosedGroupV2VisibleMessage, @@ -21,6 +20,7 @@ import { import { SyncMessageType } from '../utils/sync/syncUtils'; import { MessageSentHandler } from './MessageSentHandler'; +import { PubkeyType } from 'libsession_util_nodejs'; import { OpenGroupRequestCommonType } from '../apis/open_group_api/opengroupV2/ApiUtil'; import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2'; import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction'; @@ -33,6 +33,7 @@ import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage'; import { DataExtractionNotificationMessage } from '../messages/outgoing/controlMessage/DataExtractionNotificationMessage'; import { TypingMessage } from '../messages/outgoing/controlMessage/TypingMessage'; import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage'; +import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage'; import { GroupUpdateDeleteMemberContentMessage } from '../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; import { GroupUpdateInfoChangeMessage } from '../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage'; import { GroupUpdateMemberChangeMessage } from '../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateMemberChangeMessage'; @@ -231,6 +232,48 @@ export class MessageQueue { ); } + public async sendToGroupV2NonDurably({ + message, + }: { + message: + | ClosedGroupV2VisibleMessage + | GroupUpdateMemberChangeMessage + | GroupUpdateInfoChangeMessage + | GroupUpdateDeleteMemberContentMessage + | GroupUpdateMemberLeftMessage + | GroupUpdateDeleteMessage; + }) { + if (!message.destination || !PubKey.is03Pubkey(message.destination)) { + throw new Error('Invalid group message passed in sendToGroupV2NonDurably.'); + } + + return this.sendToPubKeyNonDurably({ + message, + namespace: message.namespace, + pubkey: PubKey.cast(message.destination), + }); + } + + public async sendToLegacyGroupNonDurably({ + message, + namespace, + destination, + }: { + message: ClosedGroupMemberLeftMessage; + namespace: SnodeNamespaces.LegacyClosedGroup; + destination: PubkeyType; + }) { + if (!destination || !PubKey.is05Pubkey(destination)) { + throw new Error('Invalid legacygroup message passed in sendToLegacyGroupNonDurably.'); + } + + return this.sendToPubKeyNonDurably({ + message, + namespace, + pubkey: PubKey.cast(destination), + }); + } + public async sendSyncMessage({ namespace, message, @@ -252,25 +295,40 @@ export class MessageQueue { } /** - * Sends a message that awaits until the message is completed sending + * Send a message to a 1o1 swarm * @param user user pub key to send to * @param message Message to be sent */ - public async sendToPubKeyNonDurably({ + public async sendTo1o1NonDurably({ namespace, message, pubkey, }: { pubkey: PubKey; message: - | ClosedGroupNewMessage | TypingMessage // no point of caching the typing message, they are very short lived | DataExtractionNotificationMessage | CallMessage - | ClosedGroupMemberLeftMessage + | ClosedGroupNewMessage | GroupUpdateInviteMessage - | GroupUpdatePromoteMessage - | GroupUpdateDeleteMessage; + | GroupUpdatePromoteMessage; + namespace: SnodeNamespaces.Default; + }): Promise { + return this.sendToPubKeyNonDurably({ message, namespace, pubkey }); + } + + /** + * Sends a message that awaits until the message is completed sending + * @param user user pub key to send to + * @param message Message to be sent + */ + private async sendToPubKeyNonDurably({ + namespace, + message, + pubkey, + }: { + pubkey: PubKey; + message: ContentMessage; namespace: SnodeNamespaces; }): Promise { let rawMessage; diff --git a/ts/session/utils/calling/CallManager.ts b/ts/session/utils/calling/CallManager.ts index a4cecb067..ffef1105c 100644 --- a/ts/session/utils/calling/CallManager.ts +++ b/ts/session/utils/calling/CallManager.ts @@ -439,7 +439,7 @@ async function createOfferAndSendIt(recipient: string, msgIdentifier: string | n }); window.log.info(`sending '${offer.type}'' with callUUID: ${currentCallUUID}`); - const negotiationOfferSendResult = await getMessageQueue().sendToPubKeyNonDurably({ + const negotiationOfferSendResult = await getMessageQueue().sendTo1o1NonDurably({ pubkey: PubKey.cast(recipient), message: offerMessage, namespace: SnodeNamespaces.Default, @@ -535,7 +535,7 @@ export async function USER_callRecipient(recipient: string) { // initiating a call is analogous to sending a message request await approveConvoAndSendResponse(recipient); - // Note: we do the sending of the preoffer manually as the sendToPubkeyNonDurably rely on having a message saved to the db for MessageSentSuccess + // Note: we do the sending of the preoffer manually as the sendTo1o1NonDurably rely on having a message saved to the db for MessageSentSuccess // which is not the case for a pre offer message (the message only exists in memory) const rawMessage = await MessageUtils.toRawMessage( PubKey.cast(recipient), @@ -623,7 +623,7 @@ const iceSenderDebouncer = _.debounce(async (recipient: string) => { `sending ICE CANDIDATES MESSAGE to ${ed25519Str(recipient)} about call ${currentCallUUID}` ); - await getMessageQueue().sendToPubKeyNonDurably({ + await getMessageQueue().sendTo1o1NonDurably({ pubkey: PubKey.cast(recipient), message: callIceCandicates, namespace: SnodeNamespaces.Default, @@ -1004,12 +1004,12 @@ export async function USER_rejectIncomingCallRequest(fromSender: string) { async function sendCallMessageAndSync(callmessage: CallMessage, user: string) { await Promise.all([ - getMessageQueue().sendToPubKeyNonDurably({ + getMessageQueue().sendTo1o1NonDurably({ pubkey: PubKey.cast(user), message: callmessage, namespace: SnodeNamespaces.Default, }), - getMessageQueue().sendToPubKeyNonDurably({ + getMessageQueue().sendTo1o1NonDurably({ pubkey: UserUtils.getOurPubKeyFromCache(), message: callmessage, namespace: SnodeNamespaces.Default, @@ -1039,7 +1039,7 @@ export async function USER_hangup(fromSender: string) { expirationType, expireTimer, }); - void getMessageQueue().sendToPubKeyNonDurably({ + void getMessageQueue().sendTo1o1NonDurably({ pubkey: PubKey.cast(fromSender), message: endCallMessage, namespace: SnodeNamespaces.Default, diff --git a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts index 4fb1636a5..2055e4054 100644 --- a/ts/session/utils/job_runners/jobs/GroupInviteJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupInviteJob.ts @@ -142,7 +142,7 @@ class GroupInviteJob extends PersistedJob { groupPk, }); - const storedAt = await getMessageQueue().sendToPubKeyNonDurably({ + const storedAt = await getMessageQueue().sendTo1o1NonDurably({ message: inviteDetails, namespace: SnodeNamespaces.Default, pubkey: PubKey.cast(member), diff --git a/ts/session/utils/job_runners/jobs/GroupPromoteJob.ts b/ts/session/utils/job_runners/jobs/GroupPromoteJob.ts index a8e9f1710..587eab5bd 100644 --- a/ts/session/utils/job_runners/jobs/GroupPromoteJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPromoteJob.ts @@ -100,7 +100,7 @@ class GroupPromoteJob extends PersistedJob { groupPk, }); - const storedAt = await getMessageQueue().sendToPubKeyNonDurably({ + const storedAt = await getMessageQueue().sendTo1o1NonDurably({ message, namespace: SnodeNamespaces.Default, pubkey: PubKey.cast(member), diff --git a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts index aa96e4e87..02d7142af 100644 --- a/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts +++ b/ts/session/utils/libsession/libsession_utils_convo_info_volatile.ts @@ -278,11 +278,11 @@ async function removeLegacyGroupFromWrapper(convoId: string) { } async function removeGroupFromWrapper(groupPk: GroupPubkeyType) { - // try { - // await ConvoInfoVolatileWrapperActions.eraseGroup(groupPk); - // } catch (e) { - // window.log.warn('removeGroupFromWrapper failed with ', e.message); - // } + try { + await ConvoInfoVolatileWrapperActions.eraseGroup(groupPk); + } catch (e) { + window.log.warn('removeGroupFromWrapper failed with ', e.message); + } window.log.warn('removeGroupFromWrapper TODO'); mappedGroupWrapperValues.delete(groupPk); } @@ -324,10 +324,10 @@ export const SessionUtilConvoInfoVolatile = { removeContactFromWrapper, // legacy group - removeLegacyGroupFromWrapper, // a group can be removed but also just marked hidden, so only call this function when the group is completely removed // TODOLATER + removeLegacyGroupFromWrapper, // a group can be removed but also just marked hidden, so only call this function when the group is completely removed // group - removeGroupFromWrapper, // a group can be removed but also just marked hidden, so only call this function when the group is completely removed // TODOLATER + removeGroupFromWrapper, // a group can be removed but also just marked hidden, so only call this function when the group is completely removed // communities removeCommunityFromWrapper, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index b739aee92..0708d0ff7 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -117,6 +117,10 @@ export type PropsForGroupUpdatePromoted = { promoted: Array; }; +export type PropsForGroupUpdateAvatarChange = { + type: 'avatarChange'; +}; + export type PropsForGroupUpdateLeft = { type: 'left'; left: Array; @@ -131,6 +135,7 @@ export type PropsForGroupUpdateType = | PropsForGroupUpdateAdd | PropsForGroupUpdateKicked | PropsForGroupUpdatePromoted + | PropsForGroupUpdateAvatarChange | PropsForGroupUpdateName | PropsForGroupUpdateLeft; diff --git a/ts/state/ducks/metaGroups.ts b/ts/state/ducks/metaGroups.ts index 5eb0ad5b9..f9c1b6f0e 100644 --- a/ts/state/ducks/metaGroups.ts +++ b/ts/state/ducks/metaGroups.ts @@ -19,7 +19,6 @@ import { SignalService } from '../../protobuf'; import { getMessageQueue } from '../../session'; import { getSwarmPollingInstance } from '../../session/apis/snode_api'; import { GetNetworkTime } from '../../session/apis/snode_api/getNetworkTime'; -import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; import { RevokeChanges, SnodeAPIRevoke } from '../../session/apis/snode_api/revokeSubaccount'; import { SnodeGroupSignature } from '../../session/apis/snode_api/signature/groupSignature'; import { ConvoHub } from '../../session/conversations'; @@ -79,12 +78,18 @@ type GroupDetailsUpdate = { members: Array; }; -async function checkWeAreAdminOrThrow(groupPk: GroupPubkeyType, context: string) { +async function checkWeAreAdmin(groupPk: GroupPubkeyType) { const us = UserUtils.getOurPubKeyStrFromCache(); const usInGroup = await MetaGroupWrapperActions.memberGet(groupPk, us); const inUserGroup = await UserGroupsWrapperActions.getGroup(groupPk); - if (isEmpty(inUserGroup?.secretKey) || !usInGroup?.promoted) { + // if the secretKey is not empty AND we are a member of the group, we are a current admin + return Boolean(!isEmpty(inUserGroup?.secretKey) && usInGroup?.promoted); +} + +async function checkWeAreAdminOrThrow(groupPk: GroupPubkeyType, context: string) { + const areWeAdmin = await checkWeAreAdmin(groupPk); + if (!areWeAdmin) { throw new Error(`checkWeAreAdminOrThrow failed with ctx: ${context}`); } } @@ -532,10 +537,8 @@ async function handleRemoveMembersAndRekey({ memberSessionIds: sortedRemoved, }); - const result = await getMessageQueue().sendToPubKeyNonDurably({ + const result = await getMessageQueue().sendToGroupV2NonDurably({ message: removedMemberMessage, - pubkey: PubKey.cast(groupPk), - namespace: SnodeNamespaces.ClosedGroupRevokedRetrievableMessages, }); if (!result) { throw new Error( @@ -577,7 +580,6 @@ async function getPendingRevokeParams({ revokeChanges.push({ action: 'revoke_subaccount', tokenToRevokeHex: token }); } - return SnodeAPIRevoke.getRevokeSubaccountParams(groupPk, { revokeChanges, unrevokeChanges }); } @@ -621,8 +623,12 @@ async function getUpdateMessagesToPush({ const updateMessages: Array = []; + if (!fromCurrentDevice) { + return updateMessages; + } + const allAdded = [...withHistory, ...withoutHistory]; // those are already enforced to be unique (and without intersection) in `validateMemberChange()` - if (fromCurrentDevice && allAdded.length) { + if (allAdded.length) { updateMessages.push( new GroupUpdateMemberChangeMessage({ added: allAdded, @@ -635,7 +641,7 @@ async function getUpdateMessagesToPush({ }) ); } - if (fromCurrentDevice && removed.length) { + if (removed.length) { updateMessages.push( new GroupUpdateMemberChangeMessage({ removed, @@ -760,6 +766,11 @@ async function handleMemberAddedFromUIOrNot({ await convo.commit(); } +/** + * This function is called in two cases: + * - to udpate the state when kicking a member from the group from the UI + * - to update the state when handling a MEMBER_LEFT message + */ async function handleMemberRemovedFromUIOrNot({ groupPk, removeMembers, @@ -781,7 +792,7 @@ async function handleMemberRemovedFromUIOrNot({ groupPk, removed: removeMembers, }); - // first, get revoke requests that need to be pushed for removed members + // first, get revoke requests that need to be pushed for leaving member const revokeUnrevokeParams = await getPendingRevokeParams({ groupPk, withHistory: [], @@ -789,7 +800,7 @@ async function handleMemberRemovedFromUIOrNot({ removed, }); - // Send the groupUpdateDeleteMessage that can still be decrypted by those removed members to namespace ClosedGroupRevokedRetrievableMessages. + // Send the groupUpdateDeleteMessage that can still be decrypted by those removed members to namespace ClosedGroupRevokedRetrievableMessages. (not when handling a MEMBER_LEFT message) // Then, rekey the wrapper, but don't push the changes yet, we want to batch all of the requests to be made together in the `pushChangesToGroupSwarmIfNeeded` below. await handleRemoveMembersAndRekey({ groupPk, @@ -990,6 +1001,45 @@ const currentDeviceGroupMembersChange = createAsyncThunk( } ); +/** + * This action is used to trigger a change when the local user does a change to a group v2 members list. + * GroupV2 added members can be added two ways: with and without the history of messages. + * GroupV2 removed members have their subaccount token revoked on the server side so they cannot poll anymore from the group's swarm. + */ +const handleMemberLeftMessage = createAsyncThunk( + 'group/handleMemberLeftMessage', + async ( + { + groupPk, + memberLeft, + }: { + groupPk: GroupPubkeyType; + memberLeft: PubkeyType; + }, + payloadCreator + ): Promise => { + const state = payloadCreator.getState() as StateType; + if (!state.groups.infos[groupPk] || !state.groups.members[groupPk]) { + throw new PreConditionFailed( + 'currentDeviceGroupMembersChange group not present in redux slice' + ); + } + + await handleMemberRemovedFromUIOrNot({ + groupPk, + removeMembers: [memberLeft], + fromCurrentDevice: true, + fromMemberLeftMessage: true, + }); + + return { + groupPk, + infos: await MetaGroupWrapperActions.infoGet(groupPk), + members: await MetaGroupWrapperActions.memberGetAll(groupPk), + }; + } +); + const markUsAsAdmin = createAsyncThunk( 'group/markUsAsAdmin', async ( @@ -1201,6 +1251,7 @@ const metaGroupSlice = createSlice({ state.memberChangesFromUIPending = true; }); + /** currentDeviceGroupNameChange */ builder.addCase(currentDeviceGroupNameChange.fulfilled, (state, action) => { state.nameChangesFromUIPending = false; @@ -1218,6 +1269,21 @@ const metaGroupSlice = createSlice({ builder.addCase(currentDeviceGroupNameChange.pending, state => { state.nameChangesFromUIPending = true; }); + + /** handleMemberLeftMessage */ + builder.addCase(handleMemberLeftMessage.fulfilled, (state, action) => { + const { infos, members, groupPk } = action.payload; + state.infos[groupPk] = infos; + state.members[groupPk] = members; + + window.log.debug(`groupInfo after handleMemberLeftMessage: ${stringify(infos)}`); + window.log.debug(`groupMembers after handleMemberLeftMessage: ${stringify(members)}`); + }); + builder.addCase(handleMemberLeftMessage.rejected, (_state, action) => { + window.log.error('a handleMemberLeftMessage was rejected', action.error); + }); + + /** markUsAsAdmin */ builder.addCase(markUsAsAdmin.fulfilled, (state, action) => { const { infos, members, groupPk } = action.payload; state.infos[groupPk] = infos; @@ -1253,6 +1319,7 @@ export const groupInfoActions = { currentDeviceGroupMembersChange, markUsAsAdmin, inviteResponseReceived, + handleMemberLeftMessage, currentDeviceGroupNameChange, ...metaGroupSlice.actions, };