diff options
Diffstat (limited to 'src/components/dialogs/nuxs/TenMillion/index.tsx')
-rw-r--r-- | src/components/dialogs/nuxs/TenMillion/index.tsx | 650 |
1 files changed, 650 insertions, 0 deletions
diff --git a/src/components/dialogs/nuxs/TenMillion/index.tsx b/src/components/dialogs/nuxs/TenMillion/index.tsx new file mode 100644 index 000000000..801ceb99a --- /dev/null +++ b/src/components/dialogs/nuxs/TenMillion/index.tsx @@ -0,0 +1,650 @@ +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 {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 ( + <View + style={[ + a.relative, + a.w_full, + a.overflow_hidden, + { + paddingTop: '80%', + }, + ]}> + {children} + </View> + ) +} + +export function TenMillion() { + const agent = useAgent() + const nuxDialogs = useNuxDialogContext() + const [userNumber, setUserNumber] = React.useState<number>(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) { + 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 ? <TenMillionInner userNumber={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<string | null>(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(() => { + 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(() => { + 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) + 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() + } + }, [uri]) + + /* + * Canvas stuff + */ + const imageRef = React.useRef<ViewShot>(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 : ( + <View + style={[ + a.absolute, + a.overflow_hidden, + DEBUG + ? { + width: 600, + height: 600 * RATIO, + } + : { + width: 1, + height: 1, + }, + ]}> + <View style={{width: 600}}> + <ThemeProvider theme="light"> + <Frame> + <ViewShot + ref={imageRef} + options={{width: WIDTH, height: HEIGHT}} + style={[a.absolute, a.inset_0]}> + <View + onLayout={onCanvasReady} + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + { + top: -1, + bottom: -1, + left: -1, + right: -1, + paddingVertical: 48, + paddingHorizontal: 48, + }, + ]}> + <GradientFill gradient={tokens.gradients.bonfire} /> + + <View + style={[ + a.flex_1, + a.w_full, + a.align_center, + a.justify_center, + a.rounded_md, + { + backgroundColor: 'white', + shadowRadius: 32, + shadowOpacity: 0.1, + elevation: 24, + shadowColor: tokens.gradients.bonfire.values[0][1], + }, + ]}> + <View + style={[ + a.absolute, + a.px_xl, + a.py_xl, + { + top: 0, + left: 0, + }, + ]}> + <Logomark fill={t.palette.primary_500} width={36} /> + </View> + + {/* Centered content */} + <View + style={[ + { + paddingBottom: isNative ? 0 : 24, + }, + ]}> + <Text + style={[ + a.text_md, + a.font_bold, + a.text_center, + a.pb_sm, + lightTheme.atoms.text_contrast_medium, + ]}> + <Trans> + Celebrating {formatCount(i18n, 10000000)} users + </Trans>{' '} + 🎉 + </Text> + <View style={[a.flex_row, a.align_start]}> + <Text + style={[ + a.absolute, + { + color: t.palette.primary_500, + fontSize: 32, + fontWeight: '900', + width: 32, + top: isNative ? -10 : 0, + left: 0, + transform: [ + { + translateX: -16, + }, + ], + }, + ]}> + # + </Text> + <Text + style={[ + a.relative, + a.text_center, + { + fontStyle: 'italic', + fontSize: getFontSize(userNumber), + lineHeight: getFontSize(userNumber), + fontWeight: '900', + letterSpacing: -2, + }, + ]}> + {i18n.number(userNumber)} + </Text> + </View> + + {Badge && ( + <View + style={[ + a.absolute, + { + width: 64, + height: 64, + top: isNative ? 75 : 85, + right: '5%', + transform: [ + { + rotate: '8deg', + }, + ], + }, + ]}> + <Badge fill={t.palette.primary_500} /> + </View> + )} + </View> + {/* End centered content */} + + <View + style={[ + a.absolute, + a.px_xl, + a.py_xl, + { + bottom: 0, + left: 0, + right: 0, + }, + ]}> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + {/* + <UserAvatar + size={36} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + onLoad={onCanvasReady} + /> + */} + <View style={[a.gap_2xs, a.flex_1]}> + <Text + style={[ + a.flex_1, + a.text_sm, + a.font_bold, + a.leading_tight, + {maxWidth: '60%'}, + ]}> + {sanitizeDisplayName( + profile.displayName || + sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + <View + style={[a.flex_row, a.justify_between, a.gap_4xl]}> + <Text + numberOfLines={1} + style={[ + a.flex_1, + a.text_sm, + a.font_semibold, + a.leading_snug, + lightTheme.atoms.text_contrast_medium, + ]}> + {sanitizeHandle(profile.handle, '@')} + </Text> + + {profile.createdAt && ( + <Text + numberOfLines={1} + ellipsizeMode="head" + style={[ + a.flex_1, + a.text_sm, + a.font_semibold, + a.leading_snug, + a.text_right, + lightTheme.atoms.text_contrast_low, + ]}> + <Trans> + Joined{' '} + {i18n.date(profile.createdAt, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + </Trans> + </Text> + )} + </View> + </View> + </View> + </View> + </View> + </View> + </ViewShot> + </Frame> + </ThemeProvider> + </View> + </View> + ) + + return ( + <Dialog.Outer control={control} onClose={onClose}> + <Dialog.ScrollableInner + label={_(msg`Ten Million`)} + style={[ + { + padding: 0, + paddingTop: 0, + }, + ]}> + <View + style={[ + a.rounded_md, + a.overflow_hidden, + isNative && { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }, + ]}> + <Frame> + <View + style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}> + <GradientFill gradient={tokens.gradients.bonfire} /> + {error ? ( + <View> + <Text + style={[ + a.text_md, + a.leading_snug, + a.text_center, + a.pb_md, + { + maxWidth: 300, + }, + ]}> + (╯°□°)╯︵ ┻━┻ + </Text> + <Text + style={[ + a.text_xl, + a.font_bold, + a.leading_snug, + a.text_center, + { + maxWidth: 300, + }, + ]}> + {error} + </Text> + </View> + ) : isLoadingData || isLoadingImage ? ( + <Loader size="xl" fill="white" /> + ) : ( + <Animated.View + entering={FadeIn.duration(150)} + style={[a.w_full, a.h_full]}> + <Image + accessibilityIgnoresInvertColors + source={{uri}} + style={[a.w_full, a.h_full]} + /> + </Animated.View> + )} + </View> + </Frame> + + {canvas} + + <View style={[gtMobile ? a.p_2xl : a.p_xl]}> + <Text + style={[ + a.text_5xl, + a.leading_tight, + a.pb_lg, + { + fontWeight: '900', + }, + ]}> + <Trans>Thanks for being one of our first 10 million users.</Trans> + </Text> + + <Text style={[a.leading_snug, a.text_lg, a.pb_xl]}> + <Trans> + Together, we're rebuilding the social internet. We're glad + you're here. + </Trans> + </Text> + + <Divider /> + + <View + style={[ + a.flex_row, + a.align_center, + a.justify_end, + a.gap_md, + a.pt_xl, + ]}> + <Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}> + <Trans>Brag a little!</Trans> + </Text> + + <Button + disabled={isLoadingImage} + label={ + isNative && isIOS + ? _(msg`Share image externally`) + : _(msg`Download image`) + } + size="large" + variant="solid" + color="secondary" + shape="square" + onPress={ + isNative + ? isIOS + ? onNativeShare + : onNativeDownload + : onWebDownload + }> + <ButtonIcon icon={isNative && isIOS ? Share : Download} /> + </Button> + <Button + disabled={isLoadingImage} + label={_(msg`Share image in post`)} + size="large" + variant="solid" + color="primary" + onPress={sharePost}> + <ButtonText>{_(msg`Share`)}</ButtonText> + <ButtonIcon position="right" icon={ImageIcon} /> + </Button> + </View> + </View> + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} |