[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>
This commit is contained in:
Eric Bailey
2025-08-06 15:15:52 -05:00
committed by GitHub
parent fd37d92f85
commit 328aa2be94
29 changed files with 1214 additions and 88 deletions
@@ -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`Were 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>
Were 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>
Were 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])
}