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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 literal 0 HcmV?d00001 diff --git a/assets/images/invite_friends_promo_banner.webp b/assets/images/invite_friends_promo_banner.webp new file mode 100644 index 0000000000000000000000000000000000000000..cb6d45e681fda7b632952f80bdafa422dae591d4 GIT binary patch literal 55610 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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 5157c66e30..453fbd10f4 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 0000000000..48e4c05965 --- /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 2d2e2c6a6a..41daa8b9f0 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 0000000000..7ca76f6e66 --- /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 0000000000..155c533918 --- /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 0000000000..7d2641a775 --- /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 0000000000..2887835c6b --- /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 0000000000..5a5875f3e7 --- /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 0000000000..b96af58139 --- /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 0000000000..8701181599 --- /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 0000000000..179c80d815 --- /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 0000000000..394d6ecca5 --- /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 0000000000..bdecdc97b2 --- /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 0000000000..90e2c0668f --- /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 0000000000..f6e0416bbc --- /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 0000000000..6dbdfb2e9c --- /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 0000000000..6b71217372 --- /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 0000000000..e24c3ca1cc --- /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 0000000000..7a22b8faf4 --- /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 22bb50572a..d40bcad58c 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 f7e73ff2c2..87b58e0d07 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 8e0055cc8c..e023753515 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 e1973c74f9..57f1edde90 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 2e0138c5e2..5fd53cde39 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 25f975db26..7e55b9f50a 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 e6dc865450..6303600343 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 0b5d0729cf..32ec6d6deb 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 33c31a9ee6..84e8cb4397 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 10e4af037f..2e151fa832 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} /> + ) }