diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 888a454..22149a2 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,4 +1,3 @@ -import { Formbricks } from "@/components/formbricks"; import { CommandQueue } from "@/lib/common/command-queue"; import { Logger } from "@/lib/common/logger"; import * as Actions from "@/lib/survey/action"; @@ -39,4 +38,4 @@ export const logout = async (): Promise => { await queue.wait(); }; -export default Formbricks; +export { Formbricks as default } from "@/components/formbricks"; diff --git a/packages/react-native/src/lib/common/file-upload.ts b/packages/react-native/src/lib/common/file-upload.ts deleted file mode 100644 index 78d4907..0000000 --- a/packages/react-native/src/lib/common/file-upload.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* eslint-disable no-console -- used for error logging */ -import { - type TUploadFileConfig, - type TUploadFileResponse, -} from "@/types/storage"; - -export class StorageAPI { - private readonly appUrl: string; - private readonly environmentId: string; - - constructor(appUrl: string, environmentId: string) { - this.appUrl = appUrl; - this.environmentId = environmentId; - } - - async uploadFile( - file: { - type: string; - name: string; - base64: string; - }, - { allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {} - ): Promise { - if (!file.name || !file.type || !file.base64) { - throw new Error(`Invalid file object`); - } - - const payload = { - fileName: file.name, - fileType: file.type, - allowedFileExtensions, - surveyId, - }; - - const response = await fetch( - `${this.appUrl}/api/v1/client/${this.environmentId}/storage`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - } - ); - - if (!response.ok) { - throw new Error(`Upload failed with status: ${String(response.status)}`); - } - - const json = (await response.json()) as TUploadFileResponse; - - const { data } = json; - - const { - signedUrl, - fileUrl, - signingData, - presignedFields, - updatedFileName, - } = data; - - let localUploadDetails: Record = {}; - - if (signingData) { - const { signature, timestamp, uuid } = signingData; - - localUploadDetails = { - fileType: file.type, - fileName: encodeURIComponent(updatedFileName), - surveyId: surveyId ?? "", - signature, - timestamp: String(timestamp), - uuid, - }; - } - - const formData: Record = {}; - const formDataForS3 = new FormData(); - - if (presignedFields) { - Object.keys(presignedFields).forEach((key) => { - formDataForS3.append(key, presignedFields[key]); - }); - - try { - const buffer = Buffer.from(file.base64.split(",")[1], "base64"); - const blob = new Blob([buffer], { type: file.type }); - - formDataForS3.append("file", blob); - } catch (buffErr) { - console.error({ buffErr }); - - throw new Error("Error uploading file"); - } - } - - formData.fileBase64String = file.base64; - - let uploadResponse: Response = {} as Response; - - const signedUrlCopy = signedUrl.replace( - "http://localhost:3000", - this.appUrl - ); - - try { - uploadResponse = await fetch(signedUrlCopy, { - method: "POST", - body: presignedFields - ? formDataForS3 - : JSON.stringify({ - ...formData, - ...localUploadDetails, - }), - }); - } catch (err) { - console.error("Error uploading file", err); - } - - if (!uploadResponse.ok) { - // if local storage is used, we'll use the json response: - if (signingData) { - const uploadJson = (await uploadResponse.json()) as { message: string }; - const error = new Error(uploadJson.message); - error.name = "FileTooLargeError"; - throw error; - } - - // if s3 is used, we'll use the text response: - const errorText = await uploadResponse.text(); - if (presignedFields && errorText.includes("EntityTooLarge")) { - const error = new Error( - "File size exceeds the size limit for your plan" - ); - error.name = "FileTooLargeError"; - throw error; - } - - throw new Error( - `Upload failed with status: ${String(uploadResponse.status)}` - ); - } - - return fileUrl; - } -} diff --git a/packages/react-native/src/lib/common/setup.ts b/packages/react-native/src/lib/common/setup.ts index 8a4d213..d1b1149 100644 --- a/packages/react-native/src/lib/common/setup.ts +++ b/packages/react-native/src/lib/common/setup.ts @@ -226,8 +226,8 @@ const shouldSyncConfig = ( ): boolean => { return Boolean( existingConfig?.environment && - existingConfig.environmentId === configInput.environmentId && - existingConfig.appUrl === configInput.appUrl + existingConfig.environmentId === configInput.environmentId && + existingConfig.appUrl === configInput.appUrl ); }; @@ -412,7 +412,7 @@ export const handleErrorOnFirstSetup = async (e: { const initialErrorConfig: Partial = { status: { value: "error", - expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + expiresAt: new Date(Date.now() + 10 * 60000), // 10 minutes in the future }, }; diff --git a/packages/react-native/src/lib/common/tests/file-upload.test.ts b/packages/react-native/src/lib/common/tests/file-upload.test.ts deleted file mode 100644 index 4000c69..0000000 --- a/packages/react-native/src/lib/common/tests/file-upload.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// file-upload.test.ts -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { StorageAPI } from "@/lib/common/file-upload"; -import type { TUploadFileConfig } from "@/types/storage"; - -// A global fetch mock so we can capture fetch calls. -// Alternatively, use `vi.stubGlobal("fetch", ...)`. -const fetchMock = vi.fn(); -global.fetch = fetchMock; - -const mockEnvironmentId = "dv46cywjt1fxkkempq7vwued"; - -describe("StorageAPI", () => { - const APP_URL = "https://myapp.example"; - const ENV_ID = mockEnvironmentId; - - let storage: StorageAPI; - - beforeEach(() => { - vi.clearAllMocks(); - storage = new StorageAPI(APP_URL, ENV_ID); - }); - - test("throws an error if file object is invalid", async () => { - // File missing "name", "type", or "base64" - await expect(storage.uploadFile({ type: "", name: "", base64: "" }, {})).rejects.toThrow( - "Invalid file object" - ); - }); - - test("throws if first fetch (storage route) returns non-OK", async () => { - // We provide a valid file object - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - - // First fetch returns not ok - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 400, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("Upload failed with status: 400"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${APP_URL}/api/v1/client/${ENV_ID}/storage`, - expect.objectContaining({ - method: "POST", - }) - ); - }); - - test("throws if second fetch returns non-OK (local storage w/ signingData)", async () => { - // Suppose the first fetch is OK and returns JSON with signingData - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://myapp.example/uploadLocal", - fileUrl: "https://myapp.example/files/test.png", - signingData: { signature: "xxx", timestamp: 1234, uuid: "abc" }, - presignedFields: null, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch fails - .mockResolvedValueOnce({ - ok: false, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { message: "File size exceeded your plan limit" }; - }, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeded your plan limit"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("throws if second fetch returns non-OK (S3) containing 'EntityTooLarge'", async () => { - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - - // First fetch response includes presignedFields => indicates S3 scenario - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://some-s3-bucket/presigned", - fileUrl: "https://some-s3-bucket/test.png", - signingData: null, // means not local - presignedFields: { - key: "some-key", - policy: "base64policy", - }, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch fails with "EntityTooLarge" - .mockResolvedValueOnce({ - ok: false, - text: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return "Some error with EntityTooLarge text in it"; - }, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeds the size limit for your plan"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("successful upload returns fileUrl", async () => { - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - const mockFileUrl = "https://myapp.example/files/test.png"; - - // First fetch => OK, returns JSON with 'signedUrl', 'fileUrl', etc. - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://myapp.example/uploadLocal", - fileUrl: mockFileUrl, - signingData: { - signature: "xxx", - timestamp: 1234, - uuid: "abc", - }, - presignedFields: null, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch => also OK - .mockResolvedValueOnce({ - ok: true, - } as Response); - - const url = await storage.uploadFile(file, { - allowedFileExtensions: [".png", ".jpg"], - surveyId: "survey_123", - } as TUploadFileConfig); - - expect(url).toBe(mockFileUrl); - expect(fetchMock).toHaveBeenCalledTimes(2); - - // We can also check the first fetch request body - const firstCall = fetchMock.mock.calls[0]; - expect(firstCall[0]).toBe(`${APP_URL}/api/v1/client/${ENV_ID}/storage`); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- we know it's a string - const bodyPayload = JSON.parse(firstCall[1].body as string); - - expect(bodyPayload).toMatchObject({ - fileName: "test.png", - fileType: "image/png", - allowedFileExtensions: [".png", ".jpg"], - surveyId: "survey_123", - }); - }); -}); diff --git a/packages/react-native/src/lib/common/utils.ts b/packages/react-native/src/lib/common/utils.ts index 04a9450..342d029 100644 --- a/packages/react-native/src/lib/common/utils.ts +++ b/packages/react-native/src/lib/common/utils.ts @@ -15,19 +15,19 @@ export const diffInDays = (date1: Date, date2: Date): number => { export const wrapThrowsAsync = (fn: (...args: A) => Promise) => - async (...args: A): Promise> => { - try { - return { - ok: true, - data: await fn(...args), - }; - } catch (error) { - return { - ok: false, - error: error as Error, - }; - } - }; + async (...args: A): Promise> => { + try { + return { + ok: true, + data: await fn(...args), + }; + } catch (error) { + return { + ok: false, + error: error as Error, + }; + } + }; /** * Filters surveys based on the displayOption, recontactDays, and segments @@ -90,8 +90,6 @@ export const filterSurveys = ( // if survey has recontactDays, check if the last display was more than recontactDays ago // The previous approach checked the last display for each survey which is why we still have a surveyId in the displays array. - // NOSONAR - // TODO: Remove the surveyId from the displays array if (survey.recontactDays !== null) { return ( diffInDays(new Date(), new Date(lastDisplayAt)) >= survey.recontactDays @@ -185,8 +183,7 @@ export const getLanguageCode = ( export const shouldDisplayBasedOnPercentage = ( displayPercentage: number ): boolean => { - const randomNum = Math.floor(Math.random() * 10000) / 100; - return randomNum <= displayPercentage; + return Math.random() * 100 < displayPercentage; // NOSONAR: Math.random() is sufficient for non-security survey display logic }; export const isNowExpired = (expirationDate: Date): boolean => { diff --git a/packages/react-native/src/lib/environment/state.ts b/packages/react-native/src/lib/environment/state.ts index 434508f..62ee0a3 100644 --- a/packages/react-native/src/lib/environment/state.ts +++ b/packages/react-native/src/lib/environment/state.ts @@ -98,7 +98,7 @@ export const addEnvironmentStateExpiryCheckListener = ...existingConfig, environment: { ...existingConfig.environment, - expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes }, }); } diff --git a/packages/react-native/src/lib/survey/store.ts b/packages/react-native/src/lib/survey/store.ts index cb76534..269e28e 100644 --- a/packages/react-native/src/lib/survey/store.ts +++ b/packages/react-native/src/lib/survey/store.ts @@ -20,9 +20,9 @@ export class SurveyStore { const prevSurvey = this.survey; if (prevSurvey?.id !== survey.id) { this.survey = survey; - this.listeners.forEach((listener) => { + for (const listener of this.listeners) { listener(this.survey, prevSurvey); - }); + } } } @@ -30,9 +30,9 @@ export class SurveyStore { const prevSurvey = this.survey; if (prevSurvey !== null) { this.survey = null; - this.listeners.forEach((listener) => { + for (const listener of this.listeners) { listener(this.survey, prevSurvey); - }); + } } } diff --git a/packages/react-native/src/lib/user/state.ts b/packages/react-native/src/lib/user/state.ts index f19649e..bbe475d 100644 --- a/packages/react-native/src/lib/user/state.ts +++ b/packages/react-native/src/lib/user/state.ts @@ -35,7 +35,7 @@ export const addUserStateExpiryCheckListener = async (): Promise => { ...config.get(), user: { ...config.get().user, - expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes }, }); };