From d5a383bc58b769332b41ba612e6721545ff7a4f1 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 14 Jan 2026 15:19:25 -0600 Subject: [PATCH] Merge conflicts --- src/components/Button.tsx | 1 + src/components/Dialog/index.tsx | 10 +- src/components/Menu/index.tsx | 12 +- src/components/Menu/index.web.tsx | 11 +- src/components/Menu/types.ts | 1 + .../dialogs/nuxs/LiveNowBetaDialog.tsx | 201 ++++++++++++++++++ src/components/dialogs/nuxs/index.tsx | 14 +- src/features/nuxs/components/Dot.tsx | 31 +++ src/features/nuxs/components/Gradient.tsx | 19 ++ src/lib/actor-status.ts | 13 +- src/lib/statsig/gates.ts | 1 + src/state/queries/nuxs/definitions.ts | 12 ++ src/state/service-config.tsx | 14 +- src/view/com/posts/PostFeed.tsx | 2 +- src/view/com/profile/ProfileMenu.tsx | 77 +++++-- 15 files changed, 361 insertions(+), 58 deletions(-) create mode 100644 src/components/dialogs/nuxs/LiveNowBetaDialog.tsx create mode 100644 src/features/nuxs/components/Dot.tsx create mode 100644 src/features/nuxs/components/Gradient.tsx 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' &&