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,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<NavigationProp>()
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 <Layout.Screen />
}
if (!permission.granted) {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>{l`Scan QR code`}</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.p_xl,
a.gap_md,
]}>
<InfoIcon size="xl" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_xl, a.font_bold, a.text_center]}>
{l`Camera access needed`}
</Text>
<Text
style={[
a.text_md,
a.text_center,
t.atoms.text_contrast_medium,
{maxWidth: 320},
]}>
{l`Bluesky needs camera access to scan QR codes from other profiles.`}
</Text>
<Button
label={l`Grant access`}
color="primary"
size="large"
onPress={() => void requestPermission()}
style={{marginTop: 8}}>
<ButtonText>{l`Grant access`}</ButtonText>
</Button>
</View>
</Layout.Screen>
)
}
// Active scanner ------------------------------------------------------------
return (
<Layout.Screen
minimalShell
noInsetTop
style={{backgroundColor: t.palette.black}}>
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{barcodeTypes: ['qr']}}
onBarcodeScanned={scannerEnabled ? onBarcodeScanned : undefined}
/>
<ScannerScrim />
{/* scan-frame outline (the colored border around the cutout) */}
<View
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: SCAN_FRAME_SIZE,
height: SCAN_FRAME_SIZE,
marginTop: -SCAN_FRAME_SIZE / 2,
marginLeft: -SCAN_FRAME_SIZE / 2,
borderWidth: 2,
borderColor: '#006aff',
borderRadius: SCAN_FRAME_RADIUS,
}}
pointerEvents="none"
/>
<View
style={{
position: 'absolute',
top: insets.top,
left: 0,
right: 0,
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Close scanner`}
accessibilityHint={l`Dismisses the QR scanner`}
onPress={onClose}
hitSlop={12}
style={({pressed}) => [
{
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
},
]}>
<ArrowLeftIcon size="lg" fill={t.palette.white} />
</Pressable>
</View>
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
{showError && (
<View
style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
transform: [{translateY: -118}],
alignItems: 'center',
}}
pointerEvents="box-none">
<View
style={[
a.gap_md,
a.align_center,
{
width: 280,
paddingHorizontal: 24,
paddingVertical: 24,
borderRadius: 16,
backgroundColor: t.palette.white,
},
]}>
<InfoIcon size="xl" fill={t.palette.negative_500} />
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
{l`Profile not found`}
</Text>
<Text
style={[
a.text_md,
a.text_center,
{color: t.palette.contrast_700},
]}>
{l`Oops, this profile doesn't exist. Try scanning another one.`}
</Text>
<Button
label={l`Try again`}
color="negative"
size="large"
onPress={onRetry}
style={{width: '100%', marginTop: 4}}>
<ButtonText>{l`Try again`}</ButtonText>
</Button>
</View>
</View>
)}
</Layout.Screen>
)
}
/**
* 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 (
<Svg
style={StyleSheet.absoluteFill}
width={width}
height={height}
pointerEvents="none">
<Path d={d} fill="rgba(0, 0, 0, 0.55)" fillRule="evenodd" />
</Svg>
)
}