deer social fork for personal usage. but you might see a use idk. github mirror

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>

authored by Alex Benzer Samuel Newman and committed by GitHub 625b4e61 0b02d9d9

Changed files
+313
assets
src
assets/images/welcome-modal-bg.jpg

This is a binary file and will not be displayed.

+1
src/alf/atoms.ts
··· 1063 1063 }), 1064 1064 fade_out: web({ 1065 1065 animation: 'fadeOut ease-out 0.15s', 1066 + animationFillMode: 'forwards', 1066 1067 }), 1067 1068 zoom_in: web({ 1068 1069 animation: 'zoomIn ease-out 0.1s',
+247
src/components/WelcomeModal.tsx
··· 1 + import {useEffect, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {ImageBackground} from 'expo-image' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 + 8 + import {logger} from '#/logger' 9 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 10 + import {Logo} from '#/view/icons/Logo' 11 + import {atoms as a, flatten, useBreakpoints, web} from '#/alf' 12 + import {Button, ButtonText} from '#/components/Button' 13 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 + import {Text} from '#/components/Typography' 15 + 16 + const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') 17 + 18 + interface WelcomeModalProps { 19 + control: { 20 + isOpen: boolean 21 + open: () => void 22 + close: () => void 23 + } 24 + } 25 + 26 + export function WelcomeModal({control}: WelcomeModalProps) { 27 + const {_} = useLingui() 28 + const {requestSwitchToAccount} = useLoggedOutViewControls() 29 + const {gtMobile} = useBreakpoints() 30 + const [isExiting, setIsExiting] = useState(false) 31 + const [signInLinkHovered, setSignInLinkHovered] = useState(false) 32 + 33 + const fadeOutAndClose = (callback?: () => void) => { 34 + setIsExiting(true) 35 + setTimeout(() => { 36 + control.close() 37 + if (callback) callback() 38 + }, 150) 39 + } 40 + 41 + useEffect(() => { 42 + if (control.isOpen) { 43 + logger.metric('welcomeModal:presented', {}) 44 + } 45 + }, [control.isOpen]) 46 + 47 + const onPressCreateAccount = () => { 48 + logger.metric('welcomeModal:signupClicked', {}) 49 + control.close() 50 + requestSwitchToAccount({requestedAccount: 'new'}) 51 + } 52 + 53 + const onPressExplore = () => { 54 + logger.metric('welcomeModal:exploreClicked', {}) 55 + fadeOutAndClose() 56 + } 57 + 58 + const onPressSignIn = () => { 59 + logger.metric('welcomeModal:signinClicked', {}) 60 + control.close() 61 + requestSwitchToAccount({requestedAccount: 'existing'}) 62 + } 63 + 64 + FocusGuards.useFocusGuards() 65 + 66 + return ( 67 + <View 68 + role="dialog" 69 + aria-modal 70 + style={[ 71 + a.fixed, 72 + a.inset_0, 73 + a.justify_center, 74 + a.align_center, 75 + {zIndex: 9999, backgroundColor: 'rgba(0,0,0,0.2)'}, 76 + web({backdropFilter: 'blur(15px)'}), 77 + isExiting ? a.fade_out : a.fade_in, 78 + ]}> 79 + <FocusScope.FocusScope asChild loop trapped> 80 + <View 81 + style={flatten([ 82 + { 83 + maxWidth: 800, 84 + maxHeight: 600, 85 + width: '90%', 86 + height: '90%', 87 + backgroundColor: '#C0DCF0', 88 + }, 89 + a.rounded_lg, 90 + a.overflow_hidden, 91 + a.zoom_in, 92 + ])}> 93 + <ImageBackground 94 + source={welcomeModalBg} 95 + style={[a.flex_1, a.justify_center]} 96 + contentFit="cover"> 97 + <View style={[a.gap_2xl, a.align_center, a.p_4xl]}> 98 + <View 99 + style={[ 100 + a.flex_row, 101 + a.align_center, 102 + a.justify_center, 103 + a.w_full, 104 + a.p_0, 105 + ]}> 106 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 107 + <Logo width={26} /> 108 + <Text 109 + style={[ 110 + a.text_2xl, 111 + a.font_bold, 112 + a.user_select_none, 113 + {color: '#354358', letterSpacing: -0.5}, 114 + ]}> 115 + Bluesky 116 + </Text> 117 + </View> 118 + </View> 119 + <View 120 + style={[ 121 + a.gap_sm, 122 + a.align_center, 123 + a.pt_5xl, 124 + a.pb_3xl, 125 + a.mt_2xl, 126 + ]}> 127 + <Text 128 + style={[ 129 + gtMobile ? a.text_4xl : a.text_3xl, 130 + a.font_bold, 131 + a.text_center, 132 + {color: '#354358'}, 133 + web({ 134 + backgroundImage: 135 + 'linear-gradient(180deg, #313F54 0%, #667B99 83.65%, rgba(102, 123, 153, 0.50) 100%)', 136 + backgroundClip: 'text', 137 + WebkitBackgroundClip: 'text', 138 + WebkitTextFillColor: 'transparent', 139 + color: 'transparent', 140 + lineHeight: 1.2, 141 + letterSpacing: -0.5, 142 + }), 143 + ]}> 144 + <Trans>Real people.</Trans> 145 + {'\n'} 146 + <Trans>Real conversations.</Trans> 147 + {'\n'} 148 + <Trans>Social media you control.</Trans> 149 + </Text> 150 + </View> 151 + <View style={[a.gap_md, a.align_center]}> 152 + <View> 153 + <Button 154 + onPress={onPressCreateAccount} 155 + label={_(msg`Create account`)} 156 + size="large" 157 + color="primary" 158 + style={{ 159 + width: 200, 160 + backgroundColor: '#006AFF', 161 + }}> 162 + <ButtonText> 163 + <Trans>Create account</Trans> 164 + </ButtonText> 165 + </Button> 166 + <Button 167 + onPress={onPressExplore} 168 + label={_(msg`Explore the app`)} 169 + size="large" 170 + color="primary" 171 + variant="ghost" 172 + style={[a.bg_transparent, {width: 200}]} 173 + hoverStyle={[a.bg_transparent]}> 174 + {({hovered}) => ( 175 + <ButtonText 176 + style={[hovered && [a.underline], {color: '#006AFF'}]}> 177 + <Trans>Explore the app</Trans> 178 + </ButtonText> 179 + )} 180 + </Button> 181 + </View> 182 + <View style={[a.align_center, {minWidth: 200}]}> 183 + <Text 184 + style={[ 185 + a.text_md, 186 + a.text_center, 187 + {color: '#405168', lineHeight: 24}, 188 + ]}> 189 + <Trans>Already have an account?</Trans>{' '} 190 + <Pressable 191 + onPointerEnter={() => setSignInLinkHovered(true)} 192 + onPointerLeave={() => setSignInLinkHovered(false)} 193 + accessibilityRole="button" 194 + accessibilityLabel={_(msg`Sign in`)} 195 + accessibilityHint=""> 196 + <Text 197 + style={[ 198 + a.font_medium, 199 + { 200 + color: '#006AFF', 201 + fontSize: undefined, 202 + }, 203 + signInLinkHovered && a.underline, 204 + ]} 205 + onPress={onPressSignIn}> 206 + <Trans>Sign in</Trans> 207 + </Text> 208 + </Pressable> 209 + </Text> 210 + </View> 211 + </View> 212 + </View> 213 + <Button 214 + label={_(msg`Close welcome modal`)} 215 + style={[ 216 + a.absolute, 217 + { 218 + top: 8, 219 + right: 8, 220 + }, 221 + a.bg_transparent, 222 + ]} 223 + hoverStyle={[a.bg_transparent]} 224 + onPress={() => { 225 + logger.metric('welcomeModal:dismissed', {}) 226 + fadeOutAndClose() 227 + }} 228 + color="secondary" 229 + size="small" 230 + variant="ghost" 231 + shape="round"> 232 + {({hovered, pressed, focused}) => ( 233 + <XIcon 234 + size="md" 235 + style={{ 236 + color: '#354358', 237 + opacity: hovered || pressed || focused ? 1 : 0.7, 238 + }} 239 + /> 240 + )} 241 + </Button> 242 + </ImageBackground> 243 + </View> 244 + </FocusScope.FocusScope> 245 + </View> 246 + ) 247 + }
+3
src/components/hooks/useWelcomeModal.native.ts
··· 1 + export function useWelcomeModal() { 2 + throw new Error('useWelcomeModal is web only') 3 + }
+43
src/components/hooks/useWelcomeModal.ts
··· 1 + import {useEffect, useState} from 'react' 2 + 3 + import {isWeb} from '#/platform/detection' 4 + import {useSession} from '#/state/session' 5 + 6 + export function useWelcomeModal() { 7 + const {hasSession} = useSession() 8 + const [isOpen, setIsOpen] = useState(false) 9 + 10 + const open = () => setIsOpen(true) 11 + const close = () => { 12 + setIsOpen(false) 13 + // Mark that user has actively closed the modal, don't show again this session 14 + if (typeof window !== 'undefined') { 15 + sessionStorage.setItem('welcomeModalClosed', 'true') 16 + } 17 + } 18 + 19 + useEffect(() => { 20 + // Only show modal if: 21 + // 1. User is not logged in 22 + // 2. We're on the web (this is a web-only feature) 23 + // 3. We're on the homepage (path is '/' or '/home') 24 + // 4. User hasn't actively closed the modal in this session 25 + if (isWeb && !hasSession && typeof window !== 'undefined') { 26 + const currentPath = window.location.pathname 27 + const isHomePage = currentPath === '/' 28 + const hasUserClosedModal = 29 + sessionStorage.getItem('welcomeModalClosed') === 'true' 30 + 31 + if (isHomePage && !hasUserClosedModal) { 32 + // Small delay to ensure the page has loaded 33 + const timer = setTimeout(() => { 34 + open() 35 + }, 1000) 36 + 37 + return () => clearTimeout(timer) 38 + } 39 + } 40 + }, [hasSession]) 41 + 42 + return {isOpen, open, close} 43 + }
+3
src/lib/hooks/useWebMediaQueries.tsx
··· 2 2 3 3 import {isNative} from '#/platform/detection' 4 4 5 + /** 6 + * @deprecated use `useBreakpoints` from `#/alf` instead 7 + */ 5 8 export function useWebMediaQueries() { 6 9 const isDesktop = useMediaQuery({minWidth: 1300}) 7 10 const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
+1
src/lib/statsig/gates.ts
··· 15 15 | 'remove_show_latest_button' 16 16 | 'test_gate_1' 17 17 | 'test_gate_2' 18 + | 'welcome_modal'
+5
src/logger/metrics.ts
··· 48 48 // Screen events 49 49 'splash:signInPressed': {} 50 50 'splash:createAccountPressed': {} 51 + 'welcomeModal:signupClicked': {} 52 + 'welcomeModal:exploreClicked': {} 53 + 'welcomeModal:signinClicked': {} 54 + 'welcomeModal:dismissed': {} 55 + 'welcomeModal:presented': {} 51 56 'signup:nextPressed': { 52 57 activeStep: number 53 58 phoneVerificationRequired?: boolean
+10
src/view/shell/index.web.tsx
··· 8 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 9 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 10 import {type NavigationProp} from '#/lib/routes/types' 11 + import {useGate} from '#/lib/statsig/statsig' 11 12 import {useGeolocation} from '#/state/geolocation' 12 13 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 13 14 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' ··· 22 23 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 24 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 24 25 import {SigninDialog} from '#/components/dialogs/Signin' 26 + import {useWelcomeModal} from '#/components/hooks/useWelcomeModal' 25 27 import { 26 28 Outlet as PolicyUpdateOverlayPortalOutlet, 27 29 usePolicyUpdateContext, 28 30 } from '#/components/PolicyUpdateOverlay' 29 31 import {Outlet as PortalOutlet} from '#/components/Portal' 32 + import {WelcomeModal} from '#/components/WelcomeModal' 30 33 import {FlatNavigator, RoutesContainer} from '#/Navigation' 31 34 import {Composer} from './Composer.web' 32 35 import {DrawerContent} from './Drawer' ··· 42 45 const showDrawer = !isDesktop && isDrawerOpen 43 46 const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer) 44 47 const {state: policyUpdateState} = usePolicyUpdateContext() 48 + const welcomeModalControl = useWelcomeModal() 49 + const gate = useGate() 45 50 46 51 useLayoutEffect(() => { 47 52 if (showDrawer !== showDrawerDelayedExit) { ··· 79 84 <AgeAssuranceRedirectDialog /> 80 85 <LinkWarningDialog /> 81 86 <Lightbox /> 87 + 88 + {/* Show welcome modal if the gate is enabled */} 89 + {welcomeModalControl.isOpen && gate('welcome_modal') && ( 90 + <WelcomeModal control={welcomeModalControl} /> 91 + )} 82 92 83 93 {/* Until policy update has been completed by the user, don't render anything that is portaled */} 84 94 {policyUpdateState.completed && (