···4343import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
4444import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
4545import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
4646+import {Provider as ServiceAccountManager} from '#/state/service-config'
4647import {
4748 Provider as SessionProvider,
4849 type SessionAccount,
···5758import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
5859import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
5960import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
6060-import {Provider as TrendingConfigProvider} from '#/state/trending-config'
6161import {TestCtrls} from '#/view/com/testing/TestCtrls'
6262import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
6363import * as Toast from '#/view/com/util/Toast'
···149149 <BackgroundNotificationPreferencesProvider>
150150 <MutedThreadsProvider>
151151 <ProgressGuideProvider>
152152- <TrendingConfigProvider>
152152+ <ServiceAccountManager>
153153 <GestureHandlerRootView
154154 style={s.h100pct}>
155155 <IntentDialogProvider>
···158158 <NuxDialogs />
159159 </IntentDialogProvider>
160160 </GestureHandlerRootView>
161161- </TrendingConfigProvider>
161161+ </ServiceAccountManager>
162162 </ProgressGuideProvider>
163163 </MutedThreadsProvider>
164164 </BackgroundNotificationPreferencesProvider>
+3-3
src/App.web.tsx
···3333import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
3434import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
3535import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
3636+import {Provider as ServiceConfigProvider} from '#/state/service-config'
3637import {
3738 Provider as SessionProvider,
3839 type SessionAccount,
···4748import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
4849import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
4950import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
5050-import {Provider as TrendingConfigProvider} from '#/state/trending-config'
5151import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
5252import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
5353import * as Toast from '#/view/com/util/Toast'
···130130 <MutedThreadsProvider>
131131 <SafeAreaProvider>
132132 <ProgressGuideProvider>
133133- <TrendingConfigProvider>
133133+ <ServiceConfigProvider>
134134 <IntentDialogProvider>
135135 <Shell />
136136 <NuxDialogs />
137137 </IntentDialogProvider>
138138- </TrendingConfigProvider>
138138+ </ServiceConfigProvider>
139139 </ProgressGuideProvider>
140140 </SafeAreaProvider>
141141 </MutedThreadsProvider>
+1-1
src/components/interstitials/Trending.tsx
···99 useTrendingSettingsApi,
1010} from '#/state/preferences/trending'
1111import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
1212-import {useTrendingConfig} from '#/state/trending-config'
1212+import {useTrendingConfig} from '#/state/service-config'
1313import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
1414import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
1515import {atoms as a, useGutters, useTheme} from '#/alf'
+18-3
src/components/live/GoLiveDialog.tsx
···1010import {toNiceDomain} from '#/lib/strings/url-helpers'
1111import {definitelyUrl} from '#/lib/strings/url-helpers'
1212import {useModerationOpts} from '#/state/preferences/moderation-opts'
1313-import {useAgent} from '#/state/session'
1313+import {useLiveNowConfig} from '#/state/service-config'
1414+import {useAgent, useSession} from '#/state/session'
1415import {useTickEveryMinute} from '#/state/shell'
1516import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
1617import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
···5859 const [duration, setDuration] = useState(60)
5960 const moderationOpts = useModerationOpts()
6061 const tick = useTickEveryMinute()
6262+ const liveNowConfig = useLiveNowConfig()
6363+ const {currentAccount} = useSession()
6464+6565+ const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
61666267 const time = useCallback(
6368 (offset: number) => {
···79848085 const liveLinkUrl = definitelyUrl(liveLink)
8186 const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
8282- const hasLink = !!debouncedUrl
83878488 const {
8589 data: linkMeta,
···9195 queryKey: ['link-meta', debouncedUrl],
9296 queryFn: async () => {
9397 if (!debouncedUrl) return null
9898+ if (!config) throw new Error(_(msg`You are not allowed to go live`))
9999+100100+ const urlp = new URL(debouncedUrl)
101101+ if (!config.domains.includes(urlp.hostname)) {
102102+ throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
103103+ }
104104+94105 return getLinkMeta(agent, debouncedUrl)
95106 },
96107 })
···100111 isPending: isGoingLive,
101112 error: goLiveError,
102113 } = useUpsertLiveStatusMutation(duration, linkMeta)
114114+115115+ const isSourceInvalid = !!liveLinkError || !!linkMetaError
116116+117117+ const hasLink = !!debouncedUrl && !isSourceInvalid
103118104119 return (
105120 <Dialog.ScrollableInner
···136151 <TextField.LabelText>
137152 <Trans>Live link</Trans>
138153 </TextField.LabelText>
139139- <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
154154+ <TextField.Root isInvalid={isSourceInvalid}>
140155 <TextField.Input
141156 label={_(msg`Live link`)}
142157 placeholder={_(msg`www.mylivestream.tv`)}
-41
src/components/live/temp.ts
···11-import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api'
22-33-import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
44-import type * as bsky from '#/types/bsky'
55-66-export const LIVE_DIDS: Record<string, true> = {
77- 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com
88- 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social
99-}
1010-1111-export const LIVE_SOURCES: Record<string, true> = {
1212- 'nba.com': true,
1313- 'twitch.tv': true,
1414-}
1515-1616-// TEMP: dumb gating
1717-export function temp__canBeLive(profile: bsky.profile.AnyProfileView) {
1818- if (__DEV__)
1919- return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did]
2020- return !!LIVE_DIDS[profile.did]
2121-}
2222-2323-export function temp__canGoLive(profile: bsky.profile.AnyProfileView) {
2424- if (__DEV__) return true
2525- return !!LIVE_DIDS[profile.did]
2626-}
2727-2828-// status must have a embed, and the embed must be an approved host for the status to be valid
2929-export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) {
3030- if (status.status !== 'app.bsky.actor.status#live') return false
3131- try {
3232- if (AppBskyEmbedExternal.isView(status.embed)) {
3333- const url = new URL(status.embed.external.uri)
3434- return !!LIVE_SOURCES[url.hostname]
3535- } else {
3636- return false
3737- }
3838- } catch {
3939- return false
4040- }
4141-}
+28-5
src/lib/actor-status.ts
···22import {
33 type $Typed,
44 type AppBskyActorDefs,
55- type AppBskyEmbedExternal,
55+ AppBskyEmbedExternal,
66} from '@atproto/api'
77import {isAfter, parseISO} from 'date-fns'
8899import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
1010+import {useLiveNowConfig} from '#/state/service-config'
1011import {useTickEveryMinute} from '#/state/shell'
1111-import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
1212import type * as bsky from '#/types/bsky'
13131414export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
1515 const shadowed = useMaybeProfileShadow(actor)
1616 const tick = useTickEveryMinute()
1717+ const config = useLiveNowConfig()
1818+1719 return useMemo(() => {
1820 tick! // revalidate every minute
19212022 if (
2123 shadowed &&
2222- temp__canBeLive(shadowed) &&
2324 'status' in shadowed &&
2425 shadowed.status &&
2525- temp__isStatusValid(shadowed.status) &&
2626+ validateStatus(shadowed.did, shadowed.status, config) &&
2627 isStatusStillActive(shadowed.status.expiresAt)
2728 ) {
2829 return {
···3940 record: {},
4041 } satisfies AppBskyActorDefs.StatusView
4142 }
4242- }, [shadowed, tick])
4343+ }, [shadowed, config, tick])
4344}
44454546export function isStatusStillActive(timeStr: string | undefined) {
···49505051 return isAfter(expiry, now)
5152}
5353+5454+export function validateStatus(
5555+ did: string,
5656+ status: AppBskyActorDefs.StatusView,
5757+ config: {did: string; domains: string[]}[],
5858+) {
5959+ if (status.status !== 'app.bsky.actor.status#live') return false
6060+ const sources = config.find(cfg => cfg.did === did)
6161+ if (!sources) {
6262+ return false
6363+ }
6464+ try {
6565+ if (AppBskyEmbedExternal.isView(status.embed)) {
6666+ const url = new URL(status.embed.external.uri)
6767+ return sources.domains.includes(url.hostname)
6868+ } else {
6969+ return false
7070+ }
7171+ } catch {
7272+ return false
7373+ }
7474+}
···88 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
99 useTrendingTopics,
1010} from '#/state/queries/trending/useTrendingTopics'
1111-import {useTrendingConfig} from '#/state/trending-config'
1111+import {useTrendingConfig} from '#/state/service-config'
1212import {atoms as a, useGutters, useTheme} from '#/alf'
1313import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
1414import {
···88import {useModerationOpts} from '#/state/preferences/moderation-opts'
99import {useTrendingSettings} from '#/state/preferences/trending'
1010import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
1111-import {useTrendingConfig} from '#/state/trending-config'
1111+import {useTrendingConfig} from '#/state/service-config'
1212import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
1313import {formatCount} from '#/view/com/util/numeric/format'
1414import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
+1-1
src/screens/Settings/ContentAndMediaSettings.tsx
···1414 useTrendingSettings,
1515 useTrendingSettingsApi,
1616} from '#/state/preferences/trending'
1717-import {useTrendingConfig} from '#/state/trending-config'
1717+import {useTrendingConfig} from '#/state/service-config'
1818import * as SettingsList from '#/screens/Settings/components/SettingsList'
1919import * as Toggle from '#/components/forms/Toggle'
2020import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
···11+import {createContext, useContext, useMemo} from 'react'
22+33+import {useLanguagePrefs} from '#/state/preferences/languages'
44+import {useServiceConfigQuery} from '#/state/queries/service-config'
55+import {device} from '#/storage'
66+77+type TrendingContext = {
88+ enabled: boolean
99+}
1010+1111+type LiveNowContext = {
1212+ did: string
1313+ domains: string[]
1414+}[]
1515+1616+const TrendingContext = createContext<TrendingContext>({
1717+ enabled: false,
1818+})
1919+2020+const LiveNowContext = createContext<LiveNowContext | null>(null)
2121+2222+export function Provider({children}: {children: React.ReactNode}) {
2323+ const langPrefs = useLanguagePrefs()
2424+ const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
2525+ const trending = useMemo<TrendingContext>(() => {
2626+ if (__DEV__) {
2727+ return {enabled: true}
2828+ }
2929+3030+ /*
3131+ * Only English during beta period
3232+ */
3333+ if (
3434+ !!langPrefs.contentLanguages.length &&
3535+ !langPrefs.contentLanguages.includes('en')
3636+ ) {
3737+ return {enabled: false}
3838+ }
3939+4040+ /*
4141+ * While loading, use cached value
4242+ */
4343+ const cachedEnabled = device.get(['trendingBetaEnabled'])
4444+ if (isInitialLoad) {
4545+ return {enabled: Boolean(cachedEnabled)}
4646+ }
4747+4848+ /*
4949+ * Doing an extra check here to reduce hits to statsig. If it's disabled on
5050+ * the server, we can exit early.
5151+ */
5252+ const enabled = Boolean(config?.topicsEnabled)
5353+5454+ // update cache
5555+ device.set(['trendingBetaEnabled'], enabled)
5656+5757+ return {enabled}
5858+ }, [isInitialLoad, config, langPrefs.contentLanguages])
5959+6060+ const liveNow = useMemo<LiveNowContext>(() => config?.liveNow ?? [], [config])
6161+6262+ return (
6363+ <TrendingContext.Provider value={trending}>
6464+ <LiveNowContext.Provider value={liveNow}>
6565+ {children}
6666+ </LiveNowContext.Provider>
6767+ </TrendingContext.Provider>
6868+ )
6969+}
7070+7171+export function useTrendingConfig() {
7272+ return useContext(TrendingContext)
7373+}
7474+7575+export function useLiveNowConfig() {
7676+ const ctx = useContext(LiveNowContext)
7777+ if (!ctx) {
7878+ throw new Error(
7979+ 'useLiveNowConfig must be used within a LiveNowConfigProvider',
8080+ )
8181+ }
8282+ return ctx
8383+}
8484+8585+export function useCanGoLive(did?: string) {
8686+ const config = useLiveNowConfig()
8787+ return !!config.find(cfg => cfg.did === did)
8888+}
-57
src/state/trending-config.tsx
···11-import React from 'react'
22-33-import {useLanguagePrefs} from '#/state/preferences/languages'
44-import {useServiceConfigQuery} from '#/state/queries/service-config'
55-import {device} from '#/storage'
66-77-type Context = {
88- enabled: boolean
99-}
1010-1111-const Context = React.createContext<Context>({
1212- enabled: false,
1313-})
1414-1515-export function Provider({children}: React.PropsWithChildren<{}>) {
1616- const langPrefs = useLanguagePrefs()
1717- const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
1818- const ctx = React.useMemo<Context>(() => {
1919- if (__DEV__) {
2020- return {enabled: true}
2121- }
2222-2323- /*
2424- * Only English during beta period
2525- */
2626- if (
2727- !!langPrefs.contentLanguages.length &&
2828- !langPrefs.contentLanguages.includes('en')
2929- ) {
3030- return {enabled: false}
3131- }
3232-3333- /*
3434- * While loading, use cached value
3535- */
3636- const cachedEnabled = device.get(['trendingBetaEnabled'])
3737- if (isInitialLoad) {
3838- return {enabled: Boolean(cachedEnabled)}
3939- }
4040-4141- /*
4242- * Doing an extra check here to reduce hits to statsig. If it's disabled on
4343- * the server, we can exit early.
4444- */
4545- const enabled = Boolean(config?.topicsEnabled)
4646-4747- // update cache
4848- device.set(['trendingBetaEnabled'], enabled)
4949-5050- return {enabled}
5151- }, [isInitialLoad, config, langPrefs.contentLanguages])
5252- return <Context.Provider value={ctx}>{children}</Context.Provider>
5353-}
5454-5555-export function useTrendingConfig() {
5656- return React.useContext(Context)
5757-}
+7-5
src/view/com/posts/PostFeed.tsx
···1919import {useLingui} from '@lingui/react'
2020import {useQueryClient} from '@tanstack/react-query'
21212222-import {isStatusStillActive} from '#/lib/actor-status'
2222+import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
2323import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
2424import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
2525import {logEvent} from '#/lib/statsig/statsig'
···3939 RQKEY,
4040 usePostFeedQuery,
4141} from '#/state/queries/post-feed'
4242+import {useLiveNowConfig} from '#/state/service-config'
4243import {useSession} from '#/state/session'
4344import {useProgressGuide} from '#/state/shell/progress-guide'
4445import {List, type ListRef} from '#/view/com/util/List'
···5354} from '#/components/feeds/PostFeedVideoGridRow'
5455import {TrendingInterstitial} from '#/components/interstitials/Trending'
5556import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
5656-import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
5757import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
5858import {FeedShutdownMsg} from './FeedShutdownMsg'
5959import {PostFeedErrorMessage} from './PostFeedErrorMessage'
···777777 )
778778 }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
779779780780+ const liveNowConfig = useLiveNowConfig()
781781+780782 const seenActorWithStatusRef = useRef<Set<string>>(new Set())
781783 const onItemSeen = useCallback(
782784 (item: FeedRow) => {
783785 feedFeedback.onItemSeen(item)
784786 if (item.type === 'sliceItem') {
785787 const actor = item.slice.items[item.indexInSlice].post.author
788788+786789 if (
787790 actor.status &&
788788- temp__canBeLive(actor) &&
789789- temp__isStatusValid(actor.status) &&
791791+ validateStatus(actor.did, actor.status, liveNowConfig) &&
790792 isStatusStillActive(actor.status.expiresAt)
791793 ) {
792794 if (!seenActorWithStatusRef.current.has(actor.did)) {
···799801 }
800802 }
801803 },
802802- [feedFeedback, feed],
804804+ [feedFeedback, feed, liveNowConfig],
803805 )
804806805807 return (
+3-2
src/view/com/profile/ProfileMenu.tsx
···2020 useProfileFollowMutationQueue,
2121 useProfileMuteMutationQueue,
2222} from '#/state/queries/profile'
2323+import {useCanGoLive} from '#/state/service-config'
2324import {useSession} from '#/state/session'
2425import {EventStopper} from '#/view/com/util/EventStopper'
2526import * as Toast from '#/view/com/util/Toast'
···4344import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
4445import {EditLiveDialog} from '#/components/live/EditLiveDialog'
4546import {GoLiveDialog} from '#/components/live/GoLiveDialog'
4646-import {temp__canGoLive} from '#/components/live/temp'
4747import * as Menu from '#/components/Menu'
4848import {
4949 ReportDialog,
···7373 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
7474 const [devModeEnabled] = useDevMode()
7575 const verification = useFullVerificationState({profile})
7676+ const canGoLive = useCanGoLive(currentAccount?.did)
76777778 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
7879 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
···299300 </Menu.ItemText>
300301 <Menu.ItemIcon icon={List} />
301302 </Menu.Item>
302302- {isSelf && temp__canGoLive(profile) && (
303303+ {isSelf && canGoLive && (
303304 <Menu.Item
304305 testID="profileHeaderDropdownListAddRemoveBtn"
305306 label={
+1-1
src/view/shell/desktop/SidebarTrendingTopics.tsx
···99 useTrendingSettingsApi,
1010} from '#/state/preferences/trending'
1111import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
1212-import {useTrendingConfig} from '#/state/trending-config'
1212+import {useTrendingConfig} from '#/state/service-config'
1313import {atoms as a, useTheme} from '#/alf'
1414import {Button, ButtonIcon} from '#/components/Button'
1515import {Divider} from '#/components/Divider'