Add blocking announcement dialog feature

This commit is contained in:
Eric Bailey
2025-07-31 15:46:32 -05:00
parent fd37d92f85
commit b57eef7050
11 changed files with 508 additions and 15 deletions
+1
View File
@@ -22,6 +22,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: false,
preventDismiss: false,
})
export function useDialogContext() {
+2
View File
@@ -54,6 +54,7 @@ export function Outer({
control,
onClose,
nativeOptions,
preventDismiss,
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const themeName = useThemeName()
@@ -165,6 +166,7 @@ export function Outer({
cornerRadius={20}
backgroundColor={t.atoms.bg.backgroundColor}
{...nativeOptions}
preventDismiss={preventDismiss ?? nativeOptions?.preventDismiss}
onSnapPointChange={onSnapPointChange}
onStateChange={onStateChange}
disableDrag={disableDrag}>
+11 -4
View File
@@ -40,6 +40,7 @@ export function Outer({
control,
onClose,
webOptions,
preventDismiss,
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
@@ -52,7 +53,9 @@ export function Outer({
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback<DialogControlProps['close']>(
cb => {
(cb, force) => {
if (preventDismiss && !force) return
setDialogIsOpen(control.id, false)
setIsOpen(false)
@@ -72,7 +75,7 @@ export function Outer({
onClose?.()
},
[control.id, onClose, setDialogIsOpen],
[control.id, onClose, setDialogIsOpen, preventDismiss],
)
const handleBackgroundPress = React.useCallback(async () => {
@@ -96,8 +99,9 @@ export function Outer({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: true,
preventDismiss: preventDismiss ?? false,
}),
[close],
[close, preventDismiss],
)
return (
@@ -239,7 +243,10 @@ export const InnerFlatList = React.forwardRef<
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
const {close, preventDismiss} = React.useContext(Context)
if (preventDismiss) return null
return (
<View
style={[
+13 -11
View File
@@ -1,15 +1,15 @@
import React from 'react'
import type {
AccessibilityProps,
GestureResponderEvent,
ScrollViewProps,
import {
type AccessibilityProps,
type GestureResponderEvent,
type ScrollViewProps,
} from 'react-native'
import {ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import type React from 'react'
import {ViewStyleProp} from '#/alf'
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {type ViewStyleProp} from '#/alf'
import {type BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {type BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
type A11yProps = Required<AccessibilityProps>
@@ -26,7 +26,7 @@ export type DialogControlRefProps = {
open: (
options?: DialogControlOpenOptions & Partial<GestureResponderEvent>,
) => void
close: (callback?: () => void) => void
close: (callback?: () => void, force?: boolean) => void
}
/**
@@ -46,6 +46,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
preventDismiss: boolean
}
export type DialogControlOpenOptions = {
@@ -62,6 +63,7 @@ export type DialogOuterProps = {
control: DialogControlProps
onClose?: () => void
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
preventDismiss?: boolean
webOptions?: {
alignCenter?: boolean
}
@@ -0,0 +1,211 @@
import {type ReactNode, useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {InlineLinkText} from '#/components/Link'
import {Span, Text} from '#/components/Typography'
export function BlockingAlertDialog() {
const {enabled} = usePolicyUpdate20250801()
return (
<>
{enabled && (
<BlockingAnnouncementDialogOuter>
<PolicyUpdate20250801 />
</BlockingAnnouncementDialogOuter>
)}
</>
)
}
function useForceClose() {
const {close} = Dialog.useDialogContext()
return useCallback(
(cb?: () => void) => {
close(cb, true)
},
[close],
)
}
export function BlockingAnnouncementDialogOuter({
children,
}: {
children: ReactNode
}) {
const {signinDialogControl: control} = useGlobalDialogsControlContext()
Dialog.useAutoOpen(control, 1e3)
return (
<Dialog.Outer preventDismiss control={control}>
<Dialog.Handle />
{children}
</Dialog.Outer>
)
}
export function AnnouncementBadge() {
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>
)
}
function usePolicyUpdate20250801() {
return {
enabled: true,
}
}
function PolicyUpdate20250801() {
const t = useTheme()
const {_} = useLingui()
const forceClose = useForceClose()
const linkStyle = [a.text_md]
const links = {
terms: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Terms of Service`),
style: linkStyle,
},
privacy: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Privacy Policy`),
style: linkStyle,
},
copyright: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Copyright Policy`),
style: linkStyle,
},
guidelines: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Community Guidelines`),
style: linkStyle,
},
blog: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Our blog post`),
style: linkStyle,
},
}
const handleClose = useCallback(() => {
forceClose(() => {
console.log('closed')
})
}, [forceClose])
return (
<Dialog.ScrollableInner
label={_(msg`Sign in to Bluesky or create a new account`)}
style={[web({maxWidth: 420})]}>
<View style={[a.align_start, a.gap_xl]}>
<AnnouncementBadge />
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold, a.leading_snug]}>
<Trans>Hey there 👋</Trans>
</Text>
<Text emoji style={[a.leading_snug, a.text_md]}>
<Trans>
Were updating our{' '}
<InlineLinkText {...links.terms}>Terms of Service</InlineLinkText>
,{' '}
<InlineLinkText {...links.privacy}>Privacy Policy</InlineLinkText>
, and{' '}
<InlineLinkText {...links.copyright}>
Copyright Policy
</InlineLinkText>
, <Span style={[]}>effective September 1st, 2025.</Span>
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
We're also updating our{' '}
<InlineLinkText {...links.guidelines}>
Community Guidelines
</InlineLinkText>
, <Span style={[]}>and we want your input!</Span> These new
guidelines will take effect on{' '}
<Span style={[]}>October 1st, 2025.</Span>
</Trans>
</Text>
<Text emoji 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}>
reading our blog post.
</InlineLinkText>
</Trans>
</Text>
</View>
<View style={[a.w_full, a.gap_md]}>
<Button
label={_(msg`Continue`)}
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>
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,141 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Nux} from '#/state/queries/nuxs'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {
AnnouncementBadge,
useAnnouncementState,
useForceClose,
} from '#/components/dialogs/BlockingAnnouncements/common'
import {InlineLinkText} from '#/components/Link'
import {Span, Text} from '#/components/Typography'
export function useLocalState() {
return useAnnouncementState({
id: Nux.BlockingAnnouncementPolicyUpdate20250801,
})
}
export function Announcement() {
const t = useTheme()
const {_} = useLingui()
const forceClose = useForceClose()
const {complete} = useLocalState()
const handleClose = useCallback(() => {
forceClose(() => complete())
}, [forceClose, complete])
const linkStyle = [a.text_md]
const links = {
terms: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Terms of Service`),
style: linkStyle,
},
privacy: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Privacy Policy`),
style: linkStyle,
},
copyright: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Copyright Policy`),
style: linkStyle,
},
guidelines: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Community Guidelines`),
style: linkStyle,
},
blog: {
overridePresentation: true,
to: `https://bsky.social/about/support`,
label: _(msg`Our blog post`),
style: linkStyle,
},
}
return (
<Dialog.ScrollableInner
label={_(msg`Were updating our policies`)}
style={[web({maxWidth: 420})]}>
<View style={[a.align_start, a.gap_xl]}>
<AnnouncementBadge />
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold, a.leading_snug]}>
<Trans>Hey there 👋</Trans>
</Text>
<Text emoji style={[a.leading_snug, a.text_md]}>
<Trans>
Were updating our{' '}
<InlineLinkText {...links.terms}>Terms of Service</InlineLinkText>
,{' '}
<InlineLinkText {...links.privacy}>Privacy Policy</InlineLinkText>
, and{' '}
<InlineLinkText {...links.copyright}>
Copyright Policy
</InlineLinkText>
, <Span style={[]}>effective September 1st, 2025.</Span>
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
We're also updating our{' '}
<InlineLinkText {...links.guidelines}>
Community Guidelines
</InlineLinkText>
, <Span style={[]}>and we want your input!</Span> These new
guidelines will take effect on{' '}
<Span style={[]}>October 1st, 2025.</Span>
</Trans>
</Text>
<Text emoji 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}>
reading our blog post.
</InlineLinkText>
</Trans>
</Text>
</View>
<View style={[a.w_full, a.gap_md]}>
<Button
label={_(msg`Continue`)}
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>
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,94 @@
import {type ReactNode, useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {type Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Text} from '#/components/Typography'
export function useForceClose() {
const {close} = Dialog.useDialogContext()
return useCallback(
(cb?: () => void) => {
close(cb, true)
},
[close],
)
}
export function useAnnouncementState({id}: {id: Nux}) {
const nux = useNux(id)
const {mutate: save, variables} = useSaveNux()
const optimisticallyCompleted = !!variables?.completed
return useMemo(
() => ({
/**
* Until data has loaded, assumed completed
*/
completed:
optimisticallyCompleted || nux.status === 'ready'
? nux.nux?.completed === true
: true,
complete() {
save({
id,
completed: true,
data: undefined,
})
},
}),
[id, nux, save, optimisticallyCompleted],
)
}
export function BlockingAnnouncementDialogOuter({
children,
}: {
children: ReactNode
}) {
const {signinDialogControl: control} = useGlobalDialogsControlContext()
Dialog.useAutoOpen(control, 1e3)
return (
<Dialog.Outer preventDismiss control={control}>
<Dialog.Handle />
{children}
</Dialog.Outer>
)
}
export function AnnouncementBadge() {
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,21 @@
import {BlockingAnnouncementDialogOuter} from '#/components/dialogs/BlockingAnnouncements/common'
import * as PolicyUpdate20250801 from '#/components/dialogs/BlockingAnnouncements/PolicyUpdate20250801'
export function BlockingAnnouncements() {
const policyUpdate20250801 = PolicyUpdate20250801.useLocalState()
/*
* See `window.clearNux` example in `/state/queries/nuxs` for a way to clear
* NUX state for local testing and debugging.
*/
return (
<>
{!policyUpdate20250801.completed && (
<BlockingAnnouncementDialogOuter>
<PolicyUpdate20250801.Announcement />
</BlockingAnnouncementDialogOuter>
)}
</>
)
}
+10
View File
@@ -9,6 +9,11 @@ export enum Nux {
ActivitySubscriptions = 'ActivitySubscriptions',
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
/*
* Blocking announcements. New IDs are required for each new announcement.
*/
BlockingAnnouncementPolicyUpdate20250801 = 'BlockingAnnouncementPolicyUpdate20250801',
}
export const nuxNames = new Set(Object.values(Nux))
@@ -38,6 +43,10 @@ export type AppNux = BaseNux<
id: Nux.AgeAssuranceDismissibleFeedBanner
data: undefined
}
| {
id: Nux.BlockingAnnouncementPolicyUpdate20250801
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.BlockingAnnouncementPolicyUpdate20250801]: undefined,
}
+2
View File
@@ -26,6 +26,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {BlockingAnnouncements} from '#/components/dialogs/BlockingAnnouncements'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
@@ -155,6 +156,7 @@ function ShellInner() {
<ModalsContainer />
<MutedWordsDialog />
<SigninDialog />
<BlockingAnnouncements />
<EmailDialog />
<AgeAssuranceRedirectDialog />
<InAppBrowserConsentDialog />
+2
View File
@@ -18,6 +18,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {BlockingAnnouncements} from '#/components/dialogs/BlockingAnnouncements'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -70,6 +71,7 @@ function ShellInner() {
<ModalsContainer />
<MutedWordsDialog />
<SigninDialog />
<BlockingAnnouncements />
<EmailDialog />
<AgeAssuranceRedirectDialog />
<LinkWarningDialog />