Init state, Android and web

This commit is contained in:
Eric Bailey
2024-10-25 16:05:30 -05:00
parent cc1cda8bf6
commit a6985a28ea
9 changed files with 957 additions and 0 deletions
+8
View File
@@ -5,6 +5,7 @@ import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import Purchases from 'react-native-purchases'
import {RootSiblingParent} from 'react-native-root-siblings'
import {
initialWindowMetrics,
@@ -24,6 +25,7 @@ import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid,isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -66,6 +68,7 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {RC_GOOGLE_PUBLIC_KEY} from '#/env'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -173,6 +176,11 @@ function App() {
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
setReady(true),
)
if (isIOS) {
} else if (isAndroid) {
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
}
}, [])
if (!isReady) {
+3
View File
@@ -7,3 +7,6 @@ export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
| 'info'
| 'warn'
| 'error'
export const RC_GOOGLE_PUBLIC_KEY =
process.env.EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY || ''
+302
View File
@@ -0,0 +1,302 @@
import React from 'react'
import {deviceLocales} from '#/locale/deviceLocales'
import {useGeolocation} from '#/state/geolocation'
import {useLanguagePrefs} from '#/state/preferences'
/**
* From react-native-localize
*
* MIT License
* Copyright (c) 2017-present, Mathieu Acthernoene
*
* @see https://github.com/zoontek/react-native-localize/blob/master/LICENSE
* @see https://github.com/zoontek/react-native-localize/blob/ee5bf25e0bb8f3b8e4f3fd055f67ad46269c81ea/src/constants.ts
*/
export const countryCodeToCurrency: Record<string, string> = {
ad: 'eur',
ae: 'aed',
af: 'afn',
ag: 'xcd',
ai: 'xcd',
al: 'all',
am: 'amd',
an: 'ang',
ao: 'aoa',
ar: 'ars',
as: 'usd',
at: 'eur',
au: 'aud',
aw: 'awg',
ax: 'eur',
az: 'azn',
ba: 'bam',
bb: 'bbd',
bd: 'bdt',
be: 'eur',
bf: 'xof',
bg: 'bgn',
bh: 'bhd',
bi: 'bif',
bj: 'xof',
bl: 'eur',
bm: 'bmd',
bn: 'bnd',
bo: 'bob',
bq: 'usd',
br: 'brl',
bs: 'bsd',
bt: 'btn',
bv: 'nok',
bw: 'bwp',
by: 'byn',
bz: 'bzd',
ca: 'cad',
cc: 'aud',
cd: 'cdf',
cf: 'xaf',
cg: 'xaf',
ch: 'chf',
ci: 'xof',
ck: 'nzd',
cl: 'clp',
cm: 'xaf',
cn: 'cny',
co: 'cop',
cr: 'crc',
cu: 'cup',
cv: 'cve',
cw: 'ang',
cx: 'aud',
cy: 'eur',
cz: 'czk',
de: 'eur',
dj: 'djf',
dk: 'dkk',
dm: 'xcd',
do: 'dop',
dz: 'dzd',
ec: 'usd',
ee: 'eur',
eg: 'egp',
eh: 'mad',
er: 'ern',
es: 'eur',
et: 'etb',
fi: 'eur',
fj: 'fjd',
fk: 'fkp',
fm: 'usd',
fo: 'dkk',
fr: 'eur',
ga: 'xaf',
gb: 'gbp',
gd: 'xcd',
ge: 'gel',
gf: 'eur',
gg: 'gbp',
gh: 'ghs',
gi: 'gip',
gl: 'dkk',
gm: 'gmd',
gn: 'gnf',
gp: 'eur',
gq: 'xaf',
gr: 'eur',
gs: 'gbp',
gt: 'gtq',
gu: 'usd',
gw: 'xof',
gy: 'gyd',
hk: 'hkd',
hm: 'aud',
hn: 'hnl',
hr: 'hrk',
ht: 'htg',
hu: 'huf',
id: 'idr',
ie: 'eur',
il: 'ils',
im: 'gbp',
in: 'inr',
io: 'usd',
iq: 'iqd',
ir: 'irr',
is: 'isk',
it: 'eur',
je: 'gbp',
jm: 'jmd',
jo: 'jod',
jp: 'jpy',
ke: 'kes',
kg: 'kgs',
kh: 'khr',
ki: 'aud',
km: 'kmf',
kn: 'xcd',
kp: 'kpw',
kr: 'krw',
kw: 'kwd',
ky: 'kyd',
kz: 'kzt',
la: 'lak',
lb: 'lbp',
lc: 'xcd',
li: 'chf',
lk: 'lkr',
lr: 'lrd',
ls: 'lsl',
lt: 'eur',
lu: 'eur',
lv: 'eur',
ly: 'lyd',
ma: 'mad',
mc: 'eur',
md: 'mdl',
me: 'eur',
mf: 'eur',
mg: 'mga',
mh: 'usd',
mk: 'mkd',
ml: 'xof',
mm: 'mmk',
mn: 'mnt',
mo: 'mop',
mp: 'usd',
mq: 'eur',
mr: 'mro',
ms: 'xcd',
mt: 'eur',
mu: 'mur',
mv: 'mvr',
mw: 'mwk',
mx: 'mxn',
my: 'myr',
mz: 'mzn',
na: 'nad',
nc: 'xpf',
ne: 'xof',
nf: 'aud',
ng: 'ngn',
ni: 'nio',
nl: 'eur',
no: 'nok',
np: 'npr',
nr: 'aud',
nu: 'nzd',
nz: 'nzd',
om: 'omr',
pa: 'pab',
pe: 'pen',
pf: 'xpf',
pg: 'pgk',
ph: 'php',
pk: 'pkr',
pl: 'pln',
pm: 'eur',
pn: 'nzd',
pr: 'usd',
ps: 'ils',
pt: 'eur',
pw: 'usd',
py: 'pyg',
qa: 'qar',
re: 'eur',
ro: 'ron',
rs: 'rsd',
ru: 'rub',
rw: 'rwf',
sa: 'sar',
sb: 'sbd',
sc: 'scr',
sd: 'sdg',
se: 'sek',
sg: 'sgd',
sh: 'shp',
si: 'eur',
sj: 'nok',
sk: 'eur',
sl: 'sll',
sm: 'eur',
sn: 'xof',
so: 'sos',
sr: 'srd',
ss: 'ssp',
st: 'std',
sv: 'svc',
sx: 'ang',
sy: 'syp',
sz: 'szl',
tc: 'usd',
td: 'xaf',
tf: 'eur',
tg: 'xof',
th: 'thb',
tj: 'tjs',
tk: 'nzd',
tl: 'usd',
tm: 'tmt',
tn: 'tnd',
to: 'top',
tr: 'try',
tt: 'ttd',
tv: 'aud',
tw: 'twd',
tz: 'tzs',
ua: 'uah',
ug: 'ugx',
um: 'usd',
us: 'usd',
uy: 'uyu',
uz: 'uzs',
va: 'eur',
vc: 'xcd',
ve: 'vef',
vg: 'usd',
vi: 'usd',
vn: 'vnd',
vu: 'vuv',
wf: 'xpf',
ws: 'wst',
ye: 'yer',
yt: 'eur',
za: 'zar',
zm: 'zmw',
zw: 'zwl',
}
/**
* Best-guess currency formatting.
*
* Attempts to use `getLocales` from `expo-localization` if available,
* otherwise falls back to the `persisted.appLanguage` setting, and geolocation
* API for region.
*/
export function useCurrencyFormatter(
options?: Parameters<typeof Intl.NumberFormat>[1],
) {
const {geolocation} = useGeolocation()
const {appLanguage} = useLanguagePrefs()
return React.useMemo(() => {
const locale = deviceLocales.at(0)
const languageTag = locale?.languageTag || appLanguage || 'en-US'
const countryCode = (
locale?.regionCode ||
geolocation?.countryCode ||
'us'
).toLowerCase()
const currency = countryCodeToCurrency[countryCode]
const format = new Intl.NumberFormat(languageTag, {
...(options || {}),
style: 'currency',
currency: currency,
}).format
return {
format,
currency,
countryCode,
languageTag,
}
}, [geolocation, appLanguage, options])
}
+260
View File
@@ -0,0 +1,260 @@
/**
* From react-native-localize
*
* MIT License
* Copyright (c) 2017-present, Mathieu Acthernoene
*
* @see https://github.com/zoontek/react-native-localize/blob/master/LICENSE
* @see https://github.com/zoontek/react-native-localize/blob/ee5bf25e0bb8f3b8e4f3fd055f67ad46269c81ea/src/constants.ts
*/
export const countryCodeToCurrency: Record<string, string> = {
ad: 'eur',
ae: 'aed',
af: 'afn',
ag: 'xcd',
ai: 'xcd',
al: 'all',
am: 'amd',
an: 'ang',
ao: 'aoa',
ar: 'ars',
as: 'usd',
at: 'eur',
au: 'aud',
aw: 'awg',
ax: 'eur',
az: 'azn',
ba: 'bam',
bb: 'bbd',
bd: 'bdt',
be: 'eur',
bf: 'xof',
bg: 'bgn',
bh: 'bhd',
bi: 'bif',
bj: 'xof',
bl: 'eur',
bm: 'bmd',
bn: 'bnd',
bo: 'bob',
bq: 'usd',
br: 'brl',
bs: 'bsd',
bt: 'btn',
bv: 'nok',
bw: 'bwp',
by: 'byn',
bz: 'bzd',
ca: 'cad',
cc: 'aud',
cd: 'cdf',
cf: 'xaf',
cg: 'xaf',
ch: 'chf',
ci: 'xof',
ck: 'nzd',
cl: 'clp',
cm: 'xaf',
cn: 'cny',
co: 'cop',
cr: 'crc',
cu: 'cup',
cv: 'cve',
cw: 'ang',
cx: 'aud',
cy: 'eur',
cz: 'czk',
de: 'eur',
dj: 'djf',
dk: 'dkk',
dm: 'xcd',
do: 'dop',
dz: 'dzd',
ec: 'usd',
ee: 'eur',
eg: 'egp',
eh: 'mad',
er: 'ern',
es: 'eur',
et: 'etb',
fi: 'eur',
fj: 'fjd',
fk: 'fkp',
fm: 'usd',
fo: 'dkk',
fr: 'eur',
ga: 'xaf',
gb: 'gbp',
gd: 'xcd',
ge: 'gel',
gf: 'eur',
gg: 'gbp',
gh: 'ghs',
gi: 'gip',
gl: 'dkk',
gm: 'gmd',
gn: 'gnf',
gp: 'eur',
gq: 'xaf',
gr: 'eur',
gs: 'gbp',
gt: 'gtq',
gu: 'usd',
gw: 'xof',
gy: 'gyd',
hk: 'hkd',
hm: 'aud',
hn: 'hnl',
hr: 'hrk',
ht: 'htg',
hu: 'huf',
id: 'idr',
ie: 'eur',
il: 'ils',
im: 'gbp',
in: 'inr',
io: 'usd',
iq: 'iqd',
ir: 'irr',
is: 'isk',
it: 'eur',
je: 'gbp',
jm: 'jmd',
jo: 'jod',
jp: 'jpy',
ke: 'kes',
kg: 'kgs',
kh: 'khr',
ki: 'aud',
km: 'kmf',
kn: 'xcd',
kp: 'kpw',
kr: 'krw',
kw: 'kwd',
ky: 'kyd',
kz: 'kzt',
la: 'lak',
lb: 'lbp',
lc: 'xcd',
li: 'chf',
lk: 'lkr',
lr: 'lrd',
ls: 'lsl',
lt: 'eur',
lu: 'eur',
lv: 'eur',
ly: 'lyd',
ma: 'mad',
mc: 'eur',
md: 'mdl',
me: 'eur',
mf: 'eur',
mg: 'mga',
mh: 'usd',
mk: 'mkd',
ml: 'xof',
mm: 'mmk',
mn: 'mnt',
mo: 'mop',
mp: 'usd',
mq: 'eur',
mr: 'mro',
ms: 'xcd',
mt: 'eur',
mu: 'mur',
mv: 'mvr',
mw: 'mwk',
mx: 'mxn',
my: 'myr',
mz: 'mzn',
na: 'nad',
nc: 'xpf',
ne: 'xof',
nf: 'aud',
ng: 'ngn',
ni: 'nio',
nl: 'eur',
no: 'nok',
np: 'npr',
nr: 'aud',
nu: 'nzd',
nz: 'nzd',
om: 'omr',
pa: 'pab',
pe: 'pen',
pf: 'xpf',
pg: 'pgk',
ph: 'php',
pk: 'pkr',
pl: 'pln',
pm: 'eur',
pn: 'nzd',
pr: 'usd',
ps: 'ils',
pt: 'eur',
pw: 'usd',
py: 'pyg',
qa: 'qar',
re: 'eur',
ro: 'ron',
rs: 'rsd',
ru: 'rub',
rw: 'rwf',
sa: 'sar',
sb: 'sbd',
sc: 'scr',
sd: 'sdg',
se: 'sek',
sg: 'sgd',
sh: 'shp',
si: 'eur',
sj: 'nok',
sk: 'eur',
sl: 'sll',
sm: 'eur',
sn: 'xof',
so: 'sos',
sr: 'srd',
ss: 'ssp',
st: 'std',
sv: 'svc',
sx: 'ang',
sy: 'syp',
sz: 'szl',
tc: 'usd',
td: 'xaf',
tf: 'eur',
tg: 'xof',
th: 'thb',
tj: 'tjs',
tk: 'nzd',
tl: 'usd',
tm: 'tmt',
tn: 'tnd',
to: 'top',
tr: 'try',
tt: 'ttd',
tv: 'aud',
tw: 'twd',
tz: 'tzs',
ua: 'uah',
ug: 'ugx',
um: 'usd',
us: 'usd',
uy: 'uyu',
uz: 'uzs',
va: 'eur',
vc: 'xcd',
ve: 'vef',
vg: 'usd',
vi: 'usd',
vn: 'vnd',
vu: 'vuv',
wf: 'xpf',
ws: 'wst',
ye: 'yer',
yt: 'eur',
za: 'zar',
zm: 'zmw',
zw: 'zwl',
}
@@ -0,0 +1,54 @@
import Purchases, {PurchasesPackage} from 'react-native-purchases'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation,useQuery} from '@tanstack/react-query'
import {Subscription} from '#/state/purchases/useSubscriptions/types'
import {identifierToSubscriptionInfo} from '#/state/purchases/useSubscriptions/util'
import {useSession} from '#/state/session'
export function useAvailableSubscriptions() {
const {_} = useLingui()
const {currentAccount} = useSession()
const did = currentAccount!.did
return useQuery<Subscription[]>({
queryKey: ['availableSubscriptions', did],
async queryFn() {
Purchases.logIn(did)
const offerings = await Purchases.getOfferings()
if (
offerings.current !== null &&
offerings.current.availablePackages.length !== 0
) {
return normalizePackages(offerings.current.availablePackages)
}
throw new Error(_(msg`Error loading subscriptions`))
},
})
}
export function usePurchaseSubscription() {
return useMutation({
async mutationFn(pkg: PurchasesPackage) {
return Purchases.purchasePackage(pkg)
},
})
}
function normalizePackages(pkgs: PurchasesPackage[]): Subscription[] {
return pkgs
.map(p => {
const info = identifierToSubscriptionInfo(p.product.identifier)
if (!info) return
return {
info,
pricing: {
formatted: p.product.priceString,
value: p.product.price,
},
raw: p,
}
})
.filter(Boolean) as Subscription[]
}
@@ -0,0 +1,198 @@
import {useMutation,useQuery} from '@tanstack/react-query'
import {useCurrencyFormatter} from '#/lib/currency'
import {Subscription} from '#/state/purchases/useSubscriptions/types'
import {
StripePrice,
StripeProduct,
} from '#/state/purchases/useSubscriptions/types/stripe'
import {identifierToSubscriptionInfo} from '#/state/purchases/useSubscriptions/util'
import {useSession} from '#/state/session'
export function useAvailableSubscriptions() {
const {currentAccount} = useSession()
const currencyFormatter = useCurrencyFormatter()
return useQuery<Subscription[]>({
queryKey: ['availableSubscriptions', currentAccount!.did],
async queryFn() {
return normalizeProducts(RESPONSE, {
currencyFormatter,
})
},
})
}
export function usePurchaseSubscription() {
return useMutation({
async mutationFn(_priceObject: any) {},
})
}
function normalizeProducts(
products: {product: StripeProduct; price: StripePrice}[],
options: {
currencyFormatter: ReturnType<typeof useCurrencyFormatter>
},
): Subscription[] {
return products
.map(({product, price}) => {
const info = identifierToSubscriptionInfo(product.name)
if (!info) return
const priceObj =
price.currency_options[options.currencyFormatter.currency]
const value = priceObj.unit_amount
const formatted = options.currencyFormatter.format(value / 100)
return {
info,
pricing: {
value,
formatted,
},
raw: {
price_id: price.id,
},
}
})
.filter(Boolean) as Subscription[]
}
const RESPONSE = [
{
product: {
id: 'prod_R2eNjNa6mB1Jlu',
object: 'product',
active: true,
attributes: [],
created: 1729092225,
default_price: 'price_1QAZ4fAwTlpRxHkARl1gtw5C',
description: null,
features: [],
images: [],
livemode: false,
marketing_features: [],
metadata: {},
name: 'bsky_tier_0_monthly',
package_dimensions: null,
shippable: null,
statement_descriptor: null,
tax_code: 'txcd_10000000',
type: 'service',
unit_label: null,
updated: 1729869343,
url: null,
},
price: {
id: 'price_1QAZ4fAwTlpRxHkARl1gtw5C',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1729092225,
currency: 'usd',
currency_options: {
gbp: {
custom_unit_amount: null,
tax_behavior: 'inclusive',
unit_amount: 250,
unit_amount_decimal: '250',
},
usd: {
custom_unit_amount: null,
tax_behavior: 'inclusive',
unit_amount: 300,
unit_amount_decimal: '300',
},
},
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_R2eNjNa6mB1Jlu',
recurring: {
aggregate_usage: null,
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'inclusive',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 300,
unit_amount_decimal: '300',
},
},
{
product: {
id: 'prod_R37Zg28EeQ9XAz',
object: 'product',
active: true,
attributes: [],
created: 1729200807,
default_price: 'price_1QB1K0AwTlpRxHkA4Mdfxati',
description: null,
features: [],
images: [],
livemode: false,
marketing_features: [],
metadata: {},
name: 'bsky_tier_0_annual',
package_dimensions: null,
shippable: null,
statement_descriptor: null,
tax_code: 'txcd_10000000',
type: 'service',
unit_label: null,
updated: 1729869329,
url: null,
},
price: {
id: 'price_1QB1K0AwTlpRxHkA4Mdfxati',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1729200808,
currency: 'usd',
currency_options: {
gbp: {
custom_unit_amount: null,
tax_behavior: 'inclusive',
unit_amount: 2000,
unit_amount_decimal: '2000',
},
usd: {
custom_unit_amount: null,
tax_behavior: 'inclusive',
unit_amount: 2400,
unit_amount_decimal: '2400',
},
},
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_R37Zg28EeQ9XAz',
recurring: {
aggregate_usage: null,
interval: 'year',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'inclusive',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 2400,
unit_amount_decimal: '2400',
},
},
]
@@ -0,0 +1,19 @@
export enum SubscriptionIdentifier {
Tier0MonthlyAuto = 'Tier0MonthlyAuto',
Tier0AnnualAuto = 'Tier0AnnualAuto',
Tier1MonthlyAuto = 'Tier1MonthlyAuto',
Tier1AnnualAuto = 'Tier1AnnualAuto',
Tier2MonthlyAuto = 'Tier2MonthlyAuto',
Tier2AnnualAuto = 'Tier2AnnualAuto',
}
export type SubscriptionInfo = {
identifier: SubscriptionIdentifier
interval: 'monthly' | 'annual'
autoRenew: boolean
}
export type Subscription = {
info: SubscriptionInfo
raw: any
}
@@ -0,0 +1,60 @@
export type StripeProduct = {
// main stuff
id: string
default_price: string // ID of Price obj
name: string
active: boolean
// other stuff
object: string
attributes: unknown[]
created: number
description: string | null
features: unknown[]
images: unknown[]
livemode: boolean
marketing_features: unknown[]
metadata: unknown
package_dimensions: unknown | null
shippable: unknown | null
statement_descriptor: unknown | null
tax_code: string
type: 'service' | string
unit_label: unknown | null
updated: number
url: string | null
}
export type StripePrice = {
// main stuff
id: string
product: string // Product obj associated
currency_options: {
[key: string]: {
custom_unit_amount: number | null
tax_behavior: 'inclusive' | string
unit_amount: number
unit_amount_decimal: string
}
}
active: boolean
recurring: {
interval: 'month' | 'year' | string
interval_count: number
usage_type: 'licensed' | string
}
object: 'price' | string
type: 'recurring' | string
// other stuff
metadata: unknown
unit_amount: number // default Price in cents
currency: string // default currency
tax_behavior: 'inclusive' | string // default tax behavior
billing_scheme: 'per_unit' | string
created: number
custom_unit_amount: number | null
livemode: boolean
lookup_key: string | null
nickname: string | null
tiers_mode: string | null
transform_quantity: string | null
}
@@ -0,0 +1,53 @@
import {
SubscriptionIdentifier,
SubscriptionInfo,
} from '#/state/purchases/useSubscriptions/types'
export function identifierToSubscriptionInfo(
identifier: string,
): SubscriptionInfo | undefined {
switch (identifier) {
/*
* Android
*/
case 'bsky_tier_0:monthly-auto': {
return {
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'bsky_tier_0:annual-auto': {
return {
identifier: SubscriptionIdentifier.Tier0AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
/*
* Stripe
*/
case 'bsky_tier_0_monthly': {
return {
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'bsky_tier_0_annual': {
return {
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
interval: 'annual',
autoRenew: true,
}
}
/*
* Fallback
*/
default: {
return undefined
}
}
}