Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 26 additions & 19 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { StyleSheet } from 'react-native-unistyles';

import { config } from '$domain/constants';
import { AuthContextProvider } from '$domain/contexts';
import { SubscriptionContextProvider } from '$domain/contexts/subscriptionContext';
import { useAppFocusManager } from '$infra/api';
import { persistOptions, queryClient } from '$infra/api/queryClient';
import { ErrorMonitoring } from '$infra/monitoring';
Expand Down Expand Up @@ -81,29 +82,35 @@ const RootLayout = () => {
<BottomSheetModalProvider>
<KeyboardProvider>
<AuthContextProvider>
<>
<Stack screenOptions={screenOptions}>
<Stack.Protected guard={!isBootstrappingApplication}>
<Stack.Protected guard={config.isStorybookEnabled}>
<Stack.Screen name="Storybook" />
<SubscriptionContextProvider>
<>
<Stack screenOptions={screenOptions}>
<Stack.Protected
guard={!isBootstrappingApplication}
>
<Stack.Protected
guard={config.isStorybookEnabled}
>
<Stack.Screen name="Storybook" />
</Stack.Protected>

<Stack.Protected guard={!isUserLoggedIn}>
<Stack.Screen name="Login" />
</Stack.Protected>

<Stack.Protected guard={isUserLoggedIn}>
<Stack.Screen name="(protected)/(tabs)" />
</Stack.Protected>
</Stack.Protected>
</Stack>

<Stack.Protected guard={!isUserLoggedIn}>
<Stack.Screen name="Login" />
</Stack.Protected>

<Stack.Protected guard={isUserLoggedIn}>
<Stack.Screen name="(protected)/(tabs)" />
</Stack.Protected>
</Stack.Protected>
</Stack>

<Toast config={toastConfig} />
<Toast config={toastConfig} />

<AppUpdateNeeded />
<AppUpdateNeeded />

<MaintenanceMode />
</>
<MaintenanceMode />
</>
</SubscriptionContextProvider>
</AuthContextProvider>
</KeyboardProvider>
</BottomSheetModalProvider>
Expand Down
2 changes: 1 addition & 1 deletion src/domain/contexts/authContext/AuthContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const AuthContextProvider = ({ children }: AuthContextProviderProps) => {

ErrorMonitoring.setUser(user);

await Purchase.setUser(user.id);
await Purchase.setUser(user);

Notifications.setUser(user.id);
Notifications.setUserEmail(user.email);
Expand Down
12 changes: 12 additions & 0 deletions src/domain/contexts/subscriptionContext/SubscriptionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext } from 'react';
import { PurchasesOffering } from 'react-native-purchases';

const SubscriptionContext = createContext<{
isPayingUser: boolean | null;
offeringToDisplay: PurchasesOffering | null;
}>({
isPayingUser: null,
offeringToDisplay: null,
});

export default SubscriptionContext;
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react';
import { PurchasesOffering } from 'react-native-purchases';

import { hasActiveEntitlements } from '$domain/subscription';
import { OfferingFlagType, useGetRemoteConfigSync } from '$infra/featureFlags';
import { Logger } from '$infra/logger';
import { Purchase } from '$infra/purchase';
import { useRunOnMount } from '$shared/hooks';

import SubscriptionContext from './SubscriptionContext';

interface SubscriptionContextProviderProps {
children: React.ReactNode;
}

export const SubscriptionContextProvider = ({
children,
}: SubscriptionContextProviderProps) => {
const [isPayingUser, setIsPayingUser] = useState<boolean | null>(null);
const [offeringToDisplay, setOfferingToDisplay] =
useState<PurchasesOffering | null>(null);

const { getFlagPayloadSync } = useGetRemoteConfigSync();

useRunOnMount(() => {
const fetchIsPayingUser = async () => {
try {
const isPayingUser = await Purchase.isPayingUser();

setIsPayingUser(isPayingUser);
} catch (error) {
Logger.error({
error,
level: 'warning',
message: 'Failed to fetch user subscription status',
});

setIsPayingUser(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider using null instead of false for error state consistency.

Line 38 sets isPayingUser to false on error, while line 77 sets offeringToDisplay to null on error. Since isPayingUser is typed as boolean | null, setting it to null on error would be more consistent and better distinguish "error/unknown" state from "definitely not a paying user."

♻️ Suggested change for consistency
-       setIsPayingUser(false);
+       setIsPayingUser(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setIsPayingUser(false);
setIsPayingUser(null);
🤖 Prompt for AI Agents
In @src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx at
line 38, The error branch should set isPayingUser to null instead of false to
match the typed union and mirror offeringToDisplay's null-on-error pattern;
update the error handling in SubscriptionContextProvider (replace the
setIsPayingUser(false) call with setIsPayingUser(null)) so the "unknown/error"
state is clearly distinguished from a definite non-paying user, and ensure any
downstream checks that rely on isPayingUser handle null accordingly.

}
};

void fetchIsPayingUser();
});
Comment on lines +25 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add cleanup to prevent state updates after unmount.

The async operation in fetchIsPayingUser can complete after the component unmounts, causing React to attempt setting state on an unmounted component. This leads to memory leaks and console warnings.

🔧 Proposed fix with mounted flag
  useRunOnMount(() => {
+   let mounted = true;
+
    const fetchIsPayingUser = async () => {
      try {
        const isPayingUser = await Purchase.isPayingUser();

-       setIsPayingUser(isPayingUser);
+       if (mounted) {
+         setIsPayingUser(isPayingUser);
+       }
      } catch (error) {
        Logger.error({
          error,
          level: 'warning',
          message: 'Failed to fetch user subscription status',
        });

-       setIsPayingUser(false);
+       if (mounted) {
+         setIsPayingUser(false);
+       }
      }
    };

    void fetchIsPayingUser();
+
+   return () => {
+     mounted = false;
+   };
  });
🤖 Prompt for AI Agents
In @src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx
around lines 25 - 43, The async fetchIsPayingUser can call setIsPayingUser after
unmount; wrap the effect body returned to useRunOnMount with an isMounted flag
(e.g., let isMounted = true) and return a cleanup that sets isMounted = false,
then check isMounted before calling setIsPayingUser in both the try and catch
paths (when awaiting Purchase.isPayingUser()). This prevents state updates after
unmount while keeping the same functions (useRunOnMount, fetchIsPayingUser,
Purchase.isPayingUser, setIsPayingUser).


useRunOnMount(() => {
return Purchase.customerListener((customerInfo) => {
setIsPayingUser(hasActiveEntitlements(customerInfo));
});
});
Comment thread
tsyirvo marked this conversation as resolved.

useRunOnMount(() => {
const fetchOfferingToDisplay = async () => {
try {
const offering = await Purchase.getOfferings();
const payload = getFlagPayloadSync<OfferingFlagType>(
'offering-to-display',
);

if (payload?.type === 'offering' && payload.offering) {
const remotelySelectedOffering = offering.all[payload.offering];

if (remotelySelectedOffering) {
setOfferingToDisplay(remotelySelectedOffering);
} else {
setOfferingToDisplay(offering.current);
}
} else {
setOfferingToDisplay(offering.current);
}
} catch (error) {
Logger.error({
error,
level: 'warning',
message: 'Failed to fetch offering to display',
});

setOfferingToDisplay(null);
}
};

void fetchOfferingToDisplay();
});
Comment on lines +51 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add cleanup to prevent state updates after unmount.

Similar to the first useRunOnMount, the async operation in fetchOfferingToDisplay can complete after unmount, causing state updates on an unmounted component.

🔧 Proposed fix with mounted flag
  useRunOnMount(() => {
+   let mounted = true;
+
    const fetchOfferingToDisplay = async () => {
      try {
        const offering = await Purchase.getOfferings();
        const payload = getFlagPayloadSync<OfferingFlagType>(
          'offering-to-display',
        );

        if (payload?.type === 'offering' && payload.offering) {
          const remotelySelectedOffering = offering.all[payload.offering];

          if (remotelySelectedOffering) {
-           setOfferingToDisplay(remotelySelectedOffering);
+           if (mounted) {
+             setOfferingToDisplay(remotelySelectedOffering);
+           }
          } else {
-           setOfferingToDisplay(offering.current);
+           if (mounted) {
+             setOfferingToDisplay(offering.current);
+           }
          }
        } else {
-         setOfferingToDisplay(offering.current);
+         if (mounted) {
+           setOfferingToDisplay(offering.current);
+         }
        }
      } catch (error) {
        Logger.error({
          error,
          level: 'warning',
          message: 'Failed to fetch offering to display',
        });

-       setOfferingToDisplay(null);
+       if (mounted) {
+         setOfferingToDisplay(null);
+       }
      }
    };

    void fetchOfferingToDisplay();
+
+   return () => {
+     mounted = false;
+   };
  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useRunOnMount(() => {
const fetchOfferingToDisplay = async () => {
try {
const offering = await Purchase.getOfferings();
const payload = getFlagPayloadSync<OfferingFlagType>(
'offering-to-display',
);
if (payload?.type === 'offering' && payload.offering) {
const remotelySelectedOffering = offering.all[payload.offering];
if (remotelySelectedOffering) {
setOfferingToDisplay(remotelySelectedOffering);
} else {
setOfferingToDisplay(offering.current);
}
} else {
setOfferingToDisplay(offering.current);
}
} catch (error) {
Logger.error({
error,
level: 'warning',
message: 'Failed to fetch offering to display',
});
setOfferingToDisplay(null);
}
};
void fetchOfferingToDisplay();
});
useRunOnMount(() => {
let mounted = true;
const fetchOfferingToDisplay = async () => {
try {
const offering = await Purchase.getOfferings();
const payload = getFlagPayloadSync<OfferingFlagType>(
'offering-to-display',
);
if (payload?.type === 'offering' && payload.offering) {
const remotelySelectedOffering = offering.all[payload.offering];
if (remotelySelectedOffering) {
if (mounted) {
setOfferingToDisplay(remotelySelectedOffering);
}
} else {
if (mounted) {
setOfferingToDisplay(offering.current);
}
}
} else {
if (mounted) {
setOfferingToDisplay(offering.current);
}
}
} catch (error) {
Logger.error({
error,
level: 'warning',
message: 'Failed to fetch offering to display',
});
if (mounted) {
setOfferingToDisplay(null);
}
}
};
void fetchOfferingToDisplay();
return () => {
mounted = false;
};
});
🤖 Prompt for AI Agents
In @src/domain/contexts/subscriptionContext/SubscriptionContextProvider.tsx
around lines 51 - 82, The async fetchOfferingToDisplay in useRunOnMount may call
setOfferingToDisplay after the component unmounts; fix by adding a mounted flag
(e.g., let mounted = true) inside useRunOnMount, check mounted before every
setOfferingToDisplay and before Logger/error handling if needed, and return a
cleanup function that sets mounted = false so pending Purchase.getOfferings()
results are ignored after unmount; update references in fetchOfferingToDisplay
and the enclosing useRunOnMount accordingly.


const value = useMemo(
() => ({
isPayingUser,
offeringToDisplay,
}),
[isPayingUser, offeringToDisplay],
);

return (
<SubscriptionContext.Provider value={value}>
{children}
</SubscriptionContext.Provider>
);
};
2 changes: 2 additions & 0 deletions src/domain/contexts/subscriptionContext/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './SubscriptionContextProvider';
export * from './useSubscriptionContext';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useContext } from 'react';

import SubscriptionContext from './SubscriptionContext';

export const useSubscriptionContext = () => {
const value = useContext(SubscriptionContext);

return value;
};
1 change: 1 addition & 0 deletions src/domain/subscription/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './utils';
5 changes: 5 additions & 0 deletions src/domain/subscription/utils/hasActiveEntitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CustomerInfo } from 'react-native-purchases';

export const hasActiveEntitlements = (customerInfo: CustomerInfo): boolean => {
return Object.entries(customerInfo.entitlements.active).length > 0;
};
1 change: 1 addition & 0 deletions src/domain/subscription/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './hasActiveEntitlements';
10 changes: 0 additions & 10 deletions src/infra/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,6 @@ class AnalyticsClass {
});
}

/* ***** ***** Revenue ***** ***** */

trackRevenue(revenueData: {
productId: AnalyticsType.ProductIds;
price: number;
revenueType: AnalyticsType.RevenueTypes;
}) {
this.trackEvent('purchase', revenueData);
}

/* ***** ***** Events ***** ***** */

trackEvent(
Expand Down
10 changes: 0 additions & 10 deletions src/infra/analytics/analytics.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,6 @@ export namespace AnalyticsType {
| 'deep-link-opened'
| 'purchase';

export type ProductIds =
| 'monthly-subscription'
| 'monthly-subscription-30-off'
| 'monthly-subscription-50-off'
| 'yearly-subscription'
| 'yearly-subscription-30-off'
| 'yearly-subscription-50-off';

export type RevenueTypes = 'purchase';

export type JsonType =
| string
| number
Expand Down
4 changes: 3 additions & 1 deletion src/infra/featureFlags/defaultFlags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AvailableFeatureFlags,
AvailableRemoteConfig,
OfferingFlagType,
VersionFlagType,
} from './featureFlags.types';

Expand All @@ -13,8 +14,9 @@ export const defaultFeatureFlags: Record<

export const defaultRemoteConfig: Record<
AvailableRemoteConfig,
VersionFlagType
VersionFlagType | OfferingFlagType
> = {
'last-supported-app-version': { type: 'version', version: '2.0.0' },
'latest-released-app-version': { type: 'version', version: '2.1.0' },
'offering-to-display': { type: 'offering', offering: 'default' },
};
8 changes: 7 additions & 1 deletion src/infra/featureFlags/featureFlags.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ export type AvailableFeatureFlags = 'is-maintenance-mode';

export type AvailableRemoteConfig =
| 'last-supported-app-version'
| 'latest-released-app-version';
| 'latest-released-app-version'
| 'offering-to-display';

export type BooleanFeatureFlags = Extract<
AvailableFeatureFlags,
'is-maintenance-mode'
>;

export interface OfferingFlagType {
type: 'offering';
offering: string;
}

export interface VersionFlagType {
type: 'version';
version: string;
Expand Down
30 changes: 24 additions & 6 deletions src/infra/purchase/purchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import type {
LOG_LEVEL,
PurchasesPackage,
} from 'react-native-purchases';
import RevenueCat from 'react-native-purchases';
import RevenueCat, {
LOG_LEVEL as PURCHASES_LOG_LEVEL,
} from 'react-native-purchases';

import { IS_IOS, config } from '$domain/constants';
import { User } from '$domain/entities';
import { hasActiveEntitlements } from '$domain/subscription';
import { ErrorMonitoring } from '$infra/monitoring';

const API_KEY = IS_IOS
Expand All @@ -17,6 +21,8 @@ class PurchaseClass {

init() {
RevenueCat.configure({ apiKey: API_KEY });

void this.setLogLevel(PURCHASES_LOG_LEVEL.ERROR);
}
Comment thread
tsyirvo marked this conversation as resolved.

async setLogLevel(logLevel: LOG_LEVEL) {
Expand All @@ -25,8 +31,12 @@ class PurchaseClass {

/* ***** ***** User related ***** ***** */

async setUser(appUserID: string) {
await RevenueCat.logIn(appUserID);
async setUser(user: User) {
await RevenueCat.logIn(user.id);
await RevenueCat.setEmail(user.email);
await this.setAttributes({
$posthogUserId: user.id,
});
}

async clearUser() {
Expand All @@ -45,12 +55,16 @@ class PurchaseClass {
return await RevenueCat.getCustomerInfo();
}

async isPayingUser() {
const customerInfo = await this.getUserInformations();

return hasActiveEntitlements(customerInfo);
}

/* ***** ***** RevenueCat ***** ***** */

async getOfferings() {
const offerings = await RevenueCat.getOfferings();

return offerings.current;
return await RevenueCat.getOfferings();
}

async restorePurchases() {
Expand All @@ -69,6 +83,10 @@ class PurchaseClass {

customerListener(callback: (customerInfo: CustomerInfo) => void) {
RevenueCat.addCustomerInfoUpdateListener(callback);

return () => {
RevenueCat.removeCustomerInfoUpdateListener(callback);
};
}
}

Expand Down