diff --git a/src/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx index 3304bceb61..f329a0f228 100644 --- a/src/components/live/EditLiveDialog.tsx +++ b/src/components/live/EditLiveDialog.tsx @@ -150,7 +150,7 @@ function DialogInner({ setLiveLinkError('')} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx index 44c604cde7..7e7552e137 100644 --- a/src/components/live/GoLiveDialog.tsx +++ b/src/components/live/GoLiveDialog.tsx @@ -123,7 +123,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { setLiveLinkError('')} diff --git a/src/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx index f15650e485..4d4c6951cb 100644 --- a/src/components/live/LiveStatusDialog.tsx +++ b/src/components/live/LiveStatusDialog.tsx @@ -17,9 +17,14 @@ import {unstableCacheProfileView} from '#/state/queries/profile' import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '../icons/CircleInfo' import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' import {LiveIndicator} from './LiveIndicator' @@ -205,16 +210,64 @@ export function LiveStatus({ )} - - Live feature is in beta testing - + ) } + +function BetaTestingFooter({profile}: {profile: bsky.profile.AnyProfileView}) { + const {_} = useLingui() + const t = useTheme() + const reportControl = useReportDialogControl() + + return ( + <> + + + + + Live feature is in beta + + + + + + + ) +} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts index 19cb54ebf2..4db78056ad 100644 --- a/src/components/live/queries.ts +++ b/src/components/live/queries.ts @@ -14,14 +14,13 @@ import {imageToThumb} from '#/lib/api/resolve' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {logger} from '#/logger' import {updateProfileShadow} from '#/state/cache/profile-shadow' -import {useLiveNowConfig} from '#/state/service-config' +import {useAllowedLiveDomains} from '#/state/service-config' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {useDialogContext} from '#/components/Dialog' export function useLiveLinkMetaQuery(url: string | null) { - const liveNowConfig = useLiveNowConfig() - const {currentAccount} = useSession() + const allowedDomains = useAllowedLiveDomains(useSession().currentAccount?.did) const {_} = useLingui() const agent = useAgent() @@ -30,12 +29,22 @@ 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`)) + if (allowedDomains.length === 0) { + throw new Error(_(msg`You are not allowed to go live`)) + } const urlp = new URL(url) - if (!config.domains.includes(urlp.hostname)) { + if (!allowedDomains.includes(urlp.hostname)) { + // Check if this is a non-Twitch domain for better error message + const isTwitchAttempt = allowedDomains.some(domain => + domain.includes('twitch'), + ) + if (isTwitchAttempt && !urlp.hostname.includes('twitch')) { + throw new Error( + _(msg`Only Twitch links are supported during the beta`), + ) + } throw new Error(_(msg`${urlp.hostname} is not a valid URL`)) } diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts index 7e023be44e..c2887d238b 100644 --- a/src/lib/actor-status.ts +++ b/src/lib/actor-status.ts @@ -7,14 +7,14 @@ import { import {isAfter, parseISO} from 'date-fns' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' -import {useLiveNowConfig} from '#/state/service-config' +import {useAllowedLiveDomains} from '#/state/service-config' import {useTickEveryMinute} from '#/state/shell' import type * as bsky from '#/types/bsky' export function useActorStatus(actor?: bsky.profile.AnyProfileView) { const shadowed = useMaybeProfileShadow(actor) const tick = useTickEveryMinute() - const config = useLiveNowConfig() + const allowedDomains = useAllowedLiveDomains(shadowed?.did) return useMemo(() => { tick! // revalidate every minute @@ -23,7 +23,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { shadowed && 'status' in shadowed && shadowed.status && - validateStatus(shadowed.did, shadowed.status, config) && + validateStatus(shadowed.status, allowedDomains) && isStatusStillActive(shadowed.status.expiresAt) ) { return { @@ -40,7 +40,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { record: {}, } satisfies AppBskyActorDefs.StatusView } - }, [shadowed, config, tick]) + }, [shadowed, allowedDomains, tick]) } export function isStatusStillActive(timeStr: string | undefined) { @@ -52,19 +52,17 @@ export function isStatusStillActive(timeStr: string | undefined) { } export function validateStatus( - did: string, status: AppBskyActorDefs.StatusView, - config: {did: string; domains: string[]}[], + allowedDomains: string[], ) { if (status.status !== 'app.bsky.actor.status#live') return false - const sources = config.find(cfg => cfg.did === did) - if (!sources) { + if (allowedDomains.length === 0) { return false } try { if (AppBskyEmbedExternal.isView(status.embed)) { const url = new URL(status.embed.external.uri) - return sources.domains.includes(url.hostname) + return allowedDomains.includes(url.hostname) } else { return false } diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index ea263e22e5..82efd851f2 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -5,6 +5,7 @@ export type Gate = | 'debug_subscriptions' | 'explore_show_suggested_feeds' | 'feed_reply_button_open_thread' + | 'live_now_beta' | 'old_postonboarding' | 'onboarding_add_video_feed' | 'onboarding_suggested_starterpacks' diff --git a/src/state/service-config.tsx b/src/state/service-config.tsx index 242022b60d..83af92f963 100644 --- a/src/state/service-config.tsx +++ b/src/state/service-config.tsx @@ -1,5 +1,6 @@ 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 {device} from '#/storage' @@ -93,8 +94,38 @@ export function useLiveNowConfig() { } export function useCanGoLive(did?: string) { + const gate = useGate() + const isInBeta = __DEV__ ? true : gate('live_now_beta') + + if (!isInBeta || !did) { + return false + } + + return true +} + +/** + * Returns the allowed livestream domains for a given user. + * - VIP users (in config) get their custom domains + * - All other users get default Twitch domains + */ +export function useAllowedLiveDomains(did?: string): string[] { const config = useLiveNowConfig() - return !!config.find(cfg => cfg.did === did) + const gate = useGate() + const isInBeta = __DEV__ ? true : gate('live_now_beta') + + if (!isInBeta || !did) { + return [] + } + + // Check if user is a VIP with custom domains + const vipConfig = config.find(cfg => cfg.did === did) + if (vipConfig) { + return vipConfig.domains + } + + // Default: Twitch only for all beta users + return ['twitch.tv', 'www.twitch.tv'] } export function useCheckEmailConfirmed() {