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>
(cherry picked from commit 625b4e61db)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -1063,6 +1063,7 @@ export const atoms = {
|
|||||||
}),
|
}),
|
||||||
fade_out: web({
|
fade_out: web({
|
||||||
animation: 'fadeOut ease-out 0.15s',
|
animation: 'fadeOut ease-out 0.15s',
|
||||||
|
animationFillMode: 'forwards',
|
||||||
}),
|
}),
|
||||||
zoom_in: web({
|
zoom_in: web({
|
||||||
animation: 'zoomIn ease-out 0.1s',
|
animation: 'zoomIn ease-out 0.1s',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function useWelcomeModal() {
|
||||||
|
throw new Error('useWelcomeModal is web only')
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import {useMediaQuery} from 'react-responsive'
|
|||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `useBreakpoints` from `#/alf` instead
|
||||||
|
*/
|
||||||
export function useWebMediaQueries() {
|
export function useWebMediaQueries() {
|
||||||
const isDesktop = useMediaQuery({minWidth: 1300})
|
const isDesktop = useMediaQuery({minWidth: 1300})
|
||||||
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
|
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export type Gate =
|
|||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
| 'test_gate_1'
|
| 'test_gate_1'
|
||||||
| 'test_gate_2'
|
| 'test_gate_2'
|
||||||
|
| 'welcome_modal'
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export type MetricEvents = {
|
|||||||
// Screen events
|
// Screen events
|
||||||
'splash:signInPressed': {}
|
'splash:signInPressed': {}
|
||||||
'splash:createAccountPressed': {}
|
'splash:createAccountPressed': {}
|
||||||
|
'welcomeModal:signupClicked': {}
|
||||||
|
'welcomeModal:exploreClicked': {}
|
||||||
|
'welcomeModal:signinClicked': {}
|
||||||
|
'welcomeModal:dismissed': {}
|
||||||
|
'welcomeModal:presented': {}
|
||||||
'signup:nextPressed': {
|
'signup:nextPressed': {
|
||||||
activeStep: number
|
activeStep: number
|
||||||
phoneVerificationRequired?: boolean
|
phoneVerificationRequired?: boolean
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
|||||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocation} from '#/state/geolocation'
|
||||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||||
@@ -22,11 +23,13 @@ import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
|||||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
import {SigninDialog} from '#/components/dialogs/Signin'
|
import {SigninDialog} from '#/components/dialogs/Signin'
|
||||||
|
import {useWelcomeModal} from '#/components/hooks/useWelcomeModal'
|
||||||
import {
|
import {
|
||||||
Outlet as PolicyUpdateOverlayPortalOutlet,
|
Outlet as PolicyUpdateOverlayPortalOutlet,
|
||||||
usePolicyUpdateContext,
|
usePolicyUpdateContext,
|
||||||
} from '#/components/PolicyUpdateOverlay'
|
} from '#/components/PolicyUpdateOverlay'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {WelcomeModal} from '#/components/WelcomeModal'
|
||||||
import {FlatNavigator, RoutesContainer} from '#/Navigation'
|
import {FlatNavigator, RoutesContainer} from '#/Navigation'
|
||||||
import {Composer} from './Composer.web'
|
import {Composer} from './Composer.web'
|
||||||
import {DrawerContent} from './Drawer'
|
import {DrawerContent} from './Drawer'
|
||||||
@@ -42,6 +45,8 @@ function ShellInner() {
|
|||||||
const showDrawer = !isDesktop && isDrawerOpen
|
const showDrawer = !isDesktop && isDrawerOpen
|
||||||
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
|
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
|
||||||
const {state: policyUpdateState} = usePolicyUpdateContext()
|
const {state: policyUpdateState} = usePolicyUpdateContext()
|
||||||
|
const welcomeModalControl = useWelcomeModal()
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (showDrawer !== showDrawerDelayedExit) {
|
if (showDrawer !== showDrawerDelayedExit) {
|
||||||
@@ -80,6 +85,11 @@ function ShellInner() {
|
|||||||
<LinkWarningDialog />
|
<LinkWarningDialog />
|
||||||
<Lightbox />
|
<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 */}
|
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
|
||||||
{policyUpdateState.completed && (
|
{policyUpdateState.completed && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user