diff --git a/packages/classic-shared/src/shared.ts b/packages/classic-shared/src/shared.ts index 9865826f..368192dd 100644 --- a/packages/classic-shared/src/shared.ts +++ b/packages/classic-shared/src/shared.ts @@ -173,7 +173,7 @@ export type BlocksToWebviewMessage = payload: FeedbackResponse; } | { - type: 'PURCHASE_PRODUCT_SUCCESS_RESPONSE'; + type: 'HARDCORE_ACCESS_UPDATE'; payload: { access: HardcoreAccessStatus; }; diff --git a/packages/classic-webview/src/hooks/useHardcoreAccess.tsx b/packages/classic-webview/src/hooks/useHardcoreAccess.tsx index 5de1ff6b..aaf4bf6f 100644 --- a/packages/classic-webview/src/hooks/useHardcoreAccess.tsx +++ b/packages/classic-webview/src/hooks/useHardcoreAccess.tsx @@ -12,7 +12,7 @@ const hardcoreAccessContext = createContext(null); export const HardcoreAccessContextProvider = (props: { children: React.ReactNode }) => { const [access, setAccess] = useState({ status: 'inactive' }); const hardcoreAccessInitResponse = useDevvitListener('HARDCORE_ACCESS_INIT_RESPONSE'); - const productPurchaseResponse = useDevvitListener('PURCHASE_PRODUCT_SUCCESS_RESPONSE'); + const hardcoreAccessUpdate = useDevvitListener('HARDCORE_ACCESS_UPDATE'); useEffect(() => { if (hardcoreAccessInitResponse?.hardcoreAccessStatus != null) { @@ -23,10 +23,10 @@ export const HardcoreAccessContextProvider = (props: { children: React.ReactNode // When a purchase is successful, update 'access' state // `unlock hardcore` page and modal should react to this and act accordingly useEffect(() => { - if (productPurchaseResponse != null) { - setAccess(productPurchaseResponse.access); + if (hardcoreAccessUpdate != null) { + setAccess(hardcoreAccessUpdate.access); } - }, [productPurchaseResponse, setAccess]); + }, [hardcoreAccessUpdate, setAccess]); return ( diff --git a/packages/classic/src/main.tsx b/packages/classic/src/main.tsx index d66c08ee..f0f065d8 100644 --- a/packages/classic/src/main.tsx +++ b/packages/classic/src/main.tsx @@ -6,7 +6,7 @@ import './menu-actions/newChallenge.js'; import './menu-actions/addWordToDictionary.js'; import './menu-actions/totalReminders.js'; -import { Devvit, useInterval, useState } from '@devvit/public-api'; +import { Devvit, JSONValue, useInterval, useState } from '@devvit/public-api'; import { DEVVIT_SETTINGS_KEYS } from './constants.js'; import { isServerCall, omit } from '@hotandcold/shared/utils'; import { GameMode, HardcoreAccessStatus, WebviewToBlocksMessage } from '@hotandcold/classic-shared'; @@ -21,6 +21,15 @@ import { RedditApiCache } from './core/redditApiCache.js'; import { sendMessageToWebview } from './utils/index.js'; import { initPayments, PaymentsRepo } from './payments.js'; import { OnPurchaseResult, OrderResultStatus, usePayments } from '@devvit/payments'; +import { useChannel } from '@devvit/public-api'; + +export type PurchasedProductBroadcast = { + payload: { + // user who purchased the product; important because we don't want the broadcast to unlock + // hardcore for all users + userId: string; + }; +}; initPayments(); @@ -60,22 +69,55 @@ type InitialState = hardcoreModeAccess: HardcoreAccessStatus; }; +const PURCHASE_REALTIME_CHANNEL = 'PURCHASE_REALTIME_CHANNEL'; + // Add a post type definition Devvit.addCustomPostType({ name: 'HotAndCold', height: 'tall', render: (context) => { + // This channel is used to broadcast purchase success events to all instances of the app. + // It's necessary because iOS and Android aggressively cache webviews, which can cause + // the purchase success state to not be reflected immediately in all open instances. + // By broadcasting the event through a realtime channel, we ensure all instances + // update their UI state correctly, even if they're cached. + const purchaseRealtimeChannel = useChannel({ + name: PURCHASE_REALTIME_CHANNEL, + onMessage(msg: JSONValue) { + const msgCasted = msg as PurchasedProductBroadcast; + if (msgCasted.payload.userId === context.userId) { + sendMessageToWebview(context, { + type: 'HARDCORE_ACCESS_UPDATE', + payload: { + access: { status: 'active' }, + }, + }); + } + }, + onSubscribed: () => { + console.log('listening for purchase success broadcast events'); + }, + }); + purchaseRealtimeChannel.subscribe(); + const paymentsRepo = new PaymentsRepo(context.redis); const payments = usePayments(async (paymentsResult: OnPurchaseResult) => { switch (paymentsResult.status) { case OrderResultStatus.Success: { context.ui.showToast(`Purchase successful!`); + const access = await paymentsRepo.getHardcoreAccessStatus(context.userId!); sendMessageToWebview(context, { - type: 'PURCHASE_PRODUCT_SUCCESS_RESPONSE', + type: 'HARDCORE_ACCESS_UPDATE', + payload: { + access, + }, + }); + void purchaseRealtimeChannel.send({ payload: { - access: await paymentsRepo.getHardcoreAccessStatus(context.userId!), + userId: context.userId!, }, }); + break; } case OrderResultStatus.Error: {