Invite friends QA polish

- Drawer: move the share button inline with the display name (right-aligned)
  and keep the drawer open when opening the share sheet
- Settings: remove the redundant "Find and invite friends" item and rename the
  existing contacts link to "Find and invite friends"
- Invite dialog: add a "Done" button and a full-width header divider using the
  Dialog.Header renderLeft + edge-to-edge content padding pattern
- NUX announcement: wire up the promo illustration as the full-bleed header
  background with the "New Feature" tag overlaid
- Fix "Try it": mount the invite dialog persistently in NuxDialogs so it
  survives the announcement being dismissed, then open it after the handoff
This commit is contained in:
vineyardbovines
2026-06-01 11:18:17 -04:00
parent 42732c8bea
commit 11ff9fa3a7
8 changed files with 118 additions and 99 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,6 +1,6 @@
import {useCallback, useState} from 'react' import {useCallback} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient' import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -12,7 +12,6 @@ import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env' import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {createIsEnabledCheck} from './utils' import {createIsEnabledCheck} from './utils'
export const enabled = createIsEnabledCheck(() => { export const enabled = createIsEnabledCheck(() => {
@@ -26,25 +25,23 @@ export function InviteFriendsAnnouncement() {
const {_} = useLingui() const {_} = useLingui()
const nuxDialogs = useNuxDialogContext() const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const inviteFriendsControl = Dialog.useDialogControl()
const [openInviteAfterClose, setOpenInviteAfterClose] = useState(false)
Dialog.useAutoOpen(control) Dialog.useAutoOpen(control)
const onClose = useCallback(() => { const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux() nuxDialogs.dismissActiveNux()
if (openInviteAfterClose) { }, [nuxDialogs])
// Defer to the next tick so the close animation completes before
// we open the next dialog (avoids visual glitch on iOS).
setOpenInviteAfterClose(false)
inviteFriendsControl.open()
}
}, [nuxDialogs, openInviteAfterClose, inviteFriendsControl])
const onPressTryIt = useCallback(() => { const onPressTryIt = useCallback(() => {
setOpenInviteAfterClose(true) // Close this announcement (which dismisses + unmounts the NUX), then open
control.close() // the invite dialog. The invite dialog is mounted persistently by NuxDialogs
}, [control]) // (not here) so it survives the dismissal - the native bottom sheet cannot
// hand off to a second sheet mounted in this same subtree. Defer the open to
// the next tick so the announcement's teardown completes first.
control.close(() => {
setTimeout(() => nuxDialogs.openInviteFriends())
})
}, [control, nuxDialogs])
return ( return (
<> <>
@@ -62,38 +59,37 @@ export function InviteFriendsAnnouncement() {
]}> ]}>
<View <View
style={[ style={[
a.align_center, a.w_full,
a.relative,
a.overflow_hidden, a.overflow_hidden,
{ {
paddingTop: IS_WEB ? 24 : 40,
paddingBottom: 16,
borderTopLeftRadius: a.rounded_md.borderRadius, borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius, borderTopRightRadius: a.rounded_md.borderRadius,
}, },
]}> ]}>
<LinearGradient {/* The image is the full header background; the tag sits on top of it. */}
colors={[t.palette.primary_100, t.palette.primary_200]} <Image
locations={[0, 1]} accessibilityIgnoresInvertColors
start={{x: 0, y: 0}} source={require('../../../../assets/images/invite_friends_announcement_nux.webp')}
end={{x: 0, y: 1}} style={[a.w_full, {aspectRatio: 754 / 440}]}
style={[a.absolute, a.inset_0]} alt={_(
msg`An illustration of the Bluesky app with a paper airplane flying out of it, representing inviting friends`,
)}
/> />
<View
style={[
a.absolute,
a.align_center,
{top: 0, left: 0, right: 0, paddingTop: IS_WEB ? 24 : 20},
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" /> <SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text style={[a.font_semi_bold, {color: t.palette.primary_800}]}> <Text
style={[a.font_semi_bold, {color: t.palette.primary_800}]}>
<Trans>New Feature</Trans> <Trans>New Feature</Trans>
</Text> </Text>
</View> </View>
{/* </View>
TODO: drop promo image
*/}
<View
style={{
width: 220,
height: 140,
marginTop: 12,
}}
/>
</View> </View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}> <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
<View style={[a.gap_sm, a.align_center]}> <View style={[a.gap_sm, a.align_center]}>
@@ -151,8 +147,6 @@ export function InviteFriendsAnnouncement() {
<Dialog.Close /> <Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
<InviteFriendsDialog control={inviteFriendsControl} />
</> </>
) )
} }
+19 -1
View File
@@ -28,12 +28,21 @@ import {
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement' } from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import * as Dialog from '#/components/Dialog'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
import {InviteFriendsDialog} from '#/features/inviteFriends'
type Context = { type Context = {
activeNux: Nux | undefined activeNux: Nux | undefined
dismissActiveNux: () => void dismissActiveNux: () => void
/**
* Opens the invite-friends dialog. It is mounted persistently by NuxDialogs
* (not inside the announcement NUX) so it survives the announcement being
* dismissed - the native bottom sheet cannot hand off between two sheets
* mounted in the same subtree.
*/
openInviteFriends: () => void
} }
const queuedNuxs: { const queuedNuxs: {
@@ -53,6 +62,7 @@ const queuedNuxs: {
const Context = createContext<Context>({ const Context = createContext<Context>({
activeNux: undefined, activeNux: undefined,
dismissActiveNux: () => {}, dismissActiveNux: () => {},
openInviteFriends: () => {},
}) })
Context.displayName = 'NuxDialogContext' Context.displayName = 'NuxDialogContext'
@@ -105,6 +115,7 @@ function Inner({
const [activeNux, setActiveNux] = useState<Nux | undefined>() const [activeNux, setActiveNux] = useState<Nux | undefined>()
const {mutateAsync: saveNux} = useSaveNux() const {mutateAsync: saveNux} = useSaveNux()
const {mutate: resetNuxs} = useResetNuxs() const {mutate: resetNuxs} = useResetNuxs()
const inviteFriendsControl = Dialog.useDialogControl()
const snoozeNuxDialog = useCallback(() => { const snoozeNuxDialog = useCallback(() => {
snooze() snooze()
@@ -188,8 +199,9 @@ function Inner({
return { return {
activeNux, activeNux,
dismissActiveNux, dismissActiveNux,
openInviteFriends: () => inviteFriendsControl.open(),
} }
}, [activeNux, dismissActiveNux]) }, [activeNux, dismissActiveNux, inviteFriendsControl])
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
@@ -198,6 +210,12 @@ function Inner({
{activeNux === Nux.InviteFriendsAnnouncement && ( {activeNux === Nux.InviteFriendsAnnouncement && (
<InviteFriendsAnnouncement /> <InviteFriendsAnnouncement />
)} )}
{/*
Mounted persistently (not inside the announcement) so the invite dialog
survives the announcement NUX being dismissed during the "Try it"
handoff.
*/}
<InviteFriendsDialog control={inviteFriendsControl} />
</Context.Provider> </Context.Provider>
) )
} }
@@ -3,12 +3,17 @@ import {InviteFriendsDialogInner} from './InviteFriendsDialogInner'
export function InviteFriendsDialog({ export function InviteFriendsDialog({
control, control,
onClose,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
onClose?: () => void
}) { }) {
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer
<Dialog.Handle /> control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
{/* <Dialog.Handle /> */}
<InviteFriendsDialogInner control={control} /> <InviteFriendsDialogInner control={control} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -5,6 +5,9 @@ import {type DialogControlProps} from '#/components/Dialog'
* Callers may still create a Dialog control and pass it; opening it does * Callers may still create a Dialog control and pass it; opening it does
* nothing on web. * nothing on web.
*/ */
export function InviteFriendsDialog(_props: {control: DialogControlProps}) { export function InviteFriendsDialog(_props: {
control: DialogControlProps
onClose?: () => void
}) {
return null return null
} }
@@ -13,6 +13,7 @@ import {logger} from '#/logger'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {Error as ErrorMessage} from '#/components/Error' import {Error as ErrorMessage} from '#/components/Error'
@@ -120,13 +121,24 @@ export function InviteFriendsDialogInner({
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={l`Invite friends`} label={l`Invite friends`}
contentContainerStyle={[a.pt_sm]} contentContainerStyle={[a.pt_0, a.px_0]}
header={ header={
<Dialog.Header> <Dialog.Header
renderLeft={() => (
<Button
label={l`Done`}
onPress={() => control.close()}
size="small"
color="primary"
variant="ghost"
style={[a.rounded_full]}>
<ButtonText style={[a.text_md]}>{l`Done`}</ButtonText>
</Button>
)}>
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText> <Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText>
</Dialog.Header> </Dialog.Header>
}> }>
<View style={[a.align_center, a.pt_xl]}> <View style={[a.align_center, a.pt_xl, a.px_xl]}>
<ThemePicker value={themeKey} onChange={setThemeKey} /> <ThemePicker value={themeKey} onChange={setThemeKey} />
<View style={[a.mt_5xl]}> <View style={[a.mt_5xl]}>
+2 -17
View File
@@ -64,7 +64,6 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import {device, useStorage} from '#/storage' import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
@@ -77,7 +76,6 @@ export function SettingsScreen({}: Props) {
const {logoutEveryAccount} = useSessionApi() const {logoutEveryAccount} = useSessionApi()
const {accounts, currentAccount} = useSession() const {accounts, currentAccount} = useSession()
const switchAccountControl = useDialogControl() const switchAccountControl = useDialogControl()
const inviteFriendsControl = useDialogControl()
const signOutPromptControl = Prompt.usePromptControl() const signOutPromptControl = Prompt.usePromptControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {data: otherProfiles} = useProfilesQuery({ const {data: otherProfiles} = useProfilesQuery({
@@ -210,27 +208,15 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans> <Trans>Content and media</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </SettingsList.LinkItem>
{IS_NATIVE && (
<SettingsList.PressableItem
label={l`Find and invite friends`}
accessibilityHint={l`Opens the invite friends sheet to share your profile`}
onPress={() => inviteFriendsControl.open()}>
<SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText>
<Trans>Find and invite friends</Trans>
</SettingsList.ItemText>
<SettingsList.Chevron />
</SettingsList.PressableItem>
)}
{IS_NATIVE && {IS_NATIVE &&
findContactsEnabled && findContactsEnabled &&
!ax.features.enabled(ax.features.ImportContactsSettingsDisable) && ( !ax.features.enabled(ax.features.ImportContactsSettingsDisable) && (
<SettingsList.LinkItem <SettingsList.LinkItem
to="/settings/find-contacts" to="/settings/find-contacts"
label={l`Find friends from contacts`}> label={l`Find and invite friends`}>
<SettingsList.ItemIcon icon={ContactsIcon} /> <SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText> <SettingsList.ItemText>
<Trans>Find friends from contacts</Trans> <Trans>Find and invite friends</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </SettingsList.LinkItem>
)} )}
@@ -316,7 +302,6 @@ export function SettingsScreen({}: Props) {
/> />
<SwitchAccountDialog control={switchAccountControl} /> <SwitchAccountDialog control={switchAccountControl} />
<InviteFriendsDialog control={inviteFriendsControl} />
</Layout.Screen> </Layout.Screen>
) )
} }
+36 -34
View File
@@ -91,38 +91,7 @@ let DrawerProfileCard = ({
accessibilityLabel={_(msg`Profile`)} accessibilityLabel={_(msg`Profile`)}
accessibilityHint={_(msg`Navigates to your profile`)} accessibilityHint={_(msg`Navigates to your profile`)}
onPress={onPressProfile} onPress={onPressProfile}
style={[a.gap_sm, a.pr_lg, {position: 'relative'}]}> style={[a.gap_sm, a.pr_lg]}>
{onPressShare && (
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Invite friends`)}
accessibilityHint={_(
msg`Opens the invite friends sheet to share your profile`,
)}
onPress={onPressShare}
hitSlop={8}
style={({pressed}) => [
{
position: 'absolute',
top: 4,
right: 16,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.7 : 1,
zIndex: 1,
},
]}>
<ArrowShareRight
width={16}
height={16}
fill={t.palette.primary_500}
/>
</Pressable>
)}
<UserAvatar <UserAvatar
size={52} size={52}
avatar={profile?.avatar} avatar={profile?.avatar}
@@ -135,11 +104,45 @@ let DrawerProfileCard = ({
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text <Text
emoji emoji
style={[a.font_bold, a.text_xl, a.mt_2xs, a.leading_tight]} style={[
a.font_bold,
a.text_xl,
a.mt_2xs,
a.leading_tight,
a.flex_shrink,
]}
numberOfLines={1}> numberOfLines={1}>
{profile?.displayName || account.handle} {profile?.displayName || account.handle}
</Text> </Text>
{profile && <ProfileBadges profile={profile} size="lg" />} {profile && <ProfileBadges profile={profile} size="lg" />}
{onPressShare && (
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Invite friends`)}
accessibilityHint={_(
msg`Opens the invite friends sheet to share your profile`,
)}
onPress={onPressShare}
hitSlop={8}
style={({pressed}) => [
a.ml_auto,
{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.7 : 1,
},
]}>
<ArrowShareRight
width={16}
height={16}
fill={t.palette.primary_500}
/>
</Pressable>
)}
</View> </View>
<Text <Text
emoji emoji
@@ -325,7 +328,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
account={currentAccount} account={currentAccount}
onPressProfile={onPressDrawerHeaderProfile} onPressProfile={onPressDrawerHeaderProfile}
onPressShare={() => { onPressShare={() => {
setDrawerOpen(false)
inviteFriendsControl.open() inviteFriendsControl.open()
}} }}
/> />