Skip to content

Commit

Permalink
Add remote config service (#65)
Browse files Browse the repository at this point in the history
  • Loading branch information
Madumo authored Oct 2, 2024
1 parent 79f0f3e commit b5006d1
Show file tree
Hide file tree
Showing 9 changed files with 348 additions and 1 deletion.
1 change: 1 addition & 0 deletions template/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ module.exports = {
'react/jsx-boolean-value': 2,
'react/react-in-jsx-scope': 0,
'react-native/no-inline-styles': 0,
'no-dupe-class-members': 0, // incompatible with typescript method overload
},
};
15 changes: 15 additions & 0 deletions template/jest-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,18 @@ jest.mock('@react-native-community/geolocation', () => {
stopObserving: jest.fn(),
};
});

jest.mock('@react-native-firebase/remote-config', () => ({
__esModule: true,
default: () => ({
setDefaults: jest.fn(() => Promise.resolve()),
onConfigUpdated: jest.fn(),
fetchAndActivate: jest.fn(() => Promise.resolve()),
setConfigSettings: jest.fn(() => Promise.resolve()),
getValue: jest.fn(() => ({
asBoolean: jest.fn(),
asNumber: jest.fn(),
asString: jest.fn(),
})),
}),
}));
2 changes: 2 additions & 0 deletions template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@klarna/react-native-vector-drawable": "^0.5.0",
"@react-native-community/geolocation": "^3.1.0",
"@react-native-community/netinfo": "^11.2.0",
"@react-native-firebase/remote-config": "^21.0.0",
"@react-navigation/native": "^6.1.9",
"@react-navigation/native-stack": "^6.9.17",
"@tanstack/react-query": "^5.12.2",
Expand Down Expand Up @@ -47,6 +48,7 @@
"react-native-splash-screen": "^3.3.0",
"react-query-kit": "^2.0.10",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"tsyringe": "^4.8.0"
},
"devDependencies": {
Expand Down
29 changes: 29 additions & 0 deletions template/src/hooks/use-remote-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { useService } from './use-service';
import RemoteConfigService, {
RemoteConfig,
RemoteConfigKey,
} from '~/services/remote-config';

export function useRemoteConfig<K extends RemoteConfigKey>(
key: K
): RemoteConfig[K] {
const remoteConfigService = useService(RemoteConfigService);
const [value, setValue] = useState(() =>
remoteConfigService.getConfigValue(key)
);

useEffect(() => {
setValue(remoteConfigService.getConfigValue(key));

const unsubscribe = remoteConfigService.subscribe(key, (newValue) =>
setValue(newValue)
);

return () => {
unsubscribe();
};
}, [key, remoteConfigService]);

return value;
}
8 changes: 7 additions & 1 deletion template/src/hooks/use-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { useMemo } from 'react';
import { InjectionToken, container } from 'tsyringe';

export function useService<T>(injectionToken: InjectionToken<T>) {
return container.resolve(injectionToken);
const service = useMemo(
() => container.resolve(injectionToken),
[injectionToken]
);

return service;
}
5 changes: 5 additions & 0 deletions template/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Geolocation, {
GeolocationStatus,
} from '~/services/geolocation';
import { useApplicationConfiguration } from '~/hooks/use-application-configuration';
import { useRemoteConfig } from '~/hooks/use-remote-config';

export type HomeScreenProps = RootStackScreenProps<'Home'>;

Expand Down Expand Up @@ -51,6 +52,8 @@ export function HomeScreen({ navigation }: HomeScreenProps) {

const [error, setError] = useState<Error | null>(null);

const someFeatureFlag = useRemoteConfig('some_feature_flag');

if (error) {
throw error;
}
Expand Down Expand Up @@ -95,6 +98,8 @@ export function HomeScreen({ navigation }: HomeScreenProps) {
)}

<Button onPress={() => toggleLanguages()}>Toggle languages</Button>

{someFeatureFlag && <Text>Feature flag example</Text>}
</Flex>
</SafeAreaView>
);
Expand Down
137 changes: 137 additions & 0 deletions template/src/services/remote-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import remoteConfig from '@react-native-firebase/remote-config';
import { BehaviorSubject } from 'rxjs';
import { singleton } from 'tsyringe';
import { Duration } from '~/utils/Duration';

