mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 10:08:34 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
: [],
|
||||
|
||||
@@ -19,6 +19,7 @@ export const GET = async (
|
||||
}
|
||||
): Promise<Response> => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// validate using zod
|
||||
const inputValidation = ZJsSyncInput.safeParse({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
130
packages/react-native/src/lib/environment-state.ts
Normal file
130
packages/react-native/src/lib/environment-state.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
32
packages/react-native/src/lib/event-listeners.ts
Normal file
32
packages/react-native/src/lib/event-listeners.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
135
packages/react-native/src/lib/person-state.ts
Normal file
135
packages/react-native/src/lib/person-state.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user