From c6f980367690119e7729be3b2a297a4faf5232dc Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Wed, 10 Jun 2026 14:14:45 -0400 Subject: [PATCH] Add profile QR code for sharing and inviting friends (#10659) --- .../invite_friends_announcement_nux.webp | Bin 0 -> 12408 bytes .../images/invite_friends_promo_banner.webp | Bin 0 -> 55610 bytes src/Navigation.tsx | 6 + src/analytics/metrics/types.ts | 39 +++ .../nuxs/InviteFriendsAnnouncement.tsx | 193 +++++++++++++ src/components/dialogs/nuxs/index.tsx | 14 + .../inviteFriends/InviteFriendsDialog.tsx | 19 ++ .../inviteFriends/InviteFriendsDialog.web.tsx | 13 + .../InviteFriendsDialogInner.tsx | 269 ++++++++++++++++++ .../inviteFriends/InviteScannerScreen.tsx | 264 +++++++++++++++++ .../inviteFriends/InviteScannerScreen.web.tsx | 45 +++ .../components/ActionButtons.tsx | 48 ++++ .../components/FollowersPromoBanner.tsx | 101 +++++++ .../inviteFriends/components/ThemePicker.tsx | 120 ++++++++ .../inviteFriends/components/ThemedQrCard.tsx | 246 ++++++++++++++++ .../components/ThemedQrCard.web.tsx | 6 + .../hooks/useFollowersPromoDismissed.ts | 12 + src/features/inviteFriends/index.ts | 4 + src/features/inviteFriends/themes.test.ts | 28 ++ src/features/inviteFriends/themes.ts | 115 ++++++++ src/features/inviteFriends/urls.test.ts | 71 +++++ src/features/inviteFriends/urls.ts | 31 ++ src/lib/routes/types.ts | 1 + src/routes.ts | 2 + .../Profile/Header/ProfileHeaderStandard.tsx | 25 +- src/screens/Settings/FindContactsSettings.tsx | 23 +- src/screens/Settings/Settings.tsx | 4 +- src/state/queries/nuxs/definitions.ts | 6 + src/storage/schema.ts | 1 + src/view/com/profile/ProfileFollowers.tsx | 128 +++++---- src/view/screens/Storybook/Storybook.tsx | 12 + src/view/shell/Drawer.tsx | 62 +++- 32 files changed, 1853 insertions(+), 55 deletions(-) create mode 100644 assets/images/invite_friends_announcement_nux.webp create mode 100644 assets/images/invite_friends_promo_banner.webp create mode 100644 src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx create mode 100644 src/features/inviteFriends/InviteFriendsDialog.tsx create mode 100644 src/features/inviteFriends/InviteFriendsDialog.web.tsx create mode 100644 src/features/inviteFriends/InviteFriendsDialogInner.tsx create mode 100644 src/features/inviteFriends/InviteScannerScreen.tsx create mode 100644 src/features/inviteFriends/InviteScannerScreen.web.tsx create mode 100644 src/features/inviteFriends/components/ActionButtons.tsx create mode 100644 src/features/inviteFriends/components/FollowersPromoBanner.tsx create mode 100644 src/features/inviteFriends/components/ThemePicker.tsx create mode 100644 src/features/inviteFriends/components/ThemedQrCard.tsx create mode 100644 src/features/inviteFriends/components/ThemedQrCard.web.tsx create mode 100644 src/features/inviteFriends/hooks/useFollowersPromoDismissed.ts create mode 100644 src/features/inviteFriends/index.ts create mode 100644 src/features/inviteFriends/themes.test.ts create mode 100644 src/features/inviteFriends/themes.ts create mode 100644 src/features/inviteFriends/urls.test.ts create mode 100644 src/features/inviteFriends/urls.ts diff --git a/assets/images/invite_friends_announcement_nux.webp b/assets/images/invite_friends_announcement_nux.webp new file mode 100644 index 0000000000000000000000000000000000000000..53e79e1d54f2ba028fca5fe999e18f4db4d1cdb0 GIT binary patch literal 12408 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 DebugModScreen} options={{title: title(msg`Moderation states`), requireAuth: true}} /> + InviteScannerScreen} + options={{title: title(msg`Scan QR code`), requireAuth: true}} + /> SharedPreferencesTesterScreen} diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 5157c66e3..453fbd10f 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1208,4 +1208,43 @@ export type Events = { postAuthorDid: 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': {} } diff --git a/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx new file mode 100644 index 000000000..48e4c0596 --- /dev/null +++ b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx @@ -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 && ( + + )} + + + ) +} + +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 ( + <> + + + + + + {/* The image is the full header background; the tag sits on top of it. */} + {l`An + + {/* 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. */} + + + + New Feature + + + + + + + + Invite your friends + + + + 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. + + + + + {!IS_WEB && ( + + + + + )} + + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 2d2e2c6a6..41daa8b9f 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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({ @@ -187,6 +195,12 @@ function Inner({ {/*For example, activeNux === Nux.NeueTypography && */} {activeNux === Nux.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. + */} + ) } diff --git a/src/features/inviteFriends/InviteFriendsDialog.tsx b/src/features/inviteFriends/InviteFriendsDialog.tsx new file mode 100644 index 000000000..7ca76f6e6 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialog.tsx @@ -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 ( + + + + ) +} diff --git a/src/features/inviteFriends/InviteFriendsDialog.web.tsx b/src/features/inviteFriends/InviteFriendsDialog.web.tsx new file mode 100644 index 000000000..155c53391 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialog.web.tsx @@ -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 +} diff --git a/src/features/inviteFriends/InviteFriendsDialogInner.tsx b/src/features/inviteFriends/InviteFriendsDialogInner.tsx new file mode 100644 index 000000000..7d2641a77 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialogInner.tsx @@ -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() + const {currentAccount} = useSession() + const profileQuery = useProfileQuery({did: currentAccount?.did}) + const [themeKey, setThemeKey] = useState('day') + const captureRef = useRef(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 ( + ( + + )}> + {l`Invite Friends`} + + }> + + + + + {profileQuery.isLoading ? ( + + + + ) : profileQuery.error || !isHandleValid ? ( + + profileQuery.refetch()} + hideBackButton + /> + + ) : ( + + + + }> + + + )} + + + + void onShare()} + onDownload={() => void onDownload()} + onScan={onScan} + /> + + + + + + {l({ + message: 'Invite link', + context: 'profile invite', + comment: + 'Link that invites someone to follow your profile, distinct from a group chat invite link', + })} + + + + + + + + {displayUrl || ' '} + + void onCopy()} + hitSlop={8} + style={({pressed}) => [{opacity: pressed ? 0.7 : 1}]}> + + {l`Copy`} + + + + + + ) +} diff --git a/src/features/inviteFriends/InviteScannerScreen.tsx b/src/features/inviteFriends/InviteScannerScreen.tsx new file mode 100644 index 000000000..2887835c6 --- /dev/null +++ b/src/features/inviteFriends/InviteScannerScreen.tsx @@ -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() + 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 + } + + if (!permission.granted) { + return ( + + + + + {l`Scan QR code`} + + + + + + + {l`Camera access needed`} + + + {l`Bluesky needs camera access to scan QR codes from other profiles.`} + + + + + ) + } + + // Active scanner ------------------------------------------------------------ + + return ( + + + + + + {/* scan-frame outline (the colored border around the cutout) */} + + + + [ + { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + }, + ]}> + + + + + {/* Error overlay shown when scanned QR isn't a valid profile URL */} + {showError && ( + + + + + {l`Profile not found`} + + + {l`Oops, this profile doesn't exist. Try scanning another one.`} + + + + + )} + + ) +} + +/** + * 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 ( + + + + ) +} diff --git a/src/features/inviteFriends/InviteScannerScreen.web.tsx b/src/features/inviteFriends/InviteScannerScreen.web.tsx new file mode 100644 index 000000000..5a5875f3e --- /dev/null +++ b/src/features/inviteFriends/InviteScannerScreen.web.tsx @@ -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 ( + + + + + {l`Scan QR code`} + + + + + + + {l`Not available on this platform`} + + + {l`Please use the Bluesky mobile app to scan a QR code.`} + + + + ) +} diff --git a/src/features/inviteFriends/components/ActionButtons.tsx b/src/features/inviteFriends/components/ActionButtons.tsx new file mode 100644 index 000000000..b96af5813 --- /dev/null +++ b/src/features/inviteFriends/components/ActionButtons.tsx @@ -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 ( + + + {l`Share link`} + + + {l`Download`} + + + {l`Scan`} + + + ) +} diff --git a/src/features/inviteFriends/components/FollowersPromoBanner.tsx b/src/features/inviteFriends/components/FollowersPromoBanner.tsx new file mode 100644 index 000000000..870118159 --- /dev/null +++ b/src/features/inviteFriends/components/FollowersPromoBanner.tsx @@ -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 ( + + {/* + * 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. + */} + + [ + 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, + }, + ]}> + + + {l`Import contacts or invite your friends`} + + + [ + { + position: 'absolute', + top: 8, + right: 8, + padding: 4, + opacity: pressed ? 0.5 : 1, + }, + ]}> + + + + + ) +} diff --git a/src/features/inviteFriends/components/ThemePicker.tsx b/src/features/inviteFriends/components/ThemePicker.tsx new file mode 100644 index 000000000..179c80d81 --- /dev/null +++ b/src/features/inviteFriends/components/ThemePicker.tsx @@ -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 = { + dawn: l`Dawn`, + day: l`Day`, + dusk: l`Dusk`, + night: l`Night`, + } + + return ( + + + {({props}) => ( + ({ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingLeft: 12, + paddingRight: 14, + paddingVertical: 7, + borderRadius: 16, + backgroundColor: t.palette.contrast_50, + opacity: pressed ? 0.7 : 1, + })}> + + + + {l`Color`} + + + + + )} + + + + + {INVITE_THEME_KEYS.map(key => { + const theme = INVITE_THEMES[key] + return ( + onChange(key)}> + + {labels[key]} + + + ) + })} + + + + ) +} + +function GradientSwatch({ + from, + to, + size, +}: { + from: string + to: string + size: number +}) { + return ( + + ) +} diff --git a/src/features/inviteFriends/components/ThemedQrCard.tsx b/src/features/inviteFriends/components/ThemedQrCard.tsx new file mode 100644 index 000000000..394d6ecca --- /dev/null +++ b/src/features/inviteFriends/components/ThemedQrCard.tsx @@ -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 +}) { + const t = useTheme() + return ( + + + + + + + + {avatarUri ? ( + + ) : ( + + + + )} + + + + + {`@${handle}`} + + + + + ) +} + +/** + * 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 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, + ) +} diff --git a/src/features/inviteFriends/components/ThemedQrCard.web.tsx b/src/features/inviteFriends/components/ThemedQrCard.web.tsx new file mode 100644 index 000000000..bdecdc97b --- /dev/null +++ b/src/features/inviteFriends/components/ThemedQrCard.web.tsx @@ -0,0 +1,6 @@ +/** + * Web no-op - the invite-friends feature is native-only by design. + */ +export function ThemedQrCard() { + return null +} diff --git a/src/features/inviteFriends/hooks/useFollowersPromoDismissed.ts b/src/features/inviteFriends/hooks/useFollowersPromoDismissed.ts new file mode 100644 index 000000000..90e2c0668 --- /dev/null +++ b/src/features/inviteFriends/hooks/useFollowersPromoDismissed.ts @@ -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 +} diff --git a/src/features/inviteFriends/index.ts b/src/features/inviteFriends/index.ts new file mode 100644 index 000000000..f6e0416bb --- /dev/null +++ b/src/features/inviteFriends/index.ts @@ -0,0 +1,4 @@ +export {FollowersPromoBanner} from './components/FollowersPromoBanner' +export {useFollowersPromoDismissed} from './hooks/useFollowersPromoDismissed' +export {InviteFriendsDialog} from './InviteFriendsDialog' +export {InviteScannerScreen} from './InviteScannerScreen' diff --git a/src/features/inviteFriends/themes.test.ts b/src/features/inviteFriends/themes.test.ts new file mode 100644 index 000000000..6dbdfb2e9 --- /dev/null +++ b/src/features/inviteFriends/themes.test.ts @@ -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) + }) +}) diff --git a/src/features/inviteFriends/themes.ts b/src/features/inviteFriends/themes.ts new file mode 100644 index 000000000..6b7121737 --- /dev/null +++ b/src/features/inviteFriends/themes.ts @@ -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 = { + 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 +} diff --git a/src/features/inviteFriends/urls.test.ts b/src/features/inviteFriends/urls.test.ts new file mode 100644 index 000000000..e24c3ca1c --- /dev/null +++ b/src/features/inviteFriends/urls.test.ts @@ -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:\/\//, ''), + ) + }) + }) +}) diff --git a/src/features/inviteFriends/urls.ts b/src/features/inviteFriends/urls.ts new file mode 100644 index 000000000..7a22b8faf --- /dev/null +++ b/src/features/inviteFriends/urls.ts @@ -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:\/\//, '') +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 22bb50572..d40bcad58 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -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} diff --git a/src/routes.ts b/src/routes.ts index f7e73ff2c..87b58e0d0 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -40,6 +40,8 @@ export const router = new Router({ Debug: '/sys/debug', DebugMod: '/sys/debug-mod', Log: '/sys/log', + // invite friends + InviteScanner: '/invite/scan', // settings LanguageSettings: '/settings/language', AppPasswords: '/settings/app-passwords', diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 8e0055cc8..e02375351 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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, @@ -38,7 +39,9 @@ import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' 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 {GermButton} from '../components/GermButton' import {EditProfileDialog} from './EditProfileDialog' @@ -238,6 +241,7 @@ export function HeaderStandardButtons({ minimal?: boolean }) { const {_} = useLingui() + const ax = useAnalytics() const {hasSession, currentAccount} = useSession() const playHaptic = useHaptics() const requireAuth = useRequireAuth() @@ -247,6 +251,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 +352,25 @@ export function HeaderStandardButtons({ Edit Profile + {/* 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 && ( + + )} + {IS_NATIVE && } ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : ( diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index e1973c74f..57f1edde9 100644 --- a/src/screens/Settings/FindContactsSettings.tsx +++ b/src/screens/Settings/FindContactsSettings.tsx @@ -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) { - Find Friends + Find and invite friends @@ -113,6 +115,8 @@ function Intro() { const gutter = useGutters(['base']) const t = useTheme() const {_} = useLingui() + const ax = useAnalytics() + const inviteFriendsControl = useDialogControl() const {data: isAvailable, isSuccess} = useQuery({ queryKey: ['contacts-available'], @@ -122,6 +126,9 @@ function Intro() { return ( + + Find people you know + Find your friends on Bluesky by verifying your phone number and @@ -161,6 +168,20 @@ function Intro() { ) )} + + ) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 2e0138c5e..5fd53cde3 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -213,10 +213,10 @@ export function SettingsScreen({}: Props) { !ax.features.enabled(ax.features.ImportContactsSettingsDisable) && ( + label={l`Find and invite friends`}> - Find friends from contacts + Find and invite friends )} diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 25f975db2..7e55b9f50 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -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 | undefined> = { @@ -93,4 +98,5 @@ export const NuxSchemas: Record | undefined> = { [Nux.LiveNowBetaDialog]: undefined, [Nux.LiveNowBetaNudge]: undefined, [Nux.DraftsAnnouncement]: undefined, + [Nux.InviteFriendsAnnouncement]: undefined, } diff --git a/src/storage/schema.ts b/src/storage/schema.ts index e6dc86545..630360034 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -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. diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 0b5d0729c..32ec6d6de 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -5,14 +5,21 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {type NavigationProp} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {useProfileFollowersQuery} from '#/state/queries/profile-followers' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' +import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {useAnalytics} from '#/analytics' +import {IS_NATIVE} from '#/env' +import { + FollowersPromoBanner, + useFollowersPromoDismissed, +} from '#/features/inviteFriends' import {List} from '../util/List' import {ProfileCardWithFollowBtn} from './ProfileCard' @@ -43,7 +50,7 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) { export function ProfileFollowers({name}: {name: string}) { const {_} = useLingui() const ax = useAnalytics() - const navigation = useNavigation() + const navigation = useNavigation() const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() @@ -158,55 +165,80 @@ export function ProfileFollowers({name}: {name: string}) { [ax, followers, resolvedDid], ) - if (followers.length < 1) { - return ( - navigation.goBack(), - }} - /> - ) - } + 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 ( - + {showFollowersPromo && ( + navigation.navigate('FindContactsSettings')} + onDismiss={() => setFollowersPromoDismissed(true)} /> - } - // @ts-ignore our .web version only -prf - desktopFixedHeight - initialNumToRender={initialNumToRender} - windowSize={11} - sideBorders={false} - /> + )} + {followers.length < 1 ? ( + navigation.goBack(), + }} + /> + ) : ( + + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + sideBorders={false} + /> + )} + ) } diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index 33c31a9ee..84e8cb439 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -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, @@ -33,6 +35,7 @@ export default function Storybook() { const navigation = useNavigation() const requestDeviceGeolocation = useRequestDeviceGeolocation() const {setDeviceGeolocation} = useDeviceGeolocationApi() + const inviteFriendsControl = Dialog.useDialogControl() return ( <> @@ -98,6 +101,15 @@ export default function Storybook() { Get GPS Location + + + diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 10e4af037..2e151fa83 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -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, @@ -57,7 +65,8 @@ import {InlineLinkText} from '#/components/Link' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' 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' 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() @@ -93,11 +104,45 @@ let DrawerProfileCard = ({ {profile?.displayName || account.handle} {profile && } + {onPressShare && ( + [ + a.ml_auto, + { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: t.palette.contrast_50, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.7 : 1, + }, + ]}> + + + )} ): React.ReactNode => { isAtMessages, } = useNavigationTabState() const {hasSession, currentAccount} = useSession() + const inviteFriendsControl = useDialogControl() // events // = @@ -281,6 +327,15 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { { + ax.metric('invite:dialog:open', {logContext: 'Drawer'}) + setDrawerOpen(false) + inviteFriendsControl.open() + } + : undefined + } /> ) : ( @@ -330,6 +385,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { onPressFeedback={onPressFeedback} onPressHelp={onPressHelp} /> + ) } -- 2.51.2