diff --git a/package.json b/package.json
index f50651a878..62762bfc73 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.15.6",
+ "@atproto/api": "^0.15.7",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -219,7 +219,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
- "@atproto/dev-env": "^0.3.129",
+ "@atproto/dev-env": "^0.3.131",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index ea50fdfb9c..baab8c8385 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -43,6 +43,7 @@ import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
+import {Provider as ServiceAccountManager} from '#/state/service-config'
import {
Provider as SessionProvider,
type SessionAccount,
@@ -57,7 +58,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
-import {Provider as TrendingConfigProvider} from '#/state/trending-config'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
@@ -149,7 +149,7 @@ function InnerApp() {
-
+
@@ -158,7 +158,7 @@ function InnerApp() {
-
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index bbe23e5a56..c5ec0473ce 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -33,6 +33,7 @@ import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
+import {Provider as ServiceConfigProvider} from '#/state/service-config'
import {
Provider as SessionProvider,
type SessionAccount,
@@ -47,7 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
-import {Provider as TrendingConfigProvider} from '#/state/trending-config'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
@@ -130,12 +130,12 @@ function InnerApp() {
-
+
-
+
diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx
index 56c756c50b..5561be18e0 100644
--- a/src/components/interstitials/Trending.tsx
+++ b/src/components/interstitials/Trending.tsx
@@ -9,7 +9,7 @@ import {
useTrendingSettingsApi,
} from '#/state/preferences/trending'
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
-import {useTrendingConfig} from '#/state/trending-config'
+import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf'
diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx
index 2fad009fdb..0274472720 100644
--- a/src/components/live/GoLiveDialog.tsx
+++ b/src/components/live/GoLiveDialog.tsx
@@ -10,7 +10,8 @@ import {cleanError} from '#/lib/strings/errors'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useAgent} from '#/state/session'
+import {useLiveNowConfig} from '#/state/service-config'
+import {useAgent, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
@@ -58,6 +59,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const [duration, setDuration] = useState(60)
const moderationOpts = useModerationOpts()
const tick = useTickEveryMinute()
+ const liveNowConfig = useLiveNowConfig()
+ const {currentAccount} = useSession()
+
+ const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
const time = useCallback(
(offset: number) => {
@@ -79,7 +84,6 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const liveLinkUrl = definitelyUrl(liveLink)
const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
- const hasLink = !!debouncedUrl
const {
data: linkMeta,
@@ -91,6 +95,13 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
queryKey: ['link-meta', debouncedUrl],
queryFn: async () => {
if (!debouncedUrl) return null
+ if (!config) throw new Error(_(msg`You are not allowed to go live`))
+
+ const urlp = new URL(debouncedUrl)
+ if (!config.domains.includes(urlp.hostname)) {
+ throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
+ }
+
return getLinkMeta(agent, debouncedUrl)
},
})
@@ -101,6 +112,10 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
error: goLiveError,
} = useUpsertLiveStatusMutation(duration, linkMeta)
+ const isSourceInvalid = !!liveLinkError || !!linkMetaError
+
+ const hasLink = !!debouncedUrl && !isSourceInvalid
+
return (
Live link
-
+
= {
- 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com
- 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social
-}
-
-export const LIVE_SOURCES: Record = {
- 'nba.com': true,
- 'twitch.tv': true,
-}
-
-// TEMP: dumb gating
-export function temp__canBeLive(profile: bsky.profile.AnyProfileView) {
- if (__DEV__)
- return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did]
- return !!LIVE_DIDS[profile.did]
-}
-
-export function temp__canGoLive(profile: bsky.profile.AnyProfileView) {
- if (__DEV__) return true
- return !!LIVE_DIDS[profile.did]
-}
-
-// status must have a embed, and the embed must be an approved host for the status to be valid
-export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) {
- if (status.status !== 'app.bsky.actor.status#live') return false
- try {
- if (AppBskyEmbedExternal.isView(status.embed)) {
- const url = new URL(status.embed.external.uri)
- return !!LIVE_SOURCES[url.hostname]
- } else {
- return false
- }
- } catch {
- return false
- }
-}
diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts
index 30921a88a3..7e023be44e 100644
--- a/src/lib/actor-status.ts
+++ b/src/lib/actor-status.ts
@@ -2,27 +2,28 @@ import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
- type AppBskyEmbedExternal,
+ AppBskyEmbedExternal,
} from '@atproto/api'
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
+import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
-import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
import type * as bsky from '#/types/bsky'
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
+ const config = useLiveNowConfig()
+
return useMemo(() => {
tick! // revalidate every minute
if (
shadowed &&
- temp__canBeLive(shadowed) &&
'status' in shadowed &&
shadowed.status &&
- temp__isStatusValid(shadowed.status) &&
+ validateStatus(shadowed.did, shadowed.status, config) &&
isStatusStillActive(shadowed.status.expiresAt)
) {
return {
@@ -39,7 +40,7 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
record: {},
} satisfies AppBskyActorDefs.StatusView
}
- }, [shadowed, tick])
+ }, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
@@ -49,3 +50,25 @@ export function isStatusStillActive(timeStr: string | undefined) {
return isAfter(expiry, now)
}
+
+export function validateStatus(
+ did: string,
+ status: AppBskyActorDefs.StatusView,
+ config: {did: string; domains: string[]}[],
+) {
+ if (status.status !== 'app.bsky.actor.status#live') return false
+ const sources = config.find(cfg => cfg.did === did)
+ if (!sources) {
+ return false
+ }
+ try {
+ if (AppBskyEmbedExternal.isView(status.embed)) {
+ const url = new URL(status.embed.external.uri)
+ return sources.domains.includes(url.hostname)
+ } else {
+ return false
+ }
+ } catch {
+ return false
+ }
+}
diff --git a/src/screens/Search/modules/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx
index 4cf84269ae..de70240b18 100644
--- a/src/screens/Search/modules/ExploreRecommendations.tsx
+++ b/src/screens/Search/modules/ExploreRecommendations.tsx
@@ -8,7 +8,7 @@ import {
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
useTrendingTopics,
} from '#/state/queries/trending/useTrendingTopics'
-import {useTrendingConfig} from '#/state/trending-config'
+import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
import {
diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx
index 1d3bc2d86b..ee541e3850 100644
--- a/src/screens/Search/modules/ExploreTrendingTopics.tsx
+++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx
@@ -8,7 +8,7 @@ import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
-import {useTrendingConfig} from '#/state/trending-config'
+import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index 6fa90b1e2a..10d5b140bb 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -14,7 +14,7 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
-import {useTrendingConfig} from '#/state/trending-config'
+import {useTrendingConfig} from '#/state/service-config'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import * as Toggle from '#/components/forms/Toggle'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts
index 12d2cc6bea..890a49a5cf 100644
--- a/src/state/queries/service-config.ts
+++ b/src/state/queries/service-config.ts
@@ -6,6 +6,10 @@ import {useAgent} from '#/state/session'
type ServiceConfig = {
checkEmailConfirmed: boolean
topicsEnabled: boolean
+ liveNow: {
+ did: string
+ domains: string[]
+ }[]
}
export function useServiceConfigQuery() {
@@ -21,11 +25,13 @@ export function useServiceConfigQuery() {
checkEmailConfirmed: Boolean(data.checkEmailConfirmed),
// @ts-expect-error not included in types atm
topicsEnabled: Boolean(data.topicsEnabled),
+ liveNow: data.liveNow ?? [],
}
} catch (e) {
return {
checkEmailConfirmed: false,
topicsEnabled: false,
+ liveNow: [],
}
}
},
diff --git a/src/state/trending-config.tsx b/src/state/service-config.tsx
similarity index 52%
rename from src/state/trending-config.tsx
rename to src/state/service-config.tsx
index 1e5db9dc97..37d5685bd4 100644
--- a/src/state/trending-config.tsx
+++ b/src/state/service-config.tsx
@@ -1,21 +1,28 @@
-import React from 'react'
+import {createContext, useContext, useMemo} from 'react'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useServiceConfigQuery} from '#/state/queries/service-config'
import {device} from '#/storage'
-type Context = {
+type TrendingContext = {
enabled: boolean
}
-const Context = React.createContext({
+type LiveNowContext = {
+ did: string
+ domains: string[]
+}[]
+
+const TrendingContext = createContext({
enabled: false,
})
-export function Provider({children}: React.PropsWithChildren<{}>) {
+const LiveNowContext = createContext(null)
+
+export function Provider({children}: {children: React.ReactNode}) {
const langPrefs = useLanguagePrefs()
const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
- const ctx = React.useMemo(() => {
+ const trending = useMemo(() => {
if (__DEV__) {
return {enabled: true}
}
@@ -49,9 +56,33 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return {enabled}
}, [isInitialLoad, config, langPrefs.contentLanguages])
- return {children}
+
+ const liveNow = useMemo(() => config?.liveNow ?? [], [config])
+
+ return (
+
+
+ {children}
+
+
+ )
}
export function useTrendingConfig() {
- return React.useContext(Context)
+ return useContext(TrendingContext)
+}
+
+export function useLiveNowConfig() {
+ const ctx = useContext(LiveNowContext)
+ if (!ctx) {
+ throw new Error(
+ 'useLiveNowConfig must be used within a LiveNowConfigProvider',
+ )
+ }
+ return ctx
+}
+
+export function useCanGoLive(did?: string) {
+ const config = useLiveNowConfig()
+ return !!config.find(cfg => cfg.did === did)
}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index b4c2b27102..732d0fcab5 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -19,7 +19,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {isStatusStillActive} from '#/lib/actor-status'
+import {isStatusStillActive, validateStatus} from '#/lib/actor-status'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {logEvent} from '#/lib/statsig/statsig'
@@ -39,6 +39,7 @@ import {
RQKEY,
usePostFeedQuery,
} from '#/state/queries/post-feed'
+import {useLiveNowConfig} from '#/state/service-config'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import {List, type ListRef} from '#/view/com/util/List'
@@ -53,7 +54,6 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
-import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
@@ -777,16 +777,18 @@ let PostFeed = ({
)
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
+ const liveNowConfig = useLiveNowConfig()
+
const seenActorWithStatusRef = useRef>(new Set())
const onItemSeen = useCallback(
(item: FeedRow) => {
feedFeedback.onItemSeen(item)
if (item.type === 'sliceItem') {
const actor = item.slice.items[item.indexInSlice].post.author
+
if (
actor.status &&
- temp__canBeLive(actor) &&
- temp__isStatusValid(actor.status) &&
+ validateStatus(actor.did, actor.status, liveNowConfig) &&
isStatusStillActive(actor.status.expiresAt)
) {
if (!seenActorWithStatusRef.current.has(actor.did)) {
@@ -799,7 +801,7 @@ let PostFeed = ({
}
}
},
- [feedFeedback, feed],
+ [feedFeedback, feed, liveNowConfig],
)
return (
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index 1c2a7d62df..f1fd237ec2 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -20,6 +20,7 @@ import {
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
} from '#/state/queries/profile'
+import {useCanGoLive} from '#/state/service-config'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
@@ -43,7 +44,6 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {GoLiveDialog} from '#/components/live/GoLiveDialog'
-import {temp__canGoLive} from '#/components/live/temp'
import * as Menu from '#/components/Menu'
import {
ReportDialog,
@@ -73,6 +73,7 @@ let ProfileMenu = ({
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevMode()
const verification = useFullVerificationState({profile})
+ const canGoLive = useCanGoLive(currentAccount?.did)
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -299,7 +300,7 @@ let ProfileMenu = ({
- {isSelf && temp__canGoLive(profile) && (
+ {isSelf && canGoLive && (