add invite friends ui

This commit is contained in:
vineyardbovines
2026-05-29 17:21:41 -04:00
parent bccca283b9
commit 42732c8bea
28 changed files with 1762 additions and 25 deletions
+6
View File
@@ -132,6 +132,7 @@ import {
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {InviteScannerScreen} from '#/features/inviteFriends'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
@@ -307,6 +308,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="InviteScanner"
getComponent={() => InviteScannerScreen}
options={{title: title(msg`Scan QR Code`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
@@ -0,0 +1,158 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
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(() => {
// Native-only feature (matches the rest of the invite-friends scope).
// Skipped in E2E to keep tests deterministic.
return !IS_E2E && IS_NATIVE
})
export function InviteFriendsAnnouncement() {
const t = useTheme()
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])
const onPressTryIt = useCallback(() => {
setOpenInviteAfterClose(true)
control.close()
}, [control])
return (
<>
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle fill={t.palette.primary_400} />
<Dialog.ScrollableInner
label={_(msg`Invite your friends`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{paddingTop: 0, paddingLeft: 0, paddingRight: 0},
]}>
<View
style={[
a.align_center,
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]}
/>
<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,
}}
/>
</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]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_bold,
a.text_center,
{fontSize: IS_WEB ? 28 : 32, maxWidth: 300},
]}>
<Trans>Invite your friends</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
t.atoms.text_contrast_medium,
{maxWidth: 340},
]}>
<Trans>
You can now invite friends with a link or QR code. Share it
anywhere or let them scan to join you on Bluesky.
</Trans>
</Text>
</View>
{!IS_WEB && (
<View style={[a.w_full, a.gap_sm]}>
<Button
label={_(msg`Try it`)}
size="large"
color="primary"
onPress={onPressTryIt}
style={[a.w_full]}>
<ButtonText>
<Trans>Try it</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary"
onPress={() => control.close()}
style={[a.w_full]}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
<InviteFriendsDialog control={inviteFriendsControl} />
</>
)
}
+11
View File
@@ -22,6 +22,10 @@ import {
DraftsAnnouncement,
enabled as isDraftsAnnouncementEnabled,
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
import {
enabled as isInviteFriendsAnnouncementEnabled,
InviteFriendsAnnouncement,
} from '#/components/dialogs/nuxs/InviteFriendsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useAnalytics} from '#/analytics'
@@ -40,6 +44,10 @@ const queuedNuxs: {
id: Nux.DraftsAnnouncement,
enabled: isDraftsAnnouncementEnabled,
},
{
id: Nux.InviteFriendsAnnouncement,
enabled: isInviteFriendsAnnouncementEnabled,
},
]
const Context = createContext<Context>({
@@ -187,6 +195,9 @@ function Inner({
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
{activeNux === Nux.InviteFriendsAnnouncement && (
<InviteFriendsAnnouncement />
)}
</Context.Provider>
)
}
@@ -0,0 +1,15 @@
import * as Dialog from '#/components/Dialog'
import {InviteFriendsDialogInner} from './InviteFriendsDialogInner'
export function InviteFriendsDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<InviteFriendsDialogInner control={control} />
</Dialog.Outer>
)
}
@@ -0,0 +1,10 @@
import {type DialogControlProps} from '#/components/Dialog'
/**
* Web no-op - the invite-friends share sheet is native-only by design.
* Callers may still create a Dialog control and pass it; opening it does
* nothing on web.
*/
export function InviteFriendsDialog(_props: {control: DialogControlProps}) {
return null
}
@@ -0,0 +1,238 @@
import {Suspense, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import type ViewShot from 'react-native-view-shot'
import {setStringAsync} from 'expo-clipboard'
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
import {createAssetAsync} from 'expo-media-library'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {shareUrl as nativeShareUrl} from '#/lib/sharing'
import {logger} from '#/logger'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {Error as ErrorMessage} from '#/components/Error'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {ActionButtons} from './components/ActionButtons'
import {ThemedQrCard} from './components/ThemedQrCard'
import {ThemePicker} from './components/ThemePicker'
import {getInviteTheme, type InviteThemeKey} from './themes'
import {getInviteDisplayUrl, getInviteShareUrl} from './urls'
export function InviteFriendsDialogInner({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {t: l} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession()
const profileQuery = useProfileQuery({did: currentAccount?.did})
const [themeKey, setThemeKey] = useState<InviteThemeKey>('day')
const captureRef = useRef<ViewShot>(null)
const theme = getInviteTheme(themeKey)
const variant = t.name === 'light' ? theme.light : theme.dark
const handle = profileQuery.data?.handle
const avatarUri = profileQuery.data?.avatar
// 'handle.invalid' is the atproto sentinel for an unresolvable DID - treat
// it like a load error so we don't display a broken share URL.
const isHandleValid = !!handle && handle !== 'handle.invalid'
const canonicalShareUrl = isHandleValid ? getInviteShareUrl(handle) : ''
const cosmeticDisplayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
const onShare = async () => {
if (!canonicalShareUrl) {
Toast.show(l`Could not share - please try again`, {type: 'error'})
return
}
try {
await nativeShareUrl(canonicalShareUrl)
} catch (err) {
logger.error('InviteFriendsDialog: share failed', {safeMessage: err})
}
}
const onDownload = async () => {
if (!IS_NATIVE) return
if (!canonicalShareUrl) {
Toast.show(l`Could not download - please try again`, {type: 'error'})
return
}
const uri = await captureRef.current?.capture?.()
if (!uri) {
Toast.show(l`Could not capture QR code`, {type: 'error'})
return
}
const permission = await requestMediaLibraryPermissionsAsync()
if (!permission.granted) {
Toast.show(
l`You must grant access to your photo library to save the QR code`,
{type: 'error'},
)
return
}
try {
await createAssetAsync(`file://${uri}`)
Toast.show(l`QR code saved to your camera roll`)
} catch (err) {
logger.error('InviteFriendsDialog: download failed', {safeMessage: err})
Toast.show(l`Could not save QR code`, {type: 'error'})
}
}
const onScan = () => {
// Close dialog first, then navigate (control.close callback per CLAUDE.md
// Dialog footgun rule — prevents race with the navigation push).
control.close(() => {
navigation.navigate('InviteScanner')
})
}
const onCopy = async () => {
if (!canonicalShareUrl) {
Toast.show(l`Could not copy - please try again`, {type: 'error'})
return
}
try {
await setStringAsync(canonicalShareUrl)
Toast.show(l`Invite link copied`)
} catch (err) {
logger.error('InviteFriendsDialog: copy failed', {safeMessage: err})
Toast.show(l`Could not copy link`, {type: 'error'})
}
}
return (
<Dialog.ScrollableInner
label={l`Invite friends`}
contentContainerStyle={[a.pt_sm]}
header={
<Dialog.Header>
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText>
</Dialog.Header>
}>
<View style={[a.align_center, a.pt_xl]}>
<ThemePicker value={themeKey} onChange={setThemeKey} />
<View style={[a.mt_5xl]}>
{profileQuery.isLoading ? (
<View style={[a.align_center, a.justify_center, {minHeight: 360}]}>
<Loader size="xl" />
</View>
) : profileQuery.error || !isHandleValid ? (
<View style={[a.w_full, {minHeight: 360}]}>
<ErrorMessage
title={l`Could not load profile`}
message={l`Try again in a moment.`}
onRetry={() => profileQuery.refetch()}
hideBackButton
/>
</View>
) : (
<Suspense
fallback={
<View
style={[a.align_center, a.justify_center, {minHeight: 360}]}>
<Loader size="xl" />
</View>
}>
<ThemedQrCard
variant={variant}
shareUrl={canonicalShareUrl}
handle={handle ?? ''}
avatarUri={avatarUri}
captureRef={captureRef}
/>
</Suspense>
)}
</View>
<View style={[a.w_full, {marginTop: 48}]}>
<ActionButtons
onShare={onShare}
onDownload={onDownload}
onScan={onScan}
/>
</View>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.w_full,
a.mt_4xl,
{gap: 24},
]}>
<Divider style={[a.flex_1]} />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{l`Invite link`}
</Text>
<Divider style={[a.flex_1]} />
</View>
<View
style={[
a.flex_row,
a.align_center,
a.w_full,
a.mt_4xl,
{
height: 44,
gap: 4,
paddingHorizontal: 12,
paddingVertical: 11,
borderRadius: 10,
backgroundColor: t.palette.contrast_50,
},
]}>
<ChainLinkIcon
width={20}
height={20}
fill={t.atoms.text_contrast_medium.color}
/>
<Text
style={[
a.flex_1,
a.text_md,
{color: t.palette.contrast_975, lineHeight: 19.5},
]}
numberOfLines={1}>
{cosmeticDisplayUrl || ' '}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Copy invite link`}
accessibilityHint={l`Copies the canonical profile URL to the clipboard`}
onPress={onCopy}
hitSlop={8}
style={({pressed}) => [{opacity: pressed ? 0.7 : 1}]}>
<Text
style={[
a.text_md,
a.font_semi_bold,
{color: t.palette.primary_500, lineHeight: 19.5},
]}>
{l`Copy`}
</Text>
</Pressable>
</View>
</View>
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,326 @@
import {useCallback, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
type BarcodeScanningResult,
CameraView,
useCameraPermissions,
} from 'expo-camera'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
const SCAN_FRAME_SIZE = 333
export function InviteScannerScreen() {
const {t: l} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const navigation = useNavigation<NavigationProp>()
const [permission, requestPermission] = useCameraPermissions()
const [error, setError] = useState<string | null>(null)
const [scannerEnabled, setScannerEnabled] = useState(true)
const onClose = useCallback(() => {
navigation.goBack()
}, [navigation])
const onBarcodeScanned = useCallback(
(result: BarcodeScanningResult) => {
if (!scannerEnabled) return
const url = result.data?.trim()
if (!url) return
// Match bsky.app/profile/{handle} URLs (with or without https://).
const profileMatch = url.match(
/^(?:https?:\/\/)?bsky\.app\/profile\/([^/?#]+)/i,
)
if (!profileMatch) {
setScannerEnabled(false)
setError(
l({
message: 'Profile not found',
comment:
'Error shown when a scanned QR code is not a valid Bluesky profile URL',
}),
)
return
}
setScannerEnabled(false)
const handle = profileMatch[1]
navigation.replace('Profile', {name: handle})
},
[scannerEnabled, navigation, l],
)
const onRetry = useCallback(() => {
setError(null)
setScannerEnabled(true)
}, [])
const onPressGallery = useCallback(() => {
// TODO: implement gallery image picker + QR decode. expo-camera only
// scans live; decoding from a gallery image requires an additional
// library (e.g. @react-native-vision-camera + vision-camera-code-scanner,
// or a JS-side decoder like jsqr running on a pixel array).
logger.warn('InviteScanner: gallery picker not implemented yet')
}, [])
// Permission states ---------------------------------------------------------
if (!permission) {
// Still loading the initial permission state — render nothing.
return <Layout.Screen />
}
if (!permission.granted) {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>{l`Scan QR Code`}</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.p_xl,
a.gap_md,
]}>
<InfoIcon size="xl" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_xl, a.font_bold, a.text_center]}>
{l`Camera access needed`}
</Text>
<Text
style={[
a.text_md,
a.text_center,
t.atoms.text_contrast_medium,
{maxWidth: 320},
]}>
{l`Bluesky needs camera access to scan QR codes from other profiles.`}
</Text>
<Button
label={l`Grant access`}
color="primary"
size="large"
onPress={() => requestPermission()}
style={{marginTop: 8}}>
<ButtonText>{l`Grant access`}</ButtonText>
</Button>
</View>
</Layout.Screen>
)
}
// Active scanner ------------------------------------------------------------
return (
<Layout.Screen
minimalShell
noInsetTop
style={{backgroundColor: t.palette.black}}>
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{barcodeTypes: ['qr']}}
onBarcodeScanned={scannerEnabled ? onBarcodeScanned : undefined}
/>
<ScannerScrim />
<View
style={{
position: 'absolute',
top: insets.top,
left: 0,
right: 0,
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Close scanner`}
accessibilityHint={l`Dismisses the QR scanner`}
onPress={onClose}
hitSlop={12}
style={({pressed}) => [
{
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
},
]}>
<ArrowLeftIcon size="lg" fill={t.palette.white} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Pick from gallery`}
accessibilityHint={l`Choose a QR code image from your photo library`}
onPress={onPressGallery}
hitSlop={12}
style={({pressed}) => [
{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
},
]}>
<ImageIcon width={16} height={16} fill={t.palette.white} />
</Pressable>
</View>
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
{error && (
<View
style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
transform: [{translateY: -118}],
alignItems: 'center',
}}
pointerEvents="box-none">
<View
style={[
a.gap_md,
a.align_center,
{
width: 280,
paddingHorizontal: 24,
paddingVertical: 24,
borderRadius: 16,
backgroundColor: t.palette.white,
},
]}>
<InfoIcon size="xl" fill={t.palette.negative_500} />
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
{l`Profile not found`}
</Text>
<Text
style={[
a.text_md,
a.text_center,
{color: t.palette.contrast_700},
]}>
{l`Oops, this profile doesn't exist. Try scanning another one.`}
</Text>
<Button
label={l`Try again`}
color="negative"
size="large"
onPress={onRetry}
style={{width: '100%', marginTop: 4}}>
<ButtonText>{l`Try again`}</ButtonText>
</Button>
</View>
</View>
)}
</Layout.Screen>
)
}
/**
* Renders a dark scrim covering the camera view with a transparent 333x333
* cutout in the middle. We use four absolute Views forming a window-frame
* shape because React Native doesn't support CSS clip-path / mix-blend modes.
*/
function ScannerScrim() {
const SCRIM_BG = 'rgba(0, 0, 0, 0.55)'
return (
<>
{/* top */}
<View
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: '50%',
marginBottom: SCAN_FRAME_SIZE / 2,
backgroundColor: SCRIM_BG,
}}
/>
{/* bottom */}
<View
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
bottom: 0,
marginTop: SCAN_FRAME_SIZE / 2,
backgroundColor: SCRIM_BG,
}}
/>
{/* left */}
<View
style={{
position: 'absolute',
top: '50%',
left: 0,
width: '50%',
height: SCAN_FRAME_SIZE,
marginTop: -SCAN_FRAME_SIZE / 2,
marginRight: SCAN_FRAME_SIZE / 2,
backgroundColor: SCRIM_BG,
transform: [{translateX: -SCAN_FRAME_SIZE / 2}],
}}
/>
{/* right */}
<View
style={{
position: 'absolute',
top: '50%',
right: 0,
width: '50%',
height: SCAN_FRAME_SIZE,
marginTop: -SCAN_FRAME_SIZE / 2,
marginLeft: SCAN_FRAME_SIZE / 2,
backgroundColor: SCRIM_BG,
transform: [{translateX: SCAN_FRAME_SIZE / 2}],
}}
/>
{/* scan-frame outline (the colored border around the cutout) */}
<View
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: SCAN_FRAME_SIZE,
height: SCAN_FRAME_SIZE,
marginTop: -SCAN_FRAME_SIZE / 2,
marginLeft: -SCAN_FRAME_SIZE / 2,
borderWidth: 2,
borderColor: '#006aff',
borderRadius: 16,
}}
pointerEvents="none"
/>
</>
)
}
@@ -0,0 +1,48 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {StackedButton} from '#/components/Button'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
export function ActionButtons({
onShare,
onDownload,
onScan,
}: {
onShare: () => void
onDownload: () => void
onScan: () => void
}) {
const {t: l} = useLingui()
return (
<View style={[a.flex_row, a.justify_center, a.w_full, {gap: 12}]}>
<StackedButton
label={l({message: 'Share invite link'})}
color="primary_subtle"
icon={ShareIcon}
onPress={onShare}
style={[a.flex_1]}>
{l`Share link`}
</StackedButton>
<StackedButton
label={l({message: 'Download invite QR code'})}
color="primary_subtle"
icon={DownloadIcon}
onPress={onDownload}
style={[a.flex_1]}>
{l`Download`}
</StackedButton>
<StackedButton
label={l({message: 'Open QR scanner'})}
color="primary_subtle"
icon={CameraIcon}
onPress={onScan}
style={[a.flex_1]}>
{l`Scan`}
</StackedButton>
</View>
)
}
@@ -0,0 +1,75 @@
import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
export function FollowersPromoBanner({
onPress,
onDismiss,
}: {
onPress: () => void
onDismiss: () => void
}) {
const {t: l} = useLingui()
const t = useTheme()
return (
<View style={[a.px_lg, a.pt_md]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Invite friends`}
accessibilityHint={l`Opens the invite friends sheet to share your profile`}
onPress={onPress}
style={({pressed}) => [
a.flex_row,
a.align_center,
a.w_full,
{
height: 80,
borderRadius: 16,
backgroundColor: t.palette.primary_50,
paddingHorizontal: 16,
paddingVertical: 16,
opacity: pressed ? 0.85 : 1,
},
]}>
<View
style={{
width: 126,
height: 64,
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
}}
/>
<Text
style={[
a.flex_1,
a.text_md,
a.font_bold,
{color: t.palette.primary_700, lineHeight: 19.5},
]}>
{l`Import contacts or invite your friends`}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Dismiss`}
accessibilityHint={l`Hides the invite friends promo banner`}
onPress={onDismiss}
hitSlop={12}
style={({pressed}) => [
{
position: 'absolute',
top: 8,
right: 8,
padding: 4,
opacity: pressed ? 0.5 : 1,
},
]}>
<TimesIcon width={12} height={12} fill={t.palette.contrast_500} />
</Pressable>
</Pressable>
</View>
)
}
@@ -0,0 +1,188 @@
import {useRef, useState} from 'react'
import {Modal, Pressable, StyleSheet, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
import {Text} from '#/components/Typography'
import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes'
const PILL_LABEL_SIZE = 13.1
const SWATCH_SIZE = 14
const CHEVRON_SIZE = 8
const MENU_WIDTH = 140
const MENU_ROW_HEIGHT = 44
const MENU_ITEM_SWATCH = 18
const MENU_GAP_TO_TRIGGER = 8
export function ThemePicker({
value,
onChange,
}: {
value: InviteThemeKey
onChange: (next: InviteThemeKey) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const current = INVITE_THEMES[value]
const triggerRef = useRef<View>(null)
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState<{
x: number
y: number
w: number
h: number
} | null>(null)
const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`,
day: l`Day`,
dusk: l`Dusk`,
night: l`Night`,
}
const handleOpen = () => {
triggerRef.current?.measureInWindow((x, y, w, h) => {
setAnchor({x, y, w, h})
setOpen(true)
})
}
const handleSelect = (key: InviteThemeKey) => {
onChange(key)
setOpen(false)
}
return (
<>
<Pressable
ref={triggerRef}
accessibilityRole="button"
accessibilityLabel={l`Pick a color theme`}
accessibilityHint={l`Opens a list of color themes for the QR card`}
onPress={handleOpen}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 12,
paddingRight: 14,
paddingVertical: 7,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
opacity: pressed ? 0.7 : 1,
})}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<GradientSwatch
from={current.light.gradientFrom}
to={current.light.gradientTo}
size={SWATCH_SIZE}
/>
<Text
style={{
color: t.palette.contrast_700,
fontSize: PILL_LABEL_SIZE,
fontWeight: '500',
lineHeight: PILL_LABEL_SIZE * 1.3,
}}>
{l`Color`}
</Text>
</View>
<ChevronDown
width={CHEVRON_SIZE}
height={CHEVRON_SIZE}
fill={t.palette.contrast_700}
/>
</Pressable>
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => setOpen(false)}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Close color picker`}
accessibilityHint={l`Dismisses the color picker`}
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}>
{anchor ? (
<View
accessibilityRole="menu"
style={{
position: 'absolute',
top: anchor.y + anchor.h + MENU_GAP_TO_TRIGGER,
left: anchor.x + anchor.w / 2 - MENU_WIDTH / 2,
width: MENU_WIDTH,
backgroundColor: t.palette.white,
borderRadius: 16,
paddingVertical: 8,
shadowColor: t.palette.black,
shadowOpacity: 0.18,
shadowRadius: 24,
shadowOffset: {width: 0, height: 8},
elevation: 8,
}}>
{INVITE_THEME_KEYS.map(key => {
const theme = INVITE_THEMES[key]
return (
<Pressable
key={key}
accessibilityRole="menuitem"
accessibilityLabel={labels[key]}
accessibilityHint={l`Apply this color theme to the QR card`}
onPress={() => handleSelect(key)}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: MENU_ROW_HEIGHT,
paddingHorizontal: 16,
backgroundColor: pressed
? t.palette.contrast_50
: 'transparent',
})}>
<GradientSwatch
from={theme.light.gradientFrom}
to={theme.light.gradientTo}
size={MENU_ITEM_SWATCH}
/>
<Text
style={[
a.text_md,
a.font_medium,
{color: t.palette.contrast_975, lineHeight: 19.5},
]}>
{labels[key]}
</Text>
</Pressable>
)
})}
</View>
) : null}
</Pressable>
</Modal>
</>
)
}
function GradientSwatch({
from,
to,
size,
}: {
from: string
to: string
size: number
}) {
return (
<LinearGradient
colors={[from, to]}
start={{x: 0.5, y: 0}}
end={{x: 0.5, y: 1}}
style={{width: size, height: size, borderRadius: size / 2}}
/>
)
}
@@ -0,0 +1,255 @@
import {lazy} from 'react'
import {View} from 'react-native'
// @ts-expect-error missing types
import QRCode from 'react-native-qrcode-styled'
import type ViewShot from 'react-native-view-shot'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {type InviteThemeVariant} from '../themes'
const LazyViewShot = lazy(
// @ts-expect-error dynamic import
() => import('react-native-view-shot/src/index'),
)
const CARD_WIDTH = 278
const CARD_GRADIENT_PADDING = 12
const QR_AREA = CARD_WIDTH - CARD_GRADIENT_PADDING * 2
const QR_PIECE_SIZE = 7
const QR_INNER_PADDING = 4
const AVATAR_WRAPPER = 72
const AVATAR_IMAGE = 60
const AVATAR_RADIUS = 10
const AVATAR_BORDER = 2
export function ThemedQrCard({
variant,
shareUrl,
handle,
avatarUri,
captureRef,
}: {
variant: InviteThemeVariant
shareUrl: string
handle: string
avatarUri?: string
captureRef: React.Ref<ViewShot>
}) {
const t = useTheme()
return (
<LazyViewShot ref={captureRef}>
<View
style={{
width: CARD_WIDTH,
borderRadius: 20,
backgroundColor: variant.gradientFrom,
shadowColor: variant.shadowColor,
shadowOpacity: 0.5,
shadowRadius: 25,
shadowOffset: {width: 4, height: 4},
}}>
<LinearGradient
colors={[variant.gradientFrom, variant.gradientTo]}
start={{x: 0.5, y: 0}}
end={{x: 0.5, y: 1}}
style={[
a.align_center,
{
padding: CARD_GRADIENT_PADDING,
borderRadius: 20,
overflow: 'hidden',
},
]}>
<View
style={[
a.align_center,
a.justify_center,
{
width: QR_AREA,
height: QR_AREA,
backgroundColor: t.palette.white,
borderRadius: 10,
overflow: 'hidden',
},
]}>
<QRCode
data={shareUrl}
style={{
width: QR_AREA,
height: QR_AREA,
backgroundColor: t.palette.white,
}}
pieceSize={QR_PIECE_SIZE}
padding={QR_INNER_PADDING}
pieceBorderRadius={3.5}
{...qrGradient(variant.gradientFrom, variant.gradientTo)}
outerEyesOptions={{
topLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
innerEyesOptions={{
topLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
logo={{
hidePieces: true,
padding: 2,
scale: 0.95,
href: avatarUri
? {uri: avatarUri}
: require('../../../../assets/logo.png'),
}}
/>
<View
style={{
position: 'absolute',
width: AVATAR_WRAPPER,
height: AVATAR_WRAPPER,
borderRadius: AVATAR_RADIUS,
backgroundColor: t.palette.white,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
}}>
<View
style={{
width: AVATAR_IMAGE,
height: AVATAR_IMAGE,
borderRadius: AVATAR_RADIUS,
borderWidth: AVATAR_BORDER,
borderColor: t.palette.white,
overflow: 'hidden',
}}>
{avatarUri ? (
<Image
source={{uri: avatarUri}}
style={{
width: AVATAR_IMAGE - AVATAR_BORDER * 2,
height: AVATAR_IMAGE - AVATAR_BORDER * 2,
borderRadius: AVATAR_RADIUS - AVATAR_BORDER,
}}
accessibilityIgnoresInvertColors
/>
) : (
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Logo width={40} fill={variant.qrPrimary} />
</View>
)}
</View>
</View>
</View>
<Text
style={[
a.font_medium,
a.text_md,
{color: variant.handleColor, paddingTop: 12, paddingBottom: 2},
]}
numberOfLines={1}
ellipsizeMode="middle">
{`@${handle}`}
</Text>
</LinearGradient>
</View>
</LazyViewShot>
)
}
/**
* Returns the `gradient` prop shape that react-native-qrcode-styled accepts
* on both pieces and eyes. Mirrors the card's top-to-bottom LinearGradient
* so the QR data appears as a continuous extension of the card gradient
* rather than a separate solid-color overlay.
*/
function qrGradient(from: string, to: string) {
return {
gradient: {
type: 'linear' as const,
options: {
colors: [from, to],
start: [0.5, 0] as [number, number],
end: [0.5, 1] as [number, number],
},
},
}
}
// Vertical positions (0..1 along the QR) where the corner eye centers sit.
// QR is 33 modules square; eye centers are at module index 3 and 29.
const EYE_TOP_T = 3 / 33
const EYE_BOTTOM_T = 29 / 33
/**
* Returns the solid color the canvas-wide gradient would paint at vertical
* position `t` (0=top, 1=bottom). We use this to color each eye so that the
* eye blends seamlessly into the gradient on the surrounding data pieces.
* Eyes can't use a local `gradient` prop here - react-native-qrcode-styled
* 0.3.3 has an internal ID mismatch where the eye gradient <defs> are
* registered as `${pos}CornerSquareGradient` / `${pos}CornerDotGradient`
* but the Path fill references `url(#${pos}OuterEyeGradient)` /
* `url(#${pos}InnerEyeGradient)`, so per-eye gradients silently render
* with no fill.
*/
function eyeColor(from: string, to: string, t: number): string {
const fromRgb = hexToRgb(from)
const toRgb = hexToRgb(to)
if (!fromRgb || !toRgb) return from
const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * t)
const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * t)
const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * t)
return `#${[r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')}`
}
function hexToRgb(hex: string): {r: number; g: number; b: number} | null {
const match = hex.replace('#', '').match(/^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i)
if (!match) return null
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
}
}
@@ -0,0 +1,6 @@
/**
* Web no-op - the invite-friends feature is native-only by design.
*/
export function ThemedQrCard() {
return null
}
@@ -0,0 +1,12 @@
import {device, useStorage} from '#/storage'
/**
* Persisted dismiss state for the "Import contacts or invite your friends"
* promo banner shown on the Followers screen empty state.
*/
export function useFollowersPromoDismissed() {
const [dismissed = false, setDismissed] = useStorage(device, [
'inviteFriendsFollowersPromoDismissed',
])
return [dismissed, setDismissed] as const
}
+4
View File
@@ -0,0 +1,4 @@
export {FollowersPromoBanner} from './components/FollowersPromoBanner'
export {useFollowersPromoDismissed} from './hooks/useFollowersPromoDismissed'
export {InviteFriendsDialog} from './InviteFriendsDialog'
export {InviteScannerScreen} from './InviteScannerScreen'
+28
View File
@@ -0,0 +1,28 @@
import {describe, expect, it} from '@jest/globals'
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
describe('invite themes', () => {
it('exposes four themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night'])
})
it('has light and dark variants for every theme', () => {
for (const key of INVITE_THEME_KEYS) {
const theme = INVITE_THEMES[key]
expect(theme.light.qrPrimary).toMatch(/^#/)
expect(theme.dark.qrPrimary).toMatch(/^#/)
expect(theme.swatch).toMatch(/^#/)
}
})
it('getInviteTheme returns the requested theme', () => {
expect(getInviteTheme('day').swatch).toBe(INVITE_THEMES.day.swatch)
expect(getInviteTheme('night').swatch).toBe(INVITE_THEMES.night.swatch)
})
it('getInviteTheme falls back to day for unknown keys', () => {
// @ts-expect-error testing runtime fallback
expect(getInviteTheme('mystery').swatch).toBe(INVITE_THEMES.day.swatch)
})
})
+115
View File
@@ -0,0 +1,115 @@
/**
* Color themes for the Invite Friends share sheet (APP-2142).
*
* Each theme has a light + dark pair. The QR halo / card border use the
* matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor.
*/
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night'
export type InviteThemeVariant = {
/** QR data + eye color */
qrPrimary: string
/** Card top-to-bottom gradient start (top) */
gradientFrom: string
/** Card top-to-bottom gradient end (bottom) */
gradientTo: string
/** Card drop-shadow color (opaque hex; opacity handled separately) */
shadowColor: string
/** Handle label rendered under the QR (typically white) */
handleColor: string
}
export type InviteTheme = {
key: InviteThemeKey
/** Color shown in the theme picker dot */
swatch: string
light: InviteThemeVariant
dark: InviteThemeVariant
}
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn',
'day',
'dusk',
'night',
] as const
export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
dawn: {
key: 'dawn',
swatch: '#ff6dbe',
light: {
qrPrimary: '#ff6dbe',
gradientFrom: '#a8ccff',
gradientTo: '#ff6dbe',
shadowColor: '#ff6dbe',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#ff6dbe',
gradientFrom: '#a8ccff',
gradientTo: '#ff6dbe',
shadowColor: '#ff6dbe',
handleColor: '#ffffff',
},
},
day: {
key: 'day',
swatch: '#006aff',
light: {
qrPrimary: '#006aff',
gradientFrom: '#006aff',
gradientTo: '#75afff',
shadowColor: '#006aff',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#006aff',
gradientFrom: '#006aff',
gradientTo: '#75afff',
shadowColor: '#006aff',
handleColor: '#ffffff',
},
},
dusk: {
key: 'dusk',
swatch: '#f88f47',
light: {
qrPrimary: '#f88f47',
gradientFrom: '#f88f47',
gradientTo: '#b15aa2',
shadowColor: '#f88f47',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#f88f47',
gradientFrom: '#f88f47',
gradientTo: '#b15aa2',
shadowColor: '#f88f47',
handleColor: '#ffffff',
},
},
night: {
key: 'night',
swatch: '#0048ad',
light: {
qrPrimary: '#0048ad',
gradientFrom: '#0048ad',
gradientTo: '#001533',
shadowColor: '#001533',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#0048ad',
gradientFrom: '#0048ad',
gradientTo: '#001533',
shadowColor: '#001533',
handleColor: '#ffffff',
},
},
}
export function getInviteTheme(key: InviteThemeKey): InviteTheme {
return INVITE_THEMES[key] ?? INVITE_THEMES.day
}
+70
View File
@@ -0,0 +1,70 @@
import {describe, expect, it} from '@jest/globals'
import {getInviteDisplayUrl, getInviteShareUrl} from './urls'
describe('invite URLs', () => {
describe('getInviteShareUrl', () => {
it('returns the canonical profile URL for a handle', () => {
expect(getInviteShareUrl('alice.bsky.social')).toBe(
'https://bsky.app/profile/alice.bsky.social',
)
})
it('preserves custom domains', () => {
expect(getInviteShareUrl('thepope.dev')).toBe(
'https://bsky.app/profile/thepope.dev',
)
})
it('strips a leading @ if present', () => {
expect(getInviteShareUrl('@alice.bsky.social')).toBe(
'https://bsky.app/profile/alice.bsky.social',
)
})
it('lowercases the handle', () => {
expect(getInviteShareUrl('Alice.Bsky.Social')).toBe(
'https://bsky.app/profile/alice.bsky.social',
)
})
it('returns empty string for empty handle (EDGE-001)', () => {
expect(getInviteShareUrl('')).toBe('')
expect(getInviteShareUrl('@')).toBe('')
})
})
describe('getInviteDisplayUrl', () => {
it('returns a cosmetic bsky.app/invite/<short> string', () => {
expect(getInviteDisplayUrl('danielle.bsky.team')).toBe(
'bsky.app/invite/danielle',
)
})
it('uses the bare handle when there is no dot', () => {
expect(getInviteDisplayUrl('alice')).toBe('bsky.app/invite/alice')
})
it('lowercases the short segment', () => {
expect(getInviteDisplayUrl('Danielle.bsky.team')).toBe(
'bsky.app/invite/danielle',
)
})
it('strips a leading @ if present', () => {
expect(getInviteDisplayUrl('@alice.bsky.social')).toBe(
'bsky.app/invite/alice',
)
})
it('returns empty string for empty handle (EDGE-001)', () => {
expect(getInviteDisplayUrl('')).toBe('')
expect(getInviteDisplayUrl('@')).toBe('')
})
it('returns empty string when first segment is empty (EDGE-002)', () => {
expect(getInviteDisplayUrl('.bsky.social')).toBe('')
expect(getInviteDisplayUrl('@.bsky.social')).toBe('')
})
})
})
+28
View File
@@ -0,0 +1,28 @@
/**
* URL helpers for the Invite Friends share sheet (APP-2142).
*
* We display a cosmetic `bsky.app/invite/{shortHandle}` string to match the
* design, but every actual action (QR payload, share sheet, clipboard) uses
* the canonical `https://bsky.app/profile/{handle}` URL because no
* `bsky.app/invite/...` route exists yet.
*/
function stripLeadingAt(handle: string): string {
return handle.startsWith('@') ? handle.slice(1) : handle
}
/** Canonical URL - used for QR payload, Share, and Copy. Empty handle -> empty string. */
export function getInviteShareUrl(handle: string): string {
const bare = stripLeadingAt(handle).toLowerCase()
if (!bare) return ''
return `https://bsky.app/profile/${bare}`
}
/** Cosmetic label shown in the "Invite link" field. Not a functional URL. */
export function getInviteDisplayUrl(handle: string): string {
const bare = stripLeadingAt(handle)
if (!bare) return ''
const short = bare.split('.')[0].toLowerCase()
if (!short) return ''
return `bsky.app/invite/${short}`
}
+1
View File
@@ -59,6 +59,7 @@ export type CommonNavigatorParams = {
AboutSettings: undefined
AppIconSettings: undefined
FindContactsSettings: undefined
InviteScanner: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
+2
View File
@@ -40,6 +40,8 @@ export const router = new Router<AllNavigatableRoutes>({
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
Log: '/sys/log',
// invite friends
InviteScanner: '/invite/scan',
// settings
LanguageSettings: '/settings/language',
AppPasswords: '/settings/app-passwords',
@@ -28,6 +28,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useDialogControl} from '#/components/Dialog'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRight} from '#/components/icons/ArrowShareRight'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {
KnownFollowers,
@@ -39,6 +40,7 @@ import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog'
@@ -247,6 +249,7 @@ export function HeaderStandardButtons({
)
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
const editProfileControl = useDialogControl()
const inviteFriendsControl = useDialogControl()
const unblockPromptControl = Prompt.usePromptControl()
const isMe = currentAccount?.did === profile.did
@@ -347,7 +350,20 @@ export function HeaderStandardButtons({
<Trans>Edit Profile</Trans>
</ButtonText>
</Button>
<Button
testID="profileHeaderShareButton"
size="small"
color="secondary"
shape="round"
onPress={() => {
playHaptic('Light')
inviteFriendsControl.open()
}}
label={_(msg`Invite friends`)}>
<ButtonIcon icon={ArrowShareRight} />
</Button>
<EditProfileDialog profile={profile} control={editProfileControl} />
<InviteFriendsDialog control={inviteFriendsControl} />
</>
) : profile.viewer?.blocking ? (
profile.viewer?.blockingByList ? null : (
+18 -1
View File
@@ -39,6 +39,7 @@ import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {useDialogControl} from '#/components/Dialog'
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
@@ -50,6 +51,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../Onboarding/util'
@@ -76,7 +78,7 @@ export function FindContactsSettingsScreen({}: Props) {
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Find Friends</Trans>
<Trans>Find and Invite Friends</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
@@ -113,6 +115,7 @@ function Intro() {
const gutter = useGutters(['base'])
const t = useTheme()
const {_} = useLingui()
const inviteFriendsControl = useDialogControl()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
@@ -122,6 +125,9 @@ function Intro() {
return (
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
<ContactsHeroImage />
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Find people you know</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Find your friends on Bluesky by verifying your phone number and
@@ -161,6 +167,17 @@ function Intro() {
</Admonition>
)
)}
<Button
label={_(msg`Share my profile`)}
size="large"
color="secondary"
onPress={() => inviteFriendsControl.open()}
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Share my profile</Trans>
</ButtonText>
</Button>
<InviteFriendsDialog control={inviteFriendsControl} />
</Layout.Content>
)
}
+15
View File
@@ -64,6 +64,7 @@ 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'
@@ -76,6 +77,7 @@ 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({
@@ -208,6 +210,18 @@ 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) && (
@@ -302,6 +316,7 @@ export function SettingsScreen({}: Props) {
/>
<SwitchAccountDialog control={switchAccountControl} />
<InviteFriendsDialog control={inviteFriendsControl} />
</Layout.Screen>
)
}
+6
View File
@@ -15,6 +15,7 @@ export enum Nux {
LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge',
DraftsAnnouncement = 'DraftsAnnouncement',
InviteFriendsAnnouncement = 'InviteFriendsAnnouncement',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -77,6 +78,10 @@ export type AppNux = BaseNux<
id: Nux.DraftsAnnouncement
data: undefined
}
| {
id: Nux.InviteFriendsAnnouncement
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -93,4 +98,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined,
[Nux.DraftsAnnouncement]: undefined,
[Nux.InviteFriendsAnnouncement]: undefined,
}
+1
View File
@@ -61,6 +61,7 @@ export type Device = {
demoMode: boolean
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
inviteFriendsFollowersPromoDismissed?: boolean
/**
* Policy update overlays. New IDs are required for each new announcement.
+48 -22
View File
@@ -10,9 +10,15 @@ import {logger} from '#/logger'
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useSession} from '#/state/session'
import {useDialogControl} from '#/components/Dialog'
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useAnalytics} from '#/analytics'
import {
FollowersPromoBanner,
InviteFriendsDialog,
useFollowersPromoDismissed,
} from '#/features/inviteFriends'
import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from './ProfileCard'
@@ -158,30 +164,50 @@ export function ProfileFollowers({name}: {name: string}) {
[ax, followers, resolvedDid],
)
const inviteFriendsControl = useDialogControl()
const [followersPromoDismissed, setFollowersPromoDismissed] =
useFollowersPromoDismissed()
const showFollowersPromo =
isMe &&
!followersPromoDismissed &&
followers.length < 1 &&
!isDidLoading &&
!isFollowersLoading &&
!isError
if (followers.length < 1) {
return (
<ListMaybePlaceholder
isLoading={isDidLoading || isFollowersLoading}
isError={isError}
emptyType="results"
emptyMessage={
isMe
? _(msg`No followers yet`)
: _(msg`This user doesn't have any followers.`)
}
errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined}
sideBorders={false}
useEmptyState={true}
emptyStateIcon={PeopleRemoveIcon}
emptyStateButton={{
label: _(msg`Go back`),
text: _(msg`Go back`),
color: 'secondary',
size: 'small',
onPress: () => navigation.goBack(),
}}
/>
<>
{showFollowersPromo && (
<FollowersPromoBanner
onPress={() => inviteFriendsControl.open()}
onDismiss={() => setFollowersPromoDismissed(true)}
/>
)}
<ListMaybePlaceholder
isLoading={isDidLoading || isFollowersLoading}
isError={isError}
emptyType="results"
emptyMessage={
isMe
? _(msg`No followers yet`)
: _(msg`This user doesn't have any followers.`)
}
errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined}
sideBorders={false}
useEmptyState={true}
emptyStateIcon={PeopleRemoveIcon}
emptyStateButton={{
label: _(msg`Go back`),
text: _(msg`Go back`),
color: 'secondary',
size: 'small',
onPress: () => navigation.goBack(),
}}
/>
<InviteFriendsDialog control={inviteFriendsControl} />
</>
)
}
+12
View File
@@ -7,6 +7,8 @@ import {useSetThemePrefs} from '#/state/shell'
import {ListContained} from '#/view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {
useDeviceGeolocationApi,
useRequestDeviceGeolocation,
@@ -32,6 +34,7 @@ export default function Storybook() {
const navigation = useNavigation<NavigationProp>()
const requestDeviceGeolocation = useRequestDeviceGeolocation()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const inviteFriendsControl = Dialog.useDialogControl()
return (
<>
@@ -97,6 +100,15 @@ export default function Storybook() {
<ButtonText>Get GPS Location</ButtonText>
</Button>
<Button
color="primary"
size="large"
onPress={() => inviteFriendsControl.open()}
label="Open invite friends sheet (APP-2142)">
<ButtonText>Open invite friends sheet (APP-2142)</ButtonText>
</Button>
<InviteFriendsDialog control={inviteFriendsControl} />
<ThemeProvider theme="light">
<Theming />
</ThemeProvider>
+50 -2
View File
@@ -1,5 +1,11 @@
import {type ComponentProps, type JSX, memo, useCallback} from 'react'
import {Linking, ScrollView, TouchableOpacity, View} from 'react-native'
import {
Linking,
Pressable,
ScrollView,
TouchableOpacity,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -25,7 +31,9 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRight} from '#/components/icons/ArrowShareRight'
import {
Bell_Filled_Corner0_Rounded as BellFilled,
Bell_Stroke2_Corner0_Rounded as Bell,
@@ -58,6 +66,7 @@ import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
const iconWidth = 26
@@ -65,9 +74,11 @@ const iconWidth = 26
let DrawerProfileCard = ({
account,
onPressProfile,
onPressShare,
}: {
account: SessionAccount
onPressProfile: () => void
onPressShare?: () => void
}): React.ReactNode => {
const {_, i18n} = useLingui()
const t = useTheme()
@@ -80,7 +91,38 @@ let DrawerProfileCard = ({
accessibilityLabel={_(msg`Profile`)}
accessibilityHint={_(msg`Navigates to your profile`)}
onPress={onPressProfile}
style={[a.gap_sm, a.pr_lg]}>
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>
)}
<UserAvatar
size={52}
avatar={profile?.avatar}
@@ -151,6 +193,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
isAtMessages,
} = useNavigationTabState()
const {hasSession, currentAccount} = useSession()
const inviteFriendsControl = useDialogControl()
// events
// =
@@ -281,6 +324,10 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
<DrawerProfileCard
account={currentAccount}
onPressProfile={onPressDrawerHeaderProfile}
onPressShare={() => {
setDrawerOpen(false)
inviteFriendsControl.open()
}}
/>
) : (
<View style={[a.pr_xl]}>
@@ -330,6 +377,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
onPressFeedback={onPressFeedback}
onPressHelp={onPressHelp}
/>
<InviteFriendsDialog control={inviteFriendsControl} />
</View>
)
}