export type RemoteConfig = {
some_feature_flag: boolean;
};

export type RemoteConfigValue = boolean | number | string;

export type RemoteConfigKey = keyof RemoteConfig;

export type RemoteConfigSubjects = {
[K in RemoteConfigKey]: BehaviorSubject<RemoteConfig[K]>;
};

export type RemoteConfigSubscribeCallback<K extends RemoteConfigKey> = (
value: RemoteConfig[K]
) => void;

export type RemoteConfigUnsubscribe = () => void;

export type RemoteConfigSettings = {
timeout: Duration;
cache: Duration;
};

@singleton()
export default class RemoteConfigService {
private static readonly DEFAULTS: RemoteConfig = {
some_feature_flag: true,
};

private static readonly SETTINGS: RemoteConfigSettings = {
timeout: Duration.from('seconds', 30),
cache: Duration.from('minutes', 10),
};

private readonly configs: RemoteConfigSubjects = {
some_feature_flag: new BehaviorSubject(
RemoteConfigService.DEFAULTS.some_feature_flag
),
};

constructor() {
this.init();
}

private removeOnConfigUpdated = remoteConfig().onConfigUpdated(() => {
this.updateConfigs();
});

private async init() {
await this.setConfigSettings();
await this.setDefaults();
await this.fetchAndActivate();
this.updateConfigs();
}

private fetchAndActivate() {
return remoteConfig().fetchAndActivate();
}

private setConfigSettings() {
return remoteConfig().setConfigSettings({
minimumFetchIntervalMillis:
RemoteConfigService.SETTINGS.cache.to('milliseconds'),
fetchTimeMillis: RemoteConfigService.SETTINGS.timeout.to('milliseconds'),
});
}

private setDefaults() {
return remoteConfig().setDefaults(RemoteConfigService.DEFAULTS);
}

private updateConfigs() {
Object.keys(RemoteConfigService.DEFAULTS).forEach((key) => {
this.updateConfig(key as RemoteConfigKey);
});
}

private updateConfig(key: RemoteConfigKey) {
const $config: BehaviorSubject<any> = this.configs[key];
const value = this.getRemoteValue(key);

if ($config.getValue() !== value) {
$config.next(value);
}
}

private getRemoteValue<K extends RemoteConfigKey>(key: K): RemoteConfig[K] {
switch (typeof RemoteConfigService.DEFAULTS[key]) {
case 'boolean':
return this.getRemoteBoolean(key) as any;

case 'number':
return this.getRemoteNumber(key) as any;

case 'string':
return this.getRemoteString(key) as any;
}
}

private getRemoteBoolean(key: RemoteConfigKey) {
return remoteConfig().getValue(key).asBoolean();
}

private getRemoteNumber(key: RemoteConfigKey) {
return remoteConfig().getValue(key).asNumber();
}

private getRemoteString(key: RemoteConfigKey) {
return remoteConfig().getValue(key).asString();
}

getConfigValue<K extends RemoteConfigKey>(key: K): RemoteConfig[K] {
return this.configs[key].getValue();
}

subscribe<K extends RemoteConfigKey>(
key: K,
callback: RemoteConfigSubscribeCallback<K>
): RemoteConfigUnsubscribe {
const subscription = this.configs[key].subscribe((value) => {
callback(value);
});

return () => {
subscription.unsubscribe();
};
}

destroy() {
this.removeOnConfigUpdated();
}
}
135 changes: 135 additions & 0 deletions template/src/utils/Duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
export type DurationUnit =
| 'nanoseconds'
| 'microseconds'
| 'milliseconds'
| 'seconds'
| 'minutes'
| 'hours'
| 'days';

