Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 757c12c9f3 | |||
| 58d4935ffc | |||
| 987a5a8cac | |||
| 14e475e539 | |||
| f4f9c9644a | |||
| ccc9745639 | |||
| 1269a55958 | |||
| be2606ea9d | |||
| 9e65f7151c | |||
| c916b57e8d | |||
| 62ddf1b2f8 | |||
| 0422071844 | |||
| 36b01417c2 | |||
| 280c044231 | |||
| dd66944777 | |||
| fea90b72f9 | |||
| 2ddeb9b15f | |||
| 7d8ac45e90 | |||
| c9b5c2457f | |||
| cf281a2c43 | |||
| cd870d2485 | |||
| dcad549eb5 | |||
| 036f0e2a68 | |||
| b9ee26ab3c | |||
| 1e7eaf0ee6 | |||
| 533ebf5daa | |||
| 33181ab8ce | |||
| 011ef85230 | |||
| 6e79fec72d | |||
| 2e2b650209 | |||
| 0cf44f7749 | |||
| 91568cc574 | |||
| 3445363031 | |||
| 7e0e0a2900 | |||
| 69f761dcc2 | |||
| d8e8f398c4 | |||
| 769917e4ca | |||
| 54f655d256 | |||
| a39c51490d | |||
| 8857758789 | |||
| 262651f966 | |||
| b63cb9bf5d | |||
| 4db49edca6 | |||
| 5e4bf58811 | |||
| df9101774c | |||
| 0f15abc246 | |||
| 9be5761885 | |||
| a284df0cd5 | |||
| 4cc2690365 | |||
| bdc16e0a15 | |||
| 13e871f973 |
@@ -64,6 +64,9 @@ jobs:
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY=${{ secrets.EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_RC_APPLE_PUBLIC_KEY=${{ secrets.EXPO_PUBLIC_RC_APPLE_PUBLIC_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_BSKY_PURCHASES_API=${{ secrets.EXPO_PUBLIC_BSKY_PURCHASES_API }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -76,6 +76,9 @@ jobs:
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY=${{ secrets.EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_RC_APPLE_PUBLIC_KEY=${{ secrets.EXPO_PUBLIC_RC_APPLE_PUBLIC_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_BSKY_PURCHASES_API=${{ secrets.EXPO_PUBLIC_BSKY_PURCHASES_API }}" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 448 90"><path fill="#F0F2F5" fill-rule="evenodd" d="M49.5 40.389c-4.483-9.147-16.69-26.193-28.04-34.6C13.27-.277 0-4.971 0 9.964c0 2.983 1.733 25.057 2.75 28.641 3.534 12.46 16.41 15.637 27.866 13.714C10.593 55.68 5.5 66.817 16.5 77.953c20.891 21.15 30.026-5.307 32.367-12.086.43-1.242.63-1.824.633-1.33.003-.494.204.088.633 1.33 2.34 6.78 11.476 33.235 32.367 12.086 11-11.136 5.907-22.273-14.116-25.635 11.455 1.924 24.332-1.254 27.866-13.713C97.267 35.021 99 12.947 99 9.965 99-4.972 85.73-.278 77.54 5.788c-11.35 8.407-23.557 25.453-28.04 34.6Zm107.33.725c6.787 2.477 10.39 8.198 10.39 14.602 0 10.844-7.122 17.418-20.78 17.418h-27.901V12.34h26.98c12.987 0 19.271 6.746 19.271 15.711 0 5.977-2.681 10.332-7.96 13.064Zm-12.149-19.297H129.85V37.7h14.831c5.781 0 8.881-3.074 8.881-8.197 0-4.696-3.184-7.685-8.881-7.685ZM129.85 63.571h16.004c6.368 0 9.803-2.988 9.803-8.368q0-8.453-9.803-8.453H129.85v16.821Zm53.053 9.563h-10.557V12.34h10.557v60.795Zm35.005-44.059v24.677c0 7.258-4.525 11.1-9.636 11.1-5.446 0-8.127-2.818-8.127-8.368V29.075h-10.558v29.202c0 10.246 5.949 15.882 15.082 15.882q8.673 0 13.574-7.428v6.403h10.222V29.075h-10.557Zm58.611 22.798v2.562h-32.342c.754 7.6 4.86 11.356 11.228 11.356 4.859 0 8.127-2.134 9.887-6.318h10.138c-2.262 8.965-9.803 14.686-20.109 14.686-6.452 0-11.646-2.135-15.585-6.319s-5.948-9.734-5.948-16.735c0-6.917 1.927-12.467 5.865-16.736 3.938-4.184 9.049-6.319 15.5-6.319 6.536 0 11.731 2.22 15.585 6.575s5.781 10.161 5.781 17.248ZM255.07 36.418c-5.782 0-9.804 3.416-10.809 10.503h21.701c-.922-6.404-4.776-10.503-10.892-10.503Zm25.075 22.713c.586 10.161 7.205 15.199 19.773 15.199 12.15 0 19.188-4.953 19.188-13.833 0-7.514-4.525-11.442-14.998-13.405l-5.446-.854c-5.028-.94-7.206-2.306-7.206-5.38q0-5.123 7.541-5.123c5.781 0 8.881 1.879 9.133 7.258h9.97c-.419-9.99-6.787-14.943-19.103-14.943-11.479 0-17.931 5.038-17.931 13.32 0 7.259 4.692 11.699 14.16 13.321l4.944.854c6.451 1.11 8.462 2.647 8.462 5.806 0 3.5-2.849 5.294-8.546 5.294-6.368 0-9.049-2.05-9.636-7.514h-10.305Zm85.298 14.003h-12.066l-12.568-20.492-6.536 6.66v13.832h-10.389V12.34h10.389v35.009l17.596-18.273h23.801l3.519 11.015a419 419 0 0 1 6.871 24.591c1.843-7.002 4.189-15.199 6.954-24.506l3.519-11.1h11.06l-16.674 47.646c-1.759 4.867-3.938 8.367-6.703 10.331s-6.703 2.904-11.898 2.904c-1.759 0-3.267-.086-4.608-.257v-8.453h4.022c4.776 0 7.122-2.988 7.122-7.087 0-2.05-.67-5.038-2.011-8.88l-12.526-36.082-16.219 16.528 17.345 27.41ZM424.5 29h10v13.5H448v10h-13.5V66h-10V52.5H411v-10h13.5V29Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 330 78"><path fill="#F0F2F5" fill-rule="evenodd" d="M48.68 43.716c0-6.404-3.602-12.125-10.389-14.602 5.279-2.732 7.96-7.087 7.96-13.064C46.251 7.085 39.967.34 26.98.34H0v60.794h27.901c13.658 0 20.78-6.574 20.78-17.418ZM11.313 9.817h14.83c5.697 0 8.881 2.989 8.881 7.685 0 5.123-3.1 8.197-8.881 8.197h-14.83V9.817Zm16.003 41.754H11.312V34.75h16.003q9.803 0 9.803 8.453c0 5.38-3.435 8.368-9.803 8.368Zm26.492 9.564h10.557V.339H53.807v60.796ZM99.37 41.752V17.075h10.557v44.06H99.704V54.73q-4.901 7.428-13.574 7.428c-9.133 0-15.082-5.635-15.082-15.882V17.075h10.558v27.41c0 5.55 2.68 8.367 8.127 8.367 5.111 0 9.636-3.842 9.636-11.1Zm58.611.683v-2.562c0-7.087-1.927-12.893-5.781-17.248s-9.049-6.575-15.585-6.575c-6.451 0-11.562 2.135-15.5 6.319-3.938 4.27-5.865 9.82-5.865 16.736 0 7.001 2.01 12.552 5.948 16.736 3.938 4.183 9.133 6.318 15.585 6.318 10.306 0 17.847-5.72 20.109-14.686h-10.138c-1.76 4.184-5.027 6.318-9.887 6.318-6.368 0-10.474-3.757-11.228-11.356h32.342Zm-32.258-7.514c1.006-7.087 5.027-10.503 10.809-10.503 6.116 0 9.97 4.099 10.892 10.503h-21.701ZM181.38 62.33c-12.568 0-19.187-5.038-19.774-15.199h10.306c.587 5.465 3.268 7.514 9.636 7.514 5.698 0 8.546-1.793 8.546-5.294 0-3.16-2.01-4.696-8.462-5.806l-4.944-.854c-9.468-1.622-14.16-6.063-14.16-13.32 0-8.283 6.452-13.32 17.931-13.32 12.316 0 18.685 4.952 19.104 14.942h-9.972c-.251-5.38-3.351-7.258-9.132-7.258q-7.541 0-7.541 5.123c0 3.074 2.178 4.44 7.206 5.38l5.446.854c10.473 1.963 14.998 5.891 14.998 13.405 0 8.88-7.038 13.833-19.188 13.833Zm53.459-1.195h12.065l-17.343-27.41 16.218-16.528 12.526 36.082c1.341 3.842 2.011 6.83 2.011 8.88 0 4.099-2.346 7.087-7.122 7.087h-4.022V77.7c1.341.17 2.849.256 4.608.256 5.195 0 9.133-.94 11.898-2.904s4.944-5.464 6.703-10.331l16.674-47.646h-11.06l-3.519 11.1c-2.765 9.307-5.111 17.504-6.954 24.506a419 419 0 0 0-6.871-24.591l-3.519-11.015H233.33l-17.596 18.273V.339h-10.389v60.796h10.389V47.302l6.536-6.66 12.568 20.492ZM305.961 17h10v13.5h13.5v10h-13.5V54h-10V40.5h-13.5v-10h13.5V17Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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="M6 4.667A2.667 2.667 0 0 1 8.667 2c1.34 0 2.538.608 3.333 1.564A4.32 4.32 0 0 1 15.333 2 2.667 2.667 0 0 1 18 4.667c0 .859-.25 1.66-.681 2.333H20a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h2.681A4.3 4.3 0 0 1 6 4.667ZM10.333 7H11v-.667A2.333 2.333 0 0 0 8.667 4 .667.667 0 0 0 8 4.667 2.333 2.333 0 0 0 10.333 7ZM13 6.333V7h.667A2.333 2.333 0 0 0 16 4.667.667.667 0 0 0 15.333 4 2.333 2.333 0 0 0 13 6.333ZM11 9H5v2h6V9Zm2 2V9h6v2h-6Zm-2 2H6v6h5v-6Zm2 6v-6h5v6h-5Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 659 B |
@@ -295,6 +295,9 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// subscription
|
||||
e.GET("/subscriptions", server.WebGeneric)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"expo-updates": "~0.26.9",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"gretchen": "^1.5.1",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -180,6 +181,7 @@
|
||||
"react-native-pager-view": "6.6.1",
|
||||
"react-native-picker-select": "^9.3.1",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-purchases": "^8.4.0",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "^3.16.3",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
|
||||
+49
-34
@@ -4,6 +4,7 @@ import '#/view/icons'
|
||||
|
||||
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,
|
||||
@@ -25,7 +26,7 @@ import {s} from '#/lib/styles'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
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'
|
||||
@@ -43,6 +44,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,
|
||||
@@ -69,6 +71,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {AppProfiler} from '#/AppProfiler'
|
||||
import {RC_APPLE_PUBLIC_KEY, 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'
|
||||
@@ -131,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>
|
||||
@@ -176,9 +181,19 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
||||
setReady(true),
|
||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(
|
||||
() => {
|
||||
setReady(true)
|
||||
},
|
||||
)
|
||||
|
||||
Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG)
|
||||
|
||||
if (isIOS) {
|
||||
Purchases.configure({apiKey: RC_APPLE_PUBLIC_KEY})
|
||||
} else if (isAndroid) {
|
||||
Purchases.configure({apiKey: RC_GOOGLE_PUBLIC_KEY})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
|
||||
+32
-29
@@ -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,35 +111,37 @@ 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>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</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>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</ComposerProvider>
|
||||
</PurchasesProvider>
|
||||
</QueryProvider>
|
||||
<ToastContainer />
|
||||
</React.Fragment>
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
StarterPackScreenShort,
|
||||
} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import {Subscriptions} from '#/screens/Subscriptions'
|
||||
import {useTheme} from '#/alf'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
@@ -416,6 +417,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Subscriptions"
|
||||
getComponent={() => Subscriptions}
|
||||
options={{title: title(msg`Subscriptions`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
|
||||
export type Breakpoint = 'gtPhone' | 'gtMobile' | 'gtTablet'
|
||||
|
||||
export function useBreakpoints(): Record<Breakpoint, boolean> & {
|
||||
activeBreakpoint: Breakpoint | undefined
|
||||
} {
|
||||
const gtPhone = useMediaQuery({minWidth: 500})
|
||||
const gtMobile = useMediaQuery({minWidth: 800})
|
||||
const gtTablet = useMediaQuery({minWidth: 1300})
|
||||
return useMemo(() => {
|
||||
let active: Breakpoint | undefined
|
||||
if (gtTablet) {
|
||||
active = 'gtTablet'
|
||||
} else if (gtMobile) {
|
||||
active = 'gtMobile'
|
||||
} else if (gtPhone) {
|
||||
active = 'gtPhone'
|
||||
}
|
||||
return {
|
||||
activeBreakpoint: active,
|
||||
gtPhone,
|
||||
gtMobile,
|
||||
gtTablet,
|
||||
}
|
||||
}, [gtPhone, gtMobile, gtTablet])
|
||||
}
|
||||
+2
-13
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
|
||||
import {
|
||||
computeFontScaleMultiplier,
|
||||
@@ -14,13 +13,14 @@ import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
|
||||
import {Device} from '#/storage'
|
||||
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/breakpoints'
|
||||
export * from '#/alf/fonts'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export * from '#/alf/types'
|
||||
export * from '#/alf/util/flatten'
|
||||
export * from '#/alf/util/platform'
|
||||
export * from '#/alf/util/themeSelector'
|
||||
export * from '#/alf/util/useGutterStyles'
|
||||
export * from '#/alf/util/useGutters'
|
||||
|
||||
export type Alf = {
|
||||
themeName: ThemeName
|
||||
@@ -142,14 +142,3 @@ export function useTheme(theme?: ThemeName) {
|
||||
return theme ? alf.themes[theme] : alf.theme
|
||||
}, [theme, alf])
|
||||
}
|
||||
|
||||
export function useBreakpoints() {
|
||||
const gtPhone = useMediaQuery({minWidth: 500})
|
||||
const gtMobile = useMediaQuery({minWidth: 800})
|
||||
const gtTablet = useMediaQuery({minWidth: 1300})
|
||||
return {
|
||||
gtPhone,
|
||||
gtMobile,
|
||||
gtTablet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const gradients = {
|
||||
nordic: {
|
||||
values: [
|
||||
[0, '#083367'],
|
||||
// [0.4, '#386D84'],
|
||||
[1, '#9EE8C1'],
|
||||
],
|
||||
hover_value: '#3A7085',
|
||||
@@ -122,3 +123,9 @@ export const gradients = {
|
||||
hover_value: '#755B62',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const blueskyPlus = {
|
||||
dark: '#083367',
|
||||
mid: '#67A59F',
|
||||
light: '#9EE8C1',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
Breakpoint,
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
ViewStyleProp,
|
||||
} from '#/alf'
|
||||
|
||||
export function useGutterStyles({
|
||||
top,
|
||||
@@ -19,3 +25,70 @@ export function useGutterStyles({
|
||||
]
|
||||
}, [gtMobile, top, bottom])
|
||||
}
|
||||
|
||||
type Gutter = 'compact' | 'base' | 'wide' | 0
|
||||
|
||||
const gutters: Record<
|
||||
Exclude<Gutter, 0>,
|
||||
Record<Breakpoint | 'default', number>
|
||||
> = {
|
||||
compact: {
|
||||
default: tokens.space.sm,
|
||||
gtPhone: tokens.space.sm,
|
||||
gtMobile: tokens.space.md,
|
||||
gtTablet: tokens.space.md,
|
||||
},
|
||||
base: {
|
||||
default: tokens.space.lg,
|
||||
gtPhone: tokens.space.lg,
|
||||
gtMobile: tokens.space.xl,
|
||||
gtTablet: tokens.space.xl,
|
||||
},
|
||||
wide: {
|
||||
default: tokens.space.xl,
|
||||
gtPhone: tokens.space.xl,
|
||||
gtMobile: tokens.space._3xl,
|
||||
gtTablet: tokens.space._3xl,
|
||||
},
|
||||
}
|
||||
|
||||
type Gutters = {
|
||||
paddingTop?: number
|
||||
paddingRight?: number
|
||||
paddingBottom?: number
|
||||
paddingLeft?: number
|
||||
}
|
||||
|
||||
export function useGutters([all]: [Gutter]): Gutters
|
||||
export function useGutters([vertical, horizontal]: [Gutter, Gutter]): Gutters
|
||||
export function useGutters([top, right, bottom, left]: [
|
||||
Gutter,
|
||||
Gutter,
|
||||
Gutter,
|
||||
Gutter,
|
||||
]): Gutters
|
||||
export function useGutters([top, right, bottom, left]: Gutter[]) {
|
||||
const {activeBreakpoint} = useBreakpoints()
|
||||
return React.useMemo(() => {
|
||||
if (!right) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
right = bottom = left = top
|
||||
} else if (!bottom || !left) {
|
||||
bottom = top
|
||||
left = right
|
||||
}
|
||||
|
||||
return {
|
||||
paddingTop:
|
||||
top === 0 ? undefined : gutters[top][activeBreakpoint || 'default'],
|
||||
paddingRight:
|
||||
right === 0 ? undefined : gutters[right][activeBreakpoint || 'default'],
|
||||
paddingBottom:
|
||||
bottom === 0
|
||||
? undefined
|
||||
: gutters[bottom][activeBreakpoint || 'default'],
|
||||
paddingLeft:
|
||||
left === 0 ? undefined : gutters[left][activeBreakpoint || 'default'],
|
||||
}
|
||||
}, [activeBreakpoint, top, bottom, left, right])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a} from '#/alf/atoms'
|
||||
import {Breakpoint, useBreakpoints} from '#/alf/breakpoints'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
|
||||
type Gutter = 'compact' | 'base' | 'wide' | 0
|
||||
|
||||
const gutters: Record<
|
||||
Exclude<Gutter, 0>,
|
||||
Record<Breakpoint | 'default', number>
|
||||
> = {
|
||||
compact: {
|
||||
default: tokens.space.sm,
|
||||
gtPhone: tokens.space.sm,
|
||||
gtMobile: tokens.space.md,
|
||||
gtTablet: tokens.space.md,
|
||||
},
|
||||
base: {
|
||||
default: tokens.space.lg,
|
||||
gtPhone: tokens.space.lg,
|
||||
gtMobile: tokens.space.xl,
|
||||
gtTablet: tokens.space.xl,
|
||||
},
|
||||
wide: {
|
||||
default: tokens.space.xl,
|
||||
gtPhone: tokens.space.xl,
|
||||
gtMobile: tokens.space._3xl,
|
||||
gtTablet: tokens.space._3xl,
|
||||
},
|
||||
}
|
||||
|
||||
type Gutters = {
|
||||
paddingTop: number
|
||||
paddingRight: number
|
||||
paddingBottom: number
|
||||
paddingLeft: number
|
||||
}
|
||||
|
||||
export function useGutters([all]: [Gutter]): Gutters
|
||||
export function useGutters([vertical, horizontal]: [Gutter, Gutter]): Gutters
|
||||
export function useGutters([top, right, bottom, left]: [
|
||||
Gutter,
|
||||
Gutter,
|
||||
Gutter,
|
||||
Gutter,
|
||||
]): Gutters
|
||||
export function useGutters([top, right, bottom, left]: Gutter[]) {
|
||||
const {activeBreakpoint} = useBreakpoints()
|
||||
return React.useMemo(() => {
|
||||
if (!right) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
right = bottom = left = top
|
||||
} else if (!bottom || !left) {
|
||||
bottom = top
|
||||
left = right
|
||||
}
|
||||
|
||||
return {
|
||||
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
||||
paddingRight:
|
||||
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
|
||||
paddingBottom:
|
||||
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
|
||||
paddingLeft:
|
||||
left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
|
||||
}
|
||||
}, [activeBreakpoint, top, bottom, left, right])
|
||||
}
|
||||
|
||||
export function useGutterStyles({
|
||||
top,
|
||||
bottom,
|
||||
}: {
|
||||
top?: boolean
|
||||
bottom?: boolean
|
||||
} = {}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return React.useMemo<any>(() => {
|
||||
return [
|
||||
a.px_lg,
|
||||
top && a.pt_md,
|
||||
bottom && a.pb_md,
|
||||
gtMobile && [a.px_xl, top && a.pt_lg, bottom && a.pb_lg],
|
||||
]
|
||||
}, [gtMobile, top, bottom])
|
||||
}
|
||||
@@ -665,10 +665,12 @@ export function ButtonIcon({
|
||||
icon: Comp,
|
||||
position,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
size?: SVGIconProps['size']
|
||||
style?: SVGIconProps['style']
|
||||
}) {
|
||||
const {size: buttonSize, disabled} = useButtonContext()
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
@@ -753,6 +755,7 @@ export function ButtonIcon({
|
||||
color: textStyles.color,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useGutters,useTheme} from '#/alf'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
@@ -197,6 +197,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
{children, style, contentContainerStyle, header, ...props},
|
||||
ref,
|
||||
) {
|
||||
const gutters = useGutters(['wide'])
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -238,8 +239,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
<KeyboardAwareScrollView
|
||||
style={[style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
gutters,
|
||||
{paddingBottom},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
|
||||
@@ -16,7 +16,14 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {
|
||||
atoms as a,
|
||||
flatten,
|
||||
useBreakpoints,
|
||||
useGutters,
|
||||
useTheme,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -150,8 +157,8 @@ export function Inner({
|
||||
contentContainerStyle,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
const gutters = useGutters(['wide'])
|
||||
const {close} = React.useContext(Context)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
useFocusGuards()
|
||||
return (
|
||||
<FocusScope loop asChild trapped>
|
||||
@@ -188,9 +195,7 @@ export function Inner({
|
||||
onDismiss={close}
|
||||
style={{display: 'flex', flexDirection: 'column'}}>
|
||||
{header}
|
||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
<View style={[gutters, contentContainerStyle]}>{children}</View>
|
||||
</DismissableLayer>
|
||||
</View>
|
||||
</FocusScope>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {useMemo} from 'react'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {atoms as a, tokens, ViewStyleProp} from '#/alf'
|
||||
|
||||
export function GradientFill({
|
||||
gradient,
|
||||
}: {
|
||||
rotate = '0deg',
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
gradient:
|
||||
| typeof tokens.gradients.primary
|
||||
| typeof tokens.gradients.sky
|
||||
| typeof tokens.gradients.midnight
|
||||
| typeof tokens.gradients.sunrise
|
||||
@@ -13,20 +17,66 @@ export function GradientFill({
|
||||
| typeof tokens.gradients.bonfire
|
||||
| typeof tokens.gradients.summer
|
||||
| typeof tokens.gradients.nordic
|
||||
rotate?:
|
||||
| '0deg'
|
||||
| '45deg'
|
||||
| '90deg'
|
||||
| '135deg'
|
||||
| '180deg'
|
||||
| '225deg'
|
||||
| '270deg'
|
||||
| '315deg'
|
||||
}) {
|
||||
if (gradient.values.length < 2) {
|
||||
throw new Error('Gradient must have at least 2 colors')
|
||||
}
|
||||
|
||||
const {start, end} = useMemo(() => {
|
||||
return {
|
||||
'0deg': {
|
||||
start: {x: 0, y: 1},
|
||||
end: {x: 1, y: 1},
|
||||
},
|
||||
'45deg': {
|
||||
start: {x: 0, y: 0},
|
||||
end: {x: 1, y: 1},
|
||||
},
|
||||
'90deg': {
|
||||
start: {x: 1, y: 0},
|
||||
end: {x: 1, y: 1},
|
||||
},
|
||||
'135deg': {
|
||||
start: {x: 1, y: 0},
|
||||
end: {x: 0, y: 1},
|
||||
},
|
||||
'180deg': {
|
||||
start: {x: 1, y: 0},
|
||||
end: {x: 0, y: 0},
|
||||
},
|
||||
'225deg': {
|
||||
start: {x: 1, y: 1},
|
||||
end: {x: 0, y: 0},
|
||||
},
|
||||
'270deg': {
|
||||
start: {x: 0, y: 1},
|
||||
end: {x: 0, y: 0},
|
||||
},
|
||||
'315deg': {
|
||||
start: {x: 0, y: 1},
|
||||
end: {x: 1, y: 0},
|
||||
},
|
||||
}[rotate]
|
||||
}, [rotate])
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
|
||||
locations={
|
||||
gradient.values.map(c => c[0]) as [number, number, ...number[]]
|
||||
}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
start={start}
|
||||
end={end}
|
||||
style={[a.absolute, a.inset_0, style]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export * as Header from '#/components/Layout/Header'
|
||||
|
||||
export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
noPaddingTop?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
|
||||
*/
|
||||
export const Screen = React.memo(function Screen({
|
||||
style,
|
||||
noPaddingTop,
|
||||
...props
|
||||
}: ScreenProps) {
|
||||
const {top} = useSafeAreaInsets()
|
||||
@@ -35,7 +37,11 @@ export const Screen = React.memo(function Screen({
|
||||
<>
|
||||
{isWeb && <WebCenterBorders />}
|
||||
<View
|
||||
style={[a.util_screen_outer, {paddingTop: top}, style]}
|
||||
style={[
|
||||
a.util_screen_outer,
|
||||
{paddingTop: noPaddingTop ? 0 : top},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
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 {isWeb} from '#/platform/detection'
|
||||
import {usePurchaseOffering} from '#/state/purchases/hooks/usePurchaseOffering'
|
||||
import {useSubscriptionGroup} 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, useGutters, useTheme} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||
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, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {SubduedFill} from '#/components/purchases/BlueskyPlusGradients'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function BlueskyPlusCore({
|
||||
control,
|
||||
showLearnMore,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
showLearnMore?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner control={control} showLearnMore={showLearnMore} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
control,
|
||||
showLearnMore,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
showLearnMore?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const dialogGutters = useGutters(['wide'])
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const copy = useCoreOfferingCopy()
|
||||
// TODO if 3p PDS
|
||||
const needsEmail = !currentAccount?.email
|
||||
const needsConfirmEmail = !currentAccount?.emailConfirmed
|
||||
const isDisabled = needsEmail || needsConfirmEmail
|
||||
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [error, setError] = React.useState<string>('')
|
||||
const [offeringId, setOfferingId] = React.useState<SubscriptionOfferingId>(
|
||||
SubscriptionOfferingId.CoreAnnual,
|
||||
)
|
||||
const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core)
|
||||
const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering()
|
||||
|
||||
const onPressSubscribe = async () => {
|
||||
setError('')
|
||||
|
||||
async function purchase() {
|
||||
if (!currentAccount) return
|
||||
|
||||
const offering = coreOffering?.offerings.find(o => o.id === offeringId)
|
||||
|
||||
if (!offering) {
|
||||
const message = _(msg`Hmmmm. We couldn't locate that subscription.`)
|
||||
if (isWeb) {
|
||||
setError(message)
|
||||
} else {
|
||||
Toast.show(message, 'xmark')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const emailValue = email || currentAccount.email
|
||||
|
||||
if (!emailValue) {
|
||||
const message = _(
|
||||
msg`Hmmm. Something went wrong, sorry about that. Please try again.`,
|
||||
)
|
||||
if (isWeb) {
|
||||
setError(message)
|
||||
} else {
|
||||
Toast.show(message, 'xmark')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await purchaseOffering({
|
||||
did: currentAccount.did,
|
||||
email: emailValue,
|
||||
offering,
|
||||
})
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
try {
|
||||
await purchase()
|
||||
// TODO handle errors the same for both platforms, throw and display
|
||||
control.close()
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
_(
|
||||
msg`Hmmm. Something went wrong, sorry about that. Please try again.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
control.close(async () => {
|
||||
try {
|
||||
await purchase()
|
||||
} catch (e: any) {
|
||||
/**
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors
|
||||
*/
|
||||
switch (e.code) {
|
||||
case PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR:
|
||||
// do nothing, just reopen
|
||||
break
|
||||
default:
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Hmmm. Something went wrong, sorry about that. Please try again.`,
|
||||
),
|
||||
'xmark',
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
control.open()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Bluesky Plus`)}
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
gtMobile ? {width: '100%', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.relative, a.pb_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.overflow_hidden,
|
||||
isWeb
|
||||
? undefined
|
||||
: {borderTopLeftRadius: 20, borderTopRightRadius: 20},
|
||||
{
|
||||
top: dialogGutters.paddingTop * -1,
|
||||
bottom: 0,
|
||||
left: dialogGutters.paddingLeft * -1,
|
||||
right: dialogGutters.paddingRight * -1,
|
||||
},
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.nordic} rotate="45deg" />
|
||||
</View>
|
||||
|
||||
<BlueskyPlusLogo width={80} fill="white" style={[a.z_10]} />
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_5xl,
|
||||
a.font_heavy,
|
||||
a.leading_tight,
|
||||
a.pt_lg,
|
||||
{color: 'white'},
|
||||
]}>
|
||||
<Trans>A century of gathering clouds</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.pt_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Ghost ships arriving and leaving. The sea deeper, vaster. The parrot
|
||||
in the bamboo cage spoke several languages.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Toggle.Group
|
||||
type="radio"
|
||||
label={_(msg`Choose a Bluesky Plus plan`)}
|
||||
values={[offeringId]}
|
||||
onChange={values => setOfferingId(parseOfferingId(values[0]))}
|
||||
style={[a.w_full, a.gap_sm, a.pt_lg]}>
|
||||
{[
|
||||
SubscriptionOfferingId.CoreMonthly,
|
||||
SubscriptionOfferingId.CoreAnnual,
|
||||
].map(id => {
|
||||
return (
|
||||
<Toggle.Item key={id} name={id} label={_(msg`Monthly plan`)}>
|
||||
{({selected, hovered}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg,
|
||||
selected
|
||||
? t.atoms.border_contrast_high
|
||||
: t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{Boolean(selected || hovered) && (
|
||||
<SubduedFill style={{opacity: 0.2}} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text style={[a.text_lg, a.leading_tight, a.font_heavy]}>
|
||||
<Trans>{copy[id].title}</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>{copy[id].price}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
{copy[id].discount && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{
|
||||
backgroundColor: t.atoms.text.color,
|
||||
paddingVertical: 3,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.atoms.bg.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
{copy[id].discount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{height: 24, width: 24},
|
||||
{
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
{selected ? (
|
||||
<>
|
||||
<CheckThink
|
||||
fill={t.atoms.text_contrast_high.color}
|
||||
size="xs"
|
||||
style={[a.z_10]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.border,
|
||||
a.rounded_full,
|
||||
t.atoms.border_contrast_low,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
)
|
||||
})}
|
||||
</Toggle.Group>
|
||||
|
||||
{needsEmail ? (
|
||||
<View style={[a.pt_md]}>
|
||||
<Divider />
|
||||
<View style={[a.py_md, a.gap_xs]}>
|
||||
<Text
|
||||
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
A real email is required in order to receive support and updates
|
||||
for your subscription.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={At} />
|
||||
<TextField.Input
|
||||
label={_(
|
||||
msg`Enter your email for support and updates to your subscription.`,
|
||||
)}
|
||||
placeholder={_(msg`Email`)}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
</View>
|
||||
) : needsConfirmEmail ? (
|
||||
<View style={[a.pt_sm]}>
|
||||
<Admonition type="warning" style={[a.flex_1]}>
|
||||
<Trans>
|
||||
You must verify your email to continue. Without a valid email, we
|
||||
can provide no support for your subscription.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
to="/settings/account"
|
||||
label={_(msg`Visit account settings`)}>
|
||||
<Trans>Click here to begin.</Trans>
|
||||
</InlineLinkText>
|
||||
</Admonition>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={[a.flex_row, a.pt_md, a.gap_sm]}>
|
||||
{showLearnMore && (
|
||||
<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="gradient"
|
||||
color="gradient_nordic"
|
||||
size="large"
|
||||
onPress={onPressSubscribe}
|
||||
disabled={isPending || isDisabled}
|
||||
style={[a.flex_1, a.overflow_hidden]}>
|
||||
<ButtonText>
|
||||
<Trans>Subscribe</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={[a.flex_row, a.pt_md, a.gap_sm]}>
|
||||
<Admonition type="error" style={[a.flex_1]}>
|
||||
{error}
|
||||
</Admonition>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_md]}>
|
||||
<Text style={[a.text_xs]}>
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_low]}>
|
||||
Terms and Conditions
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_low]}>
|
||||
Privacy Policy
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_low]}>
|
||||
EULA
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function useCoreOfferingCopy() {
|
||||
const {_} = useLingui()
|
||||
return React.useMemo(() => {
|
||||
return {
|
||||
[SubscriptionOfferingId.CoreMonthly]: {
|
||||
title: _(msg`1 month`),
|
||||
price: _(msg`$1337 / month`),
|
||||
discount: undefined,
|
||||
},
|
||||
[SubscriptionOfferingId.CoreAnnual]: {
|
||||
title: _(msg`12 months`),
|
||||
price: _(msg`$1337 / year`),
|
||||
discount: _(msg`Save 25%`),
|
||||
},
|
||||
}
|
||||
}, [_])
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SubscriptionsDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const [plan, setPlan] = React.useState<'monthly' | 'annual'>('monthly')
|
||||
|
||||
const onPressSubscribe = async () => {
|
||||
try {
|
||||
if (!currentAccount) return
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`Could not take you to checkout`), 'xmark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Bluesky Plus`)} style={[web({maxWidth: 1000})]}>
|
||||
<View style={[a.flex_1, a.gap_xl, gtMobile && a.flex_row]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.gap_md,
|
||||
a.rounded_xs,
|
||||
a.relative,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.nordic} rotate="270deg" />
|
||||
<View style={[a.px_xl, a.py_4xl, a.gap_md]}>
|
||||
<MarketingBlurb />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.justify_end, a.gap_md]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Early bird discount!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_snug]}>
|
||||
<Trans>Support Bluesky now and get a 25% discount for a year</Trans>
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
type="radio"
|
||||
label={_(msg`Choose plan`)}
|
||||
values={[plan]}
|
||||
onChange={values => setPlan(values[0] as 'monthly' | 'annual')}
|
||||
style={[a.w_full, a.gap_md, a.my_xl]}>
|
||||
<Toggle.Item name="monthly" label={_(msg`Monthly plan`)}>
|
||||
{({selected}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
selected
|
||||
? {borderColor: t.palette.primary_500}
|
||||
: t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>Monthly plan</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>$6/month</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_normal,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Billed monthly, cancel anytime. $8/month after 12 months
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
<Toggle.Item name="annual" label={_(msg`Annual plan`)}>
|
||||
{({selected}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
selected
|
||||
? {borderColor: t.palette.primary_500}
|
||||
: t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>Annual plan</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_lg, a.leading_normal, a.font_bold]}>
|
||||
<Trans>$60/year</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_normal,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Billed annually, renews at $80/year</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
</Toggle.Group>
|
||||
<Button
|
||||
label={_(msg`Subscribe`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onPressSubscribe}>
|
||||
<ButtonText>
|
||||
<Trans>Subscribe</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ChevronRightIcon} />
|
||||
</Button>
|
||||
<Text>
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
Terms and Conditions
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
Privacy Policy
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText to="#" label="TODO REPLACE">
|
||||
EULA
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketingBlurb() {
|
||||
return (
|
||||
<>
|
||||
<BlueskyPlusLogo width={200} fill="white" style={[a.mt_5xl]} />
|
||||
|
||||
<Text style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
<Trans>
|
||||
Here's the thing: we don't have any premium features yet because all
|
||||
our servers are on fire. Support us now so we can put the fires out
|
||||
and get working on those features!
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[a.text_lg, a.leading_normal, a.font_bold, {color: 'white'}]}>
|
||||
<Trans>What you'll get now:</Trans>
|
||||
</Text>
|
||||
<View role="list" style={[a.gap_xs]}>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>A supporter badge on your profile</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>A warm fuzzy feeling knowing you helped us out</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[a.text_lg, a.leading_normal, a.font_bold, {color: 'white'}]}>
|
||||
<Trans>What you'll get soon: </Trans>
|
||||
</Text>
|
||||
<View role="list" style={[a.gap_xs]}>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Custom profile colors</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Longer and higher quality videos</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
role="listitem"
|
||||
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
|
||||
{' '}
|
||||
· <Trans>Custom app icons</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View, ViewStyle} from 'react-native'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
@@ -57,6 +57,7 @@ export type GroupProps = React.PropsWithChildren<{
|
||||
disabled?: boolean
|
||||
onChange: (value: string[]) => void
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>
|
||||
|
||||
export type ItemProps = ViewStyleProp & {
|
||||
@@ -82,6 +83,7 @@ export function Group({
|
||||
type = 'checkbox',
|
||||
maxSelections,
|
||||
label,
|
||||
style,
|
||||
}: GroupProps) {
|
||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||
@@ -134,7 +136,7 @@ export function Group({
|
||||
return (
|
||||
<GroupContext.Provider value={context}>
|
||||
<View
|
||||
style={[a.w_full]}
|
||||
style={[a.w_full, style]}
|
||||
role={groupRole}
|
||||
{...(groupRole === 'radiogroup'
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
/**
|
||||
* Full Bluesky+ logo with butterfly
|
||||
*/
|
||||
export function Full(props: Props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {fill, size: _size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||
// @ts-ignore it's fiiiiine
|
||||
const size = parseInt(rest.width || 100)
|
||||
const ratio = 90 / 448
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 448 90"
|
||||
accessibilityLabel={_(msg`Bluesky Plus`)}
|
||||
accessibilityHint=""
|
||||
{...rest}
|
||||
style={[style, {width: size, height: size * ratio}]}>
|
||||
{gradient}
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M49.5 40.389c-4.483-9.147-16.69-26.193-28.04-34.6C13.27-.277 0-4.971 0 9.964c0 2.983 1.733 25.057 2.75 28.641 3.534 12.46 16.41 15.637 27.866 13.714C10.593 55.68 5.5 66.817 16.5 77.953c20.891 21.15 30.026-5.307 32.367-12.086.43-1.242.63-1.824.633-1.33.003-.494.204.088.633 1.33 2.34 6.78 11.476 33.235 32.367 12.086 11-11.136 5.907-22.273-14.116-25.635 11.455 1.924 24.332-1.254 27.866-13.713C97.267 35.021 99 12.947 99 9.965 99-4.972 85.73-.278 77.54 5.788c-11.35 8.407-23.557 25.453-28.04 34.6Zm107.33.725c6.787 2.477 10.39 8.198 10.39 14.602 0 10.844-7.122 17.418-20.78 17.418h-27.901V12.34h26.98c12.987 0 19.271 6.746 19.271 15.711 0 5.977-2.681 10.332-7.96 13.064Zm-12.149-19.297H129.85V37.7h14.831c5.781 0 8.881-3.074 8.881-8.197 0-4.696-3.184-7.685-8.881-7.685ZM129.85 63.571h16.004c6.368 0 9.803-2.988 9.803-8.368q0-8.453-9.803-8.453H129.85v16.821Zm53.053 9.563h-10.557V12.34h10.557v60.795Zm35.005-44.059v24.677c0 7.258-4.525 11.1-9.636 11.1-5.446 0-8.127-2.818-8.127-8.368V29.075h-10.558v29.202c0 10.246 5.949 15.882 15.082 15.882q8.673 0 13.574-7.428v6.403h10.222V29.075h-10.557Zm58.611 22.798v2.562h-32.342c.754 7.6 4.86 11.356 11.228 11.356 4.859 0 8.127-2.134 9.887-6.318h10.138c-2.262 8.965-9.803 14.686-20.109 14.686-6.452 0-11.646-2.135-15.585-6.319s-5.948-9.734-5.948-16.735c0-6.917 1.927-12.467 5.865-16.736 3.938-4.184 9.049-6.319 15.5-6.319 6.536 0 11.731 2.22 15.585 6.575s5.781 10.161 5.781 17.248ZM255.07 36.418c-5.782 0-9.804 3.416-10.809 10.503h21.701c-.922-6.404-4.776-10.503-10.892-10.503Zm25.075 22.713c.586 10.161 7.205 15.199 19.773 15.199 12.15 0 19.188-4.953 19.188-13.833 0-7.514-4.525-11.442-14.998-13.405l-5.446-.854c-5.028-.94-7.206-2.306-7.206-5.38q0-5.123 7.541-5.123c5.781 0 8.881 1.879 9.133 7.258h9.97c-.419-9.99-6.787-14.943-19.103-14.943-11.479 0-17.931 5.038-17.931 13.32 0 7.259 4.692 11.699 14.16 13.321l4.944.854c6.451 1.11 8.462 2.647 8.462 5.806 0 3.5-2.849 5.294-8.546 5.294-6.368 0-9.049-2.05-9.636-7.514h-10.305Zm85.298 14.003h-12.066l-12.568-20.492-6.536 6.66v13.832h-10.389V12.34h10.389v35.009l17.596-18.273h23.801l3.519 11.015a419 419 0 0 1 6.871 24.591c1.843-7.002 4.189-15.199 6.954-24.506l3.519-11.1h11.06l-16.674 47.646c-1.759 4.867-3.938 8.367-6.703 10.331s-6.703 2.904-11.898 2.904c-1.759 0-3.267-.086-4.608-.257v-8.453h4.022c4.776 0 7.122-2.988 7.122-7.087 0-2.05-.67-5.038-2.011-8.88l-12.526-36.082-16.219 16.528 17.345 27.41ZM424.5 29h10v13.5H448v10h-13.5V66h-10V52.5H411v-10h13.5V29Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bluesky+ logo without butterfly
|
||||
*/
|
||||
export function Logotype(props: Props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {fill, size: _size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||
// @ts-ignore it's fiiiiine
|
||||
const size = parseInt(rest.width || 100)
|
||||
const ratio = 88 / 330
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 -10 330 88"
|
||||
accessibilityLabel={_(msg`Bluesky Plus`)}
|
||||
accessibilityHint=""
|
||||
{...rest}
|
||||
style={[style, {width: size, height: size * ratio}]}>
|
||||
{gradient}
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M48.68 43.716c0-6.404-3.602-12.125-10.389-14.602 5.279-2.732 7.96-7.087 7.96-13.064C46.251 7.085 39.967.34 26.98.34H0v60.794h27.901c13.658 0 20.78-6.574 20.78-17.418ZM11.313 9.817h14.83c5.697 0 8.881 2.989 8.881 7.685 0 5.123-3.1 8.197-8.881 8.197h-14.83V9.817Zm16.003 41.754H11.312V34.75h16.003q9.803 0 9.803 8.453c0 5.38-3.435 8.368-9.803 8.368Zm26.492 9.564h10.557V.339H53.807v60.796ZM99.37 41.752V17.075h10.557v44.06H99.704V54.73q-4.901 7.428-13.574 7.428c-9.133 0-15.082-5.635-15.082-15.882V17.075h10.558v27.41c0 5.55 2.68 8.367 8.127 8.367 5.111 0 9.636-3.842 9.636-11.1Zm58.611.683v-2.562c0-7.087-1.927-12.893-5.781-17.248s-9.049-6.575-15.585-6.575c-6.451 0-11.562 2.135-15.5 6.319-3.938 4.27-5.865 9.82-5.865 16.736 0 7.001 2.01 12.552 5.948 16.736 3.938 4.183 9.133 6.318 15.585 6.318 10.306 0 17.847-5.72 20.109-14.686h-10.138c-1.76 4.184-5.027 6.318-9.887 6.318-6.368 0-10.474-3.757-11.228-11.356h32.342Zm-32.258-7.514c1.006-7.087 5.027-10.503 10.809-10.503 6.116 0 9.97 4.099 10.892 10.503h-21.701ZM181.38 62.33c-12.568 0-19.187-5.038-19.774-15.199h10.306c.587 5.465 3.268 7.514 9.636 7.514 5.698 0 8.546-1.793 8.546-5.294 0-3.16-2.01-4.696-8.462-5.806l-4.944-.854c-9.468-1.622-14.16-6.063-14.16-13.32 0-8.283 6.452-13.32 17.931-13.32 12.316 0 18.685 4.952 19.104 14.942h-9.972c-.251-5.38-3.351-7.258-9.132-7.258q-7.541 0-7.541 5.123c0 3.074 2.178 4.44 7.206 5.38l5.446.854c10.473 1.963 14.998 5.891 14.998 13.405 0 8.88-7.038 13.833-19.188 13.833Zm53.459-1.195h12.065l-17.343-27.41 16.218-16.528 12.526 36.082c1.341 3.842 2.011 6.83 2.011 8.88 0 4.099-2.346 7.087-7.122 7.087h-4.022V77.7c1.341.17 2.849.256 4.608.256 5.195 0 9.133-.94 11.898-2.904s4.944-5.464 6.703-10.331l16.674-47.646h-11.06l-3.519 11.1c-2.765 9.307-5.111 17.504-6.954 24.506a419 419 0 0 0-6.871-24.591l-3.519-11.015H233.33l-17.596 18.273V.339h-10.389v60.796h10.389V47.302l6.536-6.66 12.568 20.492ZM305.961 17h10v13.5h13.5v10h-13.5V54h-10V40.5h-13.5v-10h13.5V17Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {View} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, tokens, useTheme, ViewStyleProp} from '#/alf'
|
||||
|
||||
const dark = tokens.gradients.nordic
|
||||
const light = {
|
||||
values: [
|
||||
[0, '#0F62C7'],
|
||||
[1, '#9EE8C1'],
|
||||
],
|
||||
} as const
|
||||
|
||||
export function SubduedFill({style}: ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
const isDark = t.name !== 'light'
|
||||
const gradient = isDark ? dark : light
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
|
||||
<LinearGradient
|
||||
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
|
||||
locations={
|
||||
gradient.values.map(c => c[0]) as [number, number, ...number[]]
|
||||
}
|
||||
start={{x: 0, y: 1}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0, {opacity: 0.3}, style]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -7,3 +7,9 @@ export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'error'
|
||||
|
||||
export const PLUS_SERVICE_URL = process.env.EXPO_PUBLIC_PLUS_SERVICE_URL
|
||||
export const RC_GOOGLE_PUBLIC_KEY =
|
||||
process.env.EXPO_PUBLIC_RC_GOOGLE_PUBLIC_KEY || ''
|
||||
export const RC_APPLE_PUBLIC_KEY =
|
||||
process.env.EXPO_PUBLIC_RC_APPLE_PUBLIC_KEY || ''
|
||||
|
||||
@@ -56,6 +56,7 @@ export type CommonNavigatorParams = {
|
||||
StarterPackShort: {code: string}
|
||||
StarterPackWizard: undefined
|
||||
StarterPackEdit: {rkey?: string}
|
||||
Subscriptions: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -63,4 +63,5 @@ export const router = new Router({
|
||||
StarterPack: '/starter-pack/:name/:rkey',
|
||||
StarterPackShort: '/starter-pack-short/:code',
|
||||
StarterPackWizard: '/starter-pack/create',
|
||||
Subscriptions: '/subscriptions',
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/compon
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||
import {Mark} from '#/components/icons/Logo'
|
||||
import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
|
||||
import {
|
||||
Person_Stroke2_Corner2_Rounded as PersonIcon,
|
||||
@@ -150,6 +151,18 @@ export function SettingsScreen({}: Props) {
|
||||
<AddAccountRow />
|
||||
)}
|
||||
<SettingsList.Divider />
|
||||
<SettingsList.LinkItem
|
||||
to="/subscriptions"
|
||||
label={_(msg`Subscriptions`)}>
|
||||
<SettingsList.ItemIcon
|
||||
icon={Mark}
|
||||
iconProps={{gradient: 'nordic'}}
|
||||
/>
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Subscriptions</Trans>
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.LinkItem>
|
||||
<SettingsList.Divider />
|
||||
<SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}>
|
||||
<SettingsList.ItemIcon icon={PersonIcon} />
|
||||
<SettingsList.ItemText>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import * as Button from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {Props as IconProps} from '#/components/icons/common'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -177,8 +178,10 @@ export function ItemIcon({
|
||||
icon: Comp,
|
||||
size = 'xl',
|
||||
color: colorProp,
|
||||
iconProps,
|
||||
}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
|
||||
color?: string
|
||||
iconProps?: Pick<IconProps, 'gradient'>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {destructive, withinGroup} = useContext(ItemContext)
|
||||
@@ -201,7 +204,7 @@ export function ItemIcon({
|
||||
|
||||
const content = (
|
||||
<View style={[a.z_20, {width: iconSize, height: iconSize}]}>
|
||||
<Comp width={iconSize} style={[{color}]} />
|
||||
<Comp width={iconSize} style={[{color}]} {...iconProps} />
|
||||
</View>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
import React from 'react'
|
||||
import {RefreshControl, TextStyle, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {PurchasesState, usePurchases, usePurchasesApi} from '#/state/purchases'
|
||||
import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription'
|
||||
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
|
||||
import {
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOfferingId,
|
||||
} from '#/state/purchases/types'
|
||||
import {
|
||||
APISubscription,
|
||||
NativePurchaseRestricted,
|
||||
} from '#/state/purchases/types'
|
||||
import {atoms as a, tokens, useBreakpoints, useGutters, useTheme} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BlueskyPlusCore} from '#/components/dialogs/BlueskyPlusCore'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {AndroidLogo} from '#/components/icons/AndroidLogo'
|
||||
import {AppleLogo} from '#/components/icons/AppleLogo'
|
||||
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 {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
// import {Mark} from '#/components/icons/Logo'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {SubduedFill} from '#/components/purchases/BlueskyPlusGradients'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type ScreenProps = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'Subscriptions'
|
||||
>
|
||||
|
||||
export function Subscriptions(_props: ScreenProps) {
|
||||
const t = useTheme()
|
||||
const isDark = t.name !== 'light'
|
||||
const gutters = useGutters(['base', 'base'])
|
||||
const insets = useSafeAreaInsets()
|
||||
const purchases = usePurchases()
|
||||
const {refetch} = usePurchasesApi()
|
||||
const {loading, restricted} = useNativeUserState()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refetch()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Screen noPaddingTop>
|
||||
<Layout.Center>
|
||||
<SubduedFill />
|
||||
|
||||
<View style={{height: insets.top}} />
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<BlueskyPlusLogo
|
||||
width={100}
|
||||
fill={isDark ? t.palette.white : t.atoms.text_contrast_high.color}
|
||||
/>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
|
||||
{/*
|
||||
<View style={[
|
||||
a.px_xl,
|
||||
]}>
|
||||
<Divider style={{borderColor: 'white', opacity: 0.2}} />
|
||||
</View>
|
||||
*/}
|
||||
</Layout.Center>
|
||||
|
||||
<Layout.Content
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}>
|
||||
<View style={[gutters]}>
|
||||
{purchases.status === 'loading' || loading ? (
|
||||
<Loader />
|
||||
) : purchases.status === 'error' ? (
|
||||
<Admonition type="error">
|
||||
{purchases.error?.message ?? 'Something went wrong.'}
|
||||
</Admonition>
|
||||
) : (
|
||||
<Core state={purchases} restricted={restricted} />
|
||||
)}
|
||||
</View>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function Core({
|
||||
state,
|
||||
restricted,
|
||||
}: {
|
||||
state: Exclude<PurchasesState, {status: 'loading' | 'error'}>
|
||||
restricted: NativePurchaseRestricted
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const coreSubscriptions = state.subscriptions.filter(
|
||||
s => s.group === SubscriptionGroupId.Core,
|
||||
)
|
||||
const activeSubscriptions = coreSubscriptions.filter(
|
||||
s => s.status === 'active' || s.status === 'paused',
|
||||
)
|
||||
const inactiveSubscriptions = coreSubscriptions.filter(
|
||||
s => s.status !== 'active' && s.status !== 'paused',
|
||||
)
|
||||
const hasActiveSubscriptions = activeSubscriptions.length > 0
|
||||
const hasInactiveSubscriptions = inactiveSubscriptions.length > 0
|
||||
|
||||
return (
|
||||
<View style={[isWeb ? a.gap_lg : a.gap_md]}>
|
||||
{restricted === 'yes' ? (
|
||||
<Admonition type="info">
|
||||
<Trans>
|
||||
Another account on this device is already subscribed to Bluesky+.
|
||||
Please subscribe additional accounts through our web application.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
) : hasActiveSubscriptions ? null : hasInactiveSubscriptions ? (
|
||||
<PurchaseHeader />
|
||||
) : (
|
||||
<PurchaseHeader />
|
||||
)}
|
||||
|
||||
{(hasActiveSubscriptions || hasInactiveSubscriptions) && (
|
||||
// TODO need a divider here if PurchaseHeader is present
|
||||
<>
|
||||
{hasActiveSubscriptions && (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>My subscriptions</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{activeSubscriptions.map(sub => (
|
||||
<Subscription key={sub.purchasedAt} subscription={sub} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{hasInactiveSubscriptions && (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Past subscriptions</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{inactiveSubscriptions.map(sub => (
|
||||
<Subscription key={sub.purchasedAt} subscription={sub} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<InfoCards />
|
||||
|
||||
<View style={[]}>
|
||||
<Text style={[a.text_xs]}>
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
Terms and Conditions
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
Privacy Policy
|
||||
</InlineLinkText>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
to="#"
|
||||
label="TODO REPLACE"
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
EULA
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCards() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {features} = useFeatures()
|
||||
const activeFeatures = features.filter(f => f.available)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
|
||||
<View
|
||||
style={[
|
||||
gtPhone ? a.flex_1 : a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.gap_sm,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Included in subscription</Trans>
|
||||
</Text>
|
||||
<View style={[a.py_xs, a.gap_sm]}>
|
||||
{activeFeatures.map(f => (
|
||||
<View key={f.text} style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<f.icon fill={tokens.blueskyPlus.mid} size="xs" />
|
||||
<Text style={[a.text_sm]}>{f.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
gtPhone ? a.flex_1 : a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.gap_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Your support helps Bluesky build a better internet. Thanks for being
|
||||
a part of the ATmosphere!
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Link
|
||||
to="https://blueskyweb.zendesk.com/hc/en-us"
|
||||
label={_(msg`Contact support`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonText>
|
||||
<Trans>Get support</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
<Link
|
||||
to="https://blueskyweb.zendesk.com/hc/en-us"
|
||||
label={_(msg`Subscriptions FAQ`)}
|
||||
size="small"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonText>
|
||||
<Trans>FAQ</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PurchaseHeader() {
|
||||
const t = useTheme()
|
||||
const isDark = t.name !== 'light'
|
||||
const {_} = useLingui()
|
||||
const control = useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const large = gtMobile
|
||||
|
||||
return (
|
||||
<View style={[large ? [a.py_sm, a.gap_md] : a.gap_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
large ? a.text_5xl : a.text_3xl,
|
||||
a.font_heavy,
|
||||
isDark ? undefined : t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>A century of gathering clouds</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Ghost ships arriving and leaving. The sea deeper, vaster. The parrot
|
||||
in the bamboo cage spoke several languages.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_('Subscribe')}
|
||||
onPress={() => control.open()}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
style={[a.overflow_hidden]}>
|
||||
<GradientFill gradient={tokens.gradients.nordic} />
|
||||
<ButtonText style={[{color: 'white'}]}>
|
||||
<Trans>Subscribe</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={Plus} position="right" style={[{color: 'white'}]} />
|
||||
</Button>
|
||||
<BlueskyPlusCore control={control} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSubscriptionUI({
|
||||
subscription: sub,
|
||||
}: {
|
||||
subscription: APISubscription
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
|
||||
return React.useMemo(() => {
|
||||
const periodEndsAt = i18n.date(new Date(sub.periodEndsAt), {
|
||||
dateStyle: 'short',
|
||||
})
|
||||
const title = {
|
||||
[SubscriptionOfferingId.CoreMonthly]: _(msg`Monthly`),
|
||||
[SubscriptionOfferingId.CoreAnnual]: _(msg`Annual`),
|
||||
}[sub.offering]
|
||||
const PlatformLogo = {
|
||||
[PlatformId.Ios]: AppleLogo,
|
||||
[PlatformId.Android]: AndroidLogo,
|
||||
[PlatformId.Web]: Globe,
|
||||
}[sub.platform]
|
||||
const platformName = {
|
||||
[PlatformId.Ios]: _('iOS'),
|
||||
[PlatformId.Android]: _('Android'),
|
||||
[PlatformId.Web]: _('Web'),
|
||||
}[sub.platform]
|
||||
const status = {
|
||||
active: _(msg`Active`),
|
||||
expired: _(msg`Expired`),
|
||||
paused: _(msg`Paused`),
|
||||
unknown: _(msg`Unknown`),
|
||||
}[sub.status]
|
||||
const renewalStatus = {
|
||||
will_renew: _(msg`Renews ${periodEndsAt}`),
|
||||
will_not_renew: {
|
||||
active: _(msg`Expires ${periodEndsAt}`),
|
||||
expired: _(msg`Expired ${periodEndsAt}`),
|
||||
paused: _(msg`Expires ${periodEndsAt}`),
|
||||
unknown: _(msg`Unknown`),
|
||||
}[sub.status],
|
||||
will_pause: _(msg`Pauses ${periodEndsAt}`),
|
||||
unknown: _(msg`Unknown`),
|
||||
}[sub.renewalStatus]
|
||||
const RenewalStatusIcon = {
|
||||
will_renew: Rotate,
|
||||
will_not_renew: Clock,
|
||||
will_pause: Clock,
|
||||
unknown: Clock,
|
||||
}[sub.renewalStatus]
|
||||
|
||||
return {
|
||||
title,
|
||||
status,
|
||||
renewalStatus,
|
||||
platformName,
|
||||
PlatformLogo,
|
||||
RenewalStatusIcon,
|
||||
}
|
||||
}, [_, i18n, sub])
|
||||
}
|
||||
|
||||
export function Subscription({
|
||||
subscription: sub,
|
||||
}: {
|
||||
subscription: APISubscription
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: manageSubscription} = useManageSubscription({
|
||||
platform: sub.platform,
|
||||
})
|
||||
const ui = useSubscriptionUI({subscription: sub})
|
||||
const isExpired = sub.status !== 'active' && sub.status !== 'paused'
|
||||
|
||||
const canManage =
|
||||
(isWeb && sub.platform === PlatformId.Web) ||
|
||||
(isIOS && sub.platform === PlatformId.Ios) ||
|
||||
(isAndroid && sub.platform === PlatformId.Android) ||
|
||||
(isNative && sub.platform === PlatformId.Web)
|
||||
const showManage = canManage && !isExpired
|
||||
|
||||
const statusStyles = React.useMemo<TextStyle>(() => {
|
||||
return {
|
||||
active: {
|
||||
color: tokens.blueskyPlus.dark,
|
||||
backgroundColor: tokens.blueskyPlus.light,
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
},
|
||||
expired: {
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
borderWidth: a.border.borderWidth,
|
||||
borderColor: t.atoms.border_contrast_high.borderColor,
|
||||
},
|
||||
paused: {
|
||||
color: t.atoms.bg.backgroundColor,
|
||||
backgroundColor: t.atoms.text_contrast_medium.color,
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
},
|
||||
unknown: {
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
backgroundColor: t.atoms.bg.backgroundColor,
|
||||
borderWidth: a.border.borderWidth,
|
||||
borderColor: t.atoms.border_contrast_high.borderColor,
|
||||
},
|
||||
}[sub.status]
|
||||
}, [t, sub.status])
|
||||
|
||||
return (
|
||||
<View
|
||||
key={sub.purchasedAt}
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
isExpired && [a.border, t.atoms.border_contrast_low],
|
||||
]}>
|
||||
{!isExpired && <GradientFill gradient={tokens.gradients.nordic} />}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderRadius: a.rounded_md.borderRadius - 2,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
{
|
||||
paddingTop: 10,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[a.text_lg, a.font_heavy]}>{ui.title}</Text>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.rounded_full,
|
||||
a.font_bold,
|
||||
{
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 6,
|
||||
...statusStyles,
|
||||
},
|
||||
]}>
|
||||
{ui.status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showManage && (
|
||||
<Link
|
||||
label={_('Manage subscription')}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary_inverted"
|
||||
{...createStaticClick(() => {
|
||||
manageSubscription()
|
||||
})}
|
||||
style={[a.justify_center]}>
|
||||
<ButtonText>
|
||||
<Trans>Manage</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
{
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{left: -1},
|
||||
]}>
|
||||
<ui.PlatformLogo size="sm" fill={t.atoms.text_contrast_low.color} />
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{ui.platformName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<ui.RenewalStatusIcon
|
||||
size="sm"
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
/>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{ui.renewalStatus}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function useFeatures() {
|
||||
const {_} = useLingui()
|
||||
|
||||
const features = [
|
||||
{
|
||||
available: true,
|
||||
icon: Check,
|
||||
text: _(msg`Custom app icons`),
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
icon: Check,
|
||||
text: _(msg`Lorem ipsum dolor sit amet`),
|
||||
},
|
||||
{
|
||||
available: false,
|
||||
icon: Clock,
|
||||
text: _(msg`Feature feature feature`),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
features,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {create} from 'gretchen'
|
||||
|
||||
import {PLUS_SERVICE_URL} from '#/env'
|
||||
|
||||
export const api = create({
|
||||
baseURL: PLUS_SERVICE_URL,
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
|
||||
import {APIEntitlement, APISubscription} from '#/state/purchases/types'
|
||||
|
||||
export type PurchasesState =
|
||||
| {
|
||||
status: 'loading'
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
error: Error
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
refetching: boolean
|
||||
email?: string
|
||||
subscriptions: APISubscription[]
|
||||
entitlements: APIEntitlement[]
|
||||
config: {}
|
||||
}
|
||||
|
||||
export type PurchasesApi = {
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
export const StateContext = React.createContext<PurchasesState>({
|
||||
status: 'loading',
|
||||
})
|
||||
|
||||
export const ApiContext = React.createContext<PurchasesApi>({
|
||||
refetch: async () => {},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import {Linking} from 'react-native'
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useManageSubscription as useManageWebSubscription} from '#/state/purchases/hooks/useManageSubscription/web'
|
||||
import {PlatformId} from '#/state/purchases/types'
|
||||
|
||||
export function useManageSubscription({platform}: {platform: PlatformId}) {
|
||||
const web = useManageWebSubscription({platform})
|
||||
return useMutation({
|
||||
async mutationFn() {
|
||||
if (platform === PlatformId.Web) {
|
||||
await web.mutateAsync()
|
||||
} else {
|
||||
const customer = await Purchases.getCustomerInfo()
|
||||
|
||||
if (customer.managementURL) {
|
||||
Linking.openURL(customer.managementURL)
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './web'
|
||||
@@ -0,0 +1,35 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {PlatformId} from '#/state/purchases/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function useManageSubscription(_props: {platform: PlatformId}) {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return useMutation({
|
||||
async mutationFn() {
|
||||
const {data, error} = await api<{
|
||||
managementUrl: string
|
||||
}>('/account/create-management-url', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
did: currentAccount!.did,
|
||||
redirecturl: IS_DEV
|
||||
? 'http://localhost:19006/subscriptions'
|
||||
: 'https://bsky.app/subscriptions',
|
||||
},
|
||||
}).json()
|
||||
|
||||
console.log(error, data)
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscriptions state')
|
||||
}
|
||||
|
||||
Linking.openURL(data.managementUrl)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,63 @@
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/types'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
export function useNativeUserState() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const {data, isFetching} = useQuery<{
|
||||
restricted: NativePurchaseRestricted
|
||||
}>({
|
||||
queryKey: ['purchases-native-user-state', currentAccount?.did],
|
||||
placeholderData: {restricted: 'unknown'},
|
||||
/**
|
||||
* Once we fetch this, no need to do so again for the current session. We
|
||||
* don't do any transfer logic between accounts, so this doesn't change
|
||||
* during the course of a user session.
|
||||
*/
|
||||
staleTime: Infinity,
|
||||
async queryFn() {
|
||||
if (!currentAccount) {
|
||||
return {restricted: 'unknown'}
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* MUST ensure we're the correct user first.
|
||||
*/
|
||||
await Purchases.logIn(currentAccount.did)
|
||||
/**
|
||||
* Attempt to restore purchases for the identified user.
|
||||
* @see https://www.revenuecat.com/docs/getting-started/restoring-purchases
|
||||
*/
|
||||
await Purchases.restorePurchases()
|
||||
return {restricted: 'no'}
|
||||
} catch (e: any) {
|
||||
switch (e.code) {
|
||||
/**
|
||||
* Indicates there are active subscriptions for another account on
|
||||
* this device. User cannot make additional purchases on this device.
|
||||
*
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
|
||||
*/
|
||||
case PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR:
|
||||
return {restricted: 'yes'}
|
||||
default:
|
||||
/**
|
||||
* Any other error is considered unknown. Let the user proceed.
|
||||
* Additional errors can occur at time of purchase and should be
|
||||
* handled there.
|
||||
*/
|
||||
return {restricted: 'no'}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
loading: isFetching,
|
||||
restricted: data?.restricted ?? 'unknown',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {NativePurchaseRestricted} from '#/state/purchases/types'
|
||||
|
||||
export function useNativeUserState(): {
|
||||
loading: boolean
|
||||
restricted: NativePurchaseRestricted
|
||||
} {
|
||||
return {
|
||||
loading: false,
|
||||
restricted: 'no',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
PlatformId,
|
||||
ReceiptAlreadyInUseError,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
|
||||
type Params = {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation<void, Error | ReceiptAlreadyInUseError, Params>({
|
||||
async mutationFn({did, email, offering}: Params) {
|
||||
console.log(offering)
|
||||
if (offering.platform === PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
|
||||
try {
|
||||
await Purchases.logIn(did)
|
||||
await Purchases.setEmail(email)
|
||||
await Purchases.purchaseStoreProduct(offering.package)
|
||||
} 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) {
|
||||
throw new ReceiptAlreadyInUseError(
|
||||
_(
|
||||
msg`This device has existing active subscriptions for a different account. Multiple account subscriptions are not possible on native platforms at this time.`,
|
||||
),
|
||||
// @ts-ignore
|
||||
{cause: e},
|
||||
)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {Linking} from 'react-native'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {PlatformId, SubscriptionOffering} from '#/state/purchases/types'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export function usePurchaseOffering() {
|
||||
return useMutation({
|
||||
async mutationFn({
|
||||
did,
|
||||
email,
|
||||
offering,
|
||||
}: {
|
||||
did: string
|
||||
email: string
|
||||
offering: SubscriptionOffering
|
||||
}) {
|
||||
if (offering.platform !== PlatformId.Web) {
|
||||
throw new Error('Unsupported platform')
|
||||
}
|
||||
|
||||
const {data, error} = await api<{
|
||||
checkoutUrl: string
|
||||
}>('/checkout/create', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
did,
|
||||
email,
|
||||
price: offering.package.priceId,
|
||||
redirectUrl: IS_DEV
|
||||
? `http://localhost:19006/subscriptions`
|
||||
: `https://bsky.app/subscriptions`,
|
||||
},
|
||||
}).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to create checkout URL`)
|
||||
}
|
||||
|
||||
Linking.openURL(data.checkoutUrl)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Purchases from 'react-native-purchases'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
APIOffering,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
|
||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||
queryKey: ['subscription-group', group],
|
||||
async queryFn() {
|
||||
const platform = isAndroid ? 'android' : 'ios'
|
||||
const {data, error} = await api<{
|
||||
offerings: APIOffering[]
|
||||
}>(`/subscriptions/${group}?platform=${platform}`).json()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to fetch subscription group`)
|
||||
}
|
||||
|
||||
const {offerings} = data
|
||||
const revenueCatIdentifiers = offerings.map(o =>
|
||||
isAndroid ? parseIdentifierFromAndroidProductId(o.product) : o.product,
|
||||
)
|
||||
const products = await Purchases.getProducts(revenueCatIdentifiers)
|
||||
|
||||
const parsed: SubscriptionOffering[] = []
|
||||
for (const o of offerings) {
|
||||
if (o.platform === PlatformId.Web) continue
|
||||
|
||||
const product = products.find(p => p.identifier === o.product)
|
||||
if (!product) continue
|
||||
|
||||
parsed.push({
|
||||
id: o.id,
|
||||
platform: o.platform,
|
||||
package: product,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
offerings: parsed,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whereas iOS has separate product IDs for each subscription product, Android
|
||||
* has a single ID with a suffixed payment plan e.g. `monthly` and `annual` for
|
||||
* our core offerings.
|
||||
*
|
||||
* However, the full "identifier" is concatenated, so we just pass around the
|
||||
* full thing and parse from there.
|
||||
*/
|
||||
function parseIdentifierFromAndroidProductId(productId: string) {
|
||||
if (!productId.includes(':')) {
|
||||
throw new Error(
|
||||
`Expected Android product ID to contain a colon: ${productId}`,
|
||||
)
|
||||
}
|
||||
|
||||
return productId.split(':')[0]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {api} from '#/state/purchases/api'
|
||||
import {
|
||||
APIOffering,
|
||||
PlatformId,
|
||||
SubscriptionGroupId,
|
||||
SubscriptionOffering,
|
||||
} from '#/state/purchases/types'
|
||||
|
||||
export function useSubscriptionGroup(group: SubscriptionGroupId) {
|
||||
return useQuery<{offerings: SubscriptionOffering[]}>({
|
||||
queryKey: ['subscription-group', group],
|
||||
async queryFn() {
|
||||
const {data, error} = await api<{
|
||||
offerings: APIOffering[]
|
||||
}>(`/subscriptions/${group}?platform=web`).json()
|
||||
if (error || !data) {
|
||||
throw new Error('Failed to fetch subscription group')
|
||||
}
|
||||
|
||||
const {offerings} = data
|
||||
|
||||
const parsed: SubscriptionOffering[] = []
|
||||
|
||||
for (const o of offerings) {
|
||||
if (o.platform !== PlatformId.Web) continue
|
||||
|
||||
parsed.push({
|
||||
id: o.id,
|
||||
platform: o.platform,
|
||||
package: {
|
||||
priceId: o.product,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
offerings: parsed,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
ApiContext,
|
||||
PurchasesApi,
|
||||
PurchasesState,
|
||||
StateContext,
|
||||
} from '#/state/purchases/context'
|
||||
import {useNativeEventsListener} from '#/state/purchases/hooks/useNativeEventsListener'
|
||||
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,
|
||||
isRefetching,
|
||||
} = usePurchasesState()
|
||||
const ctx = React.useMemo<PurchasesState>(() => {
|
||||
if (purchasesStateError) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: purchasesStateError,
|
||||
}
|
||||
} else if (!purchases) {
|
||||
return {
|
||||
status: 'loading',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: 'ready',
|
||||
refetching: isRefetching,
|
||||
email: purchases?.email,
|
||||
subscriptions: purchases?.subscriptions ?? [],
|
||||
entitlements: purchases?.entitlements ?? [],
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
}, [purchases, purchasesStateError, isRefetching])
|
||||
|
||||
const apiCtx = React.useMemo<PurchasesApi>(() => {
|
||||
return {
|
||||
refetch: async () => {
|
||||
await refetch()
|
||||
},
|
||||
}
|
||||
}, [refetch])
|
||||
|
||||
useNativeEventsListener({
|
||||
onCustomerInfoUpdated() {
|
||||
refetch()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<ApiContext.Provider value={apiCtx}>
|
||||
<StateContext.Provider value={ctx}>{children}</StateContext.Provider>
|
||||
</ApiContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePurchases() {
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
|
||||
export function usePurchasesApi() {
|
||||
return React.useContext(ApiContext)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
|
||||
|
||||
/**
|
||||
* API TYPES FROM OUR TOY SERVER
|
||||
*/
|
||||
|
||||
export type APISubscription = {
|
||||
status: 'active' | 'paused' | 'expired' | 'unknown'
|
||||
renewalStatus: 'will_renew' | 'will_not_renew' | 'will_pause' | 'unknown'
|
||||
group: SubscriptionGroupId
|
||||
platform: PlatformId
|
||||
offering: SubscriptionOfferingId
|
||||
periodDtartsAt: string
|
||||
periodEndsAt: string
|
||||
purchasedAt: string
|
||||
}
|
||||
|
||||
export type APIEntitlement = {
|
||||
id: EntitlementId
|
||||
}
|
||||
|
||||
export type APIOffering = {
|
||||
id: SubscriptionOfferingId
|
||||
platform: PlatformId
|
||||
product: string
|
||||
}
|
||||
|
||||
/**
|
||||
* CUSTOM ERROR TYPES
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thrown if the device already has active subscriptions for other accounts.
|
||||
*
|
||||
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
|
||||
*/
|
||||
export class ReceiptAlreadyInUseError extends Error {}
|
||||
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,16 @@ import zod from 'zod'
|
||||
import {BaseNux} from '#/state/queries/nuxs/types'
|
||||
|
||||
export enum Nux {
|
||||
NeueTypography = 'NeueTypography',
|
||||
BlueskyPlus_Core_RightNav = 'BlueskyPlus_Core_RightNav',
|
||||
}
|
||||
|
||||
export const nuxNames = new Set(Object.values(Nux))
|
||||
|
||||
export type AppNux = BaseNux<{
|
||||
id: Nux.NeueTypography
|
||||
id: Nux.BlueskyPlus_Core_RightNav
|
||||
data: undefined
|
||||
}>
|
||||
|
||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.NeueTypography]: undefined,
|
||||
[Nux.BlueskyPlus_Core_RightNav]: undefined,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {H1} from '#/components/Typography'
|
||||
|
||||
export function Gradients() {
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<H1>Gradients</H1>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
{(
|
||||
[
|
||||
'0deg',
|
||||
'45deg',
|
||||
'90deg',
|
||||
'135deg',
|
||||
'180deg',
|
||||
'225deg',
|
||||
'270deg',
|
||||
'315deg',
|
||||
] as const
|
||||
).map(rotate => (
|
||||
<View
|
||||
key={rotate}
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
{
|
||||
height: 600,
|
||||
},
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.bonfire} rotate={rotate} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {Breakpoints} from './Breakpoints'
|
||||
import {Buttons} from './Buttons'
|
||||
import {Dialogs} from './Dialogs'
|
||||
import {Forms} from './Forms'
|
||||
import {Gradients} from './Gradients'
|
||||
import {Icons} from './Icons'
|
||||
import {Links} from './Links'
|
||||
import {Menus} from './Menus'
|
||||
@@ -127,6 +128,8 @@ function StorybookInner() {
|
||||
<Breakpoints />
|
||||
<Dialogs />
|
||||
|
||||
<Gradients />
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
// import {msg} from '@lingui/macro'
|
||||
// import {useLingui} from '@lingui/react'
|
||||
import {useNavigation, useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {TextLink} from '#/view/com/util/Link'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
|
||||
export function DesktopFeeds() {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
// const {_} = useLingui()
|
||||
const {data: pinnedFeedInfos} = usePinnedFeedsInfos()
|
||||
const selectedFeed = useSelectedFeed()
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
@@ -24,19 +25,30 @@ export function DesktopFeeds() {
|
||||
}
|
||||
return getCurrentRoute(state)
|
||||
})
|
||||
|
||||
if (!pinnedFeedInfos) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.gap_md,
|
||||
web({
|
||||
paddingVertical: 2, // fixes overflow?
|
||||
overflowY: 'auto',
|
||||
}),
|
||||
]}>
|
||||
{pinnedFeedInfos.map(feedInfo => {
|
||||
const feed = feedInfo.feedDescriptor
|
||||
const current = route.name === 'Home' && feed === selectedFeed
|
||||
|
||||
return (
|
||||
<FeedItem
|
||||
key={feed}
|
||||
href={'/?' + new URLSearchParams([['feed', feed]])}
|
||||
title={feedInfo.displayName}
|
||||
current={route.name === 'Home' && feed === selectedFeed}
|
||||
<InlineLinkText
|
||||
key={feedInfo.uri}
|
||||
to={'/?' + new URLSearchParams([['feed', feed]])}
|
||||
label={feedInfo.displayName}
|
||||
onPress={() => {
|
||||
setSelectedFeed(feed)
|
||||
navigation.navigate('Home')
|
||||
@@ -44,56 +56,25 @@ export function DesktopFeeds() {
|
||||
emitSoftReset()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
style={[
|
||||
a.text_lg,
|
||||
current
|
||||
? [a.font_heavy, t.atoms.text]
|
||||
: [t.atoms.text_contrast_medium],
|
||||
]}>
|
||||
{feedInfo.displayName}
|
||||
</InlineLinkText>
|
||||
)
|
||||
})}
|
||||
<View style={{paddingTop: 8, paddingBottom: 6}}>
|
||||
<TextLink
|
||||
type="lg"
|
||||
href="/feeds"
|
||||
text={_(msg`More feeds`)}
|
||||
style={[pal.link]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/*
|
||||
<InlineLinkText
|
||||
to="/feeds"
|
||||
label={_(msg`More feeds`)}
|
||||
style={[a.text_lg]}>
|
||||
{_(msg`More feeds`)}
|
||||
</InlineLinkText>
|
||||
*/}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({
|
||||
title,
|
||||
href,
|
||||
current,
|
||||
onPress,
|
||||
}: {
|
||||
title: string
|
||||
href: string
|
||||
current: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={{paddingVertical: 6}}>
|
||||
<TextLink
|
||||
type="xl"
|
||||
href={href}
|
||||
text={title}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
current ? pal.text : pal.textLight,
|
||||
{letterSpacing: 0.15, fontWeight: current ? '600' : '400'},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
// @ts-ignore web only -prf
|
||||
overflowY: 'auto',
|
||||
width: 300,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 18,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3245,7 +3245,7 @@
|
||||
"@babel/parser" "^7.25.9"
|
||||
"@babel/types" "^7.25.9"
|
||||
|
||||
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
|
||||
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
|
||||
version "7.25.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
|
||||
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
|
||||
@@ -3306,19 +3306,6 @@
|
||||
debug "^4.3.1"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
|
||||
version "7.25.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
|
||||
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.25.9"
|
||||
"@babel/generator" "^7.25.9"
|
||||
"@babel/parser" "^7.25.9"
|
||||
"@babel/template" "^7.25.9"
|
||||
"@babel/types" "^7.25.9"
|
||||
debug "^4.3.1"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
|
||||
@@ -5883,6 +5870,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f"
|
||||
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
|
||||
|
||||
"@revenuecat/purchases-typescript-internal@13.12.0":
|
||||
version "13.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-13.12.0.tgz#64c8f3e63743631ed26d1ee2426724d89027fb02"
|
||||
integrity sha512-qP5tXjrW0J2ukBBnlr+H/eHlRdCfOVtHRcXNUtM4MEeIWxRPi50Ihs7eGs/yfgFVo6n92pXeWGBpNtV5fk4pAg==
|
||||
|
||||
"@segment/loosely-validate-event@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz#87dfc979e5b4e7b82c5f1d8b722dfd5d77644681"
|
||||
@@ -11473,6 +11465,11 @@ graphemer@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
|
||||
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
|
||||
|
||||
gretchen@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/gretchen/-/gretchen-1.5.1.tgz#fbd5da351d8c5af356d85ca36555d78583ade4a4"
|
||||
integrity sha512-tk/8z1+ZriI32zldoroZOfTmHpubAX7/bmvQpqdW6jpVPhS+tseBDRlqHsAOJ0HCnRTmuRFCcRrniCQzHQvmew==
|
||||
|
||||
gzip-size@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"
|
||||
@@ -16027,6 +16024,13 @@ react-native-progress@bluesky-social/react-native-progress:
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-native-purchases@^8.4.0:
|
||||
version "8.4.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-purchases/-/react-native-purchases-8.4.0.tgz#aa73cfc6a69849d9f57a706d280d47bb6b271057"
|
||||
integrity sha512-jbMg4oHXd7byS7kVE62EADfxGfuGfdO0GMBUaFfd/BmfTMrHapJ6UVpuwP2boSJKa2VTeOy3LyOLnGCOV2kF9Q==
|
||||
dependencies:
|
||||
"@revenuecat/purchases-typescript-internal" "13.12.0"
|
||||
|
||||
react-native-qrcode-styled@^0.3.3:
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-qrcode-styled/-/react-native-qrcode-styled-0.3.3.tgz#552b7fb85f8e570524ad3e89b4c0b4a8a82e0b67"
|
||||
@@ -17498,16 +17502,7 @@ string-natural-compare@^3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
||||
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -17607,7 +17602,7 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -17621,13 +17616,6 @@ strip-ansi@^5.2.0:
|
||||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
||||
@@ -18910,7 +18898,7 @@ wordwrap@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -18928,15 +18916,6 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
||||
Reference in New Issue
Block a user