This commit is contained in:
Eric Bailey
2024-11-20 21:11:49 -06:00
parent 8857758789
commit a39c51490d
27 changed files with 687 additions and 379 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm1 6a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l2.5 2.5a1 1 0 0 0 1.414-1.414L13 11.586V8Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 332 B

+45 -40
View File
@@ -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}>
<QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
<PurchasesProvider>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</PurchasesProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
@@ -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) {
+4
View File
@@ -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}>
<QueryProvider currentDid={currentAccount?.did}>
<PurchasesProvider>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
@@ -139,6 +142,7 @@ function InnerApp() {
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</PurchasesProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
+53 -22
View File
@@ -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 (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogInner />
<DialogInner control={control} />
</Dialog.Outer>
)
}
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>(
OfferingId.CoreAnnual,
const [offeringId, setOfferingId] = React.useState<SubscriptionOfferingId>(
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() {
]}>
<BlueskyPlusLogo width={100} fill="nordic" />
<Text style={[a.text_3xl, a.font_heavy, a.pt_lg]}>
<Text style={[a.text_3xl, a.font_heavy, a.pt_md]}>
<Trans>Let's build the social web.</Trans>
</Text>
@@ -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 (
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
{({selected, hovered}) => (
@@ -209,7 +229,18 @@ function DialogInner() {
})}
</Toggle.Group>
<View style={[a.pt_md]}>
<View style={[a.flex_row, a.pt_md, a.gap_sm]}>
<Link
to="/subscriptions"
label={_(msg`Learn more about Bluesky+`)}
variant="solid"
color="secondary"
size="large"
style={[a.flex_1]}>
<ButtonText style={[a.flex_1]}>
<Trans>Learn more</Trans>
</ButtonText>
</Link>
<Button
label={_(msg`Subscribe`)}
variant="solid"
@@ -217,7 +248,7 @@ function DialogInner() {
size="large"
onPress={onPressSubscribe}
disabled={isPending}
style={[a.overflow_hidden]}>
style={[a.flex_1, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[t.atoms.text]}>
<Trans>Subscribe</Trans>
@@ -264,12 +295,12 @@ function useCoreOfferingCopy() {
const {_} = useLingui()
return React.useMemo(() => {
return {
[OfferingId.CoreMonthly]: {
[SubscriptionOfferingId.CoreMonthly]: {
title: _(msg`1 month`),
price: _(msg`$8 / month`),
discount: undefined,
},
[OfferingId.CoreAnnual]: {
[SubscriptionOfferingId.CoreAnnual]: {
title: _(msg`12 months`),
price: _(msg`$72 / year`),
discount: _(msg`Save 25%`),
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Clock_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm1 6a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l2.5 2.5a1 1 0 0 0 1.414-1.414L13 11.586V8Z',
})
+275 -82
View File
@@ -5,20 +5,27 @@ import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useSubscriptionsState} from '#/state/purchases/subscriptions/useSubscriptionsState'
import {useSyncPurchases} from '#/state/purchases/subscriptions/useSyncPurchases'
import {useManageSubscription} from '#/state/purchases/subscriptions/useManageSubscription'
import {Subscription} from '#/state/purchases/subscriptions/types'
import {PurchasesState,usePurchases} from '#/state/purchases'
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
import {SubscriptionGroupId} from '#/state/purchases/types'
import {APISubscription} from '#/state/purchases/types'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens,useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BlueskyPlus} from '#/components/dialogs/BlueskyPlus'
import {GradientFill} from '#/components/GradientFill'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Rotate} from '#/components/icons/ArrowRotateCounterClockwise'
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import * as Layout from '#/components/Layout'
import {createStaticClick,InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {InlineLinkText, createStaticClick} from '#/components/Link'
export type ScreenProps = NativeStackScreenProps<
CommonNavigatorParams,
@@ -27,22 +34,7 @@ export type ScreenProps = NativeStackScreenProps<
export function Subscriptions(_props: ScreenProps) {
const {_} = useLingui()
const {data: state, isLoading: isStateLoading} =
useSubscriptionsState()
const isSubscribed = state?.entitlements?.some(e => e.id === 'core')
const control = useDialogControl()
const {mutateAsync: syncPurchases} = useSyncPurchases()
const [data, setData] = React.useState<any>(null)
const sync = async () => {
try {
const data = await syncPurchases()
setData(data)
} catch (e: any) {
setData({ message: e.message })
}
}
const purchases = usePurchases()
return (
<Layout.Screen>
@@ -50,43 +42,14 @@ export function Subscriptions(_props: ScreenProps) {
<Layout.Content>
<CenteredView sideBorders={true} style={[a.util_screen_outer]}>
<View style={[a.px_xl, a.py_xl, a.gap_lg]}>
{isStateLoading ? (
<Loader />
) : (
<>
{isSubscribed ? (
<CoreSubscriptions subscriptions={state!.subscriptions} />
) : (
<>
<Button
label={_('Subscribe')}
onPress={() => control.open()}
size="large"
variant="solid"
color="primary">
<ButtonText>Subscribe</ButtonText>
<ButtonIcon icon={Plus} />
</Button>
<BlueskyPlus control={control} />
</>
)}
</>
)}
<Button
label={_('Subscribe')}
onPress={() => sync()}
size="large"
variant="solid"
color="secondary">
<ButtonText>Restore Purchase</ButtonText>
<ButtonIcon icon={Plus} />
</Button>
{data && (
<Text>{JSON.stringify(data, null, 2)}</Text>
)}
<View style={[a.px_xl, a.py_xl]}>
{purchases.status === 'loading' ? (
<Loader />
) : purchases.status === 'error' ? (
<View />
) : (
<Core state={purchases} />
)}
</View>
</CenteredView>
</Layout.Content>
@@ -94,33 +57,263 @@ export function Subscriptions(_props: ScreenProps) {
)
}
function CoreSubscriptions(props: { subscriptions: Subscription[] }) {
function Core({
state,
}: {
state: Exclude<PurchasesState, {status: 'loading' | 'error'}>
}) {
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 (
<View style={[a.pt_sm]}>
<BlueskyPlusLogo width={130} fill="nordic" />
{isSubscribedToCore ? (
<View style={[a.pt_md]}>
<CoreSubscriptions subscriptions={state.subscriptions} />
</View>
) : null}
{!isSubscribedToCore && (
<>
{state.config.nativePurchaseRestricted === 'yes' ? (
<View style={[a.pt_md]}>
<Admonition type="info">
<Trans>
Another account on this device is already subscribed to
Bluesky+. Please subscribe additional accounts through our web
application.
</Trans>
</Admonition>
</View>
) : (
<>
<Text style={[a.text_3xl, a.font_heavy, a.pt_md, a.pb_xs]}>
<Trans>Building a better internet needs your support.</Trans>
</Text>
<View style={[a.gap_xs]}>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>
Subscribing to Bluesky+ helps ensure that our work to build
an open, secure, and user-first internet can continue.
</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.pt_xs,
t.atoms.text_contrast_medium,
]}>
<Trans>Plus, you'll get access to exclusive features!</Trans>
</Text>
</View>
<View
style={[
a.my_md,
a.p_lg,
a.gap_sm,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
{features.map(f => (
<View key={f.text} style={[a.flex_row, a.gap_md]}>
<View style={{paddingTop: 2}}>
<f.icon
fill={
f.available
? t.palette.primary_500
: t.atoms.text_contrast_low.color
}
size="sm"
/>
</View>
<Text
style={[
a.text_md,
a.leading_snug,
f.available
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{f.text}
</Text>
</View>
))}
</View>
<Button
label={_('Subscribe')}
onPress={() => control.open()}
size="large"
variant="solid"
color="primary"
style={[a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[t.atoms.text]}>
<Trans>Subscribe</Trans>
</ButtonText>
<ButtonIcon
icon={Plus}
position="right"
style={[t.atoms.text]}
/>
</Button>
<BlueskyPlus control={control} />
</>
)}
</>
)}
<View style={[a.pt_md]}>
<Text style={[a.mb_md, a.text_xs]}>
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
Terms and Conditions
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
EULA
</InlineLinkText>
</Text>
<Admonition type="tip">
<Trans>
Learn more about Bluesky+ and our roadmap{' '}
<InlineLinkText
to="https://bsky.social/about"
label={_(msg`Learn more in our FAQ`)}>
here
</InlineLinkText>
</Trans>
</Admonition>
</View>
</View>
)
}
function CoreSubscriptions(props: {subscriptions: APISubscription[]}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {mutateAsync: manageSubscription} = useManageSubscription()
return props.subscriptions.map(sub => (
<View style={[a.p_lg, a.rounded_md, a.border, a.gap_xs, t.atoms.border_contrast_low]}>
<Text style={[a.text_lg, a.font_heavy]}>Bluesky+</Text>
return props.subscriptions.map(sub => {
const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'})
const StatusIcon = sub.renews ? Rotate : Clock
return (
<View
key={sub.purchasedAt}
style={[
a.p_lg,
a.py_md,
a.rounded_md,
a.border,
a.gap_xs,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<GradientFill
gradient={tokens.gradients.nordic}
style={{opacity: 0.1}}
/>
{sub.renews ? (
<Text style={[a.text_sm]}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Renews:</Trans></Text>{' '}
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium", timeStyle: "medium" })}</Text>
</Text>
) : (
<Text style={[a.text_sm]}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Ends:</Trans></Text>{' '}
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium" })}</Text>
</Text>
)}
<View style={[a.flex_row, a.justify_between, a.align_center]}>
<View>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
Active
</Text>
</View>
<InlineLinkText
label={_('Manage subscription')}
{...createStaticClick(() => {manageSubscription()})}
>
<Trans>Manage subscription</Trans>
</InlineLinkText>
</View>
))
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<StatusIcon size="sm" fill={t.atoms.text_contrast_low.color} />
<Text style={[a.text_sm]}>{endDate}</Text>
</View>
</View>
<Link
label={_('Manage subscription')}
size="small"
variant="ghost"
shape="round"
{...createStaticClick(() => {
manageSubscription()
})}
style={[a.justify_center]}>
<ButtonIcon icon={Gear} size="lg" />
</Link>
</View>
</View>
)
})
}
+27
View File
@@ -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<PurchasesState>({
status: 'loading',
})
@@ -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({
@@ -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) {
@@ -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])
}
@@ -0,0 +1 @@
export function useNativeEventsListener() {}
@@ -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<NativePurchaseRestricted>('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,
}
}
@@ -0,0 +1,5 @@
export function useNativeUserState() {
return {
restricted: false,
}
}
@@ -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
},
})
}
@@ -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
}
}
},
})
}
@@ -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)
}
},
})
}
+51
View File
@@ -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<PurchasesState>(() => {
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 <Context.Provider value={ctx}>{children}</Context.Provider>
}
export function usePurchases() {
return React.useContext(Context)
}
@@ -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
}
@@ -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
},
})
}
@@ -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
},
})
}
@@ -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
},
})
}
@@ -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
}
},
})
}
-12
View File
@@ -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}`);
}
}
+74
View File
@@ -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}`)
}
}
-38
View File
@@ -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}
/>
<SettingsMenuItem onPress={onPressSettings} />
<SubscriptionsMenuItem
isActive={false}
onPress={onPressSubscriptions}
/>
</>
) : (
<>
@@ -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 (
<MenuItem
icon={
isActive ? (
<GiftFilled style={[t.atoms.text]} width={iconWidth} />
) : (
<Gift style={[t.atoms.text]} width={iconWidth} />
)
}
label={_(msg`Support Bluesky`)}
onPress={onPress}
/>
)
}
SubscriptionsMenuItem = React.memo(SubscriptionsMenuItem)
function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) {
const t = useTheme()
return (
+6 -3
View File
@@ -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()