[Chat] NUX (#10848)

Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-06-10 22:35:35 +03:00
committed by GitHub
parent 2d896983ab
commit 2efdc3fe35
14 changed files with 404 additions and 60 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+43 -38
View File
@@ -218,7 +218,7 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, contentContainerStyle, header, style, ...props},
{children, contentContainerStyle, header, footer, style, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
@@ -248,42 +248,45 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
}
return (
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
// dialogs that use this that actually scroll -sfn
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</ScrollView>
<>
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
platform({
ios: a.pb_2xl,
android: {
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
},
}),
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
contentInsetAdjustmentBehavior={
isAtMaxSnapPoint ? 'automatic' : 'never'
}
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
{...props}
bounces={isAtMaxSnapPoint}
scrollEventThrottle={50}
// set drag state based on scroll on android.
// we want to detect if it's at the top or not, so watch
// scrollEndDrag and momentumScrollEnd as well
onScroll={android(onScroll)}
onScrollEndDrag={android(onScroll)}
onMomentumScrollEnd={android(onScroll)}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
// dialogs that use this that actually scroll -sfn
stickyHeaderIndices={ios(header ? [0] : undefined)}>
{header}
{children}
</ScrollView>
{footer}
</>
)
},
)
@@ -350,9 +353,11 @@ export const InnerFlatList = forwardRef<
export function FlatListFooter({
children,
onLayout,
border = true,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
border?: boolean
}) {
const t = useTheme()
const {bottom} = useSafeAreaInsets()
@@ -373,7 +378,7 @@ export function FlatListFooter({
a.bottom_0,
a.w_full,
a.z_10,
a.border_t,
border && a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
a.px_lg,
+5 -1
View File
@@ -170,6 +170,7 @@ export function Inner({
accessibilityLabelledBy,
accessibilityDescribedBy,
header,
footer,
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
@@ -216,6 +217,7 @@ export function Inner({
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
{footer}
</DismissableLayer.DismissableLayer>
</View>
</FocusScope.FocusScope>
@@ -266,9 +268,11 @@ export const InnerFlatList = forwardRef<
export function FlatListFooter({
children,
onLayout,
border = true,
}: {
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
border?: boolean
}) {
const t = useTheme()
@@ -281,7 +285,7 @@ export function FlatListFooter({
a.w_full,
a.z_10,
t.atoms.bg,
a.border_t,
border && a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_md,
+4
View File
@@ -79,14 +79,18 @@ export type DialogInnerProps =
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
footer?: React.ReactNode
}>
| DialogInnerPropsBase<{
label: string
accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
showsVerticalScrollIndicator?: ScrollViewProps['showsVerticalScrollIndicator']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
footer?: React.ReactNode
}>
@@ -0,0 +1,241 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Image} from 'expo-image'
import {type ThemeName} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
atoms as a,
native,
platform,
type TextStyleProp,
useTheme,
web,
} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
import {Shield_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_E2E, IS_WEB} from '#/env'
import {createIsEnabledCheck, isExistingUserAsOf} from './utils'
// Gate: only show to existing users (created before 2026-06-11), not E2E
export const enabled = createIsEnabledCheck(props => {
return (
!IS_E2E &&
isExistingUserAsOf(
'2026-06-11T00:00:00.000Z',
props.currentProfile.createdAt,
)
)
})
function getHero(theme: ThemeName) {
switch (theme) {
case 'light':
return require('../../../../assets/images/groupchats_announcement_light.webp')
case 'dark':
return require('../../../../assets/images/groupchats_announcement_dark.webp')
case 'dim':
return require('../../../../assets/images/groupchats_announcement_dim.webp')
}
}
export function GroupChatsAnnouncement() {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
const {bottom} = useSafeAreaInsets()
// Measure the footer so the scrollable content can pad itself out from
// underneath it (the footer is absolutely positioned).
const [footerHeight, setFooterHeight] = useState(
platform({
native: 124 + bottom,
web: 128,
default: 0,
}),
)
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
const onPressStartGroupChat = useCallback(() => {
control.close(() => {
if (IS_WEB) {
navigation.navigate('Messages', {pushToNewGroupChat: true})
} else {
// On native, Messages is nested inside MessagesTab.
// @ts-expect-error nested navigators aren't typed -sfn
navigation.navigate('MessagesTab', {
screen: 'Messages',
params: {pushToNewGroupChat: true},
})
}
})
}, [control, navigation])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
showsVerticalScrollIndicator={false}
label={l`Introducing group chats`}
// Fill the full-height sheet so the absolute footer pins to the bottom.
style={[
native(a.h_full),
web([{maxWidth: 440}, a.overflow_hidden, {borderRadius: 32}]),
]}
contentContainerStyle={[
native(a.p_0),
web(a.p_md),
{paddingBottom: footerHeight},
]}
footer={
<Dialog.FlatListFooter
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
border={false}>
<View
style={[
a.gap_md,
native(a.px_lg),
web([a.px_lg, a.pb_2xl, a.pt_xl]),
]}>
<Button
label={l`Got it`}
size="large"
color="primary"
onPress={() => control.close()}>
<ButtonText>
<Trans>Got it</Trans>
</ButtonText>
</Button>
<Button
label={l`Start a group chat`}
size="large"
color="secondary"
onPress={onPressStartGroupChat}>
<ButtonText>
<Trans>Start a group chat</Trans>
</ButtonText>
</Button>
</View>
</Dialog.FlatListFooter>
}>
<View
style={[
a.w_full,
platform({
web: [a.pt_xl, a.px_4xl],
native: [a.pt_xl, a.pb_md, a.px_sm],
}),
]}>
<Image
accessibilityIgnoresInvertColors
source={getHero(t.name)}
style={[a.w_full, {aspectRatio: 343 / 230}]}
alt={l({
message: `Four message bubbles representing a group chat. First message: "Did you hear the news? Bluesky has group chats now!" Second message: "omg, no way" Third message: "Wow, 50 people in one chat!" Fourth message: "You can send invite links too!"`,
comment:
'This is alt text for a marketing image which transcribes English text that appears in the image',
})}
useAppleWebpCodec
/>
</View>
<View style={[a.px_xl, a.pt_2xl, a.gap_2xl]}>
<View style={[a.align_center, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, {left: -6}]}>
<SparkleIcon fill={t.palette.primary_500} size="sm" />
<Text
style={[
a.text_sm,
a.font_medium,
{color: t.palette.primary_500},
]}>
<Trans>New</Trans>
</Text>
</View>
<Text
style={[
a.text_center,
a.font_bold,
a.leading_tight,
{fontSize: IS_WEB ? 32 : 36},
]}>
<Trans>Group Chats</Trans>
</Text>
<Text style={[a.text_md, a.text_center]}>
<Trans>Take the conversation private.</Trans>
</Text>
</View>
<View style={[a.gap_xl, a.pt_sm, a.pb_md]}>
<Feature
icon={ChainLinkIcon}
titleText={<Trans>Add people with a link</Trans>}
descriptionText={
<Trans>Post it to Bluesky or share anywhere.</Trans>
}
/>
<Feature
icon={GroupIcon}
titleText={<Trans>Up to 50 people</Trans>}
descriptionText={
<Trans>Bring up to 50 friends together in one chat.</Trans>
}
/>
<Feature
icon={ShieldIcon}
titleText={<Trans>Youre in control</Trans>}
descriptionText={
<Trans>
Mute, leave, or remove people anytime. Its your chat.
</Trans>
}
/>
</View>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Feature({
icon: Icon,
titleText,
descriptionText,
style,
}: {
icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode
descriptionText: React.ReactNode
} & TextStyleProp) {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, style]}>
<Icon size="md" style={[t.atoms.text]} />
<View style={[a.flex_1, a.gap_2xs]}>
<Text style={[a.text_md, a.font_semi_bold]}>{titleText}</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{descriptionText}
</Text>
</View>
</View>
)
}
+6 -6
View File
@@ -19,9 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {
DraftsAnnouncement,
enabled as isDraftsAnnouncementEnabled,
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
enabled as isGroupChatsAnnouncementEnabled,
GroupChatsAnnouncement,
} from '#/components/dialogs/nuxs/GroupChatsAnnouncement'
import {
enabled as isInviteFriendsAnnouncementEnabled,
InviteFriendsAnnouncement,
@@ -41,8 +41,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean
}[] = [
{
id: Nux.DraftsAnnouncement,
enabled: isDraftsAnnouncementEnabled,
id: Nux.GroupChatsAnnouncement,
enabled: isGroupChatsAnnouncementEnabled,
},
{
id: Nux.InviteFriendsAnnouncement,
@@ -194,7 +194,7 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
{activeNux === Nux.GroupChatsAnnouncement && <GroupChatsAnnouncement />}
{/*
Mounted unconditionally: it gates the announcement on `activeNux`
internally, so it can keep the invite-friends dialog mounted across
+5 -6
View File
@@ -1,6 +1,9 @@
import {simpleAreDatesEqual} from '#/lib/strings/time'
import {IS_DEV} from '#/env'
import {device} from '#/storage'
const PROD_SNOOZE_SECONDS = 3 * 60 * 60 // 3 hours
const SNOOZE_SECONDS = IS_DEV ? 10 : PROD_SNOOZE_SECONDS
export function snooze() {
device.set(['lastNuxDialog'], new Date().toISOString())
}
@@ -14,9 +17,5 @@ export function isSnoozed() {
if (!lastNuxDialog) return false
const last = new Date(lastNuxDialog)
const now = new Date()
// already snoozed today
if (simpleAreDatesEqual(last, now)) {
return true
}
return false
return now.getTime() - last.getTime() < SNOOZE_SECONDS * 1000
}
+4 -2
View File
@@ -211,10 +211,12 @@ export function InitiateChatFlow({
title,
onSelectChat,
onSelectGroupChat,
startInGroupChat = false,
}: {
title: string
onSelectChat: (did: string) => void
onSelectGroupChat: (dids: string[], groupName: string) => void
startInGroupChat?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -243,8 +245,8 @@ export function InitiateChatFlow({
},
dispatch,
] = useReducer(reducer, {
chatState: ChatState.NEW_CHAT,
screenTitle: title,
chatState: startInGroupChat ? ChatState.NEW_GROUP_CHAT : ChatState.NEW_CHAT,
screenTitle: startInGroupChat ? l`New group chat` : title,
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
+10 -1
View File
@@ -23,9 +23,13 @@ import {useAnalytics} from '#/analytics'
export function NewChat({
control,
onNewChat,
startInGroupChat = false,
onClose,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
startInGroupChat?: boolean
onClose?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -169,13 +173,18 @@ export function NewChat({
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{fullHeight: true}}>
nativeOptions={{fullHeight: true}}
onClose={onClose}>
<Dialog.Handle />
{isGroupChatEnabled ? (
<InitiateChatFlow
// remount when the entry mode changes so the flow re-seeds its
// initial step (the children stay mounted across open/close)
key={startInGroupChat ? 'group' : 'default'}
title={l`New chat`}
onSelectChat={onCreateChat}
onSelectGroupChat={onCreateGroupChat}
startInGroupChat={startInGroupChat}
/>
) : (
<SearchablePeopleList
+15 -3
View File
@@ -110,7 +110,11 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
Messages: {
pushToConversation?: string
pushToNewGroupChat?: boolean
animation?: 'push' | 'pop'
}
}
export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -118,7 +122,11 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Feeds: undefined
Notifications: undefined
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
Messages: {
pushToConversation?: string
pushToNewGroupChat?: boolean
animation?: 'push' | 'pop'
}
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -131,7 +139,11 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
MyProfileTab: undefined
MessagesTab: undefined
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
Messages: {
pushToConversation?: string
pushToNewGroupChat?: boolean
animation?: 'push' | 'pop'
}
}
// NOTE
+65 -3
View File
@@ -8,6 +8,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/appState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
@@ -41,7 +42,7 @@ import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE} from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
import {ChatDisabled} from './components/ChatDisabled'
import {ChatListItem} from './components/ChatListItem'
import {InboxRequests} from './components/InboxRequests'
@@ -100,6 +101,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
const newChatControl = useDialogControl()
const {data: chatStatus} = useChatActorStatusQuery()
const pushToConversation = route.params?.pushToConversation
const pushToNewGroupChat = route.params?.pushToNewGroupChat
// Tracks whether the next new-chat dialog open should start directly on the
// group-chat creation step. Set when deep-linked via `pushToNewGroupChat`,
// and reset to `false` whenever the dialog is opened through the normal FAB/
// button path so it never gets stuck in group mode.
const [startNewChatInGroupChat, setStartNewChatInGroupChat] = useState(false)
// Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
// this tab. We should immediately push to the conversation after pressing the notification.
@@ -137,6 +144,7 @@ export function MessagesScreenInner({navigation, route}: Props) {
)
const openChatControl = useCallback(() => {
setStartNewChatInGroupChat(false)
newChatControl.open()
}, [newChatControl])
@@ -149,6 +157,50 @@ export function MessagesScreenInner({navigation, route}: Props) {
],
})
// Deep link into the group-chat creation step of the new-chat dialog. Mirrors
// the `pushToConversation` pattern: open the dialog (respecting the same
// email-verification gating as the normal new-chat button) starting directly
// in group mode, then clear the param so it can fire again later.
const openGroupChatControl = useCallback(() => {
setStartNewChatInGroupChat(true)
newChatControl.open()
}, [newChatControl])
const wrappedOpenGroupChatControl = requireEmailVerification(
openGroupChatControl,
{
instructions: [
<Trans key="new-group-chat">
Before you can message another user, you must first verify your email.
</Trans>,
],
},
)
// Stable reference to the (otherwise per-render) opener so the effect below
// doesn't list it as a dependency - if it did, clearing the param would
// re-run the effect and its cleanup would cancel the pending open.
const openGroupChat = useNonReactiveCallback(wrappedOpenGroupChatControl)
// Deep link into the group-chat creation step of the new-chat dialog. The
// dialog control isn't attached synchronously when navigating onto this
// screen, so defer the open by a tick. We clear the param *after* opening
// (inside the timeout) so clearing doesn't cancel the pending open.
useEffect(() => {
if (!pushToNewGroupChat) return
const timeout = setTimeout(() => {
openGroupChat()
if (IS_WEB) {
// `navigation.setParams({pushToNewGroupChat: undefined})` serializes the
// literal string "undefined" into the query on web (see router build()),
// so strip the param with the history API instead.
const url = new URL(window.location.href)
url.searchParams.delete('pushToNewGroupChat')
history.replaceState(null, '', url.pathname + url.search + url.hash)
} else {
navigation.setParams({pushToNewGroupChat: undefined})
}
}, 100)
return () => clearTimeout(timeout)
}, [navigation, pushToNewGroupChat, openGroupChat])
if (isWithinSplitView) {
return (
<>
@@ -172,7 +224,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
}
style={[a.h_full, a.justify_center, a.pb_5xl]}
/>
<NewChat onNewChat={onNewChat} control={newChatControl} />
<NewChat
onNewChat={onNewChat}
control={newChatControl}
startInGroupChat={startNewChatInGroupChat}
onClose={() => setStartNewChatInGroupChat(false)}
/>
</>
)
}
@@ -181,7 +238,12 @@ export function MessagesScreenInner({navigation, route}: Props) {
<Layout.Screen testID="messagesScreen">
<Header newChatControl={newChatControl} chatStatus={chatStatus} />
<ChatList newChatControl={newChatControl} chatStatus={chatStatus} />
<NewChat onNewChat={onNewChat} control={newChatControl} />
<NewChat
onNewChat={onNewChat}
control={newChatControl}
startInGroupChat={startNewChatInGroupChat}
onClose={() => setStartNewChatInGroupChat(false)}
/>
</Layout.Screen>
)
}
+6
View File
@@ -15,6 +15,7 @@ export enum Nux {
LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge',
DraftsAnnouncement = 'DraftsAnnouncement',
GroupChatsAnnouncement = 'GroupChatsAnnouncement',
InviteFriendsAnnouncement = 'InviteFriendsAnnouncement',
/*
@@ -78,6 +79,10 @@ export type AppNux = BaseNux<
id: Nux.DraftsAnnouncement
data: undefined
}
| {
id: Nux.GroupChatsAnnouncement
data: undefined
}
| {
id: Nux.InviteFriendsAnnouncement
data: undefined
@@ -98,5 +103,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined,
[Nux.DraftsAnnouncement]: undefined,
[Nux.GroupChatsAnnouncement]: undefined,
[Nux.InviteFriendsAnnouncement]: undefined,
}