You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/state/ducks/groups.ts

1026 lines
37 KiB
TypeScript

/* eslint-disable no-await-in-loop */
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import {
GroupInfoGet,
GroupMemberGet,
GroupPubkeyType,
PubkeyType,
Uint8ArrayLen64,
UserGroupsGet,
WithGroupPubkey,
} from 'libsession_util_nodejs';
import { base64_variants, from_base64 } from 'libsodium-wrappers-sumo';
import { intersection, isEmpty, uniq } from 'lodash';
import { ConfigDumpData } from '../../data/configDump/configDump';
import { ConversationTypeEnum } from '../../models/conversationAttributes';
import { HexString } from '../../node/hexStrings';
import { SignalService } from '../../protobuf';
import { getMessageQueue } from '../../session';
import { getSwarmPollingInstance } from '../../session/apis/snode_api';
import { GetNetworkTime } from '../../session/apis/snode_api/getNetworkTime';
import { RevokeChanges, SnodeAPIRevoke } from '../../session/apis/snode_api/revokeSubaccount';
import { SnodeGroupSignature } from '../../session/apis/snode_api/signature/groupSignature';
import { ConvoHub } from '../../session/conversations';
import { getSodiumRenderer } from '../../session/crypto';
import { ClosedGroup } from '../../session/group/closed-group';
import { GroupUpdateInfoChangeMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage';
import { GroupUpdateMemberChangeMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateMemberChangeMessage';
import { GroupUpdateDeleteMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_user/GroupUpdateDeleteMessage';
import { PubKey } from '../../session/types';
import { UserUtils } from '../../session/utils';
import { getUserED25519KeyPairBytes } from '../../session/utils/User';
import { PreConditionFailed } from '../../session/utils/errors';
import { RunJobResult } from '../../session/utils/job_runners/PersistedJob';
import { GroupInvite } from '../../session/utils/job_runners/jobs/GroupInviteJob';
import { GroupSync } from '../../session/utils/job_runners/jobs/GroupSyncJob';
import { UserSync } from '../../session/utils/job_runners/jobs/UserSyncJob';
import { stringify, toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes';
import {
getGroupPubkeyFromWrapperType,
isMetaWrapperType,
} from '../../webworker/workers/browser/libsession_worker_functions';
import {
MetaGroupWrapperActions,
UserGroupsWrapperActions,
} from '../../webworker/workers/browser/libsession_worker_interface';
import { StateType } from '../reducer';
import { openConversationWithMessages } from './conversations';
import { resetOverlayMode } from './section';
type WithAddWithoutHistoryMembers = { withoutHistory: Array<PubkeyType> };
type WithAddWithHistoryMembers = { withHistory: Array<PubkeyType> };
type WithRemoveMembers = { removed: Array<PubkeyType> };
type WithFromCurrentDevice = { fromCurrentDevice: boolean }; // there are some changes we want to do only when the current user do the change, and not when a network change triggers it.
export type GroupState = {
infos: Record<GroupPubkeyType, GroupInfoGet>;
members: Record<GroupPubkeyType, Array<GroupMemberGet>>;
creationFromUIPending: boolean;
memberChangesFromUIPending: boolean;
nameChangesFromUIPending: boolean;
};
export const initialGroupState: GroupState = {
infos: {},
members: {},
creationFromUIPending: false,
memberChangesFromUIPending: false,
nameChangesFromUIPending: false,
};
type GroupDetailsUpdate = {
groupPk: GroupPubkeyType;
infos: GroupInfoGet;
members: Array<GroupMemberGet>;
};
async function checkWeAreAdminOrThrow(groupPk: GroupPubkeyType, context: string) {
const us = UserUtils.getOurPubKeyStrFromCache();
const usInGroup = await MetaGroupWrapperActions.memberGet(groupPk, us);
const inUserGroup = await UserGroupsWrapperActions.getGroup(groupPk);
if (isEmpty(inUserGroup?.secretKey) || !usInGroup?.promoted) {
throw new Error(`checkWeAreAdminOrThrow failed with ctx: ${context}`);
}
}
/**
* 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<string>;
us: string;
},
{ dispatch }
): Promise<GroupDetailsUpdate> => {
if (!members.includes(us)) {
throw new PreConditionFailed('initNewGroupInWrapper needs us to be a member');
}
if (members.some(k => !PubKey.is05Pubkey(k))) {
throw new PreConditionFailed('initNewGroupInWrapper only works with members being pubkeys');
}
const uniqMembers = uniq(members) as Array<PubkeyType>; // the if just above ensures that this is fine
const newGroup = await UserGroupsWrapperActions.createGroup();
const groupPk = newGroup.pubkeyHex;
try {
const groupSecretKey = newGroup.secretKey;
if (!groupSecretKey) {
throw new Error('groupSecretKey was empty just after creation.');
}
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?');
}
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
await MetaGroupWrapperActions.init(groupPk, {
metaDumped: null,
userEd25519Secretkey: toFixedUint8ArrayOfLength(userEd25519Secretkey, 64).buffer,
groupEd25519Secretkey: newGroup.secretKey,
groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd2519Pk, 32).buffer,
});
for (let index = 0; index < uniqMembers.length; index++) {
const member = uniqMembers[index];
const created = await MetaGroupWrapperActions.memberGetOrConstruct(groupPk, member);
if (created.pubkeyHex === us) {
await MetaGroupWrapperActions.memberSetAdmin(groupPk, created.pubkeyHex);
} else {
await MetaGroupWrapperActions.memberSetInvited(groupPk, created.pubkeyHex, false);
}
}
const infos = await MetaGroupWrapperActions.infoGet(groupPk);
if (!infos) {
throw new Error(`getInfos of ${groupPk} returned empty result even if it was just init.`);
}
infos.name = groupName;
await MetaGroupWrapperActions.infoSet(groupPk, infos);
const membersFromWrapper = await MetaGroupWrapperActions.memberGetAll(groupPk);
if (!membersFromWrapper || isEmpty(membersFromWrapper)) {
throw new Error(
`memberGetAll of ${groupPk} returned empty result even if it was just init.`
);
}
// now that we've added members to the group, make sure to make a full key rotation
// to include them and marks the corresponding wrappers as dirty
await MetaGroupWrapperActions.keyRekey(groupPk);
const convo = await ConvoHub.use().getOrCreateAndWait(groupPk, ConversationTypeEnum.GROUPV2);
await convo.setIsApproved(true, false);
const result = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk, []);
if (result !== RunJobResult.Success) {
window.log.warn('GroupSync.pushChangesToGroupSwarmIfNeeded during create failed');
throw new Error('failed to pushChangesToGroupSwarmIfNeeded');
}
getSwarmPollingInstance().addGroupId(new PubKey(groupPk));
await convo.unhideIfNeeded();
convo.set({ active_at: Date.now() });
await convo.commit();
convo.updateLastMessage();
dispatch(resetOverlayMode());
// Everything is setup for this group, we now need to send the invites to each members,
// privately and asynchronously, and gracefully handle errors with toasts.
// Let's do all of this part of a job to handle app crashes and make sure we
// can update the groupwrapper with a failed state if a message fails to be sent.
for (let index = 0; index < membersFromWrapper.length; index++) {
const member = membersFromWrapper[index];
await GroupInvite.addJob({ member: member.pubkeyHex, groupPk });
}
await openConversationWithMessages({ conversationKey: groupPk, messageId: null });
return { groupPk: newGroup.pubkeyHex, infos, members: membersFromWrapper };
} catch (e) {
window.log.warn('group creation failed. Deleting already saved datas: ', e.message);
await UserGroupsWrapperActions.eraseGroup(groupPk);
await MetaGroupWrapperActions.infoDestroy(groupPk);
const foundConvo = ConvoHub.use().get(groupPk);
if (foundConvo) {
await ConvoHub.use().deleteClosedGroup(groupPk, {
fromSyncMessage: false,
sendLeaveMessage: false,
});
}
throw e;
}
}
);
/**
* 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<GroupDetailsUpdate> => {
// 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]) {
window.log.info('handleUserGroupUpdate group already present in redux slice');
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(groupPk),
};
}
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).buffer,
groupEd25519Secretkey: userGroup.secretKey,
groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd2519Pk, 32).buffer,
});
} catch (e) {
window.log.warn(`failed to init metawrapper ${groupPk}`);
}
const convo = await ConvoHub.use().getOrCreateAndWait(groupPk, ConversationTypeEnum.GROUPV2);
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<Array<GroupDetailsUpdate>> => {
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<GroupDetailsUpdate> = [];
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('loadMetaDumpsFromDB initing from metagroup dump', variant);
await MetaGroupWrapperActions.init(groupPk, {
groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd25519Pubkey, 32).buffer,
groupEd25519Secretkey: foundInUserWrapper?.secretKey || null,
userEd25519Secretkey: toFixedUint8ArrayOfLength(ed25519KeyPairBytes.privKeyBytes, 64)
.buffer,
metaDumped: data,
});
const infos = await MetaGroupWrapperActions.infoGet(groupPk);
const members = await MetaGroupWrapperActions.memberGetAll(groupPk);
toReturn.push({ groupPk, infos, members });
} catch (e) {
// 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} `
);
}
}
return toReturn;
}
);
/**
* 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<GroupDetailsUpdate>)
> => {
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 };
}
);
function validateMemberChange({
groupPk,
withHistory: addMembersWithHistory,
withoutHistory: addMembersWithoutHistory,
removed: removeMembers,
}: WithGroupPubkey & WithAddWithoutHistoryMembers & WithAddWithHistoryMembers & WithRemoveMembers) {
const us = UserUtils.getOurPubKeyStrFromCache();
if (
addMembersWithHistory.includes(us) ||
addMembersWithoutHistory.includes(us) ||
removeMembers.includes(us)
) {
throw new PreConditionFailed(
'currentDeviceGroupMembersChange cannot be used for changes of our own state in the group'
);
}
const withHistory = uniq(addMembersWithHistory);
const withoutHistory = uniq(addMembersWithoutHistory);
const removed = uniq(removeMembers);
const convo = ConvoHub.use().get(groupPk);
if (!convo) {
throw new PreConditionFailed('currentDeviceGroupMembersChange convo not present in convohub');
}
if (intersection(withHistory, withoutHistory).length) {
throw new Error(
'withHistory and withoutHistory can only have values which are not in the other'
);
}
if (
intersection(withHistory, removed).length ||
intersection(withHistory, removed).length ||
intersection(withoutHistory, removed).length
) {
throw new Error(
'withHistory/without and removed can only have values which are not in the other'
);
}
return { withoutHistory, withHistory, removed, us, convo };
}
function validateNameChange({
groupPk,
newName,
currentName,
}: WithGroupPubkey & { newName: string; currentName: string }) {
const us = UserUtils.getOurPubKeyStrFromCache();
if (!newName || isEmpty(newName)) {
throw new PreConditionFailed('validateNameChange needs a non empty name');
}
const convo = ConvoHub.use().get(groupPk);
if (!convo) {
throw new PreConditionFailed('validateNameChange convo not present in convohub');
}
if (newName === currentName) {
throw new PreConditionFailed('validateNameChange no name change detected');
}
return { newName, us, convo };
}
async function handleWithHistoryMembers({
groupPk,
withHistory,
}: WithGroupPubkey & {
withHistory: Array<PubkeyType>;
}) {
for (let index = 0; index < withHistory.length; index++) {
const member = withHistory[index];
const created = await MetaGroupWrapperActions.memberGetOrConstruct(groupPk, member);
await MetaGroupWrapperActions.memberSetInvited(groupPk, created.pubkeyHex, false);
}
const supplementKeys = withHistory.length
? await MetaGroupWrapperActions.generateSupplementKeys(groupPk, withHistory)
: [];
return supplementKeys;
}
async function handleWithoutHistoryMembers({
groupPk,
withoutHistory,
}: WithGroupPubkey & WithAddWithoutHistoryMembers) {
for (let index = 0; index < withoutHistory.length; index++) {
const member = withoutHistory[index];
const created = await MetaGroupWrapperActions.memberGetOrConstruct(groupPk, member);
await MetaGroupWrapperActions.memberSetInvited(groupPk, created.pubkeyHex, false);
}
if (!isEmpty(withoutHistory)) {
await MetaGroupWrapperActions.keyRekey(groupPk);
}
}
async function handleRemoveMembers({
groupPk,
removed,
secretKey,
fromCurrentDevice,
}: WithGroupPubkey & WithRemoveMembers & WithFromCurrentDevice & { secretKey: Uint8Array }) {
if (!fromCurrentDevice) {
return;
}
await MetaGroupWrapperActions.memberEraseAndRekey(groupPk, removed);
const createAtNetworkTimestamp = GetNetworkTime.now();
await Promise.all(
removed.map(async m => {
const adminSignature = await SnodeGroupSignature.signDataWithAdminSecret(
`DELETE${m}${createAtNetworkTimestamp}`,
{ secretKey }
);
const deleteMessage = new GroupUpdateDeleteMessage({
groupPk,
createAtNetworkTimestamp,
adminSignature: from_base64(adminSignature.signature, base64_variants.ORIGINAL),
});
console.warn(
'TODO: poll from namespace -11, handle messages and sig for it, batch request handle 401/403, but 200 ok for this -11 namespace'
);
// const sentStatus = await getMessageQueue().sendToPubKeyNonDurably({
// pubkey: PubKey.cast(m),
// message: deleteMessage,
// namespace: SnodeNamespaces.ClosedGroupRevokedRetrievableMessages,
// });
// if (!sentStatus) {
// window.log.warn('Failed to send a GroupUpdateDeleteMessage to a member removed: ', m);
// }
})
);
}
async function getPendingRevokeChanges({
withoutHistory,
withHistory,
removed,
groupPk,
}: WithGroupPubkey &
WithAddWithoutHistoryMembers &
WithAddWithHistoryMembers &
WithRemoveMembers): Promise<RevokeChanges> {
const revokeChanges: RevokeChanges = [];
for (let index = 0; index < withoutHistory.length; index++) {
const m = withoutHistory[index];
const token = await MetaGroupWrapperActions.swarmSubAccountToken(groupPk, m);
revokeChanges.push({ action: 'unrevoke_subaccount', tokenToRevokeHex: token });
}
for (let index = 0; index < withHistory.length; index++) {
const m = withHistory[index];
const token = await MetaGroupWrapperActions.swarmSubAccountToken(groupPk, m);
revokeChanges.push({ action: 'unrevoke_subaccount', tokenToRevokeHex: token });
}
for (let index = 0; index < removed.length; index++) {
const m = removed[index];
const token = await MetaGroupWrapperActions.swarmSubAccountToken(groupPk, m);
revokeChanges.push({ action: 'revoke_subaccount', tokenToRevokeHex: token });
}
return revokeChanges;
}
async function handleMemberChangeFromUIOrNot({
addMembersWithHistory,
addMembersWithoutHistory,
groupPk,
removeMembers,
fromCurrentDevice,
}: WithFromCurrentDevice &
WithGroupPubkey & {
addMembersWithHistory: Array<PubkeyType>;
addMembersWithoutHistory: Array<PubkeyType>;
removeMembers: Array<PubkeyType>;
}) {
const group = await UserGroupsWrapperActions.getGroup(groupPk);
if (!group || !group.secretKey || isEmpty(group.secretKey)) {
throw new Error('tried to make change to group but we do not have the admin secret key');
}
await checkWeAreAdminOrThrow(groupPk, 'handleMemberChangeFromUIOrNot');
const { removed, withHistory, withoutHistory, convo, us } = validateMemberChange({
withHistory: addMembersWithHistory,
withoutHistory: addMembersWithoutHistory,
groupPk,
removed: removeMembers,
});
// first, unrevoke people who are added, and sevoke people who are removed
const revokeChanges = await getPendingRevokeChanges({
groupPk,
withHistory,
withoutHistory,
removed,
});
await SnodeAPIRevoke.revokeSubAccounts(groupPk, revokeChanges, group.secretKey);
// then, handle the addition with history of messages by generating supplement keys.
// this adds them to the members wrapper etc
const supplementKeys = await handleWithHistoryMembers({ groupPk, withHistory });
// then handle the addition without history of messages (full rotation of keys).
// this adds them to the members wrapper etc
await handleWithoutHistoryMembers({ groupPk, withoutHistory });
// lastly, handle the removal of members.
// we've already revoked their token above
// this removes them from the wrapper
await handleRemoveMembers({ groupPk, removed, secretKey: group.secretKey, fromCurrentDevice });
// push new members & key supplement in a single batch call
const batchResult = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk, supplementKeys);
if (batchResult !== RunJobResult.Success) {
throw new Error(
'currentDeviceGroupMembersChange: pushChangesToGroupSwarmIfNeeded did not return success'
);
}
// schedule send invite details, auth signature, etc. to the new users
for (let index = 0; index < withoutHistory.length; index++) {
const member = withoutHistory[index];
await GroupInvite.addJob({ groupPk, member });
}
for (let index = 0; index < withHistory.length; index++) {
const member = withHistory[index];
await GroupInvite.addJob({ groupPk, member });
}
const sodium = await getSodiumRenderer();
const allAdded = [...withHistory, ...withoutHistory]; // those are already enforced to be unique (and without intersection) in `validateMemberChange()`
const createAtNetworkTimestamp = GetNetworkTime.now();
if (fromCurrentDevice && allAdded.length) {
const msg = await ClosedGroup.addUpdateMessage(
convo,
{ joiningMembers: allAdded },
us,
createAtNetworkTimestamp
);
await getMessageQueue().sendToGroupV2({
message: new GroupUpdateMemberChangeMessage({
added: allAdded,
groupPk,
typeOfChange: SignalService.GroupUpdateMemberChangeMessage.Type.ADDED,
identifier: msg.id,
createAtNetworkTimestamp,
secretKey: group.secretKey,
sodium,
}),
});
}
if (fromCurrentDevice && removed.length) {
const msg = await ClosedGroup.addUpdateMessage(
convo,
{ kickedMembers: removed },
us,
createAtNetworkTimestamp
);
await getMessageQueue().sendToGroupV2({
message: new GroupUpdateMemberChangeMessage({
removed,
groupPk,
typeOfChange: SignalService.GroupUpdateMemberChangeMessage.Type.REMOVED,
identifier: msg.id,
createAtNetworkTimestamp,
secretKey: group.secretKey,
sodium,
}),
});
}
convo.set({
active_at: createAtNetworkTimestamp,
});
await convo.commit();
}
async function handleNameChangeFromUIOrNot({
groupPk,
newName: uncheckedName,
fromCurrentDevice,
}: WithFromCurrentDevice &
WithGroupPubkey & {
newName: string;
}) {
const group = await UserGroupsWrapperActions.getGroup(groupPk);
if (!group || !group.secretKey || isEmpty(group.secretKey)) {
throw new Error('tried to make change to group but we do not have the admin secret key');
}
const infos = await MetaGroupWrapperActions.infoGet(groupPk);
if (!infos) {
throw new PreConditionFailed('nameChange infoGet is empty');
}
await checkWeAreAdminOrThrow(groupPk, 'handleNameChangeFromUIOrNot');
// this throws if the name is the same, or empty
const { newName, convo, us } = validateNameChange({
newName: uncheckedName,
currentName: group.name || '',
groupPk,
});
group.name = newName;
infos.name = newName;
await UserGroupsWrapperActions.setGroup(group);
await MetaGroupWrapperActions.infoSet(groupPk, infos);
const batchResult = await GroupSync.pushChangesToGroupSwarmIfNeeded(groupPk, []);
if (batchResult !== RunJobResult.Success) {
throw new Error(
'handleNameChangeFromUIOrNot: pushChangesToGroupSwarmIfNeeded did not return success'
);
}
await UserSync.queueNewJobIfNeeded();
const createAtNetworkTimestamp = GetNetworkTime.now();
if (fromCurrentDevice) {
const msg = await ClosedGroup.addUpdateMessage(
convo,
{ newName },
us,
createAtNetworkTimestamp
);
await getMessageQueue().sendToGroupV2({
message: new GroupUpdateInfoChangeMessage({
groupPk,
typeOfChange: SignalService.GroupUpdateInfoChangeMessage.Type.NAME,
updatedName: newName,
identifier: msg.id,
createAtNetworkTimestamp,
secretKey: group.secretKey,
sodium: await getSodiumRenderer(),
}),
});
}
convo.set({
active_at: createAtNetworkTimestamp,
});
await convo.commit();
}
/**
* 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 currentDeviceGroupMembersChange = createAsyncThunk(
'group/currentDeviceGroupMembersChange',
async (
{
groupPk,
...args
}: {
groupPk: GroupPubkeyType;
addMembersWithHistory: Array<PubkeyType>;
addMembersWithoutHistory: Array<PubkeyType>;
removeMembers: Array<PubkeyType>;
},
payloadCreator
): Promise<GroupDetailsUpdate> => {
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 handleMemberChangeFromUIOrNot({ groupPk, ...args, fromCurrentDevice: true });
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(groupPk),
};
}
);
const markUsAsAdmin = createAsyncThunk(
'group/markUsAsAdmin',
async (
{
groupPk,
secret,
}: {
groupPk: GroupPubkeyType;
secret: Uint8ArrayLen64;
},
payloadCreator
): Promise<GroupDetailsUpdate> => {
const state = payloadCreator.getState() as StateType;
if (!state.groups.infos[groupPk] || !state.groups.members[groupPk]) {
throw new PreConditionFailed('markUsAsAdmin group not present in redux slice');
}
if (secret.length !== 64) {
throw new PreConditionFailed('markUsAsAdmin secret needs to be 64');
}
console.warn('before setSigKeys ', groupPk, stringify(secret));
await MetaGroupWrapperActions.setSigKeys(groupPk, secret);
console.warn('after setSigKeys');
const us = UserUtils.getOurPubKeyStrFromCache();
if (state.groups.members[groupPk].find(m => m.pubkeyHex === us)?.admin) {
// we are already an admin, nothing to do
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(groupPk),
};
}
await MetaGroupWrapperActions.memberSetAdmin(groupPk, us);
await GroupSync.queueNewJobIfNeeded(groupPk);
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(groupPk),
};
}
);
const inviteResponseReceived = createAsyncThunk(
'group/inviteResponseReceived',
async (
{
groupPk,
member,
}: {
groupPk: GroupPubkeyType;
member: PubkeyType;
},
payloadCreator
): Promise<GroupDetailsUpdate> => {
const state = payloadCreator.getState() as StateType;
if (!state.groups.infos[groupPk] || !state.groups.members[groupPk]) {
throw new PreConditionFailed('inviteResponseReceived group but not present in redux slice');
}
try {
await checkWeAreAdminOrThrow(groupPk, 'inviteResponseReceived');
await MetaGroupWrapperActions.memberSetAccepted(groupPk, member);
await GroupSync.queueNewJobIfNeeded(groupPk);
} catch (e) {
window.log.info('inviteResponseReceived failed with', e.message);
// only admins can do the steps above, but we don't want to throw if we are not an admin
}
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(groupPk),
};
}
);
const currentDeviceGroupNameChange = createAsyncThunk(
'group/currentDeviceGroupNameChange',
async (
{
groupPk,
...args
}: {
groupPk: GroupPubkeyType;
newName: string;
},
payloadCreator
): Promise<GroupDetailsUpdate> => {
const state = payloadCreator.getState() as StateType;
if (!state.groups.infos[groupPk] || !state.groups.members[groupPk]) {
throw new PreConditionFailed('currentDeviceGroupNameChange group not present in redux slice');
}
await checkWeAreAdminOrThrow(groupPk, 'currentDeviceGroupNameChange');
await handleNameChangeFromUIOrNot({ groupPk, ...args, fromCurrentDevice: true });
return {
groupPk,
infos: await MetaGroupWrapperActions.infoGet(groupPk),
members: await MetaGroupWrapperActions.memberGetAll(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: {},
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;
return state;
});
builder.addCase(initNewGroupInWrapper.rejected, (state, action) => {
window.log.error('a initNewGroupInWrapper was rejected', action.error);
state.creationFromUIPending = false;
return state;
// 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');
return state;
});
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;
});
return state;
});
builder.addCase(loadMetaDumpsFromDB.rejected, (state, action) => {
window.log.error('a loadMetaDumpsFromDB was rejected', action.error);
return state;
});
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];
}
return state;
});
builder.addCase(refreshGroupDetailsFromWrapper.rejected, (_state, action) => {
window.log.error('a refreshGroupDetailsFromWrapper was rejected', action.error);
});
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, (_state, action) => {
window.log.error('a destroyGroupDetails was rejected', action.error);
});
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, (_state, action) => {
window.log.error('a handleUserGroupUpdate was rejected', action.error);
});
builder.addCase(currentDeviceGroupMembersChange.fulfilled, (state, action) => {
state.memberChangesFromUIPending = false;
const { infos, members, groupPk } = action.payload;
state.infos[groupPk] = infos;
state.members[groupPk] = members;
window.log.debug(`groupInfo after currentDeviceGroupMembersChange: ${stringify(infos)}`);
window.log.debug(`groupMembers after currentDeviceGroupMembersChange: ${stringify(members)}`);
});
builder.addCase(currentDeviceGroupMembersChange.rejected, (state, action) => {
window.log.error('a currentDeviceGroupMembersChange was rejected', action.error);
state.memberChangesFromUIPending = false;
});
builder.addCase(currentDeviceGroupMembersChange.pending, state => {
state.memberChangesFromUIPending = true;
});
builder.addCase(currentDeviceGroupNameChange.fulfilled, (state, action) => {
state.nameChangesFromUIPending = false;
const { infos, members, groupPk } = action.payload;
state.infos[groupPk] = infos;
state.members[groupPk] = members;
window.log.debug(`groupInfo after currentDeviceGroupNameChange: ${stringify(infos)}`);
window.log.debug(`groupMembers after currentDeviceGroupNameChange: ${stringify(members)}`);
});
builder.addCase(currentDeviceGroupNameChange.rejected, (state, action) => {
window.log.error('a currentDeviceGroupNameChange was rejected', action.error);
state.nameChangesFromUIPending = false;
});
builder.addCase(currentDeviceGroupNameChange.pending, state => {
state.nameChangesFromUIPending = true;
});
builder.addCase(markUsAsAdmin.fulfilled, (state, action) => {
const { infos, members, groupPk } = action.payload;
state.infos[groupPk] = infos;
state.members[groupPk] = members;
window.log.debug(`groupInfo after markUsAsAdmin: ${stringify(infos)}`);
window.log.debug(`groupMembers after markUsAsAdmin: ${stringify(members)}`);
});
builder.addCase(markUsAsAdmin.rejected, (_state, action) => {
window.log.error('a markUsAsAdmin was rejected', action.error);
});
builder.addCase(inviteResponseReceived.fulfilled, (state, action) => {
const { infos, members, groupPk } = action.payload;
state.infos[groupPk] = infos;
state.members[groupPk] = members;
window.log.debug(`groupInfo after inviteResponseReceived: ${stringify(infos)}`);
window.log.debug(`groupMembers after inviteResponseReceived: ${stringify(members)}`);
});
builder.addCase(inviteResponseReceived.rejected, (_state, action) => {
window.log.error('a inviteResponseReceived was rejected', action.error);
});
},
});
export const groupInfoActions = {
initNewGroupInWrapper,
loadMetaDumpsFromDB,
destroyGroupDetails,
refreshGroupDetailsFromWrapper,
handleUserGroupUpdate,
currentDeviceGroupMembersChange,
markUsAsAdmin,
inviteResponseReceived,
currentDeviceGroupNameChange,
...groupSlice.actions,
};
export const groupReducer = groupSlice.reducer;