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 {LinearGradient} from 'expo-linear-gradient'
import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
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 {Text} from '#/components/Typography'
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {createIsEnabledCheck} from './utils'
export const enabled = createIsEnabledCheck(() => {
@@ -26,25 +25,23 @@ export function InviteFriendsAnnouncement() {
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
const inviteFriendsControl = Dialog.useDialogControl()
const [openInviteAfterClose, setOpenInviteAfterClose] = useState(false)
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
if (openInviteAfterClose) {
// 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])
}, [nuxDialogs])
const onPressTryIt = useCallback(() => {
setOpenInviteAfterClose(true)
control.close()
}, [control])
// Close this announcement (which dismisses + unmounts the NUX), then open
// the invite dialog. The invite dialog is mounted persistently by NuxDialogs
// (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 (
<>
@@ -62,38 +59,37 @@ export function InviteFriendsAnnouncement() {
]}>
<View
style={[
a.align_center,
a.w_full,
a.relative,
a.overflow_hidden,
{
paddingTop: IS_WEB ? 24 : 40,
paddingBottom: 16,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<LinearGradient
colors={[t.palette.primary_100, t.palette.primary_200]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 0, y: 1}}
style={[a.absolute, a.inset_0]}
{/* The image is the full header background; the tag sits on top of it. */}
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/invite_friends_announcement_nux.webp')}
style={[a.w_full, {aspectRatio: 754 / 440}]}
alt={_(
msg`An illustration of the Bluesky app with a paper airplane flying out of it, representing inviting friends`,
)}
/>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text style={[a.font_semi_bold, {color: t.palette.primary_800}]}>
<Trans>New Feature</Trans>
</Text>
</View>
{/*
TODO: drop promo image
*/}
<View
style={{
width: 220,
height: 140,
marginTop: 12,
}}
/>
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]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[a.font_semi_bold, {color: t.palette.primary_800}]}>
<Trans>New Feature</Trans>
</Text>
</View>
</View>
</View>
<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]}>
@@ -151,8 +147,6 @@ export function InviteFriendsAnnouncement() {
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
<InviteFriendsDialog control={inviteFriendsControl} />
</>
)
}
+19 -1
View File
@@ -28,12 +28,21 @@ import {
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import * as Dialog from '#/components/Dialog'
import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation'
import {InviteFriendsDialog} from '#/features/inviteFriends'
type Context = {
activeNux: Nux | undefined
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: {
@@ -53,6 +62,7 @@ const queuedNuxs: {
const Context = createContext<Context>({
activeNux: undefined,
dismissActiveNux: () => {},
openInviteFriends: () => {},
})
Context.displayName = 'NuxDialogContext'
@@ -105,6 +115,7 @@ function Inner({
const [activeNux, setActiveNux] = useState<Nux | undefined>()
const {mutateAsync: saveNux} = useSaveNux()
const {mutate: resetNuxs} = useResetNuxs()
const inviteFriendsControl = Dialog.useDialogControl()
const snoozeNuxDialog = useCallback(() => {
snooze()
@@ -188,8 +199,9 @@ function Inner({
return {
activeNux,
dismissActiveNux,
openInviteFriends: () => inviteFriendsControl.open(),
}
}, [activeNux, dismissActiveNux])
}, [activeNux, dismissActiveNux, inviteFriendsControl])
return (
<Context.Provider value={ctx}>
@@ -198,6 +210,12 @@ function Inner({
{activeNux === Nux.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>
)
}
@@ -3,12 +3,17 @@ import {InviteFriendsDialogInner} from './InviteFriendsDialogInner'
export function InviteFriendsDialog({
control,
onClose,
}: {
control: Dialog.DialogControlProps
onClose?: () => void
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
{/* <Dialog.Handle /> */}
<InviteFriendsDialogInner control={control} />
</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
* nothing on web.
*/
export function InviteFriendsDialog(_props: {control: DialogControlProps}) {
export function InviteFriendsDialog(_props: {
control: DialogControlProps
onClose?: () => void
}) {
return null
}
@@ -13,6 +13,7 @@ import {logger} from '#/logger'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {Error as ErrorMessage} from '#/components/Error'
@@ -120,13 +121,24 @@ export function InviteFriendsDialogInner({
return (
<Dialog.ScrollableInner
label={l`Invite friends`}
contentContainerStyle={[a.pt_sm]}
contentContainerStyle={[a.pt_0, a.px_0]}
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.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} />
<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 {useAnalytics} from '#/analytics'
import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
@@ -77,7 +76,6 @@ export function SettingsScreen({}: Props) {
const {logoutEveryAccount} = useSessionApi()
const {accounts, currentAccount} = useSession()
const switchAccountControl = useDialogControl()
const inviteFriendsControl = useDialogControl()
const signOutPromptControl = Prompt.usePromptControl()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {data: otherProfiles} = useProfilesQuery({
@@ -210,27 +208,15 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans>
</SettingsList.ItemText>
</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 &&
findContactsEnabled &&
!ax.features.enabled(ax.features.ImportContactsSettingsDisable) && (
<SettingsList.LinkItem
to="/settings/find-contacts"
label={l`Find friends from contacts`}>
label={l`Find and invite friends`}>
<SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText>
<Trans>Find friends from contacts</Trans>
<Trans>Find and invite friends</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
)}
@@ -316,7 +302,6 @@ export function SettingsScreen({}: Props) {
/>
<SwitchAccountDialog control={switchAccountControl} />
<InviteFriendsDialog control={inviteFriendsControl} />
</Layout.Screen>
)
}
+36 -34
View File
@@ -91,38 +91,7 @@ let DrawerProfileCard = ({
accessibilityLabel={_(msg`Profile`)}
accessibilityHint={_(msg`Navigates to your profile`)}
onPress={onPressProfile}
style={[a.gap_sm, a.pr_lg, {position: 'relative'}]}>
{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>
)}
style={[a.gap_sm, a.pr_lg]}>
<UserAvatar
size={52}
avatar={profile?.avatar}
@@ -135,11 +104,45 @@ let DrawerProfileCard = ({
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text
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}>
{profile?.displayName || account.handle}
</Text>
{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>
<Text
emoji
@@ -325,7 +328,6 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
account={currentAccount}
onPressProfile={onPressDrawerHeaderProfile}
onPressShare={() => {
setDrawerOpen(false)
inviteFriendsControl.open()
}}
/>