@segment/analytics-react-native
Version:
The hassle-free way to add Segment analytics to your React-Native app.
559 lines (506 loc) • 16.5 kB
text/typescript
import {
createStore,
registerBridgeStore,
Store,
Persistor,
} from '@segment/sovran-react-native';
import deepmerge from 'deepmerge';
import type {
SegmentAPIIntegrations,
IntegrationSettings,
SegmentEvent,
DeepPartial,
Context,
UserInfoState,
RoutingRule,
DestinationFilters,
SegmentAPIConsentSettings,
EdgeFunctionSettings,
} from '..';
import { getUUID } from '../uuid';
import { createGetter } from './helpers';
import { isObject, isString } from '../util';
import type {
Storage,
StorageConfig,
DeepLinkData,
getStateFunc,
Watchable,
Settable,
Dictionary,
ReadinessStore,
Queue,
} from './types';
type Data = {
context: DeepPartial<Context>;
settings: SegmentAPIIntegrations;
consentSettings: SegmentAPIConsentSettings | undefined;
edgeFunctionSettings: EdgeFunctionSettings | undefined;
userInfo: UserInfoState;
filters: DestinationFilters;
pendingEvents: SegmentEvent[];
enabled: boolean;
};
const INITIAL_VALUES: Data = {
context: {},
settings: {},
consentSettings: undefined,
edgeFunctionSettings: undefined,
filters: {},
userInfo: {
anonymousId: getUUID(),
userId: undefined,
traits: undefined,
},
pendingEvents: [],
enabled: true,
};
const isEverythingReady = (state: ReadinessStore) =>
Object.values(state).every((v) => v === true);
/**
* Global store for deeplink information
* A single instance is needed for all SovranStorage objects since only one deeplink data exists at a time
* No need to persist this information
*/
const deepLinkStore = createStore<DeepLinkData>({
referring_application: '',
url: '',
});
/**
* Action to set the referring app and link url
* @param deepLinkData referring app and link url
*/
const addDeepLinkData = (deepLinkData: unknown) => (state: DeepLinkData) => {
if (!isObject(deepLinkData)) {
return state;
}
return {
referring_application: deepLinkData.referring_application,
url: deepLinkData.url,
} as DeepLinkData;
};
/**
* Registers the deeplink store to listen to native events
*/
registerBridgeStore({
store: deepLinkStore,
actions: {
'add-deepLink-data': addDeepLinkData,
},
});
/**
* Action to set the anonymousId from native
* @param anonymousId native anonymousId string
*/
const addAnonymousId =
(payload: unknown) => (state: { userInfo: UserInfoState }) => {
if (isObject(payload)) {
const nativeAnonymousId = payload.anonymousId;
if (isString(nativeAnonymousId)) {
return {
userInfo: {
...state.userInfo,
anonymousId: nativeAnonymousId,
},
};
}
}
return state;
};
function createStoreGetter<
U extends object,
Z extends keyof U | undefined = undefined,
V = undefined
>(store: Store<U>, key?: Z): getStateFunc<Z extends keyof U ? V : U> {
type X = Z extends keyof U ? V : U;
return createGetter(
() => {
const state = store.getState();
if (key !== undefined) {
return state[key] as unknown as X;
}
return state as X;
},
async () => {
const promise = await store.getState(true);
if (key !== undefined) {
return promise[key] as unknown as X;
}
return promise as unknown as X;
}
);
}
export class SovranStorage implements Storage {
private storeId: string;
private storePersistor?: Persistor;
private storePersistorSaveDelay?: number;
private readinessStore: Store<ReadinessStore>;
private contextStore: Store<{ context: DeepPartial<Context> }>;
private consentSettingsStore: Store<{
consentSettings: SegmentAPIConsentSettings | undefined;
}>;
private edgeFunctionSettingsStore: Store<{
edgeFunctionSettings: EdgeFunctionSettings | undefined;
}>;
private settingsStore: Store<{ settings: SegmentAPIIntegrations }>;
private userInfoStore: Store<{ userInfo: UserInfoState }>;
private deepLinkStore: Store<DeepLinkData> = deepLinkStore;
private filtersStore: Store<DestinationFilters>;
private pendingStore: Store<SegmentEvent[]>;
readonly isReady: Watchable<boolean>;
readonly context: Watchable<DeepPartial<Context> | undefined> &
Settable<DeepPartial<Context>>;
readonly settings: Watchable<SegmentAPIIntegrations | undefined> &
Settable<SegmentAPIIntegrations> &
Dictionary<string, IntegrationSettings, SegmentAPIIntegrations>;
readonly consentSettings: Watchable<SegmentAPIConsentSettings | undefined> &
Settable<SegmentAPIConsentSettings | undefined>;
readonly edgeFunctionSettings: Watchable<EdgeFunctionSettings | undefined> &
Settable<EdgeFunctionSettings | undefined>;
readonly filters: Watchable<DestinationFilters | undefined> &
Settable<DestinationFilters> &
Dictionary<string, RoutingRule, DestinationFilters>;
readonly userInfo: Watchable<UserInfoState> & Settable<UserInfoState>;
readonly deepLinkData: Watchable<DeepLinkData>;
readonly pendingEvents: Watchable<SegmentEvent[]> &
Settable<SegmentEvent[]> &
Queue<SegmentEvent, SegmentEvent[]>;
readonly enabledStore: Store<{ enabled: boolean }>;
readonly enabled: Watchable<boolean> & Settable<boolean>;
constructor(config: StorageConfig) {
this.storeId = config.storeId;
this.storePersistor = config.storePersistor;
this.storePersistorSaveDelay = config.storePersistorSaveDelay;
this.readinessStore = createStore<ReadinessStore>({
hasRestoredContext: false,
hasRestoredSettings: false,
hasRestoredUserInfo: false,
hasRestoredFilters: false,
hasRestoredPendingEvents: false,
hasRestoredEnabled: false,
});
const markAsReadyGenerator = (key: keyof ReadinessStore) => () => {
void this.readinessStore.dispatch((state) => ({
...state,
[key]: true,
}));
};
this.isReady = {
get: createGetter(
() => {
const state = this.readinessStore.getState();
return isEverythingReady(state);
},
async () => {
const promise = await this.readinessStore
.getState(true)
.then(isEverythingReady);
return promise;
}
),
onChange: (callback: (value: boolean) => void) => {
return this.readinessStore.subscribe((store) => {
if (isEverythingReady(store)) {
callback(true);
}
});
},
};
// Context Store
this.contextStore = createStore(
{ context: INITIAL_VALUES.context },
{
persist: {
storeId: `${this.storeId}-context`,
persistor: this.storePersistor,
onInitialized: markAsReadyGenerator('hasRestoredContext'),
},
}
);
this.context = {
get: createStoreGetter(this.contextStore, 'context'),
onChange: (callback: (value?: DeepPartial<Context>) => void) =>
this.contextStore.subscribe((store) => callback(store.context)),
set: async (value) => {
const { context } = await this.contextStore.dispatch((state) => {
let newState: typeof state.context;
if (value instanceof Function) {
newState = value(state.context);
} else {
newState = deepmerge(state.context, value);
}
return { context: newState };
});
return context;
},
};
// Settings Store
this.settingsStore = createStore(
{ settings: INITIAL_VALUES.settings },
{
persist: {
storeId: `${this.storeId}-settings`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredSettings'),
},
}
);
this.settings = {
get: createStoreGetter(this.settingsStore, 'settings'),
onChange: (
callback: (value?: SegmentAPIIntegrations | undefined) => void
) => this.settingsStore.subscribe((store) => callback(store.settings)),
set: async (value) => {
const { settings } = await this.settingsStore.dispatch((state) => {
let newState: typeof state.settings;
if (value instanceof Function) {
newState = value(state.settings);
} else {
newState = { ...state.settings, ...value };
}
return { settings: newState };
});
return settings;
},
add: (key: string, value: IntegrationSettings) => {
return this.settingsStore.dispatch((state) => ({
settings: { ...state.settings, [key]: value },
}));
},
};
// Consent settings
this.consentSettingsStore = createStore(
{ consentSettings: INITIAL_VALUES.consentSettings },
{
persist: {
storeId: `${this.storeId}-consentSettings`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredSettings'),
},
}
);
this.consentSettings = {
get: createStoreGetter(this.consentSettingsStore, 'consentSettings'),
onChange: (
callback: (value?: SegmentAPIConsentSettings | undefined) => void
) =>
this.consentSettingsStore.subscribe((store) =>
callback(store.consentSettings)
),
set: async (value) => {
const { consentSettings } = await this.consentSettingsStore.dispatch(
(state) => {
let newState: typeof state.consentSettings;
if (value instanceof Function) {
newState = value(state.consentSettings);
} else {
newState = Object.assign({}, state.consentSettings, value);
}
return { consentSettings: newState };
}
);
return consentSettings;
},
};
// Edge function settings
this.edgeFunctionSettingsStore = createStore(
{ edgeFunctionSettings: INITIAL_VALUES.edgeFunctionSettings },
{
persist: {
storeId: `${this.storeId}-edgeFunctionSettings`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredSettings'),
},
}
);
this.edgeFunctionSettings = {
get: createStoreGetter(
this.edgeFunctionSettingsStore,
'edgeFunctionSettings'
),
onChange: (
callback: (value?: EdgeFunctionSettings | undefined) => void
) =>
this.edgeFunctionSettingsStore.subscribe((store) =>
callback(store.edgeFunctionSettings)
),
set: async (value) => {
const { edgeFunctionSettings } =
await this.edgeFunctionSettingsStore.dispatch((state) => {
let newState: typeof state.edgeFunctionSettings;
if (value instanceof Function) {
newState = value(state.edgeFunctionSettings);
} else {
newState = Object.assign({}, state.edgeFunctionSettings, value);
}
return { edgeFunctionSettings: newState };
});
return edgeFunctionSettings;
},
};
// Filters
this.filtersStore = createStore(INITIAL_VALUES.filters, {
persist: {
storeId: `${this.storeId}-filters`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredFilters'),
},
});
this.filters = {
get: createStoreGetter(this.filtersStore),
onChange: (callback: (value?: DestinationFilters | undefined) => void) =>
this.filtersStore.subscribe((store) => callback(store)),
set: async (value) => {
const filters = await this.filtersStore.dispatch((state) => {
let newState: typeof state;
if (value instanceof Function) {
newState = value(state);
} else {
newState = { ...state, ...value };
}
return newState;
});
return filters;
},
add: (key, value) => {
return this.filtersStore.dispatch((state) => ({
...state,
[key]: value,
}));
},
};
// User Info Store
this.userInfoStore = createStore(
{ userInfo: INITIAL_VALUES.userInfo },
{
persist: {
storeId: `${this.storeId}-userInfo`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredUserInfo'),
},
}
);
this.userInfo = {
get: createStoreGetter(this.userInfoStore, 'userInfo'),
onChange: (callback: (value: UserInfoState) => void) =>
this.userInfoStore.subscribe((store) => callback(store.userInfo)),
set: async (value) => {
const { userInfo } = await this.userInfoStore.dispatch((state) => {
let newState: typeof state.userInfo;
if (value instanceof Function) {
newState = value(state.userInfo);
} else {
newState = deepmerge(state.userInfo, value);
}
return { userInfo: newState };
});
return userInfo;
},
};
// Pending Events
this.pendingStore = createStore<SegmentEvent[]>(
INITIAL_VALUES.pendingEvents,
{
persist: {
storeId: `${this.storeId}-pendingEvents`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredPendingEvents'),
},
}
);
this.pendingEvents = {
get: createStoreGetter(this.pendingStore),
onChange: (callback: (value: SegmentEvent[]) => void) =>
this.pendingStore.subscribe((store) => callback(store)),
set: async (value) => {
return await this.pendingStore.dispatch((state) => {
let newState: SegmentEvent[];
if (value instanceof Function) {
newState = value(state);
} else {
newState = [...value];
}
return newState;
});
},
add: (event: SegmentEvent) => {
return this.pendingStore.dispatch((events) => [...events, event]);
},
remove: (event: SegmentEvent) => {
return this.pendingStore.dispatch((events) =>
events.filter((e) => e.messageId != event.messageId)
);
},
};
registerBridgeStore({
store: this.userInfoStore,
actions: {
'add-anonymous-id': addAnonymousId,
},
});
this.deepLinkData = {
get: createStoreGetter(this.deepLinkStore),
onChange: (callback: (value: DeepLinkData) => void) =>
this.deepLinkStore.subscribe(callback),
};
this.enabledStore = createStore(
{ enabled: INITIAL_VALUES.enabled },
{
persist: {
storeId: `${this.storeId}-enabled`,
persistor: this.storePersistor,
saveDelay: this.storePersistorSaveDelay,
onInitialized: markAsReadyGenerator('hasRestoredEnabled'),
},
}
);
// Accessor object for enabled
this.enabled = {
get: createGetter(
() => {
const state = this.enabledStore.getState();
return state.enabled;
},
async () => {
const value = await this.enabledStore.getState(true);
return value.enabled;
}
),
onChange: (callback: (value: boolean) => void) => {
return this.enabledStore.subscribe((store) => {
callback(store.enabled);
});
},
set: async (value: boolean | ((prev: boolean) => boolean)) => {
const { enabled } = await this.enabledStore.dispatch((state) => {
const newEnabled =
value instanceof Function ? value(state.enabled) : value;
return { enabled: newEnabled };
});
return enabled;
},
};
this.fixAnonymousId();
}
/**
* This is a fix for users that have started the app with the anonymousId set to 'anonymousId' bug
*/
private fixAnonymousId = () => {
const fixUnsubscribe = this.userInfoStore.subscribe((store) => {
if (store.userInfo.anonymousId === 'anonymousId') {
void this.userInfoStore.dispatch((state) => {
return {
userInfo: { ...state.userInfo, anonymousId: getUUID() },
};
});
}
fixUnsubscribe();
});
};
}