Make Live Now beta generally available

This commit is contained in:
Alex Benzer
2025-12-06 13:40:36 -08:00
parent b4e1fdfccf
commit 435483d31e
7 changed files with 119 additions and 27 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ function DialogInner({
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}> <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
<TextField.Input <TextField.Input
label={_(msg`Live link`)} label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)} placeholder={_(msg`twitch.tv/username`)}
value={liveLink} value={liveLink}
onChangeText={setLiveLink} onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')} onFocus={() => setLiveLinkError('')}
+1 -1
View File
@@ -123,7 +123,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
<TextField.Root isInvalid={isSourceInvalid}> <TextField.Root isInvalid={isSourceInvalid}>
<TextField.Input <TextField.Input
label={_(msg`Live link`)} label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)} placeholder={_(msg`twitch.tv/username`)}
value={liveLink} value={liveLink}
onChangeText={setLiveLink} onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')} onFocus={() => setLiveLinkError('')}
+62 -9
View File
@@ -17,9 +17,14 @@ import {unstableCacheProfileView} from '#/state/queries/profile'
import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {
ReportDialog,
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' 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 {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
import {LiveIndicator} from './LiveIndicator' import {LiveIndicator} from './LiveIndicator'
@@ -205,16 +210,64 @@ export function LiveStatus({
</Button> </Button>
</ProfileCard.Header> </ProfileCard.Header>
)} )}
<Text <BetaTestingFooter profile={profile} />
style={[
a.w_full,
a.text_center,
t.atoms.text_contrast_low,
a.text_sm,
]}>
<Trans>Live feature is in beta testing</Trans>
</Text>
</View> </View>
</> </>
) )
} }
function BetaTestingFooter({profile}: {profile: bsky.profile.AnyProfileView}) {
const {_} = useLingui()
const t = useTheme()
const reportControl = useReportDialogControl()
return (
<>
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.w_full,
a.pt_md,
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<CircleInfoIcon size="sm" style={[t.atoms.text_contrast_low]} />
<Text
style={[
a.flex_1,
t.atoms.text_contrast_low,
a.text_sm,
a.text_left,
]}>
<Trans>Live feature is in beta</Trans>
</Text>
</View>
<Button
label={_(msg`Report`)}
size="tiny"
variant="ghost"
color="secondary"
onPress={() => {
logger.metric(
'live:report',
{subject: profile.did},
{statsig: true},
)
reportControl.open()
}}>
<ButtonText style={[t.atoms.text_contrast_medium]}>
<Trans>Report</Trans>
</ButtonText>
</Button>
</View>
<ReportDialog
control={reportControl}
subject={{
...profile,
$type: 'app.bsky.actor.defs#profileViewDetailed',
}}
/>
</>
)
}
+15 -6
View File
@@ -14,14 +14,13 @@ import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {logger} from '#/logger' import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow' 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 {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
export function useLiveLinkMetaQuery(url: string | null) { export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig() const allowedDomains = useAllowedLiveDomains(useSession().currentAccount?.did)
const {currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
@@ -30,12 +29,22 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url], queryKey: ['link-meta', url],
queryFn: async () => { queryFn: async () => {
if (!url) return undefined 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) 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`)) throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
} }
+7 -9
View File
@@ -7,14 +7,14 @@ import {
import {isAfter, parseISO} from 'date-fns' import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' 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 {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function useActorStatus(actor?: bsky.profile.AnyProfileView) { export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor) const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const config = useLiveNowConfig() const allowedDomains = useAllowedLiveDomains(shadowed?.did)
return useMemo(() => { return useMemo(() => {
tick! // revalidate every minute tick! // revalidate every minute
@@ -23,7 +23,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
shadowed && shadowed &&
'status' in shadowed && 'status' in shadowed &&
shadowed.status && shadowed.status &&
validateStatus(shadowed.did, shadowed.status, config) && validateStatus(shadowed.status, allowedDomains) &&
isStatusStillActive(shadowed.status.expiresAt) isStatusStillActive(shadowed.status.expiresAt)
) { ) {
return { return {
@@ -40,7 +40,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
record: {}, record: {},
} satisfies AppBskyActorDefs.StatusView } satisfies AppBskyActorDefs.StatusView
} }
}, [shadowed, config, tick]) }, [shadowed, allowedDomains, tick])
} }
export function isStatusStillActive(timeStr: string | undefined) { export function isStatusStillActive(timeStr: string | undefined) {
@@ -52,19 +52,17 @@ export function isStatusStillActive(timeStr: string | undefined) {
} }
export function validateStatus( export function validateStatus(
did: string,
status: AppBskyActorDefs.StatusView, status: AppBskyActorDefs.StatusView,
config: {did: string; domains: string[]}[], allowedDomains: string[],
) { ) {
if (status.status !== 'app.bsky.actor.status#live') return false if (status.status !== 'app.bsky.actor.status#live') return false
const sources = config.find(cfg => cfg.did === did) if (allowedDomains.length === 0) {
if (!sources) {
return false return false
} }
try { try {
if (AppBskyEmbedExternal.isView(status.embed)) { if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri) const url = new URL(status.embed.external.uri)
return sources.domains.includes(url.hostname) return allowedDomains.includes(url.hostname)
} else { } else {
return false return false
} }
+1
View File
@@ -5,6 +5,7 @@ export type Gate =
| 'debug_subscriptions' | 'debug_subscriptions'
| 'explore_show_suggested_feeds' | 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread' | 'feed_reply_button_open_thread'
| 'live_now_beta'
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'onboarding_suggested_starterpacks' | 'onboarding_suggested_starterpacks'
+32 -1
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig'
import {useLanguagePrefs} from '#/state/preferences/languages' import {useLanguagePrefs} from '#/state/preferences/languages'
import {useServiceConfigQuery} from '#/state/queries/service-config' import {useServiceConfigQuery} from '#/state/queries/service-config'
import {device} from '#/storage' import {device} from '#/storage'
@@ -93,8 +94,38 @@ export function useLiveNowConfig() {
} }
export function useCanGoLive(did?: string) { 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() 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() { export function useCheckEmailConfirmed() {