fix: refactors react native client (#4527)

Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Anshuman Pandey
2024-12-27 18:59:31 +05:30
committed by GitHub
parent 9a4f6721e2
commit 7f3c45f85a
21 changed files with 628 additions and 337 deletions

View File

@@ -1,5 +1,5 @@
import { StatusBar } from "expo-status-bar";
import type { JSX } from "react";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, { track } from "@formbricks/react-native";
@@ -15,8 +15,8 @@ export default function App(): JSX.Element {
}
const config = {
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.EXPO_PUBLIC_API_HOST,
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string,
apiHost: process.env.EXPO_PUBLIC_API_HOST as string,
userId: "random-user-id",
attributes: {
language: "en",

View File

@@ -360,7 +360,7 @@ import Formbricks from "@formbricks/react-native";
const config = {
environmentId: "<environment-id>",
apiHost: "<api-host>",
userId: "<user-id>",
userId: "<user-id>", // optional
};
export default function App() {

View File

@@ -1,3 +1,5 @@
// Deprecated: This api route is deprecated now and will be removed in the future.
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
@@ -19,7 +21,7 @@ import {
} from "@formbricks/lib/posthogServer";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TJsRNStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
export const OPTIONS = async (): Promise<Response> => {
@@ -174,7 +176,7 @@ export const GET = async (
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state: TJsRNStateSync = {
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],

View File

@@ -19,6 +19,7 @@ export const GET = async (
}
): Promise<Response> => {
const params = await props.params;
try {
// validate using zod
const inputValidation = ZJsSyncInput.safeParse({

View File

@@ -55,6 +55,9 @@ export const fetchEnvironmentState = async (
};
};
/**
* Add a listener to check if the environment state has expired with a certain interval
*/
export const addEnvironmentStateExpiryCheckListener = (): void => {
let updateInterval = 1000 * 60; // every minute
if (typeof window !== "undefined" && environmentStateSyncIntervalId === null) {

View File

@@ -83,7 +83,6 @@ export const fetchPersonState = async (
/**
* Add a listener to check if the person state has expired with a certain interval
* @param config - The configuration for the SDK
*/
export const addPersonStateExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every 60 seconds

View File

@@ -27,8 +27,9 @@ export default function App() {
const config = {
environmentId: "your-environment-id",
apiHost: "https://app.formbricks.com",
userId: "hello-user",
userId: "hello-user", // optional
attributes: {
// optional
plan: "free",
},
};
@@ -42,6 +43,6 @@ export default function App() {
}
```
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages. Please make sure to pass a unique user identifier as `userId` to the Formbricks SDK (e.g. database id, email address).
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages.
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/react-native",
"version": "1.2.0",
"version": "1.3.0",
"license": "MIT",
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",

View File

@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
import { type TJsRNConfigInput } from "@formbricks/types/js";
import { type TJsConfigInput } from "@formbricks/types/js";
import { Logger } from "../../js-core/src/lib/logger";
import { init } from "./lib";
import { SurveyStore } from "./lib/survey-store";
import { SurveyWebView } from "./survey-web-view";
interface FormbricksProps {
initConfig: TJsRNConfigInput;
initConfig: TJsConfigInput;
}
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
@@ -22,7 +22,7 @@ export function Formbricks({ initConfig }: FormbricksProps): React.JSX.Element |
userId: initConfig.userId,
attributes: initConfig.attributes,
});
} catch (error) {
} catch {
logger.debug("Initialization failed");
}
};

View File

@@ -1,4 +1,4 @@
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import {
type InvalidCodeError,
type NetworkError,
@@ -8,9 +8,10 @@ import {
} from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { shouldDisplayBasedOnPercentage } from "../../../js-core/src/lib/utils";
import { appConfig } from "./config";
import { RNConfig } from "./config";
import { SurveyStore } from "./survey-store";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
const surveyStore = SurveyStore.getInstance();
@@ -29,10 +30,11 @@ export const triggerSurvey = (survey: TJsEnvironmentStateSurvey): void => {
export const trackAction = (name: string, alias?: string): Result<void, NetworkError> => {
const aliasName = alias ?? name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = appConfig.get().state.surveys;
const activeSurveys = appConfig.get().filteredSurveys;
if (Boolean(activeSurveys) && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
@@ -53,7 +55,9 @@ export const trackCodeAction = (
code: string
): Result<void, NetworkError> | Result<void, InvalidCodeError> => {
const {
state: { actionClasses = [] },
environmentState: {
data: { actionClasses = [] },
},
} = appConfig.get();
const codeActionClasses = actionClasses.filter((action) => action.type === "code");

View File

@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- could be undefined */
import { FormbricksAPI } from "@formbricks/api";
import type { TAttributes } from "@formbricks/types/attributes";
import { type Result, err, ok } from "@formbricks/types/error-handlers";
import type { NetworkError } from "@formbricks/types/errors";
import type { ForbiddenError, NetworkError } from "@formbricks/types/errors";
import { type Result, err, ok } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { appConfig } from "./config";
const logger = Logger.getInstance();
@@ -13,32 +12,17 @@ export const updateAttributes = async (
environmentId: string,
userId: string,
attributes: TAttributes
): Promise<Result<TAttributes, NetworkError>> => {
): Promise<Result<TAttributes, NetworkError | ForbiddenError>> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
try {
const existingAttributes = appConfig.get()?.state?.attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- required
delete updatedAttributes[key];
}
}
}
} catch (e) {
logger.debug("config not set; sending all attributes to backend");
}
// send to backend if updatedAttributes is not empty
if (Object.keys(updatedAttributes).length === 0) {
logger.debug("No attributes to update. Skipping update.");
return ok(updatedAttributes);
}
logger.debug(`Updating attributes: ${JSON.stringify(updatedAttributes)}`);
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
const api = new FormbricksAPI({
apiHost,
@@ -48,19 +32,25 @@ export const updateAttributes = async (
const res = await api.client.attribute.update({ userId, attributes: updatedAttributes });
if (res.ok) {
if (res.data.details) {
Object.entries(res.data.details).forEach(([key, value]) => {
logger.debug(`${key}: ${value}`);
});
}
return ok(updatedAttributes);
}
// @ts-expect-error -- details is not defined in the error type
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- required
// @ts-expect-error -- details could be defined and present
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- details could be defined and present
if (res.error.details?.ignore) {
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
return ok(updatedAttributes);
}
return err({
code: "network_error",
status: 500,
code: (res.error as ForbiddenError).code ?? "network_error",
status: (res.error as NetworkError | ForbiddenError).status ?? 500,
message: `Error updating person with userId ${userId}`,
url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
responseMessage: res.error.message,

View File

@@ -1,12 +1,13 @@
/* eslint-disable no-console -- Required for error logging */
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import type { TJsRNConfig, TJsRNConfigUpdateInput } from "@formbricks/types/js";
import type { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/lib/constants";
export class RNConfig {
private static instance: RNConfig | undefined;
private config: TJsRNConfig | null = null;
private static instance: RNConfig | null = null;
private config: TJsConfig | null = null;
private constructor() {
this.loadFromStorage()
@@ -24,42 +25,48 @@ export class RNConfig {
if (!RNConfig.instance) {
RNConfig.instance = new RNConfig();
}
return RNConfig.instance;
}
public update(newConfig: TJsRNConfigUpdateInput): void {
public update(newConfig: TJsConfigUpdateInput): void {
this.config = {
...this.config,
...newConfig,
status: newConfig.status ?? "success",
status: {
value: newConfig.status?.value ?? "success",
expiresAt: newConfig.status?.expiresAt ?? null,
},
};
void this.saveToStorage();
}
public get(): TJsRNConfig {
public get(): TJsConfig {
if (!this.config) {
throw new Error("config is null, maybe the init function was not called?");
}
return this.config;
}
public async loadFromStorage(): Promise<Result<TJsRNConfig>> {
public async loadFromStorage(): Promise<Result<TJsConfig>> {
try {
// const savedConfig = await this.storageHandler.getItem(this.storageKey);
const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig) as TJsRNConfig;
const parsedConfig = JSON.parse(savedConfig) as TJsConfig;
// check if the config has expired
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set
if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) {
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set
parsedConfig.environmentState.expiresAt &&
new Date(parsedConfig.environmentState.expiresAt) <= new Date()
) {
return err(new Error("Config in local storage has expired"));
}
return ok(parsedConfig);
}
} catch (e) {
} catch {
return err(new Error("No or invalid config in local storage"));
}
@@ -73,7 +80,6 @@ export class RNConfig {
}
// reset the config
public async resetConfig(): Promise<Result<void>> {
this.config = null;
@@ -82,5 +88,3 @@ export class RNConfig {
})();
}
}
export const appConfig = RNConfig.getInstance();

View File

@@ -0,0 +1,130 @@
/* eslint-disable no-console -- logging required for error logging */
// shared functions for environment and person state(s)
import { type TJsEnvironmentState, type TJsEnvironmentSyncParams } from "@formbricks/types/js";
import { err } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { filterSurveys } from "../../../js-core/src/lib/utils";
import { RNConfig } from "./config";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
let environmentStateSyncIntervalId: number | null = null;
/**
* Fetch the environment state from the backend
* @param apiHost - The API host
* @param environmentId - The environment ID
* @param noCache - Whether to skip the cache
* @returns The environment state
* @throws NetworkError
*/
export const fetchEnvironmentState = async (
{ apiHost, environmentId }: TJsEnvironmentSyncParams,
noCache = false
): Promise<TJsEnvironmentState> => {
const url = `${apiHost}/api/v1/client/${environmentId}/environment`;
try {
const fetchOptions: RequestInit = {};
if (noCache) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = (await response.json()) as { message: string };
const error = err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url: new URL(url),
responseMessage: jsonRes.message,
});
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
throw error.error;
}
const data = (await response.json()) as { data: TJsEnvironmentState["data"] };
const { data: state } = data;
return {
data: { ...state },
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
};
} catch (e: unknown) {
const errorTyped = e as { message?: string };
const error = err({
code: "network_error",
message: errorTyped.message ?? "Error fetching the environment state",
status: 500,
url: new URL(url),
responseMessage: errorTyped.message ?? "Unknown error",
});
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
throw error.error;
}
};
/**
* Add a listener to check if the environment state has expired with a certain interval
*/
export const addEnvironmentStateExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every minute
if (environmentStateSyncIntervalId === null) {
const intervalHandler = async (): Promise<void> => {
const expiresAt = appConfig.get().environmentState.expiresAt;
try {
// check if the environmentState has not expired yet
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null
if (expiresAt && new Date(expiresAt) >= new Date()) {
return;
}
logger.debug("Environment State has expired. Starting sync.");
const personState = appConfig.get().personState;
const environmentState = await fetchEnvironmentState(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
},
true
);
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
...appConfig.get(),
environmentState,
filteredSurveys,
});
} catch (e) {
console.error(`Error during expiry check: ${e as string}`);
logger.debug("Extending config and try again later.");
const existingConfig = appConfig.get();
appConfig.update(existingConfig);
}
};
environmentStateSyncIntervalId = setInterval(
() => void intervalHandler(),
updateInterval
) as unknown as number;
}
};
export const clearEnvironmentStateExpiryCheckListener = (): void => {
if (environmentStateSyncIntervalId) {
clearInterval(environmentStateSyncIntervalId);
environmentStateSyncIntervalId = null;
}
};

View File

@@ -0,0 +1,32 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
} from "./environment-state";
import { addPersonStateExpiryCheckListener, clearPersonStateExpiryCheckListener } from "./person-state";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (): void => {
addEnvironmentStateExpiryCheckListener();
addPersonStateExpiryCheckListener();
};
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
clearPersonStateExpiryCheckListener();
areRemoveEventListenersAdded = true;
};
export const removeCleanupEventListeners = (): void => {
if (!areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
clearPersonStateExpiryCheckListener();
areRemoveEventListenersAdded = false;
};
export const removeAllEventListeners = (): void => {
clearEnvironmentStateExpiryCheckListener();
clearPersonStateExpiryCheckListener();
removeCleanupEventListeners();
};

View File

@@ -1,4 +1,4 @@
import { type TJsRNConfigInput } from "@formbricks/types/js";
import { type TJsConfigInput } from "@formbricks/types/js";
import { ErrorHandler } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { trackCodeAction } from "./actions";
@@ -9,7 +9,7 @@ const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
export const init = async (initConfig: TJsRNConfigInput): Promise<void> => {
export const init = async (initConfig: TJsConfigInput): Promise<void> => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(initialize, false, initConfig);
await queue.wait();

View File

@@ -1,5 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type TAttributes } from "@formbricks/types/attributes";
import { type TJsRNConfig, type TJsRNConfigInput } from "@formbricks/types/js";
import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/lib/constants";
import {
ErrorHandler,
type MissingFieldError,
@@ -11,12 +14,16 @@ import {
okVoid,
} from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { filterSurveys } from "../../../js-core/src/lib/utils";
import { trackAction } from "./actions";
import { updateAttributes } from "./attributes";
import { appConfig } from "./config";
import { sync } from "./sync";
import { RNConfig } from "./config";
import { fetchEnvironmentState } from "./environment-state";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./event-listeners";
import { DEFAULT_PERSON_STATE_NO_USER_ID, fetchPersonState } from "./person-state";
let isInitialized = false;
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
export const setIsInitialize = (state: boolean): void => {
@@ -24,18 +31,39 @@ export const setIsInitialize = (state: boolean): void => {
};
export const initialize = async (
c: TJsRNConfigInput
configInput: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
let existingConfig: TJsConfig | undefined;
try {
existingConfig = appConfig.get();
logger.debug("Found existing configuration.");
} catch {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status.value === "error") {
logger.debug("Formbricks was set to an error state.");
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && new Date(expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
return okVoid();
}
logger.debug("Error state is expired. Continue with initialization.");
}
ErrorHandler.getInstance().printStatus();
logger.debug("Start initialize");
if (!c.environmentId) {
if (!configInput.environmentId) {
logger.debug("No environmentId provided");
return err({
code: "missing_field",
@@ -43,7 +71,7 @@ export const initialize = async (
});
}
if (!c.apiHost) {
if (!configInput.apiHost) {
logger.debug("No apiHost provided");
return err({
@@ -52,84 +80,149 @@ export const initialize = async (
});
}
let existingConfig: TJsRNConfig | undefined;
try {
existingConfig = appConfig.get();
} catch (e) {
logger.debug("No existing configuration found.");
}
if (
existingConfig?.state &&
existingConfig.environmentId === c.environmentId &&
existingConfig.apiHost === c.apiHost &&
existingConfig.userId === c.userId &&
Boolean(existingConfig.expiresAt) // only accept config when they follow new config version with expiresAt
existingConfig?.environmentState &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.apiHost === configInput.apiHost
) {
logger.debug("Found existing configuration.");
if (existingConfig.expiresAt < new Date()) {
logger.debug("Configuration expired.");
logger.debug("Configuration fits init parameters.");
let isEnvironmentStateExpired = false;
let isPersonStateExpired = false;
await sync(
{
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
},
appConfig,
true
);
} else {
logger.debug("Configuration not expired. Extending expiration.");
appConfig.update(existingConfig);
if (new Date(existingConfig.environmentState.expiresAt) < new Date()) {
logger.debug("Environment state expired. Syncing.");
isEnvironmentStateExpired = true;
}
if (
configInput.userId &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- personState could be null
(existingConfig.personState === null ||
(existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()))
) {
logger.debug("Person state needs syncing - either null or expired");
isPersonStateExpired = true;
}
try {
// fetch the environment state (if expired)
const environmentState = isEnvironmentStateExpired
? await fetchEnvironmentState({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
})
: existingConfig.environmentState;
// fetch the person state (if expired)
let { personState } = existingConfig;
if (isPersonStateExpired) {
if (configInput.userId) {
personState = await fetchPersonState({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
});
} else {
personState = DEFAULT_PERSON_STATE_NO_USER_ID;
}
}
// filter the environment state wrt the person state
const filteredSurveys = filterSurveys(environmentState, personState);
// update the appConfig with the new filtered surveys
appConfig.update({
...existingConfig,
environmentState,
personState,
filteredSurveys,
attributes: configInput.attributes ?? {},
});
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
} catch {
logger.debug("Error during sync. Please try again.");
}
} else {
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug("No valid configuration found. Resetting config and creating new one.");
void appConfig.resetConfig();
logger.debug("Syncing.");
await sync(
{
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
},
appConfig,
true
);
try {
const environmentState = await fetchEnvironmentState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
},
false
);
const personState = configInput.userId
? await fetchPersonState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
},
false
)
: DEFAULT_PERSON_STATE_NO_USER_ID;
const filteredSurveys = filterSurveys(environmentState, personState);
let updatedAttributes: TAttributes | null = null;
if (configInput.attributes) {
if (configInput.userId) {
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (!res.ok) {
if (res.error.code === "forbidden") {
logger.error(`Authorization error: ${res.error.responseMessage ?? ""}`);
}
return err(res.error) as unknown as Result<
void,
MissingFieldError | NetworkError | MissingPersonError
>;
}
updatedAttributes = res.value;
} else {
updatedAttributes = { ...configInput.attributes };
}
}
appConfig.update({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
personState,
environmentState,
filteredSurveys,
attributes: updatedAttributes ?? {},
});
} catch (e) {
await handleErrorOnFirstInit(e as { code: string; responseMessage: string });
}
// and track the new session event
trackAction("New Session");
}
// todo: update attributes
// update attributes in config
// if userId and attributes are available, set them in backend
let updatedAttributes: TAttributes | null = null;
if (c.userId && c.attributes) {
const res = await updateAttributes(c.apiHost, c.environmentId, c.userId, c.attributes);
if (!res.ok) {
return err(res.error) as unknown as Result<void, MissingFieldError | NetworkError | MissingPersonError>;
}
updatedAttributes = res.data;
}
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
appConfig.update({
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
state: {
...appConfig.get().state,
attributes: { ...appConfig.get().state.attributes, ...c.attributes },
},
expiresAt: appConfig.get().expiresAt,
});
}
logger.debug("Adding event listeners");
addEventListeners();
addCleanupEventListeners();
setIsInitialize(true);
logger.debug("Initialized");
// check page url if initialized after page load
return okVoid();
};
@@ -148,7 +241,34 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
export const deinitalize = async (): Promise<void> => {
logger.debug("Deinitializing");
// closeSurvey();
await appConfig.resetConfig();
setIsInitialize(false);
removeAllEventListeners();
};
export const handleErrorOnFirstInit = async (e: {
code: string;
responseMessage: string;
}): Promise<never> => {
if (e.code === "forbidden") {
logger.error(`Authorization error: ${e.responseMessage}`);
} else {
logger.error(
`Error during first initialization: ${e.code} - ${e.responseMessage}. Please try again later.`
);
}
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TJsConfig> = {
status: {
value: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
},
};
await wrapThrowsAsync(async () => {
await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig));
})();
throw new Error("Could not initialize formbricks");
};

View File

@@ -0,0 +1,135 @@
import { type TJsPersonState, type TJsPersonSyncParams } from "@formbricks/types/js";
import { err } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { RNConfig } from "./config";
const config = RNConfig.getInstance();
const logger = Logger.getInstance();
let personStateSyncIntervalId: number | null = null;
export const DEFAULT_PERSON_STATE_NO_USER_ID: TJsPersonState = {
expiresAt: null,
data: {
userId: null,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
},
} as const;
/**
* Fetch the person state from the backend
* @param apiHost - The API host
* @param environmentId - The environment ID
* @param userId - The user ID
* @param noCache - Whether to skip the cache
* @returns The person state
* @throws NetworkError
*/
export const fetchPersonState = async (
{ apiHost, environmentId, userId }: TJsPersonSyncParams,
noCache = false
): Promise<TJsPersonState> => {
const url = `${apiHost}/api/v1/client/${environmentId}/identify/contacts/${userId}`;
try {
const fetchOptions: RequestInit = {};
if (noCache) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = (await response.json()) as { code: string; message: string };
const error = err({
code: jsonRes.code === "forbidden" ? "forbidden" : "network_error",
status: response.status,
message: "Error syncing with backend",
url: new URL(url),
responseMessage: jsonRes.message,
});
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
throw error.error;
}
const data = (await response.json()) as { data: TJsPersonState["data"] };
const { data: state } = data;
const defaultPersonState: TJsPersonState = {
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
data: {
userId,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
},
};
if (!Object.keys(state).length) {
return defaultPersonState;
}
return {
data: { ...state },
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
};
} catch (e: unknown) {
const errorTyped = e as { message?: string };
const error = err({
code: "network_error",
message: errorTyped.message ?? "Error fetching the person state",
status: 500,
url: new URL(url),
responseMessage: errorTyped.message ?? "Unknown error",
});
// eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
throw error.error;
}
};
/**
* Add a listener to check if the person state has expired with a certain interval
*/
export const addPersonStateExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every 60 seconds
if (personStateSyncIntervalId === null) {
const intervalHandler = (): void => {
const userId = config.get().personState.data.userId;
if (!userId) {
return;
}
// extend the personState validity by 30 minutes:
config.update({
...config.get(),
personState: {
...config.get().personState,
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
},
});
};
personStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number;
}
};
/**
* Clear the person state expiry check listener
*/
export const clearPersonStateExpiryCheckListener = (): void => {
if (personStateSyncIntervalId) {
clearInterval(personStateSyncIntervalId);
personStateSyncIntervalId = null;
}
};

View File

@@ -1,8 +1,9 @@
import { type NetworkError, type Result, err, okVoid } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { appConfig } from "./config";
import { RNConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
export const logoutPerson = async (): Promise<void> => {
@@ -12,11 +13,12 @@ export const logoutPerson = async (): Promise<void> => {
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
const userId = appConfig.get().personState.data.userId;
const syncParams = {
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
...(userId && { userId }),
attributes: appConfig.get().attributes,
};
await logoutPerson();
try {

View File

@@ -1,129 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- required */
/* eslint-disable no-console -- required for logging */
import type { TAttributes } from "@formbricks/types/attributes";
import { type Result, err, ok } from "@formbricks/types/error-handlers";
import type { NetworkError } from "@formbricks/types/errors";
import type { TJsRNState, TJsRNStateSync, TJsRNSyncParams } from "@formbricks/types/js";
import { Logger } from "../../../js-core/src/lib/logger";
import type { RNConfig } from "./config";
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
const syncWithBackend = async (
{ apiHost, environmentId, userId }: TJsRNSyncParams,
noCache: boolean
): Promise<Result<TJsRNStateSync, NetworkError>> => {
try {
const fetchOptions: RequestInit = {};
if (noCache) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
logger.debug("syncing with backend");
const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = (await response.json()) as { message: string };
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
}) as Result<TJsRNStateSync, NetworkError>;
}
const data = (await response.json()) as { data: TJsRNStateSync };
const { data: state } = data;
return ok(state);
} catch (e) {
return err(e as NetworkError);
}
};
export const sync = async (params: TJsRNSyncParams, appConfig: RNConfig, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
if (!syncResult.ok) {
throw syncResult.error as unknown as Error;
}
const attributes: TAttributes = params.attributes ?? {};
if (syncResult.data.language) {
attributes.language = syncResult.data.language;
}
const state: TJsRNState = {
surveys: syncResult.data.surveys,
actionClasses: syncResult.data.actionClasses,
project: syncResult.data.project,
attributes,
};
const surveyNames = state.surveys.map((s) => s.name);
logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
appConfig.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
userId: params.userId,
state,
expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future
});
} catch (error) {
console.error(`Error during sync: ${error as string}`);
throw error;
}
};
export const addExpiryCheckListener = (appConfig: RNConfig): void => {
const updateInterval = 1000 * 30; // every 30 seconds
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- we want to run this function async
async () => {
try {
// check if the config has not expired yet
if (appConfig.get().expiresAt && new Date(appConfig.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
},
appConfig
);
} catch (e) {
console.error(`Error during expiry check: ${e as string}`);
logger.debug("Extending config and try again later.");
const existingConfig = appConfig.get();
appConfig.update(existingConfig);
}
},
updateInterval
);
}
};
export const removeExpiryCheckListener = (): void => {
if (typeof window !== "undefined" && syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
syncIntervalId = null;
}
};

View File

@@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-call -- required */
/* eslint-disable no-console -- debugging*/
import React, { type JSX, useEffect, useMemo, useRef, useState } from "react";
import { Modal } from "react-native";
@@ -10,16 +9,16 @@ import { SurveyState } from "@formbricks/lib/surveyState";
import { getStyling } from "@formbricks/lib/utils/styling";
import type { SurveyInlineProps } from "@formbricks/types/formbricks-surveys";
import { ZJsRNWebViewOnMessageData } from "@formbricks/types/js";
import type { TJsEnvironmentStateSurvey, TJsFileUploadParams } from "@formbricks/types/js";
import type { TJsEnvironmentStateSurvey, TJsFileUploadParams, TJsPersonState } from "@formbricks/types/js";
import type { TResponseUpdate } from "@formbricks/types/responses";
import type { TUploadFileConfig } from "@formbricks/types/storage";
import { Logger } from "../../js-core/src/lib/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/lib/utils";
import { appConfig } from "./lib/config";
import { filterSurveys, getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/lib/utils";
import { RNConfig } from "./lib/config";
import { StorageAPI } from "./lib/storage";
import { SurveyStore } from "./lib/survey-store";
import { sync } from "./lib/sync";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
logger.configure({ logLevel: "debug" });
@@ -34,15 +33,15 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
const [isSurveyRunning, setIsSurveyRunning] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);
const project = appConfig.get().state.project;
const attributes = appConfig.get().state.attributes;
const project = appConfig.get().environmentState.data.project;
const attributes = appConfig.get().attributes;
const styling = getStyling(project, survey);
const isBrandingEnabled = project.inAppSurveyBranding;
const isMultiLanguageSurvey = survey.languages.length > 1;
const [surveyState, setSurveyState] = useState(
new SurveyState(survey.id, null, null, appConfig.get().userId)
new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId)
);
const responseQueue = useMemo(
@@ -87,8 +86,9 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
}
const addResponseToQueue = (responseUpdate: TResponseUpdate): void => {
const { userId } = appConfig.get();
const { userId } = appConfig.get().personState.data;
if (userId) surveyState.updateUserId(userId);
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
@@ -100,33 +100,38 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
});
};
const onCloseSurvey = async (): Promise<void> => {
await sync(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().userId,
},
appConfig
);
const onCloseSurvey = (): void => {
const { environmentState, personState } = appConfig.get();
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
...appConfig.get(),
environmentState,
personState,
filteredSurveys,
});
surveyStore.resetSurvey();
setShowSurvey(false);
};
const createDisplay = async (surveyId: string): Promise<{ id: string }> => {
const { userId } = appConfig.get();
const { userId } = appConfig.get().personState.data;
const api = new FormbricksAPI({
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
});
const res = await api.client.display.create({
surveyId,
userId,
...(userId && { userId }),
});
if (!res.ok) {
throw new Error("Could not create display");
}
return res.data;
};
@@ -205,21 +210,67 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
if (onDisplay) {
const { id } = await createDisplay(survey.id);
surveyState.updateDisplayId(id);
const existingDisplays = appConfig.get().personState.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
const displays = [...existingDisplays, newDisplay];
const previousConfig = appConfig.get();
const updatedPersonState = {
...previousConfig.personState,
data: {
...previousConfig.personState.data,
displays,
lastDisplayAt: new Date(),
},
};
const filteredSurveys = filterSurveys(previousConfig.environmentState, updatedPersonState);
appConfig.update({
...previousConfig,
environmentState: previousConfig.environmentState,
personState: updatedPersonState,
filteredSurveys,
});
}
if (onResponse && responseUpdate) {
addResponseToQueue(responseUpdate);
const isNewResponse = surveyState.responseId === null;
if (isNewResponse) {
const responses = appConfig.get().personState.data.responses;
const newPersonState: TJsPersonState = {
...appConfig.get().personState,
data: {
...appConfig.get().personState.data,
responses: [...responses, surveyState.surveyId],
},
};
const filteredSurveys = filterSurveys(appConfig.get().environmentState, newPersonState);
appConfig.update({
...appConfig.get(),
environmentState: appConfig.get().environmentState,
personState: newPersonState,
filteredSurveys,
});
}
}
if (onClose) {
await onCloseSurvey();
onCloseSurvey();
}
if (onRetry) {
await responseQueue.processQueue();
}
if (onFinished) {
setTimeout(() => {
void (async () => {
await onCloseSurvey();
})();
onCloseSurvey();
}, 2500);
}
if (onFileUpload && fileUploadParams) {
@@ -310,7 +361,6 @@ const renderHtml = (options: Partial<SurveyInlineProps> & { apiHost?: string }):
};
function onResponse(responseUpdate) {
console.log(JSON.stringify({ onResponse: true, responseUpdate }));
window.ReactNativeWebView.postMessage(JSON.stringify({ onResponse: true, responseUpdate }));
};

View File

@@ -40,56 +40,6 @@ export const ZJsEnvironmentStateSurvey = ZSurvey.innerType()
export type TJsEnvironmentStateSurvey = z.infer<typeof ZJsEnvironmentStateSurvey>;
export const ZJsRNStateSync = z.object({
person: ZJsPerson.nullish(),
userId: z.string().optional(),
surveys: z.array(ZJsEnvironmentStateSurvey),
actionClasses: z.array(ZActionClass),
project: ZProject,
language: z.string().optional(),
});
export type TJsRNStateSync = z.infer<typeof ZJsRNStateSync>;
export const ZJsRNState = z.object({
attributes: ZAttributes,
surveys: z.array(ZJsEnvironmentStateSurvey),
actionClasses: z.array(ZActionClass),
project: ZProject,
});
export type TJsRNState = z.infer<typeof ZJsRNState>;
export const ZJsRNConfigUpdateInput = z.object({
environmentId: z.string().cuid2(),
apiHost: z.string(),
userId: z.string(),
state: ZJsRNState,
expiresAt: z.date(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsRNConfigUpdateInput = z.infer<typeof ZJsRNConfigUpdateInput>;
export const ZJsRNConfig = z.object({
environmentId: z.string().cuid(),
apiHost: z.string(),
userId: z.string(),
state: ZJsRNState,
expiresAt: z.date(),
status: z.enum(["success", "error"]).optional(),
});
export type TJsRNConfig = z.infer<typeof ZJsRNConfig>;
export const ZJsRNSyncParams = z.object({
environmentId: z.string().cuid(),
apiHost: z.string(),
userId: z.string(),
attributes: ZAttributes.optional(),
});
export type TJsRNSyncParams = z.infer<typeof ZJsRNSyncParams>;
export const ZJsEnvironmentStateActionClass = ZActionClass.pick({
id: true,
key: true,
@@ -190,9 +140,6 @@ export const ZJsConfigInput = z.object({
export type TJsConfigInput = z.infer<typeof ZJsConfigInput>;
export const ZJsRNConfigInput = ZJsConfigInput.omit({ userId: true }).extend({ userId: z.string() });
export type TJsRNConfigInput = z.infer<typeof ZJsRNConfigInput>;
export const ZJsPeopleUserIdInput = z.object({
environmentId: z.string().cuid2(),
userId: z.string().min(1).max(255),