diff --git a/assets/images/invite_friends_announcement_nux.webp b/assets/images/invite_friends_announcement_nux.webp new file mode 100644 index 0000000000..53e79e1d54 Binary files /dev/null and b/assets/images/invite_friends_announcement_nux.webp differ diff --git a/assets/images/invite_friends_promo_banner.webp b/assets/images/invite_friends_promo_banner.webp new file mode 100644 index 0000000000..cb6d45e681 Binary files /dev/null and b/assets/images/invite_friends_promo_banner.webp differ diff --git a/src/Navigation.tsx b/src/Navigation.tsx index ceebcf3986..a83469d27a 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -134,6 +134,7 @@ import { import {useAnalytics} from '#/analytics' import {setNavigationMetadata} from '#/analytics/metadata' import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' +import {InviteScannerScreen} from '#/features/inviteFriends' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout' @@ -309,6 +310,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => 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} /> + ) }