diff options
author | Alex Benzer <abenzer@users.noreply.github.com> | 2025-09-04 07:20:46 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-04 07:20:46 -0700 |
commit | 625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (patch) | |
tree | 8d36b6564cb2b679269dea4b1fc3a8d3887d2fd2 | |
parent | 0b02d9d9a7ca33dab256a35e4fc9b8feabe20d34 (diff) | |
download | voidsky-625b4e61dbf11c1d485bf8e8265df4d5af0c9657.tar.zst |
Welcome modal on logged-out homepage (#8944)
* Adds welcome modal to logged-out homepage * Adds metrics and feature gate for welcome modal * Slightly smaller text for mobile screens to avoid wrapping * Remove unused SVG * Adds text gradient and "X" close button * Fix color on "Already have an account?" text * tweak hooks, react import * rm stylesheet * use hardcoded colors * add focus guards and scope * no such thing as /home * reduce spacign * use css animations * use session storage * fix animation fill mode * add a11y props * Fix link/button color mismatch, reduce gap between buttons, show modal until user dismisses it * Fix "Already have an account?" line left-aligning in small window sizes * Adds "dismissed" and "presented" metric events --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r-- | assets/images/welcome-modal-bg.jpg | bin | 0 -> 116471 bytes | |||
-rw-r--r-- | src/alf/atoms.ts | 1 | ||||
-rw-r--r-- | src/components/WelcomeModal.tsx | 247 | ||||
-rw-r--r-- | src/components/hooks/useWelcomeModal.native.ts | 3 | ||||
-rw-r--r-- | src/components/hooks/useWelcomeModal.ts | 43 | ||||
-rw-r--r-- | src/lib/hooks/useWebMediaQueries.tsx | 3 | ||||
-rw-r--r-- | src/lib/statsig/gates.ts | 1 | ||||
-rw-r--r-- | src/logger/metrics.ts | 5 | ||||
-rw-r--r-- | src/view/shell/index.web.tsx | 10 |
9 files changed, 313 insertions, 0 deletions
diff --git a/assets/images/welcome-modal-bg.jpg b/assets/images/welcome-modal-bg.jpg new file mode 100644 index 000000000..99b6c9d64 --- /dev/null +++ b/assets/images/welcome-modal-bg.jpg Binary files differdiff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index ae88f457d..dc5d9f59c 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1063,6 +1063,7 @@ export const atoms = { }), fade_out: web({ animation: 'fadeOut ease-out 0.15s', + animationFillMode: 'forwards', }), zoom_in: web({ animation: 'zoomIn ease-out 0.1s', diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx new file mode 100644 index 000000000..7c9ecd84d --- /dev/null +++ b/src/components/WelcomeModal.tsx @@ -0,0 +1,247 @@ +import {useEffect, useState} from 'react' +import {Pressable, View} from 'react-native' +import {ImageBackground} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FocusGuards, FocusScope} from 'radix-ui/internal' + +import {logger} from '#/logger' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {Logo} from '#/view/icons/Logo' +import {atoms as a, flatten, useBreakpoints, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') + +interface WelcomeModalProps { + control: { + isOpen: boolean + open: () => void + close: () => void + } +} + +export function WelcomeModal({control}: WelcomeModalProps) { + const {_} = useLingui() + const {requestSwitchToAccount} = useLoggedOutViewControls() + const {gtMobile} = useBreakpoints() + const [isExiting, setIsExiting] = useState(false) + const [signInLinkHovered, setSignInLinkHovered] = useState(false) + + const fadeOutAndClose = (callback?: () => void) => { + setIsExiting(true) + setTimeout(() => { + control.close() + if (callback) callback() + }, 150) + } + + useEffect(() => { + if (control.isOpen) { + logger.metric('welcomeModal:presented', {}) + } + }, [control.isOpen]) + + const onPressCreateAccount = () => { + logger.metric('welcomeModal:signupClicked', {}) + control.close() + requestSwitchToAccount({requestedAccount: 'new'}) + } + + const onPressExplore = () => { + logger.metric('welcomeModal:exploreClicked', {}) + fadeOutAndClose() + } + + const onPressSignIn = () => { + logger.metric('welcomeModal:signinClicked', {}) + control.close() + requestSwitchToAccount({requestedAccount: 'existing'}) + } + + FocusGuards.useFocusGuards() + + return ( + <View + role="dialog" + aria-modal + style={[ + a.fixed, + a.inset_0, + a.justify_center, + a.align_center, + {zIndex: 9999, backgroundColor: 'rgba(0,0,0,0.2)'}, + web({backdropFilter: 'blur(15px)'}), + isExiting ? a.fade_out : a.fade_in, + ]}> + <FocusScope.FocusScope asChild loop trapped> + <View + style={flatten([ + { + maxWidth: 800, + maxHeight: 600, + width: '90%', + height: '90%', + backgroundColor: '#C0DCF0', + }, + a.rounded_lg, + a.overflow_hidden, + a.zoom_in, + ])}> + <ImageBackground + source={welcomeModalBg} + style={[a.flex_1, a.justify_center]} + contentFit="cover"> + <View style={[a.gap_2xl, a.align_center, a.p_4xl]}> + <View + style={[ + a.flex_row, + a.align_center, + a.justify_center, + a.w_full, + a.p_0, + ]}> + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <Logo width={26} /> + <Text + style={[ + a.text_2xl, + a.font_bold, + a.user_select_none, + {color: '#354358', letterSpacing: -0.5}, + ]}> + Bluesky + </Text> + </View> + </View> + <View + style={[ + a.gap_sm, + a.align_center, + a.pt_5xl, + a.pb_3xl, + a.mt_2xl, + ]}> + <Text + style={[ + gtMobile ? a.text_4xl : a.text_3xl, + a.font_bold, + a.text_center, + {color: '#354358'}, + web({ + backgroundImage: + 'linear-gradient(180deg, #313F54 0%, #667B99 83.65%, rgba(102, 123, 153, 0.50) 100%)', + backgroundClip: 'text', + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + color: 'transparent', + lineHeight: 1.2, + letterSpacing: -0.5, + }), + ]}> + <Trans>Real people.</Trans> + {'\n'} + <Trans>Real conversations.</Trans> + {'\n'} + <Trans>Social media you control.</Trans> + </Text> + </View> + <View style={[a.gap_md, a.align_center]}> + <View> + <Button + onPress={onPressCreateAccount} + label={_(msg`Create account`)} + size="large" + color="primary" + style={{ + width: 200, + backgroundColor: '#006AFF', + }}> + <ButtonText> + <Trans>Create account</Trans> + </ButtonText> + </Button> + <Button + onPress={onPressExplore} + label={_(msg`Explore the app`)} + size="large" + color="primary" + variant="ghost" + style={[a.bg_transparent, {width: 200}]} + hoverStyle={[a.bg_transparent]}> + {({hovered}) => ( + <ButtonText + style={[hovered && [a.underline], {color: '#006AFF'}]}> + <Trans>Explore the app</Trans> + </ButtonText> + )} + </Button> + </View> + <View style={[a.align_center, {minWidth: 200}]}> + <Text + style={[ + a.text_md, + a.text_center, + {color: '#405168', lineHeight: 24}, + ]}> + <Trans>Already have an account?</Trans>{' '} + <Pressable + onPointerEnter={() => setSignInLinkHovered(true)} + onPointerLeave={() => setSignInLinkHovered(false)} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in`)} + accessibilityHint=""> + <Text + style={[ + a.font_medium, + { + color: '#006AFF', + fontSize: undefined, + }, + signInLinkHovered && a.underline, + ]} + onPress={onPressSignIn}> + <Trans>Sign in</Trans> + </Text> + </Pressable> + </Text> + </View> + </View> + </View> + <Button + label={_(msg`Close welcome modal`)} + style={[ + a.absolute, + { + top: 8, + right: 8, + }, + a.bg_transparent, + ]} + hoverStyle={[a.bg_transparent]} + onPress={() => { + logger.metric('welcomeModal:dismissed', {}) + fadeOutAndClose() + }} + color="secondary" + size="small" + variant="ghost" + shape="round"> + {({hovered, pressed, focused}) => ( + <XIcon + size="md" + style={{ + color: '#354358', + opacity: hovered || pressed || focused ? 1 : 0.7, + }} + /> + )} + </Button> + </ImageBackground> + </View> + </FocusScope.FocusScope> + </View> + ) +} diff --git a/src/components/hooks/useWelcomeModal.native.ts b/src/components/hooks/useWelcomeModal.native.ts new file mode 100644 index 000000000..f5bc1aa4e --- /dev/null +++ b/src/components/hooks/useWelcomeModal.native.ts @@ -0,0 +1,3 @@ +export function useWelcomeModal() { + throw new Error('useWelcomeModal is web only') +} diff --git a/src/components/hooks/useWelcomeModal.ts b/src/components/hooks/useWelcomeModal.ts new file mode 100644 index 000000000..7183f361e --- /dev/null +++ b/src/components/hooks/useWelcomeModal.ts @@ -0,0 +1,43 @@ +import {useEffect, useState} from 'react' + +import {isWeb} from '#/platform/detection' +import {useSession} from '#/state/session' + +export function useWelcomeModal() { + const {hasSession} = useSession() + const [isOpen, setIsOpen] = useState(false) + + const open = () => setIsOpen(true) + const close = () => { + setIsOpen(false) + // Mark that user has actively closed the modal, don't show again this session + if (typeof window !== 'undefined') { + sessionStorage.setItem('welcomeModalClosed', 'true') + } + } + + useEffect(() => { + // Only show modal if: + // 1. User is not logged in + // 2. We're on the web (this is a web-only feature) + // 3. We're on the homepage (path is '/' or '/home') + // 4. User hasn't actively closed the modal in this session + if (isWeb && !hasSession && typeof window !== 'undefined') { + const currentPath = window.location.pathname + const isHomePage = currentPath === '/' + const hasUserClosedModal = + sessionStorage.getItem('welcomeModalClosed') === 'true' + + if (isHomePage && !hasUserClosedModal) { + // Small delay to ensure the page has loaded + const timer = setTimeout(() => { + open() + }, 1000) + + return () => clearTimeout(timer) + } + } + }, [hasSession]) + + return {isOpen, open, close} +} diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx index fa9d6ffa6..7778383a4 100644 --- a/src/lib/hooks/useWebMediaQueries.tsx +++ b/src/lib/hooks/useWebMediaQueries.tsx @@ -2,6 +2,9 @@ import {useMediaQuery} from 'react-responsive' import {isNative} from '#/platform/detection' +/** + * @deprecated use `useBreakpoints` from `#/alf` instead + */ export function useWebMediaQueries() { const isDesktop = useMediaQuery({minWidth: 1300}) const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1}) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 391314162..b8e2e9a3c 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -15,3 +15,4 @@ export type Gate = | 'remove_show_latest_button' | 'test_gate_1' | 'test_gate_2' + | 'welcome_modal' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 1cb4eb9d3..79d7702b3 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -48,6 +48,11 @@ export type MetricEvents = { // Screen events 'splash:signInPressed': {} 'splash:createAccountPressed': {} + 'welcomeModal:signupClicked': {} + 'welcomeModal:exploreClicked': {} + 'welcomeModal:signinClicked': {} + 'welcomeModal:dismissed': {} + 'welcomeModal:presented': {} 'signup:nextPressed': { activeStep: number phoneVerificationRequired?: boolean diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index f942ab49e..bb09b5f62 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -8,6 +8,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {useGeolocation} from '#/state/geolocation' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' @@ -22,11 +23,13 @@ import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' +import {useWelcomeModal} from '#/components/hooks/useWelcomeModal' import { Outlet as PolicyUpdateOverlayPortalOutlet, usePolicyUpdateContext, } from '#/components/PolicyUpdateOverlay' import {Outlet as PortalOutlet} from '#/components/Portal' +import {WelcomeModal} from '#/components/WelcomeModal' import {FlatNavigator, RoutesContainer} from '#/Navigation' import {Composer} from './Composer.web' import {DrawerContent} from './Drawer' @@ -42,6 +45,8 @@ function ShellInner() { const showDrawer = !isDesktop && isDrawerOpen const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer) const {state: policyUpdateState} = usePolicyUpdateContext() + const welcomeModalControl = useWelcomeModal() + const gate = useGate() useLayoutEffect(() => { if (showDrawer !== showDrawerDelayedExit) { @@ -80,6 +85,11 @@ function ShellInner() { <LinkWarningDialog /> <Lightbox /> + {/* Show welcome modal if the gate is enabled */} + {welcomeModalControl.isOpen && gate('welcome_modal') && ( + <WelcomeModal control={welcomeModalControl} /> + )} + {/* Until policy update has been completed by the user, don't render anything that is portaled */} {policyUpdateState.completed && ( <> |