diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 717985d475..5e01a6b117 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -557,6 +557,7 @@ export const Button = React.forwardRef(
a.align_center,
a.justify_center,
a.curve_continuous,
+ a.overflow_hidden,
baseStyles,
style,
...(state.hovered || state.pressed
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index ee2c0f76c7..b6d47fd3b4 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -359,7 +359,13 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
)
}
-export function Handle({difference = false}: {difference?: boolean}) {
+export function Handle({
+ difference = false,
+ fill,
+}: {
+ difference?: boolean
+ fill?: string
+}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -390,7 +396,7 @@ export function Handle({difference = false}: {difference?: boolean}) {
opacity: 0.75,
}
: {
- backgroundColor: t.palette.contrast_975,
+ backgroundColor: fill || t.palette.contrast_975,
opacity: 0.5,
},
]}
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index ab911eb05a..5ceaf281c3 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -167,6 +167,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
a.gap_sm,
a.px_md,
a.rounded_md,
+ a.overflow_hidden,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
@@ -193,7 +194,6 @@ export function ItemText({children, style}: ItemTextProps) {
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
- {paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
@@ -202,16 +202,18 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
-export function ItemIcon({icon: Comp}: ItemIconProps) {
+export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
)
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 242449bf49..339b26350c 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -262,6 +262,7 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
a.gap_lg,
a.py_sm,
a.rounded_xs,
+ a.overflow_hidden,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) &&
@@ -302,7 +303,7 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
-export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
+export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
@@ -319,9 +320,11 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
index e9ee082f4c..a5fce102ba 100644
--- a/src/components/Menu/types.ts
+++ b/src/components/Menu/types.ts
@@ -107,6 +107,7 @@ export type ItemTextProps = React.PropsWithChildren
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType
position?: 'left' | 'right'
+ fill?: (props: {disabled: boolean}) => string
}>
export type GroupProps = React.PropsWithChildren
diff --git a/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx
new file mode 100644
index 0000000000..3d65679276
--- /dev/null
+++ b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx
@@ -0,0 +1,201 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {LinearGradient} from 'expo-linear-gradient'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useTheme, utils, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useNuxDialogContext} from '#/components/dialogs/nuxs'
+import {
+ createIsEnabledCheck,
+ isExistingUserAsOf,
+} from '#/components/dialogs/nuxs/utils'
+import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
+import {Text} from '#/components/Typography'
+import {IS_E2E} from '#/env'
+
+export const enabled = createIsEnabledCheck(props => {
+ return (
+ !IS_E2E &&
+ isExistingUserAsOf(
+ '2026-01-16T00:00:00.000Z',
+ props.currentProfile.createdAt,
+ ) &&
+ props.gate('live_now_beta')
+ )
+})
+
+export function LiveNowBetaDialog() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const nuxDialogs = useNuxDialogContext()
+ const control = Dialog.useDialogControl()
+
+ Dialog.useAutoOpen(control)
+
+ const onClose = useCallback(() => {
+ nuxDialogs.dismissActiveNux()
+ }, [nuxDialogs])
+
+ return (
+
+
+
+
+
+
+
+
+
+ Beta Feature
+
+
+
+
+
+
+
+
+
+
+
+
+ Show when you’re live
+
+
+
+ Streaming on Twitch? Set your live status on Bluesky to add a
+ badge to your avatar.
Tapping it takes people straight to your
+ stream.
+
+
+
+
+ {!isWeb && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 2ab319fafd..eadadeb8eb 100644
--- a/src/components/dialogs/nuxs/index.tsx
+++ b/src/components/dialogs/nuxs/index.tsx
@@ -20,9 +20,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {
- enabled as isFindContactsAnnouncementEnabled,
- FindContactsAnnouncement,
-} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
+ enabled as isLiveNowBetaDialogEnabled,
+ LiveNowBetaDialog,
+} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useGeolocation} from '#/geolocation'
@@ -37,8 +37,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean
}[] = [
{
- id: Nux.FindContactsAnnouncement,
- enabled: isFindContactsAnnouncementEnabled,
+ id: Nux.LiveNowBetaDialog,
+ enabled: isLiveNowBetaDialogEnabled,
},
]
@@ -186,9 +186,7 @@ function Inner({
return (
{/*For example, activeNux === Nux.NeueTypography && */}
- {activeNux === Nux.FindContactsAnnouncement && (
-
- )}
+ {activeNux === Nux.LiveNowBetaDialog && }
)
}
diff --git a/src/features/nuxs/components/Dot.tsx b/src/features/nuxs/components/Dot.tsx
new file mode 100644
index 0000000000..7eb82bf60f
--- /dev/null
+++ b/src/features/nuxs/components/Dot.tsx
@@ -0,0 +1,31 @@
+import {View, type ViewStyle} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+
+/**
+ * The little blue dot used to nudge a user towards a certain feature. The dot
+ * is absolutely positioned, and is intended to be configured by passing in
+ * positional styles via `top`, `bottom`, `left`, and `right` props.
+ */
+export function Dot({
+ top,
+ bottom,
+ left,
+ right,
+}: Pick) {
+ const t = useTheme()
+ return (
+
+
+
+ )
+}
diff --git a/src/features/nuxs/components/Gradient.tsx b/src/features/nuxs/components/Gradient.tsx
new file mode 100644
index 0000000000..ac8fdfaaf1
--- /dev/null
+++ b/src/features/nuxs/components/Gradient.tsx
@@ -0,0 +1,19 @@
+import {LinearGradient} from 'expo-linear-gradient'
+
+import {atoms as a, useTheme, utils} from '#/alf'
+
+export function Gradient() {
+ const t = useTheme()
+ return (
+
+ )
+}
diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts
index 31e532eaa0..39ab011875 100644
--- a/src/lib/actor-status.ts
+++ b/src/lib/actor-status.ts
@@ -7,7 +7,7 @@ import {
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
-import {useLiveNowConfig} from '#/state/service-config'
+import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky'
@@ -20,7 +20,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
tick! // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
- const isValid = validateStatus(shadowed.did, shadowed.status, config)
+ const isValid = validateStatus(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
@@ -65,19 +65,14 @@ export function isStatusStillActive(timeStr: string | undefined) {
}
export function validateStatus(
- did: string,
status: AppBskyActorDefs.StatusView,
- config: {did: string; domains: string[]}[],
+ config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
- const sources = config.find(cfg => cfg.did === did)
- if (!sources) {
- return false
- }
try {
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
- return sources.domains.includes(url.hostname)
+ return config.allowedDomains.includes(url.hostname)
} else {
return false
}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index dcb330764c..700ca12f83 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -8,6 +8,7 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread'
| 'is_bsky_team_member' // special, do not remove
+ | 'live_now_beta'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks'
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 6c03c936a6..e23050c465 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -12,6 +12,8 @@ export enum Nux {
BookmarksAnnouncement = 'BookmarksAnnouncement',
FindContactsAnnouncement = 'FindContactsAnnouncement',
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
+ LiveNowBetaDialog = 'LiveNowBetaDialog',
+ LiveNowBetaNudge = 'LiveNowBetaNudge',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -62,6 +64,14 @@ export type AppNux = BaseNux<
id: Nux.FindContactsDismissibleBanner
data: undefined
}
+ | {
+ id: Nux.LiveNowBetaDialog
+ data: undefined
+ }
+ | {
+ id: Nux.LiveNowBetaNudge
+ data: undefined
+ }
>
export const NuxSchemas: Record | undefined> = {
@@ -75,4 +85,6 @@ export const NuxSchemas: Record | undefined> = {
[Nux.BookmarksAnnouncement]: undefined,
[Nux.FindContactsAnnouncement]: undefined,
[Nux.FindContactsDismissibleBanner]: undefined,
+ [Nux.LiveNowBetaDialog]: undefined,
+ [Nux.LiveNowBetaNudge]: undefined,
}
diff --git a/src/state/service-config.tsx b/src/state/service-config.tsx
index 4a9367b45d..a30952bd5b 100644
--- a/src/state/service-config.tsx
+++ b/src/state/service-config.tsx
@@ -93,16 +93,10 @@ export function useLiveNowConfig(): LiveNowConfig {
const ctx = useContext(LiveNowContext)
const canGoLive = useCanGoLive()
const {currentAccount} = useSession()
- if (!canGoLive) return {allowedDomains: []}
- if (!currentAccount?.did) return {allowedDomains: []}
+ if (!currentAccount?.did || !canGoLive) return {allowedDomains: []}
const vip = ctx.find(live => live.did === currentAccount.did)
- if (vip) {
- return {
- allowedDomains: vip.domains.concat(DEFAULT_LIVE_ALLOWED_DOMAINS),
- }
- }
return {
- allowedDomains: DEFAULT_LIVE_ALLOWED_DOMAINS,
+ allowedDomains: DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []),
}
}
@@ -110,9 +104,7 @@ export function useCanGoLive() {
const gate = useGate()
const {hasSession} = useSession()
if (!hasSession) return false
- const isInBeta = IS_DEV ? true : gate('live_now_beta')
- if (!isInBeta) return false
- return true
+ return IS_DEV ? true : gate('live_now_beta')
}
export function useCheckEmailConfirmed() {
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 9e6fcdcccb..97e5e9731e 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -954,7 +954,7 @@ let PostFeed = ({
const actor = post.author
if (
actor.status &&
- validateStatus(actor.did, actor.status, liveNowConfig) &&
+ validateStatus(actor.status, liveNowConfig) &&
isStatusStillActive(actor.status.expiresAt)
) {
if (!seenActorWithStatusRef.current.has(actor.did)) {
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index 8cf6d2c968..683a6574bb 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -15,6 +15,7 @@ import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
+import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
@@ -25,6 +26,7 @@ import {useCanGoLive} from '#/state/service-config'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
@@ -59,6 +61,8 @@ import * as Prompt from '#/components/Prompt'
import {useFullVerificationState} from '#/components/verification'
import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
+import {Dot} from '#/features/nuxs/components/Dot'
+import {Gradient} from '#/features/nuxs/components/Gradient'
import {useDevMode} from '#/storage/hooks/dev-mode'
let ProfileMenu = ({
@@ -66,6 +70,7 @@ let ProfileMenu = ({
}: {
profile: Shadow
}): React.ReactNode => {
+ const t = useTheme()
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
@@ -79,8 +84,12 @@ let ProfileMenu = ({
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevMode()
const verification = useFullVerificationState({profile})
- const canGoLive = useCanGoLive(currentAccount?.did)
+ const canGoLive = useCanGoLive()
const status = useActorStatus(profile)
+ const statusNudge = useNux(Nux.LiveNowBetaNudge)
+ const statusNudgeActive =
+ canGoLive && statusNudge.status === 'ready' && !statusNudge.nux?.completed
+ const {mutate: saveNux} = useSaveNux()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -229,17 +238,22 @@ let ProfileMenu = ({
{({props}) => {
return (
-
+ <>
+
+
+ {statusNudgeActive && }
+ >
)
}}
@@ -337,11 +351,19 @@ let ProfileMenu = ({
? _(msg`Edit live status`)
: _(msg`Go live`)
}
- onPress={
- status.isDisabled
- ? goLiveDisabledDialogControl.open
- : goLiveDialogControl.open
- }>
+ onPress={() => {
+ if (status.isDisabled) {
+ goLiveDisabledDialogControl.open()
+ } else {
+ goLiveDialogControl.open()
+ }
+ saveNux({
+ id: Nux.LiveNowBetaNudge,
+ data: undefined,
+ completed: true,
+ })
+ }}>
+
{status.isDisabled ? (
Go live (disabled)
@@ -351,7 +373,26 @@ let ProfileMenu = ({
Go live
)}
-
+ {statusNudgeActive && (
+
+ New
+
+ )}
+ t.palette.primary_500
+ : undefined
+ }
+ />
)}
{verification.viewer.role === 'verifier' &&