Add profile QR code for sharing and inviting friends (#10659)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -134,6 +134,7 @@ import {
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import {InviteScannerScreen} from '#/features/inviteFriends'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||||
@@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
getComponent={() => DebugModScreen}
|
getComponent={() => DebugModScreen}
|
||||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
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
|
<Stack.Screen
|
||||||
name="SharedPreferencesTester"
|
name="SharedPreferencesTester"
|
||||||
getComponent={() => SharedPreferencesTesterScreen}
|
getComponent={() => SharedPreferencesTesterScreen}
|
||||||
|
|||||||
@@ -1208,4 +1208,43 @@ export type Events = {
|
|||||||
postAuthorDid: string
|
postAuthorDid: string
|
||||||
feedDescriptor?: string
|
feedDescriptor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Invite friends (profile QR share sheet)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NUX announcement dialog was shown to the user
|
||||||
|
'invite:nux:presented': {}
|
||||||
|
// user pressed "Try it" on the NUX announcement
|
||||||
|
'invite:nux:tryItPressed': {}
|
||||||
|
// invite friends dialog opened, with the surface that triggered it
|
||||||
|
'invite:dialog:open': {
|
||||||
|
logContext:
|
||||||
|
| 'ProfileHeader'
|
||||||
|
| 'Drawer'
|
||||||
|
| 'FindContactsSettings'
|
||||||
|
| 'NuxAnnouncement'
|
||||||
|
}
|
||||||
|
// user copied the invite link to clipboard
|
||||||
|
'invite:action:copy': {}
|
||||||
|
// user invoked the native share sheet with the invite link
|
||||||
|
'invite:action:share': {}
|
||||||
|
// user saved the QR code image to their camera roll (success only)
|
||||||
|
'invite:action:download': {}
|
||||||
|
// user pressed the scan button to open the QR scanner
|
||||||
|
'invite:action:scan': {}
|
||||||
|
// user changed the QR card color theme
|
||||||
|
'invite:theme:change': {
|
||||||
|
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
|
||||||
|
}
|
||||||
|
// QR scanner decoded a code; result indicates whether it resolved to a profile
|
||||||
|
'invite:scanner:scanned': {
|
||||||
|
result: 'profileFound' | 'invalidQr'
|
||||||
|
}
|
||||||
|
// empty-followers banner promoting invite/find friends was shown
|
||||||
|
'invite:followersPromo:seen': {}
|
||||||
|
// user pressed the empty-followers promo banner
|
||||||
|
'invite:followersPromo:press': {}
|
||||||
|
// user dismissed the empty-followers promo banner
|
||||||
|
'invite:followersPromo:dismiss': {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {useCallOnce} from '#/lib/once'
|
||||||
|
import {Nux} from '#/state/queries/nuxs'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {themes} from '#/alf/themes'
|
||||||
|
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 {useAnalytics} from '#/analytics'
|
||||||
|
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 {activeNux} = useNuxDialogContext()
|
||||||
|
/*
|
||||||
|
* The invite dialog's control lives here (outside the activeNux
|
||||||
|
* conditional) so the dialog survives the announcement being dismissed
|
||||||
|
* during the "Try it" handoff - the native bottom sheet cannot hand off to
|
||||||
|
* a second sheet that unmounts along with the first.
|
||||||
|
*/
|
||||||
|
const inviteFriendsControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{activeNux === Nux.InviteFriendsAnnouncement && (
|
||||||
|
<Announcement inviteFriendsControl={inviteFriendsControl} />
|
||||||
|
)}
|
||||||
|
<InviteFriendsDialog control={inviteFriendsControl} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Announcement({
|
||||||
|
inviteFriendsControl,
|
||||||
|
}: {
|
||||||
|
inviteFriendsControl: Dialog.DialogControlProps
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const nuxDialogs = useNuxDialogContext()
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
Dialog.useAutoOpen(control)
|
||||||
|
|
||||||
|
useCallOnce(() => {
|
||||||
|
ax.metric('invite:nux:presented', {})
|
||||||
|
})()
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
nuxDialogs.dismissActiveNux()
|
||||||
|
}, [nuxDialogs])
|
||||||
|
|
||||||
|
const onPressTryIt = useCallback(() => {
|
||||||
|
ax.metric('invite:nux:tryItPressed', {})
|
||||||
|
// Close this announcement (which dismisses the NUX and unmounts this
|
||||||
|
// component), then open the invite dialog. Its control lives in the
|
||||||
|
// parent, which stays mounted, so the dialog survives the dismissal.
|
||||||
|
// Defer the open to the next frame so the announcement's teardown
|
||||||
|
// completes first.
|
||||||
|
control.close(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ax.metric('invite:dialog:open', {logContext: 'NuxAnnouncement'})
|
||||||
|
inviteFriendsControl.open()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [ax, control, inviteFriendsControl])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
onClose={onClose}
|
||||||
|
nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle fill={t.palette.primary_400} />
|
||||||
|
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={l`Invite your friends`}
|
||||||
|
style={[web({maxWidth: 440})]}
|
||||||
|
contentContainerStyle={[
|
||||||
|
{paddingTop: 0, paddingLeft: 0, paddingRight: 0},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.relative,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{/* 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={l`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},
|
||||||
|
]}>
|
||||||
|
{/* Pinned to the light palette: the header illustration has a
|
||||||
|
fixed light background, so the tag must stay dark in any
|
||||||
|
theme to remain legible. */}
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<SparkleIcon fill={themes.lightPalette.primary_800} size="sm" />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_semi_bold,
|
||||||
|
{color: themes.lightPalette.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]}>
|
||||||
|
<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 the
|
||||||
|
link anywhere, or let them scan the code to join you on
|
||||||
|
Bluesky.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!IS_WEB && (
|
||||||
|
<View style={[a.w_full, a.gap_sm]}>
|
||||||
|
<Button
|
||||||
|
label={l`Try it`}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
onPress={onPressTryIt}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Try it</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={l`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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
DraftsAnnouncement,
|
DraftsAnnouncement,
|
||||||
enabled as isDraftsAnnouncementEnabled,
|
enabled as isDraftsAnnouncementEnabled,
|
||||||
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
|
} 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 {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 {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -40,6 +44,10 @@ const queuedNuxs: {
|
|||||||
id: Nux.DraftsAnnouncement,
|
id: Nux.DraftsAnnouncement,
|
||||||
enabled: isDraftsAnnouncementEnabled,
|
enabled: isDraftsAnnouncementEnabled,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: Nux.InviteFriendsAnnouncement,
|
||||||
|
enabled: isInviteFriendsAnnouncementEnabled,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const Context = createContext<Context>({
|
const Context = createContext<Context>({
|
||||||
@@ -187,6 +195,12 @@ function Inner({
|
|||||||
<Context.Provider value={ctx}>
|
<Context.Provider value={ctx}>
|
||||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||||
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
|
||||||
|
{/*
|
||||||
|
Mounted unconditionally: it gates the announcement on `activeNux`
|
||||||
|
internally, so it can keep the invite-friends dialog mounted across
|
||||||
|
the announcement's dismissal during the "Try it" handoff.
|
||||||
|
*/}
|
||||||
|
<InviteFriendsAnnouncement />
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {InviteFriendsDialogInner} from './InviteFriendsDialogInner'
|
||||||
|
|
||||||
|
export function InviteFriendsDialog({
|
||||||
|
control,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
onClose={onClose}
|
||||||
|
nativeOptions={{preventExpansion: true}}>
|
||||||
|
<InviteFriendsDialogInner control={control} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
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 {createAssetAsync, requestPermissionsAsync} 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 {Button, ButtonText} from '#/components/Button'
|
||||||
|
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 {useAnalytics} from '#/analytics'
|
||||||
|
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 ax = useAnalytics()
|
||||||
|
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 displayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
|
||||||
|
|
||||||
|
const onShare = async () => {
|
||||||
|
if (!canonicalShareUrl) {
|
||||||
|
Toast.show(l`Could not share – please try again`, {type: 'error'})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ax.metric('invite:action:share', {})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-only permission - saving the QR image does not require read access
|
||||||
|
// to the user's photo library.
|
||||||
|
const permission = await requestPermissionsAsync(true)
|
||||||
|
if (!permission.granted) {
|
||||||
|
Toast.show(
|
||||||
|
l`You must grant access to your photo library to save a QR code`,
|
||||||
|
{type: 'error'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createAssetAsync(`file://${uri}`)
|
||||||
|
ax.metric('invite:action:download', {})
|
||||||
|
Toast.show(l`QR code saved to your camera roll!`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('InviteFriendsDialog: download failed', {safeMessage: err})
|
||||||
|
Toast.show(l`An error occurred while saving the QR code!`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScan = () => {
|
||||||
|
ax.metric('invite:action:scan', {})
|
||||||
|
// 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)
|
||||||
|
ax.metric('invite:action:copy', {})
|
||||||
|
Toast.show(l`Invite link copied`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('InviteFriendsDialog: copy failed', {safeMessage: err})
|
||||||
|
Toast.show(l`Failed to copy link`, {type: 'error'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThemeChange = (next: InviteThemeKey) => {
|
||||||
|
ax.metric('invite:theme:change', {themeKey: next})
|
||||||
|
setThemeKey(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={l`Invite friends`}
|
||||||
|
contentContainerStyle={[a.pt_0, a.px_0]}
|
||||||
|
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, a.px_xl]}>
|
||||||
|
<ThemePicker value={themeKey} onChange={onThemeChange} />
|
||||||
|
|
||||||
|
<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={() => void onShare()}
|
||||||
|
onDownload={() => void 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({
|
||||||
|
message: 'Invite link',
|
||||||
|
context: 'profile invite',
|
||||||
|
comment:
|
||||||
|
'Link that invites someone to follow your profile, distinct from a group chat 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}>
|
||||||
|
{displayUrl || ' '}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Copy invite link`}
|
||||||
|
accessibilityHint={l`Copies the canonical profile URL to the clipboard`}
|
||||||
|
onPress={() => void 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,264 @@
|
|||||||
|
import {useCallback, useState} from 'react'
|
||||||
|
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import Svg, {Path} from 'react-native-svg'
|
||||||
|
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 {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 * as Layout from '#/components/Layout'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
|
const SCAN_FRAME_SIZE = 333
|
||||||
|
const SCAN_FRAME_RADIUS = 16
|
||||||
|
|
||||||
|
export function InviteScannerScreen() {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const [permission, requestPermission] = useCameraPermissions()
|
||||||
|
const [showError, setShowError] = useState(false)
|
||||||
|
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) {
|
||||||
|
ax.metric('invite:scanner:scanned', {result: 'invalidQr'})
|
||||||
|
setScannerEnabled(false)
|
||||||
|
setShowError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ax.metric('invite:scanner:scanned', {result: 'profileFound'})
|
||||||
|
setScannerEnabled(false)
|
||||||
|
const handle = profileMatch[1]
|
||||||
|
navigation.replace('Profile', {name: handle})
|
||||||
|
},
|
||||||
|
[ax, scannerEnabled, navigation],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onRetry = useCallback(() => {
|
||||||
|
setShowError(false)
|
||||||
|
setScannerEnabled(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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={() => void 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 />
|
||||||
|
|
||||||
|
{/* 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: SCAN_FRAME_RADIUS,
|
||||||
|
}}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
|
||||||
|
{showError && (
|
||||||
|
<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 rounded-rect cutout in
|
||||||
|
* the middle. Implemented as a single SVG Path (outer screen rect + inner
|
||||||
|
* rounded rect, even-odd fill) so the cutout's corners match the colored
|
||||||
|
* outline above it.
|
||||||
|
*/
|
||||||
|
function ScannerScrim() {
|
||||||
|
const {width, height} = useWindowDimensions()
|
||||||
|
const r = SCAN_FRAME_RADIUS
|
||||||
|
const half = SCAN_FRAME_SIZE / 2
|
||||||
|
const x = width / 2 - half
|
||||||
|
const y = height / 2 - half
|
||||||
|
const right = x + SCAN_FRAME_SIZE
|
||||||
|
const bottom = y + SCAN_FRAME_SIZE
|
||||||
|
const d =
|
||||||
|
`M0 0 H${width} V${height} H0 Z ` +
|
||||||
|
`M${x + r} ${y} H${right - r} A${r} ${r} 0 0 1 ${right} ${y + r} ` +
|
||||||
|
`V${bottom - r} A${r} ${r} 0 0 1 ${right - r} ${bottom} ` +
|
||||||
|
`H${x + r} A${r} ${r} 0 0 1 ${x} ${bottom - r} ` +
|
||||||
|
`V${y + r} A${r} ${r} 0 0 1 ${x + r} ${y} Z`
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
pointerEvents="none">
|
||||||
|
<Path d={d} fill="rgba(0, 0, 0, 0.55)" fillRule="evenodd" />
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web fallback - QR scanning relies on the device camera and is native-only by
|
||||||
|
* design. The route is registered on all platforms, so a web user reaching
|
||||||
|
* /invite/scan via a direct URL gets this message instead of the native
|
||||||
|
* camera UI.
|
||||||
|
*/
|
||||||
|
export function InviteScannerScreen() {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
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`Not available on this platform`}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.text_center,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{maxWidth: 320},
|
||||||
|
]}>
|
||||||
|
{l`Please use the Bluesky mobile app to scan a QR code.`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 code scanner'})}
|
||||||
|
color="primary_subtle"
|
||||||
|
icon={CameraIcon}
|
||||||
|
onPress={onScan}
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
{l`Scan`}
|
||||||
|
</StackedButton>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
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'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
|
export function FollowersPromoBanner({
|
||||||
|
onPress,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
onPress: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ax.metric('invite:followersPromo:seen', {})
|
||||||
|
// Fire once per mount - parent unmounts the banner when followers > 0 or
|
||||||
|
// when dismissed, so each mount is a distinct impression.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
ax.metric('invite:followersPromo:press', {})
|
||||||
|
onPress()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
ax.metric('invite:followersPromo:dismiss', {})
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={[a.px_lg, a.pt_md]}>
|
||||||
|
{/*
|
||||||
|
* The dismiss button is a sibling of (not nested inside) the banner
|
||||||
|
* Pressable - nesting Pressables causes touch-handling conflicts on
|
||||||
|
* Android. This relative wrapper matches the banner bounds so the dismiss
|
||||||
|
* button can be positioned absolutely against the card's top-right corner.
|
||||||
|
*/}
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Find and invite friends`}
|
||||||
|
accessibilityHint={l`Opens the find and invite friends settings`}
|
||||||
|
onPress={handlePress}
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
source={require('../../../../assets/images/invite_friends_promo_banner.webp')}
|
||||||
|
style={{width: 126, height: 64, marginRight: 16}}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Dismiss`}
|
||||||
|
accessibilityHint={l`Hides the invite friends promo banner`}
|
||||||
|
onPress={handleDismiss}
|
||||||
|
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>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
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_ITEM_SWATCH = 18
|
||||||
|
|
||||||
|
export function ThemePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: InviteThemeKey
|
||||||
|
onChange: (next: InviteThemeKey) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const current = INVITE_THEMES[value]
|
||||||
|
|
||||||
|
const labels: Record<InviteThemeKey, string> = {
|
||||||
|
dawn: l`Dawn`,
|
||||||
|
day: l`Day`,
|
||||||
|
dusk: l`Dusk`,
|
||||||
|
night: l`Night`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
label={l`Pick a color theme`}
|
||||||
|
hint={l`Opens a list of color themes for the QR card`}>
|
||||||
|
{({props}) => (
|
||||||
|
<Pressable
|
||||||
|
{...props}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.Group>
|
||||||
|
{INVITE_THEME_KEYS.map(key => {
|
||||||
|
const theme = INVITE_THEMES[key]
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={key}
|
||||||
|
label={labels[key]}
|
||||||
|
onPress={() => onChange(key)}>
|
||||||
|
<GradientSwatch
|
||||||
|
from={theme.light.gradientFrom}
|
||||||
|
to={theme.light.gradientTo}
|
||||||
|
size={MENU_ITEM_SWATCH}
|
||||||
|
/>
|
||||||
|
<Menu.ItemText>{labels[key]}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={key === value} />
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,246 @@
|
|||||||
|
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 {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration'
|
||||||
|
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_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_WRAPPER / 2,
|
||||||
|
backgroundColor: t.palette.white,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: AVATAR_IMAGE,
|
||||||
|
height: AVATAR_IMAGE,
|
||||||
|
borderRadius: AVATAR_IMAGE / 2,
|
||||||
|
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_IMAGE - AVATAR_BORDER * 2) / 2,
|
||||||
|
}}
|
||||||
|
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
|
||||||
|
return rgbToHex(
|
||||||
|
fromRgb.r + (toRgb.r - fromRgb.r) * t,
|
||||||
|
fromRgb.g + (toRgb.g - fromRgb.g) * t,
|
||||||
|
fromRgb.b + (toRgb.b - fromRgb.b) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export {FollowersPromoBanner} from './components/FollowersPromoBanner'
|
||||||
|
export {useFollowersPromoDismissed} from './hooks/useFollowersPromoDismissed'
|
||||||
|
export {InviteFriendsDialog} from './InviteFriendsDialog'
|
||||||
|
export {InviteScannerScreen} from './InviteScannerScreen'
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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('passes the handle through verbatim (handles are API-normalized, like makeProfileLink)', () => {
|
||||||
|
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 the scheme-stripped canonical profile URL', () => {
|
||||||
|
expect(getInviteDisplayUrl('danielle.bsky.team')).toBe(
|
||||||
|
'bsky.app/profile/danielle.bsky.team',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the bare handle when there is no dot', () => {
|
||||||
|
expect(getInviteDisplayUrl('alice')).toBe('bsky.app/profile/alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the handle through verbatim (handles are API-normalized, like makeProfileLink)', () => {
|
||||||
|
expect(getInviteDisplayUrl('Danielle.bsky.team')).toBe(
|
||||||
|
'bsky.app/profile/Danielle.bsky.team',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips a leading @ if present', () => {
|
||||||
|
expect(getInviteDisplayUrl('@alice.bsky.social')).toBe(
|
||||||
|
'bsky.app/profile/alice.bsky.social',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for empty handle (EDGE-001)', () => {
|
||||||
|
expect(getInviteDisplayUrl('')).toBe('')
|
||||||
|
expect(getInviteDisplayUrl('@')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mirrors getInviteShareUrl exactly minus the scheme', () => {
|
||||||
|
expect(getInviteDisplayUrl('alice.bsky.social')).toBe(
|
||||||
|
getInviteShareUrl('alice.bsky.social').replace(/^https:\/\//, ''),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* URL helpers for the Invite Friends share sheet (APP-2142).
|
||||||
|
*
|
||||||
|
* Every action (QR payload, share sheet, clipboard) and the displayed label
|
||||||
|
* all derive from the same canonical `https://bsky.app/profile/{handle}` URL,
|
||||||
|
* so what the user reads matches exactly what they copy/share. The displayed
|
||||||
|
* label simply drops the `https://` scheme for readability.
|
||||||
|
*
|
||||||
|
* Kept as a dependency-free leaf module (no #/lib/strings/url-helpers import)
|
||||||
|
* so its unit tests stay fast and isolated from the heavy @atproto/api graph.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (!bare) return ''
|
||||||
|
return `https://bsky.app/profile/${bare}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable label shown in the "Invite link" field. This is the same
|
||||||
|
* canonical URL as getInviteShareUrl with the `https://` scheme stripped, so
|
||||||
|
* the displayed text always resolves and matches what Copy/Share use.
|
||||||
|
*/
|
||||||
|
export function getInviteDisplayUrl(handle: string): string {
|
||||||
|
return getInviteShareUrl(handle).replace(/^https:\/\//, '')
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export type CommonNavigatorParams = {
|
|||||||
AboutSettings: undefined
|
AboutSettings: undefined
|
||||||
AppIconSettings: undefined
|
AppIconSettings: undefined
|
||||||
FindContactsSettings: undefined
|
FindContactsSettings: undefined
|
||||||
|
InviteScanner: undefined
|
||||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
Topic: {topic: string}
|
Topic: {topic: string}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const router = new Router<AllNavigatableRoutes>({
|
|||||||
Debug: '/sys/debug',
|
Debug: '/sys/debug',
|
||||||
DebugMod: '/sys/debug-mod',
|
DebugMod: '/sys/debug-mod',
|
||||||
Log: '/sys/log',
|
Log: '/sys/log',
|
||||||
|
// invite friends
|
||||||
|
InviteScanner: '/invite/scan',
|
||||||
// settings
|
// settings
|
||||||
LanguageSettings: '/settings/language',
|
LanguageSettings: '/settings/language',
|
||||||
AppPasswords: '/settings/app-passwords',
|
AppPasswords: '/settings/app-passwords',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|||||||
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
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 {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {
|
import {
|
||||||
KnownFollowers,
|
KnownFollowers,
|
||||||
@@ -38,7 +39,9 @@ import * as Prompt from '#/components/Prompt'
|
|||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_IOS} from '#/env'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||||
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import {useActorStatus} from '#/features/liveNow'
|
import {useActorStatus} from '#/features/liveNow'
|
||||||
import {GermButton} from '../components/GermButton'
|
import {GermButton} from '../components/GermButton'
|
||||||
import {EditProfileDialog} from './EditProfileDialog'
|
import {EditProfileDialog} from './EditProfileDialog'
|
||||||
@@ -238,6 +241,7 @@ export function HeaderStandardButtons({
|
|||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
@@ -247,6 +251,7 @@ export function HeaderStandardButtons({
|
|||||||
)
|
)
|
||||||
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
|
const [, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
|
const inviteFriendsControl = useDialogControl()
|
||||||
const unblockPromptControl = Prompt.usePromptControl()
|
const unblockPromptControl = Prompt.usePromptControl()
|
||||||
|
|
||||||
const isMe = currentAccount?.did === profile.did
|
const isMe = currentAccount?.did === profile.did
|
||||||
@@ -347,7 +352,25 @@ export function HeaderStandardButtons({
|
|||||||
<Trans>Edit Profile</Trans>
|
<Trans>Edit Profile</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Invite friends is a native-only share sheet (the dialog is a
|
||||||
|
no-op on web), so gate the entry point to avoid a dead button. */}
|
||||||
|
{IS_NATIVE && (
|
||||||
|
<Button
|
||||||
|
testID="profileHeaderShareButton"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={() => {
|
||||||
|
playHaptic('Light')
|
||||||
|
ax.metric('invite:dialog:open', {logContext: 'ProfileHeader'})
|
||||||
|
inviteFriendsControl.open()
|
||||||
|
}}
|
||||||
|
label={_(msg`Invite friends`)}>
|
||||||
|
<ButtonIcon icon={ArrowShareRight} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<EditProfileDialog profile={profile} control={editProfileControl} />
|
<EditProfileDialog profile={profile} control={editProfileControl} />
|
||||||
|
{IS_NATIVE && <InviteFriendsDialog control={inviteFriendsControl} />}
|
||||||
</>
|
</>
|
||||||
) : profile.viewer?.blocking ? (
|
) : profile.viewer?.blocking ? (
|
||||||
profile.viewer?.blockingByList ? null : (
|
profile.viewer?.blockingByList ? null : (
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
|||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
|
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
|
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
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 {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {bulkWriteFollows} from '../Onboarding/util'
|
import {bulkWriteFollows} from '../Onboarding/util'
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ export function FindContactsSettingsScreen({}: Props) {
|
|||||||
<Layout.Header.BackButton />
|
<Layout.Header.BackButton />
|
||||||
<Layout.Header.Content>
|
<Layout.Header.Content>
|
||||||
<Layout.Header.TitleText>
|
<Layout.Header.TitleText>
|
||||||
<Trans>Find Friends</Trans>
|
<Trans>Find and invite friends</Trans>
|
||||||
</Layout.Header.TitleText>
|
</Layout.Header.TitleText>
|
||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Slot />
|
||||||
@@ -113,6 +115,8 @@ function Intro() {
|
|||||||
const gutter = useGutters(['base'])
|
const gutter = useGutters(['base'])
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const inviteFriendsControl = useDialogControl()
|
||||||
|
|
||||||
const {data: isAvailable, isSuccess} = useQuery({
|
const {data: isAvailable, isSuccess} = useQuery({
|
||||||
queryKey: ['contacts-available'],
|
queryKey: ['contacts-available'],
|
||||||
@@ -122,6 +126,9 @@ function Intro() {
|
|||||||
return (
|
return (
|
||||||
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
|
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
|
||||||
<ContactsHeroImage />
|
<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]}>
|
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Find your friends on Bluesky by verifying your phone number and
|
Find your friends on Bluesky by verifying your phone number and
|
||||||
@@ -161,6 +168,20 @@ function Intro() {
|
|||||||
</Admonition>
|
</Admonition>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
label={_(msg`Share my profile`)}
|
||||||
|
size="large"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => {
|
||||||
|
ax.metric('invite:dialog:open', {logContext: 'FindContactsSettings'})
|
||||||
|
inviteFriendsControl.open()
|
||||||
|
}}
|
||||||
|
style={[a.flex_1, a.justify_center]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Share my profile</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<InviteFriendsDialog control={inviteFriendsControl} />
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,10 +213,10 @@ export function SettingsScreen({}: Props) {
|
|||||||
!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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export enum Nux {
|
|||||||
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
LiveNowBetaDialog = 'LiveNowBetaDialog',
|
||||||
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
LiveNowBetaNudge = 'LiveNowBetaNudge',
|
||||||
DraftsAnnouncement = 'DraftsAnnouncement',
|
DraftsAnnouncement = 'DraftsAnnouncement',
|
||||||
|
InviteFriendsAnnouncement = 'InviteFriendsAnnouncement',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Blocking announcements. New IDs are required for each new announcement.
|
* Blocking announcements. New IDs are required for each new announcement.
|
||||||
@@ -77,6 +78,10 @@ export type AppNux = BaseNux<
|
|||||||
id: Nux.DraftsAnnouncement
|
id: Nux.DraftsAnnouncement
|
||||||
data: undefined
|
data: undefined
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: Nux.InviteFriendsAnnouncement
|
||||||
|
data: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | 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.LiveNowBetaDialog]: undefined,
|
||||||
[Nux.LiveNowBetaNudge]: undefined,
|
[Nux.LiveNowBetaNudge]: undefined,
|
||||||
[Nux.DraftsAnnouncement]: undefined,
|
[Nux.DraftsAnnouncement]: undefined,
|
||||||
|
[Nux.InviteFriendsAnnouncement]: undefined,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export type Device = {
|
|||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
activitySubscriptionsNudged?: boolean
|
activitySubscriptionsNudged?: boolean
|
||||||
threadgateNudged?: boolean
|
threadgateNudged?: boolean
|
||||||
|
inviteFriendsFollowersPromoDismissed?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy update overlays. New IDs are required for each new announcement.
|
* Policy update overlays. New IDs are required for each new announcement.
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
|
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
|
||||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
|
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
|
||||||
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
|
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
|
||||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import {
|
||||||
|
FollowersPromoBanner,
|
||||||
|
useFollowersPromoDismissed,
|
||||||
|
} from '#/features/inviteFriends'
|
||||||
import {List} from '../util/List'
|
import {List} from '../util/List'
|
||||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||||
|
|
||||||
@@ -43,7 +50,7 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
|||||||
export function ProfileFollowers({name}: {name: string}) {
|
export function ProfileFollowers({name}: {name: string}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
@@ -158,8 +165,34 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||||||
[ax, followers, resolvedDid],
|
[ax, followers, resolvedDid],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (followers.length < 1) {
|
const [followersPromoDismissed, setFollowersPromoDismissed] =
|
||||||
|
useFollowersPromoDismissed()
|
||||||
|
const findContactsEnabled =
|
||||||
|
useIsFindContactsFeatureEnabledBasedOnGeolocation()
|
||||||
|
// The banner deep-links into the Find and Invite Friends settings screen, so
|
||||||
|
// mirror that screen's availability gates: native-only, allowed in the user's
|
||||||
|
// region (geolocation allowlist), and not disabled by the feature flag. This
|
||||||
|
// avoids promoting contact import where the settings entry itself is hidden.
|
||||||
|
const showFollowersPromo =
|
||||||
|
IS_NATIVE &&
|
||||||
|
isMe &&
|
||||||
|
findContactsEnabled &&
|
||||||
|
!ax.features.enabled(ax.features.ImportContactsSettingsDisable) &&
|
||||||
|
!followersPromoDismissed &&
|
||||||
|
followers.length < 1 &&
|
||||||
|
!isDidLoading &&
|
||||||
|
!isFollowersLoading &&
|
||||||
|
!isError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showFollowersPromo && (
|
||||||
|
<FollowersPromoBanner
|
||||||
|
onPress={() => navigation.navigate('FindContactsSettings')}
|
||||||
|
onDismiss={() => setFollowersPromoDismissed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{followers.length < 1 ? (
|
||||||
<ListMaybePlaceholder
|
<ListMaybePlaceholder
|
||||||
isLoading={isDidLoading || isFollowersLoading}
|
isLoading={isDidLoading || isFollowersLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
@@ -182,10 +215,7 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||||||
onPress: () => navigation.goBack(),
|
onPress: () => navigation.goBack(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
) : (
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
<List
|
||||||
data={followers}
|
data={followers}
|
||||||
renderItem={renderItemWithContext}
|
renderItem={renderItemWithContext}
|
||||||
@@ -208,5 +238,7 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||||||
windowSize={11}
|
windowSize={11}
|
||||||
sideBorders={false}
|
sideBorders={false}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {useSetThemePrefs} from '#/state/shell'
|
|||||||
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
||||||
import {atoms as a, ThemeProvider} from '#/alf'
|
import {atoms as a, ThemeProvider} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import {
|
import {
|
||||||
useDeviceGeolocationApi,
|
useDeviceGeolocationApi,
|
||||||
useRequestDeviceGeolocation,
|
useRequestDeviceGeolocation,
|
||||||
@@ -33,6 +35,7 @@ export default function Storybook() {
|
|||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const requestDeviceGeolocation = useRequestDeviceGeolocation()
|
const requestDeviceGeolocation = useRequestDeviceGeolocation()
|
||||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
const inviteFriendsControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -98,6 +101,15 @@ export default function Storybook() {
|
|||||||
<ButtonText>Get GPS Location</ButtonText>
|
<ButtonText>Get GPS Location</ButtonText>
|
||||||
</Button>
|
</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">
|
<ThemeProvider theme="light">
|
||||||
<Theming />
|
<Theming />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import {type ComponentProps, type JSX, memo, useCallback} from 'react'
|
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 {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {msg, plural} from '@lingui/core/macro'
|
import {msg, plural} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -25,7 +31,9 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|||||||
import {NavSignupCard} from '#/view/shell/NavSignupCard'
|
import {NavSignupCard} from '#/view/shell/NavSignupCard'
|
||||||
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRight} from '#/components/icons/ArrowShareRight'
|
||||||
import {
|
import {
|
||||||
Bell_Filled_Corner0_Rounded as BellFilled,
|
Bell_Filled_Corner0_Rounded as BellFilled,
|
||||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||||
@@ -57,7 +65,8 @@ import {InlineLinkText} from '#/components/Link'
|
|||||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import {useActorStatus} from '#/features/liveNow'
|
import {useActorStatus} from '#/features/liveNow'
|
||||||
|
|
||||||
const iconWidth = 26
|
const iconWidth = 26
|
||||||
@@ -65,9 +74,11 @@ const iconWidth = 26
|
|||||||
let DrawerProfileCard = ({
|
let DrawerProfileCard = ({
|
||||||
account,
|
account,
|
||||||
onPressProfile,
|
onPressProfile,
|
||||||
|
onPressShare,
|
||||||
}: {
|
}: {
|
||||||
account: SessionAccount
|
account: SessionAccount
|
||||||
onPressProfile: () => void
|
onPressProfile: () => void
|
||||||
|
onPressShare?: () => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -93,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
|
||||||
@@ -151,6 +196,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
|||||||
isAtMessages,
|
isAtMessages,
|
||||||
} = useNavigationTabState()
|
} = useNavigationTabState()
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
|
const inviteFriendsControl = useDialogControl()
|
||||||
|
|
||||||
// events
|
// events
|
||||||
// =
|
// =
|
||||||
@@ -281,6 +327,15 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
|||||||
<DrawerProfileCard
|
<DrawerProfileCard
|
||||||
account={currentAccount}
|
account={currentAccount}
|
||||||
onPressProfile={onPressDrawerHeaderProfile}
|
onPressProfile={onPressDrawerHeaderProfile}
|
||||||
|
onPressShare={
|
||||||
|
IS_NATIVE
|
||||||
|
? () => {
|
||||||
|
ax.metric('invite:dialog:open', {logContext: 'Drawer'})
|
||||||
|
setDrawerOpen(false)
|
||||||
|
inviteFriendsControl.open()
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[a.pr_xl]}>
|
<View style={[a.pr_xl]}>
|
||||||
@@ -330,6 +385,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
|||||||
onPressFeedback={onPressFeedback}
|
onPressFeedback={onPressFeedback}
|
||||||
onPressHelp={onPressHelp}
|
onPressHelp={onPressHelp}
|
||||||
/>
|
/>
|
||||||
|
<InviteFriendsDialog control={inviteFriendsControl} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user