diff --git a/template/.env.template b/template/.env.template index 21a1f86..a6636a4 100644 --- a/template/.env.template +++ b/template/.env.template @@ -5,3 +5,5 @@ KILLSWITCH_API_KEY_IOS= KILLSWITCH_API_URL=https://killswitch.mirego.com SECRET_PANEL_ENABLED=true + +STORAGE_KEY_PREFIX=ProjectName diff --git a/template/__mocks__/react-native-mmkv.ts b/template/__mocks__/react-native-mmkv.ts new file mode 100644 index 0000000..ba47cec --- /dev/null +++ b/template/__mocks__/react-native-mmkv.ts @@ -0,0 +1,9 @@ +export class MMKV { + constructor() {} + getString() {} + set() {} + delete() {} + clearAll() {} + contains() {} + addOnValueChangedListener() {} +} diff --git a/template/package.json b/template/package.json index 41534b5..bcd0df6 100644 --- a/template/package.json +++ b/template/package.json @@ -25,6 +25,7 @@ "axios": "^1.6.2", "babel-plugin-module-resolver": "^5.0.0", "i18next": "^23.7.7", + "lodash": "^4.17.21", "lottie-react-native": "^6.4.1", "nanoid": "^5.0.4", "react": "18.2.0", @@ -64,6 +65,7 @@ "@testing-library/react-native": "^12.4.1", "@tsconfig/react-native": "^3.0.2", "@types/jest": "^29.5.5", + "@types/lodash": "^4.17.9", "@types/react": "^18.2.6", "@types/react-native": "^0.72.2", "@types/react-test-renderer": "^18.0.0", diff --git a/template/src/hooks/use-new-feature.ts b/template/src/hooks/use-new-feature.ts new file mode 100644 index 0000000..47334ad --- /dev/null +++ b/template/src/hooks/use-new-feature.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useService } from './use-service'; +import NewFeatureService, { Feature } from '~/services/new-features'; + +type UseNewFeatureTuple = [isNew: boolean, markSeen: () => void]; + +export function useNewFeature(feature: Feature): UseNewFeatureTuple { + const newFeatureService = useService(NewFeatureService); + const [isNew, setIsNew] = useState(() => + newFeatureService.getFeatureStatus(feature) + ); + + const markSeen = useCallback(() => { + newFeatureService.markFeatureAsSeen(feature); + }, [feature, newFeatureService]); + + useEffect(() => { + const unsubscribe = newFeatureService.subscribe(feature, (value) => { + setIsNew(value); + }); + + return () => { + unsubscribe(); + }; + }, [feature, newFeatureService]); + + return [isNew, markSeen]; +} diff --git a/template/src/screens/Home.tsx b/template/src/screens/Home.tsx index 3631a99..a026b9f 100644 --- a/template/src/screens/Home.tsx +++ b/template/src/screens/Home.tsx @@ -11,6 +11,7 @@ import Geolocation, { } from '~/services/geolocation'; import { useApplicationConfiguration } from '~/hooks/use-application-configuration'; import { useRemoteConfig } from '~/hooks/use-remote-config'; +import { useNewFeature } from '~/hooks/use-new-feature'; export type HomeScreenProps = RootStackScreenProps<'Home'>; @@ -53,6 +54,7 @@ export function HomeScreen({ navigation }: HomeScreenProps) { const [error, setError] = useState(null); const someFeatureFlag = useRemoteConfig('some_feature_flag'); + const [isFeatureNew, markSeen] = useNewFeature('new_feature_example'); if (error) { throw error; @@ -100,6 +102,13 @@ export function HomeScreen({ navigation }: HomeScreenProps) { {someFeatureFlag && Feature flag example} + + {isFeatureNew && ( + + This feature is new + + + )} ); diff --git a/template/src/services/application-configuration.ts b/template/src/services/application-configuration.ts index 57ccca7..5897418 100644 --- a/template/src/services/application-configuration.ts +++ b/template/src/services/application-configuration.ts @@ -15,6 +15,7 @@ export default class ApplicationConfiguration extends Storage(null); + + private readonly features = Object.keys(FEATURES).reduce( + (features, feature) => { + features[feature as Feature] = new BehaviorSubject(false); + return features; + }, + {} as Record> + ); + + constructor(private storage: Storage) { + this.init(); + } + + private init() { + this.restore(); + } + + private getFirstOpenedAppVersion() { + const firstOpenedAppVersion = this.storage.getItem( + FIRST_OPENED_APP_VERSION_STORAGE_KEY + ); + + if (!firstOpenedAppVersion) { + this.storage.setItem( + FIRST_OPENED_APP_VERSION_STORAGE_KEY, + this.currentAppVersion + ); + return this.currentAppVersion; + } + + return firstOpenedAppVersion; + } + + private async restore() { + const features = Object.keys(FEATURES) as Feature[]; + const allFeatureKeys = features.map((feature) => + this.getStorageKey(feature) + ); + const allFeatureStatuses = allFeatureKeys.map((key) => [ + key, + this.storage.getItem(key), + ]); + const firstOpenedAppVersion = this.getFirstOpenedAppVersion(); + + allFeatureStatuses.forEach(([_featureStorageKey, value], index) => { + const feature = features[index]; + const parsedValue = value && JSON.parse(value); + + if (typeof parsedValue === 'boolean') { + this.features[feature].next(parsedValue); + return; + } + + const range = FEATURES[feature]; + const isNew = semver.satisfies(firstOpenedAppVersion, range); + this.features[feature].next(isNew); + }); + } + + private getStorageKey(feature: Feature) { + return createStorageKey(`new-feature-${feature}`); + } + + getFeatureStatus(feature: Feature): boolean { + return this.features[feature].getValue(); + } + + markFeatureAsSeen(feature: Feature) { + this.features[feature].next(false); + const key = this.getStorageKey(feature); + this.storage.setItem(key, false); + } + + subscribe(feature: Feature, callback: (isNew: boolean) => void): () => void { + const subscription = this.features[feature].subscribe((isNew) => { + callback(isNew); + }); + + return () => { + subscription.unsubscribe(); + }; + } +} diff --git a/template/src/services/storage.ts b/template/src/services/storage.ts index a4ad3b0..45e6f1d 100644 --- a/template/src/services/storage.ts +++ b/template/src/services/storage.ts @@ -2,17 +2,17 @@ import { MMKV } from 'react-native-mmkv'; import { singleton } from 'tsyringe'; import EventEmitter from '~/services/event-emitter'; -export interface StorageSubscriptionParams { +export interface StorageSubscriptionParams { key: KeyType; value: any; } -export type StorageSubscription = ( +export type StorageSubscription = ( params: StorageSubscriptionParams ) => void; @singleton() -export default class Storage { +export default class Storage { constructor( private eventEmitter: EventEmitter< 'storage', diff --git a/template/src/types/react-native-config.d.ts b/template/src/types/react-native-config.d.ts index 70730b3..5548fca 100644 --- a/template/src/types/react-native-config.d.ts +++ b/template/src/types/react-native-config.d.ts @@ -7,6 +7,8 @@ declare module 'react-native-config' { KILLSWITCH_API_URL?: string; SECRET_PANEL_ENABLED?: string; + + STORAGE_KEY_PREFIX?: string; } export const Config: NativeConfig; diff --git a/template/src/utils/create-storage-key.ts b/template/src/utils/create-storage-key.ts new file mode 100644 index 0000000..c723e3b --- /dev/null +++ b/template/src/utils/create-storage-key.ts @@ -0,0 +1,8 @@ +import { snakeCase } from 'lodash'; +import Config from 'react-native-config'; + +const PREFIX = Config.STORAGE_KEY_PREFIX; + +export function createStorageKey(key: string) { + return `${PREFIX}_${snakeCase(key)}`.toUpperCase(); +} diff --git a/template/yarn.lock b/template/yarn.lock index 5f380c8..3f22b73 100644 --- a/template/yarn.lock +++ b/template/yarn.lock @@ -2356,6 +2356,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash@^4.17.9": + version "4.17.9" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.9.tgz#0dc4902c229f6b8e2ac5456522104d7b1a230290" + integrity sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w== + "@types/node@*": version "20.10.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.3.tgz#4900adcc7fc189d5af5bb41da8f543cea6962030"