From 625b4e61dbf11c1d485bf8e8265df4d5af0c9657 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Thu, 4 Sep 2025 07:20:46 -0700 Subject: 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 --- src/alf/atoms.ts | 1 + src/components/WelcomeModal.tsx | 247 +++++++++++++++++++++++++ src/components/hooks/useWelcomeModal.native.ts | 3 + src/components/hooks/useWelcomeModal.ts | 43 +++++ src/lib/hooks/useWebMediaQueries.tsx | 3 + src/lib/statsig/gates.ts | 1 + src/logger/metrics.ts | 5 + src/view/shell/index.web.tsx | 10 + 8 files changed, 313 insertions(+) create mode 100644 src/components/WelcomeModal.tsx create mode 100644 src/components/hooks/useWelcomeModal.native.ts create mode 100644 src/components/hooks/useWelcomeModal.ts (limited to 'src') diff --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 ( + + + + + + + + + + Bluesky + + + + + + Real people. + {'\n'} + Real conversations. + {'\n'} + Social media you control. + + + + + + + + + + Already have an account?{' '} + setSignInLinkHovered(true)} + onPointerLeave={() => setSignInLinkHovered(false)} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in`)} + accessibilityHint=""> + + Sign in + + + + + + + + + + + + ) +} 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() { + {/* Show welcome modal if the gate is enabled */} + {welcomeModalControl.isOpen && gate('welcome_modal') && ( + + )} + {/* Until policy update has been completed by the user, don't render anything that is portaled */} {policyUpdateState.completed && ( <> -- cgit 1.4.1