Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Benzer 7379dd8ca8 Add feature gate and link validation error client event 2025-12-06 13:48:41 -08:00
Alex Benzer 435483d31e Make Live Now beta generally available 2025-12-06 13:40:36 -08:00
8 changed files with 137 additions and 28 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ function DialogInner({
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
placeholder={_(msg`twitch.tv/username`)}
value={liveLink}
onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')}
+1 -1
View File
@@ -123,7 +123,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
<TextField.Root isInvalid={isSourceInvalid}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
placeholder={_(msg`twitch.tv/username`)}
value={liveLink}
onChangeText={setLiveLink}
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 {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({
</Button>
</ProfileCard.Header>
)}
<Text
style={[
a.w_full,
a.text_center,
t.atoms.text_contrast_low,
a.text_sm,
]}>
<Trans>Live feature is in beta testing</Trans>
</Text>
<BetaTestingFooter profile={profile} />
</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',
}}
/>
</>
)
}
+32 -7
View File
@@ -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,13 +29,39 @@ 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) {
const error = _(msg`You are not allowed to go live`)
logger.metric(
'live:linkValidation:error',
{error, url},
{statsig: true},
)
throw new Error(error)
}
const urlp = new URL(url)
if (!config.domains.includes(urlp.hostname)) {
throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
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')) {
const error = _(msg`Only Twitch links are supported during the beta`)
logger.metric(
'live:linkValidation:error',
{error, url},
{statsig: true},
)
throw new Error(error)
}
const error = _(msg`${urlp.hostname} is not a valid URL`)
logger.metric(
'live:linkValidation:error',
{error, url},
{statsig: true},
)
throw new Error(error)
}
return await getLinkMeta(agent, url)
+7 -9
View File
@@ -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
}
+1
View File
@@ -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'
+1
View File
@@ -556,6 +556,7 @@ export type MetricEvents = {
'live:card:openProfile': {subject: string}
'live:view:profile': {subject: string}
'live:view:post': {subject: string; feed?: string}
'live:linkValidation:error': {error: string; url?: string}
'share:open': {context: 'feed' | 'thread'}
'share:press:copyLink': {}
+32 -1
View File
@@ -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 = 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 = 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() {