[APP-1776] Refactor Live Now config, reorg files (#9871)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
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 {LinkPreview} from './LinkPreview'
|
||||
import {
|
||||
useLiveLinkMetaQuery,
|
||||
useRemoveLiveStatusMutation,
|
||||
useUpsertLiveStatusMutation,
|
||||
} from './queries'
|
||||
import {displayDuration, useDebouncedValue} from './utils'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
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 {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 type * as bsky from '#/types/bsky'
|
||||
import {LinkPreview} from './LinkPreview'
|
||||
import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
|
||||
|
||||
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, they’ll 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>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
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 {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 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,
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyActorStatus,
|
||||
type AppBskyEmbedExternal,
|
||||
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 {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 {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {getLiveServiceNames} from '#/components/live/utils'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function useLiveLinkMetaQuery(url: string | null) {
|
||||
const liveNowConfig = useLiveNowConfig()
|
||||
const {_} = useLingui()
|
||||
|
||||
const agent = useAgent()
|
||||
return useQuery({
|
||||
enabled: !!url,
|
||||
queryKey: ['link-meta', url],
|
||||
queryFn: async () => {
|
||||
if (!url) return undefined
|
||||
const urlp = new URL(url)
|
||||
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) {
|
||||
const {formatted} = getLiveServiceNames(
|
||||
liveNowConfig.currentAccountAllowedHosts,
|
||||
)
|
||||
throw new Error(
|
||||
_(
|
||||
msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return await getLinkMeta(agent, url)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpsertLiveStatusMutation(
|
||||
duration: number,
|
||||
linkMeta: LinkMeta | null | undefined,
|
||||
createdAt?: string,
|
||||
) {
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const control = useDialogContext()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAccount) throw new Error('Not logged in')
|
||||
|
||||
let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
|
||||
|
||||
if (linkMeta) {
|
||||
let thumb
|
||||
|
||||
if (linkMeta.image) {
|
||||
try {
|
||||
const img = await imageToThumb(linkMeta.image)
|
||||
if (img) {
|
||||
const blob = await uploadBlob(
|
||||
agent,
|
||||
img.source.path,
|
||||
img.source.mime,
|
||||
)
|
||||
thumb = blob.data.blob
|
||||
}
|
||||
} catch (e: any) {
|
||||
ax.logger.error(`Failed to upload thumbnail for live status`, {
|
||||
url: linkMeta.url,
|
||||
image: linkMeta.image,
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
$type: 'app.bsky.embed.external#external',
|
||||
title: linkMeta.title ?? '',
|
||||
description: linkMeta.description ?? '',
|
||||
uri: linkMeta.url,
|
||||
thumb,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const record = {
|
||||
$type: 'app.bsky.actor.status',
|
||||
createdAt: createdAt ?? new Date().toISOString(),
|
||||
status: 'app.bsky.actor.status#live',
|
||||
durationMinutes: duration,
|
||||
embed,
|
||||
} satisfies AppBskyActorStatus.Record
|
||||
|
||||
const upsert = async () => {
|
||||
const repo = currentAccount.did
|
||||
const collection = 'app.bsky.actor.status'
|
||||
|
||||
const existing = await agent.com.atproto.repo
|
||||
.getRecord({repo, collection, rkey: 'self'})
|
||||
.catch(_e => undefined)
|
||||
|
||||
await agent.com.atproto.repo.putRecord({
|
||||
repo,
|
||||
collection,
|
||||
rkey: 'self',
|
||||
record,
|
||||
swapRecord: existing?.data.cid || null,
|
||||
})
|
||||
}
|
||||
|
||||
await retry(upsert, {
|
||||
maxRetries: 5,
|
||||
retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
|
||||
})
|
||||
|
||||
return {
|
||||
record,
|
||||
image: linkMeta?.image,
|
||||
}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
ax.logger.error(`Failed to upsert live status`, {
|
||||
url: linkMeta?.url,
|
||||
image: linkMeta?.image,
|
||||
safeMessage: e,
|
||||
})
|
||||
},
|
||||
onSuccess: ({record, image}) => {
|
||||
if (createdAt) {
|
||||
ax.metric('live:edit', {duration: record.durationMinutes})
|
||||
} else {
|
||||
ax.metric('live:create', {duration: record.durationMinutes})
|
||||
}
|
||||
|
||||
Toast.show(_(msg`You are now live!`))
|
||||
control.close(() => {
|
||||
if (!currentAccount) return
|
||||
|
||||
const expiresAt = new Date(record.createdAt)
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
|
||||
|
||||
updateProfileShadow(queryClient, currentAccount.did, {
|
||||
status: {
|
||||
$type: 'app.bsky.actor.defs#statusView',
|
||||
status: 'app.bsky.actor.status#live',
|
||||
isActive: true,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
embed:
|
||||
record.embed && image
|
||||
? {
|
||||
$type: 'app.bsky.embed.external#view',
|
||||
external: {
|
||||
...record.embed.external,
|
||||
$type: 'app.bsky.embed.external#viewExternal',
|
||||
thumb: image,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
record,
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveLiveStatusMutation() {
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const control = useDialogContext()
|
||||
const {_} = useLingui()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!currentAccount) throw new Error('Not logged in')
|
||||
|
||||
await agent.app.bsky.actor.status.delete({
|
||||
repo: currentAccount.did,
|
||||
rkey: 'self',
|
||||
})
|
||||
},
|
||||
onError: (e: any) => {
|
||||
ax.logger.error(`Failed to remove live status`, {
|
||||
safeMessage: e,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
ax.metric('live:remove', {})
|
||||
Toast.show(_(msg`You are no longer live`))
|
||||
control.close(() => {
|
||||
if (!currentAccount) return
|
||||
|
||||
updateProfileShadow(queryClient, currentAccount.did, {
|
||||
status: undefined,
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {plural} from '@lingui/macro'
|
||||
|
||||
export function displayDuration(i18n: I18n, durationInMinutes: number) {
|
||||
const roundedDurationInMinutes = Math.round(durationInMinutes)
|
||||
const hours = Math.floor(roundedDurationInMinutes / 60)
|
||||
const minutes = roundedDurationInMinutes % 60
|
||||
const minutesString = i18n._(
|
||||
plural(minutes, {one: '# minute', other: '# minutes'}),
|
||||
)
|
||||
return hours > 0
|
||||
? i18n._(
|
||||
minutes > 0
|
||||
? plural(hours, {
|
||||
one: `# hour ${minutesString}`,
|
||||
other: `# hours ${minutesString}`,
|
||||
})
|
||||
: plural(hours, {
|
||||
one: '# hour',
|
||||
other: '# hours',
|
||||
}),
|
||||
)
|
||||
: 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)),
|
||||
)
|
||||
return {
|
||||
names,
|
||||
formatted: names.join(', '),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user