Bluesky app fork with some witchin' additions 💫

[APP-1356] Policy update dialog (#8782)

* Add blocking announcement dialog feature

* WIP custom dialog

* Rework dialog and add native FocusScope

* Lock scroll on web, fix backdrop

* Add web FocusScope

* Create custom Outlet for these announcements

* Clean up FocusScope native impl

* Comments

* Some styling fixes

* Handle screen reader specifically

* Clean up state, remove Portal edits

* Reorg, rename

* Add syncing, tests

* Revert dialog updates

* Revert formatting

* Delete unused file

* Format

* Add FullWindowOverlay

* remove mmkv storage in debug btn

* Add debug code

* fix taps passing through on iOS

* Reorg

* Reorg, rename everything

* Complete policy update after signup

* Add logger

* Move context around, unmount portals on native

* Move a11y prop into FocusScope

* Remove useMemo

* Update dates

* Move debug to dev settings

* Unmount web portals until policy update completed

* UPdate dates

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
Samuel Newman
and committed by
GitHub
328aa2be fd37d92f

+1214 -88
+46 -43
src/App.native.tsx
··· 70 70 import {NuxDialogs} from '#/components/dialogs/nuxs' 71 71 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 72 72 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' 73 + import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' 73 74 import {Provider as PortalProvider} from '#/components/Portal' 74 75 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 75 76 import {Splash} from '#/Splash' ··· 137 138 // Resets the entire tree below when it changes: 138 139 key={currentAccount?.did}> 139 140 <QueryProvider currentDid={currentAccount?.did}> 140 - <StatsigProvider> 141 - <AgeAssuranceProvider> 142 - <ComposerProvider> 143 - <MessagesProvider> 144 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 145 - <LabelDefsProvider> 146 - <ModerationOptsProvider> 147 - <LoggedOutViewProvider> 148 - <SelectedFeedProvider> 149 - <HiddenRepliesProvider> 150 - <HomeBadgeProvider> 151 - <UnreadNotifsProvider> 152 - <BackgroundNotificationPreferencesProvider> 153 - <MutedThreadsProvider> 154 - <ProgressGuideProvider> 155 - <ServiceAccountManager> 156 - <HideBottomBarBorderProvider> 157 - <GestureHandlerRootView 158 - style={s.h100pct}> 159 - <GlobalGestureEventsProvider> 160 - <IntentDialogProvider> 161 - <TestCtrls /> 162 - <Shell /> 163 - <NuxDialogs /> 164 - </IntentDialogProvider> 165 - </GlobalGestureEventsProvider> 166 - </GestureHandlerRootView> 167 - </HideBottomBarBorderProvider> 168 - </ServiceAccountManager> 169 - </ProgressGuideProvider> 170 - </MutedThreadsProvider> 171 - </BackgroundNotificationPreferencesProvider> 172 - </UnreadNotifsProvider> 173 - </HomeBadgeProvider> 174 - </HiddenRepliesProvider> 175 - </SelectedFeedProvider> 176 - </LoggedOutViewProvider> 177 - </ModerationOptsProvider> 178 - </LabelDefsProvider> 179 - </MessagesProvider> 180 - </ComposerProvider> 181 - </AgeAssuranceProvider> 182 - </StatsigProvider> 141 + <PolicyUpdateOverlayProvider> 142 + <StatsigProvider> 143 + <AgeAssuranceProvider> 144 + <ComposerProvider> 145 + <MessagesProvider> 146 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 147 + <LabelDefsProvider> 148 + <ModerationOptsProvider> 149 + <LoggedOutViewProvider> 150 + <SelectedFeedProvider> 151 + <HiddenRepliesProvider> 152 + <HomeBadgeProvider> 153 + <UnreadNotifsProvider> 154 + <BackgroundNotificationPreferencesProvider> 155 + <MutedThreadsProvider> 156 + <ProgressGuideProvider> 157 + <ServiceAccountManager> 158 + <HideBottomBarBorderProvider> 159 + <GestureHandlerRootView 160 + style={s.h100pct}> 161 + <GlobalGestureEventsProvider> 162 + <IntentDialogProvider> 163 + <TestCtrls /> 164 + <Shell /> 165 + <NuxDialogs /> 166 + </IntentDialogProvider> 167 + </GlobalGestureEventsProvider> 168 + </GestureHandlerRootView> 169 + </HideBottomBarBorderProvider> 170 + </ServiceAccountManager> 171 + </ProgressGuideProvider> 172 + </MutedThreadsProvider> 173 + </BackgroundNotificationPreferencesProvider> 174 + </UnreadNotifsProvider> 175 + </HomeBadgeProvider> 176 + </HiddenRepliesProvider> 177 + </SelectedFeedProvider> 178 + </LoggedOutViewProvider> 179 + </ModerationOptsProvider> 180 + </LabelDefsProvider> 181 + </MessagesProvider> 182 + </ComposerProvider> 183 + </AgeAssuranceProvider> 184 + </StatsigProvider> 185 + </PolicyUpdateOverlayProvider> 183 186 </QueryProvider> 184 187 </React.Fragment> 185 188 </VideoVolumeProvider>
+42 -39
src/App.web.tsx
··· 57 57 import {NuxDialogs} from '#/components/dialogs/nuxs' 58 58 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' 59 59 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' 60 + import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay' 60 61 import {Provider as PortalProvider} from '#/components/Portal' 61 62 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 62 63 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' ··· 117 118 // Resets the entire tree below when it changes: 118 119 key={currentAccount?.did}> 119 120 <QueryProvider currentDid={currentAccount?.did}> 120 - <StatsigProvider> 121 - <AgeAssuranceProvider> 122 - <ComposerProvider> 123 - <MessagesProvider> 124 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 125 - <LabelDefsProvider> 126 - <ModerationOptsProvider> 127 - <LoggedOutViewProvider> 128 - <SelectedFeedProvider> 129 - <HiddenRepliesProvider> 130 - <HomeBadgeProvider> 131 - <UnreadNotifsProvider> 132 - <BackgroundNotificationPreferencesProvider> 133 - <MutedThreadsProvider> 134 - <SafeAreaProvider> 135 - <ProgressGuideProvider> 136 - <ServiceConfigProvider> 137 - <HideBottomBarBorderProvider> 138 - <IntentDialogProvider> 139 - <Shell /> 140 - <NuxDialogs /> 141 - </IntentDialogProvider> 142 - </HideBottomBarBorderProvider> 143 - </ServiceConfigProvider> 144 - </ProgressGuideProvider> 145 - </SafeAreaProvider> 146 - </MutedThreadsProvider> 147 - </BackgroundNotificationPreferencesProvider> 148 - </UnreadNotifsProvider> 149 - </HomeBadgeProvider> 150 - </HiddenRepliesProvider> 151 - </SelectedFeedProvider> 152 - </LoggedOutViewProvider> 153 - </ModerationOptsProvider> 154 - </LabelDefsProvider> 155 - </MessagesProvider> 156 - </ComposerProvider> 157 - </AgeAssuranceProvider> 158 - </StatsigProvider> 121 + <PolicyUpdateOverlayProvider> 122 + <StatsigProvider> 123 + <AgeAssuranceProvider> 124 + <ComposerProvider> 125 + <MessagesProvider> 126 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 127 + <LabelDefsProvider> 128 + <ModerationOptsProvider> 129 + <LoggedOutViewProvider> 130 + <SelectedFeedProvider> 131 + <HiddenRepliesProvider> 132 + <HomeBadgeProvider> 133 + <UnreadNotifsProvider> 134 + <BackgroundNotificationPreferencesProvider> 135 + <MutedThreadsProvider> 136 + <SafeAreaProvider> 137 + <ProgressGuideProvider> 138 + <ServiceConfigProvider> 139 + <HideBottomBarBorderProvider> 140 + <IntentDialogProvider> 141 + <Shell /> 142 + <NuxDialogs /> 143 + </IntentDialogProvider> 144 + </HideBottomBarBorderProvider> 145 + </ServiceConfigProvider> 146 + </ProgressGuideProvider> 147 + </SafeAreaProvider> 148 + </MutedThreadsProvider> 149 + </BackgroundNotificationPreferencesProvider> 150 + </UnreadNotifsProvider> 151 + </HomeBadgeProvider> 152 + </HiddenRepliesProvider> 153 + </SelectedFeedProvider> 154 + </LoggedOutViewProvider> 155 + </ModerationOptsProvider> 156 + </LabelDefsProvider> 157 + </MessagesProvider> 158 + </ComposerProvider> 159 + </AgeAssuranceProvider> 160 + </StatsigProvider> 161 + </PolicyUpdateOverlayProvider> 159 162 </QueryProvider> 160 163 <ToastContainer /> 161 164 </React.Fragment>
+144
src/components/FocusScope/index.tsx
··· 1 + import { 2 + Children, 3 + cloneElement, 4 + isValidElement, 5 + type ReactElement, 6 + type ReactNode, 7 + useCallback, 8 + useEffect, 9 + useMemo, 10 + useRef, 11 + } from 'react' 12 + import { 13 + AccessibilityInfo, 14 + findNodeHandle, 15 + Pressable, 16 + Text, 17 + View, 18 + } from 'react-native' 19 + import {msg} from '@lingui/macro' 20 + import {useLingui} from '@lingui/react' 21 + 22 + import {useA11y} from '#/state/a11y' 23 + 24 + /** 25 + * Conditionally wraps children in a `FocusTrap` component based on whether 26 + * screen reader support is enabled. THIS SHOULD BE USED SPARINGLY, only when 27 + * no better option is available. 28 + */ 29 + export function FocusScope({children}: {children: ReactNode}) { 30 + const {screenReaderEnabled} = useA11y() 31 + 32 + return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children 33 + } 34 + 35 + /** 36 + * `FocusTrap` is intended as a last-ditch effort to ensure that users keep 37 + * focus within a certain section of the app, like an overlay. 38 + * 39 + * It works by placing "guards" at the start and end of the active content. 40 + * Then when the user reaches either of those guards, it will announce that 41 + * they have reached the start or end of the content and tell them how to 42 + * remain within the active content section. 43 + */ 44 + function FocusTrap({children}: {children: ReactNode}) { 45 + const {_} = useLingui() 46 + const child = useRef<View>(null) 47 + 48 + /* 49 + * Here we add a ref to the first child of this component. This currently 50 + * overrides any ref already on that first child, so we throw an error here 51 + * to prevent us from ever accidentally doing this. 52 + */ 53 + const decoratedChildren = useMemo(() => { 54 + return Children.toArray(children).map((node, i) => { 55 + if (i === 0 && isValidElement(node)) { 56 + const n = node as ReactElement<any> 57 + if (n.props.ref !== undefined) { 58 + throw new Error( 59 + 'FocusScope needs to override the ref on its first child.', 60 + ) 61 + } 62 + return cloneElement(n, { 63 + ...n.props, 64 + ref: child, 65 + }) 66 + } 67 + return node 68 + }) 69 + }, [children]) 70 + 71 + const focusNode = useCallback((ref: View | null) => { 72 + if (!ref) return 73 + const node = findNodeHandle(ref) 74 + if (node) { 75 + AccessibilityInfo.setAccessibilityFocus(node) 76 + } 77 + }, []) 78 + 79 + useEffect(() => { 80 + setTimeout(() => { 81 + focusNode(child.current) 82 + }, 1e3) 83 + }, [focusNode]) 84 + 85 + return ( 86 + <> 87 + <Pressable 88 + accessible 89 + accessibilityLabel={_( 90 + msg`You've reached the start of the active content.`, 91 + )} 92 + accessibilityHint={_( 93 + msg`Please go back, or activate this element to return to the start of the active content.`, 94 + )} 95 + accessibilityActions={[{name: 'activate', label: 'activate'}]} 96 + onAccessibilityAction={event => { 97 + switch (event.nativeEvent.actionName) { 98 + case 'activate': { 99 + focusNode(child.current) 100 + } 101 + } 102 + }}> 103 + <Noop /> 104 + </Pressable> 105 + <View 106 + /** 107 + * This property traps focus effectively on iOS, but not on Android. 108 + */ 109 + accessibilityViewIsModal> 110 + {decoratedChildren} 111 + </View> 112 + <Pressable 113 + accessibilityLabel={_( 114 + msg`You've reached the end of the active content.`, 115 + )} 116 + accessibilityHint={_( 117 + msg`Please go back, or activate this element to return to the start of the active content.`, 118 + )} 119 + accessibilityActions={[{name: 'activate', label: 'activate'}]} 120 + onAccessibilityAction={event => { 121 + switch (event.nativeEvent.actionName) { 122 + case 'activate': { 123 + focusNode(child.current) 124 + } 125 + } 126 + }}> 127 + <Noop /> 128 + </Pressable> 129 + </> 130 + ) 131 + } 132 + 133 + function Noop() { 134 + return ( 135 + <Text 136 + accessible={false} 137 + style={{ 138 + height: 1, 139 + opacity: 0, 140 + }}> 141 + {' '} 142 + </Text> 143 + ) 144 + }
+15
src/components/FocusScope/index.web.tsx
··· 1 + import {type ReactNode} from 'react' 2 + import {FocusScope as RadixFocusScope} from 'radix-ui/internal' 3 + 4 + /* 5 + * The web version of the FocusScope component is a proper implementation, we 6 + * use this in Dialogs and such already. It's here as a convenient counterpart 7 + * to the hacky native solution. 8 + */ 9 + export function FocusScope({children}: {children: ReactNode}) { 10 + return ( 11 + <RadixFocusScope.FocusScope loop asChild trapped> 12 + {children} 13 + </RadixFocusScope.FocusScope> 14 + ) 15 + }
+3
src/components/LockScroll/index.tsx
··· 1 + export function LockScroll() { 2 + return null 3 + }
+3
src/components/LockScroll/index.web.tsx
··· 1 + import {RemoveScrollBar} from 'react-remove-scroll-bar' 2 + 3 + export const LockScroll = RemoveScrollBar
+38
src/components/PolicyUpdateOverlay/Badge.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {Logo} from '#/view/icons/Logo' 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {Text} from '#/components/Typography' 7 + 8 + export function Badge() { 9 + const t = useTheme() 10 + return ( 11 + <View style={[a.align_start]}> 12 + <View 13 + style={[ 14 + a.pl_md, 15 + a.pr_lg, 16 + a.py_sm, 17 + a.rounded_full, 18 + a.flex_row, 19 + a.align_center, 20 + a.gap_xs, 21 + { 22 + backgroundColor: t.palette.primary_25, 23 + }, 24 + ]}> 25 + <Logo fill={t.palette.primary_600} width={14} /> 26 + <Text 27 + style={[ 28 + a.font_bold, 29 + { 30 + color: t.palette.primary_600, 31 + }, 32 + ]}> 33 + <Trans>Announcement</Trans> 34 + </Text> 35 + </View> 36 + </View> 37 + ) 38 + }
+139
src/components/PolicyUpdateOverlay/Overlay.tsx
··· 1 + import {type ReactNode} from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import { 4 + useSafeAreaFrame, 5 + useSafeAreaInsets, 6 + } from 'react-native-safe-area-context' 7 + import {LinearGradient} from 'expo-linear-gradient' 8 + 9 + import {isAndroid, isNative} from '#/platform/detection' 10 + import {useA11y} from '#/state/a11y' 11 + import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf' 12 + import {transparentifyColor} from '#/alf/util/colorGeneration' 13 + import {FocusScope} from '#/components/FocusScope' 14 + import {LockScroll} from '#/components/LockScroll' 15 + 16 + const GUTTER = 24 17 + 18 + export function Overlay({ 19 + children, 20 + label, 21 + }: { 22 + children: ReactNode 23 + label: string 24 + }) { 25 + const t = useTheme() 26 + const {gtPhone} = useBreakpoints() 27 + const {reduceMotionEnabled} = useA11y() 28 + const insets = useSafeAreaInsets() 29 + const frame = useSafeAreaFrame() 30 + 31 + return ( 32 + <> 33 + <LockScroll /> 34 + 35 + <View style={[a.fixed, a.inset_0, !reduceMotionEnabled && a.fade_in]}> 36 + {gtPhone ? ( 37 + <View style={[a.absolute, a.inset_0, {opacity: 0.8}]}> 38 + <View 39 + style={[ 40 + a.fixed, 41 + a.inset_0, 42 + {backgroundColor: t.palette.black}, 43 + !reduceMotionEnabled && a.fade_in, 44 + ]} 45 + /> 46 + </View> 47 + ) : ( 48 + <LinearGradient 49 + colors={[ 50 + transparentifyColor(t.atoms.bg.backgroundColor, 0), 51 + t.atoms.bg.backgroundColor, 52 + t.atoms.bg.backgroundColor, 53 + ]} 54 + start={[0.5, 0]} 55 + end={[0.5, 1]} 56 + style={[a.absolute, a.inset_0]} 57 + /> 58 + )} 59 + </View> 60 + 61 + <ScrollView 62 + showsVerticalScrollIndicator={false} 63 + style={[ 64 + a.z_10, 65 + gtPhone && 66 + web({ 67 + paddingHorizontal: GUTTER, 68 + paddingVertical: '10vh', 69 + }), 70 + ]} 71 + contentContainerStyle={[a.align_center]}> 72 + {/** 73 + * This is needed to prevent centered dialogs from overflowing 74 + * above the screen, and provides a "natural" centering so that 75 + * stacked dialogs appear relatively aligned. 76 + */} 77 + <View 78 + style={[ 79 + a.w_full, 80 + a.z_20, 81 + a.align_center, 82 + !gtPhone && [a.justify_end, {minHeight: frame.height}], 83 + isNative && [ 84 + { 85 + paddingBottom: Math.max(insets.bottom, a.p_2xl.padding), 86 + }, 87 + ], 88 + ]}> 89 + {!gtPhone && ( 90 + <View 91 + style={[ 92 + a.flex_1, 93 + a.w_full, 94 + { 95 + minHeight: Math.max(insets.top, a.p_2xl.padding), 96 + }, 97 + ]}> 98 + <LinearGradient 99 + colors={[ 100 + transparentifyColor(t.atoms.bg.backgroundColor, 0), 101 + t.atoms.bg.backgroundColor, 102 + ]} 103 + start={[0.5, 0]} 104 + end={[0.5, 1]} 105 + style={[a.absolute, a.inset_0]} 106 + /> 107 + </View> 108 + )} 109 + 110 + <FocusScope> 111 + <View 112 + accessible={isAndroid} 113 + role="dialog" 114 + aria-role="dialog" 115 + aria-label={label} 116 + style={flatten([ 117 + a.relative, 118 + a.w_full, 119 + a.p_2xl, 120 + t.atoms.bg, 121 + !reduceMotionEnabled && a.zoom_fade_in, 122 + gtPhone && [ 123 + a.rounded_md, 124 + a.border, 125 + t.atoms.shadow_lg, 126 + t.atoms.border_contrast_low, 127 + web({ 128 + maxWidth: 420, 129 + }), 130 + ], 131 + ])}> 132 + {children} 133 + </View> 134 + </FocusScope> 135 + </View> 136 + </ScrollView> 137 + </> 138 + ) 139 + }
+7
src/components/PolicyUpdateOverlay/Portal.tsx
··· 1 + import {createPortalGroup} from '#/components/Portal' 2 + 3 + const portalGroup = createPortalGroup() 4 + 5 + export const Provider = portalGroup.Provider 6 + export const Portal = portalGroup.Portal 7 + export const Outlet = portalGroup.Outlet
+195
src/components/PolicyUpdateOverlay/__tests__/useAnnouncementState.test.ts
··· 1 + import {describe, test} from '@jest/globals' 2 + 3 + import { 4 + computeCompletedState, 5 + syncCompletedState, 6 + } from '#/components/PolicyUpdateOverlay/usePolicyUpdateState' 7 + 8 + jest.mock('../../../state/queries/nuxs') 9 + 10 + describe('computeCompletedState', () => { 11 + test(`initial state`, () => { 12 + const completed = computeCompletedState({ 13 + nuxIsReady: false, 14 + nuxIsCompleted: false, 15 + nuxIsOptimisticallyCompleted: false, 16 + completedForDevice: undefined, 17 + }) 18 + 19 + expect(completed).toBe(true) 20 + }) 21 + 22 + test(`nux loaded state`, () => { 23 + const completed = computeCompletedState({ 24 + nuxIsReady: true, 25 + nuxIsCompleted: false, 26 + nuxIsOptimisticallyCompleted: false, 27 + completedForDevice: undefined, 28 + }) 29 + 30 + expect(completed).toBe(false) 31 + }) 32 + 33 + test(`nux saving state`, () => { 34 + const completed = computeCompletedState({ 35 + nuxIsReady: true, 36 + nuxIsCompleted: false, 37 + nuxIsOptimisticallyCompleted: true, 38 + completedForDevice: undefined, 39 + }) 40 + 41 + expect(completed).toBe(true) 42 + }) 43 + 44 + test(`nux is completed`, () => { 45 + const completed = computeCompletedState({ 46 + nuxIsReady: true, 47 + nuxIsCompleted: true, 48 + nuxIsOptimisticallyCompleted: false, 49 + completedForDevice: undefined, 50 + }) 51 + 52 + expect(completed).toBe(true) 53 + }) 54 + 55 + test(`initial state, but already completed for device`, () => { 56 + const completed = computeCompletedState({ 57 + nuxIsReady: false, 58 + nuxIsCompleted: false, 59 + nuxIsOptimisticallyCompleted: false, 60 + completedForDevice: true, 61 + }) 62 + 63 + expect(completed).toBe(true) 64 + }) 65 + }) 66 + 67 + describe('syncCompletedState', () => { 68 + describe('!nuxIsReady', () => { 69 + test(`!completedForDevice, no-op`, () => { 70 + const save = jest.fn() 71 + const setCompletedForDevice = jest.fn() 72 + syncCompletedState({ 73 + nuxIsReady: false, 74 + nuxIsCompleted: false, 75 + nuxIsOptimisticallyCompleted: false, 76 + completedForDevice: false, 77 + save, 78 + setCompletedForDevice, 79 + }) 80 + 81 + expect(save).not.toHaveBeenCalled() 82 + expect(setCompletedForDevice).not.toHaveBeenCalled() 83 + }) 84 + 85 + test(`completedForDevice, no-op`, () => { 86 + const save = jest.fn() 87 + const setCompletedForDevice = jest.fn() 88 + syncCompletedState({ 89 + nuxIsReady: false, 90 + nuxIsCompleted: false, 91 + nuxIsOptimisticallyCompleted: false, 92 + completedForDevice: true, 93 + save, 94 + setCompletedForDevice, 95 + }) 96 + 97 + expect(save).not.toHaveBeenCalled() 98 + expect(setCompletedForDevice).not.toHaveBeenCalled() 99 + }) 100 + }) 101 + 102 + describe('nuxIsReady', () => { 103 + describe(`!nuxIsCompleted`, () => { 104 + describe(`!nuxIsOptimisticallyCompleted`, () => { 105 + test(`!completedForDevice, no-op`, () => { 106 + const save = jest.fn() 107 + const setCompletedForDevice = jest.fn() 108 + syncCompletedState({ 109 + nuxIsReady: true, 110 + nuxIsCompleted: false, 111 + nuxIsOptimisticallyCompleted: false, 112 + completedForDevice: false, 113 + save, 114 + setCompletedForDevice, 115 + }) 116 + 117 + expect(save).not.toHaveBeenCalled() 118 + expect(setCompletedForDevice).not.toHaveBeenCalled() 119 + }) 120 + 121 + test(`completedForDevice, syncs to server`, () => { 122 + const save = jest.fn() 123 + const setCompletedForDevice = jest.fn() 124 + syncCompletedState({ 125 + nuxIsReady: true, 126 + nuxIsCompleted: false, 127 + nuxIsOptimisticallyCompleted: false, 128 + completedForDevice: true, 129 + save, 130 + setCompletedForDevice, 131 + }) 132 + 133 + expect(save).toHaveBeenCalled() 134 + expect(setCompletedForDevice).not.toHaveBeenCalled() 135 + }) 136 + }) 137 + 138 + /** 139 + * Catches the case where we already called `save` to sync device state 140 + * to server, thus `nuxIsOptimisticallyCompleted` is true. 141 + */ 142 + describe(`nuxIsOptimisticallyCompleted`, () => { 143 + test(`completedForDevice, no-op`, () => { 144 + const save = jest.fn() 145 + const setCompletedForDevice = jest.fn() 146 + syncCompletedState({ 147 + nuxIsReady: true, 148 + nuxIsCompleted: false, 149 + nuxIsOptimisticallyCompleted: true, 150 + completedForDevice: true, 151 + save, 152 + setCompletedForDevice, 153 + }) 154 + 155 + expect(save).not.toHaveBeenCalled() 156 + expect(setCompletedForDevice).not.toHaveBeenCalled() 157 + }) 158 + }) 159 + }) 160 + 161 + describe(`nuxIsCompleted`, () => { 162 + test(`!completedForDevice, syncs to device`, () => { 163 + const save = jest.fn() 164 + const setCompletedForDevice = jest.fn() 165 + syncCompletedState({ 166 + nuxIsReady: true, 167 + nuxIsCompleted: true, 168 + nuxIsOptimisticallyCompleted: false, 169 + completedForDevice: false, 170 + save, 171 + setCompletedForDevice, 172 + }) 173 + 174 + expect(save).not.toHaveBeenCalled() 175 + expect(setCompletedForDevice).toHaveBeenCalled() 176 + }) 177 + 178 + test(`completedForDevice, no-op`, () => { 179 + const save = jest.fn() 180 + const setCompletedForDevice = jest.fn() 181 + syncCompletedState({ 182 + nuxIsReady: true, 183 + nuxIsCompleted: true, 184 + nuxIsOptimisticallyCompleted: false, 185 + completedForDevice: true, 186 + save, 187 + setCompletedForDevice, 188 + }) 189 + 190 + expect(save).not.toHaveBeenCalled() 191 + expect(setCompletedForDevice).not.toHaveBeenCalled() 192 + }) 193 + }) 194 + }) 195 + })
+7
src/components/PolicyUpdateOverlay/config.ts
··· 1 + import {ID} from '#/components/PolicyUpdateOverlay/updates/202508/config' 2 + 3 + /** 4 + * The singulary active update ID. This is configured here to ensure that 5 + * the relationship is clear. 6 + */ 7 + export const ACTIVE_UPDATE_ID = ID
+32
src/components/PolicyUpdateOverlay/context.tsx
··· 1 + import {createContext, type ReactNode, useContext} from 'react' 2 + 3 + import {Provider as PortalProvider} from '#/components/PolicyUpdateOverlay/Portal' 4 + import { 5 + type PolicyUpdateState, 6 + usePolicyUpdateState, 7 + } from '#/components/PolicyUpdateOverlay/usePolicyUpdateState' 8 + 9 + const Context = createContext<PolicyUpdateState>({ 10 + completed: true, 11 + complete: () => {}, 12 + }) 13 + 14 + export function usePolicyUpdateStateContext() { 15 + const context = useContext(Context) 16 + if (!context) { 17 + throw new Error( 18 + 'usePolicyUpdateStateContext must be used within a PolicyUpdateProvider', 19 + ) 20 + } 21 + return context 22 + } 23 + 24 + export function Provider({children}: {children?: ReactNode}) { 25 + const state = usePolicyUpdateState() 26 + 27 + return ( 28 + <PortalProvider> 29 + <Context.Provider value={state}>{children}</Context.Provider> 30 + </PortalProvider> 31 + ) 32 + }
+41
src/components/PolicyUpdateOverlay/index.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {isIOS} from '#/platform/detection' 4 + import {atoms as a} from '#/alf' 5 + import {FullWindowOverlay} from '#/components/FullWindowOverlay' 6 + import {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context' 7 + import {Portal} from '#/components/PolicyUpdateOverlay/Portal' 8 + import {Content} from '#/components/PolicyUpdateOverlay/updates/202508' 9 + 10 + export {Provider} from '#/components/PolicyUpdateOverlay/context' 11 + export {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context' 12 + export {Outlet} from '#/components/PolicyUpdateOverlay/Portal' 13 + 14 + export function PolicyUpdateOverlay() { 15 + const state = usePolicyUpdateStateContext() 16 + 17 + /* 18 + * See `window.clearNux` example in `/state/queries/nuxs` for a way to clear 19 + * NUX state for local testing and debugging. 20 + */ 21 + 22 + if (state.completed) return null 23 + 24 + return ( 25 + <Portal> 26 + <FullWindowOverlay> 27 + <View 28 + style={[ 29 + a.fixed, 30 + a.inset_0, 31 + // setting a zIndex when using FullWindowOverlay on iOS 32 + // means the taps pass straight through to the underlying content (???) 33 + // so don't set it on iOS. FullWindowOverlay already does the job. 34 + !isIOS && {zIndex: 9999}, 35 + ]}> 36 + <Content state={state} /> 37 + </View> 38 + </FullWindowOverlay> 39 + </Portal> 40 + ) 41 + }
+3
src/components/PolicyUpdateOverlay/logger.ts
··· 1 + import {Logger} from '#/logger' 2 + 3 + export const logger = Logger.create(Logger.Context.PolicyUpdate)
+7
src/components/PolicyUpdateOverlay/updates/202508/config.ts
··· 1 + /* 2 + * Keep this file separate to avoid import issues. 3 + */ 4 + 5 + import {Nux} from '#/state/queries/nuxs' 6 + 7 + export const ID = Nux.PolicyUpdate202508
+190
src/components/PolicyUpdateOverlay/updates/202508/index.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {isAndroid} from '#/platform/detection' 7 + import {useA11y} from '#/state/a11y' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Button, ButtonText} from '#/components/Button' 10 + import {InlineLinkText, Link} from '#/components/Link' 11 + import {Badge} from '#/components/PolicyUpdateOverlay/Badge' 12 + import {Overlay} from '#/components/PolicyUpdateOverlay/Overlay' 13 + import {type PolicyUpdateState} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState' 14 + import {Text} from '#/components/Typography' 15 + 16 + export function Content({state}: {state: PolicyUpdateState}) { 17 + const t = useTheme() 18 + const {_} = useLingui() 19 + const {screenReaderEnabled} = useA11y() 20 + 21 + const handleClose = useCallback(() => { 22 + state.complete() 23 + }, [state]) 24 + 25 + const linkStyle = [a.text_md] 26 + const links = { 27 + terms: { 28 + overridePresentation: false, 29 + to: `https://bsky.social/about/support`, 30 + label: _(msg`Terms of Service`), 31 + }, 32 + privacy: { 33 + overridePresentation: false, 34 + to: `https://bsky.social/about/support`, 35 + label: _(msg`Privacy Policy`), 36 + }, 37 + copyright: { 38 + overridePresentation: false, 39 + to: `https://bsky.social/about/support`, 40 + label: _(msg`Copyright Policy`), 41 + }, 42 + guidelines: { 43 + overridePresentation: false, 44 + to: `https://bsky.social/about/support`, 45 + label: _(msg`Community Guidelines`), 46 + }, 47 + blog: { 48 + overridePresentation: false, 49 + to: `https://bsky.social/about/support`, 50 + label: _(msg`Our blog post`), 51 + }, 52 + } 53 + const linkButtonStyles = { 54 + overridePresentation: false, 55 + color: 'secondary', 56 + size: 'small', 57 + } as const 58 + 59 + const label = isAndroid 60 + ? _( 61 + 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.`, 62 + ) 63 + : _(msg`We're updating our policies`) 64 + 65 + return ( 66 + <Overlay label={label}> 67 + <View style={[a.align_start, a.gap_xl]}> 68 + <Badge /> 69 + 70 + {screenReaderEnabled ? ( 71 + <View style={[a.gap_sm]}> 72 + <Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}> 73 + <Trans>Hey there 👋</Trans> 74 + </Text> 75 + <Text style={[a.leading_snug, a.text_md]}> 76 + <Trans> 77 + We’re updating our Terms of Service, Privacy Policy, and 78 + Copyright Policy, effective September 12th, 2025. 79 + </Trans> 80 + </Text> 81 + <Text style={[a.leading_snug, a.text_md]}> 82 + <Trans> 83 + We're also updating our Community Guidelines, and we want your 84 + input! These new guidelines will take effect on October 13th, 85 + 2025. 86 + </Trans> 87 + </Text> 88 + <Text style={[a.leading_snug, a.text_md]}> 89 + <Trans> 90 + Learn more about these changes and how to share your thoughts 91 + with us by reading our blog post. 92 + </Trans> 93 + </Text> 94 + 95 + <Link {...links.terms} {...linkButtonStyles}> 96 + <ButtonText> 97 + <Trans>Terms of Service</Trans> 98 + </ButtonText> 99 + </Link> 100 + <Link {...links.privacy} {...linkButtonStyles}> 101 + <ButtonText> 102 + <Trans>Privacy Policy</Trans> 103 + </ButtonText> 104 + </Link> 105 + <Link {...links.copyright} {...linkButtonStyles}> 106 + <ButtonText> 107 + <Trans>Copyright Policy</Trans> 108 + </ButtonText> 109 + </Link> 110 + <Link {...links.blog} {...linkButtonStyles}> 111 + <ButtonText> 112 + <Trans>Read our blog post</Trans> 113 + </ButtonText> 114 + </Link> 115 + </View> 116 + ) : ( 117 + <View style={[a.gap_sm]}> 118 + <Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}> 119 + <Trans>Hey there 👋</Trans> 120 + </Text> 121 + <Text style={[a.leading_snug, a.text_md]}> 122 + <Trans> 123 + We’re updating our{' '} 124 + <InlineLinkText {...links.terms} style={linkStyle}> 125 + Terms of Service 126 + </InlineLinkText> 127 + ,{' '} 128 + <InlineLinkText {...links.privacy} style={linkStyle}> 129 + Privacy Policy 130 + </InlineLinkText> 131 + , and{' '} 132 + <InlineLinkText {...links.copyright} style={linkStyle}> 133 + Copyright Policy 134 + </InlineLinkText> 135 + , effective September 12th, 2025. 136 + </Trans> 137 + </Text> 138 + <Text style={[a.leading_snug, a.text_md]}> 139 + <Trans> 140 + We're also updating our{' '} 141 + <InlineLinkText {...links.guidelines} style={linkStyle}> 142 + Community Guidelines 143 + </InlineLinkText> 144 + , and we want your input! These new guidelines will take effect 145 + on October 13th, 2025. 146 + </Trans> 147 + </Text> 148 + <Text style={[a.leading_snug, a.text_md]}> 149 + <Trans> 150 + Learn more about these changes and how to share your thoughts 151 + with us by{' '} 152 + <InlineLinkText {...links.blog} style={linkStyle}> 153 + reading our blog post. 154 + </InlineLinkText> 155 + </Trans> 156 + </Text> 157 + </View> 158 + )} 159 + 160 + <View style={[a.w_full, a.gap_md]}> 161 + <Button 162 + label={_(msg`Continue`)} 163 + accessibilityHint={_( 164 + msg`Tap to acknowledge that you understand and agree to these updates and continue using Bluesky`, 165 + )} 166 + color="primary" 167 + size="large" 168 + onPress={handleClose}> 169 + <ButtonText> 170 + <Trans>Continue</Trans> 171 + </ButtonText> 172 + </Button> 173 + 174 + <Text 175 + style={[ 176 + a.leading_snug, 177 + a.text_sm, 178 + a.italic, 179 + t.atoms.text_contrast_medium, 180 + ]}> 181 + <Trans> 182 + By clicking "Continue" you acknowledge that you understand and 183 + agree to these updates. 184 + </Trans> 185 + </Text> 186 + </View> 187 + </View> 188 + </Overlay> 189 + ) 190 + }
+135
src/components/PolicyUpdateOverlay/usePolicyUpdateState.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {useNux, useSaveNux} from '#/state/queries/nuxs' 4 + import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config' 5 + import {logger} from '#/components/PolicyUpdateOverlay/logger' 6 + import {IS_DEV} from '#/env' 7 + import {device, useStorage} from '#/storage' 8 + 9 + export type PolicyUpdateState = { 10 + completed: boolean 11 + complete: () => void 12 + } 13 + 14 + export function usePolicyUpdateState() { 15 + const nux = useNux(ACTIVE_UPDATE_ID) 16 + const {mutate: save, variables} = useSaveNux() 17 + const deviceStorage = useStorage(device, [ACTIVE_UPDATE_ID]) 18 + const debugOverride = 19 + !!useStorage(device, ['policyUpdateDebugOverride'])[0] && IS_DEV 20 + return useMemo(() => { 21 + const nuxIsReady = nux.status === 'ready' 22 + const nuxIsCompleted = nux.nux?.completed === true 23 + const nuxIsOptimisticallyCompleted = !!variables?.completed 24 + const [completedForDevice, setCompletedForDevice] = deviceStorage 25 + 26 + const completed = computeCompletedState({ 27 + nuxIsReady, 28 + nuxIsCompleted, 29 + nuxIsOptimisticallyCompleted, 30 + completedForDevice, 31 + }) 32 + 33 + logger.debug(`state`, { 34 + completed, 35 + nux, 36 + completedForDevice, 37 + }) 38 + 39 + if (!debugOverride) { 40 + syncCompletedState({ 41 + nuxIsReady, 42 + nuxIsCompleted, 43 + nuxIsOptimisticallyCompleted, 44 + completedForDevice, 45 + save, 46 + setCompletedForDevice, 47 + }) 48 + } 49 + 50 + return { 51 + completed, 52 + complete() { 53 + logger.debug(`user completed`) 54 + save({ 55 + id: ACTIVE_UPDATE_ID, 56 + completed: true, 57 + data: undefined, 58 + }) 59 + setCompletedForDevice(true) 60 + }, 61 + } 62 + }, [nux, save, variables, deviceStorage, debugOverride]) 63 + } 64 + 65 + export function computeCompletedState({ 66 + nuxIsReady, 67 + nuxIsCompleted, 68 + nuxIsOptimisticallyCompleted, 69 + completedForDevice, 70 + }: { 71 + nuxIsReady: boolean 72 + nuxIsCompleted: boolean 73 + nuxIsOptimisticallyCompleted: boolean 74 + completedForDevice: boolean | undefined 75 + }): boolean { 76 + /** 77 + * Assume completed to prevent flash 78 + */ 79 + let completed = true 80 + 81 + /** 82 + * Prefer server state, if available 83 + */ 84 + if (nuxIsReady) { 85 + completed = nuxIsCompleted 86 + } 87 + 88 + /** 89 + * Override with optimistic state or device state 90 + */ 91 + if (nuxIsOptimisticallyCompleted || !!completedForDevice) { 92 + completed = true 93 + } 94 + 95 + return completed 96 + } 97 + 98 + export function syncCompletedState({ 99 + nuxIsReady, 100 + nuxIsCompleted, 101 + nuxIsOptimisticallyCompleted, 102 + completedForDevice, 103 + save, 104 + setCompletedForDevice, 105 + }: { 106 + nuxIsReady: boolean 107 + nuxIsCompleted: boolean 108 + nuxIsOptimisticallyCompleted: boolean 109 + completedForDevice: boolean | undefined 110 + save: ReturnType<typeof useSaveNux>['mutate'] 111 + setCompletedForDevice: (value: boolean) => void 112 + }) { 113 + /* 114 + * Sync device state to server state for this account 115 + */ 116 + if ( 117 + nuxIsReady && 118 + !nuxIsCompleted && 119 + !nuxIsOptimisticallyCompleted && 120 + !!completedForDevice 121 + ) { 122 + logger.debug(`syncing device state to server state`) 123 + save({ 124 + id: ACTIVE_UPDATE_ID, 125 + completed: true, 126 + data: undefined, 127 + }) 128 + } else if (nuxIsReady && nuxIsCompleted && !completedForDevice) { 129 + logger.debug(`syncing server state to device state`) 130 + /* 131 + * Sync server state to device state 132 + */ 133 + setCompletedForDevice(true) 134 + } 135 + }
+21
src/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate.ts
··· 1 + import {useCallback} from 'react' 2 + 3 + import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config' 4 + import {logger} from '#/components/PolicyUpdateOverlay/logger' 5 + import {device, useStorage} from '#/storage' 6 + 7 + /* 8 + * Marks the active policy update as completed in device storage. 9 + * `usePolicyUpdateState` will react to this and replicate this status in the 10 + * server NUX state for this account. 11 + */ 12 + export function usePreemptivelyCompleteActivePolicyUpdate() { 13 + const [_completedForDevice, setCompletedForDevice] = useStorage(device, [ 14 + ACTIVE_UPDATE_ID, 15 + ]) 16 + 17 + return useCallback(() => { 18 + logger.debug(`preemptively completing active policy update`) 19 + setCompletedForDevice(true) 20 + }, [setCompletedForDevice]) 21 + }
+1
src/logger/types.ts
··· 13 13 FeedFeedback = 'feed-feedback', 14 14 PostSource = 'post-source', 15 15 AgeAssurance = 'age-assurance', 16 + PolicyUpdate = 'policy-update', 16 17 17 18 /** 18 19 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+42
src/screens/Settings/Settings.tsx
··· 25 25 import {useModerationOpts} from '#/state/preferences/moderation-opts' 26 26 import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' 27 27 import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' 28 + import {useAgent} from '#/state/session' 28 29 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 29 30 import {useOnboardingDispatch} from '#/state/shell' 30 31 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 35 36 import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 36 37 import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' 37 38 import {AvatarStackWithFetch} from '#/components/AvatarStack' 39 + import {Button, ButtonText} from '#/components/Button' 38 40 import {useDialogControl} from '#/components/Dialog' 39 41 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 40 42 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' ··· 58 60 import * as Layout from '#/components/Layout' 59 61 import {Loader} from '#/components/Loader' 60 62 import * as Menu from '#/components/Menu' 63 + import {ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' 61 64 import * as Prompt from '#/components/Prompt' 62 65 import {Text} from '#/components/Typography' 63 66 import {useFullVerificationState} from '#/components/verification' ··· 66 69 VerificationCheckButton, 67 70 } from '#/components/verification/VerificationCheckButton' 68 71 import {IS_INTERNAL} from '#/env' 72 + import {device, useStorage} from '#/storage' 69 73 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 70 74 71 75 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> ··· 363 367 364 368 function DevOptions() { 365 369 const {_} = useLingui() 370 + const agent = useAgent() 371 + const [override, setOverride] = useStorage(device, [ 372 + 'policyUpdateDebugOverride', 373 + ]) 366 374 const onboardingDispatch = useOnboardingDispatch() 367 375 const navigation = useNavigation<NavigationProp>() 368 376 const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() ··· 502 510 </SettingsList.ItemText> 503 511 </SettingsList.PressableItem> 504 512 ) : null} 513 + 514 + <SettingsList.Divider /> 515 + <View style={[a.p_xl, a.gap_md]}> 516 + <Text style={[a.text_lg, a.font_bold]}>PolicyUpdate202508 Debug</Text> 517 + 518 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_md]}> 519 + <Button 520 + onPress={() => { 521 + setOverride(!override) 522 + }} 523 + label="Toggle" 524 + color={override ? 'primary' : 'secondary'} 525 + size="small" 526 + style={[a.flex_1]}> 527 + <ButtonText> 528 + {override ? 'Disable debug mode' : 'Enable debug mode'} 529 + </ButtonText> 530 + </Button> 531 + 532 + <Button 533 + onPress={() => { 534 + device.set([PolicyUpdate202508], false) 535 + agent.bskyAppRemoveNuxs([PolicyUpdate202508]) 536 + Toast.show(`Done`, 'info') 537 + }} 538 + label="Reset policy update nux" 539 + color="secondary" 540 + size="small" 541 + disabled={!override}> 542 + <ButtonText>Reset state</ButtonText> 543 + </Button> 544 + </View> 545 + </View> 546 + <SettingsList.Divider /> 505 547 </> 506 548 ) 507 549 }
+15 -1
src/screens/Signup/state.ts
··· 15 15 import {logger} from '#/logger' 16 16 import {useSessionApi} from '#/state/session' 17 17 import {useOnboardingDispatch} from '#/state/shell' 18 + import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate' 18 19 19 20 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 21 ··· 252 253 const {_} = useLingui() 253 254 const {createAccount} = useSessionApi() 254 255 const onboardingDispatch = useOnboardingDispatch() 256 + const preemptivelyCompleteActivePolicyUpdate = 257 + usePreemptivelyCompleteActivePolicyUpdate() 255 258 256 259 return useCallback( 257 260 async (state: SignupState, dispatch: (action: SignupAction) => void) => { ··· 325 328 }, 326 329 ) 327 330 331 + /** 332 + * Marks any active policy update as completed, since user just agreed 333 + * to TOS/privacy during sign up 334 + */ 335 + preemptivelyCompleteActivePolicyUpdate() 336 + 328 337 /* 329 338 * Must happen last so that if the user has multiple tabs open and 330 339 * createAccount fails, one tab is not stuck in onboarding — Eric ··· 363 372 dispatch({type: 'setIsLoading', value: false}) 364 373 } 365 374 }, 366 - [_, onboardingDispatch, createAccount], 375 + [ 376 + _, 377 + onboardingDispatch, 378 + createAccount, 379 + preemptivelyCompleteActivePolicyUpdate, 380 + ], 367 381 ) 368 382 }
+4 -2
src/state/persisted/index.ts
··· 3 3 import {logger} from '#/logger' 4 4 import { 5 5 defaults, 6 - Schema, 6 + type Schema, 7 7 tryParse, 8 8 tryStringify, 9 9 } from '#/state/persisted/schema' 10 - import {PersistedApi} from './types' 10 + import {device} from '#/storage' 11 + import {type PersistedApi} from './types' 11 12 import {normalizeData} from './util' 12 13 13 14 export type {PersistedAccount, Schema} from '#/state/persisted/schema' ··· 53 54 export async function clearStorage() { 54 55 try { 55 56 await AsyncStorage.removeItem(BSKY_STORAGE) 57 + device.removeAll() 56 58 } catch (e: any) { 57 59 logger.error(`persisted store: failed to clear`, {message: e.toString()}) 58 60 }
+25
src/state/queries/nuxs/__mocks__/index.ts
··· 1 + import {jest} from '@jest/globals' 2 + 3 + export {Nux} from '#/state/queries/nuxs/definitions' 4 + 5 + export const useNuxs = jest.fn(() => { 6 + return { 7 + nuxs: undefined, 8 + status: 'loading' as const, 9 + } 10 + }) 11 + 12 + export const useNux = jest.fn((id: string) => { 13 + return { 14 + nux: undefined, 15 + status: 'loading' as const, 16 + } 17 + }) 18 + 19 + export const useSaveNux = jest.fn(() => { 20 + return {} 21 + }) 22 + 23 + export const useResetNuxs = jest.fn(() => { 24 + return {} 25 + })
+10
src/state/queries/nuxs/definitions.ts
··· 9 9 ActivitySubscriptions = 'ActivitySubscriptions', 10 10 AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 11 AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', 12 + 13 + /* 14 + * Blocking announcements. New IDs are required for each new announcement. 15 + */ 16 + PolicyUpdate202508 = 'PolicyUpdate202508', 12 17 } 13 18 14 19 export const nuxNames = new Set(Object.values(Nux)) ··· 38 43 id: Nux.AgeAssuranceDismissibleFeedBanner 39 44 data: undefined 40 45 } 46 + | { 47 + id: Nux.PolicyUpdate202508 48 + data: undefined 49 + } 41 50 > 42 51 43 52 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 47 56 [Nux.ActivitySubscriptions]: undefined, 48 57 [Nux.AgeAssuranceDismissibleNotice]: undefined, 49 58 [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, 59 + [Nux.PolicyUpdate202508]: undefined, 50 60 }
+7
src/storage/index.ts
··· 67 67 } 68 68 69 69 /** 70 + * For debugging purposes 71 + */ 72 + removeAll() { 73 + this.store.clearAll() 74 + } 75 + 76 + /** 70 77 * Fires a callback when the storage associated with a given key changes 71 78 * 72 79 * @returns Listener - call `remove()` to stop listening
+8
src/storage/schema.ts
··· 1 + import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' 2 + 1 3 /** 2 4 * Device data that's specific to the device and does not vary based account 3 5 */ ··· 13 15 devMode: boolean 14 16 demoMode: boolean 15 17 activitySubscriptionsNudged?: boolean 18 + 19 + /** 20 + * Policy update overlays. New IDs are required for each new announcement. 21 + */ 22 + policyUpdateDebugOverride?: boolean 23 + [PolicyUpdate202508]?: boolean 16 24 } 17 25 18 26 export type Account = {
+4
src/view/shell/createNativeStackNavigatorWithAuth.tsx
··· 40 40 import {SignupQueued} from '#/screens/SignupQueued' 41 41 import {Takendown} from '#/screens/Takendown' 42 42 import {atoms as a, useLayoutBreakpoints} from '#/alf' 43 + import {PolicyUpdateOverlay} from '#/components/PolicyUpdateOverlay' 43 44 import {BottomBarWeb} from './bottom-bar/BottomBarWeb' 44 45 import {DesktopLeftNav} from './desktop/LeftNav' 45 46 import {DesktopRightNav} from './desktop/RightNav' ··· 167 168 {!isMobile && <DesktopRightNav routeName={activeRoute.name} />} 168 169 </> 169 170 )} 171 + 172 + {/* Only shown after logged in and onboaring etc are complete */} 173 + {hasSession && <PolicyUpdateOverlay />} 170 174 </NavigationContent> 171 175 ) 172 176 }
+16 -2
src/view/shell/index.tsx
··· 31 31 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 32 32 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 33 33 import {SigninDialog} from '#/components/dialogs/Signin' 34 + import { 35 + Outlet as PolicyUpdateOverlayPortalOutlet, 36 + usePolicyUpdateStateContext, 37 + } from '#/components/PolicyUpdateOverlay' 34 38 import {Outlet as PortalOutlet} from '#/components/Portal' 35 39 import {RoutesContainer, TabsNavigator} from '#/Navigation' 36 40 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' ··· 45 49 const setIsDrawerOpen = useSetDrawerOpen() 46 50 const winDim = useWindowDimensions() 47 51 const insets = useSafeAreaInsets() 52 + const policyUpdateState = usePolicyUpdateStateContext() 48 53 49 54 const renderDrawerContent = useCallback(() => <DrawerContent />, []) 50 55 const onOpenDrawer = useCallback( ··· 151 156 </Drawer> 152 157 </ErrorBoundary> 153 158 </View> 159 + 154 160 <Composer winHeight={winDim.height} /> 155 161 <ModalsContainer /> 156 162 <MutedWordsDialog /> ··· 160 166 <InAppBrowserConsentDialog /> 161 167 <LinkWarningDialog /> 162 168 <Lightbox /> 163 - <PortalOutlet /> 164 - <BottomSheetOutlet /> 169 + 170 + {/* Until policy update has been completed by the user, don't render anything that is portaled */} 171 + {policyUpdateState.completed && ( 172 + <> 173 + <PortalOutlet /> 174 + <BottomSheetOutlet /> 175 + </> 176 + )} 177 + 178 + <PolicyUpdateOverlayPortalOutlet /> 165 179 </> 166 180 ) 167 181 }
+14 -1
src/view/shell/index.web.tsx
··· 22 22 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 23 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 24 24 import {SigninDialog} from '#/components/dialogs/Signin' 25 + import { 26 + Outlet as PolicyUpdateOverlayPortalOutlet, 27 + usePolicyUpdateStateContext, 28 + } from '#/components/PolicyUpdateOverlay' 25 29 import {Outlet as PortalOutlet} from '#/components/Portal' 26 30 import {FlatNavigator, RoutesContainer} from '#/Navigation' 27 31 import {Composer} from './Composer.web' ··· 37 41 const {_} = useLingui() 38 42 const showDrawer = !isDesktop && isDrawerOpen 39 43 const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer) 44 + const policyUpdateState = usePolicyUpdateStateContext() 40 45 41 46 useLayoutEffect(() => { 42 47 if (showDrawer !== showDrawerDelayedExit) { ··· 74 79 <AgeAssuranceRedirectDialog /> 75 80 <LinkWarningDialog /> 76 81 <Lightbox /> 77 - <PortalOutlet /> 82 + 83 + {/* Until policy update has been completed by the user, don't render anything that is portaled */} 84 + {policyUpdateState.completed && ( 85 + <> 86 + <PortalOutlet /> 87 + </> 88 + )} 78 89 79 90 {showDrawerDelayedExit && ( 80 91 <> ··· 113 124 </TouchableWithoutFeedback> 114 125 </> 115 126 )} 127 + 128 + <PolicyUpdateOverlayPortalOutlet /> 116 129 </> 117 130 ) 118 131 }