) {
+ const names = Array.from(
+ 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)
+}
diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts
--- a/src/lib/async/retry.ts
+++ b/src/lib/async/retry.ts
@@ -29,6 +29,7 @@
export async function networkRetry(
retries: number,
fn: () => Promise
,
+ delay?: number,
): Promise
{
- return retry(retries, isNetworkError, fn)
+ return retry(retries, isNetworkError, fn, delay)
}
diff --git a/src/lib/hooks/useDebouncedValue.ts b/src/lib/hooks/useDebouncedValue.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/hooks/useDebouncedValue.ts
@@ -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(val: T, delayMs: number): T {
+ const [prev, setPrev] = useState(val)
+
+ useEffect(() => {
+ const timeout = setTimeout(() => setPrev(val), delayMs)
+ return () => clearTimeout(timeout)
+ }, [val, delayMs])
+
+ return prev
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -7,7 +7,6 @@
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 @@
} 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'
diff --git a/src/state/queries/handle-availability.ts b/src/state/queries/handle-availability.ts
--- a/src/state/queries/handle-availability.ts
+++ b/src/state/queries/handle-availability.ts
@@ -6,8 +6,8 @@
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'
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -5,7 +5,6 @@
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 {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB} from '#/env'
+import {useActorStatus} from '#/features/liveNow'
const iconWidth = 26
diff --git a/src/features/liveNow/components/EditLiveDialog.tsx b/src/features/liveNow/components/EditLiveDialog.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/EditLiveDialog.tsx
@@ -0,0 +1,243 @@
+import {useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+ type AppBskyActorDefs,
+ AppBskyActorStatus,
+ type AppBskyEmbedExternal,
+} from '@atproto/api'
+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'
+import {atoms as a, 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 {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {
+ displayDuration,
+ useLiveLinkMetaQuery,
+ useRemoveLiveStatusMutation,
+ useUpsertLiveStatusMutation,
+} from '#/features/liveNow'
+import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
+
+export function EditLiveDialog({
+ control,
+ status,
+ embed,
+}: {
+ control: Dialog.DialogControlProps
+ status: AppBskyActorDefs.StatusView
+ embed: AppBskyEmbedExternal.View
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function DialogInner({
+ status,
+ embed,
+}: {
+ status: AppBskyActorDefs.StatusView
+ embed: AppBskyEmbedExternal.View
+}) {
+ const control = Dialog.useDialogContext()
+ const {_, i18n} = useLingui()
+ const t = useTheme()
+
+ const [liveLink, setLiveLink] = useState(embed.external.uri)
+ const [liveLinkError, setLiveLinkError] = useState('')
+ const tick = useTickEveryMinute()
+
+ const liveLinkUrl = definitelyUrl(liveLink)
+ const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
+
+ const isDirty = liveLinkUrl !== embed.external.uri
+
+ const {
+ data: linkMeta,
+ isSuccess: hasValidLinkMeta,
+ isLoading: linkMetaLoading,
+ error: linkMetaError,
+ } = useLiveLinkMetaQuery(debouncedUrl)
+
+ const record = useMemo(() => {
+ if (!AppBskyActorStatus.isRecord(status.record)) return null
+ const validation = AppBskyActorStatus.validateRecord(status.record)
+ if (validation.success) {
+ return validation.value
+ }
+ return null
+ }, [status])
+
+ const {
+ mutate: goLive,
+ isPending: isGoingLive,
+ error: goLiveError,
+ } = useUpsertLiveStatusMutation(
+ record?.durationMinutes ?? 0,
+ linkMeta,
+ record?.createdAt,
+ )
+
+ const {
+ mutate: removeLiveStatus,
+ isPending: isRemovingLiveStatus,
+ error: removeLiveStatusError,
+ } = useRemoveLiveStatusMutation()
+
+ const {minutesUntilExpiry, expiryDateTime} = useMemo(() => {
+ void tick
+
+ const expiry = new Date(status.expiresAt ?? new Date())
+ return {
+ expiryDateTime: expiry,
+ minutesUntilExpiry: differenceInMinutes(expiry, new Date()),
+ }
+ }, [tick, status.expiresAt])
+
+ const submitDisabled =
+ isGoingLive ||
+ !hasValidLinkMeta ||
+ debouncedUrl !== liveLinkUrl ||
+ isRemovingLiveStatus
+
+ return (
+
+
+
+
+ You are Live
+
+
+
+
+ {typeof record?.durationMinutes === 'number' ? (
+
+ Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
+ {i18n.date(expiryDateTime, {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ })}
+
+ ) : (
+ No expiry set
+ )}
+
+
+
+
+
+
+ Live link
+
+
+ setLiveLinkError('')}
+ onBlur={() => {
+ if (!definitelyUrl(liveLink)) {
+ setLiveLinkError('Invalid URL')
+ }
+ }}
+ returnKeyType="done"
+ autoCapitalize="none"
+ autoComplete="url"
+ autoCorrect={false}
+ onSubmitEditing={() => {
+ if (isDirty && !submitDisabled) {
+ goLive()
+ }
+ }}
+ />
+
+
+ {(liveLinkError || linkMetaError) && (
+
+ {liveLinkError ? (
+ This is not a valid link
+ ) : (
+ cleanError(linkMetaError)
+ )}
+
+ )}
+
+
+
+
+ {goLiveError && (
+ {cleanError(goLiveError)}
+ )}
+ {removeLiveStatusError && (
+
+ {cleanError(removeLiveStatusError)}
+
+ )}
+
+
+ {isDirty ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/features/liveNow/components/GoLiveDialog.tsx b/src/features/liveNow/components/GoLiveDialog.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/GoLiveDialog.tsx
@@ -0,0 +1,261 @@
+import {useCallback, useState} from 'react'
+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 {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 {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'
+
+export function GoLiveDialog({
+ control,
+ profile,
+}: {
+ control: Dialog.DialogControlProps
+ profile: bsky.profile.AnyProfileView
+}) {
+ return (
+
+
+
+
+ )
+}
+
+// Possible durations: max 4 hours, 5 minute intervals
+const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5)
+
+function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
+ const control = Dialog.useDialogContext()
+ const {_, i18n} = useLingui()
+ const t = useTheme()
+ const [liveLink, setLiveLink] = useState('')
+ const [liveLinkError, setLiveLinkError] = useState('')
+ const [duration, setDuration] = useState(60)
+ const moderationOpts = useModerationOpts()
+ const tick = useTickEveryMinute()
+ const liveNowConfig = useLiveNowConfig()
+ const {formatted: allowedServices} = getLiveServiceNames(
+ liveNowConfig.currentAccountAllowedHosts,
+ )
+
+ const time = useCallback(
+ (offset: number) => {
+ void tick
+
+ const date = new Date()
+ date.setMinutes(date.getMinutes() + offset)
+ return i18n.date(date, {hour: 'numeric', minute: '2-digit', hour12: true})
+ },
+ [tick, i18n],
+ )
+
+ const onChangeDuration = useCallback((newDuration: string) => {
+ setDuration(Number(newDuration))
+ }, [])
+
+ const liveLinkUrl = definitelyUrl(liveLink)
+ const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
+
+ const {
+ data: linkMeta,
+ isSuccess: hasValidLinkMeta,
+ isLoading: linkMetaLoading,
+ error: linkMetaError,
+ } = useLiveLinkMetaQuery(debouncedUrl)
+
+ const {
+ mutate: goLive,
+ isPending: isGoingLive,
+ error: goLiveError,
+ } = useUpsertLiveStatusMutation(duration, linkMeta)
+
+ const isSourceInvalid = !!liveLinkError || !!linkMetaError
+
+ const hasLink = !!debouncedUrl && !isSourceInvalid
+
+ return (
+
+
+
+
+ Go Live
+
+
+
+ Add a temporary live status to your profile. When someone clicks
+ on your avatar, they’ll see information about your live event.
+
+
+
+ {moderationOpts && (
+
+
+
+
+ )}
+
+
+
+ Live link
+
+
+ setLiveLinkError('')}
+ onBlur={() => {
+ if (!definitelyUrl(liveLink)) {
+ setLiveLinkError('Invalid URL')
+ }
+ }}
+ returnKeyType="done"
+ autoCapitalize="none"
+ autoComplete="url"
+ autoCorrect={false}
+ />
+
+
+ {liveLinkError || linkMetaError ? (
+
+ {liveLinkError ? (
+ This is not a valid link
+ ) : (
+ cleanError(linkMetaError)
+ )}
+
+ ) : (
+
+
+ The following services are enabled for your account:{' '}
+ {allowedServices}
+
+
+ )}
+
+
+
+
+ {hasLink && (
+
+
+ Go live for
+
+
+
+
+ {displayDuration(i18n, duration)}
+ {' '}
+
+ {time(duration)}
+
+
+
+
+
+ {
+ const label = displayDuration(i18n, item)
+ return (
+
+
+
+ {label}
+ {' '}
+
+ {time(item)}
+
+
+
+ )
+ }}
+ items={DURATIONS}
+ valueExtractor={d => String(d)}
+ />
+
+
+ )}
+
+ {goLiveError && (
+ {cleanError(goLiveError)}
+ )}
+
+
+ {hasLink && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/features/liveNow/components/GoLiveDisabledDialog.tsx b/src/features/liveNow/components/GoLiveDisabledDialog.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/GoLiveDisabledDialog.tsx
@@ -0,0 +1,147 @@
+import {useCallback, useState} from 'react'
+import {View} from 'react-native'
+import {type AppBskyActorDefs, ToolsOzoneReportDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import {atoms as a, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+
+export function GoLiveDisabledDialog({
+ control,
+ status,
+}: {
+ control: Dialog.DialogControlProps
+ status: AppBskyActorDefs.StatusView
+}) {
+ return (
+
+
+
+
+ )
+}
+
+export function DialogInner({
+ control,
+ status,
+}: {
+ control: Dialog.DialogControlProps
+ status: AppBskyActorDefs.StatusView
+}) {
+ const {_} = useLingui()
+ const agent = useAgent()
+ const [details, setDetails] = useState('')
+
+ const {mutate, isPending} = useMutation({
+ mutationFn: async () => {
+ if (!agent.session?.did) {
+ throw new Error('Not logged in')
+ }
+ if (!status.uri || !status.cid) {
+ throw new Error('Status is missing uri or cid')
+ }
+
+ if (__DEV__) {
+ logger.info('Submitting go live appeal', {
+ details,
+ })
+ } else {
+ await agent.createModerationReport(
+ {
+ reasonType: ToolsOzoneReportDefs.REASONAPPEAL,
+ subject: {
+ $type: 'com.atproto.repo.strongRef',
+ uri: status.uri,
+ cid: status.cid,
+ },
+ reason: details,
+ },
+ {
+ encoding: 'application/json',
+ headers: BLUESKY_MOD_SERVICE_HEADERS,
+ },
+ )
+ }
+ },
+ onError: () => {
+ Toast.show(_(msg`Failed to submit appeal, please try again.`), {
+ type: 'error',
+ })
+ },
+ onSuccess: () => {
+ control.close()
+ Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})), {
+ type: 'success',
+ })
+ },
+ })
+
+ const onSubmit = useCallback(() => mutate(), [mutate])
+
+ return (
+
+
+
+
+ Going live is currently disabled for your account
+
+
+
+ You are currently blocked from using the Go Live feature. To
+ appeal this moderation decision, please submit the form below.
+
+
+
+
+ This appeal will be sent to Bluesky's moderation service.
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/liveNow/components/LinkPreview.tsx b/src/features/liveNow/components/LinkPreview.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/LinkPreview.tsx
@@ -0,0 +1,98 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {Trans} from '@lingui/macro'
+
+import {type LinkMeta} from '#/lib/link-meta/link-meta'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {atoms as a, useTheme} from '#/alf'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
+import {Text} from '#/components/Typography'
+
+export function LinkPreview({
+ linkMeta,
+ loading,
+}: {
+ linkMeta?: LinkMeta
+ loading: boolean
+}) {
+ const t = useTheme()
+ const [imageLoadError, setImageLoadError] = useState(false)
+
+ if (!linkMeta && !loading) {
+ return null
+ }
+
+ return (
+
+
+ {linkMeta?.image && (
+ setImageLoadError(false)}
+ onError={() => setImageLoadError(true)}
+ />
+ )}
+ {linkMeta && (!linkMeta.image || imageLoadError) && (
+ <>
+
+
+ No image
+
+ >
+ )}
+
+
+ {linkMeta ? (
+ <>
+
+ {linkMeta.title || linkMeta.url}
+
+
+
+
+ {toNiceDomain(linkMeta.url)}
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/features/liveNow/components/LiveIndicator.tsx b/src/features/liveNow/components/LiveIndicator.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/LiveIndicator.tsx
@@ -0,0 +1,53 @@
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function LiveIndicator({
+ size = 'small',
+ style,
+}: {
+ size?: 'tiny' | 'small' | 'large'
+ style?: StyleProp
+}) {
+ const t = useTheme()
+
+ const fontSize = {
+ tiny: {fontSize: 7, letterSpacing: tokens.TRACKING},
+ small: a.text_2xs,
+ large: a.text_xs,
+ }[size]
+
+ return (
+
+
+
+
+ LIVE
+
+
+
+
+ )
+}
diff --git a/src/features/liveNow/components/LiveStatusDialog.tsx b/src/features/liveNow/components/LiveStatusDialog.tsx
new file mode 100644
--- /dev/null
+++ b/src/features/liveNow/components/LiveStatusDialog.tsx
@@ -0,0 +1,257 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {type NavigationProp} from '#/lib/routes/types'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+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 {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'
+
+export function LiveStatusDialog({
+ control,
+ profile,
+ embed,
+ status,
+}: {
+ control: Dialog.DialogControlProps
+ profile: bsky.profile.AnyProfileView
+ status: AppBskyActorDefs.StatusView
+ embed: AppBskyEmbedExternal.View
+}) {
+ const navigation = useNavigation()
+ return (
+
+
+
+
+ )
+}
+
+function DialogInner({
+ profile,
+ embed,
+ navigation,
+ status,
+}: {
+ profile: bsky.profile.AnyProfileView
+ embed: AppBskyEmbedExternal.View
+ navigation: NavigationProp
+ status: AppBskyActorDefs.StatusView
+}) {
+ const {_} = useLingui()
+ const control = Dialog.useDialogContext()
+
+ const onPressOpenProfile = useCallback(() => {
+ control.close(() => {
+ navigation.push('Profile', {
+ name: profile.handle,
+ })
+ })
+ }, [navigation, profile.handle, control])
+
+ return (
+
+
+
+
+ )
+}
+
+export function LiveStatus({
+ status,
+ profile,
+ embed,
+ padding = 'xl',
+ onPressOpenProfile,
+}: {
+ status: AppBskyActorDefs.StatusView
+ profile: bsky.profile.AnyProfileView
+ embed: AppBskyEmbedExternal.View
+ padding?: 'lg' | 'xl'
+ onPressOpenProfile: () => void
+}) {
+ const ax = useAnalytics()
+ const {_} = useLingui()
+ const t = useTheme()
+ const queryClient = useQueryClient()
+ const openLink = useOpenLink()
+ const moderationOpts = useModerationOpts()
+ const reportDialogControl = useGlobalReportDialogControl()
+ const dialogContext = Dialog.useDialogContext()
+
+ return (
+ <>
+ {embed.external.thumb && (
+
+
+
+
+ )}
+
+
+
+ {embed.external.title || embed.external.uri}
+
+
+
+
+ {toNiceDomain(embed.external.uri)}
+
+
+
+
+
+ {moderationOpts && (
+
+
+ {/* Ensure wide enough on web hover */}
+
+
+
+
+
+ )}
+
+
+
+
+ Live feature is in beta
+
+
+ {status && (
+ {
+ function open() {
+ reportDialogControl.open({
+ subject: {
+ ...status,
+ $type: 'app.bsky.actor.defs#statusView',
+ },
+ })
+ }
+ if (dialogContext.isWithinDialog) {
+ dialogContext.close(open)
+ } else {
+ open()
+ }
+ })}
+ style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}>
+ Report
+
+ )}
+
+
+ >
+ )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -10,7 +10,6 @@
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 {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({
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
--- a/src/screens/PostThread/components/ThreadItemPost.tsx
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -8,7 +8,6 @@
} 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 * 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
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -10,7 +10,6 @@
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 {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'
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -14,7 +14,6 @@
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 {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'
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -19,7 +19,6 @@
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 @@
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 {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'
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -11,7 +11,6 @@
} 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 {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'
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -5,7 +5,6 @@
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 @@
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 {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 {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 @@
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 =
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -5,7 +5,6 @@
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 {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'
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -15,7 +15,6 @@
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 {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'
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -7,7 +7,6 @@
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 @@
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'
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -5,7 +5,6 @@
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 * 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'