Make Live Now beta generally available
This commit is contained in:
@@ -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('')}
|
||||
|
||||
@@ -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('')}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
Reference in New Issue
Block a user