273 lines
8.9 KiB
TypeScript
273 lines
8.9 KiB
TypeScript
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 {requestPermissionsAsync, saveToLibraryAsync} 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 {Button, ButtonText} from '#/components/Button'
|
||
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 {useAnalytics} from '#/analytics'
|
||
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 ax = useAnalytics()
|
||
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 displayUrl = isHandleValid ? getInviteDisplayUrl(handle) : ''
|
||
|
||
const onShare = async () => {
|
||
if (!canonicalShareUrl) {
|
||
Toast.show(l`Could not share – please try again`, {type: 'error'})
|
||
return
|
||
}
|
||
ax.metric('invite:action:share', {})
|
||
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
|
||
}
|
||
|
||
// Write-only permission - saving the QR image does not require read access
|
||
// to the user's photo library.
|
||
const permission = await requestPermissionsAsync(true)
|
||
if (!permission.granted) {
|
||
Toast.show(
|
||
l`You must grant access to your photo library to save a QR code`,
|
||
{type: 'error'},
|
||
)
|
||
return
|
||
}
|
||
|
||
try {
|
||
// saveToLibraryAsync writes without reading the asset back, so it works
|
||
// with the add-only permission. createAssetAsync fetches the created
|
||
// asset, which triggers the full library read prompt on iOS (APP-2374).
|
||
await saveToLibraryAsync(`file://${uri}`)
|
||
ax.metric('invite:action:download', {})
|
||
Toast.show(l`QR code saved to your camera roll!`)
|
||
} catch (err) {
|
||
logger.error('InviteFriendsDialog: download failed', {safeMessage: err})
|
||
Toast.show(l`An error occurred while saving the QR code!`, {
|
||
type: 'error',
|
||
})
|
||
}
|
||
}
|
||
|
||
const onScan = () => {
|
||
ax.metric('invite:action:scan', {})
|
||
// 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)
|
||
ax.metric('invite:action:copy', {})
|
||
Toast.show(l`Invite link copied`)
|
||
} catch (err) {
|
||
logger.error('InviteFriendsDialog: copy failed', {safeMessage: err})
|
||
Toast.show(l`Failed to copy link`, {type: 'error'})
|
||
}
|
||
}
|
||
|
||
const onThemeChange = (next: InviteThemeKey) => {
|
||
ax.metric('invite:theme:change', {themeKey: next})
|
||
setThemeKey(next)
|
||
}
|
||
|
||
return (
|
||
<Dialog.ScrollableInner
|
||
label={l`Invite friends`}
|
||
contentContainerStyle={[a.pt_0, a.px_0]}
|
||
header={
|
||
<Dialog.Header
|
||
renderLeft={() => (
|
||
<Button
|
||
label={l`Done`}
|
||
onPress={() => control.close()}
|
||
size="small"
|
||
color="primary"
|
||
variant="ghost"
|
||
style={[a.rounded_full]}>
|
||
<ButtonText style={[a.text_md]}>{l`Done`}</ButtonText>
|
||
</Button>
|
||
)}>
|
||
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText>
|
||
</Dialog.Header>
|
||
}>
|
||
<View style={[a.align_center, a.pt_xl, a.px_xl]}>
|
||
<ThemePicker value={themeKey} onChange={onThemeChange} />
|
||
|
||
<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={() => void onShare()}
|
||
onDownload={() => void 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({
|
||
message: 'Invite link',
|
||
context: 'profile invite',
|
||
comment:
|
||
'Link that invites someone to follow your profile, distinct from a group chat 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}>
|
||
{displayUrl || ' '}
|
||
</Text>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={l`Copy invite link`}
|
||
accessibilityHint={l`Copies the canonical profile URL to the clipboard`}
|
||
onPress={() => void 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>
|
||
)
|
||
}
|