Use source loader for email sprite icons
[ProtonMail-WebClient.git] / packages / account / members / index.ts
bloba7849a052f34ed67207d7b8f5a5e92490c97b273
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';
5 import {
6     CacheType,
7     cacheHelper,
8     createPromiseStore,
9     getFetchedAt,
10     getFetchedEphemeral,
11     previousSelector,
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;
30 enum ValueType {
31     dummy,
32     complete,
35 export type UnprivatizationMemberSuccess = {
36     type: 'success';
38 export type UnprivatizationMemberApproval = {
39     type: 'approval';
41 export type UnprivatizationMemberFailure = {
42     type: 'error';
43     error: string;
44     revision: boolean;
47 interface UnprivatizationMemberState {
48     members: {
49         [id: string]:
50             | UnprivatizationMemberSuccess
51             | UnprivatizationMemberFailure
52             | UnprivatizationMemberApproval
53             | undefined;
54     };
55     loading: {
56         approval: boolean;
57         automatic: boolean;
58     };
61 export interface MembersState extends UserState, AddressesState {
62     [name]: ModelState<EnhancedMember[]> & {
63         meta?: { type: ValueType };
64         unprivatization: UnprivatizationMemberState;
65     };
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) => {
74     return isAdmin(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 = {
87     value: undefined,
88     error: undefined,
89     meta: {
90         type: ValueType.complete,
91         fetchedAt: 0,
92         fetchedEphemeral: undefined,
93     },
94     unprivatization: { members: {}, loading: { approval: false, automatic: false } },
96 const slice = createSlice({
97     name,
98     initialState,
99     reducers: {
100         pending: (state) => {
101             state.error = undefined;
102         },
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();
109         },
110         rejected: (state, action) => {
111             state.error = action.payload;
112             state.meta.fetchedAt = getFetchedAt();
113         },
114         upsertMember: (state, action: PayloadAction<{ member: Member; type?: 'delete' }>) => {
115             if (!state.value) {
116                 return;
117             }
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);
122                 }
123                 return;
124             }
125             const newMember = {
126                 ...action.payload.member,
127                 addressState: 'partial' as const,
128             };
129             if (memberIndex === -1) {
130                 state.value.push(newMember);
131             } else {
132                 const previousMember = state.value[memberIndex];
133                 const previousAddressState =
134                     previousMember.addressState === 'full'
135                         ? {
136                               addressState: previousMember.addressState,
137                               Addresses: previousMember.Addresses,
138                           }
139                         : {};
140                 const mergedValue: EnhancedMember = {
141                     ...newMember,
142                     ...previousAddressState,
143                 };
144                 state.value[memberIndex] = mergedValue;
145             }
146         },
147         memberFetchFulfilled: (state, action: PayloadAction<{ member: Member; addresses: Address[] }>) => {
148             const member = getMemberFromState(state, action.payload.member);
149             if (member) {
150                 member.addressState = 'full';
151                 member.Addresses = action.payload.addresses;
152             }
153         },
154         memberFetchPending: (state, action: PayloadAction<{ member: Member }>) => {
155             const member = getMemberFromState(state, action.payload.member);
156             if (member) {
157                 member.addressState = 'pending';
158             }
159         },
160         memberFetchRejected: (state, action: PayloadAction<{ member: Member }>) => {
161             const member = getMemberFromState(state, action.payload.member);
162             if (member) {
163                 member.addressState = 'rejected';
164             }
165         },
166         setUnprivatizationState: (state, action: PayloadAction<UnprivatizationMemberState>) => {
167             state.unprivatization = action.payload;
168         },
169     },
170     extraReducers: (builder) => {
171         builder.addCase(bootstrapEvent, (state) => {
172             state.unprivatization = initialState.unprivatization;
173         });
175         builder.addCase(serverEvent, (state, action) => {
176             if (!state.value) {
177                 return;
178             }
180             const isFreeMembers = original(state)?.meta?.type === ValueType.dummy;
182             if (action.payload.Members && !isFreeMembers) {
183                 state.value = updateCollection({
184                     model: state.value,
185                     events: action.payload.Members,
186                     itemKey: 'Member',
187                     create: (a): EnhancedMember => {
188                         return {
189                             ...a,
190                             addressState: 'partial',
191                         };
192                     },
193                     merge: (a, b): EnhancedMember => {
194                         return {
195                             ...a,
196                             ...b,
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',
200                         };
201                     },
202                 });
203             } else {
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;
209                 }
211                 if (isFreeMembers && action.payload.User && canFetch(action.payload.User)) {
212                     state.value = undefined;
213                     state.error = undefined;
214                     state.meta.type = ValueType.complete;
215                 }
216             }
217         });
218     },
221 const promiseStore = createPromiseStore<Model>();
222 const previous = previousSelector(selectMembers);
224 const modelThunk = (options?: {
225     cache?: CacheType;
226 }): ThunkAction<Promise<Model>, MembersState, ProtonThunkArguments, UnknownAction> => {
227     return (dispatch, getState, extraArgument) => {
228         const select = () => {
229             return previous({ dispatch, getState, extraArgument, options });
230         };
231         const getPayload = async () => {
232             const user = await dispatch(userThunk());
233             const defaultValue = {
234                 value: freeMembers,
235                 type: ValueType.dummy,
236             };
237             if (!canFetch(user)) {
238                 return defaultValue;
239             }
240             try {
241                 const value = await getAllMembers(extraArgument.api).then((members): EnhancedMember[] => {
242                     return members.map((member) => ({
243                         ...member,
244                         addressState: 'partial' as const,
245                     }));
246                 });
247                 return {
248                     value,
249                     type: ValueType.complete,
250                 };
251             } catch (e: any) {
252                 if (getIsMissingScopeError(e)) {
253                     return defaultValue;
254                 }
255                 throw e;
256             }
257         };
258         const cb = async () => {
259             try {
260                 dispatch(slice.actions.pending());
261                 const payload = await getPayload();
262                 dispatch(slice.actions.fulfilled(payload));
263                 return payload.value;
264             } catch (error) {
265                 dispatch(slice.actions.rejected(miniSerializeError(error)));
266                 throw error;
267             }
268         };
269         return cacheHelper({ store: promiseStore, select, cb, cache: options?.cache });
270     };
273 const getTemporaryPromiseMap = (() => {
274     let map: undefined | Map<string, Promise<Address[]>>;
275     return () => {
276         if (!map) {
277             map = new Map();
278         }
279         return map;
280     };
281 })();
283 export const getMemberAddresses = ({
284     member: targetMember,
285     retry,
286     cache,
287 }: {
288     member: Member;
289     retry?: boolean;
290     cache?: CacheType;
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);
298         if (!member) {
299             return [];
300         }
301         if (Boolean(member.Self)) {
302             return dispatch(addressesThunk({ cache }));
303         }
304         if (cache !== CacheType.None) {
305             if (member.addressState === 'full' && member.Addresses) {
306                 return member.Addresses;
307             }
308             if (member.addressState === 'rejected' && !retry) {
309                 return [];
310             }
311         }
312         const oldPromise = map.get(member.ID);
313         if (oldPromise) {
314             return oldPromise;
315         }
316         const promise = fetch(extra.api, member.ID);
317         try {
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 }));
322             return result;
323         } catch (e) {
324             dispatch(slice.actions.memberFetchRejected({ member }));
325             throw e;
326         } finally {
327             map.delete(member.ID);
328         }
329     };
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';