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 '#/lib/sentry' // must be near top
import '#/view/icons' import '#/view/icons'
import Purchases from 'react-native-purchases'
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler' import {GestureHandlerRootView} from 'react-native-gesture-handler'
import Purchases from 'react-native-purchases'
import {RootSiblingParent} from 'react-native-root-siblings' import {RootSiblingParent} from 'react-native-root-siblings'
import { import {
initialWindowMetrics, initialWindowMetrics,
@@ -45,6 +45,7 @@ import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences' import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' 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 UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import { import {
Provider as SessionProvider, Provider as SessionProvider,
@@ -133,37 +134,39 @@ function InnerApp() {
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider> <PurchasesProvider>
<StatsigProvider> <ComposerProvider>
<MessagesProvider> <StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<ProgressGuideProvider> <MutedThreadsProvider>
<GestureHandlerRootView <ProgressGuideProvider>
style={s.h100pct}> <GestureHandlerRootView
<TestCtrls /> style={s.h100pct}>
<Shell /> <TestCtrls />
<NuxDialogs /> <Shell />
</GestureHandlerRootView> <NuxDialogs />
</ProgressGuideProvider> </GestureHandlerRootView>
</MutedThreadsProvider> </ProgressGuideProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HiddenRepliesProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</StatsigProvider> </MessagesProvider>
</ComposerProvider> </StatsigProvider>
</ComposerProvider>
</PurchasesProvider>
</QueryProvider> </QueryProvider>
</React.Fragment> </React.Fragment>
</VideoVolumeProvider> </VideoVolumeProvider>
@@ -178,15 +181,17 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
Promise.all([initPersistedState(), ensureGeolocationResolved(), Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG)]).then(() => { Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(
if (isIOS) { () => {
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY}) setReady(true)
} else if (isAndroid) { },
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY}) )
}
setReady(true) if (isIOS) {
}) Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
} else if (isAndroid) {
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
}
}, []) }, [])
if (!isReady) { 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 PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' 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 UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import { import {
Provider as SessionProvider, Provider as SessionProvider,
@@ -110,6 +111,8 @@ function InnerApp() {
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<PurchasesProvider>
<ComposerProvider> <ComposerProvider>
<StatsigProvider> <StatsigProvider>
<MessagesProvider> <MessagesProvider>
@@ -139,6 +142,7 @@ function InnerApp() {
</MessagesProvider> </MessagesProvider>
</StatsigProvider> </StatsigProvider>
</ComposerProvider> </ComposerProvider>
</PurchasesProvider>
</QueryProvider> </QueryProvider>
<ToastContainer /> <ToastContainer />
</React.Fragment> </React.Fragment>
+53 -22
View File
@@ -1,17 +1,18 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {
OfferingId,
SubscriptionGroupId,
} from '#/state/purchases/subscriptions/types'
import { import {
usePurchaseOffering, usePurchaseOffering,
useSubscriptionGroup, useSubscriptionGroup,
} from '#/state/purchases/subscriptions/useSubscriptionGroup' } from '#/state/purchases/hooks/useSubscriptionGroup'
import {parseOfferingId} from '#/state/purchases/subscriptions/util' import {
SubscriptionGroupId,
SubscriptionOfferingId,
} from '#/state/purchases/types'
import {parseOfferingId} from '#/state/purchases/types'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 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 {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
import {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check' import {CheckThick_Stroke2_Corner0_Rounded as CheckThink} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -30,20 +31,20 @@ export function BlueskyPlus({control}: {control: Dialog.DialogControlProps}) {
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<DialogInner /> <DialogInner control={control} />
</Dialog.Outer> </Dialog.Outer>
) )
} }
function DialogInner() { function DialogInner({control}: {control: Dialog.DialogControlProps}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const copy = useCoreOfferingCopy() const copy = useCoreOfferingCopy()
const [offeringId, setOfferingId] = React.useState<OfferingId>( const [offeringId, setOfferingId] = React.useState<SubscriptionOfferingId>(
OfferingId.CoreAnnual, SubscriptionOfferingId.CoreAnnual,
) )
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core) const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering() const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
@@ -57,13 +58,29 @@ function DialogInner() {
throw new Error('No offering') throw new Error('No offering')
} }
await purchaseOffering({ control.close(async () => {
did: currentAccount.did, try {
email: currentAccount.email!, await purchaseOffering({
offering, 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) { } 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" /> <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> <Trans>Let's build the social web.</Trans>
</Text> </Text>
@@ -99,7 +116,10 @@ function DialogInner() {
values={[offeringId]} values={[offeringId]}
onChange={values => setOfferingId(parseOfferingId(values[0]))} onChange={values => setOfferingId(parseOfferingId(values[0]))}
style={[a.w_full, a.gap_sm, a.pt_lg]}> style={[a.w_full, a.gap_sm, a.pt_lg]}>
{[OfferingId.CoreMonthly, OfferingId.CoreAnnual].map(id => { {[
SubscriptionOfferingId.CoreMonthly,
SubscriptionOfferingId.CoreAnnual,
].map(id => {
return ( return (
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}> <Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
{({selected, hovered}) => ( {({selected, hovered}) => (
@@ -209,7 +229,18 @@ function DialogInner() {
})} })}
</Toggle.Group> </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 <Button
label={_(msg`Subscribe`)} label={_(msg`Subscribe`)}
variant="solid" variant="solid"
@@ -217,7 +248,7 @@ function DialogInner() {
size="large" size="large"
onPress={onPressSubscribe} onPress={onPressSubscribe}
disabled={isPending} disabled={isPending}
style={[a.overflow_hidden]}> style={[a.flex_1, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.nordic} /> <GradientFill gradient={tokens.gradients.nordic} />
<ButtonText style={[t.atoms.text]}> <ButtonText style={[t.atoms.text]}>
<Trans>Subscribe</Trans> <Trans>Subscribe</Trans>
@@ -264,12 +295,12 @@ function useCoreOfferingCopy() {
const {_} = useLingui() const {_} = useLingui()
return React.useMemo(() => { return React.useMemo(() => {
return { return {
[OfferingId.CoreMonthly]: { [SubscriptionOfferingId.CoreMonthly]: {
title: _(msg`1 month`), title: _(msg`1 month`),
price: _(msg`$8 / month`), price: _(msg`$8 / month`),
discount: undefined, discount: undefined,
}, },
[OfferingId.CoreAnnual]: { [SubscriptionOfferingId.CoreAnnual]: {
title: _(msg`12 months`), title: _(msg`12 months`),
price: _(msg`$72 / year`), price: _(msg`$72 / year`),
discount: _(msg`Save 25%`), 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 {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
import {useSubscriptionsState} from '#/state/purchases/subscriptions/useSubscriptionsState' import {PurchasesState,usePurchases} from '#/state/purchases'
import {useSyncPurchases} from '#/state/purchases/subscriptions/useSyncPurchases' import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
import {useManageSubscription} from '#/state/purchases/subscriptions/useManageSubscription' import {SubscriptionGroupId} from '#/state/purchases/types'
import {Subscription} from '#/state/purchases/subscriptions/types' import {APISubscription} from '#/state/purchases/types'
import {CenteredView} from '#/view/com/util/Views' 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {BlueskyPlus} from '#/components/dialogs/BlueskyPlus' 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 {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 * as Layout from '#/components/Layout'
import {createStaticClick,InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {InlineLinkText, createStaticClick} from '#/components/Link'
export type ScreenProps = NativeStackScreenProps< export type ScreenProps = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -27,22 +34,7 @@ export type ScreenProps = NativeStackScreenProps<
export function Subscriptions(_props: ScreenProps) { export function Subscriptions(_props: ScreenProps) {
const {_} = useLingui() const {_} = useLingui()
const {data: state, isLoading: isStateLoading} = const purchases = usePurchases()
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 })
}
}
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -50,43 +42,14 @@ export function Subscriptions(_props: ScreenProps) {
<Layout.Content> <Layout.Content>
<CenteredView sideBorders={true} style={[a.util_screen_outer]}> <CenteredView sideBorders={true} style={[a.util_screen_outer]}>
<View style={[a.px_xl, a.py_xl, a.gap_lg]}> <View style={[a.px_xl, a.py_xl]}>
{isStateLoading ? ( {purchases.status === 'loading' ? (
<Loader /> <Loader />
) : ( ) : purchases.status === 'error' ? (
<> <View />
{isSubscribed ? ( ) : (
<CoreSubscriptions subscriptions={state!.subscriptions} /> <Core state={purchases} />
) : ( )}
<>
<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> </View>
</CenteredView> </CenteredView>
</Layout.Content> </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 t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const {mutateAsync: manageSubscription} = useManageSubscription() const {mutateAsync: manageSubscription} = useManageSubscription()
return props.subscriptions.map(sub => ( return props.subscriptions.map(sub => {
<View style={[a.p_lg, a.rounded_md, a.border, a.gap_xs, t.atoms.border_contrast_low]}> const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'})
<Text style={[a.text_lg, a.font_heavy]}>Bluesky+</Text> 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 ? ( <View style={[a.flex_row, a.justify_between, a.align_center]}>
<Text style={[a.text_sm]}> <View>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Renews:</Trans></Text>{' '} <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium", timeStyle: "medium" })}</Text> <Text
</Text> style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
) : ( Active
<Text style={[a.text_sm]}> </Text>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}><Trans>Ends:</Trans></Text>{' '} </View>
<Text style={[a.text_sm]}>{i18n.date(new Date(sub.periodEndsAt), { dateStyle: "medium" })}</Text>
</Text>
)}
<InlineLinkText <View style={[a.flex_row, a.align_center, a.gap_xs]}>
label={_('Manage subscription')} <StatusIcon size="sm" fill={t.atoms.text_contrast_low.color} />
{...createStaticClick(() => {manageSubscription()})} <Text style={[a.text_sm]}>{endDate}</Text>
> </View>
<Trans>Manage subscription</Trans> </View>
</InlineLinkText> <Link
</View> 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 {Linking} from 'react-native'
import {useMutation} from '@tanstack/react-query'
import Purchases from 'react-native-purchases' import Purchases from 'react-native-purchases'
import {useMutation} from '@tanstack/react-query'
export function useManageSubscription() { export function useManageSubscription() {
return useMutation({ return useMutation({
@@ -1,9 +1,9 @@
import {Linking} from 'react-native' import {Linking} from 'react-native'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {IS_DEV} from '#/env' import {api} from '#/state/purchases/api'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {api} from '#/state/purchases/subscriptions/api' import {IS_DEV} from '#/env'
export function useManageSubscription() { export function useManageSubscription() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -16,8 +16,10 @@ export function useManageSubscription() {
method: 'POST', method: 'POST',
json: { json: {
did: currentAccount!.did, 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() }).json()
if (error || !data) { 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 Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {useQuery, useMutation} from '@tanstack/react-query' import {useMutation,useQuery} from '@tanstack/react-query'
import Purchases, { PURCHASES_ERROR_CODE } from 'react-native-purchases'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {api} from '#/state/purchases/subscriptions/api' import {api} from '#/state/purchases/api'
import {OfferingId, PlatformId, Offering, SubscriptionGroupId} from '#/state/purchases/subscriptions/types' import {
parseOfferingId,
PlatformId,
SubscriptionGroupId,
SubscriptionOffering,
} from '#/state/purchases/types'
export function useSubscriptionGroup(group: SubscriptionGroupId) { export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{ offerings: Offering[] }>({ return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group], queryKey: ['subscription-group', group],
async queryFn() { async queryFn() {
const platform = isIOS ? 'ios' : 'android' 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) { if (error || !data) {
console.log(error, response) console.log(error, response)
throw new Error('Failed to fetch subscription group') throw new Error('Failed to fetch subscription group')
} }
const { offerings } = data const {offerings} = data
const productIds = offerings.map((o: any) => { const productIds = offerings.map((o: any) => {
return isIOS ? o.productId : o.productId.split(':')[0] return isIOS ? o.productId : o.productId.split(':')[0]
}) })
@@ -28,16 +34,18 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
return { return {
offerings: offerings.map((o: any) => { 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 { return {
id: o.id as OfferingId, id: parseOfferingId(o.id),
platform: isIOS ? PlatformId.Ios : PlatformId.Android, platform: isIOS ? PlatformId.Ios : PlatformId.Android,
price: product?.priceString, price: product?.priceString,
package: product, package: product,
} }
}), }),
} }
} },
}) })
} }
@@ -47,7 +55,11 @@ export function usePurchaseOffering() {
did, did,
email, email,
offering, offering,
}: { did: string, email: string, offering: Offering }) { }: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform === PlatformId.Web) { if (offering.platform === PlatformId.Web) {
throw new Error('Unsupported platform') throw new Error('Unsupported platform')
} }
@@ -63,6 +75,6 @@ export function usePurchaseOffering() {
throw e throw e
} }
} },
}) })
} }
@@ -1,31 +1,38 @@
import {Linking} from 'react-native' 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 {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) { export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{ offerings: Offering[] }>({ return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group], queryKey: ['subscription-group', group],
async queryFn() { 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) { if (error || !data) {
throw new Error('Failed to fetch subscription group') throw new Error('Failed to fetch subscription group')
} }
const { offerings } = data const {offerings} = data
return { return {
offerings: offerings.map((o: any) => ({ offerings: offerings.map((o: any) => ({
id: o.id as OfferingId, id: parseOfferingId(o.id),
platform: PlatformId.Web, platform: PlatformId.Web,
package: { package: {
priceId: o.productId, priceId: o.productId,
}, },
})), })),
} }
} },
}) })
} }
@@ -35,7 +42,11 @@ export function usePurchaseOffering() {
did, did,
email, email,
offering, offering,
}: { did: string, email: string, offering: Offering }) { }: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform !== PlatformId.Web) { if (offering.platform !== PlatformId.Web) {
throw new Error('Unsupported platform') throw new Error('Unsupported platform')
} }
@@ -59,6 +70,6 @@ export function usePurchaseOffering() {
} }
Linking.openURL(data.checkoutUrl) 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, Bell_Stroke2_Corner0_Rounded as Bell,
} from '#/components/icons/Bell' } from '#/components/icons/Bell'
import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList' 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 { import {
Hashtag_Filled_Corner0_Rounded as HashtagFilled, Hashtag_Filled_Corner0_Rounded as HashtagFilled,
Hashtag_Stroke2_Corner0_Rounded as Hashtag, Hashtag_Stroke2_Corner0_Rounded as Hashtag,
@@ -207,11 +203,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
setDrawerOpen(false) setDrawerOpen(false)
}, [navigation, setDrawerOpen]) }, [navigation, setDrawerOpen])
const onPressSubscriptions = React.useCallback(() => {
navigation.navigate('Subscriptions')
setDrawerOpen(false)
}, [navigation, setDrawerOpen])
const onPressFeedback = React.useCallback(() => { const onPressFeedback = React.useCallback(() => {
Linking.openURL( Linking.openURL(
FEEDBACK_FORM_URL({ FEEDBACK_FORM_URL({
@@ -273,10 +264,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
onPress={onPressProfile} onPress={onPressProfile}
/> />
<SettingsMenuItem onPress={onPressSettings} /> <SettingsMenuItem onPress={onPressSettings} />
<SubscriptionsMenuItem
isActive={false}
onPress={onPressSubscriptions}
/>
</> </>
) : ( ) : (
<> <>
@@ -546,31 +533,6 @@ let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
} }
SettingsMenuItem = React.memo(SettingsMenuItem) 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) { function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) {
const t = useTheme() const t = useTheme()
return ( 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 {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useKawaiiMode} from '#/state/preferences/kawaii' 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 {useSession} from '#/state/session'
import {DesktopFeeds} from '#/view/shell/desktop/Feeds' import {DesktopFeeds} from '#/view/shell/desktop/Feeds'
import {DesktopSearch} from '#/view/shell/desktop/Search' import {DesktopSearch} from '#/view/shell/desktop/Search'
@@ -27,8 +28,10 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
const {_} = useLingui() const {_} = useLingui()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const subscriptionsDialogControl = useDialogControl() const subscriptionsDialogControl = useDialogControl()
const {data: entitlements} = useEntitlements() const purchases = usePurchases()
const isSubscribed = entitlements?.some(e => e.id === 'core') const isSubscribed =
purchases.status === 'ready' &&
purchases.entitlements?.some(e => e.id === EntitlementId.Core)
const kawaii = useKawaiiMode() const kawaii = useKawaiiMode()