about summary refs log tree commit diff
diff options
context:
space:
mode:
authorAlex Benzer <abenzer@users.noreply.github.com>2025-09-04 07:20:46 -0700
committerGitHub <noreply@github.com>2025-09-04 07:20:46 -0700
commit625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (patch)
tree8d36b6564cb2b679269dea4b1fc3a8d3887d2fd2
parent0b02d9d9a7ca33dab256a35e4fc9b8feabe20d34 (diff)
downloadvoidsky-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.jpgbin0 -> 116471 bytes
-rw-r--r--src/alf/atoms.ts1
-rw-r--r--src/components/WelcomeModal.tsx247
-rw-r--r--src/components/hooks/useWelcomeModal.native.ts3
-rw-r--r--src/components/hooks/useWelcomeModal.ts43
-rw-r--r--src/lib/hooks/useWebMediaQueries.tsx3
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/logger/metrics.ts5
-rw-r--r--src/view/shell/index.web.tsx10
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 && (
         <>