Add profile QR code for sharing and inviting friends (#10659)

This commit is contained in:
Spence Pope
2026-06-10 14:14:45 -04:00
committed by GitHub
parent adb39b4442
commit c6f9803676
32 changed files with 1853 additions and 55 deletions
@@ -0,0 +1,48 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {StackedButton} from '#/components/Button'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
export function ActionButtons({
onShare,
onDownload,
onScan,
}: {
onShare: () => void
onDownload: () => void
onScan: () => void
}) {
const {t: l} = useLingui()
return (
<View style={[a.flex_row, a.justify_center, a.w_full, {gap: 12}]}>
<StackedButton
label={l({message: 'Share invite link'})}
color="primary_subtle"
icon={ShareIcon}
onPress={onShare}
style={[a.flex_1]}>
{l`Share link`}
</StackedButton>
<StackedButton
label={l({message: 'Download invite QR code'})}
color="primary_subtle"
icon={DownloadIcon}
onPress={onDownload}
style={[a.flex_1]}>
{l`Download`}
</StackedButton>
<StackedButton
label={l({message: 'Open QR code scanner'})}
color="primary_subtle"
icon={CameraIcon}
onPress={onScan}
style={[a.flex_1]}>
{l`Scan`}
</StackedButton>
</View>
)
}
@@ -0,0 +1,101 @@
import {useEffect} from 'react'
import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function FollowersPromoBanner({
onPress,
onDismiss,
}: {
onPress: () => void
onDismiss: () => void
}) {
const {t: l} = useLingui()
const t = useTheme()
const ax = useAnalytics()
useEffect(() => {
ax.metric('invite:followersPromo:seen', {})
// Fire once per mount - parent unmounts the banner when followers > 0 or
// when dismissed, so each mount is a distinct impression.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handlePress = () => {
ax.metric('invite:followersPromo:press', {})
onPress()
}
const handleDismiss = () => {
ax.metric('invite:followersPromo:dismiss', {})
onDismiss()
}
return (
<View style={[a.px_lg, a.pt_md]}>
{/*
* The dismiss button is a sibling of (not nested inside) the banner
* Pressable - nesting Pressables causes touch-handling conflicts on
* Android. This relative wrapper matches the banner bounds so the dismiss
* button can be positioned absolutely against the card's top-right corner.
*/}
<View style={[a.relative]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Find and invite friends`}
accessibilityHint={l`Opens the find and invite friends settings`}
onPress={handlePress}
style={({pressed}) => [
a.flex_row,
a.align_center,
a.w_full,
{
height: 80,
borderRadius: 16,
backgroundColor: t.palette.primary_50,
paddingHorizontal: 16,
paddingVertical: 16,
opacity: pressed ? 0.85 : 1,
},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/invite_friends_promo_banner.webp')}
style={{width: 126, height: 64, marginRight: 16}}
contentFit="contain"
/>
<Text
style={[
a.flex_1,
a.text_md,
a.font_bold,
{color: t.palette.primary_700, lineHeight: 19.5},
]}>
{l`Import contacts or invite your friends`}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Dismiss`}
accessibilityHint={l`Hides the invite friends promo banner`}
onPress={handleDismiss}
hitSlop={12}
style={({pressed}) => [
{
position: 'absolute',
top: 8,
right: 8,
padding: 4,
opacity: pressed ? 0.5 : 1,
},
]}>
<TimesIcon width={12} height={12} fill={t.palette.contrast_500} />
</Pressable>
</View>
</View>
)
}
@@ -0,0 +1,120 @@
import {Pressable, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {useLingui} from '@lingui/react/macro'
import {useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {INVITE_THEME_KEYS, INVITE_THEMES, type InviteThemeKey} from '../themes'
const PILL_LABEL_SIZE = 13.1
const SWATCH_SIZE = 14
const CHEVRON_SIZE = 8
const MENU_ITEM_SWATCH = 18
export function ThemePicker({
value,
onChange,
}: {
value: InviteThemeKey
onChange: (next: InviteThemeKey) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const current = INVITE_THEMES[value]
const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`,
day: l`Day`,
dusk: l`Dusk`,
night: l`Night`,
}
return (
<Menu.Root>
<Menu.Trigger
label={l`Pick a color theme`}
hint={l`Opens a list of color themes for the QR card`}>
{({props}) => (
<Pressable
{...props}
style={({pressed}) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 12,
paddingRight: 14,
paddingVertical: 7,
borderRadius: 16,
backgroundColor: t.palette.contrast_50,
opacity: pressed ? 0.7 : 1,
})}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
<GradientSwatch
from={current.light.gradientFrom}
to={current.light.gradientTo}
size={SWATCH_SIZE}
/>
<Text
style={{
color: t.palette.contrast_700,
fontSize: PILL_LABEL_SIZE,
fontWeight: '500',
lineHeight: PILL_LABEL_SIZE * 1.3,
}}>
{l`Color`}
</Text>
</View>
<ChevronDown
width={CHEVRON_SIZE}
height={CHEVRON_SIZE}
fill={t.palette.contrast_700}
/>
</Pressable>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{INVITE_THEME_KEYS.map(key => {
const theme = INVITE_THEMES[key]
return (
<Menu.Item
key={key}
label={labels[key]}
onPress={() => onChange(key)}>
<GradientSwatch
from={theme.light.gradientFrom}
to={theme.light.gradientTo}
size={MENU_ITEM_SWATCH}
/>
<Menu.ItemText>{labels[key]}</Menu.ItemText>
<Menu.ItemRadio selected={key === value} />
</Menu.Item>
)
})}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
function GradientSwatch({
from,
to,
size,
}: {
from: string
to: string
size: number
}) {
return (
<LinearGradient
colors={[from, to]}
start={{x: 0.5, y: 0}}
end={{x: 0.5, y: 1}}
style={{width: size, height: size, borderRadius: size / 2}}
/>
)
}
@@ -0,0 +1,246 @@
import {lazy} from 'react'
import {View} from 'react-native'
// @ts-expect-error missing types
import QRCode from 'react-native-qrcode-styled'
import type ViewShot from 'react-native-view-shot'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration'
import {Text} from '#/components/Typography'
import {type InviteThemeVariant} from '../themes'
const LazyViewShot = lazy(
// @ts-expect-error dynamic import
() => import('react-native-view-shot/src/index'),
)
const CARD_WIDTH = 278
const CARD_GRADIENT_PADDING = 12
const QR_AREA = CARD_WIDTH - CARD_GRADIENT_PADDING * 2
const QR_PIECE_SIZE = 7
const QR_INNER_PADDING = 4
const AVATAR_WRAPPER = 72
const AVATAR_IMAGE = 60
const AVATAR_BORDER = 2
export function ThemedQrCard({
variant,
shareUrl,
handle,
avatarUri,
captureRef,
}: {
variant: InviteThemeVariant
shareUrl: string
handle: string
avatarUri?: string
captureRef: React.Ref<ViewShot>
}) {
const t = useTheme()
return (
<LazyViewShot ref={captureRef}>
<View
style={{
width: CARD_WIDTH,
borderRadius: 20,
backgroundColor: variant.gradientFrom,
shadowColor: variant.shadowColor,
shadowOpacity: 0.5,
shadowRadius: 25,
shadowOffset: {width: 4, height: 4},
}}>
<LinearGradient
colors={[variant.gradientFrom, variant.gradientTo]}
start={{x: 0.5, y: 0}}
end={{x: 0.5, y: 1}}
style={[
a.align_center,
{
padding: CARD_GRADIENT_PADDING,
borderRadius: 20,
overflow: 'hidden',
},
]}>
<View
style={[
a.align_center,
a.justify_center,
{
width: QR_AREA,
height: QR_AREA,
backgroundColor: t.palette.white,
borderRadius: 10,
overflow: 'hidden',
},
]}>
<QRCode
data={shareUrl}
style={{
width: QR_AREA,
height: QR_AREA,
backgroundColor: t.palette.white,
}}
pieceSize={QR_PIECE_SIZE}
padding={QR_INNER_PADDING}
pieceBorderRadius={3.5}
{...qrGradient(variant.gradientFrom, variant.gradientTo)}
outerEyesOptions={{
topLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
innerEyesOptions={{
topLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
logo={{
hidePieces: true,
padding: 2,
scale: 0.95,
href: avatarUri
? {uri: avatarUri}
: require('../../../../assets/logo.png'),
}}
/>
<View
style={{
position: 'absolute',
width: AVATAR_WRAPPER,
height: AVATAR_WRAPPER,
borderRadius: AVATAR_WRAPPER / 2,
backgroundColor: t.palette.white,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
}}>
<View
style={{
width: AVATAR_IMAGE,
height: AVATAR_IMAGE,
borderRadius: AVATAR_IMAGE / 2,
borderWidth: AVATAR_BORDER,
borderColor: t.palette.white,
overflow: 'hidden',
}}>
{avatarUri ? (
<Image
source={{uri: avatarUri}}
style={{
width: AVATAR_IMAGE - AVATAR_BORDER * 2,
height: AVATAR_IMAGE - AVATAR_BORDER * 2,
borderRadius: (AVATAR_IMAGE - AVATAR_BORDER * 2) / 2,
}}
accessibilityIgnoresInvertColors
/>
) : (
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Logo width={40} fill={variant.qrPrimary} />
</View>
)}
</View>
</View>
</View>
<Text
style={[
a.font_medium,
a.text_md,
{color: variant.handleColor, paddingTop: 12, paddingBottom: 2},
]}
numberOfLines={1}
ellipsizeMode="middle">
{`@${handle}`}
</Text>
</LinearGradient>
</View>
</LazyViewShot>
)
}
/**
* Returns the `gradient` prop shape that react-native-qrcode-styled accepts
* on both pieces and eyes. Mirrors the card's top-to-bottom LinearGradient
* so the QR data appears as a continuous extension of the card gradient
* rather than a separate solid-color overlay.
*/
function qrGradient(from: string, to: string) {
return {
gradient: {
type: 'linear' as const,
options: {
colors: [from, to],
start: [0.5, 0] as [number, number],
end: [0.5, 1] as [number, number],
},
},
}
}
// Vertical positions (0..1 along the QR) where the corner eye centers sit.
// QR is 33 modules square; eye centers are at module index 3 and 29.
const EYE_TOP_T = 3 / 33
const EYE_BOTTOM_T = 29 / 33
/**
* Returns the solid color the canvas-wide gradient would paint at vertical
* position `t` (0=top, 1=bottom). We use this to color each eye so that the
* eye blends seamlessly into the gradient on the surrounding data pieces.
* Eyes can't use a local `gradient` prop here - react-native-qrcode-styled
* 0.3.3 has an internal ID mismatch where the eye gradient <defs> are
* registered as `${pos}CornerSquareGradient` / `${pos}CornerDotGradient`
* but the Path fill references `url(#${pos}OuterEyeGradient)` /
* `url(#${pos}InnerEyeGradient)`, so per-eye gradients silently render
* with no fill.
*/
function eyeColor(from: string, to: string, t: number): string {
const fromRgb = hexToRgb(from)
const toRgb = hexToRgb(to)
if (!fromRgb || !toRgb) return from
return rgbToHex(
fromRgb.r + (toRgb.r - fromRgb.r) * t,
fromRgb.g + (toRgb.g - fromRgb.g) * t,
fromRgb.b + (toRgb.b - fromRgb.b) * t,
)
}
@@ -0,0 +1,6 @@
/**
* Web no-op - the invite-friends feature is native-only by design.
*/
export function ThemedQrCard() {
return null
}