This commit is contained in:
Samuel Newman
2025-05-10 00:06:06 +03:00
committed by GitHub
parent 2e80fa3dac
commit a0bd804262
45 changed files with 2021 additions and 451 deletions
+4
View File
@@ -4,6 +4,7 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfilesQuery} from '#/state/queries/profile'
@@ -110,6 +111,7 @@ function AccountItem({
const t = useTheme()
const {_} = useLingui()
const verification = useSimpleVerificationState({profile})
const {isActive: live} = useActorStatus(profile)
const onPress = useCallback(() => {
onSelect(account)
@@ -141,6 +143,8 @@ function AccountItem({
avatar={profile?.avatar}
size={36}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
live={live}
hideLiveBadge
/>
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
+86
View File
@@ -26,6 +26,7 @@ export type ButtonColor =
| 'secondary'
| 'secondary_inverted'
| 'negative'
| 'negative_secondary'
| 'gradient_primary'
| 'gradient_sky'
| 'gradient_midnight'
@@ -336,6 +337,57 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: t.palette.negative_50,
})
} else {
baseStyles.push(a.border, {
borderColor: t.palette.negative_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.negative_100,
})
}
}
} else if (color === 'negative_secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_50,
dim: t.palette.negative_100,
dark: t.palette.negative_100,
}),
})
hoverStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_100,
dim: t.palette.negative_200,
dark: t.palette.negative_200,
}),
})
} else {
baseStyles.push({
backgroundColor: select(t.name, {
light: t.palette.negative_100,
dim: t.palette.negative_50,
dark: t.palette.negative_50,
}),
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
@@ -425,6 +477,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
secondary: tokens.gradients.sky,
secondary_inverted: tokens.gradients.sky,
negative: tokens.gradients.sky,
negative_secondary: tokens.gradients.sky,
gradient_primary: tokens.gradients.primary,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
@@ -645,6 +698,39 @@ export function useSharedButtonTextStyles() {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else if (color === 'negative_secondary') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: select(t.name, {
light: t.palette.negative_500,
dim: t.palette.negative_950,
dark: t.palette.negative_900,
}),
})
} else {
baseStyles.push({
color: select(t.name, {
light: t.palette.negative_500,
dim: t.palette.negative_700,
dark: t.palette.negative_700,
}),
opacity: 0.5,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_400})
} else {
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
}
}
} else {
if (!disabled) {
baseStyles.push({color: t.palette.white})
+13 -3
View File
@@ -307,7 +307,7 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
export function Handle({difference = false}: {difference?: boolean}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -328,9 +328,19 @@ export function Handle() {
width: 35,
height: 5,
alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
difference
? {
// TODO: mixBlendMode is only available on the new architecture -sfn
// backgroundColor: t.palette.white,
// mixBlendMode: 'difference',
backgroundColor: t.palette.white,
opacity: 0.75,
}
: {
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
+1 -7
View File
@@ -195,13 +195,7 @@ export function Inner({
onDismiss={close}
style={{display: 'flex', flexDirection: 'column'}}>
{header}
<View
style={[
gtMobile ? a.p_2xl : a.p_xl,
a.overflow_hidden,
a.rounded_md,
contentContainerStyle,
]}>
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
</DismissableLayer.DismissableLayer>
+7
View File
@@ -8,6 +8,7 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -132,20 +133,25 @@ export function Avatar({
moderationOpts,
onPress,
disabledPreview,
liveOverride,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onPress?: () => void
disabledPreview?: boolean
liveOverride?: boolean
}) {
const moderation = moderateProfile(profile, moderationOpts)
const {isActive: live} = useActorStatus(profile)
return disabledPreview ? (
<UserAvatar
size={40}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
live={liveOverride ?? live}
/>
) : (
<PreviewableUserAvatar
@@ -153,6 +159,7 @@ export function Avatar({
profile={profile}
moderation={moderation.ui('avatar')}
onBeforePress={onPress}
live={liveOverride ?? live}
/>
)
}
+41 -9
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -8,10 +8,13 @@ import {
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {isTouchDevice} from '#/lib/browser'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -32,6 +35,7 @@ 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'
@@ -105,6 +109,8 @@ const HIDE_DELAY = 150
const HIDE_DURATION = 200
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const navigation = useNavigation<NavigationProp>()
const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares,
})
@@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}>
<div style={{willChange: 'transform', ...animationStyle}}>
<Card did={props.did} hide={onPress} />
<Card did={props.did} hide={onPress} navigation={navigation} />
</div>
</div>
</Portal>
@@ -339,7 +345,15 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
)
}
let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
let Card = ({
did,
hide,
navigation,
}: {
did: string
hide: () => void
navigation: NavigationProp
}): React.ReactNode => {
const t = useTheme()
const profile = useProfileQuery({did})
@@ -347,24 +361,42 @@ let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
const data = profile.data
const status = useActorStatus(data)
const onPressOpenProfile = useCallback(() => {
if (!status.isActive || !data) return
hide()
navigation.push('Profile', {
name: data.handle,
})
}, [hide, navigation, status, data])
return (
<View
style={[
a.p_lg,
!status.isActive && a.p_lg,
a.border,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_lg,
{
width: 300,
},
a.w_full,
{maxWidth: status.isActive ? 500 : 300},
]}>
{data && moderationOpts ? (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
status.isActive ? (
<LiveStatus
profile={data}
embed={status.embed}
padding="lg"
onPressOpenProfile={onPressOpenProfile}
/>
) : (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
)
) : (
<View style={[a.justify_center]}>
<View style={[a.justify_center, a.align_center, {minHeight: 200}]}>
<Loader size="xl" />
</View>
)}
+1 -1
View File
@@ -1,4 +1,4 @@
import React from 'react'
import type React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
@@ -45,6 +45,10 @@ export function VerificationReminder({
a.overflow_hidden,
a.pt_md,
t.atoms.bg_contrast_100,
{
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<GradientFill gradient={tokens.gradients.primary} />
<ShieldIcon width={64} fill="white" style={[a.z_10]} />
@@ -1,9 +1,10 @@
import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {useLingui} from '@lingui/react'
import {useTheme} from '#/alf'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {type DateFieldProps} from '#/components/forms/DateField/types'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
@@ -21,6 +22,7 @@ export function DateField({
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {i18n} = useLingui()
const t = useTheme()
const [open, setOpen] = useState(false)
@@ -80,6 +82,8 @@ export function DateField({
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
locale={i18n.locale}
is24hourSource="locale"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
+4 -3
View File
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DateFieldProps} from '#/components/forms/DateField/types'
import {type DateFieldProps} from '#/components/forms/DateField/types'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
@@ -33,7 +33,7 @@ export function DateField({
accessibilityHint,
maximumDate,
}: DateFieldProps) {
const {_} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
@@ -83,10 +83,11 @@ export function DateField({
<View style={[a.relative, a.w_full, a.align_center]}>
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.name === 'light' ? 'light' : 'dark'}
theme={t.scheme}
date={new Date(toSimpleDateString(value))}
onDateChange={onChangeInternal}
mode="date"
locale={i18n.locale}
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Live_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2 12A9.97 9.97 0 0 1 4.929 4.93l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12c0 2.072.786 3.958 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12c0 2.761-1.12 5.262-2.929 7.07a1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A3.99 3.99 0 0 0 8 12.001c0 1.035.393 1.978 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a5.99 5.99 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A3.99 3.99 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z',
})
+348
View File
@@ -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>
)
}
+352
View File
@@ -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, 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={!!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>
)
}
+53
View File
@@ -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>
)
}
+212
View File
@@ -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>
</>
)
}
+187
View File
@@ -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,
})
})
},
})
}
+41
View File
@@ -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
}
}
+37
View File
@@ -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
}