add invite friends ui
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InviteFriendsDialogInner} from './InviteFriendsDialogInner'
|
||||
|
||||
export function InviteFriendsDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<InviteFriendsDialogInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<NavigationProp>()
|
||||
const {currentAccount} = useSession()
|
||||
const profileQuery = useProfileQuery({did: currentAccount?.did})
|
||||
const [themeKey, setThemeKey] = useState<InviteThemeKey>('day')
|
||||
const captureRef = useRef<ViewShot>(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 (
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Invite friends`}
|
||||
contentContainerStyle={[a.pt_sm]}
|
||||
header={
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
}>
|
||||
<View style={[a.align_center, a.pt_xl]}>
|
||||
<ThemePicker value={themeKey} onChange={setThemeKey} />
|
||||
|
||||
<View style={[a.mt_5xl]}>
|
||||
{profileQuery.isLoading ? (
|
||||
<View style={[a.align_center, a.justify_center, {minHeight: 360}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : profileQuery.error || !isHandleValid ? (
|
||||
<View style={[a.w_full, {minHeight: 360}]}>
|
||||
<ErrorMessage
|
||||
title={l`Could not load profile`}
|
||||
message={l`Try again in a moment.`}
|
||||
onRetry={() => profileQuery.refetch()}
|
||||
hideBackButton
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<View
|
||||
style={[a.align_center, a.justify_center, {minHeight: 360}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
}>
|
||||
<ThemedQrCard
|
||||
variant={variant}
|
||||
shareUrl={canonicalShareUrl}
|
||||
handle={handle ?? ''}
|
||||
avatarUri={avatarUri}
|
||||
captureRef={captureRef}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.w_full, {marginTop: 48}]}>
|
||||
<ActionButtons
|
||||
onShare={onShare}
|
||||
onDownload={onDownload}
|
||||
onScan={onScan}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.w_full,
|
||||
a.mt_4xl,
|
||||
{gap: 24},
|
||||
]}>
|
||||
<Divider style={[a.flex_1]} />
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{l`Invite link`}
|
||||
</Text>
|
||||
<Divider style={[a.flex_1]} />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.w_full,
|
||||
a.mt_4xl,
|
||||
{
|
||||
height: 44,
|
||||
gap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 10,
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
]}>
|
||||
<ChainLinkIcon
|
||||
width={20}
|
||||
height={20}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
{color: t.palette.contrast_975, lineHeight: 19.5},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{cosmeticDisplayUrl || ' '}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Copy invite link`}
|
||||
accessibilityHint={l`Copies the canonical profile URL to the clipboard`}
|
||||
onPress={onCopy}
|
||||
hitSlop={8}
|
||||
style={({pressed}) => [{opacity: pressed ? 0.7 : 1}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.primary_500, lineHeight: 19.5},
|
||||
]}>
|
||||
{l`Copy`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -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<NavigationProp>()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [error, setError] = useState<string | null>(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 <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={() => 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 />
|
||||
|
||||
<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>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Pick from gallery`}
|
||||
accessibilityHint={l`Choose a QR code image from your photo library`}
|
||||
onPress={onPressGallery}
|
||||
hitSlop={12}
|
||||
style={({pressed}) => [
|
||||
{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
},
|
||||
]}>
|
||||
<ImageIcon width={16} height={16} fill={t.palette.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Error overlay shown when scanned QR isn't a valid profile URL */}
|
||||
{error && (
|
||||
<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 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 */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: '50%',
|
||||
marginBottom: SCAN_FRAME_SIZE / 2,
|
||||
backgroundColor: SCRIM_BG,
|
||||
}}
|
||||
/>
|
||||
{/* bottom */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
bottom: 0,
|
||||
marginTop: SCAN_FRAME_SIZE / 2,
|
||||
backgroundColor: SCRIM_BG,
|
||||
}}
|
||||
/>
|
||||
{/* left */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
width: '50%',
|
||||
height: SCAN_FRAME_SIZE,
|
||||
marginTop: -SCAN_FRAME_SIZE / 2,
|
||||
marginRight: SCAN_FRAME_SIZE / 2,
|
||||
backgroundColor: SCRIM_BG,
|
||||
transform: [{translateX: -SCAN_FRAME_SIZE / 2}],
|
||||
}}
|
||||
/>
|
||||
{/* right */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: 0,
|
||||
width: '50%',
|
||||
height: SCAN_FRAME_SIZE,
|
||||
marginTop: -SCAN_FRAME_SIZE / 2,
|
||||
marginLeft: SCAN_FRAME_SIZE / 2,
|
||||
backgroundColor: SCRIM_BG,
|
||||
transform: [{translateX: SCAN_FRAME_SIZE / 2}],
|
||||
}}
|
||||
/>
|
||||
{/* 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: 16,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 scanner'})}
|
||||
color="primary_subtle"
|
||||
icon={CameraIcon}
|
||||
onPress={onScan}
|
||||
style={[a.flex_1]}>
|
||||
{l`Scan`}
|
||||
</StackedButton>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={[a.px_lg, a.pt_md]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Invite friends`}
|
||||
accessibilityHint={l`Opens the invite friends sheet to share your profile`}
|
||||
onPress={onPress}
|
||||
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,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={{
|
||||
width: 126,
|
||||
height: 64,
|
||||
marginRight: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Dismiss`}
|
||||
accessibilityHint={l`Hides the invite friends promo banner`}
|
||||
onPress={onDismiss}
|
||||
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>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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<View>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [anchor, setAnchor] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
} | null>(null)
|
||||
|
||||
const labels: Record<InviteThemeKey, string> = {
|
||||
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 (
|
||||
<>
|
||||
<Pressable
|
||||
ref={triggerRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Pick a color theme`}
|
||||
accessibilityHint={l`Opens a list of color themes for the QR card`}
|
||||
onPress={handleOpen}
|
||||
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>
|
||||
|
||||
<Modal
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setOpen(false)}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Close color picker`}
|
||||
accessibilityHint={l`Dismisses the color picker`}
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={() => setOpen(false)}>
|
||||
{anchor ? (
|
||||
<View
|
||||
accessibilityRole="menu"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: anchor.y + anchor.h + MENU_GAP_TO_TRIGGER,
|
||||
left: anchor.x + anchor.w / 2 - MENU_WIDTH / 2,
|
||||
width: MENU_WIDTH,
|
||||
backgroundColor: t.palette.white,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 8,
|
||||
shadowColor: t.palette.black,
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: {width: 0, height: 8},
|
||||
elevation: 8,
|
||||
}}>
|
||||
{INVITE_THEME_KEYS.map(key => {
|
||||
const theme = INVITE_THEMES[key]
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
accessibilityRole="menuitem"
|
||||
accessibilityLabel={labels[key]}
|
||||
accessibilityHint={l`Apply this color theme to the QR card`}
|
||||
onPress={() => handleSelect(key)}
|
||||
style={({pressed}) => ({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: MENU_ROW_HEIGHT,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: pressed
|
||||
? t.palette.contrast_50
|
||||
: 'transparent',
|
||||
})}>
|
||||
<GradientSwatch
|
||||
from={theme.light.gradientFrom}
|
||||
to={theme.light.gradientTo}
|
||||
size={MENU_ITEM_SWATCH}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_medium,
|
||||
{color: t.palette.contrast_975, lineHeight: 19.5},
|
||||
]}>
|
||||
{labels[key]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,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<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_RADIUS,
|
||||
backgroundColor: t.palette.white,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: AVATAR_IMAGE,
|
||||
height: AVATAR_IMAGE,
|
||||
borderRadius: AVATAR_RADIUS,
|
||||
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_RADIUS - AVATAR_BORDER,
|
||||
}}
|
||||
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
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Web no-op - the invite-friends feature is native-only by design.
|
||||
*/
|
||||
export function ThemedQrCard() {
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {FollowersPromoBanner} from './components/FollowersPromoBanner'
|
||||
export {useFollowersPromoDismissed} from './hooks/useFollowersPromoDismissed'
|
||||
export {InviteFriendsDialog} from './InviteFriendsDialog'
|
||||
export {InviteScannerScreen} from './InviteScannerScreen'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<InviteThemeKey, InviteTheme> = {
|
||||
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
|
||||
}
|
||||
@@ -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/<short> 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user