Compare commits

...

51 Commits

Author SHA1 Message Date
Eric Bailey 757c12c9f3 Cleanup 2024-12-09 12:57:18 -06:00
Eric Bailey 58d4935ffc Angle 2024-12-09 10:27:40 -06:00
Eric Bailey 987a5a8cac Add prop to show learn more 2024-12-09 10:26:36 -06:00
Eric Bailey 14e475e539 Align native gutters in dialog 2024-12-09 10:23:04 -06:00
Eric Bailey f4f9c9644a Allow managing web subscriptions from native 2024-12-09 10:18:35 -06:00
Eric Bailey ccc9745639 Align web gutters in dialog 2024-12-09 10:04:31 -06:00
Eric Bailey 1269a55958 Fix some logic 2024-12-08 18:29:51 -06:00
Eric Bailey be2606ea9d Some design 2024-12-08 18:20:18 -06:00
Eric Bailey 9e65f7151c Move some things around 2024-12-08 16:28:30 -06:00
Eric Bailey c916b57e8d Port over gutters fix 2024-12-08 15:18:13 -06:00
Eric Bailey 62ddf1b2f8 WIP full-bleed header 2024-12-08 14:56:54 -06:00
Eric Bailey 0422071844 Add no padding Screen option 2024-12-08 14:48:50 -06:00
Eric Bailey 36b01417c2 Temp port gutter util 2024-12-08 14:48:33 -06:00
Eric Bailey 280c044231 Comments 2024-12-08 14:33:56 -06:00
Eric Bailey dd66944777 WIP 2024-12-08 12:41:04 -06:00
Eric Bailey fea90b72f9 Gradients storybook 2024-12-08 12:25:52 -06:00
Eric Bailey 2ddeb9b15f Add to ios workflow 2024-12-08 12:13:50 -06:00
Eric Bailey 7d8ac45e90 Add back install 2024-12-08 12:10:53 -06:00
Eric Bailey c9b5c2457f Layout header 2024-12-08 12:00:48 -06:00
Eric Bailey cf281a2c43 Placeholder 2024-12-08 12:00:17 -06:00
Eric Bailey cd870d2485 Merge remote-tracking branch 'origin/main' into subs/base
* origin/main:
  Revert "[Video] Revert safari hackfix (#5367)" (#7001)
  Fix squashed content on mod screens (#7004)
  [ELI5] Change feed page     copy (#6932)
  Immediately parse pre-filled links in composer state (#6974)
  Add useFormatCurrency hook (#6978)
  Update view shot (#6995)
  Remove icon (#6994)
  clean rn 0.76 upgrade (#6887)
  Trim back prefs exposure in NUXs, make naming more friendly (#6980)
  [Layout] Base (#6907)
  Add some icons (#6976)
2024-12-08 11:55:35 -06:00
Eric Bailey dcad549eb5 Show error 2024-12-05 14:54:15 -06:00
Eric Bailey 036f0e2a68 Export env 2024-12-05 14:54:15 -06:00
Eric Bailey b9ee26ab3c Design 2024-12-05 14:54:15 -06:00
Eric Bailey 1e7eaf0ee6 Verify email handling 2024-12-05 14:54:15 -06:00
Eric Bailey 533ebf5daa Rename 2024-12-05 14:54:15 -06:00
Eric Bailey 33181ab8ce Add PTR 2024-12-05 14:54:15 -06:00
Eric Bailey 011ef85230 Smooth out purchase 2024-12-05 14:54:15 -06:00
Eric Bailey 6e79fec72d Tweak colors 2024-12-05 14:54:15 -06:00
Eric Bailey 2e2b650209 Bop it 2024-12-05 14:54:15 -06:00
Eric Bailey 0cf44f7749 Some design 2024-12-05 14:54:15 -06:00
Eric Bailey 91568cc574 Vary dialog by screen active 2024-12-05 14:54:15 -06:00
Eric Bailey 3445363031 NUX right nav, fix SVGs 2024-12-05 14:54:15 -06:00
Eric Bailey 7e0e0a2900 Fix global usage 2024-12-05 14:54:15 -06:00
Eric Bailey 69f761dcc2 Remove unused code 2024-12-05 14:54:15 -06:00
Eric Bailey d8e8f398c4 Remove cruft from NUXs 2024-12-05 14:54:15 -06:00
Eric Bailey 769917e4ca Add Apple and Android logo 2024-12-05 14:54:15 -06:00
Eric Bailey 54f655d256 Fix restriction check 2024-12-05 14:54:15 -06:00
Eric Bailey a39c51490d Cleanup 2024-12-05 14:54:15 -06:00
Eric Bailey 8857758789 WIP 2024-12-05 14:54:15 -06:00
Eric Bailey 262651f966 Improve right nav, add EULA 2024-12-05 14:54:15 -06:00
Eric Bailey b63cb9bf5d Simplify the dialog 2024-12-05 14:54:15 -06:00
Eric Bailey 4db49edca6 Show state, add mgmt url 2024-12-05 14:54:15 -06:00
Eric Bailey 5e4bf58811 Hook up native 2024-12-05 14:54:15 -06:00
Eric Bailey df9101774c Update queries 2024-12-05 14:54:15 -06:00
Samuel Newman 0f15abc246 subscription dialog 2024-12-05 14:54:15 -06:00
Eric Bailey 9be5761885 WIP rails 2024-12-05 14:54:15 -06:00
Eric Bailey a284df0cd5 Add right-nav link 2024-12-05 14:54:15 -06:00
Eric Bailey 4cc2690365 Clean up right nav 2024-12-05 14:54:15 -06:00
Eric Bailey bdc16e0a15 Add prelim logo 2024-12-05 14:54:15 -06:00
Eric Bailey 13e871f973 Init route 2024-12-05 14:54:15 -06:00
52 changed files with 2495 additions and 200 deletions
@@ -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
+3
View File
@@ -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
+1
View File
@@ -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

+1
View File
@@ -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

+3
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
View File
@@ -30,6 +30,7 @@ import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as PurchasesProvider} from '#/state/purchases'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
@@ -110,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>
+6
View File
@@ -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}}
/>
</>
)
}
+28
View File
@@ -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
View File
@@ -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,
}
}
+7
View File
@@ -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',
}
+74 -1
View File
@@ -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])
}
+87
View File
@@ -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])
}
+3
View File
@@ -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>
+3 -3
View File
@@ -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,
]}
+10 -5
View File
@@ -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>
+55 -5
View File
@@ -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]}
/>
)
}
+7 -1
View File
@@ -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>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_low]}>
EULA
</InlineLinkText>
</Text>
</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>{' '}
&middot;{' '}
<InlineLinkText to="#" label="TODO REPLACE">
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<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'}]}>
{' '}
&middot; <Trans>A supporter badge on your profile</Trans>
</Text>
<Text
role="listitem"
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
{' '}
&middot; <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'}]}>
{' '}
&middot; <Trans>Custom profile colors</Trans>
</Text>
<Text
role="listitem"
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
{' '}
&middot; <Trans>Longer and higher quality videos</Trans>
</Text>
<Text
role="listitem"
style={[a.text_lg, a.leading_normal, {color: 'white'}]}>
{' '}
&middot; <Trans>Custom app icons</Trans>
</Text>
</View>
</>
)
}
+4 -2
View File
@@ -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'
? {
+65
View File
@@ -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>
)
}
+6
View File
@@ -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 || ''
+1
View File
@@ -56,6 +56,7 @@ export type CommonNavigatorParams = {
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {rkey?: string}
Subscriptions: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -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',
})
+13
View File
@@ -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>
)
+589
View File
@@ -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>{' '}
&middot;{' '}
<InlineLinkText
to="#"
label="TODO REPLACE"
style={[a.text_xs, t.atoms.text_contrast_medium]}>
Privacy Policy
</InlineLinkText>{' '}
&middot;{' '}
<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,
}
}
+7
View File
@@ -0,0 +1,7 @@
import {create} from 'gretchen'
import {PLUS_SERVICE_URL} from '#/env'
export const api = create({
baseURL: PLUS_SERVICE_URL,
})
+32
View File
@@ -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,
}
},
})
}
+70
View File
@@ -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)
}
+95
View File
@@ -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 -3
View File
@@ -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,
}
+40
View File
@@ -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>
)
}
+3
View File
@@ -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"
+42 -61
View File
@@ -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,
},
})
+21 -42
View File
@@ -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"