1 import type { PayloadAction, ThunkAction, UnknownAction } from '@reduxjs/toolkit';
2 import { createSlice, miniSerializeError, original } from '@reduxjs/toolkit';
4 import type { ProtonThunkArguments } from '@proton/redux-shared-store-types';
12 } from '@proton/redux-utilities';
13 import { getIsMissingScopeError } from '@proton/shared/lib/api/helpers/apiErrorHelper';
14 import { getAllMemberAddresses, getAllMembers } from '@proton/shared/lib/api/members';
15 import updateCollection from '@proton/shared/lib/helpers/updateCollection';
16 import type { Address, Api, EnhancedMember, Member, User } from '@proton/shared/lib/interfaces';
17 import { sortAddresses } from '@proton/shared/lib/mail/addresses';
18 import { isAdmin } from '@proton/shared/lib/user/helpers';
20 import type { AddressesState } from '../addresses';
21 import { addressesThunk } from '../addresses';
22 import { bootstrapEvent } from '../bootstrap/action';
23 import { serverEvent } from '../eventLoop';
24 import type { ModelState } from '../interface';
25 import type { UserState } from '../user';
26 import { userThunk } from '../user';
28 const name = 'members' as const;
35 export type UnprivatizationMemberSuccess = {
38 export type UnprivatizationMemberApproval = {
41 export type UnprivatizationMemberFailure = {
47 interface UnprivatizationMemberState {
50 | UnprivatizationMemberSuccess
51 | UnprivatizationMemberFailure
52 | UnprivatizationMemberApproval
61 export interface MembersState extends UserState, AddressesState {
62 [name]: ModelState<EnhancedMember[]> & {
63 meta?: { type: ValueType };
64 unprivatization: UnprivatizationMemberState;
68 type SliceState = MembersState[typeof name];
69 type Model = NonNullable<SliceState['value']>;
71 export const selectMembers = (state: MembersState) => state.members;
73 const canFetch = (user: User) => {
77 const getMemberFromState = (state: ModelState<EnhancedMember[]>, target: Member) => {
78 return state.value?.find((member) => member.ID === target.ID);
80 const getMemberIndexFromState = (members: EnhancedMember[], target: Member) => {
81 return members.findIndex((member) => member.ID === target.ID);
84 const freeMembers: EnhancedMember[] = [];
86 const initialState: SliceState = {
90 type: ValueType.complete,
92 fetchedEphemeral: undefined,
94 unprivatization: { members: {}, loading: { approval: false, automatic: false } },
96 const slice = createSlice({
100 pending: (state) => {
101 state.error = undefined;
103 fulfilled: (state, action: PayloadAction<{ value: Model; type: ValueType }>) => {
104 state.value = action.payload.value;
105 state.error = undefined;
106 state.meta.type = action.payload.type;
107 state.meta.fetchedAt = getFetchedAt();
108 state.meta.fetchedEphemeral = getFetchedEphemeral();
110 rejected: (state, action) => {
111 state.error = action.payload;
112 state.meta.fetchedAt = getFetchedAt();
114 upsertMember: (state, action: PayloadAction<{ member: Member; type?: 'delete' }>) => {
118 const memberIndex = getMemberIndexFromState(state.value, action.payload.member);
119 if (action.payload.type === 'delete') {
120 if (memberIndex !== -1) {
121 state.value.splice(memberIndex, 1);
126 ...action.payload.member,
127 addressState: 'partial' as const,
129 if (memberIndex === -1) {
130 state.value.push(newMember);
132 const previousMember = state.value[memberIndex];
133 const previousAddressState =
134 previousMember.addressState === 'full'
136 addressState: previousMember.addressState,
137 Addresses: previousMember.Addresses,
140 const mergedValue: EnhancedMember = {
142 ...previousAddressState,
144 state.value[memberIndex] = mergedValue;
147 memberFetchFulfilled: (state, action: PayloadAction<{ member: Member; addresses: Address[] }>) => {
148 const member = getMemberFromState(state, action.payload.member);
150 member.addressState = 'full';
151 member.Addresses = action.payload.addresses;
154 memberFetchPending: (state, action: PayloadAction<{ member: Member }>) => {
155 const member = getMemberFromState(state, action.payload.member);
157 member.addressState = 'pending';
160 memberFetchRejected: (state, action: PayloadAction<{ member: Member }>) => {
161 const member = getMemberFromState(state, action.payload.member);
163 member.addressState = 'rejected';
166 setUnprivatizationState: (state, action: PayloadAction<UnprivatizationMemberState>) => {
167 state.unprivatization = action.payload;
170 extraReducers: (builder) => {
171 builder.addCase(bootstrapEvent, (state) => {
172 state.unprivatization = initialState.unprivatization;
175 builder.addCase(serverEvent, (state, action) => {
180 const isFreeMembers = original(state)?.meta?.type === ValueType.dummy;
182 if (action.payload.Members && !isFreeMembers) {
183 state.value = updateCollection({
185 events: action.payload.Members,
187 create: (a): EnhancedMember => {
190 addressState: 'partial',
193 merge: (a, b): EnhancedMember => {
197 // We don't receive an update for addresses in member updates. So we mark it as stale so that we can still display
198 // the old value, but fetch new one if needed.
199 addressState: a.Addresses && !b.Addresses ? 'stale' : 'partial',
204 if (!isFreeMembers && action.payload.User && !canFetch(action.payload.User)) {
205 // Do not get any members update when user becomes unsubscribed.
206 state.value = freeMembers;
207 state.error = undefined;
208 state.meta.type = ValueType.dummy;
211 if (isFreeMembers && action.payload.User && canFetch(action.payload.User)) {
212 state.value = undefined;
213 state.error = undefined;
214 state.meta.type = ValueType.complete;
221 const promiseStore = createPromiseStore<Model>();
222 const previous = previousSelector(selectMembers);
224 const modelThunk = (options?: {
226 }): ThunkAction<Promise<Model>, MembersState, ProtonThunkArguments, UnknownAction> => {
227 return (dispatch, getState, extraArgument) => {
228 const select = () => {
229 return previous({ dispatch, getState, extraArgument, options });
231 const getPayload = async () => {
232 const user = await dispatch(userThunk());
233 const defaultValue = {
235 type: ValueType.dummy,
237 if (!canFetch(user)) {
241 const value = await getAllMembers(extraArgument.api).then((members): EnhancedMember[] => {
242 return members.map((member) => ({
244 addressState: 'partial' as const,
249 type: ValueType.complete,
252 if (getIsMissingScopeError(e)) {
258 const cb = async () => {
260 dispatch(slice.actions.pending());
261 const payload = await getPayload();
262 dispatch(slice.actions.fulfilled(payload));
263 return payload.value;
265 dispatch(slice.actions.rejected(miniSerializeError(error)));
269 return cacheHelper({ store: promiseStore, select, cb, cache: options?.cache });
273 const getTemporaryPromiseMap = (() => {
274 let map: undefined | Map<string, Promise<Address[]>>;
283 export const getMemberAddresses = ({
284 member: targetMember,
291 }): ThunkAction<Promise<Address[]>, MembersState, ProtonThunkArguments, UnknownAction> => {
292 const fetch = (api: Api, ID: string) => getAllMemberAddresses(api, ID).then(sortAddresses);
294 const map = getTemporaryPromiseMap();
296 return async (dispatch, getState, extra) => {
297 const member = getMemberFromState(selectMembers(getState()), targetMember);
301 if (Boolean(member.Self)) {
302 return dispatch(addressesThunk({ cache }));
304 if (cache !== CacheType.None) {
305 if (member.addressState === 'full' && member.Addresses) {
306 return member.Addresses;
308 if (member.addressState === 'rejected' && !retry) {
312 const oldPromise = map.get(member.ID);
316 const promise = fetch(extra.api, member.ID);
318 map.set(member.ID, promise);
319 dispatch(slice.actions.memberFetchPending({ member }));
320 const result = await promise;
321 dispatch(slice.actions.memberFetchFulfilled({ member, addresses: result }));
324 dispatch(slice.actions.memberFetchRejected({ member }));
327 map.delete(member.ID);
332 export const membersReducer = { [name]: slice.reducer };
333 export const membersThunk = modelThunk;
334 export const upsertMember = slice.actions.upsertMember;
335 export const setUnprivatizationState = slice.actions.setUnprivatizationState;
336 export { default as UnavailableAddressesError } from './errors/UnavailableAddressesError';
337 export { default as InvalidAddressesError } from './errors/InvalidAddressesError';
338 export { default as MemberCreationValidationError } from './errors/MemberCreationValidationError';