[APP-1776] Refactor Live Now config, reorg files (#9871)

This commit is contained in:
Eric Bailey
2026-02-16 11:44:22 -06:00
committed by GitHub
parent 8ee9709fbb
commit cc2255cc49
34 changed files with 414 additions and 285 deletions
+3
View File
@@ -46,3 +46,6 @@ GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# app-config web worker URL
APP_CONFIG_DEV_URL=
+39 -32
View File
@@ -22,6 +22,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -103,6 +107,7 @@ if (IS_ANDROID) {
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -228,38 +233,40 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+32 -25
View File
@@ -13,6 +13,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -79,6 +83,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = useState(false)
@@ -202,31 +207,33 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+1 -1
View File
@@ -4,7 +4,6 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -19,6 +18,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useActorStatus} from '#/features/liveNow'
export function AccountList({
onSelectAccount,
+1 -1
View File
@@ -14,7 +14,6 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -47,6 +46,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
export function Default({
@@ -10,7 +10,6 @@ import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -34,7 +33,6 @@ import {
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {LiveStatus} from '#/components/live/LiveStatusDialog'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
@@ -43,6 +41,8 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB_TOUCH_DEVICE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
+10
View File
@@ -141,3 +141,13 @@ export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
export const LIVE_EVENTS_URL = IS_DEV
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
: LIVE_EVENTS_PROD_URL
/**
* URLs for the app-config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const APP_CONFIG_DEV_URL = process.env.APP_CONFIG_DEV_URL
export const APP_CONFIG_PROD_URL = `https://app-config.workers.bsky.app`
export const APP_CONFIG_URL = IS_DEV
? (APP_CONFIG_DEV_URL ?? APP_CONFIG_PROD_URL)
: APP_CONFIG_PROD_URL
@@ -9,6 +9,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInMinutes} from 'date-fns'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useTickEveryMinute} from '#/state/shell'
@@ -20,13 +21,13 @@ import * as TextField from '#/components/forms/TextField'
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
import {
displayDuration,
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
} from './queries'
import {displayDuration, useDebouncedValue} from './utils'
} from '#/features/liveNow'
import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
export function EditLiveDialog({
control,
@@ -3,28 +3,29 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {
displayDuration,
getLiveServiceNames,
useDebouncedValue,
} from '#/components/live/utils'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {
displayDuration,
getLiveServiceNames,
useLiveLinkMetaQuery,
useLiveNowConfig,
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
import {LinkPreview} from './LinkPreview'
import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
export function GoLiveDialog({
control,
@@ -17,15 +17,15 @@ 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import type * as bsky from '#/types/bsky'
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
import {LiveIndicator} from './LiveIndicator'
export function LiveStatusDialog({
control,
@@ -1,24 +1,166 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus,
type AppBskyEmbedExternal,
AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {useAppConfig} from '#/state/appConfig'
import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useAgent, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils'
import {useAnalytics} from '#/analytics'
import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
import type * as bsky from '#/types/bsky'
export * from '#/features/liveNow/utils'
export const DEFAULT_ALLOWED_DOMAINS = [
'twitch.tv',
'stream.place',
'bluecast.app',
]
export type LiveNowConfig = {
canGoLive: boolean
currentAccountAllowedHosts: Set<string>
defaultAllowedHosts: Set<string>
allowedHostsExceptionsByDid: Map<string, Set<string>>
}
export function useLiveNowConfig(): LiveNowConfig {
const ax = useAnalytics()
const {liveNow} = useAppConfig()
const {currentAccount} = useSession()
return useMemo(() => {
const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable)
const defaultAllowedHosts = new Set(
DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow),
)
const allowedHostsExceptionsByDid = new Map<string, Set<string>>()
for (const ex of liveNow.exceptions) {
allowedHostsExceptionsByDid.set(
ex.did,
new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)),
)
}
if (!currentAccount?.did || disabled) {
return {
canGoLive: false,
currentAccountAllowedHosts: new Set(),
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}
return {
canGoLive: true,
currentAccountAllowedHosts:
allowedHostsExceptionsByDid.get(currentAccount.did) ??
defaultAllowedHosts,
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}, [ax, liveNow, currentAccount])
}
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
const config = useLiveNowConfig()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const host = getLiveNowHost(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(host) : false
const isValidForAnyone = config.defaultAllowedHosts.has(host)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig()
@@ -30,8 +172,8 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url],
queryFn: async () => {
if (!url) return undefined
const urlp = new URL(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) {
const host = getLiveNowHost(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(host)) {
const {formatted} = getLiveServiceNames(
liveNowConfig.currentAccountAllowedHosts,
)
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/macro'
import psl from 'psl'
export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes)
@@ -24,41 +24,49 @@ export function displayDuration(i18n: I18n, durationInMinutes: number) {
: minutesString
}
// Trailing debounce
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
const serviceUrlToNameMap: Record<string, string> = {
'twitch.tv': 'Twitch',
'www.twitch.tv': 'Twitch',
'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'youtu.be': 'YouTube',
'nba.com': 'NBA',
'www.nba.com': 'NBA',
'nba.smart.link': 'nba.smart.link',
'espn.com': 'ESPN',
'www.espn.com': 'ESPN',
'stream.place': 'Streamplace',
'skylight.social': 'Skylight',
'bluecast.app': 'Bluecast',
'www.bluecast.app': 'Bluecast',
}
export function getLiveServiceNames(domains: Set<string>) {
const names = Array.from(
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
new Set(
Array.from(domains.values())
.map(d => sanitizeLiveNowHost(d))
.map(d => serviceUrlToNameMap[d] || d),
),
)
return {
names,
formatted: names.join(', '),
}
}
export function sanitizeLiveNowHost(hostname: string) {
// special case this one
if (hostname === 'nba.smart.link') {
return hostname
}
const parsed = psl.parse(hostname)
if (parsed.error || !parsed.listed || !parsed.domain) {
// fall back to dumb version
return hostname.replace(/^www\./, '')
}
return parsed.domain
}
/**
* Extracts the apex domain from a given URL, for use when matching allowed
* Live Now hosts.
*/
export function getLiveNowHost(url: string) {
const {hostname} = new URL(url)
return sanitizeLiveNowHost(hostname)
}
-92
View File
@@ -1,92 +0,0 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
AppBskyEmbedExternal,
AtUri,
} from '@atproto/api'
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {type LiveNowConfig, useLiveNowConfig} 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()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(url.hostname) : false
const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
+2 -1
View File
@@ -29,6 +29,7 @@ export async function retry<P>(
export async function networkRetry<P>(
retries: number,
fn: () => Promise<P>,
delay?: number,
): Promise<P> {
return retry(retries, isNetworkError, fn)
return retry(retries, isNetworkError, fn, delay)
}
+16
View File
@@ -0,0 +1,16 @@
import {useEffect, useState} from 'react'
/**
* Returns a debounced version of the input value that only updates after the
* specified delay has passed without any changes to the input value.
*/
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
@@ -10,7 +10,6 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links'
@@ -60,6 +59,7 @@ import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
@@ -8,7 +8,6 @@ import {
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
@@ -44,6 +43,7 @@ import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}>
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useHaptics} from '#/lib/haptics'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -39,6 +38,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
+4 -4
View File
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types'
@@ -28,13 +27,14 @@ import {atoms as a, platform, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
import {StatusBarShadow} from './StatusBarShadow'
+1 -1
View File
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useActorStatus} from '#/lib/actor-status'
import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
@@ -70,6 +69,7 @@ import {
} from '#/components/verification/VerificationCheckButton'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
+87
View File
@@ -0,0 +1,87 @@
import {createContext, useContext} from 'react'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {APP_CONFIG_URL} from '#/env'
const qc = new QueryClient()
const appConfigQueryKey = ['app-config']
/**
* Matches the types defined in our `app-config` worker
*/
type AppConfigResponse = {
liveNow: {
allow: string[]
exceptions: {
did: string
allow: string[]
}[]
}
}
export const DEFAULT_APP_CONFIG_RESPONSE: AppConfigResponse = {
liveNow: {
allow: [],
exceptions: [],
},
}
let fetchAppConfigPromise: Promise<AppConfigResponse> | undefined
async function fetchAppConfig(): Promise<AppConfigResponse | null> {
try {
if (!fetchAppConfigPromise) {
fetchAppConfigPromise = (async () => {
const r = await fetch(`${APP_CONFIG_URL}/config`)
if (!r.ok) throw new Error(await r.text())
const data = await r.json()
return data
})()
}
return await fetchAppConfigPromise
} catch (e) {
fetchAppConfigPromise = undefined
throw e
}
}
const Context = createContext<AppConfigResponse>(DEFAULT_APP_CONFIG_RESPONSE)
export function Provider({children}: React.PropsWithChildren<{}>) {
const {data} = useQuery<AppConfigResponse | null>(
{
staleTime: Infinity,
queryKey: appConfigQueryKey,
refetchInterval: query => {
// refetch regularly if fetch failed, otherwise never refetch
return query.state.status === 'error' ? 60e3 : Infinity
},
async queryFn() {
return fetchAppConfig()
},
},
qc,
)
return (
<Context.Provider value={data ?? DEFAULT_APP_CONFIG_RESPONSE}>
{children}
</Context.Provider>
)
}
export async function prefetchAppConfig() {
try {
const data = await fetchAppConfig()
if (data) {
qc.setQueryData(appConfigQueryKey, data)
}
} catch {}
}
export function useAppConfig() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useAppConfig must be used within a Provider')
}
return ctx
}
+1 -1
View File
@@ -6,8 +6,8 @@ import {
BSKY_SERVICE_DID,
PUBLIC_BSKY_SERVICE,
} from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {createFullHandle} from '#/lib/strings/handles'
import {useDebouncedValue} from '#/components/live/utils'
import {useAnalytics} from '#/analytics'
import * as bsky from '#/types/bsky'
import {Agent} from '../session/agent'
+3 -67
View File
@@ -2,28 +2,17 @@ import {createContext, useContext, useMemo} from 'react'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useServiceConfigQuery} from '#/state/queries/service-config'
import {useSession} from '#/state/session'
import {useAnalytics} from '#/analytics'
import {IS_DEV} from '#/env'
import {device} from '#/storage'
type TrendingContext = {
enabled: boolean
}
type LiveNowContext = {
did: string
domains: string[]
}[]
const TrendingContext = createContext<TrendingContext>({
enabled: false,
})
TrendingContext.displayName = 'TrendingContext'
const LiveNowContext = createContext<LiveNowContext>([])
LiveNowContext.displayName = 'LiveNowContext'
const CheckEmailConfirmedContext = createContext<boolean | null>(null)
export function Provider({children}: {children: React.ReactNode}) {
@@ -60,19 +49,15 @@ export function Provider({children}: {children: React.ReactNode}) {
return {enabled}
}, [isInitialLoad, config, langPrefs.contentLanguages])
const liveNow = useMemo<LiveNowContext>(() => config?.liveNow ?? [], [config])
// probably true, so default to true when loading
// if the call fails, the query will set it to false for us
const checkEmailConfirmed = config?.checkEmailConfirmed ?? true
return (
<TrendingContext.Provider value={trending}>
<LiveNowContext.Provider value={liveNow}>
<CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}>
{children}
</CheckEmailConfirmedContext.Provider>
</LiveNowContext.Provider>
<CheckEmailConfirmedContext.Provider value={checkEmailConfirmed}>
{children}
</CheckEmailConfirmedContext.Provider>
</TrendingContext.Provider>
)
}
@@ -81,55 +66,6 @@ export function useTrendingConfig() {
return useContext(TrendingContext)
}
const DEFAULT_LIVE_ALLOWED_DOMAINS = [
'twitch.tv',
'www.twitch.tv',
'stream.place',
'bluecast.app',
'www.bluecast.app',
]
export type LiveNowConfig = {
currentAccountAllowedHosts: Set<string>
defaultAllowedHosts: Set<string>
allowedHostsExceptionsByDid: Map<string, Set<string>>
}
export function useLiveNowConfig(): LiveNowConfig {
const ctx = useContext(LiveNowContext)
const canGoLive = useCanGoLive()
const {currentAccount} = useSession()
return useMemo(() => {
const defaultAllowedHosts = new Set(DEFAULT_LIVE_ALLOWED_DOMAINS)
const allowedHostsExceptionsByDid = new Map<string, Set<string>>()
for (const live of ctx) {
allowedHostsExceptionsByDid.set(
live.did,
new Set(DEFAULT_LIVE_ALLOWED_DOMAINS.concat(live.domains)),
)
}
if (!currentAccount?.did || !canGoLive)
return {
currentAccountAllowedHosts: new Set(),
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
const vip = ctx.find(live => live.did === currentAccount.did)
return {
currentAccountAllowedHosts: new Set(
DEFAULT_LIVE_ALLOWED_DOMAINS.concat(vip ? vip.domains : []),
),
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}, [ctx, currentAccount, canGoLive])
}
export function useCanGoLive() {
const ax = useAnalytics()
const {hasSession} = useSession()
if (!hasSession) return false
return IS_DEV ? true : !ax.features.enabled(ax.features.LiveNowBetaDisable)
}
export function useCheckEmailConfirmed() {
const ctx = useContext(CheckEmailConfirmedContext)
if (ctx === null) {
+5 -2
View File
@@ -19,7 +19,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {isStatusStillActive, isStatusValidForViewers} from '#/lib/actor-status'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -40,7 +39,6 @@ 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 {useSelectedFeed} from '#/state/shell/selected-feed'
@@ -63,6 +61,11 @@ import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interst
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
import {
isStatusStillActive,
isStatusValidForViewers,
useLiveNowConfig,
} from '#/features/liveNow'
import {ComposerPrompt} from '../feeds/ComposerPrompt'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FeedShutdownMsg} from './FeedShutdownMsg'
+1 -1
View File
@@ -11,7 +11,6 @@ import {
} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {type ReasonFeedSource} from '#/lib/api/feed/types'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
@@ -48,6 +47,7 @@ import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky'
import {PostFeedReason} from './PostFeedReason'
+5 -6
View File
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {HITSLOP_20} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -20,7 +19,6 @@ 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'
@@ -47,9 +45,6 @@ import {
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {StarterPack} from '#/components/icons/StarterPack'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {GoLiveDialog} from '#/components/live/GoLiveDialog'
import {GoLiveDisabledDialog} from '#/components/live/GoLiveDisabledDialog'
import * as Menu from '#/components/Menu'
import {
ReportDialog,
@@ -61,6 +56,10 @@ import {VerificationCreatePrompt} from '#/components/verification/VerificationCr
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {useActorStatus, useLiveNowConfig} from '#/features/liveNow'
import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog'
import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog'
import {Dot} from '#/features/nuxs/components/Dot'
import {Gradient} from '#/features/nuxs/components/Gradient'
import {useDevMode} from '#/storage/hooks/dev-mode'
@@ -85,7 +84,7 @@ let ProfileMenu = ({
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const [devModeEnabled] = useDevMode()
const verification = useFullVerificationState({profile})
const canGoLive = useCanGoLive()
const {canGoLive} = useLiveNowConfig()
const status = useActorStatus(profile)
const statusNudge = useNux(Nux.LiveNowBetaNudge)
const statusNudgeActive =
+1 -1
View File
@@ -5,7 +5,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {makeProfileLink} from '#/lib/routes/links'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -21,6 +20,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_ANDROID} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {TimeElapsed} from './TimeElapsed'
import {PreviewableUserAvatar} from './UserAvatar'
+3 -3
View File
@@ -15,7 +15,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useActorStatus} from '#/lib/actor-status'
import {useHaptics} from '#/lib/haptics'
import {
useCameraPermission,
@@ -47,13 +46,14 @@ import {
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Link} from '#/components/Link'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Menu from '#/components/Menu'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog'
import type * as bsky from '#/types/bsky'
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
+1 -1
View File
@@ -5,7 +5,6 @@ import {msg, Plural, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
import {type PressableScale} from '#/lib/custom-animations/PressableScale'
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
@@ -57,6 +56,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB} from '#/env'
import {useActorStatus} from '#/features/liveNow'
const iconWidth = 26
+1 -1
View File
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
import {StackActions} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {BOTTOM_BAR_AVI} from '#/lib/demo'
import {useHaptics} from '#/lib/haptics'
@@ -49,6 +48,7 @@ import {
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
} from '#/components/icons/Message'
import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
import {useDemoMode} from '#/storage/hooks/demo-mode'
import {styles} from './BottomBarStyles'
+1 -1
View File
@@ -5,7 +5,6 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
@@ -74,6 +73,7 @@ import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {router} from '../../../routes'