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