Compare commits
32 Commits
android-settle
...
tos
| Author | SHA1 | Date | |
|---|---|---|---|
| f4945d0005 | |||
| 5d09946849 | |||
| 3ba1e2de57 | |||
| 4dc6ba6c2f | |||
| 5b50adf7ab | |||
| 43ef87b852 | |||
| b8cfb2d0af | |||
| f978d9bff5 | |||
| 8dd4a73b78 | |||
| 3232f7ae48 | |||
| dc156c1b63 | |||
| ab5723a508 | |||
| 0f3ada65ad | |||
| c1b6ae8b6e | |||
| 0e53f4339b | |||
| df99b9092c | |||
| f524fa6904 | |||
| 59513bd011 | |||
| 4dc45f40ad | |||
| c877626db6 | |||
| e70fccc14f | |||
| 5db709cba6 | |||
| 7e75f40379 | |||
| f3c4441a3a | |||
| bc02b00af7 | |||
| 51011bd758 | |||
| 18b6d7cf6a | |||
| 6f53564b56 | |||
| 00a61ade61 | |||
| 2c359d3528 | |||
| c6a82c8e8a | |||
| b57eef7050 |
+46
-43
@@ -70,6 +70,7 @@ import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {Splash} from '#/Splash'
|
||||
@@ -137,49 +138,51 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
|
||||
+42
-39
@@ -57,6 +57,7 @@ import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
@@ -117,45 +118,47 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
<ToastContainer />
|
||||
</React.Fragment>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
findNodeHandle,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
|
||||
/**
|
||||
* Conditionally wraps children in a `FocusTrap` component based on whether
|
||||
* screen reader support is enabled. THIS SHOULD BE USED SPARINGLY, only when
|
||||
* no better option is available.
|
||||
*/
|
||||
export function FocusScope({children}: {children: ReactNode}) {
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
|
||||
return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children
|
||||
}
|
||||
|
||||
/**
|
||||
* `FocusTrap` is intended as a last-ditch effort to ensure that users keep
|
||||
* focus within a certain section of the app, like an overlay.
|
||||
*
|
||||
* It works by placing "guards" at the start and end of the active content.
|
||||
* Then when the user reaches either of those guards, it will announce that
|
||||
* they have reached the start or end of the content and tell them how to
|
||||
* remain within the active content section.
|
||||
*/
|
||||
function FocusTrap({children}: {children: ReactNode}) {
|
||||
const {_} = useLingui()
|
||||
const child = useRef<View>(null)
|
||||
|
||||
/*
|
||||
* Here we add a ref to the first child of this component. This currently
|
||||
* overrides any ref already on that first child, so we throw an error here
|
||||
* to prevent us from ever accidentally doing this.
|
||||
*/
|
||||
const decoratedChildren = useMemo(() => {
|
||||
return Children.toArray(children).map((node, i) => {
|
||||
if (i === 0 && isValidElement(node)) {
|
||||
const n = node as ReactElement<any>
|
||||
if (n.props.ref !== undefined) {
|
||||
throw new Error(
|
||||
'FocusScope needs to override the ref on its first child.',
|
||||
)
|
||||
}
|
||||
return cloneElement(n, {
|
||||
...n.props,
|
||||
ref: child,
|
||||
})
|
||||
}
|
||||
return node
|
||||
})
|
||||
}, [children])
|
||||
|
||||
const focusNode = useCallback((ref: View | null) => {
|
||||
if (!ref) return
|
||||
const node = findNodeHandle(ref)
|
||||
if (node) {
|
||||
AccessibilityInfo.setAccessibilityFocus(node)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
focusNode(child.current)
|
||||
}, 1e3)
|
||||
}, [focusNode])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel={_(
|
||||
msg`You've reached the start of the active content.`,
|
||||
)}
|
||||
accessibilityHint={_(
|
||||
msg`Please go back, or activate this element to return to the start of the active content.`,
|
||||
)}
|
||||
accessibilityActions={[{name: 'activate', label: 'activate'}]}
|
||||
onAccessibilityAction={event => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'activate': {
|
||||
focusNode(child.current)
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Noop />
|
||||
</Pressable>
|
||||
<View
|
||||
/**
|
||||
* This property traps focus effectively on iOS, but not on Android.
|
||||
*/
|
||||
accessibilityViewIsModal>
|
||||
{decoratedChildren}
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityLabel={_(
|
||||
msg`You've reached the end of the active content.`,
|
||||
)}
|
||||
accessibilityHint={_(
|
||||
msg`Please go back, or activate this element to return to the start of the active content.`,
|
||||
)}
|
||||
accessibilityActions={[{name: 'activate', label: 'activate'}]}
|
||||
onAccessibilityAction={event => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'activate': {
|
||||
focusNode(child.current)
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Noop />
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Noop() {
|
||||
return (
|
||||
<Text
|
||||
accessible={false}
|
||||
style={{
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
}}>
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {type ReactNode} from 'react'
|
||||
import {FocusScope as RadixFocusScope} from 'radix-ui/internal'
|
||||
|
||||
/*
|
||||
* The web version of the FocusScope component is a proper implementation, we
|
||||
* use this in Dialogs and such already. It's here as a convenient counterpart
|
||||
* to the hacky native solution.
|
||||
*/
|
||||
export function FocusScope({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<RadixFocusScope.FocusScope loop asChild trapped>
|
||||
{children}
|
||||
</RadixFocusScope.FocusScope>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function LockScroll() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
export const LockScroll = RemoveScrollBar
|
||||
@@ -0,0 +1,38 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Badge() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<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,
|
||||
},
|
||||
]}>
|
||||
<Logo fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_600,
|
||||
},
|
||||
]}>
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {type ReactNode} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {FocusScope} from '#/components/FocusScope'
|
||||
import {LockScroll} from '#/components/LockScroll'
|
||||
|
||||
const GUTTER = 24
|
||||
|
||||
export function Overlay({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const insets = useSafeAreaInsets()
|
||||
const frame = useSafeAreaFrame()
|
||||
|
||||
return (
|
||||
<>
|
||||
<LockScroll />
|
||||
|
||||
<View style={[a.fixed, a.inset_0, !reduceMotionEnabled && a.fade_in]}>
|
||||
{gtPhone ? (
|
||||
<View style={[a.absolute, a.inset_0, {opacity: 0.8}]}>
|
||||
<View
|
||||
style={[
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
{backgroundColor: t.palette.black},
|
||||
!reduceMotionEnabled && a.fade_in,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
transparentifyColor(t.atoms.bg.backgroundColor, 0),
|
||||
t.atoms.bg.backgroundColor,
|
||||
t.atoms.bg.backgroundColor,
|
||||
]}
|
||||
start={[0.5, 0]}
|
||||
end={[0.5, 1]}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={[
|
||||
a.z_10,
|
||||
gtPhone &&
|
||||
web({
|
||||
paddingHorizontal: GUTTER,
|
||||
paddingVertical: '10vh',
|
||||
}),
|
||||
]}
|
||||
contentContainerStyle={[a.align_center]}>
|
||||
{/**
|
||||
* This is needed to prevent centered dialogs from overflowing
|
||||
* above the screen, and provides a "natural" centering so that
|
||||
* stacked dialogs appear relatively aligned.
|
||||
*/}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.z_20,
|
||||
a.align_center,
|
||||
!gtPhone && [a.justify_end, {minHeight: frame.height}],
|
||||
isNative && [
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, a.p_2xl.padding),
|
||||
},
|
||||
],
|
||||
]}>
|
||||
{!gtPhone && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
{
|
||||
minHeight: Math.max(insets.top, a.p_2xl.padding),
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
transparentifyColor(t.atoms.bg.backgroundColor, 0),
|
||||
t.atoms.bg.backgroundColor,
|
||||
]}
|
||||
start={[0.5, 0]}
|
||||
end={[0.5, 1]}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FocusScope>
|
||||
<View
|
||||
accessible={isAndroid}
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={label}
|
||||
style={flatten([
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.p_2xl,
|
||||
t.atoms.bg,
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
gtPhone && [
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.shadow_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
web({
|
||||
maxWidth: 420,
|
||||
}),
|
||||
],
|
||||
])}>
|
||||
{children}
|
||||
</View>
|
||||
</FocusScope>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
|
||||
const portalGroup = createPortalGroup()
|
||||
|
||||
export const Provider = portalGroup.Provider
|
||||
export const Portal = portalGroup.Portal
|
||||
export const Outlet = portalGroup.Outlet
|
||||
@@ -0,0 +1,195 @@
|
||||
import {describe, test} from '@jest/globals'
|
||||
|
||||
import {
|
||||
computeCompletedState,
|
||||
syncCompletedState,
|
||||
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
|
||||
|
||||
jest.mock('../../../state/queries/nuxs')
|
||||
|
||||
describe('computeCompletedState', () => {
|
||||
test(`initial state`, () => {
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady: false,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: undefined,
|
||||
})
|
||||
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
|
||||
test(`nux loaded state`, () => {
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: undefined,
|
||||
})
|
||||
|
||||
expect(completed).toBe(false)
|
||||
})
|
||||
|
||||
test(`nux saving state`, () => {
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: true,
|
||||
completedForDevice: undefined,
|
||||
})
|
||||
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
|
||||
test(`nux is completed`, () => {
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: true,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: undefined,
|
||||
})
|
||||
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
|
||||
test(`initial state, but already completed for device`, () => {
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady: false,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: true,
|
||||
})
|
||||
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncCompletedState', () => {
|
||||
describe('!nuxIsReady', () => {
|
||||
test(`!completedForDevice, no-op`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: false,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: false,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test(`completedForDevice, no-op`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: false,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: true,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuxIsReady', () => {
|
||||
describe(`!nuxIsCompleted`, () => {
|
||||
describe(`!nuxIsOptimisticallyCompleted`, () => {
|
||||
test(`!completedForDevice, no-op`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: false,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test(`completedForDevice, syncs to server`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: true,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Catches the case where we already called `save` to sync device state
|
||||
* to server, thus `nuxIsOptimisticallyCompleted` is true.
|
||||
*/
|
||||
describe(`nuxIsOptimisticallyCompleted`, () => {
|
||||
test(`completedForDevice, no-op`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: false,
|
||||
nuxIsOptimisticallyCompleted: true,
|
||||
completedForDevice: true,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`nuxIsCompleted`, () => {
|
||||
test(`!completedForDevice, syncs to device`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: true,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: false,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test(`completedForDevice, no-op`, () => {
|
||||
const save = jest.fn()
|
||||
const setCompletedForDevice = jest.fn()
|
||||
syncCompletedState({
|
||||
nuxIsReady: true,
|
||||
nuxIsCompleted: true,
|
||||
nuxIsOptimisticallyCompleted: false,
|
||||
completedForDevice: true,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(setCompletedForDevice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import {ID} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||
|
||||
/**
|
||||
* The singulary active update ID. This is configured here to ensure that
|
||||
* the relationship is clear.
|
||||
*/
|
||||
export const ACTIVE_UPDATE_ID = ID
|
||||
@@ -0,0 +1,32 @@
|
||||
import {createContext, type ReactNode, useContext} from 'react'
|
||||
|
||||
import {Provider as PortalProvider} from '#/components/PolicyUpdateOverlay/Portal'
|
||||
import {
|
||||
type PolicyUpdateState,
|
||||
usePolicyUpdateState,
|
||||
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
|
||||
|
||||
const Context = createContext<PolicyUpdateState>({
|
||||
completed: true,
|
||||
complete: () => {},
|
||||
})
|
||||
|
||||
export function usePolicyUpdateStateContext() {
|
||||
const context = useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'usePolicyUpdateStateContext must be used within a PolicyUpdateProvider',
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function Provider({children}: {children?: ReactNode}) {
|
||||
const state = usePolicyUpdateState()
|
||||
|
||||
return (
|
||||
<PortalProvider>
|
||||
<Context.Provider value={state}>{children}</Context.Provider>
|
||||
</PortalProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context'
|
||||
import {Portal} from '#/components/PolicyUpdateOverlay/Portal'
|
||||
import {Content} from '#/components/PolicyUpdateOverlay/updates/202508'
|
||||
|
||||
export {Provider} from '#/components/PolicyUpdateOverlay/context'
|
||||
export {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context'
|
||||
export {Outlet} from '#/components/PolicyUpdateOverlay/Portal'
|
||||
|
||||
export function PolicyUpdateOverlay() {
|
||||
const state = usePolicyUpdateStateContext()
|
||||
|
||||
/*
|
||||
* See `window.clearNux` example in `/state/queries/nuxs` for a way to clear
|
||||
* NUX state for local testing and debugging.
|
||||
*/
|
||||
|
||||
if (state.completed) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<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},
|
||||
]}>
|
||||
<Content state={state} />
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.PolicyUpdate)
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Keep this file separate to avoid import issues.
|
||||
*/
|
||||
|
||||
import {Nux} from '#/state/queries/nuxs'
|
||||
|
||||
export const ID = Nux.PolicyUpdate202508
|
||||
@@ -0,0 +1,190 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Badge} from '#/components/PolicyUpdateOverlay/Badge'
|
||||
import {Overlay} from '#/components/PolicyUpdateOverlay/Overlay'
|
||||
import {type PolicyUpdateState} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Content({state}: {state: PolicyUpdateState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
state.complete()
|
||||
}, [state])
|
||||
|
||||
const linkStyle = [a.text_md]
|
||||
const links = {
|
||||
terms: {
|
||||
overridePresentation: false,
|
||||
to: `https://bsky.social/about/support`,
|
||||
label: _(msg`Terms of Service`),
|
||||
},
|
||||
privacy: {
|
||||
overridePresentation: false,
|
||||
to: `https://bsky.social/about/support`,
|
||||
label: _(msg`Privacy Policy`),
|
||||
},
|
||||
copyright: {
|
||||
overridePresentation: false,
|
||||
to: `https://bsky.social/about/support`,
|
||||
label: _(msg`Copyright Policy`),
|
||||
},
|
||||
guidelines: {
|
||||
overridePresentation: false,
|
||||
to: `https://bsky.social/about/support`,
|
||||
label: _(msg`Community Guidelines`),
|
||||
},
|
||||
blog: {
|
||||
overridePresentation: false,
|
||||
to: `https://bsky.social/about/support`,
|
||||
label: _(msg`Our blog post`),
|
||||
},
|
||||
}
|
||||
const linkButtonStyles = {
|
||||
overridePresentation: false,
|
||||
color: 'secondary',
|
||||
size: 'small',
|
||||
} as const
|
||||
|
||||
const label = isAndroid
|
||||
? _(
|
||||
msg`We’re updating our Terms of Service, Privacy Policy, and Copyright Policy, effective September 12th, 2025. We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 13th, 2025. Learn more about these changes and how to share your thoughts with us by reading our blog post.`,
|
||||
)
|
||||
: _(msg`We're updating our policies`)
|
||||
|
||||
return (
|
||||
<Overlay label={label}>
|
||||
<View style={[a.align_start, a.gap_xl]}>
|
||||
<Badge />
|
||||
|
||||
{screenReaderEnabled ? (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Hey there 👋</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
We’re updating our Terms of Service, Privacy Policy, and
|
||||
Copyright Policy, effective September 12th, 2025.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
We're also updating our Community Guidelines, and we want your
|
||||
input! These new guidelines will take effect on October 13th,
|
||||
2025.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
Learn more about these changes and how to share your thoughts
|
||||
with us by reading our blog post.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Link {...links.terms} {...linkButtonStyles}>
|
||||
<ButtonText>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
<Link {...links.privacy} {...linkButtonStyles}>
|
||||
<ButtonText>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
<Link {...links.copyright} {...linkButtonStyles}>
|
||||
<ButtonText>
|
||||
<Trans>Copyright Policy</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
<Link {...links.blog} {...linkButtonStyles}>
|
||||
<ButtonText>
|
||||
<Trans>Read our blog post</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Hey there 👋</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
We’re updating our{' '}
|
||||
<InlineLinkText {...links.terms} style={linkStyle}>
|
||||
Terms of Service
|
||||
</InlineLinkText>
|
||||
,{' '}
|
||||
<InlineLinkText {...links.privacy} style={linkStyle}>
|
||||
Privacy Policy
|
||||
</InlineLinkText>
|
||||
, and{' '}
|
||||
<InlineLinkText {...links.copyright} style={linkStyle}>
|
||||
Copyright Policy
|
||||
</InlineLinkText>
|
||||
, effective September 12th, 2025.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
We're also updating our{' '}
|
||||
<InlineLinkText {...links.guidelines} style={linkStyle}>
|
||||
Community Guidelines
|
||||
</InlineLinkText>
|
||||
, and we want your input! These new guidelines will take effect
|
||||
on October 13th, 2025.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, a.text_md]}>
|
||||
<Trans>
|
||||
Learn more about these changes and how to share your thoughts
|
||||
with us by{' '}
|
||||
<InlineLinkText {...links.blog} style={linkStyle}>
|
||||
reading our blog post.
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.w_full, a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Continue`)}
|
||||
accessibilityHint={_(
|
||||
msg`Tap to acknowledge that you understand and agree to these updates and continue using Bluesky`,
|
||||
)}
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={handleClose}>
|
||||
<ButtonText>
|
||||
<Trans>Continue</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
By clicking "Continue" you acknowledge that you understand and
|
||||
agree to these updates.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config'
|
||||
import {logger} from '#/components/PolicyUpdateOverlay/logger'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
export type PolicyUpdateState = {
|
||||
completed: boolean
|
||||
complete: () => void
|
||||
}
|
||||
|
||||
export function usePolicyUpdateState() {
|
||||
const nux = useNux(ACTIVE_UPDATE_ID)
|
||||
const {mutate: save, variables} = useSaveNux()
|
||||
const deviceStorage = useStorage(device, [ACTIVE_UPDATE_ID])
|
||||
const debugOverride =
|
||||
!!useStorage(device, ['policyUpdateDebugOverride'])[0] && IS_DEV
|
||||
return useMemo(() => {
|
||||
const nuxIsReady = nux.status === 'ready'
|
||||
const nuxIsCompleted = nux.nux?.completed === true
|
||||
const nuxIsOptimisticallyCompleted = !!variables?.completed
|
||||
const [completedForDevice, setCompletedForDevice] = deviceStorage
|
||||
|
||||
const completed = computeCompletedState({
|
||||
nuxIsReady,
|
||||
nuxIsCompleted,
|
||||
nuxIsOptimisticallyCompleted,
|
||||
completedForDevice,
|
||||
})
|
||||
|
||||
logger.debug(`state`, {
|
||||
completed,
|
||||
nux,
|
||||
completedForDevice,
|
||||
})
|
||||
|
||||
if (!debugOverride) {
|
||||
syncCompletedState({
|
||||
nuxIsReady,
|
||||
nuxIsCompleted,
|
||||
nuxIsOptimisticallyCompleted,
|
||||
completedForDevice,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
completed,
|
||||
complete() {
|
||||
logger.debug(`user completed`)
|
||||
save({
|
||||
id: ACTIVE_UPDATE_ID,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
setCompletedForDevice(true)
|
||||
},
|
||||
}
|
||||
}, [nux, save, variables, deviceStorage, debugOverride])
|
||||
}
|
||||
|
||||
export function computeCompletedState({
|
||||
nuxIsReady,
|
||||
nuxIsCompleted,
|
||||
nuxIsOptimisticallyCompleted,
|
||||
completedForDevice,
|
||||
}: {
|
||||
nuxIsReady: boolean
|
||||
nuxIsCompleted: boolean
|
||||
nuxIsOptimisticallyCompleted: boolean
|
||||
completedForDevice: boolean | undefined
|
||||
}): boolean {
|
||||
/**
|
||||
* Assume completed to prevent flash
|
||||
*/
|
||||
let completed = true
|
||||
|
||||
/**
|
||||
* Prefer server state, if available
|
||||
*/
|
||||
if (nuxIsReady) {
|
||||
completed = nuxIsCompleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Override with optimistic state or device state
|
||||
*/
|
||||
if (nuxIsOptimisticallyCompleted || !!completedForDevice) {
|
||||
completed = true
|
||||
}
|
||||
|
||||
return completed
|
||||
}
|
||||
|
||||
export function syncCompletedState({
|
||||
nuxIsReady,
|
||||
nuxIsCompleted,
|
||||
nuxIsOptimisticallyCompleted,
|
||||
completedForDevice,
|
||||
save,
|
||||
setCompletedForDevice,
|
||||
}: {
|
||||
nuxIsReady: boolean
|
||||
nuxIsCompleted: boolean
|
||||
nuxIsOptimisticallyCompleted: boolean
|
||||
completedForDevice: boolean | undefined
|
||||
save: ReturnType<typeof useSaveNux>['mutate']
|
||||
setCompletedForDevice: (value: boolean) => void
|
||||
}) {
|
||||
/*
|
||||
* Sync device state to server state for this account
|
||||
*/
|
||||
if (
|
||||
nuxIsReady &&
|
||||
!nuxIsCompleted &&
|
||||
!nuxIsOptimisticallyCompleted &&
|
||||
!!completedForDevice
|
||||
) {
|
||||
logger.debug(`syncing device state to server state`)
|
||||
save({
|
||||
id: ACTIVE_UPDATE_ID,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
} else if (nuxIsReady && nuxIsCompleted && !completedForDevice) {
|
||||
logger.debug(`syncing server state to device state`)
|
||||
/*
|
||||
* Sync server state to device state
|
||||
*/
|
||||
setCompletedForDevice(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config'
|
||||
import {logger} from '#/components/PolicyUpdateOverlay/logger'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
/*
|
||||
* Marks the active policy update as completed in device storage.
|
||||
* `usePolicyUpdateState` will react to this and replicate this status in the
|
||||
* server NUX state for this account.
|
||||
*/
|
||||
export function usePreemptivelyCompleteActivePolicyUpdate() {
|
||||
const [_completedForDevice, setCompletedForDevice] = useStorage(device, [
|
||||
ACTIVE_UPDATE_ID,
|
||||
])
|
||||
|
||||
return useCallback(() => {
|
||||
logger.debug(`preemptively completing active policy update`)
|
||||
setCompletedForDevice(true)
|
||||
}, [setCompletedForDevice])
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export enum LogContext {
|
||||
FeedFeedback = 'feed-feedback',
|
||||
PostSource = 'post-source',
|
||||
AgeAssurance = 'age-assurance',
|
||||
PolicyUpdate = 'policy-update',
|
||||
|
||||
/**
|
||||
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
|
||||
|
||||
@@ -25,6 +25,7 @@ import {clearStorage} from '#/state/persisted'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
@@ -35,6 +36,7 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
||||
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
|
||||
import {AvatarStackWithFetch} from '#/components/AvatarStack'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
|
||||
@@ -58,6 +60,7 @@ import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/W
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useFullVerificationState} from '#/components/verification'
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
VerificationCheckButton,
|
||||
} from '#/components/verification/VerificationCheckButton'
|
||||
import {IS_INTERNAL} from '#/env'
|
||||
import {device, useStorage} from '#/storage'
|
||||
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||
@@ -363,6 +367,10 @@ function ProfilePreview({
|
||||
|
||||
function DevOptions() {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const [override, setOverride] = useStorage(device, [
|
||||
'policyUpdateDebugOverride',
|
||||
])
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
|
||||
@@ -502,6 +510,40 @@ function DevOptions() {
|
||||
</SettingsList.ItemText>
|
||||
</SettingsList.PressableItem>
|
||||
) : null}
|
||||
|
||||
<SettingsList.Divider />
|
||||
<View style={[a.p_xl, a.gap_md]}>
|
||||
<Text style={[a.text_lg, a.font_bold]}>PolicyUpdate202508 Debug</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_md]}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setOverride(!override)
|
||||
}}
|
||||
label="Toggle"
|
||||
color={override ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
style={[a.flex_1]}>
|
||||
<ButtonText>
|
||||
{override ? 'Disable debug mode' : 'Enable debug mode'}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
device.set([PolicyUpdate202508], false)
|
||||
agent.bskyAppRemoveNuxs([PolicyUpdate202508])
|
||||
Toast.show(`Done`, 'info')
|
||||
}}
|
||||
label="Reset policy update nux"
|
||||
color="secondary"
|
||||
size="small"
|
||||
disabled={!override}>
|
||||
<ButtonText>Reset state</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<SettingsList.Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
|
||||
|
||||
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -252,6 +253,8 @@ export function useSubmitSignup() {
|
||||
const {_} = useLingui()
|
||||
const {createAccount} = useSessionApi()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const preemptivelyCompleteActivePolicyUpdate =
|
||||
usePreemptivelyCompleteActivePolicyUpdate()
|
||||
|
||||
return useCallback(
|
||||
async (state: SignupState, dispatch: (action: SignupAction) => void) => {
|
||||
@@ -325,6 +328,12 @@ export function useSubmitSignup() {
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Marks any active policy update as completed, since user just agreed
|
||||
* to TOS/privacy during sign up
|
||||
*/
|
||||
preemptivelyCompleteActivePolicyUpdate()
|
||||
|
||||
/*
|
||||
* Must happen last so that if the user has multiple tabs open and
|
||||
* createAccount fails, one tab is not stuck in onboarding — Eric
|
||||
@@ -363,6 +372,11 @@ export function useSubmitSignup() {
|
||||
dispatch({type: 'setIsLoading', value: false})
|
||||
}
|
||||
},
|
||||
[_, onboardingDispatch, createAccount],
|
||||
[
|
||||
_,
|
||||
onboardingDispatch,
|
||||
createAccount,
|
||||
preemptivelyCompleteActivePolicyUpdate,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
type Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
import {device} from '#/storage'
|
||||
import {type PersistedApi} from './types'
|
||||
import {normalizeData} from './util'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
@@ -53,6 +54,7 @@ onUpdate satisfies PersistedApi['onUpdate']
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
device.removeAll()
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {jest} from '@jest/globals'
|
||||
|
||||
export {Nux} from '#/state/queries/nuxs/definitions'
|
||||
|
||||
export const useNuxs = jest.fn(() => {
|
||||
return {
|
||||
nuxs: undefined,
|
||||
status: 'loading' as const,
|
||||
}
|
||||
})
|
||||
|
||||
export const useNux = jest.fn((id: string) => {
|
||||
return {
|
||||
nux: undefined,
|
||||
status: 'loading' as const,
|
||||
}
|
||||
})
|
||||
|
||||
export const useSaveNux = jest.fn(() => {
|
||||
return {}
|
||||
})
|
||||
|
||||
export const useResetNuxs = jest.fn(() => {
|
||||
return {}
|
||||
})
|
||||
@@ -9,6 +9,11 @@ export enum Nux {
|
||||
ActivitySubscriptions = 'ActivitySubscriptions',
|
||||
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
|
||||
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
|
||||
|
||||
/*
|
||||
* Blocking announcements. New IDs are required for each new announcement.
|
||||
*/
|
||||
PolicyUpdate202508 = 'PolicyUpdate202508',
|
||||
}
|
||||
|
||||
export const nuxNames = new Set(Object.values(Nux))
|
||||
@@ -38,6 +43,10 @@ export type AppNux = BaseNux<
|
||||
id: Nux.AgeAssuranceDismissibleFeedBanner
|
||||
data: undefined
|
||||
}
|
||||
| {
|
||||
id: Nux.PolicyUpdate202508
|
||||
data: undefined
|
||||
}
|
||||
>
|
||||
|
||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
@@ -47,4 +56,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.ActivitySubscriptions]: undefined,
|
||||
[Nux.AgeAssuranceDismissibleNotice]: undefined,
|
||||
[Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
|
||||
[Nux.PolicyUpdate202508]: undefined,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,13 @@ export class Storage<Scopes extends unknown[], Schema> {
|
||||
keys.forEach(key => this.remove([...scopes, key]))
|
||||
}
|
||||
|
||||
/**
|
||||
* For debugging purposes
|
||||
*/
|
||||
removeAll() {
|
||||
this.store.clearAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a callback when the storage associated with a given key changes
|
||||
*
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||
|
||||
/**
|
||||
* Device data that's specific to the device and does not vary based account
|
||||
*/
|
||||
@@ -13,6 +15,12 @@ export type Device = {
|
||||
devMode: boolean
|
||||
demoMode: boolean
|
||||
activitySubscriptionsNudged?: boolean
|
||||
|
||||
/**
|
||||
* Policy update overlays. New IDs are required for each new announcement.
|
||||
*/
|
||||
policyUpdateDebugOverride?: boolean
|
||||
[PolicyUpdate202508]?: boolean
|
||||
}
|
||||
|
||||
export type Account = {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {Onboarding} from '#/screens/Onboarding'
|
||||
import {SignupQueued} from '#/screens/SignupQueued'
|
||||
import {Takendown} from '#/screens/Takendown'
|
||||
import {atoms as a, useLayoutBreakpoints} from '#/alf'
|
||||
import {PolicyUpdateOverlay} from '#/components/PolicyUpdateOverlay'
|
||||
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
|
||||
import {DesktopLeftNav} from './desktop/LeftNav'
|
||||
import {DesktopRightNav} from './desktop/RightNav'
|
||||
@@ -167,6 +168,9 @@ function NativeStackNavigator({
|
||||
{!isMobile && <DesktopRightNav routeName={activeRoute.name} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Only shown after logged in and onboaring etc are complete */}
|
||||
{hasSession && <PolicyUpdateOverlay />}
|
||||
</NavigationContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsen
|
||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
import {SigninDialog} from '#/components/dialogs/Signin'
|
||||
import {
|
||||
Outlet as PolicyUpdateOverlayPortalOutlet,
|
||||
usePolicyUpdateStateContext,
|
||||
} from '#/components/PolicyUpdateOverlay'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {RoutesContainer, TabsNavigator} from '#/Navigation'
|
||||
import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
|
||||
@@ -45,6 +49,7 @@ function ShellInner() {
|
||||
const setIsDrawerOpen = useSetDrawerOpen()
|
||||
const winDim = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
const policyUpdateState = usePolicyUpdateStateContext()
|
||||
|
||||
const renderDrawerContent = useCallback(() => <DrawerContent />, [])
|
||||
const onOpenDrawer = useCallback(
|
||||
@@ -151,6 +156,7 @@ function ShellInner() {
|
||||
</Drawer>
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
|
||||
<Composer winHeight={winDim.height} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
@@ -160,8 +166,16 @@ function ShellInner() {
|
||||
<InAppBrowserConsentDialog />
|
||||
<LinkWarningDialog />
|
||||
<Lightbox />
|
||||
<PortalOutlet />
|
||||
<BottomSheetOutlet />
|
||||
|
||||
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
|
||||
{policyUpdateState.completed && (
|
||||
<>
|
||||
<PortalOutlet />
|
||||
<BottomSheetOutlet />
|
||||
</>
|
||||
)}
|
||||
|
||||
<PolicyUpdateOverlayPortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
import {SigninDialog} from '#/components/dialogs/Signin'
|
||||
import {
|
||||
Outlet as PolicyUpdateOverlayPortalOutlet,
|
||||
usePolicyUpdateStateContext,
|
||||
} from '#/components/PolicyUpdateOverlay'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {FlatNavigator, RoutesContainer} from '#/Navigation'
|
||||
import {Composer} from './Composer.web'
|
||||
@@ -37,6 +41,7 @@ function ShellInner() {
|
||||
const {_} = useLingui()
|
||||
const showDrawer = !isDesktop && isDrawerOpen
|
||||
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
|
||||
const policyUpdateState = usePolicyUpdateStateContext()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showDrawer !== showDrawerDelayedExit) {
|
||||
@@ -74,7 +79,13 @@ function ShellInner() {
|
||||
<AgeAssuranceRedirectDialog />
|
||||
<LinkWarningDialog />
|
||||
<Lightbox />
|
||||
<PortalOutlet />
|
||||
|
||||
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
|
||||
{policyUpdateState.completed && (
|
||||
<>
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDrawerDelayedExit && (
|
||||
<>
|
||||
@@ -113,6 +124,8 @@ function ShellInner() {
|
||||
</TouchableWithoutFeedback>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PolicyUpdateOverlayPortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user