diff --git a/assets/images/live_now_beta.webp b/assets/images/live_now_beta.webp new file mode 100644 index 0000000000..7b5b074835 Binary files /dev/null and b/assets/images/live_now_beta.webp differ 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..b081322c44 --- /dev/null +++ b/src/components/dialogs/nuxs/LiveNowBetaDialog.tsx @@ -0,0 +1,209 @@ +import {useCallback, useMemo} 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, select, 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]) + + const shadowColor = useMemo(() => { + return select(t.name, { + light: utils.alpha(t.palette.primary_900, 0.4), + dark: utils.alpha(t.palette.primary_25, 0.4), + dim: utils.alpha(t.palette.primary_25, 0.4), + }) + }, [t]) + + 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/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx index 3304bceb61..ba5e2b5b7a 100644 --- a/src/components/live/EditLiveDialog.tsx +++ b/src/components/live/EditLiveDialog.tsx @@ -18,7 +18,6 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' -import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {LinkPreview} from './LinkPreview' @@ -172,26 +171,13 @@ function DialogInner({ {(liveLinkError || linkMetaError) && ( - - - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + )} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx index 44c604cde7..9d866fd6a5 100644 --- a/src/components/live/GoLiveDialog.tsx +++ b/src/components/live/GoLiveDialog.tsx @@ -6,13 +6,14 @@ import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' import {definitelyUrl} from '#/lib/strings/url-helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useLiveNowConfig} from '#/state/service-config' import {useTickEveryMinute} from '#/state/shell' import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' -import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {getLiveServiceNames} from '#/components/live/utils' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import * as Select from '#/components/Select' @@ -49,6 +50,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { const [duration, setDuration] = useState(60) const moderationOpts = useModerationOpts() const tick = useTickEveryMinute() + const liveNowConfig = useLiveNowConfig() + const {formatted: allowedServices} = getLiveServiceNames( + liveNowConfig.allowedDomains, + ) const time = useCallback( (offset: number) => { @@ -139,27 +144,21 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { /> - {(liveLinkError || linkMetaError) && ( - - - - {liveLinkError ? ( - This is not a valid link - ) : ( - cleanError(linkMetaError) - )} - - + {liveLinkError || linkMetaError ? ( + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + ) : ( + + + The following services are enabled for your account:{' '} + {allowedServices} + + )} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts index 19cb54ebf2..7db944d06d 100644 --- a/src/components/live/queries.ts +++ b/src/components/live/queries.ts @@ -18,10 +18,10 @@ import {useLiveNowConfig} from '#/state/service-config' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {useDialogContext} from '#/components/Dialog' +import {getLiveServiceNames} from '#/components/live/utils' export function useLiveLinkMetaQuery(url: string | null) { const liveNowConfig = useLiveNowConfig() - const {currentAccount} = useSession() const {_} = useLingui() const agent = useAgent() @@ -30,13 +30,14 @@ export function useLiveLinkMetaQuery(url: string | null) { queryKey: ['link-meta', url], queryFn: async () => { if (!url) return undefined - const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did) - - if (!config) throw new Error(_(msg`You are not allowed to go live`)) - const urlp = new URL(url) - if (!config.domains.includes(urlp.hostname)) { - throw new Error(_(msg`${urlp.hostname} is not a valid URL`)) + if (!liveNowConfig.allowedDomains.has(urlp.hostname)) { + const {formatted} = getLiveServiceNames(liveNowConfig.allowedDomains) + throw new Error( + _( + msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`, + ), + ) } return await getLinkMeta(agent, url) diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts index 6b4267cb01..10b0ba8f90 100644 --- a/src/components/live/utils.ts +++ b/src/components/live/utils.ts @@ -35,3 +35,28 @@ export function useDebouncedValue(val: T, delayMs: number): T { return prev } + +const serviceUrlToNameMap: Record = { + 'twitch.tv': 'Twitch', + 'www.twitch.tv': 'Twitch', + 'youtube.com': 'YouTube', + 'www.youtube.com': 'YouTube', + 'youtu.be': 'YouTube', + 'nba.com': 'NBA', + 'www.nba.com': 'NBA', + 'nba.smart.link': 'nba.smart.link', + 'espn.com': 'ESPN', + 'www.espn.com': 'ESPN', + 'stream.place': 'Streamplace', + 'skylight.social': 'Skylight', +} + +export function getLiveServiceNames(domains: Set) { + const names = Array.from( + new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)), + ) + return { + names, + formatted: names.join(', '), + } +} 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..5da8b4aca3 --- /dev/null +++ b/src/features/nuxs/components/Gradient.tsx @@ -0,0 +1,24 @@ +import {LinearGradient} from 'expo-linear-gradient' + +import {atoms as a, useTheme, utils, type ViewStyleProp} from '#/alf' + +/** + * A gradient overlay using the primary color at low opacity. This component is + * absolutely positioned and intended to be composed within other components, + * with optional styling allowed, such as adjusting border radius. + */ +export function Gradient({style}: ViewStyleProp) { + const t = useTheme() + return ( + + ) +} diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts index 31e532eaa0..7c18b5dc66 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.has(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 242022b60d..a04cbb51ee 100644 --- a/src/state/service-config.tsx +++ b/src/state/service-config.tsx @@ -1,7 +1,10 @@ import {createContext, useContext, useMemo} from 'react' +import {useGate} from '#/lib/statsig/statsig' import {useLanguagePrefs} from '#/state/preferences/languages' import {useServiceConfigQuery} from '#/state/queries/service-config' +import {useSession} from '#/state/session' +import {IS_DEV} from '#/env' import {device} from '#/storage' type TrendingContext = { @@ -18,7 +21,7 @@ const TrendingContext = createContext({ }) TrendingContext.displayName = 'TrendingContext' -const LiveNowContext = createContext(null) +const LiveNowContext = createContext([]) LiveNowContext.displayName = 'LiveNowContext' const CheckEmailConfirmedContext = createContext(null) @@ -82,19 +85,28 @@ export function useTrendingConfig() { return useContext(TrendingContext) } -export function useLiveNowConfig() { +const DEFAULT_LIVE_ALLOWED_DOMAINS = ['twitch.tv', 'www.twitch.tv'] +export type LiveNowConfig = { + allowedDomains: Set +} +export function useLiveNowConfig(): LiveNowConfig { const ctx = useContext(LiveNowContext) - if (!ctx) { - throw new Error( - 'useLiveNowConfig must be used within a ServiceConfigManager', - ) + const canGoLive = useCanGoLive() + const {currentAccount} = useSession() + if (!currentAccount?.did || !canGoLive) return {allowedDomains: new Set()} + const vip = ctx.find(live => live.did === currentAccount.did) + return { + allowedDomains: new Set( + DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []), + ), } - return ctx } -export function useCanGoLive(did?: string) { - const config = useLiveNowConfig() - return !!config.find(cfg => cfg.did === did) +export function useCanGoLive() { + const gate = useGate() + const {hasSession} = useSession() + if (!hasSession) return false + 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..f9c1bac007 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,15 @@ 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 = + isSelf && + canGoLive && + statusNudge.status === 'ready' && + !statusNudge.nux?.completed + const {mutate: saveNux} = useSaveNux() const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) @@ -229,17 +241,22 @@ let ProfileMenu = ({ {({props}) => { return ( - + <> + + + {statusNudgeActive && } + ) }} @@ -337,11 +354,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, + }) + }}> + {statusNudgeActive && } {status.isDisabled ? ( Go live (disabled) @@ -351,7 +376,26 @@ let ProfileMenu = ({ Go live )} - + {statusNudgeActive && ( + + New + + )} + t.palette.primary_500 + : undefined + } + /> )} {verification.viewer.role === 'verifier' &&