diff --git a/template/.eslintrc.js b/template/.eslintrc.js index aaf3909..0fa9472 100644 --- a/template/.eslintrc.js +++ b/template/.eslintrc.js @@ -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 }, }; diff --git a/template/jest-setup.js b/template/jest-setup.js index 0ea28c7..c4e3ead 100644 --- a/template/jest-setup.js +++ b/template/jest-setup.js @@ -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(), + })), + }), +})); diff --git a/template/package.json b/template/package.json index 95d42d4..41534b5 100644 --- a/template/package.json +++ b/template/package.json @@ -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", @@ -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": { diff --git a/template/src/hooks/use-remote-config.ts b/template/src/hooks/use-remote-config.ts new file mode 100644 index 0000000..6e45ac6 --- /dev/null +++ b/template/src/hooks/use-remote-config.ts @@ -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( + 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; +} diff --git a/template/src/hooks/use-service.ts b/template/src/hooks/use-service.ts index 861ce0b..30f8b69 100644 --- a/template/src/hooks/use-service.ts +++ b/template/src/hooks/use-service.ts @@ -1,5 +1,11 @@ +import { useMemo } from 'react'; import { InjectionToken, container } from 'tsyringe'; export function useService(injectionToken: InjectionToken) { - return container.resolve(injectionToken); + const service = useMemo( + () => container.resolve(injectionToken), + [injectionToken] + ); + + return service; } diff --git a/template/src/screens/Home.tsx b/template/src/screens/Home.tsx index 14c88e3..3631a99 100644 --- a/template/src/screens/Home.tsx +++ b/template/src/screens/Home.tsx @@ -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'>; @@ -51,6 +52,8 @@ export function HomeScreen({ navigation }: HomeScreenProps) { const [error, setError] = useState(null); + const someFeatureFlag = useRemoteConfig('some_feature_flag'); + if (error) { throw error; } @@ -95,6 +98,8 @@ export function HomeScreen({ navigation }: HomeScreenProps) { )} + + {someFeatureFlag && Feature flag example} ); diff --git a/template/src/services/remote-config.ts b/template/src/services/remote-config.ts new file mode 100644 index 0000000..2f7c3e1 --- /dev/null +++ b/template/src/services/remote-config.ts @@ -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; +}; + +export type RemoteConfigSubscribeCallback = ( + 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 = this.configs[key]; + const value = this.getRemoteValue(key); + + if ($config.getValue() !== value) { + $config.next(value); + } + } + + private getRemoteValue(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(key: K): RemoteConfig[K] { + return this.configs[key].getValue(); + } + + subscribe( + key: K, + callback: RemoteConfigSubscribeCallback + ): RemoteConfigUnsubscribe { + const subscription = this.configs[key].subscribe((value) => { + callback(value); + }); + + return () => { + subscription.unsubscribe(); + }; + } + + destroy() { + this.removeOnConfigUpdated(); + } +} diff --git a/template/src/utils/Duration.ts b/template/src/utils/Duration.ts new file mode 100644 index 0000000..1fd6c90 --- /dev/null +++ b/template/src/utils/Duration.ts @@ -0,0 +1,135 @@ +export type DurationUnit = + | 'nanoseconds' + | 'microseconds' + | 'milliseconds' + | 'seconds' + | 'minutes' + | 'hours' + | 'days'; + +export type DurationComponents = Partial>; + +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 = { + 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]); + } +} diff --git a/template/yarn.lock b/template/yarn.lock index 4d9d11d..5f380c8 100644 --- a/template/yarn.lock +++ b/template/yarn.lock @@ -1947,6 +1947,11 @@ resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.2.0.tgz#2f0808ee8559905beed631bc79abd9d88fd9e8b7" integrity sha512-Zpq8/BTBUUI4kito41VUtn/dGQZU1/SCNHvPZkjlUo41Mec38z13ntxeaIC+2LP5Dw0xKpZ/Lql47BZeXk+Ocg== +"@react-native-firebase/remote-config@^21.0.0": + version "21.0.0" + resolved "https://registry.yarnpkg.com/@react-native-firebase/remote-config/-/remote-config-21.0.0.tgz#d2881bd54c28ac107fbffe1be638e5a3c16c4bb8" + integrity sha512-B/u87cXFS0XoM67LJVSnl3F8m/SL4J7F53Tm/PqFHEDsuhMdgWBBjN/bpdwSxkj4Fz16DGK4wZSpvfmZTpTNDA== + "@react-native/assets-registry@0.73.1": version "0.73.1" resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.73.1.tgz#e2a6b73b16c183a270f338dc69c36039b3946e85" @@ -6970,6 +6975,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@^7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" @@ -7538,6 +7550,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.1, tslib@^2.6 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.1.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"