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/package.json b/template/package.json index 95d42d4..a0b5896 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": { @@ -78,5 +80,6 @@ "react-native-version": "^4.0.0", "react-test-renderer": "18.2.0", "typescript": "5.0.4" - } + }, + "packageManager": "yarn@3.6.4" } 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/services/remote-config.ts b/template/src/services/remote-config.ts new file mode 100644 index 0000000..c5438ce --- /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 async 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]); + } +}