Skip to content

Commit

Permalink
Add NewFeatures service (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
Madumo authored Oct 2, 2024
1 parent b5006d1 commit 8443478
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 3 deletions.
2 changes: 2 additions & 0 deletions template/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ KILLSWITCH_API_KEY_IOS=
KILLSWITCH_API_URL=https://killswitch.mirego.com

SECRET_PANEL_ENABLED=true

STORAGE_KEY_PREFIX=ProjectName
9 changes: 9 additions & 0 deletions template/__mocks__/react-native-mmkv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class MMKV {
constructor() {}
getString() {}
set() {}
delete() {}
clearAll() {}
contains() {}
addOnValueChangedListener() {}
}
2 changes: 2 additions & 0 deletions template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions template/src/hooks/use-new-feature.ts
Original file line number Diff line number Diff line change
@@ -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];
}
9 changes: 9 additions & 0 deletions template/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;

Expand Down Expand Up @@ -53,6 +54,7 @@ export function HomeScreen({ navigation }: HomeScreenProps) {
const [error, setError] = useState<Error | null>(null);

const someFeatureFlag = useRemoteConfig('some_feature_flag');
const [isFeatureNew, markSeen] = useNewFeature('new_feature_example');

if (error) {
throw error;
Expand Down Expand Up @@ -100,6 +102,13 @@ export function HomeScreen({ navigation }: HomeScreenProps) {
<Button onPress={() => toggleLanguages()}>Toggle languages</Button>

{someFeatureFlag && <Text>Feature flag example</Text>}

{isFeatureNew && (
<Flex>
<Text>This feature is new</Text>
<Button onPress={() => markSeen()}>OK</Button>
</Flex>
)}
</Flex>
</SafeAreaView>
);
Expand Down
1 change: 1 addition & 0 deletions template/src/services/application-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class ApplicationConfiguration extends Storage<ApplicationConfigu
KILLSWITCH_API_KEY_IOS: Config.KILLSWITCH_API_KEY_IOS!,
KILLSWITCH_API_URL: Config.KILLSWITCH_API_URL!,
SECRET_PANEL_ENABLED: this.asBoolean(Config.SECRET_PANEL_ENABLED!),
STORAGE_KEY_PREFIX: Config.STORAGE_KEY_PREFIX!,
};

getItem(key: ApplicationConfigurationKey) {
Expand Down
114 changes: 114 additions & 0 deletions template/src/services/new-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import DeviceInfo from 'react-native-device-info';
import { BehaviorSubject } from 'rxjs';
import semver from 'semver';
import { autoInjectable, singleton } from 'tsyringe';
import Storage from './storage';
import { createStorageKey } from '~/utils/create-storage-key';

const FIRST_OPENED_APP_VERSION_STORAGE_KEY = createStorageKey(
'FIRST_OPENED_APP_VERSION'
);

/**
Never remove a feature flag from here (except the boilerplate examples).
Keep them even after removing the feature so
we won't reuse those keys in the future.
The string is a semver range.
When the stored first opened app version satisfies that range,
the feature is considered new for that user.
*/
const FEATURES = {
new_feature_example: '<=1.2.x',
};

export type Feature = keyof typeof FEATURES;

@singleton()
@autoInjectable()
export default class NewFeaturesService {
currentAppVersion = DeviceInfo.getVersion();
firstOpenedAppVersion = new BehaviorSubject<string | null>(null);

private readonly features = Object.keys(FEATURES).reduce(
(features, feature) => {
features[feature as Feature] = new BehaviorSubject(false);
return features;
},
{} as Record<Feature, BehaviorSubject<boolean>>
);

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();
};
}
}
6 changes: 3 additions & 3 deletions template/src/services/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { MMKV } from 'react-native-mmkv';
import { singleton } from 'tsyringe';
import EventEmitter from '~/services/event-emitter';

export interface StorageSubscriptionParams<KeyType> {
export interface StorageSubscriptionParams<KeyType extends string = string> {
key: KeyType;
value: any;
}

export type StorageSubscription<KeyType> = (
export type StorageSubscription<KeyType extends string = string> = (
params: StorageSubscriptionParams<KeyType>
) => void;

@singleton()
export default class Storage<KeyType extends string> {
export default class Storage<KeyType extends string = string> {
constructor(
private eventEmitter: EventEmitter<
'storage',
Expand Down
2 changes: 2 additions & 0 deletions template/src/types/react-native-config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions template/src/utils/create-storage-key.ts
Original file line number Diff line number Diff line change
@@ -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();
}
5 changes: 5 additions & 0 deletions template/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 8443478

Please sign in to comment.