Merge branch 'main' into user-suggestion-dismiss
This commit is contained in:
@@ -36,6 +36,3 @@ EXPO_PUBLIC_BITDRIFT_API_KEY=
|
|||||||
|
|
||||||
# bapp-config web worker URL
|
# bapp-config web worker URL
|
||||||
BAPP_CONFIG_DEV_URL=
|
BAPP_CONFIG_DEV_URL=
|
||||||
|
|
||||||
# Dev-only passthrough value for bapp-config web worker
|
|
||||||
BAPP_CONFIG_DEV_BYPASS_SECRET=
|
|
||||||
|
|||||||
@@ -957,20 +957,20 @@ describe('parseStarterPackHttpUri', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
||||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
|
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey'
|
||||||
expect(parseStarterPackUri(validAtUri)).toEqual({
|
expect(parseStarterPackUri(validAtUri)).toEqual({
|
||||||
name: 'did:123',
|
name: 'did:plc:123',
|
||||||
rkey: 'rkey',
|
rkey: 'rkey',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when the at uri has no rkey', () => {
|
it('returns null when the at uri has no rkey', () => {
|
||||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
|
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack'
|
||||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
||||||
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
|
const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey'
|
||||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,19 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
#splash {
|
#splash {
|
||||||
|
display: flex;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#splash svg {
|
||||||
|
position: relative;
|
||||||
|
top: -50px;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateX(-50%) translateY(-50%) translateY(-50px);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* We need these styles to prevent shifting due to scrollbar show/hide on
|
* We need these styles to prevent shifting due to scrollbar show/hide on
|
||||||
@@ -106,7 +114,7 @@
|
|||||||
<div id="root">
|
<div id="root">
|
||||||
<div id="splash">
|
<div id="splash">
|
||||||
<!-- Bluesky SVG -->
|
<!-- Bluesky SVG -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -72,7 +72,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.18.0",
|
"@atproto/api": "^0.18.4",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.5",
|
"@bsky.app/alf": "^0.1.5",
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"@react-navigation/native-stack": "^7.3.13",
|
"@react-navigation/native-stack": "^7.3.13",
|
||||||
"@sentry/react-native": "~6.20.0",
|
"@sentry/react-native": "~6.20.0",
|
||||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||||
"@tanstack/react-query": "^5.8.1",
|
"@tanstack/react-query": "5.25.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.25.0",
|
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||||
"@tiptap/core": "^2.9.1",
|
"@tiptap/core": "^2.9.1",
|
||||||
"@tiptap/extension-document": "^2.9.1",
|
"@tiptap/extension-document": "^2.9.1",
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
|
||||||
|
index d300fc2..0890878 100644
|
||||||
|
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
|
||||||
|
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
|
||||||
|
@@ -3,8 +3,8 @@ package expo.modules.kotlin.activityresult
|
||||||
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import java.io.Serializable
|
||||||
|
+import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
-import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A launcher for a previously-[AppContextActivityResultCaller.registerForActivityResult] prepared call
|
||||||
|
@@ -22,8 +22,12 @@ abstract class AppContextActivityResultLauncher<I : Serializable, O> {
|
||||||
|
*/
|
||||||
|
abstract fun launch(input: I, callback: ActivityResultCallback<O>)
|
||||||
|
|
||||||
|
- suspend fun launch(input: I): O = suspendCoroutine { continuation ->
|
||||||
|
- launch(input) { output -> continuation.resume(output) }
|
||||||
|
+ suspend fun launch(input: I): O = suspendCancellableCoroutine { continuation ->
|
||||||
|
+ launch(input) { output ->
|
||||||
|
+ if (continuation.isActive) {
|
||||||
|
+ continuation.resume(output)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract val contract: AppContextActivityResultContract<I, O>
|
||||||
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
||||||
index 47c4d15..afe138d 100644
|
index 47c4d15..afe138d 100644
|
||||||
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
||||||
|
index 8b65716..27c97bf 100644
|
||||||
|
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
||||||
|
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
|
||||||
|
@@ -313,8 +313,9 @@ public open class JavaTimerManager(
|
||||||
|
// We also capture the idleCallbackRunnable to tentatively fix:
|
||||||
|
// https://github.com/facebook/react-native/issues/44842
|
||||||
|
currentIdleCallbackRunnable?.cancel()
|
||||||
|
- currentIdleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
|
||||||
|
- reactApplicationContext.runOnJSQueueThread(currentIdleCallbackRunnable)
|
||||||
|
+ val idleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
|
||||||
|
+ currentIdleCallbackRunnable = idleCallbackRunnable
|
||||||
|
+ reactApplicationContext.runOnJSQueueThread(idleCallbackRunnable)
|
||||||
|
reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.IDLE_EVENT, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-15
@@ -25,16 +25,10 @@ import I18nProvider from '#/locale/i18nProvider'
|
|||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isIOS} from '#/platform/detection'
|
import {isAndroid, isIOS} from '#/platform/detection'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
|
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||||
import {listenSessionDropped} from '#/state/events'
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {
|
|
||||||
beginResolveGeolocationConfig,
|
|
||||||
ensureGeolocationConfigIsResolved,
|
|
||||||
Provider as GeolocationProvider,
|
|
||||||
} from '#/state/geolocation'
|
|
||||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
@@ -56,6 +50,7 @@ import {readLastActiveAccount} from '#/state/session/util'
|
|||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
|
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
@@ -73,6 +68,9 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
|
|||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||||
import {ToastOutlet} from '#/components/Toast'
|
import {ToastOutlet} from '#/components/Toast'
|
||||||
|
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||||
|
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||||
|
import * as Geo from '#/geolocation'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
@@ -93,7 +91,8 @@ if (isAndroid) {
|
|||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
beginResolveGeolocationConfig()
|
Geo.resolve()
|
||||||
|
prefetchAgeAssuranceConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -143,7 +142,7 @@ function InnerApp() {
|
|||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
<AgeAssuranceProvider>
|
<AgeAssuranceV2Provider>
|
||||||
<ComposerProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
@@ -186,7 +185,7 @@ function InnerApp() {
|
|||||||
</LabelDefsProvider>
|
</LabelDefsProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</ComposerProvider>
|
||||||
</AgeAssuranceProvider>
|
</AgeAssuranceV2Provider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</PolicyUpdateOverlayProvider>
|
</PolicyUpdateOverlayProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
@@ -203,10 +202,9 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||||
initPersistedState(),
|
setReady(true),
|
||||||
ensureGeolocationConfigIsResolved(),
|
)
|
||||||
]).then(() => setReady(true))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
@@ -218,9 +216,10 @@ function App() {
|
|||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<GeolocationProvider>
|
<Geo.Provider>
|
||||||
<A11yProvider>
|
<A11yProvider>
|
||||||
<KeyboardControllerProvider>
|
<KeyboardControllerProvider>
|
||||||
|
<OnboardingProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
@@ -245,9 +244,10 @@ function App() {
|
|||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</OnboardingProvider>
|
||||||
</KeyboardControllerProvider>
|
</KeyboardControllerProvider>
|
||||||
</A11yProvider>
|
</A11yProvider>
|
||||||
</GeolocationProvider>
|
</Geo.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -14,16 +14,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
|
|||||||
import I18nProvider from '#/locale/i18nProvider'
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
|
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||||
import {listenSessionDropped} from '#/state/events'
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {
|
|
||||||
beginResolveGeolocationConfig,
|
|
||||||
ensureGeolocationConfigIsResolved,
|
|
||||||
Provider as GeolocationProvider,
|
|
||||||
} from '#/state/geolocation'
|
|
||||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
import {MessagesProvider} from '#/state/messages'
|
import {MessagesProvider} from '#/state/messages'
|
||||||
@@ -44,6 +38,7 @@ import {readLastActiveAccount} from '#/state/session/util'
|
|||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
|
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
@@ -61,13 +56,18 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
|||||||
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
|
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
|
||||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||||
import {ToastOutlet} from '#/components/Toast'
|
import {ToastOutlet} from '#/components/Toast'
|
||||||
|
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||||
|
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||||
|
import * as Geo from '#/geolocation'
|
||||||
|
import {Splash} from '#/Splash'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
|
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
beginResolveGeolocationConfig()
|
Geo.resolve()
|
||||||
|
prefetchAgeAssuranceConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -104,7 +104,7 @@ function InnerApp() {
|
|||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
// wait for session to resume
|
// wait for session to resume
|
||||||
if (!isReady || !hasCheckedReferrer) return null
|
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
@@ -118,7 +118,7 @@ function InnerApp() {
|
|||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<PolicyUpdateOverlayProvider>
|
<PolicyUpdateOverlayProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
<AgeAssuranceProvider>
|
<AgeAssuranceV2Provider>
|
||||||
<ComposerProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
@@ -157,7 +157,7 @@ function InnerApp() {
|
|||||||
</LabelDefsProvider>
|
</LabelDefsProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</ComposerProvider>
|
||||||
</AgeAssuranceProvider>
|
</AgeAssuranceV2Provider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</PolicyUpdateOverlayProvider>
|
</PolicyUpdateOverlayProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
@@ -174,14 +174,13 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||||
initPersistedState(),
|
setReady(true),
|
||||||
ensureGeolocationConfigIsResolved(),
|
)
|
||||||
]).then(() => setReady(true))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return null
|
return <Splash isReady />
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -189,8 +188,9 @@ function App() {
|
|||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<GeolocationProvider>
|
<Geo.Provider>
|
||||||
<A11yProvider>
|
<A11yProvider>
|
||||||
|
<OnboardingProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
@@ -210,8 +210,9 @@ function App() {
|
|||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</OnboardingProvider>
|
||||||
</A11yProvider>
|
</A11yProvider>
|
||||||
</GeolocationProvider>
|
</Geo.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* This is a reimplementation of what exists in our HTML template files
|
||||||
|
* already. Once the React tree mounts, this is what gets rendered first, until
|
||||||
|
* the app is ready to go.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Svg, {Path} from 'react-native-svg'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
|
const size = 100
|
||||||
|
const ratio = 57 / 64
|
||||||
|
|
||||||
|
export function Splash() {
|
||||||
|
return (
|
||||||
|
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
|
||||||
|
<Svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 64 57"
|
||||||
|
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
|
||||||
|
<Path
|
||||||
|
fill="#006AFF"
|
||||||
|
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const prefetchAgeAssuranceData = () => {}
|
||||||
|
export const setBirthdateForDid = () => {}
|
||||||
|
export const setCreatedAtForDid = () => {}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import {useCallback, useEffect} from 'react'
|
||||||
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
SupportCode,
|
||||||
|
useCreateSupportLink,
|
||||||
|
} from '#/lib/hooks/useCreateSupportLink'
|
||||||
|
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||||
|
import {useSessionApi} from '#/state/session'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||||
|
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||||
|
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||||
|
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||||
|
import {Full as Logo} from '#/components/icons/Logo'
|
||||||
|
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||||
|
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||||
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||||
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
|
import {
|
||||||
|
isLegacyBirthdateBug,
|
||||||
|
useAgeAssuranceRegionConfig,
|
||||||
|
} from '#/ageAssurance/util'
|
||||||
|
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||||
|
|
||||||
|
const textStyles = [a.text_md, a.leading_snug]
|
||||||
|
|
||||||
|
export function NoAccessScreen() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtPhone} = useBreakpoints()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const birthdateControl = useDialogControl()
|
||||||
|
const {data} = useAgeAssuranceDataContext()
|
||||||
|
const region = useAgeAssuranceRegionConfig()
|
||||||
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
|
const createSupportLink = useCreateSupportLink()
|
||||||
|
|
||||||
|
const aa = useAgeAssurance()
|
||||||
|
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||||
|
const isAARegion = !!region
|
||||||
|
const hasDeclaredAge = data?.declaredAge !== undefined
|
||||||
|
const canUpdateBirthday =
|
||||||
|
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// just counting overall hits here
|
||||||
|
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPressLogout = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
// We're switching accounts, which remounts the entire app.
|
||||||
|
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||||
|
// We can't change the URL via a navigate() call because the navigator
|
||||||
|
// itself is about to unmount, and it calls pushState() too late.
|
||||||
|
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||||
|
history.pushState(null, '', '/')
|
||||||
|
}
|
||||||
|
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||||||
|
}, [logoutCurrentAccount])
|
||||||
|
|
||||||
|
const birthdateUpdateText = canUpdateBirthday ? (
|
||||||
|
<Text style={[textStyles]}>
|
||||||
|
<Trans>
|
||||||
|
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||||
|
<SimpleInlineLinkText
|
||||||
|
label={_(msg`Click here to update your birthdate`)}
|
||||||
|
style={[textStyles]}
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
birthdateControl.open()
|
||||||
|
})}>
|
||||||
|
clicking here
|
||||||
|
</SimpleInlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={[textStyles]}>
|
||||||
|
<Trans>
|
||||||
|
If you believe your birthdate is incorrect, please{' '}
|
||||||
|
<SimpleInlineLinkText
|
||||||
|
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||||
|
label={_(msg`Click here to contact our support team`)}
|
||||||
|
style={[textStyles]}>
|
||||||
|
contact our support team
|
||||||
|
</SimpleInlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
a.px_2xl,
|
||||||
|
{
|
||||||
|
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
|
a.w_full,
|
||||||
|
web({
|
||||||
|
maxWidth: 380,
|
||||||
|
paddingTop: gtPhone ? '8vh' : undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
gap: 32,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View style={[a.align_start]}>
|
||||||
|
<AgeAssuranceBadge />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{hasDeclaredAge ? (
|
||||||
|
<>
|
||||||
|
{isAARegion ? (
|
||||||
|
<>
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<Text style={[textStyles]}>
|
||||||
|
<Trans>
|
||||||
|
You are accessing Bluesky from a region that legally
|
||||||
|
requires us to verify your age before allowing you to
|
||||||
|
access the app.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!isBlocked && birthdateUpdateText}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AccessSection />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<Text style={[textStyles]}>
|
||||||
|
<Trans>
|
||||||
|
Unfortunately, the birthdate you have saved to your
|
||||||
|
profile makes you too young to access Bluesky.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{birthdateUpdateText}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<Text style={[textStyles]}>
|
||||||
|
<Trans>
|
||||||
|
It looks like you haven't added your birthdate. You must
|
||||||
|
provide an accurate date of birth to use Bluesky.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
label={_(msg`Click here to update your birthdate`)}
|
||||||
|
onPress={() => birthdateControl.open()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add your birthdate</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[a.pt_lg, a.gap_xl]}>
|
||||||
|
<Logo width={120} textFill={t.atoms.text.color} />
|
||||||
|
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
To log out,{' '}
|
||||||
|
<SimpleInlineLinkText
|
||||||
|
label={_(msg`Click here to log out`)}
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
onPressLogout()
|
||||||
|
})}>
|
||||||
|
click here
|
||||||
|
</SimpleInlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<BirthDateSettingsDialog control={birthdateControl} />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* While this blocking overlay is up, other dialogs in the shell
|
||||||
|
* are not mounted, so it _should_ be safe to use these here
|
||||||
|
* without fear of other modals showing up.
|
||||||
|
*/}
|
||||||
|
<BottomSheetOutlet />
|
||||||
|
<PortalOutlet />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessSection() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const control = useDialogControl()
|
||||||
|
const appealControl = Dialog.useDialogControl()
|
||||||
|
const locationControl = Dialog.useDialogControl()
|
||||||
|
const getTimeAgo = useGetTimeAgo()
|
||||||
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||||
|
|
||||||
|
const aa = useAgeAssurance()
|
||||||
|
const {status, lastInitiatedAt} = aa.state
|
||||||
|
const isBlocked = status === aa.Status.Blocked
|
||||||
|
const hasInitiated = !!lastInitiatedAt
|
||||||
|
const timeAgo = lastInitiatedAt
|
||||||
|
? getTimeAgo(lastInitiatedAt, new Date())
|
||||||
|
: null
|
||||||
|
const diff = lastInitiatedAt
|
||||||
|
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AgeAssuranceInitDialog control={control} />
|
||||||
|
<AgeAssuranceAppealDialog control={appealControl} />
|
||||||
|
|
||||||
|
<View style={[a.gap_xl]}>
|
||||||
|
{isBlocked ? (
|
||||||
|
<Admonition type="warning">
|
||||||
|
<Trans>
|
||||||
|
You are currently unable to access Bluesky's Age Assurance flow.
|
||||||
|
Please{' '}
|
||||||
|
<SimpleInlineLinkText
|
||||||
|
label={_(msg`Contact our moderation team`)}
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
appealControl.open()
|
||||||
|
logger.metric('ageAssurance:appealDialogOpen', {})
|
||||||
|
})}>
|
||||||
|
contact our moderation team
|
||||||
|
</SimpleInlineLinkText>{' '}
|
||||||
|
if you believe this is an error.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Verify now`)}
|
||||||
|
size="large"
|
||||||
|
color={hasInitiated ? 'secondary' : 'primary'}
|
||||||
|
onPress={() => {
|
||||||
|
control.open()
|
||||||
|
logger.metric('ageAssurance:initDialogOpen', {
|
||||||
|
hasInitiatedPreviously: hasInitiated,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<ButtonIcon icon={ShieldIcon} />
|
||||||
|
<ButtonText>
|
||||||
|
{hasInitiated ? (
|
||||||
|
<Trans>Verify again</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Verify now</Trans>
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{lastInitiatedAt && timeAgo && diff ? (
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||||
|
title={i18n.date(lastInitiatedAt, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
})}>
|
||||||
|
{diff.value === 0 ? (
|
||||||
|
<Trans>Last initiated just now</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Last initiated {timeAgo} ago</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Age assurance only takes a few minutes</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[a.gap_xs]}>
|
||||||
|
{isNative && (
|
||||||
|
<>
|
||||||
|
<Admonition>
|
||||||
|
<Trans>
|
||||||
|
Is your location not accurate?{' '}
|
||||||
|
<SimpleInlineLinkText
|
||||||
|
label={_(msg`Confirm your location`)}
|
||||||
|
{...createStaticClick(() => {
|
||||||
|
locationControl.open()
|
||||||
|
})}>
|
||||||
|
Tap here to confirm your location.
|
||||||
|
</SimpleInlineLinkText>{' '}
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
|
||||||
|
<DeviceLocationRequestDialog
|
||||||
|
control={locationControl}
|
||||||
|
onLocationAcquired={props => {
|
||||||
|
const access = computeAgeAssuranceRegionAccess(
|
||||||
|
props.geolocation,
|
||||||
|
)
|
||||||
|
if (access !== aa.Access.Full) {
|
||||||
|
props.disableDialogAction()
|
||||||
|
props.setDialogError(
|
||||||
|
_(
|
||||||
|
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
props.closeDialog(() => {
|
||||||
|
// set this after close!
|
||||||
|
setDeviceGeolocation(props.geolocation)
|
||||||
|
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {Dimensions, View} from 'react-native'
|
||||||
|
import * as Linking from 'expo-linking'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {retry} from '#/lib/async/retry'
|
||||||
|
import {wait} from '#/lib/async/wait'
|
||||||
|
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {isIOS} from '#/platform/detection'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||||
|
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
|
export type RedirectOverlayState = {
|
||||||
|
result: 'success' | 'unknown'
|
||||||
|
actorDid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and parse the query parameters returned from the age assurance
|
||||||
|
* redirect. If not valid, returns `undefined` and the dialog will not open.
|
||||||
|
*/
|
||||||
|
export function parseRedirectOverlayState(
|
||||||
|
state: {
|
||||||
|
result?: string
|
||||||
|
actorDid?: string
|
||||||
|
} = {},
|
||||||
|
): RedirectOverlayState | undefined {
|
||||||
|
let result: RedirectOverlayState['result'] = 'unknown'
|
||||||
|
const actorDid = state.actorDid
|
||||||
|
|
||||||
|
switch (state.result) {
|
||||||
|
case 'success':
|
||||||
|
result = 'success'
|
||||||
|
break
|
||||||
|
case 'unknown':
|
||||||
|
default:
|
||||||
|
result = 'unknown'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actorDid) {
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
actorDid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext<{
|
||||||
|
isOpen: boolean
|
||||||
|
open: (state: RedirectOverlayState) => void
|
||||||
|
close: () => void
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
open: () => {},
|
||||||
|
close: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useRedirectOverlayContext() {
|
||||||
|
return useContext(Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: {children?: React.ReactNode}) {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const incomingUrl = Linking.useLinkingURL()
|
||||||
|
const [state, setState] = useState<RedirectOverlayState | null>(() => {
|
||||||
|
if (!incomingUrl) return null
|
||||||
|
const url = parseLinkingUrl(incomingUrl)
|
||||||
|
if (url.pathname !== '/intent/age-assurance') return null
|
||||||
|
const params = url.searchParams
|
||||||
|
const state = parseRedirectOverlayState({
|
||||||
|
result: params.get('result') ?? undefined,
|
||||||
|
actorDid: params.get('actorDid') ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isWeb) {
|
||||||
|
// Clear the URL parameters so they don't re-trigger
|
||||||
|
history.pushState(null, '', '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we don't have an account or the account doesn't match, do
|
||||||
|
* nothing. By the time the user switches to their other account, AA
|
||||||
|
* state should be ready for them.
|
||||||
|
*/
|
||||||
|
if (state && currentAccount && state.actorDid === currentAccount.did) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const open = useCallback((state: RedirectOverlayState) => {
|
||||||
|
setState(state)
|
||||||
|
}, [])
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setState(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider
|
||||||
|
value={useMemo(
|
||||||
|
() => ({
|
||||||
|
isOpen: state !== null,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
}),
|
||||||
|
[state, open, close],
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RedirectOverlay() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {isOpen} = useRedirectOverlayContext()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
|
return isOpen ? (
|
||||||
|
<FullWindowOverlay>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.fixed,
|
||||||
|
a.inset_0,
|
||||||
|
// setting a zIndex when using FullWindowOverlay on iOS
|
||||||
|
// means the taps pass straight through to the underlying content (???)
|
||||||
|
// so don't set it on iOS. FullWindowOverlay already does the job.
|
||||||
|
!isIOS && {zIndex: 9999},
|
||||||
|
t.atoms.bg,
|
||||||
|
gtMobile ? a.p_2xl : a.p_xl,
|
||||||
|
a.align_center,
|
||||||
|
// @ts-ignore
|
||||||
|
platform({
|
||||||
|
web: {
|
||||||
|
paddingTop: '35vh',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
paddingTop: Dimensions.get('window').height * 0.35,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
role="dialog"
|
||||||
|
aria-role="dialog"
|
||||||
|
aria-label={_(msg`Verifying your age assurance status`)}>
|
||||||
|
<View style={[a.pb_3xl, {width: 300}]}>
|
||||||
|
<Inner />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</FullWindowOverlay>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const agent = useAgent()
|
||||||
|
const polling = useRef(false)
|
||||||
|
const unmounted = useRef(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const {close} = useRedirectOverlayContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polling.current) return
|
||||||
|
|
||||||
|
polling.current = true
|
||||||
|
|
||||||
|
logger.metric('ageAssurance:redirectDialogOpen', {})
|
||||||
|
|
||||||
|
wait(
|
||||||
|
3e3,
|
||||||
|
retry(
|
||||||
|
5,
|
||||||
|
() => true,
|
||||||
|
async () => {
|
||||||
|
if (!agent.session) return
|
||||||
|
if (unmounted.current) return
|
||||||
|
|
||||||
|
const data = await refetchAgeAssuranceServerState({agent})
|
||||||
|
|
||||||
|
if (data?.state.status !== 'assured') {
|
||||||
|
throw new Error(
|
||||||
|
`Polling for age assurance state did not receive assured status`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
1e3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(async data => {
|
||||||
|
if (!data) return
|
||||||
|
if (!agent.session) return
|
||||||
|
if (unmounted.current) return
|
||||||
|
|
||||||
|
setSuccess(true)
|
||||||
|
|
||||||
|
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (unmounted.current) return
|
||||||
|
setError(true)
|
||||||
|
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted.current = true
|
||||||
|
}
|
||||||
|
}, [agent])
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.align_start, a.w_full]}>
|
||||||
|
<AgeAssuranceBadge />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_md,
|
||||||
|
]}>
|
||||||
|
<SuccessIcon size="sm" fill={t.palette.positive_500} />
|
||||||
|
<Text style={[a.text_3xl, a.font_bold]}>
|
||||||
|
<Trans>Success</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
We've confirmed your age assurance status. You can now close this
|
||||||
|
dialog.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.w_full, a.pt_lg]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.align_start, a.w_full]}>
|
||||||
|
<AgeAssuranceBadge />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_md,
|
||||||
|
]}>
|
||||||
|
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
|
||||||
|
|
||||||
|
<Text style={[a.text_3xl, a.font_bold]}>
|
||||||
|
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!error && <Loader size="lg" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||||
|
{error ? (
|
||||||
|
<Trans>
|
||||||
|
We were unable to receive the verification due to a connection
|
||||||
|
issue. It may arrive later. If it does, your account will update
|
||||||
|
automatically.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
We're confirming your age assurance status with our servers. This
|
||||||
|
should only take a few seconds.
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={[a.w_full, a.pt_lg]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||||
|
import {
|
||||||
|
type AppBskyAgeassuranceDefs,
|
||||||
|
type AppBskyAgeassuranceGetConfig,
|
||||||
|
type AppBskyAgeassuranceGetState,
|
||||||
|
AtpAgent,
|
||||||
|
getAgeAssuranceRegionConfig,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||||
|
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||||
|
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||||
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
|
import {networkRetry} from '#/lib/async/retry'
|
||||||
|
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||||
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import * as debug from '#/ageAssurance/debug'
|
||||||
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
import {isLegacyBirthdateBug} from '#/ageAssurance/util'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special query client for age assurance data so we can prefetch on app
|
||||||
|
* load without interfering with other queries.
|
||||||
|
*/
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
/**
|
||||||
|
* We clear this manually, so disable automatic garbage collection.
|
||||||
|
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
||||||
|
*/
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'age-assurance-query-client',
|
||||||
|
})
|
||||||
|
const [, cacheHydrationPromise] = persistQueryClient({
|
||||||
|
queryClient: qc,
|
||||||
|
persister,
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDidFromAgentSession(agent: AtpAgent) {
|
||||||
|
const sessionManager = agent.sessionManager
|
||||||
|
if (!sessionManager || !sessionManager.did) return
|
||||||
|
return sessionManager.did
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Optimistic data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const createdAtCache = new Map<string, string>()
|
||||||
|
export function setCreatedAtForDid({
|
||||||
|
did,
|
||||||
|
createdAt,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
createdAt: string
|
||||||
|
}) {
|
||||||
|
createdAtCache.set(did, createdAt)
|
||||||
|
}
|
||||||
|
const birthdateCache = new Map<string, string>()
|
||||||
|
export function setBirthdateForDid({
|
||||||
|
did,
|
||||||
|
birthdate,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
birthdate: string
|
||||||
|
}) {
|
||||||
|
birthdateCache.set(did, birthdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Config
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const configQueryKey = ['config']
|
||||||
|
export async function getConfig() {
|
||||||
|
if (debug.enabled) return debug.resolve(debug.config)
|
||||||
|
const agent = new AtpAgent({
|
||||||
|
service: PUBLIC_BSKY_SERVICE,
|
||||||
|
})
|
||||||
|
const res = await agent.app.bsky.ageassurance.getConfig()
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
export function getConfigFromCache():
|
||||||
|
| AppBskyAgeassuranceGetConfig.OutputSchema
|
||||||
|
| undefined {
|
||||||
|
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||||
|
configQueryKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let configPrefetchPromise: Promise<void> | undefined
|
||||||
|
export async function prefetchConfig() {
|
||||||
|
if (configPrefetchPromise) {
|
||||||
|
logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configPrefetchPromise = new Promise(async resolve => {
|
||||||
|
await cacheHydrationPromise
|
||||||
|
const cached = getConfigFromCache()
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(`prefetchAgeAssuranceConfig: using cache`)
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
|
||||||
|
const res = await networkRetry(3, () => getConfig())
|
||||||
|
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||||
|
configQueryKey,
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`prefetchAgeAssuranceConfig: failed`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useConfigQuery() {
|
||||||
|
return useQuery(
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Will re-fetch when stale, at most every hour (or 5s in dev for easier
|
||||||
|
* testing).
|
||||||
|
*
|
||||||
|
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
||||||
|
*/
|
||||||
|
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
|
||||||
|
initialData: getConfigFromCache(),
|
||||||
|
initialDataUpdatedAt: () =>
|
||||||
|
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
|
||||||
|
queryKey: configQueryKey,
|
||||||
|
async queryFn() {
|
||||||
|
logger.debug(`useConfigQuery: fetching config`)
|
||||||
|
return getConfig()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Server state
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createServerStateQueryKey({did}: {did: string}) {
|
||||||
|
return ['serverState', did]
|
||||||
|
}
|
||||||
|
export async function getServerState({agent}: {agent: AtpAgent}) {
|
||||||
|
if (debug.enabled && debug.serverState)
|
||||||
|
return debug.resolve(debug.serverState)
|
||||||
|
const geolocation = device.get(['mergedGeolocation'])
|
||||||
|
if (!geolocation || !geolocation.countryCode) {
|
||||||
|
logger.error(`getServerState: missing geolocation countryCode`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {data} = await agent.app.bsky.ageassurance.getState({
|
||||||
|
countryCode: geolocation.countryCode,
|
||||||
|
regionCode: geolocation.regionCode,
|
||||||
|
})
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
if (data && did && createdAtCache.has(did)) {
|
||||||
|
/*
|
||||||
|
* If account was just created, just use the local cache if available. On
|
||||||
|
* subsequent reloads, the server should have the correct value.
|
||||||
|
*/
|
||||||
|
data.metadata.accountCreatedAt = createdAtCache.get(did)
|
||||||
|
}
|
||||||
|
return data ?? null
|
||||||
|
}
|
||||||
|
export function getServerStateFromCache({
|
||||||
|
did,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
|
||||||
|
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||||
|
createServerStateQueryKey({did}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
|
||||||
|
if (!did) return
|
||||||
|
|
||||||
|
await cacheHydrationPromise
|
||||||
|
const qk = createServerStateQueryKey({did})
|
||||||
|
const cached = getServerStateFromCache({did})
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(`prefetchServerState: using cache`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`prefetchServerState: resolving...`)
|
||||||
|
const res = await networkRetry(3, () => getServerState({agent}))
|
||||||
|
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`prefetchServerState: failed`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function refetchServerState({agent}: {agent: AtpAgent}) {
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
if (!did) return
|
||||||
|
logger.debug(`refetchServerState: fetching...`)
|
||||||
|
const res = await networkRetry(3, () => getServerState({agent}))
|
||||||
|
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||||
|
createServerStateQueryKey({did}),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
export function usePatchServerState() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
return useCallback(
|
||||||
|
async (next: AppBskyAgeassuranceDefs.State) => {
|
||||||
|
if (!currentAccount) return
|
||||||
|
const did = currentAccount.did
|
||||||
|
const prev = getServerStateFromCache({did})
|
||||||
|
const merged: AppBskyAgeassuranceGetState.OutputSchema = {
|
||||||
|
metadata: {},
|
||||||
|
...(prev || {}),
|
||||||
|
state: next,
|
||||||
|
}
|
||||||
|
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||||
|
createServerStateQueryKey({did}),
|
||||||
|
merged,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currentAccount],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function useServerStateQuery() {
|
||||||
|
const agent = useAgent()
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
const query = useQuery(
|
||||||
|
{
|
||||||
|
enabled: !!did,
|
||||||
|
initialData: () => {
|
||||||
|
if (!did) return
|
||||||
|
return getServerStateFromCache({did})
|
||||||
|
},
|
||||||
|
queryKey: createServerStateQueryKey({did: did!}),
|
||||||
|
async queryFn() {
|
||||||
|
return getServerState({agent})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qc,
|
||||||
|
)
|
||||||
|
const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
|
||||||
|
|
||||||
|
const isAssured = query.data?.state?.status === 'assured'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `refetchOnWindowFocus` doesn't seem to want to work for this custom query
|
||||||
|
* client, so we manually subscribe to focus changes.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return focusManager.subscribe(() => {
|
||||||
|
// logged out
|
||||||
|
if (!did) return
|
||||||
|
|
||||||
|
const isFocused = focusManager.isFocused()
|
||||||
|
|
||||||
|
if (!isFocused) return
|
||||||
|
|
||||||
|
const config = getConfigFromCache()
|
||||||
|
const geolocation = device.get(['mergedGeolocation'])
|
||||||
|
const isAArequired = Boolean(
|
||||||
|
config &&
|
||||||
|
geolocation &&
|
||||||
|
!!getAgeAssuranceRegionConfig(config, {
|
||||||
|
countryCode: geolocation?.countryCode ?? '',
|
||||||
|
regionCode: geolocation?.regionCode,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// only refetch when needed
|
||||||
|
if (isAssured || !isAArequired) return
|
||||||
|
|
||||||
|
refetch()
|
||||||
|
})
|
||||||
|
}, [did, refetch, isAssured])
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Other required data
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OtherRequiredData = {
|
||||||
|
birthdate: string | undefined
|
||||||
|
}
|
||||||
|
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||||
|
return ['otherRequiredData', did]
|
||||||
|
}
|
||||||
|
export async function getOtherRequiredData({
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
agent: AtpAgent
|
||||||
|
}): Promise<OtherRequiredData> {
|
||||||
|
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
||||||
|
const [prefs] = await Promise.all([agent.getPreferences()])
|
||||||
|
const data: OtherRequiredData = {
|
||||||
|
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
|
||||||
|
}
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
if (data && did && birthdateCache.has(did)) {
|
||||||
|
/*
|
||||||
|
* If birthdate was just set, use the local cache value. On subsequent
|
||||||
|
* reloads, the server should have the correct value.
|
||||||
|
*/
|
||||||
|
data.birthdate = birthdateCache.get(did)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user is under the minimum age, and the birthdate is not due to
|
||||||
|
* the legacy bug, snooze further birthdate updates for this user.
|
||||||
|
*/
|
||||||
|
if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) {
|
||||||
|
snoozeBirthdateUpdateAllowedForDid(did!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
export function getOtherRequiredDataFromCache({
|
||||||
|
did,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
}): OtherRequiredData | undefined {
|
||||||
|
return qc.getQueryData<OtherRequiredData>(
|
||||||
|
createOtherRequiredDataQueryKey({did}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
|
||||||
|
if (!did) return
|
||||||
|
|
||||||
|
await cacheHydrationPromise
|
||||||
|
const qk = createOtherRequiredDataQueryKey({did})
|
||||||
|
const cached = getOtherRequiredDataFromCache({did})
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(`prefetchOtherRequiredData: using cache`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||||
|
const res = await networkRetry(3, () => getOtherRequiredData({agent}))
|
||||||
|
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function usePatchOtherRequiredData() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
return useCallback(
|
||||||
|
async (next: OtherRequiredData) => {
|
||||||
|
if (!currentAccount) return
|
||||||
|
const did = currentAccount.did
|
||||||
|
const prev = getOtherRequiredDataFromCache({did})
|
||||||
|
const merged: OtherRequiredData = {
|
||||||
|
...(prev || {}),
|
||||||
|
...next,
|
||||||
|
}
|
||||||
|
qc.setQueryData<OtherRequiredData>(
|
||||||
|
createOtherRequiredDataQueryKey({did}),
|
||||||
|
merged,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currentAccount],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export function useOtherRequiredDataQuery() {
|
||||||
|
const agent = useAgent()
|
||||||
|
const did = getDidFromAgentSession(agent)
|
||||||
|
return useQuery(
|
||||||
|
{
|
||||||
|
enabled: !!did,
|
||||||
|
initialData: () => {
|
||||||
|
if (!did) return
|
||||||
|
return getOtherRequiredDataFromCache({did})
|
||||||
|
},
|
||||||
|
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||||
|
async queryFn() {
|
||||||
|
return getOtherRequiredData({agent})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to prefetch all age assurance data.
|
||||||
|
*/
|
||||||
|
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||||
|
return Promise.allSettled([
|
||||||
|
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||||
|
configPrefetchPromise,
|
||||||
|
prefetchServerState({agent}),
|
||||||
|
prefetchOtherRequiredData({agent}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||||
|
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
||||||
|
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||||
|
qc.removeQueries({
|
||||||
|
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAgeAssuranceData() {
|
||||||
|
logger.debug(`clearAgeAssuranceData`)
|
||||||
|
qc.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Context
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AgeAssuranceData = {
|
||||||
|
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||||
|
state: AppBskyAgeassuranceDefs.State | undefined
|
||||||
|
data:
|
||||||
|
| {
|
||||||
|
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
||||||
|
declaredAge: number | undefined
|
||||||
|
birthdate: string | undefined
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
||||||
|
config: undefined,
|
||||||
|
state: undefined,
|
||||||
|
data: {
|
||||||
|
accountCreatedAt: undefined,
|
||||||
|
declaredAge: undefined,
|
||||||
|
birthdate: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export function useAgeAssuranceDataContext() {
|
||||||
|
return useContext(AgeAssuranceDataContext)
|
||||||
|
}
|
||||||
|
export function AgeAssuranceDataProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const {data: config} = useConfigQuery()
|
||||||
|
const serverState = useServerStateQuery()
|
||||||
|
const {state, metadata} = serverState.data || {}
|
||||||
|
const {data} = useOtherRequiredDataQuery()
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
data: {
|
||||||
|
accountCreatedAt: metadata?.accountCreatedAt,
|
||||||
|
declaredAge: data?.birthdate
|
||||||
|
? getAge(new Date(data.birthdate))
|
||||||
|
: undefined,
|
||||||
|
birthdate: data?.birthdate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[config, state, data, metadata],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||||
|
{children}
|
||||||
|
</AgeAssuranceDataContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
ageAssuranceRuleIDs as ids,
|
||||||
|
type AppBskyAgeassuranceDefs,
|
||||||
|
type AppBskyAgeassuranceGetState,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type OtherRequiredData} from '#/ageAssurance/data'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
|
import {type Geolocation} from '#/geolocation'
|
||||||
|
|
||||||
|
export const enabled = IS_DEV && false
|
||||||
|
|
||||||
|
export const geolocation: Geolocation | undefined = enabled
|
||||||
|
? {
|
||||||
|
countryCode: 'AA',
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||||
|
? {
|
||||||
|
countryCode: 'AA',
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||||
|
regions: [
|
||||||
|
{
|
||||||
|
countryCode: 'AA',
|
||||||
|
regionCode: undefined,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
$type: ids.IfAccountNewerThan,
|
||||||
|
date: '2025-12-01T00:00:00Z',
|
||||||
|
access: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: ids.IfAssuredOverAge,
|
||||||
|
age: 18,
|
||||||
|
access: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: ids.IfAssuredOverAge,
|
||||||
|
age: 16,
|
||||||
|
access: 'safe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: ids.IfDeclaredUnderAge,
|
||||||
|
age: 16,
|
||||||
|
access: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: ids.Default,
|
||||||
|
access: 'safe',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const otherRequiredData: OtherRequiredData = {
|
||||||
|
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverStateEnabled = false
|
||||||
|
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||||
|
serverStateEnabled
|
||||||
|
? {
|
||||||
|
state: {
|
||||||
|
lastInitiatedAt: new Date(2023, 5, 1).toISOString(),
|
||||||
|
status: 'assured',
|
||||||
|
access: 'safe',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
accountCreatedAt: new Date(2023, 11, 1).toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
export async function resolve<T>(data: T) {
|
||||||
|
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||||
|
|
||||||
|
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||||
|
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||||
|
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
|
||||||
|
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||||
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
import {
|
||||||
|
useAgeAssuranceState,
|
||||||
|
useOnAgeAssuranceAccessUpdate,
|
||||||
|
} from '#/ageAssurance/state'
|
||||||
|
import {
|
||||||
|
AgeAssuranceAccess,
|
||||||
|
type AgeAssuranceState,
|
||||||
|
AgeAssuranceStatus,
|
||||||
|
} from '#/ageAssurance/types'
|
||||||
|
import {isUserUnderAdultAge} from '#/ageAssurance/util'
|
||||||
|
|
||||||
|
export {
|
||||||
|
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||||
|
prefetchAgeAssuranceData,
|
||||||
|
refetchServerState as refetchAgeAssuranceServerState,
|
||||||
|
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||||
|
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||||
|
} from '#/ageAssurance/data'
|
||||||
|
export {logger} from '#/ageAssurance/logger'
|
||||||
|
|
||||||
|
const AgeAssuranceStateContext = createContext<{
|
||||||
|
Access: typeof AgeAssuranceAccess
|
||||||
|
Status: typeof AgeAssuranceStatus
|
||||||
|
state: AgeAssuranceState
|
||||||
|
flags: {
|
||||||
|
adultContentDisabled: boolean
|
||||||
|
chatDisabled: boolean
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
Access: AgeAssuranceAccess,
|
||||||
|
Status: AgeAssuranceStatus,
|
||||||
|
state: {
|
||||||
|
lastInitiatedAt: undefined,
|
||||||
|
status: AgeAssuranceStatus.Unknown,
|
||||||
|
access: AgeAssuranceAccess.Full,
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
adultContentDisabled: false,
|
||||||
|
chatDisabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE MAIN AGE ASSURANCE CONTEXT HOOK
|
||||||
|
*
|
||||||
|
* Prefer this to using any of the lower-level data-provider hooks.
|
||||||
|
*/
|
||||||
|
export function useAgeAssurance() {
|
||||||
|
return useContext(AgeAssuranceStateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<AgeAssuranceDataProvider>
|
||||||
|
<InnerProvider>
|
||||||
|
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||||
|
</InnerProvider>
|
||||||
|
</AgeAssuranceDataProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||||
|
const state = useAgeAssuranceState()
|
||||||
|
const {data} = useAgeAssuranceDataContext()
|
||||||
|
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||||
|
|
||||||
|
const handleAccessUpdate = useCallback(
|
||||||
|
(s: AgeAssuranceState) => {
|
||||||
|
getAndRegisterPushToken({
|
||||||
|
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[getAndRegisterPushToken],
|
||||||
|
)
|
||||||
|
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logger.debug(`useAgeAssuranceState`, {state})
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgeAssuranceStateContext.Provider
|
||||||
|
value={useMemo(() => {
|
||||||
|
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||||
|
const isUnderage = data?.birthdate
|
||||||
|
? isUserUnderAdultAge(data.birthdate)
|
||||||
|
: true
|
||||||
|
const adultContentDisabled =
|
||||||
|
state.access !== AgeAssuranceAccess.Full || isUnderage
|
||||||
|
return {
|
||||||
|
Access: AgeAssuranceAccess,
|
||||||
|
Status: AgeAssuranceStatus,
|
||||||
|
state,
|
||||||
|
flags: {
|
||||||
|
adultContentDisabled,
|
||||||
|
chatDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [state, data])}>
|
||||||
|
{children}
|
||||||
|
</AgeAssuranceStateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
|
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||||
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
import {
|
||||||
|
AgeAssuranceAccess,
|
||||||
|
type AgeAssuranceState,
|
||||||
|
AgeAssuranceStatus,
|
||||||
|
parseAccessFromString,
|
||||||
|
parseStatusFromString,
|
||||||
|
} from '#/ageAssurance/types'
|
||||||
|
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||||
|
import {useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const geolocation = useGeolocation()
|
||||||
|
const {config, state, data} = useAgeAssuranceDataContext()
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
/**
|
||||||
|
* This is where we control logged-out moderation prefs. It's all
|
||||||
|
* downstream of AA now.
|
||||||
|
*/
|
||||||
|
if (!hasSession)
|
||||||
|
return {
|
||||||
|
status: AgeAssuranceStatus.Unknown,
|
||||||
|
access: AgeAssuranceAccess.Safe,
|
||||||
|
}
|
||||||
|
|
||||||
|
// should never happen, but need to guard
|
||||||
|
if (!config) {
|
||||||
|
logger.warn('useAgeAssuranceState: missing config')
|
||||||
|
return {
|
||||||
|
status: AgeAssuranceStatus.Unknown,
|
||||||
|
access: AgeAssuranceAccess.Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||||
|
const isAARequired = region.countryCode !== '*'
|
||||||
|
const isTerminalState =
|
||||||
|
state?.status === 'assured' || state?.status === 'blocked'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If we are in a terminal state and AA is required for this region,
|
||||||
|
* we can trust the server state completely and avoid recomputing.
|
||||||
|
*/
|
||||||
|
if (isTerminalState && isAARequired) {
|
||||||
|
return {
|
||||||
|
lastInitiatedAt: state.lastInitiatedAt,
|
||||||
|
status: parseStatusFromString(state.status),
|
||||||
|
access: parseAccessFromString(state.access),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Otherwise, we need to compute the access based on the latest data. For
|
||||||
|
* accounts with an accurate birthdate, our default fallback rules should
|
||||||
|
* ensure correct access.
|
||||||
|
*/
|
||||||
|
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||||
|
const computed = {
|
||||||
|
lastInitiatedAt: state?.lastInitiatedAt,
|
||||||
|
// prefer server state
|
||||||
|
status: state?.status
|
||||||
|
? parseStatusFromString(state?.status)
|
||||||
|
: AgeAssuranceStatus.Unknown,
|
||||||
|
// prefer server state
|
||||||
|
access: result
|
||||||
|
? parseAccessFromString(result.access)
|
||||||
|
: AgeAssuranceAccess.Full,
|
||||||
|
}
|
||||||
|
logger.debug('debug useAgeAssuranceState', {
|
||||||
|
region,
|
||||||
|
state,
|
||||||
|
data,
|
||||||
|
computed,
|
||||||
|
})
|
||||||
|
return computed
|
||||||
|
}, [hasSession, geolocation, config, state, data])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnAgeAssuranceAccessUpdate(
|
||||||
|
cb: (state: AgeAssuranceState) => void,
|
||||||
|
) {
|
||||||
|
const state = useAgeAssuranceState()
|
||||||
|
// start with null to ensure callback is called on first render
|
||||||
|
const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevAccess !== state.access) {
|
||||||
|
setPrevAccess(state.access)
|
||||||
|
cb(state)
|
||||||
|
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
|
||||||
|
}
|
||||||
|
}, [cb, state, prevAccess])
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
|
||||||
|
export enum AgeAssuranceAccess {
|
||||||
|
Unknown = 'unknown',
|
||||||
|
None = 'none',
|
||||||
|
Safe = 'safe',
|
||||||
|
Full = 'full',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AgeAssuranceStatus {
|
||||||
|
Unknown = 'unknown',
|
||||||
|
Pending = 'pending',
|
||||||
|
Assured = 'assured',
|
||||||
|
Blocked = 'blocked',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgeAssuranceState = {
|
||||||
|
lastInitiatedAt?: string
|
||||||
|
status: AgeAssuranceStatus
|
||||||
|
access: AgeAssuranceAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatusFromString(raw: string) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'unknown':
|
||||||
|
return AgeAssuranceStatus.Unknown
|
||||||
|
case 'pending':
|
||||||
|
return AgeAssuranceStatus.Pending
|
||||||
|
case 'assured':
|
||||||
|
return AgeAssuranceStatus.Assured
|
||||||
|
case 'blocked':
|
||||||
|
return AgeAssuranceStatus.Blocked
|
||||||
|
default:
|
||||||
|
logger.error(`parseStatusFromString: unknown status value: ${raw}`)
|
||||||
|
return AgeAssuranceStatus.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAccessFromString(raw: string) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'unknown':
|
||||||
|
return AgeAssuranceAccess.Unknown
|
||||||
|
case 'none':
|
||||||
|
return AgeAssuranceAccess.None
|
||||||
|
case 'safe':
|
||||||
|
return AgeAssuranceAccess.Safe
|
||||||
|
case 'full':
|
||||||
|
return AgeAssuranceAccess.Full
|
||||||
|
default:
|
||||||
|
logger.error(`parseAccessFromString: unknown access value: ${raw}`)
|
||||||
|
return AgeAssuranceAccess.Full
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
|
||||||
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {wait} from '#/lib/async/wait'
|
||||||
|
import {
|
||||||
|
DEV_ENV_APPVIEW,
|
||||||
|
PUBLIC_APPVIEW,
|
||||||
|
PUBLIC_APPVIEW_DID,
|
||||||
|
} from '#/lib/constants'
|
||||||
|
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
|
||||||
|
import {BLUESKY_PROXY_DID} from '#/env'
|
||||||
|
import {useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
|
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
|
||||||
|
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
|
||||||
|
|
||||||
|
export function useBeginAgeAssurance() {
|
||||||
|
const agent = useAgent()
|
||||||
|
const geolocation = useGeolocation()
|
||||||
|
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
async mutationFn(
|
||||||
|
props: Omit<
|
||||||
|
AppBskyAgeassuranceBegin.InputSchema,
|
||||||
|
'countryCode' | 'regionCode'
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const countryCode = geolocation?.countryCode
|
||||||
|
const regionCode = geolocation?.regionCode
|
||||||
|
if (!countryCode) {
|
||||||
|
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {token},
|
||||||
|
} = await agent.com.atproto.server.getServiceAuth({
|
||||||
|
aud: BLUESKY_PROXY_DID,
|
||||||
|
lxm: `app.bsky.ageassurance.begin`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const appView = new AtpAgent({service: APPVIEW})
|
||||||
|
appView.sessionManager.session = {...agent.session!}
|
||||||
|
appView.sessionManager.session.accessJwt = token
|
||||||
|
appView.sessionManager.session.refreshJwt = ''
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
||||||
|
* ensure the email is ready for the user once they open their inbox.
|
||||||
|
*/
|
||||||
|
const {data} = await wait(
|
||||||
|
2e3,
|
||||||
|
appView.app.bsky.ageassurance.begin({
|
||||||
|
...props,
|
||||||
|
countryCode: countryCode.toUpperCase(),
|
||||||
|
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Just keeps this in sync, not necessarily used right now
|
||||||
|
patchAgeAssuranceStateResponse(data)
|
||||||
|
},
|
||||||
|
onError(e) {
|
||||||
|
if (!isNetworkError(e)) {
|
||||||
|
logger.error(`useBeginAgeAssurance failed`, {
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||||
|
import {logger} from '#/ageAssurance/logger'
|
||||||
|
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||||
|
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||||
|
import {type Geolocation} from '#/geolocation'
|
||||||
|
|
||||||
|
export function useComputeAgeAssuranceRegionAccess() {
|
||||||
|
const {config, data} = useAgeAssuranceDataContext()
|
||||||
|
return useCallback(
|
||||||
|
(geolocation: Geolocation) => {
|
||||||
|
if (!config) {
|
||||||
|
logger.warn('useComputeAgeAssuranceRegionAccess: missing config')
|
||||||
|
return AgeAssuranceAccess.Unknown
|
||||||
|
}
|
||||||
|
const region = getAgeAssuranceRegionConfigWithFallback(
|
||||||
|
config,
|
||||||
|
geolocation,
|
||||||
|
)
|
||||||
|
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||||
|
return result
|
||||||
|
? parseAccessFromString(result.access)
|
||||||
|
: AgeAssuranceAccess.Full
|
||||||
|
},
|
||||||
|
[config, data],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {
|
||||||
|
ageAssuranceRuleIDs as ids,
|
||||||
|
type AppBskyAgeassuranceDefs,
|
||||||
|
getAgeAssuranceRegionConfig,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||||
|
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||||
|
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
|
const DEFAULT_MIN_AGE = 13
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get age assurance region config based on geolocation, with fallback to
|
||||||
|
* app defaults if no region config is found.
|
||||||
|
*
|
||||||
|
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
||||||
|
* return undefined if the geolocation does not match any AA region.
|
||||||
|
*/
|
||||||
|
export function getAgeAssuranceRegionConfigWithFallback(
|
||||||
|
config: AppBskyAgeassuranceDefs.Config,
|
||||||
|
geolocation: Geolocation,
|
||||||
|
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||||
|
const region = getAgeAssuranceRegionConfig(config, {
|
||||||
|
countryCode: geolocation.countryCode ?? '',
|
||||||
|
regionCode: geolocation.regionCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
region || {
|
||||||
|
countryCode: '*',
|
||||||
|
regionCode: undefined,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
$type: ids.IfDeclaredOverAge,
|
||||||
|
age: DEFAULT_MIN_AGE,
|
||||||
|
access: AgeAssuranceAccess.Full,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$type: ids.Default,
|
||||||
|
access: AgeAssuranceAccess.None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the age assurance region config based on current geolocation.
|
||||||
|
* Does not fall-back to our app defaults. If no config is found, returns
|
||||||
|
* undefined, which indicates no regional age assurance rules apply.
|
||||||
|
*/
|
||||||
|
export function useAgeAssuranceRegionConfig() {
|
||||||
|
const geolocation = useGeolocation()
|
||||||
|
const {config} = useAgeAssuranceDataContext()
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!config) return
|
||||||
|
// use generic helper, we want to potentially return undefined
|
||||||
|
return getAgeAssuranceRegionConfig(config, {
|
||||||
|
countryCode: geolocation.countryCode ?? '',
|
||||||
|
regionCode: geolocation.regionCode,
|
||||||
|
})
|
||||||
|
}, [config, geolocation])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some users may have erroneously set their birth date to the current date
|
||||||
|
* if one wasn't set on their account. We previously didn't do validation on
|
||||||
|
* the bday dialog, and it defaulted to the current date. This bug _has_ been
|
||||||
|
* seen in production, so we need to check for it where possible.
|
||||||
|
*/
|
||||||
|
export function isLegacyBirthdateBug(birthDate: string) {
|
||||||
|
return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the user is under the minimum age required to use the app.
|
||||||
|
* This applies to all regions.
|
||||||
|
*/
|
||||||
|
export function isUserUnderMinimumAge(birthDate: string) {
|
||||||
|
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUserUnderAdultAge(birthDate: string) {
|
||||||
|
return getAge(new Date(birthDate)) < 18
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import {useEffect} from 'react'
|
|
||||||
import {ScrollView, View} from 'react-native'
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Full as Logo, Mark} from '#/components/icons/Logo'
|
|
||||||
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
|
||||||
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
|
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
|
||||||
import * as Toast from '#/components/Toast'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
|
||||||
|
|
||||||
export function BlockedGeoOverlay() {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {gtPhone} = useBreakpoints()
|
|
||||||
const insets = useSafeAreaInsets()
|
|
||||||
const geoDialog = Dialog.useDialogControl()
|
|
||||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// just counting overall hits here
|
|
||||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const textStyles = [a.text_md, a.leading_normal]
|
|
||||||
const links = {
|
|
||||||
blog: {
|
|
||||||
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
|
|
||||||
label: _(msg`Read our blog post`),
|
|
||||||
overridePresentation: false,
|
|
||||||
disableMismatchWarning: true,
|
|
||||||
style: textStyles,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = [
|
|
||||||
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
|
|
||||||
_(
|
|
||||||
msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
|
|
||||||
),
|
|
||||||
_(
|
|
||||||
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
|
|
||||||
),
|
|
||||||
_(
|
|
||||||
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
|
|
||||||
),
|
|
||||||
<>
|
|
||||||
To learn more, read our{' '}
|
|
||||||
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
|
||||||
</>,
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={[
|
|
||||||
a.px_2xl,
|
|
||||||
{
|
|
||||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
|
||||||
paddingBottom: 100,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.mx_auto,
|
|
||||||
web({
|
|
||||||
maxWidth: 380,
|
|
||||||
paddingTop: gtPhone ? '8vh' : undefined,
|
|
||||||
}),
|
|
||||||
]}>
|
|
||||||
<View style={[a.align_start]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.pl_md,
|
|
||||||
a.pr_lg,
|
|
||||||
a.py_sm,
|
|
||||||
a.rounded_full,
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_xs,
|
|
||||||
{
|
|
||||||
backgroundColor: t.palette.primary_25,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Mark fill={t.palette.primary_600} width={14} />
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.font_semi_bold,
|
|
||||||
{
|
|
||||||
color: t.palette.primary_600,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Trans>Announcement</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.gap_lg, {paddingTop: 32}]}>
|
|
||||||
{blocks.map((block, index) => (
|
|
||||||
<Text key={index} style={[textStyles]}>
|
|
||||||
{block}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{!isWeb && (
|
|
||||||
<>
|
|
||||||
<View style={[a.pt_2xl]}>
|
|
||||||
<Divider />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.mt_xl, a.align_start]}>
|
|
||||||
<Text style={[a.text_lg, a.font_bold, a.leading_snug, a.pb_xs]}>
|
|
||||||
<Trans>Not in Mississippi?</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.text_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
a.pb_md,
|
|
||||||
]}>
|
|
||||||
<Trans>
|
|
||||||
Confirm your location with GPS. Your location data is not
|
|
||||||
tracked and does not leave your device.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Confirm your location`)}
|
|
||||||
onPress={() => geoDialog.open()}
|
|
||||||
size="small"
|
|
||||||
color="primary_subtle">
|
|
||||||
<ButtonIcon icon={LocationIcon} />
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Confirm your location</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<DeviceLocationRequestDialog
|
|
||||||
control={geoDialog}
|
|
||||||
onLocationAcquired={props => {
|
|
||||||
if (props.geolocationStatus.isAgeBlockedGeo) {
|
|
||||||
props.disableDialogAction()
|
|
||||||
props.setDialogError(
|
|
||||||
_(
|
|
||||||
msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
props.closeDialog(() => {
|
|
||||||
// set this after close!
|
|
||||||
setDeviceGeolocation({
|
|
||||||
countryCode: props.geolocationStatus.countryCode,
|
|
||||||
regionCode: props.geolocationStatus.regionCode,
|
|
||||||
})
|
|
||||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[{paddingTop: 48}]}>
|
|
||||||
<Logo width={120} textFill={t.atoms.text.color} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
* While this blocking overlay is up, other dialogs in the shell
|
|
||||||
* are not mounted, so it _should_ be safe to use these here
|
|
||||||
* without fear of other modals showing up.
|
|
||||||
*/}
|
|
||||||
<BottomSheetOutlet />
|
|
||||||
<PortalOutlet />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -421,6 +421,7 @@ export function SimpleInlineLinkText({
|
|||||||
label,
|
label,
|
||||||
disableUnderline,
|
disableUnderline,
|
||||||
shouldProxy,
|
shouldProxy,
|
||||||
|
onPress: outerOnPress,
|
||||||
...rest
|
...rest
|
||||||
}: Omit<
|
}: Omit<
|
||||||
InlineLinkProps,
|
InlineLinkProps,
|
||||||
@@ -428,7 +429,6 @@ export function SimpleInlineLinkText({
|
|||||||
| 'action'
|
| 'action'
|
||||||
| 'disableMismatchWarning'
|
| 'disableMismatchWarning'
|
||||||
| 'overridePresentation'
|
| 'overridePresentation'
|
||||||
| 'onPress'
|
|
||||||
| 'onLongPress'
|
| 'onLongPress'
|
||||||
| 'shareOnLongPress'
|
| 'shareOnLongPress'
|
||||||
> & {
|
> & {
|
||||||
@@ -448,7 +448,9 @@ export function SimpleInlineLinkText({
|
|||||||
href = createProxiedUrl(href)
|
href = createProxiedUrl(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = (e: GestureResponderEvent) => {
|
||||||
|
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||||
|
if (exitEarlyIfFalse === false) return
|
||||||
Linking.openURL(href)
|
Linking.openURL(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +519,7 @@ export function WebOnlyInlineLinkText({
|
|||||||
export function createStaticClick(
|
export function createStaticClick(
|
||||||
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
||||||
): {
|
): {
|
||||||
to: BaseLinkProps['to']
|
to: string
|
||||||
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
|
|||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
@@ -24,6 +23,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
|
|||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||||
import {RecentChats} from './RecentChats'
|
import {RecentChats} from './RecentChats'
|
||||||
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
||||||
@@ -37,7 +37,7 @@ let ShareMenuItems = ({
|
|||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const sendViaChatControl = useDialogControl()
|
const sendViaChatControl = useDialogControl()
|
||||||
const [devModeEnabled] = useDevMode()
|
const [devModeEnabled] = useDevMode()
|
||||||
const {isAgeRestricted} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
const postAuthor = useProfileShadow(post.author)
|
const postAuthor = useProfileShadow(post.author)
|
||||||
@@ -91,7 +91,7 @@ let ShareMenuItems = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu.Outer>
|
<Menu.Outer>
|
||||||
{hasSession && !isAgeRestricted && (
|
{hasSession && aa.state.access === aa.Access.Full && (
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.ContainerItem>
|
<Menu.ContainerItem>
|
||||||
<RecentChats postUri={postUri} />
|
<RecentChats postUri={postUri} />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
|
|||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useBreakpoints} from '#/alf'
|
import {useBreakpoints} from '#/alf'
|
||||||
@@ -22,6 +21,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
|
|||||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||||
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ let ShareMenuItems = ({
|
|||||||
const embedPostControl = useDialogControl()
|
const embedPostControl = useDialogControl()
|
||||||
const sendViaChatControl = useDialogControl()
|
const sendViaChatControl = useDialogControl()
|
||||||
const [devModeEnabled] = useDevMode()
|
const [devModeEnabled] = useDevMode()
|
||||||
const {isAgeRestricted} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
const postCid = post.cid
|
const postCid = post.cid
|
||||||
@@ -97,7 +97,7 @@ let ShareMenuItems = ({
|
|||||||
<Menu.Outer>
|
<Menu.Outer>
|
||||||
{!hideInPWI && copyLinkItem}
|
{!hideInPWI && copyLinkItem}
|
||||||
|
|
||||||
{hasSession && !isAgeRestricted && (
|
{hasSession && aa.state.access === aa.Access.Full && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownSendViaDMBtn"
|
testID="postDropdownSendViaDMBtn"
|
||||||
label={_(msg`Send via direct message`)}
|
label={_(msg`Send via direct message`)}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
|
||||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||||
@@ -23,14 +20,13 @@ import {Divider} from '#/components/Divider'
|
|||||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {logger, useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
|
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||||
|
|
||||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||||
const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
if (aa.state.access === aa.Access.Full) return null
|
||||||
if (!isReady) return null
|
|
||||||
if (isDeclaredUnderage) return null
|
|
||||||
if (!isAgeRestricted) return null
|
|
||||||
|
|
||||||
return <Inner style={style} />
|
return <Inner style={style} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +39,12 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
const getTimeAgo = useGetTimeAgo()
|
const getTimeAgo = useGetTimeAgo()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||||
|
|
||||||
const copy = useAgeAssuranceCopy()
|
const copy = useAgeAssuranceCopy()
|
||||||
const {status, lastInitiatedAt} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
const isBlocked = status === 'blocked'
|
const {status, lastInitiatedAt} = aa.state
|
||||||
|
const isBlocked = status === aa.Status.Blocked
|
||||||
const hasInitiated = !!lastInitiatedAt
|
const hasInitiated = !!lastInitiatedAt
|
||||||
const timeAgo = lastInitiatedAt
|
const timeAgo = lastInitiatedAt
|
||||||
? getTimeAgo(lastInitiatedAt, new Date())
|
? getTimeAgo(lastInitiatedAt, new Date())
|
||||||
@@ -98,7 +96,10 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
<DeviceLocationRequestDialog
|
<DeviceLocationRequestDialog
|
||||||
control={locationControl}
|
control={locationControl}
|
||||||
onLocationAcquired={props => {
|
onLocationAcquired={props => {
|
||||||
if (props.geolocationStatus.isAgeRestrictedGeo) {
|
const access = computeAgeAssuranceRegionAccess(
|
||||||
|
props.geolocation,
|
||||||
|
)
|
||||||
|
if (access !== aa.Access.Full) {
|
||||||
props.disableDialogAction()
|
props.disableDialogAction()
|
||||||
props.setDialogError(
|
props.setDialogError(
|
||||||
_(
|
_(
|
||||||
@@ -108,10 +109,7 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
} else {
|
} else {
|
||||||
props.closeDialog(() => {
|
props.closeDialog(() => {
|
||||||
// set this after close!
|
// set this after close!
|
||||||
setDeviceGeolocation({
|
setDeviceGeolocation(props.geolocation)
|
||||||
countryCode: props.geolocationStatus.countryCode,
|
|
||||||
regionCode: props.geolocationStatus.regionCode,
|
|
||||||
})
|
|
||||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,25 +2,23 @@ import {View} from 'react-native'
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||||
import type * as Dialog from '#/components/Dialog'
|
import type * as Dialog from '#/components/Dialog'
|
||||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export function AgeAssuranceAdmonition({
|
export function AgeAssuranceAdmonition({
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
}: ViewStyleProp & {children: React.ReactNode}) {
|
}: ViewStyleProp & {children: React.ReactNode}) {
|
||||||
const control = useDialogControl()
|
const control = useDialogControl()
|
||||||
const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
if (!isReady) return null
|
if (aa.state.access === aa.Access.Full) return null
|
||||||
if (isDeclaredUnderage) return null
|
|
||||||
if (!isAgeRestricted) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Inner style={style} control={control}>
|
<Inner style={style} control={control}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
|
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useBreakpoints, web} from '#/alf'
|
import {atoms as a, useBreakpoints, web} from '#/alf'
|
||||||
@@ -15,6 +14,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export function AgeAssuranceAppealDialog({
|
export function AgeAssuranceAppealDialog({
|
||||||
control,
|
control,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {View} from 'react-native'
|
|||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||||
import {atoms as a, select, useTheme} from '#/alf'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
@@ -13,30 +11,22 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/
|
|||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export function useInternalState() {
|
export function useInternalState() {
|
||||||
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
|
const aa = useAgeAssurance()
|
||||||
useAgeAssurance()
|
|
||||||
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
|
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
|
||||||
const {mutate: save, variables} = useSaveNux()
|
const {mutate: save, variables} = useSaveNux()
|
||||||
const hidden = !!variables
|
const hidden = !!variables
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
if (!isReady) return false
|
if (aa.state.access === aa.Access.Full) return false
|
||||||
if (isDeclaredUnderage) return false
|
if (aa.state.lastInitiatedAt) return false
|
||||||
if (!isAgeRestricted) return false
|
|
||||||
if (lastInitiatedAt) return false
|
|
||||||
if (hidden) return false
|
if (hidden) return false
|
||||||
if (nux && nux.completed) return false
|
if (nux && nux.completed) return false
|
||||||
return true
|
return true
|
||||||
}, [
|
}, [aa, hidden, nux])
|
||||||
isReady,
|
|
||||||
isDeclaredUnderage,
|
|
||||||
isAgeRestricted,
|
|
||||||
lastInitiatedAt,
|
|
||||||
hidden,
|
|
||||||
nux,
|
|
||||||
])
|
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
save({
|
save({
|
||||||
|
|||||||
@@ -2,28 +2,25 @@ import {View} from 'react-native'
|
|||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
|
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
|
const aa = useAgeAssurance()
|
||||||
useAgeAssurance()
|
|
||||||
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
|
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
|
||||||
const copy = useAgeAssuranceCopy()
|
const copy = useAgeAssuranceCopy()
|
||||||
const {mutate: save, variables} = useSaveNux()
|
const {mutate: save, variables} = useSaveNux()
|
||||||
const hidden = !!variables
|
const hidden = !!variables
|
||||||
|
|
||||||
if (!isReady) return null
|
if (aa.state.access === aa.Access.Full) return null
|
||||||
if (isDeclaredUnderage) return null
|
if (aa.state.lastInitiatedAt) return null
|
||||||
if (!isAgeRestricted) return null
|
|
||||||
if (lastInitiatedAt) return null
|
|
||||||
if (hidden) return null
|
if (hidden) return null
|
||||||
if (nux && nux.completed) return null
|
if (nux && nux.completed) return null
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
|||||||
import {useTLDs} from '#/lib/hooks/useTLDs'
|
import {useTLDs} from '#/lib/hooks/useTLDs'
|
||||||
import {isEmailMaybeInvalid} from '#/lib/strings/email'
|
import {isEmailMaybeInvalid} from '#/lib/strings/email'
|
||||||
import {type AppLanguage} from '#/locale/languages'
|
import {type AppLanguage} from '#/locale/languages'
|
||||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
|
||||||
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
@@ -30,9 +27,12 @@ import {Divider} from '#/components/Divider'
|
|||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||||
import {LanguageSelect} from '#/components/LanguageSelect'
|
import {LanguageSelect} from '#/components/LanguageSelect'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {SimpleInlineLinkText} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
|
||||||
|
|
||||||
export {useDialogControl} from '#/components/Dialog/context'
|
export {useDialogControl} from '#/components/Dialog/context'
|
||||||
|
|
||||||
@@ -69,7 +69,8 @@ function Inner() {
|
|||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const cleanError = useCleanError()
|
const cleanError = useCleanError()
|
||||||
const {close} = Dialog.useDialogContext()
|
const {close} = Dialog.useDialogContext()
|
||||||
const {lastInitiatedAt} = useAgeAssuranceContext()
|
const aa = useAgeAssurance()
|
||||||
|
const lastInitiatedAt = aa.state.lastInitiatedAt
|
||||||
const getTimeAgo = useGetTimeAgo()
|
const getTimeAgo = useGetTimeAgo()
|
||||||
const tlds = useTLDs()
|
const tlds = useTLDs()
|
||||||
const createSupportLink = useCreateSupportLink()
|
const createSupportLink = useCreateSupportLink()
|
||||||
@@ -88,7 +89,7 @@ function Inner() {
|
|||||||
)
|
)
|
||||||
const [error, setError] = useState<React.ReactNode>(null)
|
const [error, setError] = useState<React.ReactNode>(null)
|
||||||
|
|
||||||
const {mutateAsync: init, isPending} = useInitAgeAssurance()
|
const {mutateAsync: begin, isPending} = useBeginAgeAssurance()
|
||||||
|
|
||||||
const runEmailValidation = () => {
|
const runEmailValidation = () => {
|
||||||
if (validateEmail(email)) {
|
if (validateEmail(email)) {
|
||||||
@@ -127,7 +128,7 @@ function Inner() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await init({
|
await begin({
|
||||||
email,
|
email,
|
||||||
language,
|
language,
|
||||||
})
|
})
|
||||||
@@ -150,11 +151,11 @@ function Inner() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
We're having issues initializing the age assurance process for
|
We're having issues initializing the age assurance process for
|
||||||
your account. Please{' '}
|
your account. Please{' '}
|
||||||
<InlineLinkText
|
<SimpleInlineLinkText
|
||||||
to={createSupportLink({code: SupportCode.AA_DID, email})}
|
to={createSupportLink({code: SupportCode.AA_DID, email})}
|
||||||
label={_(msg`Contact support`)}>
|
label={_(msg`Contact support`)}>
|
||||||
contact support
|
contact support
|
||||||
</InlineLinkText>{' '}
|
</SimpleInlineLinkText>{' '}
|
||||||
for assistance.
|
for assistance.
|
||||||
</Trans>
|
</Trans>
|
||||||
</>
|
</>
|
||||||
@@ -195,14 +196,12 @@ function Inner() {
|
|||||||
<Text style={[a.text_sm, a.leading_snug]}>
|
<Text style={[a.text_sm, a.leading_snug]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
We have partnered with{' '}
|
We have partnered with{' '}
|
||||||
<InlineLinkText
|
<SimpleInlineLinkText
|
||||||
overridePresentation
|
|
||||||
disableMismatchWarning
|
|
||||||
label={_(msg`KWS website`)}
|
label={_(msg`KWS website`)}
|
||||||
to={urls.kwsHome}
|
to={urls.kwsHome}
|
||||||
style={[a.text_sm, a.leading_snug]}>
|
style={[a.text_sm, a.leading_snug]}>
|
||||||
KWS
|
KWS
|
||||||
</InlineLinkText>{' '}
|
</SimpleInlineLinkText>{' '}
|
||||||
to verify that you’re an adult. When you click "Begin" below,
|
to verify that you’re an adult. When you click "Begin" below,
|
||||||
KWS will check if you have previously verified your age using
|
KWS will check if you have previously verified your age using
|
||||||
this email address for other games/services powered by KWS
|
this email address for other games/services powered by KWS
|
||||||
@@ -328,24 +327,20 @@ function Inner() {
|
|||||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
|
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
By continuing, you agree to the{' '}
|
By continuing, you agree to the{' '}
|
||||||
<InlineLinkText
|
<SimpleInlineLinkText
|
||||||
overridePresentation
|
|
||||||
disableMismatchWarning
|
|
||||||
label={_(msg`KWS Terms of Use`)}
|
label={_(msg`KWS Terms of Use`)}
|
||||||
to={urls.kwsTermsOfUse}
|
to={urls.kwsTermsOfUse}
|
||||||
style={[a.text_xs, a.leading_snug]}>
|
style={[a.text_xs, a.leading_snug]}>
|
||||||
KWS Terms of Use
|
KWS Terms of Use
|
||||||
</InlineLinkText>{' '}
|
</SimpleInlineLinkText>{' '}
|
||||||
and acknowledge that KWS will store your verified status with
|
and acknowledge that KWS will store your verified status with
|
||||||
your hashed email address in accordance with the{' '}
|
your hashed email address in accordance with the{' '}
|
||||||
<InlineLinkText
|
<SimpleInlineLinkText
|
||||||
overridePresentation
|
|
||||||
disableMismatchWarning
|
|
||||||
label={_(msg`KWS Privacy Policy`)}
|
label={_(msg`KWS Privacy Policy`)}
|
||||||
to={urls.kwsPrivacyPolicy}
|
to={urls.kwsPrivacyPolicy}
|
||||||
style={[a.text_xs, a.leading_snug]}>
|
style={[a.text_xs, a.leading_snug]}>
|
||||||
KWS Privacy Policy
|
KWS Privacy Policy
|
||||||
</InlineLinkText>
|
</SimpleInlineLinkText>
|
||||||
. This means you won’t need to verify again the next time you
|
. This means you won’t need to verify again the next time you
|
||||||
use this email for other apps, games, and services powered by
|
use this email for other apps, games, and services powered by
|
||||||
KWS technology.
|
KWS technology.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {retry} from '#/lib/async/retry'
|
import {retry} from '#/lib/async/retry'
|
||||||
import {wait} from '#/lib/async/wait'
|
import {wait} from '#/lib/async/wait'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||||
@@ -18,6 +16,8 @@ import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/ic
|
|||||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export type AgeAssuranceRedirectDialogState = {
|
export type AgeAssuranceRedirectDialogState = {
|
||||||
result: 'success' | 'unknown'
|
result: 'success' | 'unknown'
|
||||||
@@ -63,7 +63,7 @@ export function AgeAssuranceRedirectDialog() {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const control = useAgeAssuranceRedirectDialogControl()
|
const control = useAgeAssuranceRedirectDialogControl()
|
||||||
|
|
||||||
// TODO for testing
|
// for testing
|
||||||
// Dialog.useAutoOpen(control.control, 3e3)
|
// Dialog.useAutoOpen(control.control, 3e3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +88,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
|||||||
const control = useAgeAssuranceRedirectDialogControl()
|
const control = useAgeAssuranceRedirectDialogControl()
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (polling.current) return
|
if (polling.current) return
|
||||||
@@ -106,9 +105,9 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
|||||||
if (!agent.session) return
|
if (!agent.session) return
|
||||||
if (unmounted.current) return
|
if (unmounted.current) return
|
||||||
|
|
||||||
const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
|
const data = await refetchAgeAssuranceServerState({agent})
|
||||||
|
|
||||||
if (data.status !== 'assured') {
|
if (data?.state.status !== 'assured') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Polling for age assurance state did not receive assured status`,
|
`Polling for age assurance state did not receive assured status`,
|
||||||
)
|
)
|
||||||
@@ -124,9 +123,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
|||||||
if (!agent.session) return
|
if (!agent.session) return
|
||||||
if (unmounted.current) return
|
if (unmounted.current) return
|
||||||
|
|
||||||
// success! update state
|
|
||||||
await refreshAgeAssuranceState()
|
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
|
||||||
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||||
@@ -134,15 +130,13 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (unmounted.current) return
|
if (unmounted.current) return
|
||||||
setError(true)
|
setError(true)
|
||||||
// try a refetch anyway
|
|
||||||
refreshAgeAssuranceState()
|
|
||||||
logger.metric('ageAssurance:redirectDialogFail', {})
|
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unmounted.current = true
|
unmounted.current = true
|
||||||
}
|
}
|
||||||
}, [agent, control, refreshAgeAssuranceState])
|
}, [agent, control])
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {View} from 'react-native'
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||||
@@ -13,6 +11,8 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
|
|||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
import {logger} from '#/ageAssurance'
|
||||||
|
|
||||||
export function AgeRestrictedScreen({
|
export function AgeRestrictedScreen({
|
||||||
children,
|
children,
|
||||||
@@ -27,22 +27,9 @@ export function AgeRestrictedScreen({
|
|||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const copy = useAgeAssuranceCopy()
|
const copy = useAgeAssuranceCopy()
|
||||||
const {isReady, isAgeRestricted} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
if (!isReady) {
|
if (aa.state.access === aa.Access.Full) return children
|
||||||
return (
|
|
||||||
<Layout.Screen>
|
|
||||||
<Layout.Header.Outer>
|
|
||||||
<Layout.Header.Content>
|
|
||||||
<Layout.Header.TitleText> </Layout.Header.TitleText>
|
|
||||||
</Layout.Header.Content>
|
|
||||||
<Layout.Header.Slot />
|
|
||||||
</Layout.Header.Outer>
|
|
||||||
<Layout.Content />
|
|
||||||
</Layout.Screen>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!isAgeRestricted) return children
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Screen>
|
<Layout.Screen>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function useAgeAssuranceCopy() {
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return {
|
return {
|
||||||
notice: _(
|
notice: _(
|
||||||
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
|
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
|
||||||
),
|
),
|
||||||
banner: _(
|
banner: _(
|
||||||
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
|
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {cleanError} from '#/lib/strings/errors'
|
|||||||
import {getAge, getDateAgo} from '#/lib/strings/time'
|
import {getAge, getDateAgo} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS, isWeb} from '#/platform/detection'
|
||||||
|
import {
|
||||||
|
useBirthdateMutation,
|
||||||
|
useIsBirthdateUpdateAllowed,
|
||||||
|
} from '#/state/birthdate'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
type UsePreferencesQueryResponse,
|
type UsePreferencesQueryResponse,
|
||||||
usePreferencesSetBirthDateMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
@@ -18,7 +21,7 @@ import {Admonition} from '#/components/Admonition'
|
|||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {DateField} from '#/components/forms/DateField'
|
import {DateField} from '#/components/forms/DateField'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {SimpleInlineLinkText} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -30,18 +33,21 @@ export function BirthDateSettingsDialog({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||||
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
|
{isBirthdateUpdateAllowed ? (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`My Birthday`)}
|
label={_(msg`My Birthdate`)}
|
||||||
style={web({maxWidth: 400})}>
|
style={web({maxWidth: 400})}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_md]}>
|
||||||
<Text style={[a.text_xl, a.font_semi_bold]}>
|
<Text style={[a.text_xl, a.font_semi_bold]}>
|
||||||
<Trans>My Birthday</Trans>
|
<Trans>My Birthdate</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text
|
||||||
|
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
This information is private and not shared with other users.
|
This information is private and not shared with other users.
|
||||||
</Trans>
|
</Trans>
|
||||||
@@ -66,6 +72,32 @@ export function BirthDateSettingsDialog({
|
|||||||
|
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
|
) : (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`You recently changed your birthdate`)}
|
||||||
|
style={web({maxWidth: 400})}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xl,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
{paddingRight: 32},
|
||||||
|
]}>
|
||||||
|
<Trans>You recently changed your birthdate</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
There is a limit to how often you can change your birthdate. You
|
||||||
|
may need to wait a day or two before updating it again.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)}
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,7 +118,7 @@ function BirthdayInner({
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
mutateAsync: setBirthDate,
|
mutateAsync: setBirthDate,
|
||||||
} = usePreferencesSetBirthDateMutation()
|
} = useBirthdateMutation()
|
||||||
const hasChanged = date !== preferences.birthDate
|
const hasChanged = date !== preferences.birthDate
|
||||||
|
|
||||||
const age = getAge(new Date(date))
|
const age = getAge(new Date(date))
|
||||||
@@ -112,7 +144,7 @@ function BirthdayInner({
|
|||||||
testID="birthdayInput"
|
testID="birthdayInput"
|
||||||
value={date}
|
value={date}
|
||||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||||
label={_(msg`Birthday`)}
|
label={_(msg`Birthdate`)}
|
||||||
accessibilityHint={_(msg`Enter your birthdate`)}
|
accessibilityHint={_(msg`Enter your birthdate`)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -130,11 +162,11 @@ function BirthdayInner({
|
|||||||
<Admonition type="error">
|
<Admonition type="error">
|
||||||
<Trans>
|
<Trans>
|
||||||
You must be at least 13 years old to use Bluesky. Read our{' '}
|
You must be at least 13 years old to use Bluesky. Read our{' '}
|
||||||
<InlineLinkText
|
<SimpleInlineLinkText
|
||||||
to="https://bsky.social/about/support/tos"
|
to="https://bsky.social/about/support/tos"
|
||||||
label={_(msg`Terms of Service`)}>
|
label={_(msg`Terms of Service`)}>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</InlineLinkText>{' '}
|
</SimpleInlineLinkText>{' '}
|
||||||
for more information.
|
for more information.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
@@ -146,7 +178,7 @@ function BirthdayInner({
|
|||||||
|
|
||||||
<View style={isWeb && [a.flex_row, a.justify_end]}>
|
<View style={isWeb && [a.flex_row, a.justify_end]}>
|
||||||
<Button
|
<Button
|
||||||
label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
|
label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)}
|
||||||
size="large"
|
size="large"
|
||||||
onPress={onSave}
|
onPress={onSave}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import {wait} from '#/lib/async/wait'
|
|||||||
import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError'
|
import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {
|
|
||||||
computeGeolocationStatus,
|
|
||||||
type GeolocationStatus,
|
|
||||||
useGeolocationConfig,
|
|
||||||
} from '#/state/geolocation'
|
|
||||||
import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation'
|
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -20,10 +14,11 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type Geolocation, useRequestDeviceGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
onLocationAcquired?: (props: {
|
onLocationAcquired?: (props: {
|
||||||
geolocationStatus: GeolocationStatus
|
geolocation: Geolocation
|
||||||
setDialogError: (error: string) => void
|
setDialogError: (error: string) => void
|
||||||
disableDialogAction: () => void
|
disableDialogAction: () => void
|
||||||
closeDialog: (callback?: () => void) => void
|
closeDialog: (callback?: () => void) => void
|
||||||
@@ -57,8 +52,7 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {close} = Dialog.useDialogContext()
|
const {close} = Dialog.useDialogContext()
|
||||||
const requestDeviceLocation = useRequestDeviceLocation()
|
const requestDeviceLocation = useRequestDeviceGeolocation()
|
||||||
const {config} = useGeolocationConfig()
|
|
||||||
const cleanError = useCleanError()
|
const cleanError = useCleanError()
|
||||||
|
|
||||||
const [isRequesting, setIsRequesting] = useState(false)
|
const [isRequesting, setIsRequesting] = useState(false)
|
||||||
@@ -76,9 +70,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
|
|||||||
const location = req.location
|
const location = req.location
|
||||||
|
|
||||||
if (location && location.countryCode) {
|
if (location && location.countryCode) {
|
||||||
const geolocationStatus = computeGeolocationStatus(location, config)
|
|
||||||
onLocationAcquired?.({
|
onLocationAcquired?.({
|
||||||
geolocationStatus,
|
geolocation: location,
|
||||||
setDialogError: setError,
|
setDialogError: setError,
|
||||||
disableDialogAction: () => setDialogDisabled(true),
|
disableDialogAction: () => setDialogDisabled(true),
|
||||||
closeDialog: close,
|
closeDialog: close,
|
||||||
|
|||||||
Vendored
+5
-8
@@ -100,14 +100,11 @@ export const GCP_PROJECT_ID: number =
|
|||||||
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
|
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL for the bapp-config web worker _development_ environment. Can be a
|
* URLs for the app config web worker. Can be a
|
||||||
* locally running server, see `env.example` for more.
|
* locally running server, see `env.example` for more.
|
||||||
*/
|
*/
|
||||||
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
|
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
|
||||||
|
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
|
||||||
/**
|
export const BAPP_CONFIG_URL = IS_DEV
|
||||||
* Dev environment passthrough value for bapp-config web worker. Allows local
|
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
|
||||||
* dev access to the web worker running in `development` mode.
|
: BAPP_CONFIG_PROD_URL
|
||||||
*/
|
|
||||||
export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
|
|
||||||
process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {BAPP_CONFIG_URL} from '#/env'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
|
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default geolocation config.
|
||||||
|
*/
|
||||||
|
export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
|
||||||
|
countryCode: undefined,
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as aaDebug from '#/ageAssurance/debug'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
|
const localEnabled = false
|
||||||
|
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
||||||
|
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
||||||
|
countryCode: 'AU',
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
|
export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? {
|
||||||
|
countryCode: 'AU',
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolve<T>(data: T) {
|
||||||
|
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {useCallback, useEffect, useRef} from 'react'
|
||||||
|
import * as Location from 'expo-location'
|
||||||
|
import {createPermissionHook} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import * as debug from '#/geolocation/debug'
|
||||||
|
import {logger} from '#/geolocation/logger'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
import {normalizeDeviceLocation} from '#/geolocation/util'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location.useForegroundPermissions on web just errors if the
|
||||||
|
* navigator.permissions API is not available. We need to catch and ignore it,
|
||||||
|
* since it's effectively denied.
|
||||||
|
*
|
||||||
|
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
||||||
|
*/
|
||||||
|
const useForegroundPermissions = createPermissionHook({
|
||||||
|
getMethod: () =>
|
||||||
|
Location.getForegroundPermissionsAsync().catch(error => {
|
||||||
|
logger.debug(
|
||||||
|
'useForegroundPermission: error getting location permissions',
|
||||||
|
{safeMessage: error},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
status: Location.PermissionStatus.DENIED,
|
||||||
|
granted: false,
|
||||||
|
canAskAgain: false,
|
||||||
|
expires: 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
requestMethod: () =>
|
||||||
|
Location.requestForegroundPermissionsAsync().catch(error => {
|
||||||
|
logger.debug(
|
||||||
|
'useForegroundPermission: error requesting location permissions',
|
||||||
|
{safeMessage: error},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
status: Location.PermissionStatus.DENIED,
|
||||||
|
granted: false,
|
||||||
|
canAskAgain: false,
|
||||||
|
expires: 0,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getDeviceGeolocation(): Promise<Geolocation> {
|
||||||
|
if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geocode = await Location.getCurrentPositionAsync()
|
||||||
|
const locations = await Location.reverseGeocodeAsync({
|
||||||
|
latitude: geocode.coords.latitude,
|
||||||
|
longitude: geocode.coords.longitude,
|
||||||
|
})
|
||||||
|
const location = locations.at(0)
|
||||||
|
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||||
|
return {
|
||||||
|
countryCode: normalized?.countryCode ?? undefined,
|
||||||
|
regionCode: normalized?.regionCode ?? undefined,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('getDeviceGeolocation: failed', {safeMessage: e})
|
||||||
|
return {
|
||||||
|
countryCode: undefined,
|
||||||
|
regionCode: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequestDeviceGeolocation(): () => Promise<
|
||||||
|
| {
|
||||||
|
granted: true
|
||||||
|
location: Geolocation | undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
granted: false
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return useCallback(async () => {
|
||||||
|
const status = await Location.requestForegroundPermissionsAsync()
|
||||||
|
if (status.granted) {
|
||||||
|
return {
|
||||||
|
granted: true,
|
||||||
|
location: await getDeviceGeolocation(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
granted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||||
|
* using device storage. If permissions are not granted, it will clear any cached
|
||||||
|
* storage value.
|
||||||
|
*/
|
||||||
|
export function useSyncDeviceGeolocationOnStartup(
|
||||||
|
sync: (location: Geolocation | undefined) => void,
|
||||||
|
) {
|
||||||
|
const synced = useRef(false)
|
||||||
|
const [status] = useForegroundPermissions()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNative) return
|
||||||
|
|
||||||
|
async function get() {
|
||||||
|
// no need to set this more than once per session
|
||||||
|
if (synced.current) return
|
||||||
|
logger.debug('useSyncDeviceGeolocationOnStartup: checking perms')
|
||||||
|
if (status?.granted) {
|
||||||
|
const location = await getDeviceGeolocation()
|
||||||
|
if (location) {
|
||||||
|
logger.debug('useSyncDeviceGeolocationOnStartup: got location')
|
||||||
|
sync(location)
|
||||||
|
synced.current = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
||||||
|
/**
|
||||||
|
* If we have a cached value, but user has revoked permissions,
|
||||||
|
* quietly (will take effect lazily) clear this out.
|
||||||
|
*/
|
||||||
|
if (hasCachedValue) {
|
||||||
|
logger.debug(
|
||||||
|
'useSyncDeviceGeolocationOnStartup: clearing cached location, perms revoked',
|
||||||
|
)
|
||||||
|
device.set(['deviceGeolocation'], undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get().catch(e => {
|
||||||
|
logger.error(
|
||||||
|
'useSyncDeviceGeolocationOnStartup: failed to get location',
|
||||||
|
{
|
||||||
|
safeMessage: e,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [status, sync])
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
|
||||||
|
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
import {mergeGeolocations} from '#/geolocation/util'
|
||||||
|
import {device, useStorage} from '#/storage'
|
||||||
|
|
||||||
|
export {useRequestDeviceGeolocation} from '#/geolocation/device'
|
||||||
|
export {resolve} from '#/geolocation/service'
|
||||||
|
export * from '#/geolocation/types'
|
||||||
|
|
||||||
|
const GeolocationContext = createContext<Geolocation>({
|
||||||
|
countryCode: undefined,
|
||||||
|
regionCode: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DeviceGeolocationAPIContext = createContext<{
|
||||||
|
setDeviceGeolocation(deviceGeolocation: Geolocation): void
|
||||||
|
}>({
|
||||||
|
setDeviceGeolocation: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useGeolocation() {
|
||||||
|
return useContext(GeolocationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeviceGeolocationApi() {
|
||||||
|
return useContext(DeviceGeolocationAPIContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: {children: ReactNode}) {
|
||||||
|
const geolocationService = useGeolocationServiceResponse()
|
||||||
|
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||||
|
'deviceGeolocation',
|
||||||
|
])
|
||||||
|
const geolocation = useMemo(() => {
|
||||||
|
return mergeGeolocations(deviceGeolocation, geolocationService)
|
||||||
|
}, [deviceGeolocation, geolocationService])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Save this for out-of-band-reads during future cold starts of the app.
|
||||||
|
* Needs to be available for the data prefetching we do on boot.
|
||||||
|
*/
|
||||||
|
device.set(['mergedGeolocation'], geolocation)
|
||||||
|
}, [geolocation])
|
||||||
|
|
||||||
|
useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GeolocationContext.Provider value={geolocation}>
|
||||||
|
<DeviceGeolocationAPIContext.Provider
|
||||||
|
value={useMemo(() => ({setDeviceGeolocation}), [setDeviceGeolocation])}>
|
||||||
|
{children}
|
||||||
|
</DeviceGeolocationAPIContext.Provider>
|
||||||
|
</GeolocationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
import {networkRetry} from '#/lib/async/retry'
|
||||||
|
import {
|
||||||
|
FALLBACK_GEOLOCATION_SERVICE_RESPONSE,
|
||||||
|
GEOLOCATION_SERVICE_URL,
|
||||||
|
} from '#/geolocation/const'
|
||||||
|
import * as debug from '#/geolocation/debug'
|
||||||
|
import {logger} from '#/geolocation/logger'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
|
||||||
|
const events = new EventEmitter()
|
||||||
|
const EVENT = 'geolocation-service-response-updated'
|
||||||
|
const emitGeolocationServiceResponseUpdate = (data: Geolocation) => {
|
||||||
|
events.emit(EVENT, data)
|
||||||
|
}
|
||||||
|
const onGeolocationServiceResponseUpdate = (
|
||||||
|
listener: (data: Geolocation) => void,
|
||||||
|
) => {
|
||||||
|
events.on(EVENT, listener)
|
||||||
|
return () => {
|
||||||
|
events.off(EVENT, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGeolocationServiceData(
|
||||||
|
url: string,
|
||||||
|
): Promise<Geolocation | undefined> {
|
||||||
|
if (debug.enabled) return debug.resolve(debug.geolocation)
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`fetchGeolocationServiceData failed ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<Geolocation>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local promise used within this file only.
|
||||||
|
*/
|
||||||
|
let geolocationServicePromise: Promise<{success: boolean}> | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin the process of resolving geolocation config. This is called right away
|
||||||
|
* at app start, and the promise is awaited later before proceeding with app
|
||||||
|
* startup.
|
||||||
|
*/
|
||||||
|
export async function resolve() {
|
||||||
|
if (geolocationServicePromise) {
|
||||||
|
const cached = device.get(['geolocationServiceResponse'])
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(`resolve(): using cache`)
|
||||||
|
} else {
|
||||||
|
logger.debug(`resolve(): no cache`)
|
||||||
|
const {success} = await geolocationServicePromise
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`resolve(): resolved`)
|
||||||
|
} else {
|
||||||
|
logger.info(`resolve(): failed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug(`resolve(): initiating`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THIS PROMISE SHOULD NEVER `reject()`! We want the app to proceed with
|
||||||
|
* startup, even if geolocation resolution fails.
|
||||||
|
*/
|
||||||
|
geolocationServicePromise = new Promise(async resolve => {
|
||||||
|
let success = false
|
||||||
|
|
||||||
|
function cacheResponseOrThrow(response: Geolocation | undefined) {
|
||||||
|
if (response) {
|
||||||
|
device.set(['geolocationServiceResponse'], response)
|
||||||
|
emitGeolocationServiceResponseUpdate(response)
|
||||||
|
} else {
|
||||||
|
// endpoint should throw on all failures, this is insurance
|
||||||
|
throw new Error(`fetchGeolocationServiceData returned no data`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try once, fail fast
|
||||||
|
const config = await fetchGeolocationServiceData(
|
||||||
|
GEOLOCATION_SERVICE_URL,
|
||||||
|
)
|
||||||
|
cacheResponseOrThrow(config)
|
||||||
|
success = true
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.debug(
|
||||||
|
`resolve(): fetchGeolocationServiceData failed initial request`,
|
||||||
|
{
|
||||||
|
safeMessage: e.message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// retry 3 times, but don't await, proceed with default
|
||||||
|
networkRetry(3, () =>
|
||||||
|
fetchGeolocationServiceData(GEOLOCATION_SERVICE_URL),
|
||||||
|
)
|
||||||
|
.then(config => {
|
||||||
|
cacheResponseOrThrow(config)
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
// complete fail closed
|
||||||
|
logger.debug(
|
||||||
|
`resolve(): fetchGeolocationServiceData failed retries`,
|
||||||
|
{
|
||||||
|
safeMessage: e.message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
resolve({success})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeolocationServiceResponse() {
|
||||||
|
const [config, setConfig] = useState(() => {
|
||||||
|
const initial =
|
||||||
|
device.get(['geolocationServiceResponse']) ||
|
||||||
|
FALLBACK_GEOLOCATION_SERVICE_RESPONSE
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onGeolocationServiceResponseUpdate(config => {
|
||||||
|
setConfig(config!)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type Geolocation = {
|
||||||
|
countryCode: string | undefined
|
||||||
|
regionCode: string | undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {type LocationGeocodedAddress} from 'expo-location'
|
||||||
|
|
||||||
|
import {logger} from '#/geolocation/logger'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps full US region names to their short codes.
|
||||||
|
*
|
||||||
|
* Context: in some cases, like on Android, we get the full region name instead
|
||||||
|
* of the short code. We may need to expand this in the future to other
|
||||||
|
* countries, hence the prefix.
|
||||||
|
*/
|
||||||
|
export const USRegionNameToRegionCode: {
|
||||||
|
[regionName: string]: string
|
||||||
|
} = {
|
||||||
|
Alabama: 'AL',
|
||||||
|
Alaska: 'AK',
|
||||||
|
Arizona: 'AZ',
|
||||||
|
Arkansas: 'AR',
|
||||||
|
California: 'CA',
|
||||||
|
Colorado: 'CO',
|
||||||
|
Connecticut: 'CT',
|
||||||
|
Delaware: 'DE',
|
||||||
|
Florida: 'FL',
|
||||||
|
Georgia: 'GA',
|
||||||
|
Hawaii: 'HI',
|
||||||
|
Idaho: 'ID',
|
||||||
|
Illinois: 'IL',
|
||||||
|
Indiana: 'IN',
|
||||||
|
Iowa: 'IA',
|
||||||
|
Kansas: 'KS',
|
||||||
|
Kentucky: 'KY',
|
||||||
|
Louisiana: 'LA',
|
||||||
|
Maine: 'ME',
|
||||||
|
Maryland: 'MD',
|
||||||
|
Massachusetts: 'MA',
|
||||||
|
Michigan: 'MI',
|
||||||
|
Minnesota: 'MN',
|
||||||
|
Mississippi: 'MS',
|
||||||
|
Missouri: 'MO',
|
||||||
|
Montana: 'MT',
|
||||||
|
Nebraska: 'NE',
|
||||||
|
Nevada: 'NV',
|
||||||
|
['New Hampshire']: 'NH',
|
||||||
|
['New Jersey']: 'NJ',
|
||||||
|
['New Mexico']: 'NM',
|
||||||
|
['New York']: 'NY',
|
||||||
|
['North Carolina']: 'NC',
|
||||||
|
['North Dakota']: 'ND',
|
||||||
|
Ohio: 'OH',
|
||||||
|
Oklahoma: 'OK',
|
||||||
|
Oregon: 'OR',
|
||||||
|
Pennsylvania: 'PA',
|
||||||
|
['Rhode Island']: 'RI',
|
||||||
|
['South Carolina']: 'SC',
|
||||||
|
['South Dakota']: 'SD',
|
||||||
|
Tennessee: 'TN',
|
||||||
|
Texas: 'TX',
|
||||||
|
Utah: 'UT',
|
||||||
|
Vermont: 'VT',
|
||||||
|
Virginia: 'VA',
|
||||||
|
Washington: 'WA',
|
||||||
|
['West Virginia']: 'WV',
|
||||||
|
Wisconsin: 'WI',
|
||||||
|
Wyoming: 'WY',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a `LocationGeocodedAddress` into a `Geolocation`.
|
||||||
|
*
|
||||||
|
* We don't want or care about the full location data, so we trim it down and
|
||||||
|
* normalize certain fields, like region, into the format we need.
|
||||||
|
*/
|
||||||
|
export function normalizeDeviceLocation(
|
||||||
|
location: LocationGeocodedAddress,
|
||||||
|
): Geolocation {
|
||||||
|
let {isoCountryCode, region} = location
|
||||||
|
|
||||||
|
if (region) {
|
||||||
|
if (isoCountryCode === 'US') {
|
||||||
|
region = USRegionNameToRegionCode[region] ?? region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
countryCode: isoCountryCode ?? undefined,
|
||||||
|
regionCode: region ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines precise location data with the geolocation config fetched from the
|
||||||
|
* IP service, with preference to the precise data.
|
||||||
|
*/
|
||||||
|
export function mergeGeolocations(
|
||||||
|
device?: Geolocation,
|
||||||
|
geolocationService?: Geolocation,
|
||||||
|
): Geolocation {
|
||||||
|
let geolocation: Geolocation = {
|
||||||
|
countryCode: geolocationService?.countryCode ?? undefined,
|
||||||
|
regionCode: geolocationService?.regionCode ?? undefined,
|
||||||
|
}
|
||||||
|
// prefer GPS
|
||||||
|
if (device?.countryCode) {
|
||||||
|
geolocation = device
|
||||||
|
}
|
||||||
|
logger.debug('merged geolocation data', {
|
||||||
|
device,
|
||||||
|
service: geolocationService,
|
||||||
|
merged: geolocation,
|
||||||
|
})
|
||||||
|
return geolocation
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {describe, expect, it} from '@jest/globals'
|
||||||
|
|
||||||
|
import {parseLinkingUrl} from '../parseLinkingUrl'
|
||||||
|
|
||||||
|
describe('parseLinkingUrl', () => {
|
||||||
|
it('should correctly parse bluesky:// URLs', () => {
|
||||||
|
const url =
|
||||||
|
'bluesky://intent/age-assurance?result=success&actorDid=did:example:123'
|
||||||
|
const urlp = parseLinkingUrl(url)
|
||||||
|
expect(urlp.protocol).toBe('bluesky:')
|
||||||
|
expect(urlp.host).toBe('')
|
||||||
|
expect(urlp.pathname).toBe('/intent/age-assurance')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly parse standard URLs', () => {
|
||||||
|
const url =
|
||||||
|
'https://bsky.app/intent/age-assurance?result=success&actorDid=did:example:123'
|
||||||
|
const urlp = parseLinkingUrl(url)
|
||||||
|
expect(urlp.protocol).toBe('https:')
|
||||||
|
expect(urlp.host).toBe('bsky.app')
|
||||||
|
expect(urlp.pathname).toBe('/intent/age-assurance')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -163,6 +163,7 @@ export async function resolveLink(
|
|||||||
const res = await agent.resolveHandle({
|
const res = await agent.resolveHandle({
|
||||||
handle: urip.host,
|
handle: urip.host,
|
||||||
})
|
})
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data.did
|
urip.host = res.data.did
|
||||||
}
|
}
|
||||||
const res = await agent.getPosts({
|
const res = await agent.getPosts({
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
|
|||||||
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
|
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
|
||||||
|
|
||||||
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
|
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
|
||||||
|
export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always the same
|
||||||
|
|
||||||
// temp hack for e2e - esb
|
// temp hack for e2e - esb
|
||||||
export const BLUESKY_PROXY_HEADER = {
|
export const BLUESKY_PROXY_HEADER = {
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import {deviceLocales} from '#/locale/deviceLocales'
|
import {deviceLocales} from '#/locale/deviceLocales'
|
||||||
import {useGeolocationStatus} from '#/state/geolocation'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
import {useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* From react-native-localize
|
* From react-native-localize
|
||||||
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record<string, string> = {
|
|||||||
export function useFormatCurrency(
|
export function useFormatCurrency(
|
||||||
options?: Parameters<typeof Intl.NumberFormat>[1],
|
options?: Parameters<typeof Intl.NumberFormat>[1],
|
||||||
) {
|
) {
|
||||||
const {location: geolocation} = useGeolocationStatus()
|
const geolocation = useGeolocation()
|
||||||
const {appLanguage} = useLanguagePrefs()
|
const {appLanguage} = useLanguagePrefs()
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
const locale = deviceLocales.at(0)
|
const locale = deviceLocales.at(0)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function useAccountSwitcher() {
|
|||||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||||
history.pushState(null, '', '/')
|
history.pushState(null, '', '/')
|
||||||
}
|
}
|
||||||
await resumeSession(account)
|
await resumeSession(account, true)
|
||||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const ZENDESK_SUPPORT_URL =
|
|||||||
|
|
||||||
export enum SupportCode {
|
export enum SupportCode {
|
||||||
AA_DID = 'AA_DID',
|
AA_DID = 'AA_DID',
|
||||||
|
AA_BIRTHDATE = 'AA_BIRTHDATE',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ import * as Linking from 'expo-linking'
|
|||||||
import * as WebBrowser from 'expo-web-browser'
|
import * as WebBrowser from 'expo-web-browser'
|
||||||
|
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
|
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isNative} from '#/platform/detection'
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {
|
|
||||||
parseAgeAssuranceRedirectDialogState,
|
|
||||||
useAgeAssuranceRedirectDialogControl,
|
|
||||||
} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
|
||||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
|
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
|
||||||
@@ -27,8 +24,6 @@ export function useIntentHandler() {
|
|||||||
const incomingUrl = Linking.useLinkingURL()
|
const incomingUrl = Linking.useLinkingURL()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
const verifyEmailIntent = useVerifyEmailIntent()
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
const ageAssuranceRedirectDialogControl =
|
|
||||||
useAgeAssuranceRedirectDialogControl()
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
||||||
|
|
||||||
@@ -47,17 +42,8 @@ export function useIntentHandler() {
|
|||||||
hostname: referrerInfo?.hostname,
|
hostname: referrerInfo?.hostname,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const urlp = parseLinkingUrl(url)
|
||||||
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
|
const [, intent, intentType] = urlp.pathname.split('/')
|
||||||
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
|
|
||||||
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
|
|
||||||
// path parameter is in pathname rather than in hostname.
|
|
||||||
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
|
||||||
url = url.replace('bluesky://', 'bluesky:///')
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlp = new URL(url)
|
|
||||||
const [__, intent, intentType] = urlp.pathname.split('/')
|
|
||||||
|
|
||||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||||
@@ -82,23 +68,7 @@ export function useIntentHandler() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'age-assurance': {
|
case 'age-assurance': {
|
||||||
const state = parseAgeAssuranceRedirectDialogState({
|
// Handled in `#/ageAssurance/components/RedirectOverlay.tsx`
|
||||||
result: params.get('result') ?? undefined,
|
|
||||||
actorDid: params.get('actorDid') ?? undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If we don't have an account or the account doesn't match, do
|
|
||||||
* nothing. By the time the user switches to their other account, AA
|
|
||||||
* state should be ready for them.
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
state &&
|
|
||||||
currentAccount &&
|
|
||||||
state.actorDid === currentAccount.did
|
|
||||||
) {
|
|
||||||
ageAssuranceRedirectDialogControl.open(state)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'apply-ota': {
|
case 'apply-ota': {
|
||||||
@@ -127,7 +97,6 @@ export function useIntentHandler() {
|
|||||||
incomingUrl,
|
incomingUrl,
|
||||||
composeIntent,
|
composeIntent,
|
||||||
verifyEmailIntent,
|
verifyEmailIntent,
|
||||||
ageAssuranceRedirectDialogControl,
|
|
||||||
currentAccount,
|
currentAccount,
|
||||||
tryApplyUpdate,
|
tryApplyUpdate,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
|
|||||||
import {logger as notyLogger} from '#/lib/notifications/util'
|
import {logger as notyLogger} from '#/lib/notifications/util'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
|
||||||
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
||||||
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {IS_DEV} from '#/env'
|
import {IS_DEV} from '#/env'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +125,7 @@ async function getPushToken() {
|
|||||||
* @see https://github.com/bluesky-social/social-app/pull/4467
|
* @see https://github.com/bluesky-social/social-app/pull/4467
|
||||||
*/
|
*/
|
||||||
export function useGetAndRegisterPushToken() {
|
export function useGetAndRegisterPushToken() {
|
||||||
const {isAgeRestricted} = useAgeAssuranceContext()
|
const aa = useAgeAssurance()
|
||||||
const registerPushToken = useRegisterPushToken()
|
const registerPushToken = useRegisterPushToken()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async ({
|
async ({
|
||||||
@@ -152,13 +152,14 @@ export function useGetAndRegisterPushToken() {
|
|||||||
*/
|
*/
|
||||||
registerPushToken({
|
registerPushToken({
|
||||||
token,
|
token,
|
||||||
isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted,
|
isAgeRestricted:
|
||||||
|
isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
[registerPushToken, isAgeRestricted],
|
[registerPushToken, aa],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +174,14 @@ export function useNotificationsRegistration() {
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const registerPushToken = useRegisterPushToken()
|
const registerPushToken = useRegisterPushToken()
|
||||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||||
const {isReady: isAgeRestrictionReady, isAgeRestricted} =
|
const aa = useAgeAssurance()
|
||||||
useAgeAssuranceContext()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/**
|
/**
|
||||||
* We want this to init right away _after_ we have a logged in user, and
|
* We want this to init right away _after_ we have a logged in user, and
|
||||||
* _after_ we've loaded their age assurance state.
|
* _after_ we've loaded their age assurance state.
|
||||||
*/
|
*/
|
||||||
if (!currentAccount || !isAgeRestrictionReady) return
|
if (!currentAccount) return
|
||||||
|
|
||||||
notyLogger.debug(`useNotificationsRegistration`)
|
notyLogger.debug(`useNotificationsRegistration`)
|
||||||
|
|
||||||
@@ -206,20 +206,17 @@ export function useNotificationsRegistration() {
|
|||||||
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
|
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
|
||||||
*/
|
*/
|
||||||
const subscription = Notifications.addPushTokenListener(async token => {
|
const subscription = Notifications.addPushTokenListener(async token => {
|
||||||
registerPushToken({token, isAgeRestricted: isAgeRestricted})
|
registerPushToken({
|
||||||
|
token,
|
||||||
|
isAgeRestricted: aa.state.access !== aa.Access.Full,
|
||||||
|
})
|
||||||
notyLogger.debug(`addPushTokenListener callback`, {token})
|
notyLogger.debug(`addPushTokenListener callback`, {token})
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove()
|
subscription.remove()
|
||||||
}
|
}
|
||||||
}, [
|
}, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
|
||||||
currentAccount,
|
|
||||||
getAndRegisterPushToken,
|
|
||||||
registerPushToken,
|
|
||||||
isAgeRestrictionReady,
|
|
||||||
isAgeRestricted,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRequestNotificationsPermission() {
|
export function useRequestNotificationsPermission() {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function parseLinkingUrl(url: string): URL {
|
||||||
|
/*
|
||||||
|
* Hack: add a third slash to bluesky:// urls so that `URL.host` is empty and
|
||||||
|
* `URL.pathname` has the full path.
|
||||||
|
*/
|
||||||
|
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
||||||
|
url = url.replace('bluesky://', 'bluesky:///')
|
||||||
|
}
|
||||||
|
return new URL(url)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export type Gate =
|
|||||||
| 'onboarding_add_video_feed'
|
| 'onboarding_add_video_feed'
|
||||||
| 'onboarding_suggested_starterpacks'
|
| 'onboarding_suggested_starterpacks'
|
||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
|
| 'show_composer_prompt'
|
||||||
| 'suggested_users_dismiss'
|
| 'suggested_users_dismiss'
|
||||||
| 'test_gate_1'
|
| 'test_gate_1'
|
||||||
| 'test_gate_2'
|
| 'test_gate_2'
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function makeRecordUri(
|
|||||||
collection: string,
|
collection: string,
|
||||||
rkey: string,
|
rkey: string,
|
||||||
) {
|
) {
|
||||||
const urip = new AtUri('at://host/')
|
const urip = new AtUri('at://placeholder.placeholder/')
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = didOrName
|
urip.host = didOrName
|
||||||
urip.collection = collection
|
urip.collection = collection
|
||||||
urip.rkey = rkey
|
urip.rkey = rkey
|
||||||
|
|||||||
+188
-161
@@ -544,10 +544,6 @@ msgstr ""
|
|||||||
msgid "A new form of verification"
|
msgid "A new form of verification"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:50
|
|
||||||
msgid "A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.
|
#. Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.
|
||||||
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:120
|
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:120
|
||||||
msgid "A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads \"inventing a saturday that immediately follows monday\"."
|
msgid "A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads \"inventing a saturday that immediately follows monday\"."
|
||||||
@@ -823,6 +819,10 @@ msgstr ""
|
|||||||
msgid "Add user to list"
|
msgid "Add user to list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:182
|
||||||
|
msgid "Add your birthdate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:109
|
#: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:109
|
||||||
#: src/view/com/modals/UserAddRemoveLists.tsx:162
|
#: src/view/com/modals/UserAddRemoveLists.tsx:162
|
||||||
msgid "Added to list"
|
msgid "Added to list"
|
||||||
@@ -855,7 +855,7 @@ msgstr ""
|
|||||||
msgid "Adult Content"
|
msgid "Adult Content"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:418
|
#: src/screens/Moderation/index.tsx:394
|
||||||
msgid "Adult content can only be enabled via the Web at <0>bsky.app</0>."
|
msgid "Adult content can only be enabled via the Web at <0>bsky.app</0>."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -872,7 +872,7 @@ msgstr ""
|
|||||||
msgid "Adult sexual abuse content"
|
msgid "Adult sexual abuse content"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:468
|
#: src/screens/Moderation/index.tsx:439
|
||||||
msgid "Advanced"
|
msgid "Advanced"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -889,7 +889,8 @@ msgctxt "toast"
|
|||||||
msgid "Age assurance inquiry was submitted"
|
msgid "Age assurance inquiry was submitted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:193
|
#: src/ageAssurance/components/NoAccessScreen.tsx:302
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:191
|
||||||
msgid "Age assurance only takes a few minutes"
|
msgid "Age assurance only takes a few minutes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -928,8 +929,8 @@ msgstr ""
|
|||||||
msgid "Allow anyone to reply"
|
msgid "Allow anyone to reply"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:146
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:139
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:152
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:145
|
||||||
msgid "Allow location access"
|
msgid "Allow location access"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1127,7 +1128,6 @@ msgstr ""
|
|||||||
msgid "Animated GIF"
|
msgid "Animated GIF"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:104
|
|
||||||
#: src/components/PolicyUpdateOverlay/Badge.tsx:33
|
#: src/components/PolicyUpdateOverlay/Badge.tsx:33
|
||||||
msgid "Announcement"
|
msgid "Announcement"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1303,10 +1303,6 @@ msgstr ""
|
|||||||
msgid "Artistic or non-erotic nudity."
|
msgid "Artistic or non-erotic nudity."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:53
|
|
||||||
msgid "As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:529
|
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:529
|
||||||
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:531
|
#: src/components/PostControls/PostMenu/PostMenuItems.tsx:531
|
||||||
msgid "Assign topic for algo"
|
msgid "Assign topic for algo"
|
||||||
@@ -1394,11 +1390,11 @@ msgstr ""
|
|||||||
msgid "Before you can message another user, you must first verify your email."
|
msgid "Before you can message another user, you must first verify your email."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:318
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:317
|
||||||
msgid "Begin"
|
msgid "Begin"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:312
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:311
|
||||||
msgid "Begin age assurance process"
|
msgid "Begin age assurance process"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1406,7 +1402,10 @@ msgstr ""
|
|||||||
msgid "Begin the age assurance process by completing the fields below."
|
msgid "Begin the age assurance process by completing the fields below."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:115
|
#: src/components/dialogs/BirthDateSettings.tsx:147
|
||||||
|
msgid "Birthdate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Settings/AccountSettings.tsx:142
|
#: src/screens/Settings/AccountSettings.tsx:142
|
||||||
msgid "Birthday"
|
msgid "Birthday"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1471,7 +1470,7 @@ msgstr ""
|
|||||||
msgid "Blocked"
|
msgid "Blocked"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:306
|
#: src/screens/Moderation/index.tsx:311
|
||||||
msgid "Blocked accounts"
|
msgid "Blocked accounts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1643,7 +1642,7 @@ msgstr ""
|
|||||||
msgid "By clicking \"Continue\" you acknowledge that you understand and agree to these updates."
|
msgid "By clicking \"Continue\" you acknowledge that you understand and agree to these updates."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:329
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:328
|
||||||
msgid "By continuing, you agree to the <0>KWS Terms of Use</0> and acknowledge that KWS will store your verified status with your hashed email address in accordance with the <1>KWS Privacy Policy</1>. This means you won’t need to verify again the next time you use this email for other apps, games, and services powered by KWS technology."
|
msgid "By continuing, you agree to the <0>KWS Terms of Use</0> and acknowledge that KWS will store your verified status with your hashed email address in accordance with the <1>KWS Privacy Policy</1>. This means you won’t need to verify again the next time you use this email for other apps, games, and services powered by KWS technology."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1670,8 +1669,8 @@ msgstr ""
|
|||||||
#: src/components/activity-notifications/SubscribeProfileDialog.tsx:206
|
#: src/components/activity-notifications/SubscribeProfileDialog.tsx:206
|
||||||
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:128
|
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:128
|
||||||
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:134
|
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:134
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:159
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:152
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:164
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:157
|
||||||
#: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:125
|
#: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:125
|
||||||
#: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:131
|
#: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:131
|
||||||
#: src/components/dialogs/InAppBrowserConsent.tsx:98
|
#: src/components/dialogs/InAppBrowserConsent.tsx:98
|
||||||
@@ -1956,10 +1955,23 @@ msgstr ""
|
|||||||
msgid "click here"
|
msgid "click here"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:102
|
||||||
|
msgid "Click here to contact our support team"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:194
|
||||||
|
msgid "Click here to log out"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/EmailDialog/screens/Verify.tsx:383
|
#: src/components/dialogs/EmailDialog/screens/Verify.tsx:383
|
||||||
msgid "Click here to restart the verification process."
|
msgid "Click here to restart the verification process."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:86
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:179
|
||||||
|
msgid "Click here to update your birthdate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/EmailDialog/screens/Verify.tsx:275
|
#: src/components/dialogs/EmailDialog/screens/Verify.tsx:275
|
||||||
msgid "Click here to update your email"
|
msgid "Click here to update your email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1980,10 +1992,14 @@ msgstr ""
|
|||||||
msgid "Clip 🐴 clop 🐴"
|
msgid "Clip 🐴 clop 🐴"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/RedirectOverlay.tsx:264
|
||||||
|
#: src/ageAssurance/components/RedirectOverlay.tsx:270
|
||||||
|
#: src/ageAssurance/components/RedirectOverlay.tsx:320
|
||||||
|
#: src/ageAssurance/components/RedirectOverlay.tsx:326
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:172
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:178
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:178
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:184
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:231
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:237
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:237
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:243
|
|
||||||
#: src/components/dialogs/GifSelect.tsx:269
|
#: src/components/dialogs/GifSelect.tsx:269
|
||||||
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:158
|
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:158
|
||||||
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167
|
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167
|
||||||
@@ -2031,8 +2047,8 @@ msgstr ""
|
|||||||
msgid "Close bottom drawer"
|
msgid "Close bottom drawer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:224
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:223
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:230
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:229
|
||||||
#: src/components/dialogs/GifSelect.tsx:263
|
#: src/components/dialogs/GifSelect.tsx:263
|
||||||
#: src/components/verification/VerificationsDialog.tsx:136
|
#: src/components/verification/VerificationsDialog.tsx:136
|
||||||
#: src/components/verification/VerifierDialog.tsx:142
|
#: src/components/verification/VerifierDialog.tsx:142
|
||||||
@@ -2042,7 +2058,7 @@ msgstr ""
|
|||||||
msgid "Close dialog"
|
msgid "Close dialog"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/shell/index.web.tsx:107
|
#: src/view/shell/index.web.tsx:108
|
||||||
msgid "Close drawer menu"
|
msgid "Close drawer menu"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2175,26 +2191,13 @@ msgstr ""
|
|||||||
msgid "Confirm delete account"
|
msgid "Confirm delete account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:356
|
#: src/ageAssurance/components/NoAccessScreen.tsx:316
|
||||||
msgid "Confirm your age:"
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:87
|
||||||
msgstr ""
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:40
|
||||||
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:105
|
||||||
#: src/screens/Moderation/index.tsx:347
|
|
||||||
msgid "Confirm your birthdate"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:89
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:140
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:146
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:45
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:112
|
|
||||||
msgid "Confirm your location"
|
msgid "Confirm your location"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:134
|
|
||||||
msgid "Confirm your location with GPS. Your location data is not tracked and does not leave your device."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/dialogs/EmailDialog/components/TokenField.tsx:36
|
#: src/components/dialogs/EmailDialog/components/TokenField.tsx:36
|
||||||
#: src/screens/Login/LoginForm.tsx:284
|
#: src/screens/Login/LoginForm.tsx:284
|
||||||
#: src/screens/Settings/components/ChangePasswordDialog.tsx:186
|
#: src/screens/Settings/components/ChangePasswordDialog.tsx:186
|
||||||
@@ -2210,16 +2213,18 @@ msgstr ""
|
|||||||
msgid "Connecting..."
|
msgid "Connecting..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:213
|
#: src/ageAssurance/components/RedirectOverlay.tsx:296
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:207
|
||||||
msgid "Connection issue"
|
msgid "Connection issue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:132
|
#: src/ageAssurance/components/NoAccessScreen.tsx:253
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:130
|
||||||
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:29
|
#: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:29
|
||||||
msgid "Contact our moderation team"
|
msgid "Contact our moderation team"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:155
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:156
|
||||||
#: src/screens/Signup/index.tsx:222
|
#: src/screens/Signup/index.tsx:222
|
||||||
#: src/screens/Signup/index.tsx:225
|
#: src/screens/Signup/index.tsx:225
|
||||||
msgid "Contact support"
|
msgid "Contact support"
|
||||||
@@ -2246,7 +2251,7 @@ msgstr ""
|
|||||||
msgid "Content Blocked"
|
msgid "Content Blocked"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:340
|
#: src/screens/Moderation/index.tsx:344
|
||||||
msgid "Content filters"
|
msgid "Content filters"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2851,7 +2856,7 @@ msgstr ""
|
|||||||
#: src/lib/moderation/useLabelBehaviorDescription.ts:71
|
#: src/lib/moderation/useLabelBehaviorDescription.ts:71
|
||||||
#: src/screens/Messages/Settings.tsx:144
|
#: src/screens/Messages/Settings.tsx:144
|
||||||
#: src/screens/Messages/Settings.tsx:147
|
#: src/screens/Messages/Settings.tsx:147
|
||||||
#: src/screens/Moderation/index.tsx:408
|
#: src/screens/Moderation/index.tsx:384
|
||||||
msgid "Disabled"
|
msgid "Disabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2949,8 +2954,8 @@ msgstr ""
|
|||||||
msgid "Don't see an email? <0>Click here to resend.</0>"
|
msgid "Don't see an email? <0>Click here to resend.</0>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx:113
|
#: src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx:103
|
||||||
#: src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx:36
|
#: src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx:33
|
||||||
msgid "Don't show again"
|
msgid "Don't show again"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2958,8 +2963,8 @@ msgstr ""
|
|||||||
msgid "Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult."
|
msgid "Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:149
|
#: src/components/dialogs/BirthDateSettings.tsx:181
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:156
|
#: src/components/dialogs/BirthDateSettings.tsx:188
|
||||||
#: src/components/dialogs/ServerInput.tsx:240
|
#: src/components/dialogs/ServerInput.tsx:240
|
||||||
#: src/components/dialogs/ServerInput.tsx:242
|
#: src/components/dialogs/ServerInput.tsx:242
|
||||||
#: src/components/dms/AfterReportDialog.tsx:142
|
#: src/components/dms/AfterReportDialog.tsx:142
|
||||||
@@ -3019,6 +3024,10 @@ msgstr ""
|
|||||||
msgid "Drop to add images"
|
msgid "Drop to add images"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/ageAssurance/useAgeAssuranceCopy.ts:11
|
||||||
|
msgid "Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/MutedWords.tsx:158
|
#: src/components/dialogs/MutedWords.tsx:158
|
||||||
msgid "Duration:"
|
msgid "Duration:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -3230,7 +3239,7 @@ msgstr ""
|
|||||||
msgid "Enable {0} only"
|
msgid "Enable {0} only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:395
|
#: src/screens/Moderation/index.tsx:371
|
||||||
msgid "Enable adult content"
|
msgid "Enable adult content"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -3281,7 +3290,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: src/screens/Messages/Settings.tsx:135
|
#: src/screens/Messages/Settings.tsx:135
|
||||||
#: src/screens/Messages/Settings.tsx:138
|
#: src/screens/Messages/Settings.tsx:138
|
||||||
#: src/screens/Moderation/index.tsx:406
|
#: src/screens/Moderation/index.tsx:382
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -3324,7 +3333,7 @@ msgstr ""
|
|||||||
msgid "Enter the username or email address you used when you created your account"
|
msgid "Enter the username or email address you used when you created your account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:116
|
#: src/components/dialogs/BirthDateSettings.tsx:148
|
||||||
msgid "Enter your birthdate"
|
msgid "Enter your birthdate"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -3641,7 +3650,7 @@ msgstr ""
|
|||||||
msgid "Failed to remove verification"
|
msgid "Failed to remove verification"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:87
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:80
|
||||||
msgid "Failed to resolve location. Please try again."
|
msgid "Failed to resolve location. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4022,10 +4031,6 @@ msgstr ""
|
|||||||
msgid "Food"
|
msgid "Food"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:56
|
|
||||||
msgid "For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/view/com/modals/DeleteAccount.tsx:125
|
#: src/view/com/modals/DeleteAccount.tsx:125
|
||||||
msgid "For security reasons, we'll need to send a confirmation code to your email address."
|
msgid "For security reasons, we'll need to send a confirmation code to your email address."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -4236,10 +4241,10 @@ msgstr ""
|
|||||||
msgid "Go to {firstAuthorName}'s profile"
|
msgid "Go to {firstAuthorName}'s profile"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:89
|
#: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:87
|
||||||
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:77
|
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:64
|
||||||
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:86
|
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:73
|
||||||
#: src/screens/Moderation/index.tsx:214
|
#: src/screens/Moderation/index.tsx:219
|
||||||
msgid "Go to account settings"
|
msgid "Go to account settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4546,6 +4551,14 @@ msgstr ""
|
|||||||
msgid "If you are not yet an adult according to the laws of your country, your parent or legal guardian must read these Terms on your behalf."
|
msgid "If you are not yet an adult according to the laws of your country, your parent or legal guardian must read these Terms on your behalf."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:98
|
||||||
|
msgid "If you believe your birthdate is incorrect, please <0>contact our support team</0>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:83
|
||||||
|
msgid "If you believe your birthdate is incorrect, you can update it by <0>clicking here</0>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/ProfileList/components/MoreOptionsMenu.tsx:277
|
#: src/screens/ProfileList/components/MoreOptionsMenu.tsx:277
|
||||||
msgid "If you delete this list, you won't be able to recover it."
|
msgid "If you delete this list, you won't be able to recover it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -4670,7 +4683,7 @@ msgstr ""
|
|||||||
msgid "Interaction limited"
|
msgid "Interaction limited"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:246
|
#: src/screens/Moderation/index.tsx:251
|
||||||
msgid "Interaction settings"
|
msgid "Interaction settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4724,10 +4737,15 @@ msgstr ""
|
|||||||
msgid "Invites, but personal"
|
msgid "Invites, but personal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:86
|
#: src/ageAssurance/components/NoAccessScreen.tsx:313
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:84
|
||||||
msgid "Is your location not accurate? <0>Tap here to confirm your location.</0>"
|
msgid "Is your location not accurate? <0>Tap here to confirm your location.</0>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:171
|
||||||
|
msgid "It looks like you haven't added your birthdate. You must provide an accurate date of birth to use Bluesky."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Signup/StepInfo/index.tsx:292
|
#: src/screens/Signup/StepInfo/index.tsx:292
|
||||||
msgid "It's correct"
|
msgid "It's correct"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -4770,15 +4788,15 @@ msgstr ""
|
|||||||
msgid "Keep me posted"
|
msgid "Keep me posted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:344
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:339
|
||||||
msgid "KWS Privacy Policy"
|
msgid "KWS Privacy Policy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:334
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:331
|
||||||
msgid "KWS Terms of Use"
|
msgid "KWS Terms of Use"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:201
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:200
|
||||||
msgid "KWS website"
|
msgid "KWS website"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4825,11 +4843,13 @@ msgstr ""
|
|||||||
msgid "Larger"
|
msgid "Larger"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:187
|
#: src/ageAssurance/components/NoAccessScreen.tsx:296
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:185
|
||||||
msgid "Last initiated {timeAgo} ago"
|
msgid "Last initiated {timeAgo} ago"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:185
|
#: src/ageAssurance/components/NoAccessScreen.tsx:294
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:183
|
||||||
msgid "Last initiated just now"
|
msgid "Last initiated just now"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4853,7 +4873,7 @@ msgstr ""
|
|||||||
msgid "Learn More"
|
msgid "Learn More"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx:74
|
#: src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx:64
|
||||||
msgid "Learn more about age assurance"
|
msgid "Learn more about age assurance"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -4894,7 +4914,7 @@ msgstr ""
|
|||||||
msgid "Learn more about what is public on Bluesky."
|
msgid "Learn more about what is public on Bluesky."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:86
|
#: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:84
|
||||||
msgid "Learn more in your <0>account settings.</0>"
|
msgid "Learn more in your <0>account settings.</0>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5241,7 +5261,7 @@ msgstr ""
|
|||||||
msgid "Manage saved feeds"
|
msgid "Manage saved feeds"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:316
|
#: src/screens/Moderation/index.tsx:321
|
||||||
msgid "Manage verification settings"
|
msgid "Manage verification settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5353,7 +5373,7 @@ msgid "Misleading"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/Navigation.tsx:177
|
#: src/Navigation.tsx:177
|
||||||
#: src/screens/Moderation/index.tsx:100
|
#: src/screens/Moderation/index.tsx:99
|
||||||
#: src/screens/Settings/Settings.tsx:188
|
#: src/screens/Settings/Settings.tsx:188
|
||||||
#: src/screens/Settings/Settings.tsx:191
|
#: src/screens/Settings/Settings.tsx:191
|
||||||
msgid "Moderation"
|
msgid "Moderation"
|
||||||
@@ -5387,7 +5407,7 @@ msgctxt "toast"
|
|||||||
msgid "Moderation list updated"
|
msgid "Moderation list updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:276
|
#: src/screens/Moderation/index.tsx:281
|
||||||
msgid "Moderation lists"
|
msgid "Moderation lists"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5404,7 +5424,7 @@ msgstr ""
|
|||||||
msgid "Moderation states"
|
msgid "Moderation states"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:230
|
#: src/screens/Moderation/index.tsx:235
|
||||||
msgid "Moderation tools"
|
msgid "Moderation tools"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5519,7 +5539,7 @@ msgstr ""
|
|||||||
msgid "Mute words & tags"
|
msgid "Mute words & tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:291
|
#: src/screens/Moderation/index.tsx:296
|
||||||
msgid "Muted accounts"
|
msgid "Muted accounts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5536,7 +5556,7 @@ msgstr ""
|
|||||||
msgid "Muted by \"{0}\""
|
msgid "Muted by \"{0}\""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:261
|
#: src/screens/Moderation/index.tsx:266
|
||||||
msgid "Muted words & tags"
|
msgid "Muted words & tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -5544,9 +5564,9 @@ msgstr ""
|
|||||||
msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them."
|
msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:38
|
#: src/components/dialogs/BirthDateSettings.tsx:43
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:42
|
#: src/components/dialogs/BirthDateSettings.tsx:47
|
||||||
msgid "My Birthday"
|
msgid "My Birthdate"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/view/screens/Feeds.tsx:704
|
#: src/view/screens/Feeds.tsx:704
|
||||||
@@ -5908,10 +5928,6 @@ msgstr ""
|
|||||||
msgid "Not Found"
|
msgid "Not Found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:125
|
|
||||||
msgid "Not in Mississippi?"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/view/com/profile/ProfileMenu.tsx:502
|
#: src/view/com/profile/ProfileMenu.tsx:502
|
||||||
msgid "Note about sharing"
|
msgid "Note about sharing"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -6128,7 +6144,7 @@ msgstr ""
|
|||||||
msgid "Open moderation debug page"
|
msgid "Open moderation debug page"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:257
|
#: src/screens/Moderation/index.tsx:262
|
||||||
msgid "Open muted words and tags settings"
|
msgid "Open muted words and tags settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -6494,7 +6510,7 @@ msgstr ""
|
|||||||
msgid "Please add any content warning labels that are applicable for the media you are posting."
|
msgid "Please add any content warning labels that are applicable for the media you are posting."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:188
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:189
|
||||||
msgid "Please check your email inbox for further instructions. It may take a minute or two to arrive."
|
msgid "Please check your email inbox for further instructions. It may take a minute or two to arrive."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -6515,7 +6531,7 @@ msgstr ""
|
|||||||
msgid "Please complete the verification captcha."
|
msgid "Please complete the verification captcha."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:101
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:102
|
||||||
#: src/screens/Signup/StepInfo/index.tsx:111
|
#: src/screens/Signup/StepInfo/index.tsx:111
|
||||||
msgid "Please double-check that you have entered your email address correctly."
|
msgid "Please double-check that you have entered your email address correctly."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -6538,7 +6554,7 @@ msgstr ""
|
|||||||
msgid "Please enter a valid code."
|
msgid "Please enter a valid code."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:110
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:111
|
||||||
#: src/components/dialogs/EmailDialog/screens/Update.tsx:148
|
#: src/components/dialogs/EmailDialog/screens/Update.tsx:148
|
||||||
msgid "Please enter a valid email address."
|
msgid "Please enter a valid email address."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -6547,7 +6563,7 @@ msgstr ""
|
|||||||
msgid "Please enter a valid word, tag, or phrase to mute"
|
msgid "Please enter a valid word, tag, or phrase to mute"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:144
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:145
|
||||||
msgid "Please enter a valid, non-temporary email address. You may need to access this email in the future."
|
msgid "Please enter a valid, non-temporary email address. You may need to access this email in the future."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -6605,7 +6621,7 @@ msgstr ""
|
|||||||
msgid "Please provide any additional details you feel moderators may need in order to properly assess your Age Assurance status."
|
msgid "Please provide any additional details you feel moderators may need in order to properly assess your Age Assurance status."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:303
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:302
|
||||||
msgid "Please select a language"
|
msgid "Please select a language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -6626,7 +6642,7 @@ msgstr ""
|
|||||||
msgid "Please verify your email"
|
msgid "Please verify your email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/lib/hooks/useCreateSupportLink.ts:28
|
#: src/lib/hooks/useCreateSupportLink.ts:29
|
||||||
msgid "Please write your message below:"
|
msgid "Please write your message below:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -6994,7 +7010,6 @@ msgstr ""
|
|||||||
msgid "Read more replies"
|
msgid "Read more replies"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:40
|
|
||||||
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112
|
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112
|
||||||
msgid "Read our blog post"
|
msgid "Read our blog post"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -7570,7 +7585,7 @@ msgstr ""
|
|||||||
msgid "Returns to the previous step"
|
msgid "Returns to the previous step"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:156
|
#: src/components/dialogs/BirthDateSettings.tsx:188
|
||||||
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:292
|
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:292
|
||||||
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:307
|
#: src/components/dialogs/lists/CreateOrEditListDialog.tsx:307
|
||||||
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:662
|
#: src/components/dialogs/PostInteractionSettingsDialog.tsx:662
|
||||||
@@ -7596,8 +7611,8 @@ msgctxt "action"
|
|||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:149
|
#: src/components/dialogs/BirthDateSettings.tsx:181
|
||||||
msgid "Save birthday"
|
msgid "Save birthdate"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/activity-notifications/SubscribeProfileDialog.tsx:191
|
#: src/components/activity-notifications/SubscribeProfileDialog.tsx:191
|
||||||
@@ -8038,10 +8053,6 @@ msgstr ""
|
|||||||
msgid "Set app icon to {0}"
|
msgid "Set app icon to {0}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:359
|
|
||||||
msgid "Set birthdate"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/screens/Login/SetNewPasswordForm.tsx:106
|
#: src/screens/Login/SetNewPasswordForm.tsx:106
|
||||||
msgid "Set new password"
|
msgid "Set new password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8468,7 +8479,7 @@ msgstr ""
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:138
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139
|
||||||
#: src/components/moderation/ReportDialog/index.tsx:266
|
#: src/components/moderation/ReportDialog/index.tsx:266
|
||||||
#: src/screens/Deactivated.tsx:94
|
#: src/screens/Deactivated.tsx:94
|
||||||
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:59
|
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:59
|
||||||
@@ -8476,7 +8487,7 @@ msgstr ""
|
|||||||
msgid "Something went wrong, please try again"
|
msgid "Something went wrong, please try again"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:112
|
#: src/screens/Moderation/index.tsx:111
|
||||||
#: src/screens/Profile/Sections/Labels.tsx:183
|
#: src/screens/Profile/Sections/Labels.tsx:183
|
||||||
msgid "Something went wrong, please try again."
|
msgid "Something went wrong, please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8501,7 +8512,7 @@ msgstr ""
|
|||||||
msgid "Sorry, we're unable to load account suggestions at this time."
|
msgid "Sorry, we're unable to load account suggestions at this time."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/App.native.tsx:128
|
#: src/App.native.tsx:127
|
||||||
#: src/App.web.tsx:100
|
#: src/App.web.tsx:100
|
||||||
msgid "Sorry! Your session expired. Please sign in again."
|
msgid "Sorry! Your session expired. Please sign in again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8660,11 +8671,12 @@ msgstr ""
|
|||||||
msgid "Subscribe to this list"
|
msgid "Subscribe to this list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:164
|
#: src/ageAssurance/components/RedirectOverlay.tsx:251
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:158
|
||||||
msgid "Success"
|
msgid "Success"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:182
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:183
|
||||||
#: src/components/dialogs/EmailDialog/screens/Update.tsx:286
|
#: src/components/dialogs/EmailDialog/screens/Update.tsx:286
|
||||||
msgid "Success!"
|
msgid "Success!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8744,7 +8756,7 @@ msgstr ""
|
|||||||
msgid "Tags only"
|
msgid "Tags only"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:116
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:109
|
||||||
msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region."
|
msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -8808,7 +8820,7 @@ msgstr ""
|
|||||||
msgid "Terms"
|
msgid "Terms"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:135
|
#: src/components/dialogs/BirthDateSettings.tsx:167
|
||||||
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:30
|
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:30
|
||||||
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:97
|
#: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:97
|
||||||
#: src/Navigation.tsx:336
|
#: src/Navigation.tsx:336
|
||||||
@@ -8837,8 +8849,8 @@ msgstr ""
|
|||||||
msgid "Thanks, you have successfully verified your email address. You can close this dialog."
|
msgid "Thanks, you have successfully verified your email address. You can close this dialog."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:115
|
#: src/ageAssurance/components/NoAccessScreen.tsx:342
|
||||||
#: src/components/BlockedGeoOverlay.tsx:168
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:113
|
||||||
msgid "Thanks! You're all set."
|
msgid "Thanks! You're all set."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -8883,11 +8895,11 @@ msgstr ""
|
|||||||
msgid "The author of this thread has hidden this reply."
|
msgid "The author of this thread has hidden this reply."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:122
|
#: src/components/dialogs/BirthDateSettings.tsx:154
|
||||||
msgid "The birthdate you've entered means you are under 18 years old. Certain content and features may be unavailable to you."
|
msgid "The birthdate you've entered means you are under 18 years old. Certain content and features may be unavailable to you."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:421
|
#: src/screens/Moderation/index.tsx:397
|
||||||
msgid "The Bluesky web application"
|
msgid "The Bluesky web application"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -8927,10 +8939,6 @@ msgstr ""
|
|||||||
msgid "The following settings will be used as your defaults when creating new posts. You can edit these for a specific post from the composer."
|
msgid "The following settings will be used as your defaults when creating new posts. You can edit these for a specific post from the composer."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/useAgeAssuranceCopy.ts:11
|
|
||||||
msgid "The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/ageAssurance/useAgeAssuranceCopy.ts:14
|
#: src/components/ageAssurance/useAgeAssuranceCopy.ts:14
|
||||||
msgid "The laws in your location require you to verify you're an adult to access certain features. Tap to learn more."
|
msgid "The laws in your location require you to verify you're an adult to access certain features. Tap to learn more."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -8977,6 +8985,10 @@ msgstr ""
|
|||||||
msgid "Theme"
|
msgid "Theme"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/dialogs/BirthDateSettings.tsx:91
|
||||||
|
msgid "There is a limit to how often you can change your birthdate. You may need to wait a day or two before updating it again."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86
|
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86
|
||||||
msgid "There is no time limit for account deactivation, come back any time."
|
msgid "There is no time limit for account deactivation, come back any time."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -9189,7 +9201,7 @@ msgstr ""
|
|||||||
msgid "This handle is reserved. Please try a different one."
|
msgid "This handle is reserved. Please try a different one."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:45
|
#: src/components/dialogs/BirthDateSettings.tsx:51
|
||||||
msgid "This information is private and not shared with other users."
|
msgid "This information is private and not shared with other users."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9274,7 +9286,7 @@ msgstr ""
|
|||||||
msgid "This should create a domain record at:"
|
msgid "This should create a domain record at:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:215
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:214
|
||||||
msgid "This should only take a few minutes."
|
msgid "This should only take a few minutes."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9367,6 +9379,10 @@ msgstr ""
|
|||||||
msgid "To disable your email 2FA method, please verify your access to <0>{0}</0>"
|
msgid "To disable your email 2FA method, please verify your access to <0>{0}</0>"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:191
|
||||||
|
msgid "To log out, <0>click here</0>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dms/ReportConversationPrompt.tsx:19
|
#: src/components/dms/ReportConversationPrompt.tsx:19
|
||||||
msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue."
|
msgid "To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -9375,7 +9391,7 @@ msgstr ""
|
|||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:398
|
#: src/screens/Moderation/index.tsx:374
|
||||||
msgid "Toggle to enable or disable adult content"
|
msgid "Toggle to enable or disable adult content"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9456,7 +9472,7 @@ msgstr ""
|
|||||||
msgid "Type:"
|
msgid "Type:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:92
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:85
|
||||||
msgid "Unable to access location. You'll need to visit your system settings to enable location services for Bluesky."
|
msgid "Unable to access location. You'll need to visit your system settings to enable location services for Bluesky."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9490,7 +9506,7 @@ msgstr ""
|
|||||||
msgid "Unapply Pull Request {currentChannel}"
|
msgid "Unapply Pull Request {currentChannel}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:53
|
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:40
|
||||||
msgid "Unavailable"
|
msgid "Unavailable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9562,14 +9578,14 @@ msgstr ""
|
|||||||
msgid "Unfollows the user"
|
msgid "Unfollows the user"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:48
|
|
||||||
msgid "Unfortunately, Bluesky is unavailable in Mississippi right now."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/moderation/ReportDialog/index.tsx:471
|
#: src/components/moderation/ReportDialog/index.tsx:471
|
||||||
msgid "Unfortunately, none of your subscribed labelers supports this report type."
|
msgid "Unfortunately, none of your subscribed labelers supports this report type."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:158
|
||||||
|
msgid "Unfortunately, the birthdate you have saved to your profile makes you too young to access Bluesky."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/verification/VerificationsDialog.tsx:211
|
#: src/components/verification/VerificationsDialog.tsx:211
|
||||||
msgid "Unknown verifier"
|
msgid "Unknown verifier"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -9810,7 +9826,7 @@ msgstr ""
|
|||||||
msgid "Use this to sign in to the other app along with your handle."
|
msgid "Use this to sign in to the other app along with your handle."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:279
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:278
|
||||||
msgid "Use your account email address, or another real email address you control, in case KWS or Bluesky needs to contact you."
|
msgid "Use your account email address, or another real email address you control, in case KWS or Bluesky needs to contact you."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9899,7 +9915,7 @@ msgstr ""
|
|||||||
msgid "Verification failed, please try again."
|
msgid "Verification failed, please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:321
|
#: src/screens/Moderation/index.tsx:326
|
||||||
msgid "Verification settings"
|
msgid "Verification settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9923,7 +9939,8 @@ msgstr ""
|
|||||||
msgid "Verify account"
|
msgid "Verify account"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:170
|
#: src/ageAssurance/components/NoAccessScreen.tsx:279
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:168
|
||||||
msgid "Verify again"
|
msgid "Verify again"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9949,8 +9966,10 @@ msgstr ""
|
|||||||
msgid "Verify email dialog"
|
msgid "Verify email dialog"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:158
|
#: src/ageAssurance/components/NoAccessScreen.tsx:267
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:172
|
#: src/ageAssurance/components/NoAccessScreen.tsx:281
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:156
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:170
|
||||||
msgid "Verify now"
|
msgid "Verify now"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9963,7 +9982,7 @@ msgstr ""
|
|||||||
msgid "Verify this account?"
|
msgid "Verify this account?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:182
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:183
|
||||||
msgid "Verify your age"
|
msgid "Verify your age"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -9973,10 +9992,12 @@ msgstr ""
|
|||||||
msgid "Verify your email"
|
msgid "Verify your email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:213
|
#: src/ageAssurance/components/RedirectOverlay.tsx:296
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:207
|
||||||
msgid "Verifying"
|
msgid "Verifying"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/RedirectOverlay.tsx:165
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:74
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:74
|
||||||
msgid "Verifying your age assurance status"
|
msgid "Verifying your age assurance status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -10131,11 +10152,11 @@ msgstr ""
|
|||||||
msgid "View video"
|
msgid "View video"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:301
|
#: src/screens/Moderation/index.tsx:306
|
||||||
msgid "View your blocked accounts"
|
msgid "View your blocked accounts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:241
|
#: src/screens/Moderation/index.tsx:246
|
||||||
msgid "View your default post interaction settings"
|
msgid "View your default post interaction settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10144,11 +10165,11 @@ msgstr ""
|
|||||||
msgid "View your feeds and explore more"
|
msgid "View your feeds and explore more"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:271
|
#: src/screens/Moderation/index.tsx:276
|
||||||
msgid "View your moderation lists"
|
msgid "View your moderation lists"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:286
|
#: src/screens/Moderation/index.tsx:291
|
||||||
msgid "View your muted accounts"
|
msgid "View your muted accounts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10229,7 +10250,7 @@ msgstr ""
|
|||||||
msgid "We estimate {estimatedTime} until your account is ready."
|
msgid "We estimate {estimatedTime} until your account is ready."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:196
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:197
|
||||||
msgid "We have partnered with <0>KWS</0> to verify that you’re an adult. When you click \"Begin\" below, KWS will check if you have previously verified your age using this email address for other games/services powered by KWS technology. If not, KWS will email you instructions for verifying your age. When you’re done, you'll be brought back to continue using Bluesky."
|
msgid "We have partnered with <0>KWS</0> to verify that you’re an adult. When you click \"Begin\" below, KWS will check if you have previously verified your age using this email address for other games/services powered by KWS technology. If not, KWS will email you instructions for verifying your age. When you’re done, you'll be brought back to continue using Bluesky."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10257,15 +10278,16 @@ msgstr ""
|
|||||||
msgid "We were unable to determine if you are allowed to upload videos. Please try again."
|
msgid "We were unable to determine if you are allowed to upload videos. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:57
|
#: src/components/dialogs/BirthDateSettings.tsx:63
|
||||||
msgid "We were unable to load your birthdate preferences. Please try again."
|
msgid "We were unable to load your birthdate preferences. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:478
|
#: src/screens/Moderation/index.tsx:449
|
||||||
msgid "We were unable to load your configured labelers at this time."
|
msgid "We were unable to load your configured labelers at this time."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:221
|
#: src/ageAssurance/components/RedirectOverlay.tsx:304
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:215
|
||||||
msgid "We were unable to receive the verification due to a connection issue. It may arrive later. If it does, your account will update automatically."
|
msgid "We were unable to receive the verification due to a connection issue. It may arrive later. If it does, your account will update automatically."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10289,11 +10311,12 @@ msgstr ""
|
|||||||
msgid "We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 15th, 2025."
|
msgid "We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 15th, 2025."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:227
|
#: src/ageAssurance/components/RedirectOverlay.tsx:310
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:221
|
||||||
msgid "We're confirming your age assurance status with our servers. This should only take a few seconds."
|
msgid "We're confirming your age assurance status with our servers. This should only take a few seconds."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:150
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:151
|
||||||
msgid "We're having issues initializing the age assurance process for your account. Please <0>contact support</0> for assistance."
|
msgid "We're having issues initializing the age assurance process for your account. Please <0>contact support</0> for assistance."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10310,14 +10333,11 @@ msgstr ""
|
|||||||
msgid "We're so excited to have you join us!"
|
msgid "We're so excited to have you join us!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:105
|
#: src/ageAssurance/components/NoAccessScreen.tsx:335
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:106
|
||||||
msgid "We're sorry, but based on your device's location, you are currently located in a region that requires age assurance."
|
msgid "We're sorry, but based on your device's location, you are currently located in a region that requires age assurance."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/BlockedGeoOverlay.tsx:158
|
|
||||||
msgid "We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/screens/ProfileList/index.tsx:87
|
#: src/screens/ProfileList/index.tsx:87
|
||||||
msgid "We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @{handleOrDid}."
|
msgid "We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @{handleOrDid}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -10360,7 +10380,8 @@ msgstr ""
|
|||||||
msgid "We’re updating our Terms of Service, Privacy Policy, and Copyright Policy, effective September 15th, 2025. We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 15th, 2025. Learn more about these changes and how to share your thoughts with us by reading our blog post."
|
msgid "We’re updating our Terms of Service, Privacy Policy, and Copyright Policy, effective September 15th, 2025. We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 15th, 2025. Learn more about these changes and how to share your thoughts with us by reading our blog post."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:169
|
#: src/ageAssurance/components/RedirectOverlay.tsx:256
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:163
|
||||||
msgid "We've confirmed your age assurance status. You can now close this dialog."
|
msgid "We've confirmed your age assurance status. You can now close this dialog."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10520,11 +10541,16 @@ msgstr ""
|
|||||||
msgid "You are a trusted verifier"
|
msgid "You are a trusted verifier"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/ageAssurance/components/NoAccessScreen.tsx:143
|
||||||
|
msgid "You are accessing Bluesky from a region that legally requires us to verify your age before allowing you to access the app."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/forms/HostingProvider.tsx:45
|
#: src/components/forms/HostingProvider.tsx:45
|
||||||
msgid "You are creating an account on"
|
msgid "You are creating an account on"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:128
|
#: src/ageAssurance/components/NoAccessScreen.tsx:249
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:126
|
||||||
msgid "You are currently unable to access Bluesky's Age Assurance flow. Please <0>contact our moderation team</0> if you believe this is an error."
|
msgid "You are currently unable to access Bluesky's Age Assurance flow. Please <0>contact our moderation team</0> if you believe this is an error."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10733,7 +10759,7 @@ msgstr ""
|
|||||||
msgid "You hid this reply."
|
msgid "You hid this reply."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:241
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:240
|
||||||
msgid "You initiated this flow already, {0} ago. It may take up to 5 minutes for emails to reach your inbox. Please consider waiting a few minutes before trying again."
|
msgid "You initiated this flow already, {0} ago. It may take up to 5 minutes for emails to reach your inbox. Please consider waiting a few minutes before trying again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10765,7 +10791,7 @@ msgstr ""
|
|||||||
msgid "You must be 13 years of age or older to create an account."
|
msgid "You must be 13 years of age or older to create an account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/BirthDateSettings.tsx:131
|
#: src/components/dialogs/BirthDateSettings.tsx:163
|
||||||
msgid "You must be at least 13 years old to use Bluesky. Read our <0>Terms of Service</0> for more information."
|
msgid "You must be at least 13 years old to use Bluesky. Read our <0>Terms of Service</0> for more information."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10773,11 +10799,7 @@ msgstr ""
|
|||||||
msgid "You must be following at least seven other people to generate a starter pack."
|
msgid "You must be following at least seven other people to generate a starter pack."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:368
|
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:53
|
||||||
msgid "You must complete age assurance in order to access the settings below."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeRestrictedScreen.tsx:66
|
|
||||||
msgid "You must complete age assurance in order to access this screen."
|
msgid "You must complete age assurance in order to access this screen."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -10814,6 +10836,11 @@ msgstr ""
|
|||||||
msgid "You reacted {0} to {1}"
|
msgid "You reacted {0} to {1}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/components/dialogs/BirthDateSettings.tsx:77
|
||||||
|
#: src/components/dialogs/BirthDateSettings.tsx:87
|
||||||
|
msgid "You recently changed your birthdate"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Settings/Settings.tsx:286
|
#: src/screens/Settings/Settings.tsx:286
|
||||||
#: src/view/shell/desktop/LeftNav.tsx:210
|
#: src/view/shell/desktop/LeftNav.tsx:210
|
||||||
msgid "You will be signed out of all your accounts."
|
msgid "You will be signed out of all your accounts."
|
||||||
@@ -10973,13 +11000,13 @@ msgstr ""
|
|||||||
msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account."
|
msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/screens/Moderation/index.tsx:208
|
#: src/screens/Moderation/index.tsx:213
|
||||||
msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your birthdate in your <0>account settings</0>."
|
msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your birthdate in your <0>account settings</0>."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:253
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:252
|
||||||
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:256
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:257
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:257
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:258
|
|
||||||
msgid "Your email"
|
msgid "Your email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -11027,7 +11054,7 @@ msgstr ""
|
|||||||
msgid "Your interests help us find what you like!"
|
msgid "Your interests help us find what you like!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:130
|
#: src/components/dialogs/DeviceLocationRequestDialog.tsx:123
|
||||||
msgid "Your location data is not tracked and does not leave your device."
|
msgid "Your location data is not tracked and does not leave your device."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -11055,7 +11082,7 @@ msgstr ""
|
|||||||
msgid "Your posts, likes, and blocks are public. Mutes are private."
|
msgid "Your posts, likes, and blocks are public. Mutes are private."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:290
|
#: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:289
|
||||||
msgid "Your preferred language"
|
msgid "Your preferred language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type MetricEvents = {
|
|||||||
| 'SignupQueued'
|
| 'SignupQueued'
|
||||||
| 'Deactivated'
|
| 'Deactivated'
|
||||||
| 'Takendown'
|
| 'Takendown'
|
||||||
|
| 'AgeAssuranceNoAccessScreen'
|
||||||
scope: 'current' | 'every'
|
scope: 'current' | 'every'
|
||||||
}
|
}
|
||||||
'notifications:openApp': {
|
'notifications:openApp': {
|
||||||
@@ -202,6 +203,10 @@ export type MetricEvents = {
|
|||||||
|
|
||||||
'composer:gif:open': {}
|
'composer:gif:open': {}
|
||||||
'composer:gif:select': {}
|
'composer:gif:select': {}
|
||||||
|
'postComposer:click': {}
|
||||||
|
'composerPrompt:press': {}
|
||||||
|
'composerPrompt:camera:press': {}
|
||||||
|
'composerPrompt:gallery:press': {}
|
||||||
|
|
||||||
'composer:threadgate:open': {
|
'composer:threadgate:open': {
|
||||||
nudged: boolean
|
nudged: boolean
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const ChooseAccountForm = ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setPendingDid(account.did)
|
setPendingDid(account.did)
|
||||||
await resumeSession(account)
|
await resumeSession(account, true)
|
||||||
logEvent('account:loggedIn', {
|
logEvent('account:loggedIn', {
|
||||||
logContext: 'ChooseAccountForm',
|
logContext: 'ChooseAccountForm',
|
||||||
withPassword: false,
|
withPassword: false,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||||
import {
|
import {
|
||||||
useMyLabelersQuery,
|
useMyLabelersQuery,
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
@@ -24,9 +24,8 @@ import {useSetMinimalShellMode} from '#/state/shell'
|
|||||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import {Button} from '#/components/Button'
|
||||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
@@ -45,6 +44,7 @@ import {ListMaybePlaceholder} from '#/components/Lists'
|
|||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
|
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
|
||||||
function ErrorState({error}: {error: string}) {
|
function ErrorState({error}: {error: string}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -86,9 +86,8 @@ export function ModerationScreen(
|
|||||||
error: preferencesError,
|
error: preferencesError,
|
||||||
data: preferences,
|
data: preferences,
|
||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isReady: isAgeInfoReady} = useAgeAssurance()
|
|
||||||
|
|
||||||
const isLoading = isPreferencesLoading || !isAgeInfoReady
|
const isLoading = isPreferencesLoading
|
||||||
const error = preferencesError
|
const error = preferencesError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,13 +161,14 @@ export function ModerationScreenInner({
|
|||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
const birthdateDialogControl = Dialog.useDialogControl()
|
|
||||||
const {
|
const {
|
||||||
isLoading: isLabelersLoading,
|
isLoading: isLabelersLoading,
|
||||||
data: labelers,
|
data: labelers,
|
||||||
error: labelersError,
|
error: labelersError,
|
||||||
} = useMyLabelersQuery()
|
} = useMyLabelersQuery()
|
||||||
const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
|
const aaCopy = useAgeAssuranceCopy()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -178,10 +178,17 @@ export function ModerationScreenInner({
|
|||||||
|
|
||||||
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||||
usePreferencesSetAdultContentMutation()
|
usePreferencesSetAdultContentMutation()
|
||||||
const adultContentEnabled = !!(
|
let adultContentEnabled = !!(
|
||||||
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
||||||
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
||||||
)
|
)
|
||||||
|
const adultContentUIDisabledOnIOS = isIOS && !adultContentEnabled
|
||||||
|
let adultContentUIDisabled = adultContentUIDisabledOnIOS
|
||||||
|
|
||||||
|
if (aa.flags.adultContentDisabled) {
|
||||||
|
adultContentEnabled = false
|
||||||
|
adultContentUIDisabled = true
|
||||||
|
}
|
||||||
|
|
||||||
const onToggleAdultContentEnabled = useCallback(
|
const onToggleAdultContentEnabled = useCallback(
|
||||||
async (selected: boolean) => {
|
async (selected: boolean) => {
|
||||||
@@ -198,11 +205,9 @@ export function ModerationScreenInner({
|
|||||||
[setAdultContentPref],
|
[setAdultContentPref],
|
||||||
)
|
)
|
||||||
|
|
||||||
const disabledOnIOS = isIOS && !adultContentEnabled
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
|
<View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
|
||||||
{isDeclaredUnderage && (
|
{aa.flags.adultContentDisabled && isBirthdateUpdateAllowed && (
|
||||||
<View style={[a.pb_2xl]}>
|
<View style={[a.pb_2xl]}>
|
||||||
<Admonition type="tip" style={[a.pb_md]}>
|
<Admonition type="tip" style={[a.pb_md]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -328,7 +333,6 @@ export function ModerationScreenInner({
|
|||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(!isDeclaredUnderage || declaredAge === undefined) && (
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.pt_2xl,
|
a.pt_2xl,
|
||||||
@@ -339,36 +343,9 @@ export function ModerationScreenInner({
|
|||||||
]}>
|
]}>
|
||||||
<Trans>Content filters</Trans>
|
<Trans>Content filters</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
|
|
||||||
{declaredAge === undefined ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Confirm your birthdate`)}
|
|
||||||
size="small"
|
|
||||||
variant="solid"
|
|
||||||
color="secondary"
|
|
||||||
onPress={() => {
|
|
||||||
birthdateDialogControl.open()
|
|
||||||
}}
|
|
||||||
style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Confirm your age:</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Set birthdate</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<BirthDateSettingsDialog control={birthdateDialogControl} />
|
|
||||||
</>
|
|
||||||
) : !isDeclaredUnderage ? (
|
|
||||||
<>
|
|
||||||
<AgeAssuranceAdmonition style={[a.pb_md]}>
|
<AgeAssuranceAdmonition style={[a.pb_md]}>
|
||||||
<Trans>
|
{aaCopy.notice}
|
||||||
You must complete age assurance in order to access the settings
|
|
||||||
below.
|
|
||||||
</Trans>
|
|
||||||
</AgeAssuranceAdmonition>
|
</AgeAssuranceAdmonition>
|
||||||
|
|
||||||
<View style={[a.gap_md]}>
|
<View style={[a.gap_md]}>
|
||||||
@@ -379,7 +356,7 @@ export function ModerationScreenInner({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}>
|
]}>
|
||||||
{!isDeclaredUnderage && (
|
{aa.state.access === aa.Access.Full && (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -388,15 +365,14 @@ export function ModerationScreenInner({
|
|||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_between,
|
a.justify_between,
|
||||||
disabledOnIOS && {opacity: 0.5},
|
adultContentUIDisabled && {opacity: 0.5},
|
||||||
]}>
|
]}>
|
||||||
<Text
|
<Text style={[a.font_semi_bold, t.atoms.text_contrast_high]}>
|
||||||
style={[a.font_semi_bold, t.atoms.text_contrast_high]}>
|
|
||||||
<Trans>Enable adult content</Trans>
|
<Trans>Enable adult content</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Toggle to enable or disable adult content`)}
|
label={_(msg`Toggle to enable or disable adult content`)}
|
||||||
disabled={disabledOnIOS || isAgeRestricted}
|
disabled={adultContentUIDisabled}
|
||||||
name="adultContent"
|
name="adultContent"
|
||||||
value={adultContentEnabled}
|
value={adultContentEnabled}
|
||||||
onChange={onToggleAdultContentEnabled}>
|
onChange={onToggleAdultContentEnabled}>
|
||||||
@@ -412,7 +388,7 @@ export function ModerationScreenInner({
|
|||||||
</View>
|
</View>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
</View>
|
</View>
|
||||||
{disabledOnIOS && (
|
{adultContentUIDisabledOnIOS && (
|
||||||
<View style={[a.pb_lg, a.px_lg]}>
|
<View style={[a.pb_lg, a.px_lg]}>
|
||||||
<Text>
|
<Text>
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -444,18 +420,13 @@ export function ModerationScreenInner({
|
|||||||
labelDefinition={LABELS['graphic-media']}
|
labelDefinition={LABELS['graphic-media']}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<GlobalLabelPreference
|
<GlobalLabelPreference labelDefinition={LABELS.nudity} />
|
||||||
disabled={isDeclaredUnderage || isAgeRestricted}
|
|
||||||
labelDefinition={LABELS.nudity}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -312,7 +312,6 @@ function SuggestedProfileCard({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.py_lg,
|
a.py_lg,
|
||||||
a.px_xl,
|
a.px_xl,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type ViewabilityConfig,
|
type ViewabilityConfig,
|
||||||
type ViewToken,
|
type ViewToken,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {SystemBars} from 'react-native-edge-to-edge'
|
|
||||||
import {
|
import {
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureDetector,
|
GestureDetector,
|
||||||
@@ -153,7 +152,6 @@ export function VideoFeed({}: NativeStackScreenProps<
|
|||||||
<ThemeProvider theme="dark">
|
<ThemeProvider theme="dark">
|
||||||
<Layout.Screen noInsetTop style={{backgroundColor: 'black'}}>
|
<Layout.Screen noInsetTop style={{backgroundColor: 'black'}}>
|
||||||
<KeepAwake />
|
<KeepAwake />
|
||||||
<SystemBars style={{statusBar: 'light', navigationBar: 'light'}} />
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.absolute,
|
a.absolute,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const snoozeBirthdateUpdateAllowedForDid = () => {}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import {type ModerationPrefs} from '@atproto/api'
|
|
||||||
|
|
||||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
|
||||||
|
|
||||||
export const makeAgeRestrictedModerationPrefs = (
|
|
||||||
prefs: ModerationPrefs,
|
|
||||||
): ModerationPrefs => ({
|
|
||||||
...prefs,
|
|
||||||
adultContentEnabled: false,
|
|
||||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import {createContext, useContext, useMemo, useState} from 'react'
|
|
||||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
|
||||||
import {useQuery} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {networkRetry} from '#/lib/async/retry'
|
|
||||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
|
||||||
import {
|
|
||||||
type AgeAssuranceAPIContextType,
|
|
||||||
type AgeAssuranceContextType,
|
|
||||||
} from '#/state/ageAssurance/types'
|
|
||||||
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {useGeolocationStatus} from '#/state/geolocation'
|
|
||||||
import {useAgent} from '#/state/session'
|
|
||||||
|
|
||||||
export const createAgeAssuranceQueryKey = (did: string) =>
|
|
||||||
['ageAssurance', did] as const
|
|
||||||
|
|
||||||
const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
|
|
||||||
lastInitiatedAt: undefined,
|
|
||||||
status: 'unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
const AgeAssuranceContext = createContext<AgeAssuranceContextType>({
|
|
||||||
status: 'unknown',
|
|
||||||
isReady: false,
|
|
||||||
lastInitiatedAt: undefined,
|
|
||||||
isAgeRestricted: false,
|
|
||||||
})
|
|
||||||
AgeAssuranceContext.displayName = 'AgeAssuranceContext'
|
|
||||||
|
|
||||||
const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
|
|
||||||
// @ts-ignore can't be bothered to type this
|
|
||||||
refetch: () => Promise.resolve(),
|
|
||||||
})
|
|
||||||
AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Low-level provider for fetching age assurance state on app load. Do not add
|
|
||||||
* any other data fetching in here to avoid complications and reduced
|
|
||||||
* performance.
|
|
||||||
*/
|
|
||||||
export function Provider({children}: {children: React.ReactNode}) {
|
|
||||||
const agent = useAgent()
|
|
||||||
const {status: geolocation} = useGeolocationStatus()
|
|
||||||
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
|
||||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
|
||||||
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
|
||||||
|
|
||||||
const {data, isFetched, refetch} = useQuery({
|
|
||||||
/**
|
|
||||||
* This is load bearing. We always want this query to run and end in a
|
|
||||||
* "fetched" state, even if we fall back to defaults. This lets the rest of
|
|
||||||
* the app know that we've at least attempted to load the AA state.
|
|
||||||
*
|
|
||||||
* However, it only needs to run if AA is enabled.
|
|
||||||
*/
|
|
||||||
enabled: isAgeAssuranceEnabled,
|
|
||||||
refetchOnWindowFocus: refetchWhilePending,
|
|
||||||
queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
|
|
||||||
async queryFn() {
|
|
||||||
if (!agent.session) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {data} = await networkRetry(3, () =>
|
|
||||||
agent.app.bsky.unspecced.getAgeAssuranceState(),
|
|
||||||
)
|
|
||||||
// const {data} = {
|
|
||||||
// data: {
|
|
||||||
// lastInitiatedAt: new Date().toISOString(),
|
|
||||||
// status: 'pending',
|
|
||||||
// } as AppBskyUnspeccedDefs.AgeAssuranceState,
|
|
||||||
// }
|
|
||||||
|
|
||||||
logger.debug(`fetch`, {
|
|
||||||
data,
|
|
||||||
account: agent.session?.did,
|
|
||||||
})
|
|
||||||
|
|
||||||
await getAndRegisterPushToken({
|
|
||||||
isAgeRestricted:
|
|
||||||
!!geolocation?.isAgeRestrictedGeo && data.status !== 'assured',
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
} catch (e) {
|
|
||||||
if (!isNetworkError(e)) {
|
|
||||||
logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
|
|
||||||
}
|
|
||||||
// don't re-throw error, we'll just fall back to defaults
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive state, or fall back to defaults
|
|
||||||
*/
|
|
||||||
const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => {
|
|
||||||
const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
|
|
||||||
const ctx: AgeAssuranceContextType = {
|
|
||||||
isReady: isFetched || !isAgeAssuranceEnabled,
|
|
||||||
status,
|
|
||||||
lastInitiatedAt,
|
|
||||||
isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
|
|
||||||
}
|
|
||||||
logger.debug(`context`, ctx)
|
|
||||||
return ctx
|
|
||||||
}, [isFetched, data, isAgeAssuranceEnabled])
|
|
||||||
|
|
||||||
if (
|
|
||||||
!!ageAssuranceContext.lastInitiatedAt &&
|
|
||||||
ageAssuranceContext.status === 'pending' &&
|
|
||||||
!refetchWhilePending
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
* If we have a pending state, we want to refetch on window focus to ensure
|
|
||||||
* that we get the latest state when the user returns to the app.
|
|
||||||
*/
|
|
||||||
setRefetchWhilePending(true)
|
|
||||||
} else if (
|
|
||||||
!!ageAssuranceContext.lastInitiatedAt &&
|
|
||||||
ageAssuranceContext.status !== 'pending' &&
|
|
||||||
refetchWhilePending
|
|
||||||
) {
|
|
||||||
setRefetchWhilePending(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>(
|
|
||||||
() => ({
|
|
||||||
refetch,
|
|
||||||
}),
|
|
||||||
[refetch],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}>
|
|
||||||
<AgeAssuranceContext.Provider value={ageAssuranceContext}>
|
|
||||||
{children}
|
|
||||||
</AgeAssuranceContext.Provider>
|
|
||||||
</AgeAssuranceAPIContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access to low-level AA state. Prefer using {@link useAgeInfo} for a
|
|
||||||
* more user-friendly interface.
|
|
||||||
*/
|
|
||||||
export function useAgeAssuranceContext() {
|
|
||||||
return useContext(AgeAssuranceContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgeAssuranceAPIContext() {
|
|
||||||
return useContext(AgeAssuranceAPIContext)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
|
||||||
import {type QueryObserverBaseResult} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
export type AgeAssuranceContextType = {
|
|
||||||
/**
|
|
||||||
* Whether the age assurance state has been fetched from the server. If user
|
|
||||||
* is not in a region that requires AA, or AA is otherwise disabled, this
|
|
||||||
* will always be `true`.
|
|
||||||
*/
|
|
||||||
isReady: boolean
|
|
||||||
/**
|
|
||||||
* The server-reported status of the user's age verification process.
|
|
||||||
*/
|
|
||||||
status: AppBskyUnspeccedDefs.AgeAssuranceState['status']
|
|
||||||
/**
|
|
||||||
* The last time the age assurance state was attempted by the user.
|
|
||||||
*/
|
|
||||||
lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt']
|
|
||||||
/**
|
|
||||||
* Indicates the user is age restricted based on the requirements of their
|
|
||||||
* region, and their server-provided age assurance status. Does not factor in
|
|
||||||
* the user's declared age. If AA is otherise disabled, this will always be
|
|
||||||
* `false`.
|
|
||||||
*/
|
|
||||||
isAgeRestricted: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgeAssuranceAPIContextType = {
|
|
||||||
/**
|
|
||||||
* Refreshes the age assurance state by fetching it from the server.
|
|
||||||
*/
|
|
||||||
refetch: QueryObserverBaseResult['refetch']
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {useMemo} from 'react'
|
|
||||||
|
|
||||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
||||||
|
|
||||||
type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
|
|
||||||
/**
|
|
||||||
* The age the user has declared in their preferences, if any.
|
|
||||||
*/
|
|
||||||
declaredAge: number | undefined
|
|
||||||
/**
|
|
||||||
* Indicates whether the user has declared an age under 18.
|
|
||||||
*/
|
|
||||||
isDeclaredUnderage: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed age information based on age assurance status and the user's
|
|
||||||
* declared age. Use this instead of {@link useAgeAssuranceContext} to get a
|
|
||||||
* more user-friendly interface.
|
|
||||||
*/
|
|
||||||
export function useAgeAssurance(): AgeAssurance {
|
|
||||||
const aa = useAgeAssuranceContext()
|
|
||||||
const {isFetched: preferencesLoaded, data: preferences} =
|
|
||||||
usePreferencesQuery()
|
|
||||||
const declaredAge = preferences?.userAge
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const isReady = aa.isReady && preferencesLoaded
|
|
||||||
const isDeclaredUnderage =
|
|
||||||
declaredAge !== undefined ? declaredAge < 18 : false
|
|
||||||
const state: AgeAssurance = {
|
|
||||||
isReady,
|
|
||||||
status: aa.status,
|
|
||||||
lastInitiatedAt: aa.lastInitiatedAt,
|
|
||||||
isAgeRestricted: aa.isAgeRestricted,
|
|
||||||
declaredAge,
|
|
||||||
isDeclaredUnderage,
|
|
||||||
}
|
|
||||||
logger.debug(`state`, state)
|
|
||||||
return state
|
|
||||||
}, [aa, preferencesLoaded, declaredAge])
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
type AppBskyUnspeccedDefs,
|
|
||||||
type AppBskyUnspeccedInitAgeAssurance,
|
|
||||||
AtpAgent,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {wait} from '#/lib/async/wait'
|
|
||||||
import {
|
|
||||||
// DEV_ENV_APPVIEW,
|
|
||||||
PUBLIC_APPVIEW,
|
|
||||||
PUBLIC_APPVIEW_DID,
|
|
||||||
} from '#/lib/constants'
|
|
||||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
|
|
||||||
import {type DeviceLocation, useGeolocationStatus} from '#/state/geolocation'
|
|
||||||
import {useAgent} from '#/state/session'
|
|
||||||
|
|
||||||
let APPVIEW = PUBLIC_APPVIEW
|
|
||||||
let APPVIEW_DID = PUBLIC_APPVIEW_DID
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Uncomment if using the local dev-env
|
|
||||||
*/
|
|
||||||
// if (__DEV__) {
|
|
||||||
// APPVIEW = DEV_ENV_APPVIEW
|
|
||||||
// /*
|
|
||||||
// * IMPORTANT: you need to get this value from `http://localhost:2581`
|
|
||||||
// * introspection endpoint and updated in `constants`, since it changes
|
|
||||||
// * every time you run the dev-env.
|
|
||||||
// */
|
|
||||||
// APPVIEW_DID = ``
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an ISO country code string from the given geolocation data.
|
|
||||||
* Examples: `GB` or `GB-ENG`
|
|
||||||
*/
|
|
||||||
function createISOCountryCode(
|
|
||||||
geolocation: Omit<DeviceLocation, 'countryCode'> & {
|
|
||||||
countryCode: string
|
|
||||||
},
|
|
||||||
): string {
|
|
||||||
return geolocation.countryCode.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInitAgeAssurance() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const agent = useAgent()
|
|
||||||
const {status: geolocation} = useGeolocationStatus()
|
|
||||||
return useMutation({
|
|
||||||
async mutationFn(
|
|
||||||
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
|
||||||
) {
|
|
||||||
const countryCode = geolocation?.countryCode
|
|
||||||
const regionCode = geolocation?.regionCode
|
|
||||||
if (!countryCode) {
|
|
||||||
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: {token},
|
|
||||||
} = await agent.com.atproto.server.getServiceAuth({
|
|
||||||
aud: APPVIEW_DID,
|
|
||||||
lxm: `app.bsky.unspecced.initAgeAssurance`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const appView = new AtpAgent({service: APPVIEW})
|
|
||||||
appView.sessionManager.session = {...agent.session!}
|
|
||||||
appView.sessionManager.session.accessJwt = token
|
|
||||||
appView.sessionManager.session.refreshJwt = ''
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
|
||||||
* ensure the email is ready for the user once they open their inbox.
|
|
||||||
*/
|
|
||||||
const {data} = await wait(
|
|
||||||
2e3,
|
|
||||||
appView.app.bsky.unspecced.initAgeAssurance({
|
|
||||||
...props,
|
|
||||||
countryCode: createISOCountryCode({
|
|
||||||
countryCode,
|
|
||||||
regionCode,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>(
|
|
||||||
createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
|
|
||||||
() => data,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError(e) {
|
|
||||||
if (!isNetworkError(e)) {
|
|
||||||
logger.error(`useInitAgeAssurance failed`, {
|
|
||||||
safeMessage: e,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import {useMemo} from 'react'
|
|
||||||
|
|
||||||
import {useGeolocationStatus} from '#/state/geolocation'
|
|
||||||
|
|
||||||
export function useIsAgeAssuranceEnabled() {
|
|
||||||
const {status: geolocation} = useGeolocationStatus()
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return !!geolocation?.isAgeRestrictedGeo
|
|
||||||
}, [geolocation])
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
|
import {account} from '#/storage'
|
||||||
|
|
||||||
|
// 6s in dev, 48h in prod
|
||||||
|
const BIRTHDATE_DELAY_HOURS = IS_DEV ? 0.001 : 48
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the timestamp of the birthday update locally. This is used to
|
||||||
|
* debounce birthday updates globally.
|
||||||
|
*
|
||||||
|
* Use {@link useIsBirthDateUpdateAllowed} to check if an update is allowed.
|
||||||
|
*/
|
||||||
|
export function snoozeBirthdateUpdateAllowedForDid(did: string) {
|
||||||
|
account.set([did, 'birthdateLastUpdatedAt'], new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a birthdate update is currently allowed, based on the
|
||||||
|
* last update timestamp stored locally.
|
||||||
|
*/
|
||||||
|
export function useIsBirthdateUpdateAllowed() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!currentAccount) return false
|
||||||
|
const lastUpdated = account.get([
|
||||||
|
currentAccount.did,
|
||||||
|
'birthdateLastUpdatedAt',
|
||||||
|
])
|
||||||
|
if (!lastUpdated) return true
|
||||||
|
const lastUpdatedDate = new Date(lastUpdated)
|
||||||
|
const diffMs = Date.now() - lastUpdatedDate.getTime()
|
||||||
|
const diffHours = diffMs / (1000 * 60 * 60)
|
||||||
|
return diffHours >= BIRTHDATE_DELAY_HOURS
|
||||||
|
}, [currentAccount])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBirthdateMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
const patchOtherRequiredData = usePatchAgeAssuranceOtherRequiredData()
|
||||||
|
|
||||||
|
return useMutation<void, unknown, {birthDate: Date}>({
|
||||||
|
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||||
|
const bday = birthDate.toISOString()
|
||||||
|
await agent.setPersonalDetails({birthDate: bday})
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Also patch the age assurance other required data with the new
|
||||||
|
* birthdate, which may change the user's age assurance access level.
|
||||||
|
*/
|
||||||
|
patchOtherRequiredData({birthdate: bday})
|
||||||
|
snoozeBirthdateUpdateAllowedForDid(agent.sessionManager.did!)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+79
-1
@@ -13,7 +13,10 @@ import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from
|
|||||||
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
|
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
|
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
|
import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/state/queries/post-feed'
|
import {
|
||||||
|
type FeedPage,
|
||||||
|
findAllProfilesInQueryData as findAllProfilesInFeedsQueryData,
|
||||||
|
} from '#/state/queries/post-feed'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
|
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
|
import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
|
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
|
||||||
@@ -110,6 +113,81 @@ export function useMaybeProfileShadow<
|
|||||||
}, [profile, shadow])
|
}, [profile, shadow])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a list of posts, and returns a list of DIDs that should be filtered out
|
||||||
|
*
|
||||||
|
* Note: it doesn't retroactively scan the cache, but only listens to new updates.
|
||||||
|
* The use case here is intended for removing a post from a feed after you mute the author
|
||||||
|
*/
|
||||||
|
export function usePostAuthorShadowFilter(data?: FeedPage[]) {
|
||||||
|
const [trackedDids, setTrackedDids] = useState<string[]>(
|
||||||
|
() =>
|
||||||
|
data?.flatMap(page =>
|
||||||
|
page.slices.flatMap(slice =>
|
||||||
|
slice.items.map(item => item.post.author.did),
|
||||||
|
),
|
||||||
|
) ?? [],
|
||||||
|
)
|
||||||
|
const [authors, setAuthors] = useState(
|
||||||
|
new Map<string, {muted: boolean; blocked: boolean}>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [prevData, setPrevData] = useState(data)
|
||||||
|
if (data !== prevData) {
|
||||||
|
const newAuthors = new Set(trackedDids)
|
||||||
|
let hasNew = false
|
||||||
|
for (const slice of data?.flatMap(page => page.slices) ?? []) {
|
||||||
|
for (const item of slice.items) {
|
||||||
|
const author = item.post.author
|
||||||
|
if (!newAuthors.has(author.did)) {
|
||||||
|
hasNew = true
|
||||||
|
newAuthors.add(author.did)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNew) setTrackedDids([...newAuthors])
|
||||||
|
setPrevData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubs: Array<() => void> = []
|
||||||
|
|
||||||
|
for (const did of trackedDids) {
|
||||||
|
function onUpdate(value: Partial<ProfileShadow>) {
|
||||||
|
setAuthors(prev => {
|
||||||
|
const prevValue = prev.get(did)
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(did, {
|
||||||
|
blocked: Boolean(value.blockingUri ?? prevValue?.blocked ?? false),
|
||||||
|
muted: Boolean(value.muted ?? prevValue?.muted ?? false),
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emitter.addListener(did, onUpdate)
|
||||||
|
unsubs.push(() => {
|
||||||
|
emitter.removeListener(did, onUpdate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubs.map(fn => fn())
|
||||||
|
}
|
||||||
|
}, [trackedDids])
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const dids: Array<string> = []
|
||||||
|
|
||||||
|
for (const [did, value] of authors.entries()) {
|
||||||
|
if (value.blocked || value.muted) {
|
||||||
|
dids.push(did)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dids
|
||||||
|
}, [authors])
|
||||||
|
}
|
||||||
|
|
||||||
export function updateProfileShadow(
|
export function updateProfileShadow(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
did: string,
|
did: string,
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import {networkRetry} from '#/lib/async/retry'
|
|
||||||
import {
|
|
||||||
DEFAULT_GEOLOCATION_CONFIG,
|
|
||||||
GEOLOCATION_CONFIG_URL,
|
|
||||||
} from '#/state/geolocation/const'
|
|
||||||
import {emitGeolocationConfigUpdate} from '#/state/geolocation/events'
|
|
||||||
import {logger} from '#/state/geolocation/logger'
|
|
||||||
import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env'
|
|
||||||
import {type Device, device} from '#/storage'
|
|
||||||
|
|
||||||
async function getGeolocationConfig(
|
|
||||||
url: string,
|
|
||||||
): Promise<Device['geolocation']> {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: IS_DEV
|
|
||||||
? {
|
|
||||||
'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`config: fetch failed ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json()
|
|
||||||
|
|
||||||
if (json.countryCode) {
|
|
||||||
/**
|
|
||||||
* Only construct known values here, ignore any extras.
|
|
||||||
*/
|
|
||||||
const config: Device['geolocation'] = {
|
|
||||||
countryCode: json.countryCode,
|
|
||||||
regionCode: json.regionCode ?? undefined,
|
|
||||||
ageRestrictedGeos: json.ageRestrictedGeos ?? [],
|
|
||||||
ageBlockedGeos: json.ageBlockedGeos ?? [],
|
|
||||||
}
|
|
||||||
logger.debug(`config: success`)
|
|
||||||
return config
|
|
||||||
} else {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local promise used within this file only.
|
|
||||||
*/
|
|
||||||
let geolocationConfigResolution: Promise<{success: boolean}> | undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin the process of resolving geolocation config. This should be called
|
|
||||||
* once at app start.
|
|
||||||
*
|
|
||||||
* THIS METHOD SHOULD NEVER THROW.
|
|
||||||
*
|
|
||||||
* This method is otherwise not used for any purpose. To ensure geolocation
|
|
||||||
* config is resolved, use {@link ensureGeolocationConfigIsResolved}
|
|
||||||
*/
|
|
||||||
export function beginResolveGeolocationConfig() {
|
|
||||||
/**
|
|
||||||
* Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing.
|
|
||||||
*/
|
|
||||||
// if (__DEV__) {
|
|
||||||
// geolocationConfigResolution = new Promise(y => y({success: true}))
|
|
||||||
// device.set(['deviceGeolocation'], undefined) // clears GPS data
|
|
||||||
// device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
geolocationConfigResolution = new Promise(async resolve => {
|
|
||||||
let success = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try once, fail fast
|
|
||||||
const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL)
|
|
||||||
if (config) {
|
|
||||||
device.set(['geolocation'], config)
|
|
||||||
emitGeolocationConfigUpdate(config)
|
|
||||||
} else {
|
|
||||||
// endpoint should throw on all failures, this is insurance
|
|
||||||
throw new Error(
|
|
||||||
`geolocation config: nothing returned from initial request`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
success = false
|
|
||||||
|
|
||||||
logger.debug(`config: failed initial request`, {
|
|
||||||
safeMessage: e.message,
|
|
||||||
})
|
|
||||||
|
|
||||||
// set to default
|
|
||||||
device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG)
|
|
||||||
|
|
||||||
// retry 3 times, but don't await, proceed with default
|
|
||||||
networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL))
|
|
||||||
.then(config => {
|
|
||||||
if (config) {
|
|
||||||
device.set(['geolocation'], config)
|
|
||||||
emitGeolocationConfigUpdate(config)
|
|
||||||
success = true
|
|
||||||
} else {
|
|
||||||
// endpoint should throw on all failures, this is insurance
|
|
||||||
throw new Error(`config: nothing returned from retries`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e: any) => {
|
|
||||||
// complete fail closed
|
|
||||||
logger.debug(`config: failed retries`, {
|
|
||||||
safeMessage: e.message,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
resolve({success})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure that geolocation config has been resolved, or at the very least attempted
|
|
||||||
* once. Subsequent retries will not be captured by this `await`. Those will be
|
|
||||||
* reported via {@link emitGeolocationConfigUpdate}.
|
|
||||||
*/
|
|
||||||
export async function ensureGeolocationConfigIsResolved() {
|
|
||||||
if (!geolocationConfigResolution) {
|
|
||||||
throw new Error(`config: beginResolveGeolocationConfig not called yet`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = device.get(['geolocation'])
|
|
||||||
if (cached) {
|
|
||||||
logger.debug(`config: using cache`)
|
|
||||||
} else {
|
|
||||||
logger.debug(`config: no cache`)
|
|
||||||
const {success} = await geolocationConfigResolution
|
|
||||||
if (success) {
|
|
||||||
logger.debug(`config: resolved`)
|
|
||||||
} else {
|
|
||||||
logger.info(`config: failed to resolve`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import {type GeolocationStatus} from '#/state/geolocation/types'
|
|
||||||
import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env'
|
|
||||||
import {type Device} from '#/storage'
|
|
||||||
|
|
||||||
export const IPCC_URL = `https://bsky.app/ipcc`
|
|
||||||
export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config`
|
|
||||||
export const BAPP_CONFIG_URL = IS_DEV
|
|
||||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD)
|
|
||||||
: BAPP_CONFIG_URL_PROD
|
|
||||||
export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default geolocation config.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = {
|
|
||||||
countryCode: undefined,
|
|
||||||
regionCode: undefined,
|
|
||||||
ageRestrictedGeos: [],
|
|
||||||
ageBlockedGeos: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default geolocation status.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = {
|
|
||||||
countryCode: undefined,
|
|
||||||
regionCode: undefined,
|
|
||||||
isAgeRestrictedGeo: false,
|
|
||||||
isAgeBlockedGeo: false,
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import EventEmitter from 'eventemitter3'
|
|
||||||
|
|
||||||
import {type Device} from '#/storage'
|
|
||||||
|
|
||||||
const events = new EventEmitter()
|
|
||||||
const EVENT = 'geolocation-config-updated'
|
|
||||||
|
|
||||||
export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => {
|
|
||||||
events.emit(EVENT, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onGeolocationConfigUpdate = (
|
|
||||||
listener: (config: Device['geolocation']) => void,
|
|
||||||
) => {
|
|
||||||
events.on(EVENT, listener)
|
|
||||||
return () => {
|
|
||||||
events.off(EVENT, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
DEFAULT_GEOLOCATION_CONFIG,
|
|
||||||
DEFAULT_GEOLOCATION_STATUS,
|
|
||||||
} from '#/state/geolocation/const'
|
|
||||||
import {onGeolocationConfigUpdate} from '#/state/geolocation/events'
|
|
||||||
import {logger} from '#/state/geolocation/logger'
|
|
||||||
import {
|
|
||||||
type DeviceLocation,
|
|
||||||
type GeolocationStatus,
|
|
||||||
} from '#/state/geolocation/types'
|
|
||||||
import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation'
|
|
||||||
import {
|
|
||||||
computeGeolocationStatus,
|
|
||||||
mergeGeolocation,
|
|
||||||
} from '#/state/geolocation/util'
|
|
||||||
import {type Device, device} from '#/storage'
|
|
||||||
|
|
||||||
export * from '#/state/geolocation/config'
|
|
||||||
export * from '#/state/geolocation/types'
|
|
||||||
export * from '#/state/geolocation/util'
|
|
||||||
|
|
||||||
type DeviceGeolocationContext = {
|
|
||||||
deviceGeolocation: DeviceLocation | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceGeolocationAPIContext = {
|
|
||||||
setDeviceGeolocation(deviceGeolocation: DeviceLocation): void
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeolocationConfigContext = {
|
|
||||||
config: Device['geolocation']
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeolocationStatusContext = {
|
|
||||||
/**
|
|
||||||
* Merged geolocation from config and device GPS (if available).
|
|
||||||
*/
|
|
||||||
location: DeviceLocation
|
|
||||||
/**
|
|
||||||
* Computed geolocation status based on the merged location and config.
|
|
||||||
*/
|
|
||||||
status: GeolocationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({
|
|
||||||
deviceGeolocation: undefined,
|
|
||||||
})
|
|
||||||
DeviceGeolocationContext.displayName = 'DeviceGeolocationContext'
|
|
||||||
|
|
||||||
const DeviceGeolocationAPIContext =
|
|
||||||
React.createContext<DeviceGeolocationAPIContext>({
|
|
||||||
setDeviceGeolocation: () => {},
|
|
||||||
})
|
|
||||||
DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext'
|
|
||||||
|
|
||||||
const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({
|
|
||||||
config: DEFAULT_GEOLOCATION_CONFIG,
|
|
||||||
})
|
|
||||||
GeolocationConfigContext.displayName = 'GeolocationConfigContext'
|
|
||||||
|
|
||||||
const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({
|
|
||||||
location: {
|
|
||||||
countryCode: undefined,
|
|
||||||
regionCode: undefined,
|
|
||||||
},
|
|
||||||
status: DEFAULT_GEOLOCATION_STATUS,
|
|
||||||
})
|
|
||||||
GeolocationStatusContext.displayName = 'GeolocationStatusContext'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider of geolocation config and computed geolocation status.
|
|
||||||
*/
|
|
||||||
export function GeolocationStatusProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const {deviceGeolocation} = React.useContext(DeviceGeolocationContext)
|
|
||||||
const [config, setConfig] = React.useState(() => {
|
|
||||||
const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG
|
|
||||||
return initial
|
|
||||||
})
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return onGeolocationConfigUpdate(config => {
|
|
||||||
setConfig(config!)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const configContext = React.useMemo(() => ({config}), [config])
|
|
||||||
const statusContext = React.useMemo(() => {
|
|
||||||
if (deviceGeolocation?.countryCode) {
|
|
||||||
logger.debug('has device geolocation available')
|
|
||||||
}
|
|
||||||
const geolocation = mergeGeolocation(deviceGeolocation, config)
|
|
||||||
const status = computeGeolocationStatus(geolocation, config)
|
|
||||||
// ensure this remains debug and never leaves device
|
|
||||||
logger.debug('result', {deviceGeolocation, geolocation, status, config})
|
|
||||||
return {location: geolocation, status}
|
|
||||||
}, [config, deviceGeolocation])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GeolocationConfigContext.Provider value={configContext}>
|
|
||||||
<GeolocationStatusContext.Provider value={statusContext}>
|
|
||||||
{children}
|
|
||||||
</GeolocationStatusContext.Provider>
|
|
||||||
</GeolocationConfigContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider of providers. Provides device geolocation data to lower-level
|
|
||||||
* `GeolocationStatusProvider`, and device geolocation APIs to children.
|
|
||||||
*/
|
|
||||||
export function Provider({children}: {children: React.ReactNode}) {
|
|
||||||
const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation()
|
|
||||||
|
|
||||||
const handleSetDeviceGeolocation = React.useCallback(
|
|
||||||
(location: DeviceLocation) => {
|
|
||||||
logger.debug('setting device geolocation')
|
|
||||||
setDeviceGeolocation({
|
|
||||||
countryCode: location.countryCode ?? undefined,
|
|
||||||
regionCode: location.regionCode ?? undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[setDeviceGeolocation],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DeviceGeolocationAPIContext.Provider
|
|
||||||
value={React.useMemo(
|
|
||||||
() => ({setDeviceGeolocation: handleSetDeviceGeolocation}),
|
|
||||||
[handleSetDeviceGeolocation],
|
|
||||||
)}>
|
|
||||||
<DeviceGeolocationContext.Provider
|
|
||||||
value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}>
|
|
||||||
<GeolocationStatusProvider>{children}</GeolocationStatusProvider>
|
|
||||||
</DeviceGeolocationContext.Provider>
|
|
||||||
</DeviceGeolocationAPIContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeviceGeolocationApi() {
|
|
||||||
return React.useContext(DeviceGeolocationAPIContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeolocationConfig() {
|
|
||||||
return React.useContext(GeolocationConfigContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeolocationStatus() {
|
|
||||||
return React.useContext(GeolocationStatusContext)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type DeviceLocation = {
|
|
||||||
countryCode: string | undefined
|
|
||||||
regionCode: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GeolocationStatus = DeviceLocation & {
|
|
||||||
isAgeRestrictedGeo: boolean
|
|
||||||
isAgeBlockedGeo: boolean
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import {useCallback} from 'react'
|
|
||||||
import * as Location from 'expo-location'
|
|
||||||
|
|
||||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
|
||||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
|
||||||
|
|
||||||
export {PermissionStatus} from 'expo-location'
|
|
||||||
|
|
||||||
export function useRequestDeviceLocation(): () => Promise<
|
|
||||||
| {
|
|
||||||
granted: true
|
|
||||||
location: DeviceLocation | undefined
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
granted: false
|
|
||||||
status: {
|
|
||||||
canAskAgain: boolean
|
|
||||||
/**
|
|
||||||
* Enum, use `PermissionStatus` export for comparisons
|
|
||||||
*/
|
|
||||||
permissionStatus: Location.PermissionStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
> {
|
|
||||||
return useCallback(async () => {
|
|
||||||
const status = await Location.requestForegroundPermissionsAsync()
|
|
||||||
|
|
||||||
if (status.granted) {
|
|
||||||
return {
|
|
||||||
granted: true,
|
|
||||||
location: await getDeviceGeolocation(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
granted: false,
|
|
||||||
status: {
|
|
||||||
canAskAgain: status.canAskAgain,
|
|
||||||
permissionStatus: status.status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {useEffect, useRef} from 'react'
|
|
||||||
import * as Location from 'expo-location'
|
|
||||||
import {createPermissionHook} from 'expo-modules-core'
|
|
||||||
|
|
||||||
import {logger} from '#/state/geolocation/logger'
|
|
||||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
|
||||||
import {device, useStorage} from '#/storage'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available.
|
|
||||||
* We need to catch and ignore it, since it's effectively denied.
|
|
||||||
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
|
||||||
*/
|
|
||||||
const useForegroundPermissions = createPermissionHook({
|
|
||||||
getMethod: () =>
|
|
||||||
Location.getForegroundPermissionsAsync().catch(error => {
|
|
||||||
logger.debug(
|
|
||||||
'useForegroundPermission: error getting location permissions',
|
|
||||||
{safeMessage: error},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
status: Location.PermissionStatus.DENIED,
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: false,
|
|
||||||
expires: 0,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
requestMethod: () =>
|
|
||||||
Location.requestForegroundPermissionsAsync().catch(error => {
|
|
||||||
logger.debug(
|
|
||||||
'useForegroundPermission: error requesting location permissions',
|
|
||||||
{safeMessage: error},
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
status: Location.PermissionStatus.DENIED,
|
|
||||||
granted: false,
|
|
||||||
canAskAgain: false,
|
|
||||||
expires: 0,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
|
||||||
* using device storage. If permissions are not granted, it will clear any cached
|
|
||||||
* storage value.
|
|
||||||
*/
|
|
||||||
export function useSyncedDeviceGeolocation() {
|
|
||||||
const synced = useRef(false)
|
|
||||||
const [status] = useForegroundPermissions()
|
|
||||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
|
||||||
'deviceGeolocation',
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function get() {
|
|
||||||
// no need to set this more than once per session
|
|
||||||
if (synced.current) return
|
|
||||||
|
|
||||||
logger.debug('useSyncedDeviceGeolocation: checking perms')
|
|
||||||
|
|
||||||
if (status?.granted) {
|
|
||||||
const location = await getDeviceGeolocation()
|
|
||||||
if (location) {
|
|
||||||
logger.debug('useSyncedDeviceGeolocation: syncing location')
|
|
||||||
setDeviceGeolocation(location)
|
|
||||||
synced.current = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If we have a cached value, but user has revoked permissions,
|
|
||||||
* quietly (will take effect lazily) clear this out.
|
|
||||||
*/
|
|
||||||
if (hasCachedValue) {
|
|
||||||
logger.debug(
|
|
||||||
'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
|
|
||||||
)
|
|
||||||
device.set(['deviceGeolocation'], undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get().catch(e => {
|
|
||||||
logger.error('useSyncedDeviceGeolocation: failed to sync', {
|
|
||||||
safeMessage: e,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [status, setDeviceGeolocation])
|
|
||||||
|
|
||||||
return [deviceGeolocation, setDeviceGeolocation] as const
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import {
|
|
||||||
getCurrentPositionAsync,
|
|
||||||
type LocationGeocodedAddress,
|
|
||||||
reverseGeocodeAsync,
|
|
||||||
} from 'expo-location'
|
|
||||||
|
|
||||||
import {logger} from '#/state/geolocation/logger'
|
|
||||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
|
||||||
import {type Device} from '#/storage'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps full US region names to their short codes.
|
|
||||||
*
|
|
||||||
* Context: in some cases, like on Android, we get the full region name instead
|
|
||||||
* of the short code. We may need to expand this in the future to other
|
|
||||||
* countries, hence the prefix.
|
|
||||||
*/
|
|
||||||
export const USRegionNameToRegionCode: {
|
|
||||||
[regionName: string]: string
|
|
||||||
} = {
|
|
||||||
Alabama: 'AL',
|
|
||||||
Alaska: 'AK',
|
|
||||||
Arizona: 'AZ',
|
|
||||||
Arkansas: 'AR',
|
|
||||||
California: 'CA',
|
|
||||||
Colorado: 'CO',
|
|
||||||
Connecticut: 'CT',
|
|
||||||
Delaware: 'DE',
|
|
||||||
Florida: 'FL',
|
|
||||||
Georgia: 'GA',
|
|
||||||
Hawaii: 'HI',
|
|
||||||
Idaho: 'ID',
|
|
||||||
Illinois: 'IL',
|
|
||||||
Indiana: 'IN',
|
|
||||||
Iowa: 'IA',
|
|
||||||
Kansas: 'KS',
|
|
||||||
Kentucky: 'KY',
|
|
||||||
Louisiana: 'LA',
|
|
||||||
Maine: 'ME',
|
|
||||||
Maryland: 'MD',
|
|
||||||
Massachusetts: 'MA',
|
|
||||||
Michigan: 'MI',
|
|
||||||
Minnesota: 'MN',
|
|
||||||
Mississippi: 'MS',
|
|
||||||
Missouri: 'MO',
|
|
||||||
Montana: 'MT',
|
|
||||||
Nebraska: 'NE',
|
|
||||||
Nevada: 'NV',
|
|
||||||
['New Hampshire']: 'NH',
|
|
||||||
['New Jersey']: 'NJ',
|
|
||||||
['New Mexico']: 'NM',
|
|
||||||
['New York']: 'NY',
|
|
||||||
['North Carolina']: 'NC',
|
|
||||||
['North Dakota']: 'ND',
|
|
||||||
Ohio: 'OH',
|
|
||||||
Oklahoma: 'OK',
|
|
||||||
Oregon: 'OR',
|
|
||||||
Pennsylvania: 'PA',
|
|
||||||
['Rhode Island']: 'RI',
|
|
||||||
['South Carolina']: 'SC',
|
|
||||||
['South Dakota']: 'SD',
|
|
||||||
Tennessee: 'TN',
|
|
||||||
Texas: 'TX',
|
|
||||||
Utah: 'UT',
|
|
||||||
Vermont: 'VT',
|
|
||||||
Virginia: 'VA',
|
|
||||||
Washington: 'WA',
|
|
||||||
['West Virginia']: 'WV',
|
|
||||||
Wisconsin: 'WI',
|
|
||||||
Wyoming: 'WY',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
|
|
||||||
*
|
|
||||||
* We don't want or care about the full location data, so we trim it down and
|
|
||||||
* normalize certain fields, like region, into the format we need.
|
|
||||||
*/
|
|
||||||
export function normalizeDeviceLocation(
|
|
||||||
location: LocationGeocodedAddress,
|
|
||||||
): DeviceLocation {
|
|
||||||
let {isoCountryCode, region} = location
|
|
||||||
|
|
||||||
if (region) {
|
|
||||||
if (isoCountryCode === 'US') {
|
|
||||||
region = USRegionNameToRegionCode[region] ?? region
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
countryCode: isoCountryCode ?? undefined,
|
|
||||||
regionCode: region ?? undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines precise location data with the geolocation config fetched from the
|
|
||||||
* IP service, with preference to the precise data.
|
|
||||||
*/
|
|
||||||
export function mergeGeolocation(
|
|
||||||
location?: DeviceLocation,
|
|
||||||
config?: Device['geolocation'],
|
|
||||||
): DeviceLocation {
|
|
||||||
if (location?.countryCode) return location
|
|
||||||
return {
|
|
||||||
countryCode: config?.countryCode,
|
|
||||||
regionCode: config?.regionCode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the geolocation status (age-restricted, age-blocked) based on the
|
|
||||||
* given location and geolocation config. `location` here should be merged with
|
|
||||||
* `mergeGeolocation()` ahead of time if needed.
|
|
||||||
*/
|
|
||||||
export function computeGeolocationStatus(
|
|
||||||
location: DeviceLocation,
|
|
||||||
config: Device['geolocation'],
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* We can't do anything if we don't have this data.
|
|
||||||
*/
|
|
||||||
if (!location.countryCode) {
|
|
||||||
return {
|
|
||||||
...location,
|
|
||||||
isAgeRestrictedGeo: false,
|
|
||||||
isAgeBlockedGeo: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
|
|
||||||
if (rule.countryCode === location.countryCode) {
|
|
||||||
if (!rule.regionCode) {
|
|
||||||
return true // whole country is blocked
|
|
||||||
} else if (rule.regionCode === location.regionCode) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
|
|
||||||
if (rule.countryCode === location.countryCode) {
|
|
||||||
if (!rule.regionCode) {
|
|
||||||
return true // whole country is blocked
|
|
||||||
} else if (rule.regionCode === location.regionCode) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...location,
|
|
||||||
isAgeRestrictedGeo: !!isAgeRestrictedGeo,
|
|
||||||
isAgeBlockedGeo: !!isAgeBlockedGeo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeviceGeolocation(): Promise<DeviceLocation> {
|
|
||||||
try {
|
|
||||||
const geocode = await getCurrentPositionAsync()
|
|
||||||
const locations = await reverseGeocodeAsync({
|
|
||||||
latitude: geocode.coords.latitude,
|
|
||||||
longitude: geocode.coords.longitude,
|
|
||||||
})
|
|
||||||
const location = locations.at(0)
|
|
||||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
|
||||||
return {
|
|
||||||
countryCode: normalized?.countryCode ?? undefined,
|
|
||||||
regionCode: normalized?.regionCode ?? undefined,
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('getDeviceGeolocation: failed', {
|
|
||||||
safeMessage: e,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
countryCode: undefined,
|
|
||||||
regionCode: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
|||||||
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
|
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
|
||||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
@@ -141,12 +140,8 @@ export function usePostFeedQuery(
|
|||||||
* available for the remainder of the session, so this delay only affects cold
|
* available for the remainder of the session, so this delay only affects cold
|
||||||
* loads. -esb
|
* loads. -esb
|
||||||
*/
|
*/
|
||||||
const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext()
|
|
||||||
const enabled =
|
const enabled =
|
||||||
opts?.enabled !== false &&
|
opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
|
||||||
Boolean(moderationOpts) &&
|
|
||||||
Boolean(preferences) &&
|
|
||||||
isAgeAssuranceReady
|
|
||||||
const userInterests = aggregateUserInterests(preferences)
|
const userInterests = aggregateUserInterests(preferences)
|
||||||
const followingPinnedIndex =
|
const followingPinnedIndex =
|
||||||
preferences?.savedFeeds?.findIndex(
|
preferences?.savedFeeds?.findIndex(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function usePostQuery(uri: string | undefined) {
|
|||||||
const res = await agent.resolveHandle({
|
const res = await agent.resolveHandle({
|
||||||
handle: urip.host,
|
handle: urip.host,
|
||||||
})
|
})
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data.did
|
urip.host = res.data.did
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export function useGetPost() {
|
|||||||
const res = await agent.resolveHandle({
|
const res = await agent.resolveHandle({
|
||||||
handle: urip.host,
|
handle: urip.host,
|
||||||
})
|
})
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data.did
|
urip.host = res.data.did
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export async function getPostgateRecord({
|
|||||||
const res = await agent.resolveHandle({
|
const res = await agent.resolveHandle({
|
||||||
handle: urip.host,
|
handle: urip.host,
|
||||||
})
|
})
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data.did
|
urip.host = res.data.did
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
|||||||
import {replaceEqualDeep} from '#/lib/functions'
|
import {replaceEqualDeep} from '#/lib/functions'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
|
||||||
import {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const'
|
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {
|
import {
|
||||||
DEFAULT_HOME_FEED_PREFS,
|
DEFAULT_HOME_FEED_PREFS,
|
||||||
@@ -24,6 +22,7 @@ import {
|
|||||||
} from '#/state/queries/preferences/types'
|
} from '#/state/queries/preferences/types'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {saveLabelers} from '#/state/session/agent-config'
|
import {saveLabelers} from '#/state/session/agent-config'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
|
|
||||||
export * from '#/state/queries/preferences/const'
|
export * from '#/state/queries/preferences/const'
|
||||||
export * from '#/state/queries/preferences/moderation'
|
export * from '#/state/queries/preferences/moderation'
|
||||||
@@ -34,7 +33,7 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
|
|||||||
|
|
||||||
export function usePreferencesQuery() {
|
export function usePreferencesQuery() {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {isAgeRestricted} = useAgeAssuranceContext()
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
staleTime: STALE.SECONDS.FIFTEEN,
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
@@ -75,18 +74,19 @@ export function usePreferencesQuery() {
|
|||||||
},
|
},
|
||||||
select: useCallback(
|
select: useCallback(
|
||||||
(data: UsePreferencesQueryResponse) => {
|
(data: UsePreferencesQueryResponse) => {
|
||||||
const isUnderage = (data.userAge || 0) < 18
|
/**
|
||||||
if (isUnderage || isAgeRestricted) {
|
* Prefs are all downstream of age assurance now. For logged-out
|
||||||
|
* users, we override moderation prefs based on AA state.
|
||||||
|
*/
|
||||||
|
if (aa.state.access !== aa.Access.Full) {
|
||||||
data = {
|
data = {
|
||||||
...data,
|
...data,
|
||||||
moderationPrefs: makeAgeRestrictedModerationPrefs(
|
moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||||
data.moderationPrefs,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
[isAgeRestricted],
|
[aa],
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -168,21 +168,6 @@ export function usePreferencesSetAdultContentMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePreferencesSetBirthDateMutation() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const agent = useAgent()
|
|
||||||
|
|
||||||
return useMutation<void, unknown, {birthDate: Date}>({
|
|
||||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
|
||||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
|
||||||
// triggers a refetch
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: preferencesQueryKey,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetFeedViewPreferencesMutation() {
|
export function useSetFeedViewPreferencesMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
|
|||||||
const urip = new AtUri(uri || '')
|
const urip = new AtUri(uri || '')
|
||||||
const res = useResolveDidQuery(urip.host)
|
const res = useResolveDidQuery(urip.host)
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data
|
urip.host = res.data
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export async function getThreadgateRecord({
|
|||||||
const res = await agent.resolveHandle({
|
const res = await agent.resolveHandle({
|
||||||
handle: urip.host,
|
handle: urip.host,
|
||||||
})
|
})
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = res.data.did
|
urip.host = res.data.did
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ jest.mock('jwt-decode', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('../../birthdate')
|
||||||
|
jest.mock('../../../ageAssurance/data')
|
||||||
|
|
||||||
describe('session', () => {
|
describe('session', () => {
|
||||||
it('can log in and out', () => {
|
it('can log in and out', () => {
|
||||||
let state = getInitialState([])
|
let state = getInitialState([])
|
||||||
|
|||||||
+125
-22
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Agent as BaseAgent,
|
Agent as BaseAgent,
|
||||||
|
type AppBskyActorProfile,
|
||||||
type AtprotoServiceType,
|
type AtprotoServiceType,
|
||||||
type AtpSessionData,
|
type AtpSessionData,
|
||||||
type AtpSessionEvent,
|
type AtpSessionEvent,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
type Did,
|
type Did,
|
||||||
|
type Un$Typed,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {type FetchHandler} from '@atproto/api/dist/agent'
|
import {type FetchHandler} from '@atproto/api/dist/agent'
|
||||||
import {type SessionManager} from '@atproto/api/dist/session-manager'
|
import {type SessionManager} from '@atproto/api/dist/session-manager'
|
||||||
@@ -23,7 +25,13 @@ import {
|
|||||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||||
|
import {
|
||||||
|
prefetchAgeAssuranceData,
|
||||||
|
setBirthdateForDid,
|
||||||
|
setCreatedAtForDid,
|
||||||
|
} from '#/ageAssurance/data'
|
||||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||||
import {addSessionErrorLog} from './logging'
|
import {addSessionErrorLog} from './logging'
|
||||||
import {
|
import {
|
||||||
@@ -77,9 +85,15 @@ export async function createAgentAndResume(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// after session is attached
|
||||||
|
const aa = prefetchAgeAssuranceData({agent})
|
||||||
|
|
||||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
return agent.prepare(gates, moderation, onSessionChange)
|
return agent.prepare({
|
||||||
|
resolvers: [gates, moderation, aa],
|
||||||
|
onSessionChange,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAgentAndLogin(
|
export async function createAgentAndLogin(
|
||||||
@@ -111,10 +125,14 @@ export async function createAgentAndLogin(
|
|||||||
const account = agentToSessionAccountOrThrow(agent)
|
const account = agentToSessionAccountOrThrow(agent)
|
||||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||||
const moderation = configureModerationForAccount(agent, account)
|
const moderation = configureModerationForAccount(agent, account)
|
||||||
|
const aa = prefetchAgeAssuranceData({agent})
|
||||||
|
|
||||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
return agent.prepare(gates, moderation, onSessionChange)
|
return agent.prepare({
|
||||||
|
resolvers: [gates, moderation, aa],
|
||||||
|
onSessionChange,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAgentAndCreateAccount(
|
export async function createAgentAndCreateAccount(
|
||||||
@@ -156,13 +174,49 @@ export async function createAgentAndCreateAccount(
|
|||||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||||
const moderation = configureModerationForAccount(agent, account)
|
const moderation = configureModerationForAccount(agent, account)
|
||||||
|
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
const birthdate = birthDate.toISOString()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Since we have a race with account creation, profile creation, and AA
|
||||||
|
* state, set these values locally to ensure sync reads. Values are written
|
||||||
|
* to the server in the next step, so on subsequent reloads, the server will
|
||||||
|
* be the source of truth.
|
||||||
|
*/
|
||||||
|
setCreatedAtForDid({did: account.did, createdAt})
|
||||||
|
setBirthdateForDid({did: account.did, birthdate})
|
||||||
|
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||||
|
// do this last
|
||||||
|
const aa = prefetchAgeAssuranceData({agent})
|
||||||
|
|
||||||
// Not awaited so that we can still get into onboarding.
|
// Not awaited so that we can still get into onboarding.
|
||||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||||
if (IS_PROD_SERVICE(service)) {
|
if (IS_PROD_SERVICE(service)) {
|
||||||
try {
|
Promise.allSettled(
|
||||||
networkRetry(1, async () => {
|
[
|
||||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
networkRetry(3, () => {
|
||||||
await agent.overwriteSavedFeeds([
|
return agent.setPersonalDetails({
|
||||||
|
birthDate: birthdate,
|
||||||
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
networkRetry(3, () => {
|
||||||
|
return agent.upsertProfile(prev => {
|
||||||
|
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||||
|
next.displayName = handle
|
||||||
|
next.createdAt = createdAt
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
logger.info(
|
||||||
|
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
networkRetry(1, () => {
|
||||||
|
return agent.overwriteSavedFeeds([
|
||||||
{
|
{
|
||||||
...DISCOVER_SAVED_FEED,
|
...DISCOVER_SAVED_FEED,
|
||||||
id: TID.nextStr(),
|
id: TID.nextStr(),
|
||||||
@@ -172,9 +226,15 @@ export async function createAgentAndCreateAccount(
|
|||||||
id: TID.nextStr(),
|
id: TID.nextStr(),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
}).catch(e => {
|
||||||
if (getAge(birthDate) < 18) {
|
logger.info(
|
||||||
await agent.api.com.atproto.repo.putRecord({
|
`createAgentAndCreateAccount: failed to set initial feeds`,
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
getAge(birthDate) < 18 &&
|
||||||
|
networkRetry(3, () => {
|
||||||
|
return agent.com.atproto.repo.putRecord({
|
||||||
repo: account.did,
|
repo: account.did,
|
||||||
collection: 'chat.bsky.actor.declaration',
|
collection: 'chat.bsky.actor.declaration',
|
||||||
rkey: 'self',
|
rkey: 'self',
|
||||||
@@ -183,15 +243,53 @@ export async function createAgentAndCreateAccount(
|
|||||||
allowIncoming: 'none',
|
allowIncoming: 'none',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
logger.info(
|
||||||
|
`createAgentAndCreateAccount: failed to set chat declaration`,
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
).then(promises => {
|
||||||
|
const rejected = promises.filter(p => p.status === 'rejected')
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
logger.error(
|
||||||
|
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(e, {
|
|
||||||
message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
Promise.allSettled(
|
||||||
|
[
|
||||||
|
networkRetry(3, () => {
|
||||||
|
return agent.setPersonalDetails({
|
||||||
|
birthDate: birthDate.toISOString(),
|
||||||
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
networkRetry(3, () => {
|
||||||
|
return agent.upsertProfile(prev => {
|
||||||
|
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||||
|
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
logger.info(
|
||||||
|
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
).then(promises => {
|
||||||
|
const rejected = promises.filter(p => p.status === 'rejected')
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
logger.error(
|
||||||
|
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -203,7 +301,10 @@ export async function createAgentAndCreateAccount(
|
|||||||
|
|
||||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||||
|
|
||||||
return agent.prepare(gates, moderation, onSessionChange)
|
return agent.prepare({
|
||||||
|
resolvers: [gates, moderation, aa],
|
||||||
|
onSessionChange,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
||||||
@@ -306,18 +407,20 @@ class BskyAppAgent extends BskyAgent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepare(
|
async prepare({
|
||||||
|
resolvers,
|
||||||
|
onSessionChange,
|
||||||
|
}: {
|
||||||
// Not awaited in the calling code so we can delay blocking on them.
|
// Not awaited in the calling code so we can delay blocking on them.
|
||||||
gates: Promise<void>,
|
resolvers: Promise<unknown>[]
|
||||||
moderation: Promise<void>,
|
|
||||||
onSessionChange: (
|
onSessionChange: (
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
did: string,
|
did: string,
|
||||||
event: AtpSessionEvent,
|
event: AtpSessionEvent,
|
||||||
) => void,
|
) => void
|
||||||
) {
|
}) {
|
||||||
// There's nothing else left to do, so block on them here.
|
// There's nothing else left to do, so block on them here.
|
||||||
await Promise.all([gates, moderation])
|
await Promise.all(resolvers)
|
||||||
|
|
||||||
// Now the agent is ready.
|
// Now the agent is ready.
|
||||||
const account = agentToSessionAccountOrThrow(this)
|
const account = agentToSessionAccountOrThrow(this)
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
type SessionApiContext,
|
type SessionApiContext,
|
||||||
type SessionStateContext,
|
type SessionStateContext,
|
||||||
} from '#/state/session/types'
|
} from '#/state/session/types'
|
||||||
|
import {useOnboardingDispatch} from '#/state/shell/onboarding'
|
||||||
|
import {
|
||||||
|
clearAgeAssuranceData,
|
||||||
|
clearAgeAssuranceDataForDid,
|
||||||
|
} from '#/ageAssurance/data'
|
||||||
|
|
||||||
const StateContext = React.createContext<SessionStateContext>({
|
const StateContext = React.createContext<SessionStateContext>({
|
||||||
accounts: [],
|
accounts: [],
|
||||||
@@ -91,6 +96,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const cancelPendingTask = useOneTaskAtATime()
|
const cancelPendingTask = useOneTaskAtATime()
|
||||||
const [store] = React.useState(() => new SessionStore())
|
const [store] = React.useState(() => new SessionStore())
|
||||||
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
||||||
|
const onboardingDispatch = useOnboardingDispatch()
|
||||||
|
|
||||||
const onAgentSessionChange = React.useCallback(
|
const onAgentSessionChange = React.useCallback(
|
||||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||||
@@ -166,6 +172,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
logContext => {
|
logContext => {
|
||||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
|
const prevState = store.getState()
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'logged-out-current-account',
|
type: 'logged-out-current-account',
|
||||||
})
|
})
|
||||||
@@ -175,8 +182,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{statsig: true},
|
{statsig: true},
|
||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||||
|
if (prevState.currentAgentState.did) {
|
||||||
|
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
|
||||||
|
}
|
||||||
|
// reset onboarding flow on logout
|
||||||
|
onboardingDispatch({type: 'skip'})
|
||||||
},
|
},
|
||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask, onboardingDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
const logoutEveryAccount = React.useCallback<
|
const logoutEveryAccount = React.useCallback<
|
||||||
@@ -194,12 +206,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
{statsig: true},
|
{statsig: true},
|
||||||
)
|
)
|
||||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||||
|
clearAgeAssuranceData()
|
||||||
|
// reset onboarding flow on logout
|
||||||
|
onboardingDispatch({type: 'skip'})
|
||||||
},
|
},
|
||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask, onboardingDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||||
async storedAccount => {
|
async (storedAccount, isSwitchingAccounts = false) => {
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:start',
|
type: 'method:start',
|
||||||
method: 'resumeSession',
|
method: 'resumeSession',
|
||||||
@@ -220,8 +235,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||||
|
if (isSwitchingAccounts) {
|
||||||
|
// reset onboarding flow on switch account
|
||||||
|
onboardingDispatch({type: 'skip'})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[store, onAgentSessionChange, cancelPendingTask],
|
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
const partialRefreshSession = React.useCallback<
|
const partialRefreshSession = React.useCallback<
|
||||||
@@ -254,6 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
accountDid: account.did,
|
accountDid: account.did,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
||||||
|
clearAgeAssuranceDataForDid({did: account.did})
|
||||||
},
|
},
|
||||||
[store, cancelPendingTask],
|
[store, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export type SessionApiContext = {
|
|||||||
logoutEveryAccount: (
|
logoutEveryAccount: (
|
||||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||||
) => void
|
) => void
|
||||||
resumeSession: (account: SessionAccount) => Promise<void>
|
resumeSession: (
|
||||||
|
account: SessionAccount,
|
||||||
|
isSwitchingAccounts?: boolean,
|
||||||
|
) => Promise<void>
|
||||||
removeAccount: (account: SessionAccount) => void
|
removeAccount: (account: SessionAccount) => void
|
||||||
/**
|
/**
|
||||||
* Calls `getSession` and updates select fields on the current account and
|
* Calls `getSession` and updates select fields on the current account and
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface ComposerOpts {
|
|||||||
text?: string
|
text?: string
|
||||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||||
videoUri?: {uri: string; width: number; height: number}
|
videoUri?: {uri: string; width: number; height: number}
|
||||||
|
openGallery?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateContext = ComposerOpts | undefined
|
type StateContext = ComposerOpts | undefined
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {Provider as ColorModeProvider} from './color-mode'
|
|||||||
import {Provider as DrawerOpenProvider} from './drawer-open'
|
import {Provider as DrawerOpenProvider} from './drawer-open'
|
||||||
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
||||||
import {Provider as MinimalModeProvider} from './minimal-mode'
|
import {Provider as MinimalModeProvider} from './minimal-mode'
|
||||||
import {Provider as OnboardingProvider} from './onboarding'
|
|
||||||
import {Provider as ShellLayoutProvder} from './shell-layout'
|
import {Provider as ShellLayoutProvder} from './shell-layout'
|
||||||
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
|
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
|
||||||
|
|
||||||
@@ -23,9 +22,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
<DrawerSwipableProvider>
|
<DrawerSwipableProvider>
|
||||||
<MinimalModeProvider>
|
<MinimalModeProvider>
|
||||||
<ColorModeProvider>
|
<ColorModeProvider>
|
||||||
<OnboardingProvider>
|
|
||||||
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
||||||
</OnboardingProvider>
|
|
||||||
</ColorModeProvider>
|
</ColorModeProvider>
|
||||||
</MinimalModeProvider>
|
</MinimalModeProvider>
|
||||||
</DrawerSwipableProvider>
|
</DrawerSwipableProvider>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export function useUnstablePostSource(key: string) {
|
|||||||
*/
|
*/
|
||||||
export function buildPostSourceKey(key: string, handle: string) {
|
export function buildPostSourceKey(key: string, handle: string) {
|
||||||
const urip = new AtUri(key)
|
const urip = new AtUri(key)
|
||||||
|
// @ts-expect-error TODO new-sdk-migration
|
||||||
urip.host = handle
|
urip.host = handle
|
||||||
return urip.toString()
|
return urip.toString()
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -1,4 +1,5 @@
|
|||||||
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||||
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device data that's specific to the device and does not vary based account
|
* Device data that's specific to the device and does not vary based account
|
||||||
@@ -25,13 +26,21 @@ export type Device = {
|
|||||||
regionCode: string | undefined
|
regionCode: string | undefined
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw response from the geolocation service, if available. We
|
||||||
|
* cache this here and update it lazily on session start.
|
||||||
|
*/
|
||||||
|
geolocationServiceResponse?: Geolocation
|
||||||
/**
|
/**
|
||||||
* The GPS-based geolocation, if the user has granted permission.
|
* The GPS-based geolocation, if the user has granted permission.
|
||||||
*/
|
*/
|
||||||
deviceGeolocation?: {
|
deviceGeolocation?: Geolocation
|
||||||
countryCode: string | undefined
|
/**
|
||||||
regionCode: string | undefined
|
* The merged geolocation, combining `geolocationServiceResponse` and
|
||||||
}
|
* `deviceGeolocation`, with preference to `deviceGeolocation`.
|
||||||
|
*/
|
||||||
|
mergedGeolocation?: Geolocation
|
||||||
|
|
||||||
trendingBetaEnabled: boolean
|
trendingBetaEnabled: boolean
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
@@ -49,4 +58,10 @@ export type Device = {
|
|||||||
export type Account = {
|
export type Account = {
|
||||||
searchTermHistory?: string[]
|
searchTermHistory?: string[]
|
||||||
searchAccountHistory?: string[]
|
searchAccountHistory?: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ISO date string of when this account's birthdate was last updated on
|
||||||
|
* this device.
|
||||||
|
*/
|
||||||
|
birthdateLastUpdatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export const ComposePost = ({
|
|||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
videoUri: initVideoUri,
|
videoUri: initVideoUri,
|
||||||
|
openGallery,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
}: Props & {
|
}: Props & {
|
||||||
cancelRef?: React.RefObject<CancelRef | null>
|
cancelRef?: React.RefObject<CancelRef | null>
|
||||||
@@ -721,6 +722,7 @@ export const ComposePost = ({
|
|||||||
}}
|
}}
|
||||||
currentLanguages={currentLanguages}
|
currentLanguages={currentLanguages}
|
||||||
onSelectLanguage={onSelectLanguage}
|
onSelectLanguage={onSelectLanguage}
|
||||||
|
openGallery={openGallery}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1334,6 +1336,7 @@ function ComposerFooter({
|
|||||||
onAddPost,
|
onAddPost,
|
||||||
currentLanguages,
|
currentLanguages,
|
||||||
onSelectLanguage,
|
onSelectLanguage,
|
||||||
|
openGallery,
|
||||||
}: {
|
}: {
|
||||||
post: PostDraft
|
post: PostDraft
|
||||||
dispatch: (action: PostAction) => void
|
dispatch: (action: PostAction) => void
|
||||||
@@ -1344,6 +1347,7 @@ function ComposerFooter({
|
|||||||
onAddPost: () => void
|
onAddPost: () => void
|
||||||
currentLanguages: string[]
|
currentLanguages: string[]
|
||||||
onSelectLanguage?: (language: string) => void
|
onSelectLanguage?: (language: string) => void
|
||||||
|
openGallery?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -1463,6 +1467,7 @@ function ComposerFooter({
|
|||||||
allowedAssetTypes={selectedAssetsType}
|
allowedAssetTypes={selectedAssetsType}
|
||||||
selectedAssetsCount={selectedAssetsCount}
|
selectedAssetsCount={selectedAssetsCount}
|
||||||
onSelectAssets={onSelectAssets}
|
onSelectAssets={onSelectAssets}
|
||||||
|
autoOpen={openGallery}
|
||||||
/>
|
/>
|
||||||
<OpenCameraBtn
|
<OpenCameraBtn
|
||||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback, useEffect, useRef} from 'react'
|
||||||
import {Keyboard} from 'react-native'
|
import {Keyboard} from 'react-native'
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {msg, plural} from '@lingui/macro'
|
import {msg, plural} from '@lingui/macro'
|
||||||
@@ -31,6 +31,10 @@ export type SelectMediaButtonProps = {
|
|||||||
assets: ImagePickerAsset[]
|
assets: ImagePickerAsset[]
|
||||||
errors: string[]
|
errors: string[]
|
||||||
}) => void
|
}) => void
|
||||||
|
/**
|
||||||
|
* If true, automatically open the media picker when the component mounts.
|
||||||
|
*/
|
||||||
|
autoOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,12 +362,14 @@ export function SelectMediaButton({
|
|||||||
allowedAssetTypes,
|
allowedAssetTypes,
|
||||||
selectedAssetsCount,
|
selectedAssetsCount,
|
||||||
onSelectAssets,
|
onSelectAssets,
|
||||||
|
autoOpen,
|
||||||
}: SelectMediaButtonProps) {
|
}: SelectMediaButtonProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const hasAutoOpened = useRef(false)
|
||||||
|
|
||||||
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
|
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
|
||||||
|
|
||||||
@@ -460,6 +466,13 @@ export function SelectMediaButton({
|
|||||||
selectionCountRemaining,
|
selectionCountRemaining,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoOpen && !hasAutoOpened.current && !disabled) {
|
||||||
|
hasAutoOpened.current = true
|
||||||
|
onPressSelectMedia()
|
||||||
|
}
|
||||||
|
}, [autoOpen, disabled, onPressSelectMedia])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
testID="openMediaBtn"
|
testID="openMediaBtn"
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React, {useCallback, useState} from 'react'
|
||||||
|
import {Keyboard, Pressable, View} from 'react-native'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
|
import {
|
||||||
|
useCameraPermission,
|
||||||
|
usePhotoLibraryPermission,
|
||||||
|
useVideoLibraryPermission,
|
||||||
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||||
|
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
|
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
|
||||||
|
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||||
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function ComposerPrompt() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {openComposer} = useOpenComposer()
|
||||||
|
const profile = useCurrentAccountProfile()
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
|
||||||
|
const onPress = React.useCallback(() => {
|
||||||
|
logger.metric('composerPrompt:press', {})
|
||||||
|
openComposer({})
|
||||||
|
}, [openComposer])
|
||||||
|
|
||||||
|
const onPressImage = useCallback(async () => {
|
||||||
|
logger.metric('composerPrompt:gallery:press', {})
|
||||||
|
|
||||||
|
// On web, open the composer with the gallery picker auto-opening
|
||||||
|
if (!isNative) {
|
||||||
|
openComposer({openGallery: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [photoAccess, videoAccess] = await Promise.all([
|
||||||
|
requestPhotoAccessIfNeeded(),
|
||||||
|
requestVideoAccessIfNeeded(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!photoAccess && !videoAccess) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Keyboard.isVisible()) {
|
||||||
|
Keyboard.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionCountRemaining = MAX_IMAGES
|
||||||
|
const {assets, canceled} = await sheetWrapper(
|
||||||
|
openUnifiedPicker({selectionCountRemaining}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (canceled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assets.length > 0) {
|
||||||
|
const imageUris = assets
|
||||||
|
.filter(asset => asset.mimeType?.startsWith('image/'))
|
||||||
|
.slice(0, MAX_IMAGES)
|
||||||
|
.map(asset => ({
|
||||||
|
uri: asset.uri,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (imageUris.length > 0) {
|
||||||
|
openComposer({imageUris})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!String(err).toLowerCase().includes('cancel')) {
|
||||||
|
logger.warn('Error opening image picker', {error: err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
openComposer,
|
||||||
|
requestPhotoAccessIfNeeded,
|
||||||
|
requestVideoAccessIfNeeded,
|
||||||
|
sheetWrapper,
|
||||||
|
])
|
||||||
|
|
||||||
|
const onPressCamera = useCallback(async () => {
|
||||||
|
logger.metric('composerPrompt:camera:press', {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNative && Keyboard.isVisible()) {
|
||||||
|
Keyboard.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await openCamera({
|
||||||
|
mediaTypes: 'images',
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageUris = [
|
||||||
|
{
|
||||||
|
uri: image.path,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
openComposer({
|
||||||
|
imageUris: isNative ? imageUris : undefined,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!String(err).toLowerCase().includes('cancel')) {
|
||||||
|
logger.warn('Error opening camera', {error: err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [openComposer, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
android_ripple={null}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Compose new post`)}
|
||||||
|
accessibilityHint={_(msg`Opens the post composer`)}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={() => setHover(false)}
|
||||||
|
style={({pressed}) => [
|
||||||
|
a.relative,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_start,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
{
|
||||||
|
paddingLeft: 18,
|
||||||
|
paddingRight: 15,
|
||||||
|
},
|
||||||
|
a.py_md,
|
||||||
|
native({
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
}),
|
||||||
|
web({
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 'none',
|
||||||
|
}),
|
||||||
|
pressed && web({outline: 'none'}),
|
||||||
|
]}>
|
||||||
|
<SubtleHover hover={hover} />
|
||||||
|
<UserAvatar
|
||||||
|
avatar={profile.avatar}
|
||||||
|
size={40}
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
/>
|
||||||
|
<View style={[a.flex_1, a.ml_md, a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.px_md,
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 40,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
t.atoms.text_contrast_low,
|
||||||
|
a.text_md,
|
||||||
|
a.pl_xs,
|
||||||
|
{
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{_(msg`What's up?`)}
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.gap_md, a.mr_xs]}>
|
||||||
|
{isNative && (
|
||||||
|
<Button
|
||||||
|
onPress={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onPressCamera()
|
||||||
|
}}
|
||||||
|
label={_(msg`Open camera`)}
|
||||||
|
accessibilityHint={_(msg`Opens device camera`)}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round">
|
||||||
|
{({hovered}) => (
|
||||||
|
<CameraIcon
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
color: hovered
|
||||||
|
? t.palette.primary_500
|
||||||
|
: t.palette.contrast_300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onPress={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onPressImage()
|
||||||
|
}}
|
||||||
|
label={_(msg`Add image`)}
|
||||||
|
accessibilityHint={_(msg`Opens image picker`)}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round">
|
||||||
|
{({hovered}) => (
|
||||||
|
<ImageIcon
|
||||||
|
size="md"
|
||||||
|
style={{
|
||||||
|
color: hovered
|
||||||
|
? t.palette.primary_500
|
||||||
|
: t.palette.contrast_300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,10 +31,11 @@ import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
|
|||||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
|
import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
|
||||||
import {listenPostCreated} from '#/state/events'
|
import {listenPostCreated} from '#/state/events'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||||
@@ -69,6 +70,7 @@ import {
|
|||||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||||
|
import {ComposerPrompt} from '../feeds/ComposerPrompt'
|
||||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||||
@@ -149,6 +151,10 @@ type FeedRow =
|
|||||||
type: 'ageAssuranceBanner'
|
type: 'ageAssuranceBanner'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'composerPrompt'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getItemsForFeedback(feedRow: FeedRow): {
|
export function getItemsForFeedback(feedRow: FeedRow): {
|
||||||
item: FeedPostSliceItem
|
item: FeedPostSliceItem
|
||||||
@@ -224,6 +230,7 @@ let PostFeed = ({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
|
const gate = useGate()
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const feedFeedback = useFeedFeedbackContext()
|
const feedFeedback = useFeedFeedbackContext()
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
@@ -363,6 +370,11 @@ let PostFeed = ({
|
|||||||
*/
|
*/
|
||||||
const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
|
const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed)
|
||||||
|
|
||||||
|
const blockedOrMutedAuthors = usePostAuthorShadowFilter(
|
||||||
|
// author feeds have their own handling
|
||||||
|
feed.startsWith('author|') ? undefined : data?.pages,
|
||||||
|
)
|
||||||
|
|
||||||
const feedItems: FeedRow[] = useMemo(() => {
|
const feedItems: FeedRow[] = useMemo(() => {
|
||||||
// wraps a slice item, and replaces it with a showLessFollowup item
|
// wraps a slice item, and replaces it with a showLessFollowup item
|
||||||
// if the user has pressed show less on it
|
// if the user has pressed show less on it
|
||||||
@@ -423,7 +435,11 @@ let PostFeed = ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
item => item.uri === slice.feedPostUri,
|
item => item.uri === slice.feedPostUri,
|
||||||
)
|
)
|
||||||
if (item && AppBskyEmbedVideo.isView(item.post.embed)) {
|
if (
|
||||||
|
item &&
|
||||||
|
AppBskyEmbedVideo.isView(item.post.embed) &&
|
||||||
|
!blockedOrMutedAuthors.includes(item.post.author.did)
|
||||||
|
) {
|
||||||
videos.push({
|
videos.push({
|
||||||
item,
|
item,
|
||||||
feedContext: slice.feedContext,
|
feedContext: slice.feedContext,
|
||||||
@@ -501,6 +517,18 @@ let PostFeed = ({
|
|||||||
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Show composer prompt for Discover and Following feeds
|
||||||
|
if (
|
||||||
|
hasSession &&
|
||||||
|
(feedUriOrActorDid === DISCOVER_FEED_URI ||
|
||||||
|
feed === 'following') &&
|
||||||
|
gate('show_composer_prompt')
|
||||||
|
) {
|
||||||
|
arr.push({
|
||||||
|
type: 'composerPrompt',
|
||||||
|
key: 'composerPrompt-' + sliceIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else if (sliceIndex === 15) {
|
} else if (sliceIndex === 15) {
|
||||||
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
|
if (areVideoFeedsEnabled && !trendingVideoDisabled) {
|
||||||
arr.push({
|
arr.push({
|
||||||
@@ -514,6 +542,16 @@ let PostFeed = ({
|
|||||||
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
|
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (feedKind === 'following') {
|
||||||
|
if (sliceIndex === 0) {
|
||||||
|
// Show composer prompt for Following feed
|
||||||
|
if (hasSession && gate('show_composer_prompt')) {
|
||||||
|
arr.push({
|
||||||
|
type: 'composerPrompt',
|
||||||
|
key: 'composerPrompt-' + sliceIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (feedKind === 'profile') {
|
} else if (feedKind === 'profile') {
|
||||||
if (sliceIndex === 5) {
|
if (sliceIndex === 5) {
|
||||||
arr.push({
|
arr.push({
|
||||||
@@ -541,6 +579,12 @@ let PostFeed = ({
|
|||||||
key:
|
key:
|
||||||
'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt,
|
'sliceFallbackMarker-' + sliceIndex + '-' + lastFetchedAt,
|
||||||
})
|
})
|
||||||
|
} else if (
|
||||||
|
slice.items.some(item =>
|
||||||
|
blockedOrMutedAuthors.includes(item.post.author.did),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// skip
|
||||||
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
|
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||||
const beforeLast = slice.items.length - 2
|
const beforeLast = slice.items.length - 2
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
@@ -622,6 +666,7 @@ let PostFeed = ({
|
|||||||
isEmpty,
|
isEmpty,
|
||||||
lastFetchedAt,
|
lastFetchedAt,
|
||||||
data,
|
data,
|
||||||
|
feed,
|
||||||
feedType,
|
feedType,
|
||||||
feedUriOrActorDid,
|
feedUriOrActorDid,
|
||||||
feedTab,
|
feedTab,
|
||||||
@@ -636,6 +681,8 @@ let PostFeed = ({
|
|||||||
hasPressedShowLessUris,
|
hasPressedShowLessUris,
|
||||||
ageAssuranceBannerState,
|
ageAssuranceBannerState,
|
||||||
isCurrentFeedAtStartupSelected,
|
isCurrentFeedAtStartupSelected,
|
||||||
|
gate,
|
||||||
|
blockedOrMutedAuthors,
|
||||||
])
|
])
|
||||||
|
|
||||||
// events
|
// events
|
||||||
@@ -726,6 +773,8 @@ let PostFeed = ({
|
|||||||
return <AgeAssuranceDismissibleFeedBanner />
|
return <AgeAssuranceDismissibleFeedBanner />
|
||||||
} else if (row.type === 'interstitialTrending') {
|
} else if (row.type === 'interstitialTrending') {
|
||||||
return <TrendingInterstitial />
|
return <TrendingInterstitial />
|
||||||
|
} else if (row.type === 'composerPrompt') {
|
||||||
|
return <ComposerPrompt />
|
||||||
} else if (row.type === 'interstitialTrendingVideos') {
|
} else if (row.type === 'interstitialTrendingVideos') {
|
||||||
return <TrendingVideosInterstitial />
|
return <TrendingVideosInterstitial />
|
||||||
} else if (row.type === 'fallbackMarker') {
|
} else if (row.type === 'fallbackMarker') {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {useSession} from '#/state/session'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
|
||||||
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
|
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||||
import {Link, WebOnlyInlineLinkText} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {FeedNameText} from '../util/FeedInfoText'
|
import {FeedNameText} from '../util/FeedInfoText'
|
||||||
@@ -82,32 +82,21 @@ export function PostFeedReason({
|
|||||||
width={13}
|
width={13}
|
||||||
height={13}
|
height={13}
|
||||||
/>
|
/>
|
||||||
<Text
|
|
||||||
style={[t.atoms.text_contrast_medium, a.font_medium, a.leading_snug]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
{isOwner ? (
|
|
||||||
<Trans>Reposted by you</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
Reposted by{' '}
|
|
||||||
<ProfileHoverCard did={reason.by.did}>
|
<ProfileHoverCard did={reason.by.did}>
|
||||||
<WebOnlyInlineLinkText
|
<Text
|
||||||
label={reposter}
|
|
||||||
numberOfLines={1}
|
|
||||||
to={makeProfileLink(reason.by)}
|
|
||||||
onPress={onOpenReposter}
|
|
||||||
style={[
|
style={[
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
a.font_medium,
|
a.font_medium,
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
]}
|
]}
|
||||||
emoji>
|
numberOfLines={1}>
|
||||||
{reposter}
|
{isOwner ? (
|
||||||
</WebOnlyInlineLinkText>
|
<Trans>Reposted by you</Trans>
|
||||||
</ProfileHoverCard>
|
) : (
|
||||||
</Trans>
|
<Trans>Reposted by {reposter}</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</ProfileHoverCard>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import {View} from 'react-native'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {Sentry} from '#/logger/sentry/lib'
|
|
||||||
import {useSetThemePrefs} from '#/state/shell'
|
import {useSetThemePrefs} from '#/state/shell'
|
||||||
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
||||||
import {atoms as a, ThemeProvider} from '#/alf'
|
import {atoms as a, ThemeProvider} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {
|
||||||
|
useDeviceGeolocationApi,
|
||||||
|
useRequestDeviceGeolocation,
|
||||||
|
} from '#/geolocation'
|
||||||
import {Admonitions} from './Admonitions'
|
import {Admonitions} from './Admonitions'
|
||||||
import {Breakpoints} from './Breakpoints'
|
import {Breakpoints} from './Breakpoints'
|
||||||
import {Buttons} from './Buttons'
|
import {Buttons} from './Buttons'
|
||||||
@@ -45,6 +48,8 @@ function StorybookInner() {
|
|||||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
const [showContainedList, setShowContainedList] = React.useState(false)
|
const [showContainedList, setShowContainedList] = React.useState(false)
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const requestDeviceGeolocation = useRequestDeviceGeolocation()
|
||||||
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -97,11 +102,17 @@ function StorybookInner() {
|
|||||||
<ButtonText>Open Shared Prefs Tester</ButtonText>
|
<ButtonText>Open Shared Prefs Tester</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="negative"
|
color="primary_subtle"
|
||||||
size="large"
|
size="large"
|
||||||
onPress={() => Sentry.nativeCrash()}
|
onPress={() =>
|
||||||
|
requestDeviceGeolocation().then(req => {
|
||||||
|
if (req.granted && req.location) {
|
||||||
|
setDeviceGeolocation(req.location)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
label="crash">
|
label="crash">
|
||||||
<ButtonText>Sentry Crash</ButtonText>
|
<ButtonText>Get GPS Location</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ThemeProvider theme="light">
|
<ThemeProvider theme="light">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function Composer({}: {winHeight: number}) {
|
|||||||
text={state?.text}
|
text={state?.text}
|
||||||
imageUris={state?.imageUris}
|
imageUris={state?.imageUris}
|
||||||
videoUri={state?.videoUri}
|
videoUri={state?.videoUri}
|
||||||
|
openGallery={state?.openGallery}
|
||||||
/>
|
/>
|
||||||
</TooltipSheetCompatProvider>
|
</TooltipSheetCompatProvider>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user