Merge branch 'INDA-330-pii-update' into 'main'
[ProtonMail-WebClient.git] / packages / features / reducer.ts
blob46fe1aab92f25cd26c832098929dada98f037172
1 import { PayloadAction, createSlice } from '@reduxjs/toolkit';
2 import { ThunkAction } from 'redux-thunk';
4 import { CacheType, getFetchedAt, getFetchedEphemeral, isNotStale } from '@proton/redux-utilities';
5 import { getFeatures, updateFeatureValue } from '@proton/shared/lib/api/features';
6 import { getSilentApi } from '@proton/shared/lib/api/helpers/customConfig';
7 import { HOUR } from '@proton/shared/lib/constants';
8 import type { Api } from '@proton/shared/lib/interfaces';
9 import unique from '@proton/utils/unique';
11 import type { Feature, FeatureCode } from './interface';
13 interface FeatureState {
14     value: Feature;
15     meta: { fetchedAt: number; fetchedEphemeral: boolean | undefined };
18 type FeaturesState = { [key in FeatureCode]?: FeatureState };
20 const codePromiseCache: { [key in FeatureCode]?: Promise<any> } = {};
22 export interface FeaturesReducerState {
23     features: FeaturesState;
26 export const selectFeatures = (state: FeaturesReducerState) => state.features;
28 export interface ThunkArguments {
29     api: Api;
32 const initialState = {} as FeaturesState;
34 export const featuresReducer = createSlice({
35     name: 'features',
36     initialState,
37     reducers: {
38         'fetch/fulfilled': (state, action: PayloadAction<{ codes: FeatureCode[]; features: FeaturesState }>) => {
39             return {
40                 ...state,
41                 ...action.payload.features,
42             };
43         },
44         'update/pending': (state, action: PayloadAction<{ code: FeatureCode; featureValue: any }>) => {
45             const old = state[action.payload.code];
46             if (old) {
47                 state[action.payload.code] = {
48                     ...old,
49                     value: {
50                         ...old.value,
51                         Value: action.payload.featureValue,
52                     },
53                 };
54             }
55         },
56         'update/fulfilled': (state, action: PayloadAction<{ code: FeatureCode; feature: FeatureState }>) => {
57             const old = state[action.payload.code];
58             state[action.payload.code] = {
59                 value: {
60                     ...old?.value,
61                     ...action.payload.feature.value,
62                 },
63                 meta: action.payload.feature.meta,
64             };
65         },
66         'update/rejected': (state, action: PayloadAction<{ code: FeatureCode; feature: FeatureState | undefined }>) => {
67             state[action.payload.code] = action.payload.feature;
68         },
69     },
70 });
72 export const isValidFeature = (
73     featureState: FeatureState | undefined,
74     cache?: CacheType
75 ): featureState is FeatureState => {
76     if (cache === CacheType.None) {
77         return false;
78     }
79     if (
80         featureState?.value !== undefined &&
81         (cache === CacheType.Stale || isNotStale(featureState.meta.fetchedAt, HOUR * 2))
82     ) {
83         return true;
84     }
85     return false;
88 type ThunkResult<Key extends FeatureCode> = { [key in Key]: Feature };
90 const defaultFeature = {} as Feature;
92 export const fetchFeatures = <T extends FeatureCode>(
93     codes: T[],
94     options?: {
95         cache: CacheType;
96     }
97 ): ThunkAction<
98     Promise<ThunkResult<T>>,
99     FeaturesReducerState,
100     ThunkArguments,
101     ReturnType<(typeof featuresReducer.actions)['fetch/fulfilled']>
102 > => {
103     return async (dispatch, getState, extraArgument) => {
104         const featuresState = selectFeatures(getState());
106         const codesToFetch = codes.filter((code) => {
107             if (codePromiseCache[code] !== undefined) {
108                 return false;
109             }
110             return !isValidFeature(featuresState[code], options?.cache);
111         });
113         if (codesToFetch.length) {
114             const promise = getSilentApi(extraArgument.api)<{
115                 Features: Feature[];
116             }>(getFeatures(codesToFetch)).then(({ Features }) => {
117                 const fetchedAt = getFetchedAt();
118                 const fetchedEphemeral = getFetchedEphemeral();
119                 const result = Features.reduce<FeaturesState>(
120                     (acc, Feature) => {
121                         acc[Feature.Code as FeatureCode] = { value: Feature, meta: { fetchedAt, fetchedEphemeral } };
122                         return acc;
123                     },
124                     codesToFetch.reduce<FeaturesState>((acc, code) => {
125                         acc[code] = { value: defaultFeature, meta: { fetchedAt, fetchedEphemeral } };
126                         return acc;
127                     }, {})
128                 );
129                 dispatch(featuresReducer.actions['fetch/fulfilled']({ codes: codesToFetch, features: result }));
130                 return result;
131             });
133             codesToFetch.forEach((code) => {
134                 codePromiseCache[code] = promise;
136                 promise
137                     .finally(() => {
138                         delete codePromiseCache[code];
139                     })
140                     .catch(() => {});
141             });
142         }
144         const promises = unique(codes.map((code) => codePromiseCache[code]).filter(Boolean));
145         if (promises.length) {
146             await Promise.all(promises);
147         }
149         const latestFeaturesState = selectFeatures(getState());
150         return Object.fromEntries(
151             codes.map((code) => {
152                 return [code, latestFeaturesState[code]?.value || featuresState[code]?.value || defaultFeature];
153             })
154         ) as ThunkResult<T>;
155     };
158 export const updateFeature = (
159     code: FeatureCode,
160     value: any
161 ): ThunkAction<
162     Promise<Feature>,
163     FeaturesReducerState,
164     ThunkArguments,
165     | ReturnType<(typeof featuresReducer.actions)['update/pending']>
166     | ReturnType<(typeof featuresReducer.actions)['update/fulfilled']>
167     | ReturnType<(typeof featuresReducer.actions)['update/rejected']>
168 > => {
169     return async (dispatch, getState, extraArgument) => {
170         const current = selectFeatures(getState())[code];
171         try {
172             dispatch(
173                 featuresReducer.actions['update/pending']({
174                     code,
175                     featureValue: value,
176                 })
177             );
178             const { Feature } = await getSilentApi(extraArgument.api)<{
179                 Feature: Feature;
180             }>(updateFeatureValue(code, value));
181             dispatch(
182                 featuresReducer.actions['update/fulfilled']({
183                     code,
184                     feature: {
185                         value: Feature,
186                         meta: { fetchedAt: getFetchedAt(), fetchedEphemeral: getFetchedEphemeral() },
187                     },
188                 })
189             );
190             return value;
191         } catch (error) {
192             dispatch(featuresReducer.actions['update/rejected']({ code, feature: current }));
193             throw error;
194         }
195     };