import React from 'react' import {View} from 'react-native' import Animated, {FadeIn} from 'react-native-reanimated' import ViewShot from 'react-native-view-shot' import {Image} from 'expo-image' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' import * as MediaLibrary from 'expo-media-library' import {moderateProfile} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {networkRetry} from '#/lib/async/retry' import {getCanvas} from '#/lib/canvas' import {shareUrl} from '#/lib/sharing' import {logEvent} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {isIOS, isNative} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileQuery} from '#/state/queries/profile' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from 'state/shell' import {formatCount} from '#/view/com/util/numeric/format' import {Logomark} from '#/view/icons/Logomark' import * as Toast from 'view/com/util/Toast' import { atoms as a, ThemeProvider, tokens, useBreakpoints, useTheme, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useNuxDialogContext} from '#/components/dialogs/nuxs' import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent' import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent' import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent' import {Divider} from '#/components/Divider' import {GradientFill} from '#/components/GradientFill' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' const DEBUG = false const RATIO = 8 / 10 const WIDTH = 2000 const HEIGHT = WIDTH * RATIO function getFontSize(count: number) { const length = count.toString().length if (length < 7) { return 80 } else if (length < 5) { return 100 } else { return 70 } } function getPercentBadge(percent: number) { if (percent <= 0.001) { return PointOnePercent } else if (percent <= 0.01) { return OnePercent } else if (percent <= 0.1) { return TenPercent } return null } function Frame({children}: {children: React.ReactNode}) { return ( {children} ) } export function TenMillion() { const agent = useAgent() const nuxDialogs = useNuxDialogContext() const [userNumber, setUserNumber] = React.useState(0) const fetching = React.useRef(false) React.useEffect(() => { async function fetchUserNumber() { const isBlueskyHosted = agent.sessionManager.pdsUrl ?.toString() .includes('bsky.network') if (isBlueskyHosted && agent.session?.accessJwt) { const res = await fetch( `https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`, { headers: { Authorization: `Bearer ${agent.session.accessJwt}`, }, }, ) if (!res.ok) { throw new Error('Network request failed') } const data = await res.json() if (data.number && data.number <= 10_000_000) { setUserNumber(data.number) } else { // should be rare nuxDialogs.dismissActiveNux() } } } if (!fetching.current) { fetching.current = true networkRetry(3, fetchUserNumber).catch(() => { nuxDialogs.dismissActiveNux() }) } }, [ agent.sessionManager.pdsUrl, agent.session?.accessJwt, setUserNumber, nuxDialogs.dismissActiveNux, nuxDialogs, ]) return userNumber ? : null } export function TenMillionInner({userNumber}: {userNumber: number}) { const t = useTheme() const lightTheme = useTheme('light') const {_, i18n} = useLingui() const control = Dialog.useDialogControl() const {gtMobile} = useBreakpoints() const {openComposer} = useComposerControls() const {currentAccount} = useSession() const { isLoading: isProfileLoading, data: profile, error: profileError, } = useProfileQuery({ did: currentAccount!.did, }) const moderationOpts = useModerationOpts() const nuxDialogs = useNuxDialogContext() const moderation = React.useMemo(() => { return profile && moderationOpts ? moderateProfile(profile, moderationOpts) : undefined }, [profile, moderationOpts]) const [uri, setUri] = React.useState(null) const percent = userNumber / 10_000_000 const Badge = getPercentBadge(percent) const isLoadingData = isProfileLoading || !moderation || !profile const isLoadingImage = !uri const error: string = React.useMemo(() => { if (profileError) { return _( msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`, ) } return '' }, [_, profileError]) /* * Opening and closing */ React.useEffect(() => { const timeout = setTimeout(() => { control.open() }, 3e3) return () => { clearTimeout(timeout) } }, [control]) const onClose = React.useCallback(() => { nuxDialogs.dismissActiveNux() }, [nuxDialogs]) /* * Actions */ const sharePost = React.useCallback(() => { if (uri) { control.close(() => { setTimeout(() => { logEvent('tmd:post', {}) openComposer({ text: _( msg`Bluesky now has over 10 million users, and I was #${i18n.number( userNumber, )}!`, ), // TODO imageUris: [ { uri, width: WIDTH, height: HEIGHT, }, ], }) }, 1e3) }) } }, [_, i18n, control, openComposer, uri, userNumber]) const onNativeShare = React.useCallback(() => { if (uri) { control.close(() => { logEvent('tmd:share', {}) shareUrl(uri) }) } }, [uri, control]) const onNativeDownload = React.useCallback(async () => { if (uri) { const res = await requestMediaLibraryPermissionsAsync() if (!res) { Toast.show( _( msg`You must grant access to your photo library to save the image.`, ), 'xmark', ) return } try { await MediaLibrary.createAssetAsync(uri) logEvent('tmd:download', {}) Toast.show(_(msg`Image saved to your camera roll!`)) } catch (e: unknown) { console.log(e) Toast.show(_(msg`An error occurred while saving the image!`), 'xmark') return } } }, [_, uri]) const onWebDownload = React.useCallback(async () => { if (uri) { const canvas = await getCanvas(uri) const imgHref = canvas .toDataURL('image/png') .replace('image/png', 'image/octet-stream') const link = document.createElement('a') link.setAttribute('download', `Bluesky 10M Users.png`) link.setAttribute('href', imgHref) link.click() logEvent('tmd:download', {}) } }, [uri]) /* * Canvas stuff */ const imageRef = React.useRef(null) const captureInProgress = React.useRef(false) const onCanvasReady = React.useCallback(async () => { if ( imageRef.current && imageRef.current.capture && !captureInProgress.current ) { captureInProgress.current = true const uri = await imageRef.current.capture() setUri(uri) } }, [setUri]) const canvas = isLoadingData ? null : ( {/* Centered content */} Celebrating {formatCount(i18n, 10000000)} users {' '} 🎉 # {i18n.number(userNumber)} {Badge && ( )} {/* End centered content */} {/* */} {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), )} {sanitizeHandle(profile.handle, '@')} {profile.createdAt && ( Joined{' '} {i18n.date(profile.createdAt, { month: 'short', day: 'numeric', year: 'numeric', })} )} ) return ( {error ? ( (╯°□°)╯︵ ┻━┻ {error} ) : isLoadingData || isLoadingImage ? ( ) : ( )} {canvas} Thanks for being one of our first 10 million users. Together, we're rebuilding the social internet. We're glad you're here. Brag a little! ) }