export type DurationComponents = Partial<Record<DurationUnit, number>>;

const NANOSECONDS = 1;
const MICROSECONDS = NANOSECONDS * 1000;
const MILLISECONDS = MICROSECONDS * 1000;
const SECONDS = MILLISECONDS * 1000;
const MINUTES = SECONDS * 60;
const HOURS = MINUTES * 60;
const DAYS = HOURS * 24;

const MULTIPLIERS: Record<DurationUnit, number> = {
nanoseconds: NANOSECONDS,
microseconds: MICROSECONDS,
milliseconds: MILLISECONDS,
seconds: SECONDS,
minutes: MINUTES,
hours: HOURS,
days: DAYS,
};

const UNITS: DurationUnit[] = [
'nanoseconds',
'microseconds',
'milliseconds',
'seconds',
'minutes',
'hours',
'days',
];

export class Duration {
private constructor(private readonly _nanoseconds: number) {}

static from(components: DurationComponents): Duration;
static from(unit: DurationUnit, value: number): Duration;
static from(
componentsOrUnit: DurationComponents | DurationUnit,
value?: number
): Duration {
if (typeof componentsOrUnit === 'object') {
return Duration.fromComponents(componentsOrUnit);
}

return Duration.fromUnit(componentsOrUnit, value!);
}

private static fromComponents(components: DurationComponents) {
let nanoseconds = 0;

for (const unit of UNITS) {
const multiplier = MULTIPLIERS[unit];
const value = components[unit] ?? 0;
nanoseconds += value * multiplier;
}

return new Duration(nanoseconds);
}

private static fromUnit(unit: DurationUnit, value: number) {
return new Duration(value * MULTIPLIERS[unit]);
}

to(unit: DurationUnit) {
return this._nanoseconds / MULTIPLIERS[unit];
}

add(duration: Duration): Duration;
add(components: DurationComponents): Duration;
add(unit: DurationUnit, value: number): Duration;
add(
durationOrcomponentsOrUnit: Duration | DurationComponents | DurationUnit,
value?: number
): Duration {
if (durationOrcomponentsOrUnit instanceof Duration) {
return this.addDuration(durationOrcomponentsOrUnit);
}

if (typeof durationOrcomponentsOrUnit === 'object') {
return this.addComponents(durationOrcomponentsOrUnit);
}

return this.addUnit(durationOrcomponentsOrUnit, value!);
}

private addDuration(duration: Duration) {
return new Duration(this._nanoseconds + duration._nanoseconds);
}

private addComponents(components: DurationComponents) {
return this.addDuration(Duration.fromComponents(components));
}

private addUnit(unit: DurationUnit, value: number) {
return new Duration(this._nanoseconds + value * MULTIPLIERS[unit]);
}

sub(duration: Duration): Duration;
sub(components: DurationComponents): Duration;
sub(unit: DurationUnit, value: number): Duration;
sub(
durationOrComponentsOrUnit: Duration | DurationComponents | DurationUnit,
value?: number
): Duration {
if (durationOrComponentsOrUnit instanceof Duration) {
return this.subDuration(durationOrComponentsOrUnit);
}

if (typeof durationOrComponentsOrUnit === 'object') {
return this.subComponents(durationOrComponentsOrUnit);
}

return this.subUnit(durationOrComponentsOrUnit, value!);
}

private subDuration(duration: Duration) {
return new Duration(this._nanoseconds - duration._nanoseconds);
}

private subComponents(components: DurationComponents) {
return this.subDuration(Duration.fromComponents(components));
}

private subUnit(unit: DurationUnit, value: number) {
return new Duration(this._nanoseconds - value * MULTIPLIERS[unit]);
}
}
Loading

0 comments on commit b5006d1

Please sign in to comment.