diff --git a/assets/icons/clock_stroke2_corner0_rounded.svg b/assets/icons/clock_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..6699b35f63 --- /dev/null +++ b/assets/icons/clock_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/App.native.tsx b/src/App.native.tsx index 59b2ad9563..113caa7068 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -2,9 +2,9 @@ import 'react-native-url-polyfill/auto' import '#/lib/sentry' // must be near top import '#/view/icons' -import Purchases from 'react-native-purchases' import React, {useEffect, useState} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' +import Purchases from 'react-native-purchases' import {RootSiblingParent} from 'react-native-root-siblings' import { initialWindowMetrics, @@ -45,6 +45,7 @@ import {init as initPersistedState} from '#/state/persisted' import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' +import {Provider as PurchasesProvider} from '#/state/purchases' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, @@ -133,37 +134,39 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -178,15 +181,17 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - Promise.all([initPersistedState(), ensureGeolocationResolved(), Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG)]).then(() => { - if (isIOS) { - Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY}) - } else if (isAndroid) { - Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY}) - } + Promise.all([initPersistedState(), ensureGeolocationResolved()]).then( + () => { + setReady(true) + }, + ) - setReady(true) - }) + if (isIOS) { + Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY}) + } else if (isAndroid) { + Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY}) + } }, []) if (!isReady) { diff --git a/src/App.web.tsx b/src/App.web.tsx index 808b0fc278..b55c199613 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -30,6 +30,7 @@ import {init as initPersistedState} from '#/state/persisted' import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' +import {Provider as PurchasesProvider} from '#/state/purchases' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' import { Provider as SessionProvider, @@ -110,6 +111,8 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> + + @@ -139,6 +142,7 @@ function InnerApp() { + diff --git a/src/components/dialogs/BlueskyPlus/index.tsx b/src/components/dialogs/BlueskyPlus/index.tsx index 7b8809c3e4..3686733676 100644 --- a/src/components/dialogs/BlueskyPlus/index.tsx +++ b/src/components/dialogs/BlueskyPlus/index.tsx @@ -1,17 +1,18 @@ import React from 'react' import {View} from 'react-native' +import {PURCHASES_ERROR_CODE} from 'react-native-purchases' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import { - OfferingId, - SubscriptionGroupId, -} from '#/state/purchases/subscriptions/types' import { usePurchaseOffering, useSubscriptionGroup, -} from '#/state/purchases/subscriptions/useSubscriptionGroup' -import {parseOfferingId} from '#/state/purchases/subscriptions/util' +} from '#/state/purchases/hooks/useSubscriptionGroup' +import { + SubscriptionGroupId, + SubscriptionOfferingId, +} from '#/state/purchases/types' +import {parseOfferingId} from '#/state/purchases/types' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' @@ -22,7 +23,7 @@ import {GradientFill} from '#/components/GradientFill' import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus' import {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {InlineLinkText} from '#/components/Link' +import {InlineLinkText, Link} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -30,20 +31,20 @@ export function BlueskyPlus({control}: {control: Dialog.DialogControlProps}) { return ( - + ) } -function DialogInner() { +function DialogInner({control}: {control: Dialog.DialogControlProps}) { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() const {currentAccount} = useSession() const copy = useCoreOfferingCopy() - const [offeringId, setOfferingId] = React.useState( - OfferingId.CoreAnnual, + const [offeringId, setOfferingId] = React.useState( + SubscriptionOfferingId.CoreAnnual, ) const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core) const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering() @@ -57,13 +58,29 @@ function DialogInner() { throw new Error('No offering') } - await purchaseOffering({ - did: currentAccount.did, - email: currentAccount.email!, - offering, + control.close(async () => { + try { + await purchaseOffering({ + did: currentAccount.did, + email: currentAccount.email!, + offering, + }) + } catch (e: any) { + /** + * @see https://www.revenuecat.com/docs/test-and-launch/errors + */ + if (e.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) { + control.open() + } else { + Toast.show( + _(msg`Hmmmm, something went wrong. Please try again.`), + 'xmark', + ) + } + } }) } catch (e: any) { - Toast.show(_(msg`Could not take you to checkout`), 'xmark') + Toast.show(_(msg`Hmmmm. We couldn't locate that subscription.`), 'xmark') } } @@ -76,7 +93,7 @@ function DialogInner() { ]}> - + Let's build the social web. @@ -99,7 +116,10 @@ function DialogInner() { values={[offeringId]} onChange={values => setOfferingId(parseOfferingId(values[0]))} style={[a.w_full, a.gap_sm, a.pt_lg]}> - {[OfferingId.CoreMonthly, OfferingId.CoreAnnual].map(id => { + {[ + SubscriptionOfferingId.CoreMonthly, + SubscriptionOfferingId.CoreAnnual, + ].map(id => { return ( {({selected, hovered}) => ( @@ -209,7 +229,18 @@ function DialogInner() { })} - + + + + Learn more + + - - - )} - - )} - - - - {data && ( - {JSON.stringify(data, null, 2)} - )} + + {purchases.status === 'loading' ? ( + + ) : purchases.status === 'error' ? ( + + ) : ( + + )} @@ -94,33 +57,263 @@ export function Subscriptions(_props: ScreenProps) { ) } -function CoreSubscriptions(props: { subscriptions: Subscription[] }) { +function Core({ + state, +}: { + state: Exclude +}) { + const t = useTheme() + const {_} = useLingui() + const control = useDialogControl() + const coreSubscriptions = state.subscriptions.filter( + s => s.group === SubscriptionGroupId.Core, + ) + const isSubscribedToCore = !!coreSubscriptions.length + console.log(state) + + const features = [ + { + available: true, + icon: Check, + text: _(msg`Bluesky+ supporter badge`), + }, + { + available: true, + icon: Check, + text: _(msg`Custom app icons`), + }, + { + available: true, + icon: Check, + text: _(msg`Profile customizations`), + }, + { + available: true, + icon: Check, + text: _(msg`Higher video upload limits`), + }, + { + available: true, + icon: Check, + text: _(msg`High quality video resolution`), + }, + { + available: false, + icon: Clock, + text: _(msg`Inline post translations (coming soon)`), + }, + { + available: false, + icon: Clock, + text: _(msg`Post analytics (coming soon)`), + }, + { + available: false, + icon: Clock, + text: _(msg`Bookmark folders (coming soon)`), + }, + ] + + return ( + + + + {isSubscribedToCore ? ( + + + + ) : null} + + {!isSubscribedToCore && ( + <> + {state.config.nativePurchaseRestricted === 'yes' ? ( + + + + Another account on this device is already subscribed to + Bluesky+. Please subscribe additional accounts through our web + application. + + + + ) : ( + <> + + Building a better internet needs your support. + + + + + + Subscribing to Bluesky+ helps ensure that our work to build + an open, secure, and user-first internet can continue. + + + + Plus, you'll get access to exclusive features! + + + + + {features.map(f => ( + + + + + + {f.text} + + + ))} + + + + + + )} + + )} + + + + + Terms and Conditions + {' '} + ·{' '} + + Privacy Policy + {' '} + ·{' '} + + EULA + + + + + + Learn more about Bluesky+ and our roadmap{' '} + + here + + + + + + ) +} + +function CoreSubscriptions(props: {subscriptions: APISubscription[]}) { const t = useTheme() const {_, i18n} = useLingui() const {mutateAsync: manageSubscription} = useManageSubscription() - return props.subscriptions.map(sub => ( - - Bluesky+ + return props.subscriptions.map(sub => { + const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'}) + const StatusIcon = sub.renews ? Rotate : Clock + return ( + + - {sub.renews ? ( - - Renews:{' '} - {i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium", timeStyle: "medium" })} - - ) : ( - - Ends:{' '} - {i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium" })} - - )} + + + + + Active + + - {manageSubscription()})} - > - Manage subscription - - - )) + + + {endDate} + + + { + manageSubscription() + })} + style={[a.justify_center]}> + + + + + ) + }) } diff --git a/src/state/purchases/subscriptions/api.ts b/src/state/purchases/api.ts similarity index 100% rename from src/state/purchases/subscriptions/api.ts rename to src/state/purchases/api.ts diff --git a/src/state/purchases/context.tsx b/src/state/purchases/context.tsx new file mode 100644 index 0000000000..0912e418f1 --- /dev/null +++ b/src/state/purchases/context.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +import {APIEntitlement,APISubscription} from '#/state/purchases/types' + +export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown' + +export type PurchasesState = + | { + status: 'loading' + } + | { + status: 'error' + error: Error + } + | { + status: 'ready' + email?: string + subscriptions: APISubscription[] + entitlements: APIEntitlement[] + config: { + nativePurchaseRestricted: NativePurchaseRestricted + } + } + +export const Context = React.createContext({ + status: 'loading', +}) diff --git a/src/state/purchases/subscriptions/useManageSubscription/index.ts b/src/state/purchases/hooks/useManageSubscription/index.ts similarity index 100% rename from src/state/purchases/subscriptions/useManageSubscription/index.ts rename to src/state/purchases/hooks/useManageSubscription/index.ts index c0d34871ba..00232e4757 100644 --- a/src/state/purchases/subscriptions/useManageSubscription/index.ts +++ b/src/state/purchases/hooks/useManageSubscription/index.ts @@ -1,6 +1,6 @@ import {Linking} from 'react-native' -import {useMutation} from '@tanstack/react-query' import Purchases from 'react-native-purchases' +import {useMutation} from '@tanstack/react-query' export function useManageSubscription() { return useMutation({ diff --git a/src/state/purchases/subscriptions/useManageSubscription/index.web.ts b/src/state/purchases/hooks/useManageSubscription/index.web.ts similarity index 78% rename from src/state/purchases/subscriptions/useManageSubscription/index.web.ts rename to src/state/purchases/hooks/useManageSubscription/index.web.ts index 41db909d97..4b3cc2f909 100644 --- a/src/state/purchases/subscriptions/useManageSubscription/index.web.ts +++ b/src/state/purchases/hooks/useManageSubscription/index.web.ts @@ -1,9 +1,9 @@ import {Linking} from 'react-native' import {useMutation} from '@tanstack/react-query' -import {IS_DEV} from '#/env' +import {api} from '#/state/purchases/api' import {useSession} from '#/state/session' -import {api} from '#/state/purchases/subscriptions/api' +import {IS_DEV} from '#/env' export function useManageSubscription() { const {currentAccount} = useSession() @@ -16,8 +16,10 @@ export function useManageSubscription() { method: 'POST', json: { did: currentAccount!.did, - redirecturl: IS_DEV ? 'http://localhost:19006/subscriptions' : 'https://bsky.app/subscriptions', - } + redirecturl: IS_DEV + ? 'http://localhost:19006/subscriptions' + : 'https://bsky.app/subscriptions', + }, }).json() if (error || !data) { diff --git a/src/state/purchases/hooks/useNativeEventsListener/index.ts b/src/state/purchases/hooks/useNativeEventsListener/index.ts new file mode 100644 index 0000000000..43db86a3d1 --- /dev/null +++ b/src/state/purchases/hooks/useNativeEventsListener/index.ts @@ -0,0 +1,15 @@ +import React from 'react' +import Purchases, {CustomerInfo} from 'react-native-purchases' + +export function useNativeEventsListener({ + onCustomerInfoUpdated, +}: { + onCustomerInfoUpdated: (info: CustomerInfo) => void +}) { + React.useEffect(() => { + Purchases.addCustomerInfoUpdateListener(onCustomerInfoUpdated) + return () => { + Purchases.removeCustomerInfoUpdateListener(onCustomerInfoUpdated) + } + }, [onCustomerInfoUpdated]) +} diff --git a/src/state/purchases/hooks/useNativeEventsListener/index.web.ts b/src/state/purchases/hooks/useNativeEventsListener/index.web.ts new file mode 100644 index 0000000000..3ae58572d6 --- /dev/null +++ b/src/state/purchases/hooks/useNativeEventsListener/index.web.ts @@ -0,0 +1 @@ +export function useNativeEventsListener() {} diff --git a/src/state/purchases/hooks/useNativeUserState/index.ts b/src/state/purchases/hooks/useNativeUserState/index.ts new file mode 100644 index 0000000000..366975bcb5 --- /dev/null +++ b/src/state/purchases/hooks/useNativeUserState/index.ts @@ -0,0 +1,37 @@ +import React from 'react' +import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases' + +import {NativePurchaseRestricted} from '#/state/purchases/context' +import {useSession} from '#/state/session' + +export function useNativeUserState() { + const {currentAccount} = useSession() + const [restricted, setRestricted] = + React.useState('unknown') + + React.useEffect(() => { + async function check() { + if (!currentAccount?.did) return + + try { + // MUST ensure we're the correct user first + await Purchases.logIn(currentAccount?.did) + await Purchases.restorePurchases() + setRestricted('no') + } catch (e: any) { + /** + * @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use + */ + if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) { + setRestricted('yes') + } + } + } + + check() + }, [currentAccount?.did]) + + return { + restricted, + } +} diff --git a/src/state/purchases/hooks/useNativeUserState/index.web.ts b/src/state/purchases/hooks/useNativeUserState/index.web.ts new file mode 100644 index 0000000000..bf6269eec7 --- /dev/null +++ b/src/state/purchases/hooks/useNativeUserState/index.web.ts @@ -0,0 +1,5 @@ +export function useNativeUserState() { + return { + restricted: false, + } +} diff --git a/src/state/purchases/hooks/usePurchasesState.ts b/src/state/purchases/hooks/usePurchasesState.ts new file mode 100644 index 0000000000..9ddc57a7db --- /dev/null +++ b/src/state/purchases/hooks/usePurchasesState.ts @@ -0,0 +1,35 @@ +import {useQuery} from '@tanstack/react-query' + +import {api} from '#/state/purchases/api' +import {APIEntitlement,APISubscription} from '#/state/purchases/types' +import {useSession} from '#/state/session' + +export const rootPurchasesStateQueryKey = 'subscriptions-state' +export const createPurchasesStateQueryKey = (did?: string) => [ + rootPurchasesStateQueryKey, + did, +] + +export function usePurchasesState() { + const {currentAccount} = useSession() + + return useQuery({ + enabled: !!currentAccount, + refetchOnWindowFocus: true, + refetchInterval: 1000 * 60 * 2, + queryKey: createPurchasesStateQueryKey(currentAccount?.did), + async queryFn() { + const {data, error} = await api<{ + email: string + subscriptions: APISubscription[] + entitlements: APIEntitlement[] + }>(`/account?did=${currentAccount!.did}`).json() + + if (error || !data) { + throw new Error('Failed to fetch subscriptions state') + } + + return data + }, + }) +} diff --git a/src/state/purchases/subscriptions/useSubscriptionGroup/index.ts b/src/state/purchases/hooks/useSubscriptionGroup/index.ts similarity index 66% rename from src/state/purchases/subscriptions/useSubscriptionGroup/index.ts rename to src/state/purchases/hooks/useSubscriptionGroup/index.ts index bf6f43217b..fb9b342203 100644 --- a/src/state/purchases/subscriptions/useSubscriptionGroup/index.ts +++ b/src/state/purchases/hooks/useSubscriptionGroup/index.ts @@ -1,23 +1,29 @@ -import React from 'react' -import {useQuery, useMutation} from '@tanstack/react-query' -import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases' +import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases' +import {useMutation,useQuery} from '@tanstack/react-query' import {isIOS} from '#/platform/detection' -import {api} from '#/state/purchases/subscriptions/api' -import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' +import {api} from '#/state/purchases/api' +import { + parseOfferingId, + PlatformId, + SubscriptionGroupId, + SubscriptionOffering, +} from '#/state/purchases/types' export function useSubscriptionGroup(group: SubscriptionGroupId) { - return useQuery<{ offerings: Offering[] }>({ + return useQuery<{offerings: SubscriptionOffering[]}>({ queryKey: ['subscription-group', group], async queryFn() { const platform = isIOS ? 'ios' : 'android' - const { data, error, response } = await api(`/subscriptions/${group}?platform=${platform}`).json() + const {data, error, response} = await api( + `/subscriptions/${group}?platform=${platform}`, + ).json() if (error || !data) { console.log(error, response) throw new Error('Failed to fetch subscription group') } - const { offerings } = data + const {offerings} = data const productIds = offerings.map((o: any) => { return isIOS ? o.productId : o.productId.split(':')[0] }) @@ -28,16 +34,18 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) { return { offerings: offerings.map((o: any) => { - const product = products.find((p: any) => p.identifier === o.productId) + const product = products.find( + (p: any) => p.identifier === o.productId, + ) return { - id: o.id as OfferingId, + id: parseOfferingId(o.id), platform: isIOS ? PlatformId.Ios : PlatformId.Android, price: product?.priceString, package: product, } }), } - } + }, }) } @@ -47,7 +55,11 @@ export function usePurchaseOffering() { did, email, offering, - }: { did: string, email: string, offering: Offering }) { + }: { + did: string + email: string + offering: SubscriptionOffering + }) { if (offering.platform === PlatformId.Web) { throw new Error('Unsupported platform') } @@ -63,6 +75,6 @@ export function usePurchaseOffering() { throw e } - } + }, }) } diff --git a/src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts b/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts similarity index 68% rename from src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts rename to src/state/purchases/hooks/useSubscriptionGroup/index.web.ts index 0a712b8be4..7bf2177d9d 100644 --- a/src/state/purchases/subscriptions/useSubscriptionGroup/index.web.ts +++ b/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts @@ -1,31 +1,38 @@ import {Linking} from 'react-native' -import {useQuery, useMutation} from '@tanstack/react-query' +import {useMutation,useQuery} from '@tanstack/react-query' +import {api} from '#/state/purchases/api' +import { + parseOfferingId, + PlatformId, + SubscriptionGroupId, + SubscriptionOffering, +} from '#/state/purchases/types' import {IS_DEV} from '#/env' -import {api} from '#/state/purchases/subscriptions/api' -import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' export function useSubscriptionGroup(group: SubscriptionGroupId) { - return useQuery<{ offerings: Offering[] }>({ + return useQuery<{offerings: SubscriptionOffering[]}>({ queryKey: ['subscription-group', group], async queryFn() { - const { data, error } = await api(`/subscriptions/${group}?platform=web`).json() + const {data, error} = await api( + `/subscriptions/${group}?platform=web`, + ).json() if (error || !data) { throw new Error('Failed to fetch subscription group') } - const { offerings } = data + const {offerings} = data return { offerings: offerings.map((o: any) => ({ - id: o.id as OfferingId, + id: parseOfferingId(o.id), platform: PlatformId.Web, package: { priceId: o.productId, }, })), } - } + }, }) } @@ -35,7 +42,11 @@ export function usePurchaseOffering() { did, email, offering, - }: { did: string, email: string, offering: Offering }) { + }: { + did: string + email: string + offering: SubscriptionOffering + }) { if (offering.platform !== PlatformId.Web) { throw new Error('Unsupported platform') } @@ -59,6 +70,6 @@ export function usePurchaseOffering() { } Linking.openURL(data.checkoutUrl) - } + }, }) } diff --git a/src/state/purchases/index.tsx b/src/state/purchases/index.tsx new file mode 100644 index 0000000000..568b51d42a --- /dev/null +++ b/src/state/purchases/index.tsx @@ -0,0 +1,51 @@ +import React from 'react' + +import {Context, PurchasesState} from '#/state/purchases/context' +import {useNativeEventsListener} from '#/state/purchases/hooks/useNativeEventsListener' +import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState' +import {usePurchasesState} from '#/state/purchases/hooks/usePurchasesState' + +export type {PurchasesState} from '#/state/purchases/context' + +export function Provider({children}: {children: React.ReactNode}) { + const { + data: purchases, + error: purchasesStateError, + refetch, + } = usePurchasesState() + const {restricted} = useNativeUserState() + const ctx = React.useMemo(() => { + if (purchasesStateError) { + return { + status: 'error', + error: purchasesStateError, + } + } else if (!purchases) { + return { + status: 'loading', + } + } else { + return { + status: 'ready', + email: purchases?.email, + subscriptions: purchases?.subscriptions ?? [], + entitlements: purchases?.entitlements ?? [], + config: { + nativePurchaseRestricted: restricted, + }, + } + } + }, [purchases, purchasesStateError, restricted]) + + useNativeEventsListener({ + onCustomerInfoUpdated() { + refetch() + }, + }) + + return {children} +} + +export function usePurchases() { + return React.useContext(Context) +} diff --git a/src/state/purchases/subscriptions/types.ts b/src/state/purchases/subscriptions/types.ts deleted file mode 100644 index 1402ad23e0..0000000000 --- a/src/state/purchases/subscriptions/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { PurchasesStoreProduct } from 'react-native-purchases' - -export enum EntitlementId { - Core = 'core', -} - -export enum PlatformId { - Android = 'android', - Ios = 'ios', - Web = 'web', -} - -export enum SubscriptionGroupId { - Core = 'core', -} - -export enum OfferingId { - CoreMonthly = 'coreMonthly', - CoreAnnual = 'coreAnnual', -} - -export type Offering = - | { - id: OfferingId - platform: PlatformId.Ios | PlatformId.Android - price: number - package: PurchasesStoreProduct - } - | { - id: OfferingId - platform: PlatformId.Web - price: number - package: { - priceId: string - } - } - -export type Subscription = { - group: SubscriptionGroupId - platform: PlatformId - renews: boolean - periodDtartsAt: string - periodEndsAt: string - purchasedAt: string -} diff --git a/src/state/purchases/subscriptions/useCreateCheckout.ts b/src/state/purchases/subscriptions/useCreateCheckout.ts deleted file mode 100644 index 775b2def18..0000000000 --- a/src/state/purchases/subscriptions/useCreateCheckout.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {useMutation} from '@tanstack/react-query' - -import {api} from '#/state/purchases/subscriptions/api' -import {IS_DEV} from '#/env' - -export function useCreateCheckout() { - return useMutation({ - async mutationFn(props: {price: string; did: string; email: string}) { - const {data, error} = await api<{ - checkoutUrl: string - }>('/checkout/create', { - method: 'POST', - json: { - ...props, - redirectUrl: IS_DEV - ? `http://localhost:19006/subscriptions` - : `https://bsky.app/subscriptions`, - }, - }).json() - - if (error) { - throw error - } - - return data - }, - }) -} diff --git a/src/state/purchases/subscriptions/useEntitlements.ts b/src/state/purchases/subscriptions/useEntitlements.ts deleted file mode 100644 index 2f53749cea..0000000000 --- a/src/state/purchases/subscriptions/useEntitlements.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {useQuery} from '@tanstack/react-query' - -import {useSession} from '#/state/session' -import {api} from '#/state/purchases/subscriptions/api' - -export function useEntitlements() { - const {currentAccount} = useSession() - - return useQuery({ - enabled: !!currentAccount, - queryKey: ['entitlements', currentAccount?.did], - refetchOnWindowFocus: true, - async queryFn() { - const params = new URLSearchParams('/entitlements') - params.set('did', currentAccount!.did) - const url = `/account?${params.toString()}` - const {data, error} = await api<{ - entitlements: {id: 'core'; platform: 'web'}[] - }>(url).json() - - if (error) { - return [] - } - - return data?.entitlements - }, - }) -} diff --git a/src/state/purchases/subscriptions/useSubscriptionsState.ts b/src/state/purchases/subscriptions/useSubscriptionsState.ts deleted file mode 100644 index c4b02e6f05..0000000000 --- a/src/state/purchases/subscriptions/useSubscriptionsState.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {useQuery} from '@tanstack/react-query' - -import {useSession} from '#/state/session' -import {api} from '#/state/purchases/subscriptions/api' -import {SubscriptionGroupId, PlatformId, Subscription} from '#/state/purchases/subscriptions/types' - -export function useSubscriptionsState() { - const {currentAccount} = useSession() - - return useQuery({ - enabled: !!currentAccount, - queryKey: ['subscriptions-state', currentAccount?.did], - refetchOnWindowFocus: true, - async queryFn() { - const url = `/account?did=${currentAccount!.did}` - const {data, error} = await api<{ - subscriptions: Subscription[] - entitlements: {id: 'core'; platform: 'web'}[] - }>(url).json() - - if (error || !data) { - throw new Error('Failed to fetch subscriptions state') - } - - return data - }, - }) -} diff --git a/src/state/purchases/subscriptions/useSyncPurchases.ts b/src/state/purchases/subscriptions/useSyncPurchases.ts deleted file mode 100644 index c68dcdb195..0000000000 --- a/src/state/purchases/subscriptions/useSyncPurchases.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {useMutation} from '@tanstack/react-query' -import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases' - -import {useSession} from '#/state/session' - -export function useSyncPurchases() { - const {currentAccount} = useSession() - return useMutation({ - async mutationFn() { - if (!currentAccount) { - throw new Error('Not logged in') - } - - try { - await Purchases.logIn(currentAccount.did) - return await Purchases.restorePurchases() - } catch (e: any) { - if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) { - console.log('recipt error', e) - } - throw e - } - }, - }) -} diff --git a/src/state/purchases/subscriptions/util.ts b/src/state/purchases/subscriptions/util.ts deleted file mode 100644 index 27ee4789b1..0000000000 --- a/src/state/purchases/subscriptions/util.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {OfferingId} from '#/state/purchases/subscriptions/types'; - -export function parseOfferingId(id: string): OfferingId { - switch (id) { - case 'coreMonthly': - return OfferingId.CoreMonthly; - case 'coreAnnual': - return OfferingId.CoreAnnual; - default: - throw new Error(`Unknown offering id: ${id}`); - } -} diff --git a/src/state/purchases/types.ts b/src/state/purchases/types.ts new file mode 100644 index 0000000000..ee0dd4844d --- /dev/null +++ b/src/state/purchases/types.ts @@ -0,0 +1,74 @@ +import type {PurchasesStoreProduct} from 'react-native-purchases' + +/** + * Primitives + */ + +export enum EntitlementId { + Core = 'core', +} + +export enum PlatformId { + Android = 'android', + Ios = 'ios', + Web = 'web', +} + +/** + * Subscription primitives + */ + +export enum SubscriptionGroupId { + Core = 'core', +} + +export enum SubscriptionOfferingId { + CoreMonthly = 'coreMonthly', + CoreAnnual = 'coreAnnual', +} + +export type SubscriptionOffering = + | { + id: SubscriptionOfferingId + platform: PlatformId.Ios | PlatformId.Android + package: PurchasesStoreProduct + } + | { + id: SubscriptionOfferingId + platform: PlatformId.Web + package: { + priceId: string + } + } + +/** + * API types for our toy server + */ + +export type APISubscription = { + group: SubscriptionGroupId + platform: PlatformId + renews: boolean + periodDtartsAt: string + periodEndsAt: string + purchasedAt: string +} + +export type APIEntitlement = { + id: EntitlementId +} + +/** + * Parsers + */ + +export function parseOfferingId(id: string): SubscriptionOfferingId { + switch (id) { + case 'coreMonthly': + return SubscriptionOfferingId.CoreMonthly + case 'coreAnnual': + return SubscriptionOfferingId.CoreAnnual + default: + throw new Error(`Unknown offering id: ${id}`) + } +} diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 7100c67849..3dc2b076c4 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -30,10 +30,6 @@ import { Bell_Stroke2_Corner0_Rounded as Bell, } from '#/components/icons/Bell' import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList' -import { - Gift1_Filled_Corner0_Rounded as GiftFilled, - Gift1_Stroke2_Corner0_Rounded as Gift, -} from '#/components/icons/Gift1' import { Hashtag_Filled_Corner0_Rounded as HashtagFilled, Hashtag_Stroke2_Corner0_Rounded as Hashtag, @@ -207,11 +203,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { setDrawerOpen(false) }, [navigation, setDrawerOpen]) - const onPressSubscriptions = React.useCallback(() => { - navigation.navigate('Subscriptions') - setDrawerOpen(false) - }, [navigation, setDrawerOpen]) - const onPressFeedback = React.useCallback(() => { Linking.openURL( FEEDBACK_FORM_URL({ @@ -273,10 +264,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { onPress={onPressProfile} /> - ) : ( <> @@ -546,31 +533,6 @@ let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => { } SettingsMenuItem = React.memo(SettingsMenuItem) -let SubscriptionsMenuItem = ({ - isActive, - onPress, -}: { - isActive: boolean - onPress: () => void -}): React.ReactNode => { - const {_} = useLingui() - const t = useTheme() - return ( - - ) : ( - - ) - } - label={_(msg`Support Bluesky`)} - onPress={onPress} - /> - ) -} -SubscriptionsMenuItem = React.memo(SubscriptionsMenuItem) - function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) { const t = useTheme() return ( diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 1634089c5d..354f549db2 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useKawaiiMode} from '#/state/preferences/kawaii' -import {useEntitlements} from '#/state/purchases/subscriptions/useEntitlements' +import {usePurchases} from '#/state/purchases' +import {EntitlementId} from '#/state/purchases/types' import {useSession} from '#/state/session' import {DesktopFeeds} from '#/view/shell/desktop/Feeds' import {DesktopSearch} from '#/view/shell/desktop/Search' @@ -27,8 +28,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) { const {_} = useLingui() const {hasSession, currentAccount} = useSession() const subscriptionsDialogControl = useDialogControl() - const {data: entitlements} = useEntitlements() - const isSubscribed = entitlements?.some(e => e.id === 'core') + const purchases = usePurchases() + const isSubscribed = + purchases.status === 'ready' && + purchases.entitlements?.some(e => e.id === EntitlementId.Core) const kawaii = useKawaiiMode()