Move components into liveNow

This commit is contained in:
Eric Bailey
2026-02-13 11:18:23 -06:00
parent 6620d0ee5a
commit 320ed472fe
9 changed files with 11 additions and 11 deletions
@@ -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 {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,
useDebouncedValue,
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import {LinkPreview} from './LinkPreview'
export function EditLiveDialog({
control,
status,
embed,
}: {
control: Dialog.DialogControlProps
status: AppBskyActorDefs.StatusView
embed: AppBskyEmbedExternal.View
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner status={status} embed={embed} />
</Dialog.Outer>
)
}
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 (
<Dialog.ScrollableInner
label={_(msg`You are Live`)}
style={web({maxWidth: 420})}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_semi_bold, a.text_2xl]}>
<Trans>You are Live</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<ClockIcon style={[t.atoms.text_contrast_high]} size="sm" />
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{typeof record?.durationMinutes === 'number' ? (
<Trans>
Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
{i18n.date(expiryDateTime, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</Trans>
) : (
<Trans>No expiry set</Trans>
)}
</Text>
</View>
</View>
<View style={[a.gap_sm]}>
<View>
<TextField.LabelText>
<Trans>Live link</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
value={liveLink}
onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')}
onBlur={() => {
if (!definitelyUrl(liveLink)) {
setLiveLinkError('Invalid URL')
}
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="url"
autoCorrect={false}
onSubmitEditing={() => {
if (isDirty && !submitDisabled) {
goLive()
}
}}
/>
</TextField.Root>
</View>
{(liveLinkError || linkMetaError) && (
<Admonition type="error">
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Admonition>
)}
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
</View>
{goLiveError && (
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
)}
{removeLiveStatusError && (
<Admonition type="error">
{cleanError(removeLiveStatusError)}
</Admonition>
)}
<View
style={platform({
native: [a.gap_md, a.pt_lg],
web: [a.flex_row_reverse, a.gap_md, a.align_center],
})}>
{isDirty ? (
<Button
label={_(msg`Save`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => goLive()}
disabled={submitDisabled}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
{isGoingLive && <ButtonIcon icon={Loader} />}
</Button>
) : (
<Button
label={_(msg`Close`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
<Button
label={_(msg`Remove live status`)}
onPress={() => removeLiveStatus()}
size={platform({native: 'large', web: 'small'})}
color="negative_subtle"
variant="solid"
disabled={isRemovingLiveStatus || isGoingLive}>
<ButtonText>
<Trans>Remove live status</Trans>
</ButtonText>
{isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -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 {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,
useDebouncedValue,
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 (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner profile={profile} />
</Dialog.Outer>
)
}
// 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 (
<Dialog.ScrollableInner
label={_(msg`Go Live`)}
style={web({maxWidth: 420})}>
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_semi_bold, a.text_2xl]}>
<Trans>Go Live</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Add a temporary live status to your profile. When someone clicks
on your avatar, theyll see information about your live event.
</Trans>
</Text>
</View>
{moderationOpts && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
liveOverride
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
)}
<View style={[a.gap_sm]}>
<View>
<TextField.LabelText>
<Trans>Live link</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={isSourceInvalid}>
<TextField.Input
label={_(msg`Live link`)}
placeholder={_(msg`www.mylivestream.tv`)}
value={liveLink}
onChangeText={setLiveLink}
onFocus={() => setLiveLinkError('')}
onBlur={() => {
if (!definitelyUrl(liveLink)) {
setLiveLinkError('Invalid URL')
}
}}
returnKeyType="done"
autoCapitalize="none"
autoComplete="url"
autoCorrect={false}
/>
</TextField.Root>
</View>
{liveLinkError || linkMetaError ? (
<Admonition type="error">
{liveLinkError ? (
<Trans>This is not a valid link</Trans>
) : (
cleanError(linkMetaError)
)}
</Admonition>
) : (
<Admonition type="tip">
<Trans>
The following services are enabled for your account:{' '}
{allowedServices}
</Trans>
</Admonition>
)}
<LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
</View>
{hasLink && (
<View>
<TextField.LabelText>
<Trans>Go live for</Trans>
</TextField.LabelText>
<Select.Root
value={String(duration)}
onValueChange={onChangeDuration}>
<Select.Trigger label={_(msg`Select duration`)}>
<Text style={[ios(a.py_xs)]}>
{displayDuration(i18n, duration)}
{' '}
<Text style={[t.atoms.text_contrast_low]}>
{time(duration)}
</Text>
</Text>
<Select.Icon />
</Select.Trigger>
<Select.Content
renderItem={(item, _i, selectedValue) => {
const label = displayDuration(i18n, item)
return (
<Select.Item value={String(item)} label={label}>
<Select.ItemIndicator />
<Select.ItemText>
{label}
{' '}
<Text
style={[
native(a.text_md),
web(a.ml_xs),
selectedValue === String(item)
? t.atoms.text_contrast_medium
: t.atoms.text_contrast_low,
a.font_normal,
]}>
{time(item)}
</Text>
</Select.ItemText>
</Select.Item>
)
}}
items={DURATIONS}
valueExtractor={d => String(d)}
/>
</Select.Root>
</View>
)}
{goLiveError && (
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
)}
<View
style={platform({
native: [a.gap_md, a.pt_lg],
web: [a.flex_row_reverse, a.gap_md, a.align_center],
})}>
{hasLink && (
<Button
label={_(msg`Go Live`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => goLive()}
disabled={
isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl
}>
<ButtonText>
<Trans>Go Live</Trans>
</ButtonText>
{isGoingLive && <ButtonIcon icon={Loader} />}
</Button>
)}
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
size={platform({native: 'large', web: 'small'})}
color="secondary"
variant={platform({native: 'solid', web: 'ghost'})}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -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 (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner control={control} status={status} />
</Dialog.Outer>
)
}
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 (
<Dialog.ScrollableInner
label={_(msg`Appeal livestream suspension`)}
style={[web({maxWidth: 400})]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_md]}>
<Text
style={[
a.flex_1,
a.text_2xl,
a.font_semi_bold,
a.leading_snug,
a.pr_4xl,
]}>
<Trans>Going live is currently disabled for your account</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
You are currently blocked from using the Go Live feature. To
appeal this moderation decision, please submit the form below.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to Bluesky's moderation service.
</Trans>
</Text>
</View>
<View style={[a.gap_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think your Go Live access was incorrectly disabled.`,
)}
value={details}
onChangeText={setDetails}
autoFocus={true}
numberOfLines={3}
multiline
maxLength={300}
/>
<Button
testID="submitBtn"
variant="solid"
color="primary"
size="large"
onPress={onSubmit}
label={_(msg`Submit`)}>
<ButtonText>{_(msg`Submit`)}</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -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 (
<View
style={[
a.w_full,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.flex_row,
a.rounded_sm,
a.overflow_hidden,
a.align_stretch,
]}>
<View
style={[
t.atoms.bg_contrast_25,
{minHeight: 64, width: 114},
a.justify_center,
a.align_center,
a.gap_xs,
]}>
{linkMeta?.image && (
<Image
source={linkMeta.image}
accessibilityIgnoresInvertColors
transition={200}
style={[a.absolute, a.inset_0]}
contentFit="cover"
onLoad={() => setImageLoadError(false)}
onError={() => setImageLoadError(true)}
/>
)}
{linkMeta && (!linkMeta.image || imageLoadError) && (
<>
<ImageIcon style={[t.atoms.text_contrast_low]} />
<Text style={[t.atoms.text_contrast_low, a.text_xs, a.text_center]}>
<Trans>No image</Trans>
</Text>
</>
)}
</View>
<View style={[a.flex_1, a.justify_center, a.py_sm, a.gap_xs, a.px_md]}>
{linkMeta ? (
<>
<Text
numberOfLines={2}
style={[a.leading_snug, a.font_semi_bold, a.text_md]}>
{linkMeta.title || linkMeta.url}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<GlobeIcon size="xs" style={[t.atoms.text_contrast_low]} />
<Text
numberOfLines={1}
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{toNiceDomain(linkMeta.url)}
</Text>
</View>
</>
) : (
<>
<LoadingPlaceholder height={16} width={128} />
<LoadingPlaceholder height={12} width={72} />
</>
)}
</View>
</View>
)
}
@@ -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<ViewStyle>
}) {
const t = useTheme()
const fontSize = {
tiny: {fontSize: 7, letterSpacing: tokens.TRACKING},
small: a.text_2xs,
large: a.text_xs,
}[size]
return (
<View
style={[
a.absolute,
a.w_full,
a.align_center,
a.pointer_events_none,
{bottom: size === 'large' ? -8 : -5},
style,
]}>
<View
style={{
backgroundColor: t.palette.negative_500,
paddingVertical: size === 'large' ? 2 : 1,
paddingHorizontal: size === 'large' ? 4 : 3,
borderRadius: size === 'large' ? 5 : tokens.borderRadius.xs,
}}>
<Text
style={[
a.text_center,
a.font_semi_bold,
fontSize,
{color: t.palette.white},
]}>
<Trans comment="Live status indicator on avatar. Should be extremely short, not much space for more than 4 characters">
LIVE
</Trans>
</Text>
</View>
</View>
)
}
@@ -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<NavigationProp>()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle difference={!!embed.external.thumb} />
<DialogInner
status={status}
profile={profile}
embed={embed}
navigation={navigation}
/>
</Dialog.Outer>
)
}
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 (
<Dialog.ScrollableInner
label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
contentContainerStyle={[a.pt_0, a.px_0]}
style={[web({maxWidth: 420}), a.overflow_hidden]}>
<LiveStatus
status={status}
profile={profile}
embed={embed}
onPressOpenProfile={onPressOpenProfile}
/>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
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 && (
<View
style={[
t.atoms.bg_contrast_25,
a.w_full,
a.aspect_card,
android([
a.overflow_hidden,
{
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]),
]}>
<Image
source={embed.external.thumb}
contentFit="cover"
style={[a.absolute, a.inset_0]}
accessibilityIgnoresInvertColors
/>
<LiveIndicator
size="large"
style={[
a.absolute,
{top: tokens.space.lg, left: tokens.space.lg},
a.align_start,
]}
/>
</View>
)}
<View
style={[
a.gap_lg,
padding === 'xl'
? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
: a.p_lg,
]}>
<View style={[a.w_full, a.justify_center, a.gap_2xs]}>
<Text
numberOfLines={3}
style={[a.leading_snug, a.font_semi_bold, a.text_xl]}>
{embed.external.title || embed.external.uri}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<Globe_Stroke2_Corner0_Rounded
size="xs"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{toNiceDomain(embed.external.uri)}
</Text>
</View>
</View>
<Button
label={_(msg`Watch now`)}
size={platform({native: 'large', web: 'small'})}
color="primary"
variant="solid"
onPress={() => {
ax.metric('live:card:watch', {subject: profile.did})
openLink(embed.external.uri, false)
}}>
<ButtonText>
<Trans>Watch now</Trans>
</ButtonText>
<ButtonIcon icon={SquareArrowTopRightIcon} />
</Button>
<View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
{moderationOpts && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
{/* Ensure wide enough on web hover */}
<View style={[a.flex_1, web({minWidth: 100})]}>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</View>
<Button
label={_(msg`Open profile`)}
size="small"
color="secondary"
variant="solid"
onPress={() => {
ax.metric('live:card:openProfile', {subject: profile.did})
unstableCacheProfileView(queryClient, profile)
onPressOpenProfile()
}}>
<ButtonText>
<Trans>Open profile</Trans>
</ButtonText>
</Button>
</ProfileCard.Header>
)}
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.w_full,
a.pt_sm,
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<CircleInfoIcon size="sm" fill={t.atoms.text_contrast_low.color} />
<Text style={[t.atoms.text_contrast_low, a.text_sm]}>
<Trans>Live feature is in beta</Trans>
</Text>
</View>
{status && (
<SimpleInlineLinkText
label={_(msg`Report this livestream`)}
{...createStaticClick(() => {
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]}>
<Trans>Report</Trans>
</SimpleInlineLinkText>
)}
</View>
</View>
</>
)
}