From 42732c8beab1c7b9f15c43572568ba28310f34e6 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Fri, 29 May 2026 17:21:41 -0400 Subject: [PATCH] add invite friends ui --- src/Navigation.tsx | 6 + .../nuxs/InviteFriendsAnnouncement.tsx | 158 +++++++++ src/components/dialogs/nuxs/index.tsx | 11 + .../inviteFriends/InviteFriendsDialog.tsx | 15 + .../inviteFriends/InviteFriendsDialog.web.tsx | 10 + .../InviteFriendsDialogInner.tsx | 238 +++++++++++++ .../inviteFriends/InviteScannerScreen.tsx | 326 ++++++++++++++++++ .../components/ActionButtons.tsx | 48 +++ .../components/FollowersPromoBanner.tsx | 75 ++++ .../inviteFriends/components/ThemePicker.tsx | 188 ++++++++++ .../inviteFriends/components/ThemedQrCard.tsx | 255 ++++++++++++++ .../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 | 70 ++++ src/features/inviteFriends/urls.ts | 28 ++ src/lib/routes/types.ts | 1 + src/routes.ts | 2 + .../Profile/Header/ProfileHeaderStandard.tsx | 16 + src/screens/Settings/FindContactsSettings.tsx | 19 +- src/screens/Settings/Settings.tsx | 15 + src/state/queries/nuxs/definitions.ts | 6 + src/storage/schema.ts | 1 + src/view/com/profile/ProfileFollowers.tsx | 70 ++-- src/view/screens/Storybook/Storybook.tsx | 12 + src/view/shell/Drawer.tsx | 52 ++- 28 files changed, 1762 insertions(+), 25 deletions(-) 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/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/src/Navigation.tsx b/src/Navigation.tsx index 6284aff34b..2bfcc5fd78 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -132,6 +132,7 @@ import { import {useAnalytics} from '#/analytics' import {setNavigationMetadata} from '#/analytics/metadata' import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' +import {InviteScannerScreen} from '#/features/inviteFriends' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout' @@ -307,6 +308,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => DebugModScreen} options={{title: title(msg`Moderation states`), requireAuth: true}} /> + InviteScannerScreen} + options={{title: title(msg`Scan QR Code`), requireAuth: true}} + /> SharedPreferencesTesterScreen} diff --git a/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx new file mode 100644 index 0000000000..7e052da1bd --- /dev/null +++ b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx @@ -0,0 +1,158 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' +import {Text} from '#/components/Typography' +import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env' +import {InviteFriendsDialog} from '#/features/inviteFriends' +import {createIsEnabledCheck} from './utils' + +export const enabled = createIsEnabledCheck(() => { + // Native-only feature (matches the rest of the invite-friends scope). + // Skipped in E2E to keep tests deterministic. + return !IS_E2E && IS_NATIVE +}) + +export function InviteFriendsAnnouncement() { + const t = useTheme() + const {_} = useLingui() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + const inviteFriendsControl = Dialog.useDialogControl() + const [openInviteAfterClose, setOpenInviteAfterClose] = useState(false) + + Dialog.useAutoOpen(control) + + const onClose = useCallback(() => { + nuxDialogs.dismissActiveNux() + if (openInviteAfterClose) { + // Defer to the next tick so the close animation completes before + // we open the next dialog (avoids visual glitch on iOS). + setOpenInviteAfterClose(false) + inviteFriendsControl.open() + } + }, [nuxDialogs, openInviteAfterClose, inviteFriendsControl]) + + const onPressTryIt = useCallback(() => { + setOpenInviteAfterClose(true) + control.close() + }, [control]) + + return ( + <> + + + + + + + + + + New Feature + + + {/* + TODO: drop promo image + */} + + + + + + Invite your friends + + + + You can now invite friends with a link or QR code. Share it + anywhere or let them scan 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..2516b22bd3 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,9 @@ function Inner({ {/*For example, activeNux === Nux.NeueTypography && */} {activeNux === Nux.DraftsAnnouncement && } + {activeNux === Nux.InviteFriendsAnnouncement && ( + + )} ) } diff --git a/src/features/inviteFriends/InviteFriendsDialog.tsx b/src/features/inviteFriends/InviteFriendsDialog.tsx new file mode 100644 index 0000000000..39d13c8777 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialog.tsx @@ -0,0 +1,15 @@ +import * as Dialog from '#/components/Dialog' +import {InviteFriendsDialogInner} from './InviteFriendsDialogInner' + +export function InviteFriendsDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + return ( + + + + + ) +} diff --git a/src/features/inviteFriends/InviteFriendsDialog.web.tsx b/src/features/inviteFriends/InviteFriendsDialog.web.tsx new file mode 100644 index 0000000000..1a28607194 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialog.web.tsx @@ -0,0 +1,10 @@ +import {type DialogControlProps} from '#/components/Dialog' + +/** + * Web no-op - the invite-friends share sheet is native-only by design. + * Callers may still create a Dialog control and pass it; opening it does + * nothing on web. + */ +export function InviteFriendsDialog(_props: {control: DialogControlProps}) { + return null +} diff --git a/src/features/inviteFriends/InviteFriendsDialogInner.tsx b/src/features/inviteFriends/InviteFriendsDialogInner.tsx new file mode 100644 index 0000000000..dc1cdf6483 --- /dev/null +++ b/src/features/inviteFriends/InviteFriendsDialogInner.tsx @@ -0,0 +1,238 @@ +import {Suspense, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import type ViewShot from 'react-native-view-shot' +import {setStringAsync} from 'expo-clipboard' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import {createAssetAsync} from 'expo-media-library' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {shareUrl as nativeShareUrl} from '#/lib/sharing' +import {logger} from '#/logger' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {Error as ErrorMessage} from '#/components/Error' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' +import {ActionButtons} from './components/ActionButtons' +import {ThemedQrCard} from './components/ThemedQrCard' +import {ThemePicker} from './components/ThemePicker' +import {getInviteTheme, type InviteThemeKey} from './themes' +import {getInviteDisplayUrl, getInviteShareUrl} from './urls' + +export function InviteFriendsDialogInner({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const {t: l} = useLingui() + const t = useTheme() + const navigation = useNavigation() + 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 cosmeticDisplayUrl = isHandleValid ? getInviteDisplayUrl(handle) : '' + + const onShare = async () => { + if (!canonicalShareUrl) { + Toast.show(l`Could not share - please try again`, {type: 'error'}) + return + } + try { + await nativeShareUrl(canonicalShareUrl) + } catch (err) { + logger.error('InviteFriendsDialog: share failed', {safeMessage: err}) + } + } + + const onDownload = async () => { + if (!IS_NATIVE) return + if (!canonicalShareUrl) { + Toast.show(l`Could not download - please try again`, {type: 'error'}) + return + } + const uri = await captureRef.current?.capture?.() + if (!uri) { + Toast.show(l`Could not capture QR code`, {type: 'error'}) + return + } + + const permission = await requestMediaLibraryPermissionsAsync() + if (!permission.granted) { + Toast.show( + l`You must grant access to your photo library to save the QR code`, + {type: 'error'}, + ) + return + } + + try { + await createAssetAsync(`file://${uri}`) + Toast.show(l`QR code saved to your camera roll`) + } catch (err) { + logger.error('InviteFriendsDialog: download failed', {safeMessage: err}) + Toast.show(l`Could not save QR code`, {type: 'error'}) + } + } + + const onScan = () => { + // Close dialog first, then navigate (control.close callback per CLAUDE.md + // Dialog footgun rule — prevents race with the navigation push). + control.close(() => { + navigation.navigate('InviteScanner') + }) + } + + const onCopy = async () => { + if (!canonicalShareUrl) { + Toast.show(l`Could not copy - please try again`, {type: 'error'}) + return + } + try { + await setStringAsync(canonicalShareUrl) + Toast.show(l`Invite link copied`) + } catch (err) { + logger.error('InviteFriendsDialog: copy failed', {safeMessage: err}) + Toast.show(l`Could not copy link`, {type: 'error'}) + } + } + + return ( + + {l`Invite Friends`} + + }> + + + + + {profileQuery.isLoading ? ( + + + + ) : profileQuery.error || !isHandleValid ? ( + + profileQuery.refetch()} + hideBackButton + /> + + ) : ( + + + + }> + + + )} + + + + + + + + + + {l`Invite link`} + + + + + + + + {cosmeticDisplayUrl || ' '} + + [{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..4770653c92 --- /dev/null +++ b/src/features/inviteFriends/InviteScannerScreen.tsx @@ -0,0 +1,326 @@ +import {useCallback, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + type BarcodeScanningResult, + CameraView, + useCameraPermissions, +} from 'expo-camera' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo' +import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +const SCAN_FRAME_SIZE = 333 + +export function InviteScannerScreen() { + const {t: l} = useLingui() + const t = useTheme() + const insets = useSafeAreaInsets() + const navigation = useNavigation() + const [permission, requestPermission] = useCameraPermissions() + const [error, setError] = useState(null) + const [scannerEnabled, setScannerEnabled] = useState(true) + + const onClose = useCallback(() => { + navigation.goBack() + }, [navigation]) + + const onBarcodeScanned = useCallback( + (result: BarcodeScanningResult) => { + if (!scannerEnabled) return + const url = result.data?.trim() + if (!url) return + + // Match bsky.app/profile/{handle} URLs (with or without https://). + const profileMatch = url.match( + /^(?:https?:\/\/)?bsky\.app\/profile\/([^/?#]+)/i, + ) + if (!profileMatch) { + setScannerEnabled(false) + setError( + l({ + message: 'Profile not found', + comment: + 'Error shown when a scanned QR code is not a valid Bluesky profile URL', + }), + ) + return + } + + setScannerEnabled(false) + const handle = profileMatch[1] + navigation.replace('Profile', {name: handle}) + }, + [scannerEnabled, navigation, l], + ) + + const onRetry = useCallback(() => { + setError(null) + setScannerEnabled(true) + }, []) + + const onPressGallery = useCallback(() => { + // TODO: implement gallery image picker + QR decode. expo-camera only + // scans live; decoding from a gallery image requires an additional + // library (e.g. @react-native-vision-camera + vision-camera-code-scanner, + // or a JS-side decoder like jsqr running on a pixel array). + logger.warn('InviteScanner: gallery picker not implemented yet') + }, []) + + // Permission states --------------------------------------------------------- + + if (!permission) { + // Still loading the initial permission state — render nothing. + return + } + + 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 ( + + + + + + + [ + { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + }, + ]}> + + + [ + { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(255, 255, 255, 0.25)', + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + }, + ]}> + + + + + {/* Error overlay shown when scanned QR isn't a valid profile URL */} + {error && ( + + + + + {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 transparent 333x333 + * cutout in the middle. We use four absolute Views forming a window-frame + * shape because React Native doesn't support CSS clip-path / mix-blend modes. + */ +function ScannerScrim() { + const SCRIM_BG = 'rgba(0, 0, 0, 0.55)' + return ( + <> + {/* top */} + + {/* bottom */} + + {/* left */} + + {/* right */} + + {/* scan-frame outline (the colored border around the cutout) */} + + + ) +} diff --git a/src/features/inviteFriends/components/ActionButtons.tsx b/src/features/inviteFriends/components/ActionButtons.tsx new file mode 100644 index 0000000000..a1ffef7989 --- /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..778aa9c62d --- /dev/null +++ b/src/features/inviteFriends/components/FollowersPromoBanner.tsx @@ -0,0 +1,75 @@ +import {Pressable, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +export function FollowersPromoBanner({ + onPress, + onDismiss, +}: { + onPress: () => void + onDismiss: () => void +}) { + const {t: l} = useLingui() + const t = useTheme() + return ( + + [ + 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..305424d579 --- /dev/null +++ b/src/features/inviteFriends/components/ThemePicker.tsx @@ -0,0 +1,188 @@ +import {useRef, useState} from 'react' +import {Modal, Pressable, StyleSheet, View} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme} from '#/alf' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {Text} from '#/components/Typography' +import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes' + +const PILL_LABEL_SIZE = 13.1 +const SWATCH_SIZE = 14 +const CHEVRON_SIZE = 8 + +const MENU_WIDTH = 140 +const MENU_ROW_HEIGHT = 44 +const MENU_ITEM_SWATCH = 18 +const MENU_GAP_TO_TRIGGER = 8 + +export function ThemePicker({ + value, + onChange, +}: { + value: InviteThemeKey + onChange: (next: InviteThemeKey) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const current = INVITE_THEMES[value] + const triggerRef = useRef(null) + const [open, setOpen] = useState(false) + const [anchor, setAnchor] = useState<{ + x: number + y: number + w: number + h: number + } | null>(null) + + const labels: Record = { + dawn: l`Dawn`, + day: l`Day`, + dusk: l`Dusk`, + night: l`Night`, + } + + const handleOpen = () => { + triggerRef.current?.measureInWindow((x, y, w, h) => { + setAnchor({x, y, w, h}) + setOpen(true) + }) + } + + const handleSelect = (key: InviteThemeKey) => { + onChange(key) + setOpen(false) + } + + return ( + <> + ({ + 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`} + + + + + + setOpen(false)}> + setOpen(false)}> + {anchor ? ( + + {INVITE_THEME_KEYS.map(key => { + const theme = INVITE_THEMES[key] + return ( + handleSelect(key)} + style={({pressed}) => ({ + flexDirection: 'row', + alignItems: 'center', + gap: 6, + height: MENU_ROW_HEIGHT, + paddingHorizontal: 16, + backgroundColor: pressed + ? t.palette.contrast_50 + : 'transparent', + })}> + + + {labels[key]} + + + ) + })} + + ) : null} + + + + ) +} + +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..6b6e61c70b --- /dev/null +++ b/src/features/inviteFriends/components/ThemedQrCard.tsx @@ -0,0 +1,255 @@ +import {lazy} from 'react' +import {View} from 'react-native' +// @ts-expect-error missing types +import QRCode from 'react-native-qrcode-styled' +import type ViewShot from 'react-native-view-shot' +import {Image} from 'expo-image' +import {LinearGradient} from 'expo-linear-gradient' + +import {Logo} from '#/view/icons/Logo' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {type InviteThemeVariant} from '../themes' + +const LazyViewShot = lazy( + // @ts-expect-error dynamic import + () => import('react-native-view-shot/src/index'), +) + +const CARD_WIDTH = 278 +const CARD_GRADIENT_PADDING = 12 +const QR_AREA = CARD_WIDTH - CARD_GRADIENT_PADDING * 2 +const QR_PIECE_SIZE = 7 +const QR_INNER_PADDING = 4 +const AVATAR_WRAPPER = 72 +const AVATAR_IMAGE = 60 +const AVATAR_RADIUS = 10 +const AVATAR_BORDER = 2 + +export function ThemedQrCard({ + variant, + shareUrl, + handle, + avatarUri, + captureRef, +}: { + variant: InviteThemeVariant + shareUrl: string + handle: string + avatarUri?: string + captureRef: React.Ref +}) { + 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 + const r = Math.round(fromRgb.r + (toRgb.r - fromRgb.r) * t) + const g = Math.round(fromRgb.g + (toRgb.g - fromRgb.g) * t) + const b = Math.round(fromRgb.b + (toRgb.b - fromRgb.b) * t) + return `#${[r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')}` +} + +function hexToRgb(hex: string): {r: number; g: number; b: number} | null { + const match = hex.replace('#', '').match(/^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i) + if (!match) return null + return { + r: parseInt(match[1], 16), + g: parseInt(match[2], 16), + b: parseInt(match[3], 16), + } +} 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..0819ff5f6e --- /dev/null +++ b/src/features/inviteFriends/urls.test.ts @@ -0,0 +1,70 @@ +import {describe, expect, it} from '@jest/globals' + +import {getInviteDisplayUrl, getInviteShareUrl} from './urls' + +describe('invite URLs', () => { + describe('getInviteShareUrl', () => { + it('returns the canonical profile URL for a handle', () => { + expect(getInviteShareUrl('alice.bsky.social')).toBe( + 'https://bsky.app/profile/alice.bsky.social', + ) + }) + + it('preserves custom domains', () => { + expect(getInviteShareUrl('thepope.dev')).toBe( + 'https://bsky.app/profile/thepope.dev', + ) + }) + + it('strips a leading @ if present', () => { + expect(getInviteShareUrl('@alice.bsky.social')).toBe( + 'https://bsky.app/profile/alice.bsky.social', + ) + }) + + it('lowercases the handle', () => { + expect(getInviteShareUrl('Alice.Bsky.Social')).toBe( + 'https://bsky.app/profile/alice.bsky.social', + ) + }) + + it('returns empty string for empty handle (EDGE-001)', () => { + expect(getInviteShareUrl('')).toBe('') + expect(getInviteShareUrl('@')).toBe('') + }) + }) + + describe('getInviteDisplayUrl', () => { + it('returns a cosmetic bsky.app/invite/ string', () => { + expect(getInviteDisplayUrl('danielle.bsky.team')).toBe( + 'bsky.app/invite/danielle', + ) + }) + + it('uses the bare handle when there is no dot', () => { + expect(getInviteDisplayUrl('alice')).toBe('bsky.app/invite/alice') + }) + + it('lowercases the short segment', () => { + expect(getInviteDisplayUrl('Danielle.bsky.team')).toBe( + 'bsky.app/invite/danielle', + ) + }) + + it('strips a leading @ if present', () => { + expect(getInviteDisplayUrl('@alice.bsky.social')).toBe( + 'bsky.app/invite/alice', + ) + }) + + it('returns empty string for empty handle (EDGE-001)', () => { + expect(getInviteDisplayUrl('')).toBe('') + expect(getInviteDisplayUrl('@')).toBe('') + }) + + it('returns empty string when first segment is empty (EDGE-002)', () => { + expect(getInviteDisplayUrl('.bsky.social')).toBe('') + expect(getInviteDisplayUrl('@.bsky.social')).toBe('') + }) + }) +}) diff --git a/src/features/inviteFriends/urls.ts b/src/features/inviteFriends/urls.ts new file mode 100644 index 0000000000..8d2628cbb9 --- /dev/null +++ b/src/features/inviteFriends/urls.ts @@ -0,0 +1,28 @@ +/** + * URL helpers for the Invite Friends share sheet (APP-2142). + * + * We display a cosmetic `bsky.app/invite/{shortHandle}` string to match the + * design, but every actual action (QR payload, share sheet, clipboard) uses + * the canonical `https://bsky.app/profile/{handle}` URL because no + * `bsky.app/invite/...` route exists yet. + */ + +function stripLeadingAt(handle: string): string { + return handle.startsWith('@') ? handle.slice(1) : handle +} + +/** Canonical URL - used for QR payload, Share, and Copy. Empty handle -> empty string. */ +export function getInviteShareUrl(handle: string): string { + const bare = stripLeadingAt(handle).toLowerCase() + if (!bare) return '' + return `https://bsky.app/profile/${bare}` +} + +/** Cosmetic label shown in the "Invite link" field. Not a functional URL. */ +export function getInviteDisplayUrl(handle: string): string { + const bare = stripLeadingAt(handle) + if (!bare) return '' + const short = bare.split('.')[0].toLowerCase() + if (!short) return '' + return `bsky.app/invite/${short}` +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 3c30b740da..ee7db71608 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 7ae73dd6e1..68bb491c36 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..90b3ebabe8 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, @@ -39,6 +40,7 @@ import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {IS_IOS} from '#/env' +import {InviteFriendsDialog} from '#/features/inviteFriends' import {useActorStatus} from '#/features/liveNow' import {GermButton} from '../components/GermButton' import {EditProfileDialog} from './EditProfileDialog' @@ -247,6 +249,7 @@ export function HeaderStandardButtons({ ) const [, queueUnblock] = useProfileBlockMutationQueue(profile) const editProfileControl = useDialogControl() + const inviteFriendsControl = useDialogControl() const unblockPromptControl = Prompt.usePromptControl() const isMe = currentAccount?.did === profile.did @@ -347,7 +350,20 @@ export function HeaderStandardButtons({ Edit Profile + + ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : ( diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index e1973c74f9..0116c23fd7 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,7 @@ function Intro() { const gutter = useGutters(['base']) const t = useTheme() const {_} = useLingui() + const inviteFriendsControl = useDialogControl() const {data: isAvailable, isSuccess} = useQuery({ queryKey: ['contacts-available'], @@ -122,6 +125,9 @@ function Intro() { return ( + + Find people you know + Find your friends on Bluesky by verifying your phone number and @@ -161,6 +167,17 @@ function Intro() { ) )} + + ) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 2e0138c5e2..b63e79f7fb 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -64,6 +64,7 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' +import {InviteFriendsDialog} from '#/features/inviteFriends' import {useActorStatus} from '#/features/liveNow' import {device, useStorage} from '#/storage' import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' @@ -76,6 +77,7 @@ export function SettingsScreen({}: Props) { const {logoutEveryAccount} = useSessionApi() const {accounts, currentAccount} = useSession() const switchAccountControl = useDialogControl() + const inviteFriendsControl = useDialogControl() const signOutPromptControl = Prompt.usePromptControl() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: otherProfiles} = useProfilesQuery({ @@ -208,6 +210,18 @@ export function SettingsScreen({}: Props) { Content and media + {IS_NATIVE && ( + inviteFriendsControl.open()}> + + + Find and invite friends + + + + )} {IS_NATIVE && findContactsEnabled && !ax.features.enabled(ax.features.ImportContactsSettingsDisable) && ( @@ -302,6 +316,7 @@ export function SettingsScreen({}: Props) { /> + ) } 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..71d72d7b25 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -10,9 +10,15 @@ import {logger} from '#/logger' import {useProfileFollowersQuery} from '#/state/queries/profile-followers' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' +import {useDialogControl} from '#/components/Dialog' import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {useAnalytics} from '#/analytics' +import { + FollowersPromoBanner, + InviteFriendsDialog, + useFollowersPromoDismissed, +} from '#/features/inviteFriends' import {List} from '../util/List' import {ProfileCardWithFollowBtn} from './ProfileCard' @@ -158,30 +164,50 @@ export function ProfileFollowers({name}: {name: string}) { [ax, followers, resolvedDid], ) + const inviteFriendsControl = useDialogControl() + const [followersPromoDismissed, setFollowersPromoDismissed] = + useFollowersPromoDismissed() + const showFollowersPromo = + isMe && + !followersPromoDismissed && + followers.length < 1 && + !isDidLoading && + !isFollowersLoading && + !isError + if (followers.length < 1) { return ( - navigation.goBack(), - }} - /> + <> + {showFollowersPromo && ( + inviteFriendsControl.open()} + onDismiss={() => setFollowersPromoDismissed(true)} + /> + )} + navigation.goBack(), + }} + /> + + ) } diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index 8fb85e8e0a..a6c6256244 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, @@ -32,6 +34,7 @@ export default function Storybook() { const navigation = useNavigation() const requestDeviceGeolocation = useRequestDeviceGeolocation() const {setDeviceGeolocation} = useDeviceGeolocationApi() + const inviteFriendsControl = Dialog.useDialogControl() return ( <> @@ -97,6 +100,15 @@ export default function Storybook() { Get GPS Location + + + diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 10e4af037f..2ae286a110 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, @@ -58,6 +66,7 @@ import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' +import {InviteFriendsDialog} from '#/features/inviteFriends' import {useActorStatus} from '#/features/liveNow' const iconWidth = 26 @@ -65,9 +74,11 @@ const iconWidth = 26 let DrawerProfileCard = ({ account, onPressProfile, + onPressShare, }: { account: SessionAccount onPressProfile: () => void + onPressShare?: () => void }): React.ReactNode => { const {_, i18n} = useLingui() const t = useTheme() @@ -80,7 +91,38 @@ let DrawerProfileCard = ({ accessibilityLabel={_(msg`Profile`)} accessibilityHint={_(msg`Navigates to your profile`)} onPress={onPressProfile} - style={[a.gap_sm, a.pr_lg]}> + style={[a.gap_sm, a.pr_lg, {position: 'relative'}]}> + {onPressShare && ( + [ + { + position: 'absolute', + top: 4, + right: 16, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: t.palette.contrast_50, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.7 : 1, + zIndex: 1, + }, + ]}> + + + )} ): React.ReactNode => { isAtMessages, } = useNavigationTabState() const {hasSession, currentAccount} = useSession() + const inviteFriendsControl = useDialogControl() // events // = @@ -281,6 +324,10 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { { + setDrawerOpen(false) + inviteFriendsControl.open() + }} /> ) : ( @@ -330,6 +377,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { onPressFeedback={onPressFeedback} onPressHelp={onPressHelp} /> + ) }