Add profile QR code for sharing and inviting friends (#10659)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user