diff options
Diffstat (limited to 'src/components/live')
-rw-r--r-- | src/components/live/EditLiveDialog.tsx | 348 | ||||
-rw-r--r-- | src/components/live/GoLiveDialog.tsx | 352 | ||||
-rw-r--r-- | src/components/live/LiveIndicator.tsx | 53 | ||||
-rw-r--r-- | src/components/live/LiveStatusDialog.tsx | 212 | ||||
-rw-r--r-- | src/components/live/queries.ts | 187 | ||||
-rw-r--r-- | src/components/live/temp.ts | 41 | ||||
-rw-r--r-- | src/components/live/utils.ts | 37 |
7 files changed, 1230 insertions, 0 deletions
diff --git a/src/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx new file mode 100644 index 000000000..36c292cb5 --- /dev/null +++ b/src/components/live/EditLiveDialog.tsx @@ -0,0 +1,348 @@ +import {useMemo, useState} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import { + type AppBskyActorDefs, + AppBskyActorStatus, + type AppBskyEmbedExternal, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' +import {differenceInMinutes} from 'date-fns' + +import {getLinkMeta} from '#/lib/link-meta/link-meta' +import {cleanError} from '#/lib/strings/errors' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {definitelyUrl} from '#/lib/strings/url-helpers' +import {useAgent} from '#/state/session' +import {useTickEveryMinute} from '#/state/shell' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +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 {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' +import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import { + 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 agent = useAgent() + const [liveLink, setLiveLink] = useState(embed.external.uri) + const [liveLinkError, setLiveLinkError] = useState('') + const [imageLoadError, setImageLoadError] = useState(false) + 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, + } = useQuery({ + enabled: !!debouncedUrl, + queryKey: ['link-meta', debouncedUrl], + queryFn: async () => { + if (!debouncedUrl) return null + return getLinkMeta(agent, 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(() => { + 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_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) && ( + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <WarningIcon + style={[{color: t.palette.negative_500}]} + size="sm" + /> + <Text + style={[ + a.text_sm, + a.leading_snug, + a.flex_1, + a.font_bold, + {color: t.palette.negative_500}, + ]}> + {liveLinkError ? ( + <Trans>This is not a valid link</Trans> + ) : ( + cleanError(linkMetaError) + )} + </Text> + </View> + )} + + {(linkMeta || linkMetaLoading) && ( + <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, + ]}> + {(!linkMeta || linkMeta.image) && ( + <View + style={[ + t.atoms.bg_contrast_25, + {minHeight: 64, width: 114}, + a.justify_center, + a.align_center, + ]}> + {linkMeta?.image && ( + <Image + source={linkMeta.image} + accessibilityIgnoresInvertColors + transition={200} + style={[a.absolute, a.inset_0]} + contentFit="cover" + onLoad={() => setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + <CircleXIcon + style={[t.atoms.text_contrast_low]} + size="xl" + /> + )} + </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_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> + )} + </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_secondary" + variant="solid" + disabled={isRemovingLiveStatus || isGoingLive}> + <ButtonText> + <Trans>Remove live status</Trans> + </ButtonText> + {isRemovingLiveStatus && <ButtonIcon icon={Loader} />} + </Button> + </View> + </View> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx new file mode 100644 index 000000000..2fad009fd --- /dev/null +++ b/src/components/live/GoLiveDialog.tsx @@ -0,0 +1,352 @@ +import {useCallback, useState} from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' + +import {getLinkMeta} from '#/lib/link-meta/link-meta' +import {cleanError} from '#/lib/strings/errors' +import {toNiceDomain} from '#/lib/strings/url-helpers' +import {definitelyUrl} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent} from '#/state/session' +import {useTickEveryMinute} from '#/state/shell' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +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 {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +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 {useUpsertLiveStatusMutation} from './queries' +import {displayDuration, useDebouncedValue} from './utils' + +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 agent = useAgent() + const [liveLink, setLiveLink] = useState('') + const [liveLinkError, setLiveLinkError] = useState('') + const [imageLoadError, setImageLoadError] = useState(false) + const [duration, setDuration] = useState(60) + const moderationOpts = useModerationOpts() + const tick = useTickEveryMinute() + + const time = useCallback( + (offset: number) => { + tick! + + const date = new Date() + date.setMinutes(date.getMinutes() + offset) + return i18n + .date(date, {hour: 'numeric', minute: '2-digit', hour12: true}) + .toLocaleUpperCase() + .replace(' ', '') + }, + [tick, i18n], + ) + + const onChangeDuration = useCallback((newDuration: string) => { + setDuration(Number(newDuration)) + }, []) + + const liveLinkUrl = definitelyUrl(liveLink) + const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) + const hasLink = !!debouncedUrl + + const { + data: linkMeta, + isSuccess: hasValidLinkMeta, + isLoading: linkMetaLoading, + error: linkMetaError, + } = useQuery({ + enabled: !!debouncedUrl, + queryKey: ['link-meta', debouncedUrl], + queryFn: async () => { + if (!debouncedUrl) return null + return getLinkMeta(agent, debouncedUrl) + }, + }) + + const { + mutate: goLive, + isPending: isGoingLive, + error: goLiveError, + } = useUpsertLiveStatusMutation(duration, linkMeta) + + 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_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={!!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} + /> + </TextField.Root> + </View> + {(liveLinkError || linkMetaError) && ( + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <WarningIcon + style={[{color: t.palette.negative_500}]} + size="sm" + /> + <Text + style={[ + a.text_sm, + a.leading_snug, + a.flex_1, + a.font_bold, + {color: t.palette.negative_500}, + ]}> + {liveLinkError ? ( + <Trans>This is not a valid link</Trans> + ) : ( + cleanError(linkMetaError) + )} + </Text> + </View> + )} + + {(linkMeta || linkMetaLoading) && ( + <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, + ]}> + {(!linkMeta || linkMeta.image) && ( + <View + style={[ + t.atoms.bg_contrast_25, + {minHeight: 64, width: 114}, + a.justify_center, + a.align_center, + ]}> + {linkMeta?.image && ( + <Image + source={linkMeta.image} + accessibilityIgnoresInvertColors + transition={200} + style={[a.absolute, a.inset_0]} + contentFit="cover" + onLoad={() => setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + <CircleXIcon + style={[t.atoms.text_contrast_low]} + size="xl" + /> + )} + </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_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> + )} + </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> + ) +} diff --git a/src/components/live/LiveIndicator.tsx b/src/components/live/LiveIndicator.tsx new file mode 100644 index 000000000..c237e8c83 --- /dev/null +++ b/src/components/live/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<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_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> + ) +} diff --git a/src/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx new file mode 100644 index 000000000..c892dea58 --- /dev/null +++ b/src/components/live/LiveStatusDialog.tsx @@ -0,0 +1,212 @@ +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 {logger} from '#/logger' +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 * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +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, +}: { + 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 profile={profile} embed={embed} navigation={navigation} /> + </Dialog.Outer> + ) +} + +function DialogInner({ + profile, + embed, + navigation, +}: { + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + navigation: NavigationProp +}) { + 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 + profile={profile} + embed={embed} + onPressOpenProfile={onPressOpenProfile} + /> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +export function LiveStatus({ + profile, + embed, + padding = 'xl', + onPressOpenProfile, +}: { + profile: bsky.profile.AnyProfileView + embed: AppBskyEmbedExternal.View + padding?: 'lg' | 'xl' + onPressOpenProfile: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const openLink = useOpenLink() + const moderationOpts = useModerationOpts() + + return ( + <> + {embed.external.thumb && ( + <View + style={[ + t.atoms.bg_contrast_25, + a.w_full, + {aspectRatio: 1.91}, + 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.flex_1, a.justify_center, a.gap_2xs]}> + <Text + numberOfLines={3} + style={[a.leading_snug, a.font_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={() => { + logger.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={() => { + logger.metric('live:card:openProfile', {subject: profile.did}) + unstableCacheProfileView(queryClient, profile) + onPressOpenProfile() + }}> + <ButtonText> + <Trans>Open profile</Trans> + </ButtonText> + </Button> + </ProfileCard.Header> + )} + <Text + style={[ + a.w_full, + a.text_center, + t.atoms.text_contrast_low, + a.text_sm, + ]}> + <Trans>Live feature is in beta testing</Trans> + </Text> + </View> + </> + ) +} diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts new file mode 100644 index 000000000..1958ab49d --- /dev/null +++ b/src/components/live/queries.ts @@ -0,0 +1,187 @@ +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, useQueryClient} from '@tanstack/react-query' + +import {uploadBlob} from '#/lib/api' +import {imageToThumb} from '#/lib/api/resolve' +import {type LinkMeta} from '#/lib/link-meta/link-meta' +import {logger} from '#/logger' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useDialogContext} from '#/components/Dialog' + +export function useUpsertLiveStatusMutation( + duration: number, + linkMeta: LinkMeta | null | undefined, + createdAt?: string, +) { + 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) { + 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) => { + logger.error(`Failed to upsert live status`, { + url: linkMeta?.url, + image: linkMeta?.image, + safeMessage: e, + }) + }, + onSuccess: ({record, image}) => { + if (createdAt) { + logger.metric('live:edit', {duration: record.durationMinutes}) + } else { + logger.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 {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) => { + logger.error(`Failed to remove live status`, { + safeMessage: e, + }) + }, + onSuccess: () => { + logger.metric('live:remove', {}) + Toast.show(_(msg`You are no longer live`)) + control.close(() => { + if (!currentAccount) return + + updateProfileShadow(queryClient, currentAccount.did, { + status: undefined, + }) + }) + }, + }) +} diff --git a/src/components/live/temp.ts b/src/components/live/temp.ts new file mode 100644 index 000000000..fb26b8c06 --- /dev/null +++ b/src/components/live/temp.ts @@ -0,0 +1,41 @@ +import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api' + +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import type * as bsky from '#/types/bsky' + +export const LIVE_DIDS: Record<string, true> = { + 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com + 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social +} + +export const LIVE_SOURCES: Record<string, true> = { + 'nba.com': true, + 'twitch.tv': true, +} + +// TEMP: dumb gating +export function temp__canBeLive(profile: bsky.profile.AnyProfileView) { + if (__DEV__) + return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did] + return !!LIVE_DIDS[profile.did] +} + +export function temp__canGoLive(profile: bsky.profile.AnyProfileView) { + if (__DEV__) return true + return !!LIVE_DIDS[profile.did] +} + +// status must have a embed, and the embed must be an approved host for the status to be valid +export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) { + if (status.status !== 'app.bsky.actor.status#live') return false + try { + if (AppBskyEmbedExternal.isView(status.embed)) { + const url = new URL(status.embed.external.uri) + return !!LIVE_SOURCES[url.hostname] + } else { + return false + } + } catch { + return false + } +} diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts new file mode 100644 index 000000000..6b4267cb0 --- /dev/null +++ b/src/components/live/utils.ts @@ -0,0 +1,37 @@ +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 +} |