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

409 lines
15 KiB
TypeScript

/* eslint-disable no-await-in-loop */
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 { getSwarmPollingInstance } from '../../session/apis/snode_api';
import { ConvoHub } from '../../session/conversations';
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 { GroupSync } from '../../session/utils/job_runners/jobs/GroupSyncJob';
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';
export type GroupState = {
infos: Record<GroupPubkeyType, GroupInfoGet>;
members: Record<GroupPubkeyType, Array<GroupMemberGet>>;
creationFromUIPending: boolean;
};
export const initialGroupState: GroupState = {
infos: {},
members: {},
creationFromUIPending: false,
};
type GroupDetailsUpdate = {
groupPk: GroupPubkeyType;
infos: GroupInfoGet;
members: Array<GroupMemberGet>;
};
/**
* 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');
}
const uniqMembers = uniq(members);
const newGroup = await UserGroupsWrapperActions.createGroup();
const groupPk = newGroup.pubkeyHex;
try {
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),
groupEd25519Secretkey: newGroup.secretKey,
groupEd25519Pubkey: toFixedUint8ArrayOfLength(groupEd2519Pk, 32),
});
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.memberSetPromoted(groupPk, created.pubkeyHex, false);
} 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.`
);
}
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');
}
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 };
} 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]) {
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 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),
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 });
} 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 };
}
);
/**
* 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;
});
builder.addCase(initNewGroupInWrapper.rejected, state => {
window.log.error('a initNewGroupInWrapper was rejected');
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(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(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,
loadMetaDumpsFromDB,
destroyGroupDetails,
refreshGroupDetailsFromWrapper,
handleUserGroupUpdate,
...groupSlice.actions,
};
export const groupReducer = groupSlice.reducer;