[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
@@ -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
}