Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add NewFeatures service #66

Merged
merged 1 commit into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading