diff options
45 files changed, 2021 insertions, 451 deletions
diff --git a/assets/icons/live_stroke2_corner0_rounded.svg b/assets/icons/live_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..d0e708056 --- /dev/null +++ b/assets/icons/live_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2 12a9.97 9.97 0 0 1 2.929-7.07l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12a7.97 7.97 0 0 0 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 12a9.97 9.97 0 0 1-2.929 7.07 1 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.414A4 4 0 0 0 8 12.001a3.98 3.98 0 0 0 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 12a6 6 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A4 4 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z"/></svg> diff --git a/package.json b/package.json index 7213da03d..f50651a87 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.5", + "@atproto/api": "^0.15.6", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -219,7 +219,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.128", + "@atproto/dev-env": "^0.3.129", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/runtime": "^7.26.0", diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 68aa3cc88..02ad98c5f 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -27,6 +27,9 @@ export const atoms = { relative: { position: 'relative', }, + static: { + position: 'static', + }, sticky: web({ position: 'sticky', }), diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index f2e781ccf..eb770eeca 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -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]}> diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 2d6ddc834..42eb64844 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -26,6 +26,7 @@ export type ButtonColor = | 'secondary' | 'secondary_inverted' | 'negative' + | 'negative_secondary' | 'gradient_primary' | 'gradient_sky' | 'gradient_midnight' @@ -356,6 +357,57 @@ export const Button = React.forwardRef<View, ButtonProps>( }) } } + } 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, + }) + 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, + }) + } + } } if (shape === 'default') { @@ -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}) diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 4c6c5816c..4795385ee 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -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> diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index c43e9c5c0..12bd8819b 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -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> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index aa5830eb9..30b26bead 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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} /> ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 09b587c5e..4f6545a2e 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -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> )} diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts index 2fa064383..37087dc95 100644 --- a/src/components/ProfileHoverCard/types.ts +++ b/src/components/ProfileHoverCard/types.ts @@ -1,4 +1,4 @@ -import React from 'react' +import type React from 'react' export type ProfileHoverCardProps = { children: React.ReactElement diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx index 267b784b0..d6c946956 100644 --- a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx +++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx @@ -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]} /> diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 3be555238..2a89be7d3 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -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} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index b8ecf2e6f..3683ee9e6 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -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} diff --git a/src/components/icons/Live.tsx b/src/components/icons/Live.tsx new file mode 100644 index 000000000..609aa2127 --- /dev/null +++ b/src/components/icons/Live.tsx @@ -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', +}) 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 +} diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts new file mode 100644 index 000000000..30921a88a --- /dev/null +++ b/src/lib/actor-status.ts @@ -0,0 +1,51 @@ +import {useMemo} from 'react' +import { + type $Typed, + type AppBskyActorDefs, + type AppBskyEmbedExternal, +} from '@atproto/api' +import {isAfter, parseISO} from 'date-fns' + +import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' +import {useTickEveryMinute} from '#/state/shell' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' +import type * as bsky from '#/types/bsky' + +export function useActorStatus(actor?: bsky.profile.AnyProfileView) { + const shadowed = useMaybeProfileShadow(actor) + const tick = useTickEveryMinute() + return useMemo(() => { + tick! // revalidate every minute + + if ( + shadowed && + temp__canBeLive(shadowed) && + 'status' in shadowed && + shadowed.status && + temp__isStatusValid(shadowed.status) && + isStatusStillActive(shadowed.status.expiresAt) + ) { + return { + isActive: true, + status: 'app.bsky.actor.status#live', + embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this + expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this + record: shadowed.status.record, + } satisfies AppBskyActorDefs.StatusView + } else { + return { + status: '', + isActive: false, + record: {}, + } satisfies AppBskyActorDefs.StatusView + } + }, [shadowed, tick]) +} + +export function isStatusStillActive(timeStr: string | undefined) { + if (!timeStr) return false + const now = new Date() + const expiry = parseISO(timeStr) + + return isAfter(expiry, now) +} diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 371062350..93d16ff0c 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,10 +1,10 @@ import { - AppBskyFeedDefs, - AppBskyGraphDefs, - ComAtprotoRepoStrongRef, + type AppBskyFeedDefs, + type AppBskyGraphDefs, + type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {BskyAgent} from '@atproto/api' +import {type BskyAgent} from '@atproto/api' import {POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta} from '#/lib/link-meta/link-meta' @@ -22,9 +22,9 @@ import { isBskyStartUrl, isShortLink, } from '#/lib/strings/url-helpers' -import {ComposerImage} from '#/state/gallery' +import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {createGIFDescription} from '../gif-alt-text' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' @@ -213,7 +213,7 @@ async function resolveExternal( } } -async function imageToThumb( +export async function imageToThumb( imageUri: string, ): Promise<ComposerImage | undefined> { try { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bb98f9fc8..dca03647a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -31,6 +31,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = { 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol 'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me 'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social + 'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social } const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 20c3fabbc..ad194714a 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -372,3 +372,33 @@ export function getServiceAuthAudFromUrl(url: string | URL): string | null { } return `did:web:${hostname}` } + +// passes URL.parse, and has a TLD etc +export function definitelyUrl(maybeUrl: string) { + try { + if (maybeUrl.endsWith('.')) return null + + // Prepend 'https://' if the input doesn't start with a protocol + if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) { + maybeUrl = 'https://' + maybeUrl + } + + const url = new URL(maybeUrl) + + // Extract the hostname and split it into labels + const hostname = url.hostname + const labels = hostname.split('.') + + // Ensure there are at least two labels (e.g., 'example' and 'com') + if (labels.length < 2) return null + + const tld = labels[labels.length - 1] + + // Check that the TLD is at least two characters long and contains only letters + if (!/^[a-z]{2,}$/i.test(tld)) return null + + return url.toString() + } catch { + return null + } +} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 42b0d6ef3..665633d7c 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -383,4 +383,13 @@ export type MetricEvents = { } 'verification:settings:hideBadges': {} 'verification:settings:unHideBadges': {} + + 'live:create': {duration: number} + 'live:edit': {} + 'live:remove': {} + 'live:card:open': {subject: string; from: 'post' | 'profile'} + 'live:card:watch': {subject: string} + 'live:card:openProfile': {subject: string} + 'live:view:profile': {subject: string} + 'live:view:post': {subject: string; feed?: string} } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2dff101e6..1639abaf0 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -9,6 +9,7 @@ import { 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 {logger} from '#/logger' @@ -138,6 +139,8 @@ let ProfileHeaderStandard = ({ [currentAccount, profile], ) + const {isActive: live} = useActorStatus(profile) + return ( <ProfileHeaderShell profile={profile} @@ -228,7 +231,8 @@ let ProfileHeaderStandard = ({ ) : null} <ProfileMenu profile={profile} /> </View> - <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}> + <View + style={[a.flex_col, a.gap_2xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> <Text emoji diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index a3efdedf5..9e868c474 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,24 +1,35 @@ -import React, {memo} from 'react' +import React, {memo, useEffect} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' +import { + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {BACK_HITSLOP} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {isIOS} from '#/platform/detection' -import {Shadow} from '#/state/cache/types' +import {type Shadow} from '#/state/cache/types' import {useLightboxControls} from '#/state/lightbox' import {useSession} from '#/state/session' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, platform, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {EditLiveDialog} from '#/components/live/EditLiveDialog' +import {LiveIndicator} from '#/components/live/LiveIndicator' +import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {GrowableAvatar} from './GrowableAvatar' @@ -45,6 +56,8 @@ let ProfileHeaderShell = ({ const {openLightbox} = useLightboxControls() const navigation = useNavigation<NavigationProp>() const {top: topInset} = useSafeAreaInsets() + const playHaptic = useHaptics() + const liveStatusControl = useDialogControl() const aviRef = useHandleRef() @@ -79,24 +92,46 @@ let ProfileHeaderShell = ({ [openLightbox], ) - const onPressAvi = React.useCallback(() => { - const modui = moderation.ui('avatar') - const avatar = profile.avatar - if (avatar && !(modui.blur && modui.noOverride)) { - const aviHandle = aviRef.current - runOnUI(() => { - 'worklet' - const rect = measureHandle(aviHandle) - runOnJS(_openLightbox)(avatar, rect) - })() - } - }, [profile, moderation, _openLightbox, aviRef]) - const isMe = React.useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) + const live = useActorStatus(profile) + + useEffect(() => { + if (live.isActive) { + logger.metric('live:view:profile', {subject: profile.did}) + } + }, [live.isActive, profile.did]) + + const onPressAvi = React.useCallback(() => { + if (live.isActive) { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'profile'}) + liveStatusControl.open() + } else { + const modui = moderation.ui('avatar') + const avatar = profile.avatar + if (avatar && !(modui.blur && modui.noOverride)) { + const aviHandle = aviRef.current + runOnUI(() => { + 'worklet' + const rect = measureHandle(aviHandle) + runOnJS(_openLightbox)(avatar, rect) + })() + } + } + }, [ + profile, + moderation, + _openLightbox, + aviRef, + liveStatusControl, + live, + playHaptic, + ]) + return ( <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> <View @@ -170,21 +205,44 @@ let ProfileHeaderShell = ({ <View style={[ t.atoms.bg, - {borderColor: t.atoms.bg.backgroundColor}, + a.rounded_full, + { + borderWidth: live.isActive ? 3 : 2, + borderColor: live.isActive + ? t.palette.negative_500 + : t.atoms.bg.backgroundColor, + }, styles.avi, profile.associated?.labeler && styles.aviLabeler, ]}> <View ref={aviRef} collapsable={false}> <UserAvatar type={profile.associated?.labeler ? 'labeler' : 'user'} - size={90} + size={live.isActive ? 88 : 90} avatar={profile.avatar} moderation={moderation.ui('avatar')} /> + {live.isActive && <LiveIndicator size="large" />} </View> </View> </TouchableWithoutFeedback> </GrowableAvatar> + + {live.isActive && + (isMe ? ( + <EditLiveDialog + control={liveStatusControl} + status={live} + embed={live.embed} + /> + ) : ( + <LiveStatusDialog + control={liveStatusControl} + status={live} + embed={live.embed} + profile={profile} + /> + ))} </View> ) } @@ -219,8 +277,6 @@ const styles = StyleSheet.create({ avi: { width: 94, height: 94, - borderRadius: 47, - borderWidth: 2, }, aviLabeler: { borderRadius: 10, diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 76eb48203..9f36c27ac 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' +import {useActorStatus} from '#/lib/actor-status' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' @@ -287,6 +288,7 @@ function ProfilePreview({ const verificationState = useFullVerificationState({ profile: shadow, }) + const {isActive: live} = useActorStatus(profile) if (!moderationOpts) return null @@ -303,6 +305,7 @@ function ProfilePreview({ avatar={shadow.avatar} moderation={moderation.ui('avatar')} type={shadow.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View @@ -468,6 +471,7 @@ function AccountRow({ const moderationOpts = useModerationOpts() const removePromptControl = Prompt.usePromptControl() const {removeAccount} = useSessionApi() + const {isActive: live} = useActorStatus(profile) const onSwitchAccount = () => { if (pendingDid) return @@ -485,6 +489,8 @@ function AccountRow({ avatar={profile.avatar} moderation={moderateProfile(profile, moderationOpts).ui('avatar')} type={profile.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> ) : ( <View style={[{width: 28}]} /> diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 9c23e4550..a1212d8a2 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -31,6 +31,7 @@ export interface ProfileShadow { muted: boolean | undefined blockingUri: string | undefined verification: AppBskyActorDefs.VerificationState + status: AppBskyActorDefs.StatusView | undefined } const shadows: WeakMap< @@ -138,6 +139,12 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>( }, verification: 'verification' in shadow ? shadow.verification : profile.verification, + status: + 'status' in shadow + ? shadow.status + : 'status' in profile + ? profile.status + : undefined, }) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 10c3e6b4d..3925ce9bd 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -16,6 +16,7 @@ import { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' @@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({ setLimitLines(false) }, [setLimitLines]) + const {isActive: live} = useActorStatus(post.author) + if (!record) { return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> } @@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View style={[a.flex_1]}> <View style={[a.flex_row, a.align_center]}> @@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {showChildReplyLine && ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index c6cf254f3..03463f977 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,7 +27,6 @@ import { import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -174,14 +173,12 @@ function PostInner({ {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={styles.layoutContent}> <PostMeta diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx deleted file mode 100644 index 1c894bffe..000000000 --- a/src/view/com/posts/AviFollowButton.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useSession} from '#/state/session' -import { - DropdownItem, - NativeDropdown, -} from '#/view/com/util/forms/NativeDropdown' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, select, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useFollowMethods} from '#/components/hooks/useFollowMethods' -import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' - -export function AviFollowButton({ - author, - moderation, - children, -}: { - author: AppBskyActorDefs.ProfileViewBasic - moderation: ModerationDecision - children: React.ReactNode -}) { - const {_} = useLingui() - const t = useTheme() - const profile = useProfileShadow(author) - const {follow} = useFollowMethods({ - profile: profile, - logContext: 'AvatarButton', - }) - const {currentAccount, hasSession} = useSession() - const navigation = useNavigation<NavigationProp>() - - const name = sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - const isFollowing = - profile.viewer?.following || profile.did === currentAccount?.did - - function onPress() { - follow() - Toast.show(_(msg`Following ${name}`)) - } - - const items: DropdownItem[] = [ - { - label: _(msg`View profile`), - onPress: () => { - navigation.navigate('Profile', {name: profile.did}) - }, - icon: { - ios: { - name: 'arrow.up.right.square', - }, - android: '', - web: ['far', 'arrow-up-right-from-square'], - }, - }, - { - label: _(msg`Follow ${name}`), - onPress: onPress, - icon: { - ios: { - name: 'person.badge.plus', - }, - android: '', - web: ['far', 'user-plus'], - }, - }, - ] - - return hasSession ? ( - <View style={a.relative}> - {children} - - {!isFollowing && ( - <Button - label={_(msg`Open ${name} profile shortcut menu`)} - style={[ - a.rounded_full, - a.absolute, - { - bottom: -7, - right: -7, - }, - ]}> - <NativeDropdown items={items}> - <View - style={[ - { - // An asymmetric hit slop - // to prioritize bottom right taps. - paddingTop: 2, - paddingLeft: 2, - paddingBottom: 6, - paddingRight: 6, - }, - a.align_center, - a.justify_center, - a.rounded_full, - ]}> - <View - style={[ - a.rounded_full, - a.align_center, - select(t.name, { - light: t.atoms.bg_contrast_100, - dim: t.atoms.bg_contrast_100, - dark: t.atoms.bg_contrast_200, - }), - { - borderWidth: 1, - borderColor: t.atoms.bg.backgroundColor, - }, - ]}> - <Plus - size="sm" - fill={ - select(t.name, { - light: t.atoms.bg_contrast_600, - dim: t.atoms.bg_contrast_500, - dark: t.atoms.bg_contrast_600, - }).backgroundColor - } - /> - </View> - </View> - </NativeDropdown> - </Button> - )} - </View> - ) : ( - children - ) -} diff --git a/src/view/com/posts/AviFollowButton.web.tsx b/src/view/com/posts/AviFollowButton.web.tsx deleted file mode 100644 index 90b2ddeec..000000000 --- a/src/view/com/posts/AviFollowButton.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react' - -export function AviFollowButton({children}: {children: React.ReactNode}) { - return children -} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 181b35026..b4c2b2710 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -1,4 +1,4 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useCallback, useRef} from 'react' import { ActivityIndicator, AppState, @@ -19,6 +19,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {isStatusStillActive} from '#/lib/actor-status' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent} from '#/lib/statsig/statsig' @@ -52,6 +53,7 @@ import { } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' @@ -775,6 +777,31 @@ let PostFeed = ({ ) }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) + const seenActorWithStatusRef = useRef<Set<string>>(new Set()) + const onItemSeen = useCallback( + (item: FeedRow) => { + feedFeedback.onItemSeen(item) + if (item.type === 'sliceItem') { + const actor = item.slice.items[item.indexInSlice].post.author + if ( + actor.status && + temp__canBeLive(actor) && + temp__isStatusValid(actor.status) && + isStatusStillActive(actor.status.expiresAt) + ) { + if (!seenActorWithStatusRef.current.has(actor.did)) { + seenActorWithStatusRef.current.add(actor.did) + logger.metric('live:view:post', { + subject: actor.did, + feed, + }) + } + } + } + }, + [feedFeedback, feed], + ) + return ( <View testID={testID} style={style}> <List @@ -797,7 +824,6 @@ let PostFeed = ({ onEndReachedThreshold={2} // number of posts left to trigger load more removeClippedSubviews={true} extraData={extraData} - // @ts-ignore our .web version only -prf desktopFixedHeight={ desktopFixedHeightOffset ? desktopFixedHeightOffset : true } @@ -805,7 +831,7 @@ let PostFeed = ({ windowSize={9} maxToRenderPerBatch={isIOS ? 5 : 1} updateCellsBatchingPeriod={40} - onItemSeen={feedFeedback.onItemSeen} + onItemSeen={onItemSeen} /> </View> ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 123a8b0c2..ceb653b9c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' -import {AviFollowButton} from './AviFollowButton' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,6 +251,8 @@ let FeedItemInner = ({ ? rootPost.threadgate.record : undefined + const {isActive: live} = useActorStatus(post.author) + return ( <Link testID={`feedItem-by-${post.author.handle}`} @@ -381,15 +383,14 @@ let FeedItemInner = ({ <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - onBeforePress={onOpenAuthor} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + onBeforePress={onOpenAuthor} + live={live} + /> {isThreadParent && ( <View style={[ @@ -397,7 +398,7 @@ let FeedItemInner = ({ { flexGrow: 1, backgroundColor: pal.colors.replyLine, - marginTop: 4, + marginTop: live ? 8 : 4, }, ]} /> diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 43ec44834..1c2a7d62d 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -23,12 +24,14 @@ import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' +import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' @@ -38,6 +41,9 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {EditLiveDialog} from '#/components/live/EditLiveDialog' +import {GoLiveDialog} from '#/components/live/GoLiveDialog' +import {temp__canGoLive} from '#/components/live/temp' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -77,6 +83,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() + const goLiveDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -201,6 +208,8 @@ let ProfileMenu = ({ return v.issuer === currentAccount?.did }) ?? [] + const status = useActorStatus(profile) + return ( <EventStopper onKeyDown={false}> <Menu.Root> @@ -290,6 +299,25 @@ let ProfileMenu = ({ </Menu.ItemText> <Menu.ItemIcon icon={List} /> </Menu.Item> + {isSelf && temp__canGoLive(profile) && ( + <Menu.Item + testID="profileHeaderDropdownListAddRemoveBtn" + label={ + status.isActive + ? _(msg`Edit live status`) + : _(msg`Go live`) + } + onPress={goLiveDialogControl.open}> + <Menu.ItemText> + {status.isActive ? ( + <Trans>Edit live status</Trans> + ) : ( + <Trans>Go live</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={LiveIcon} /> + </Menu.Item> + )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( @@ -456,6 +484,16 @@ let ProfileMenu = ({ profile={profile} verifications={currentAccountVerifications} /> + + {status.isActive ? ( + <EditLiveDialog + control={goLiveDialogControl} + status={status} + embed={status.embed} + /> + ) : ( + <GoLiveDialog control={goLiveDialogControl} profile={profile} /> + )} </EventStopper> ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index fd8e3a38b..62ba32c9b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import type React from 'react' +import {useActorStatus} from '#/lib/actor-status' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' @@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const timestampLabel = niceDate(i18n, opts.timestamp) const verification = useSimpleVerificationState({profile: author}) + const {isActive: live} = useActorStatus(author) return ( <View @@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { profile={author} moderation={opts.moderation?.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 326a2fff8..b3bf144f7 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' +import {isTouchDevice} from '#/lib/browser' +import {useHaptics} from '#/lib/haptics' import { useCameraPermission, usePhotoLibraryPermission, @@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import { @@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { @@ -42,6 +48,8 @@ import { import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' +import {LiveIndicator} from '#/components/live/LiveIndicator' +import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -54,6 +62,8 @@ interface BaseUserAvatarProps { shape?: 'circle' | 'square' size: number avatar?: string | null + live?: boolean + hideLiveBadge?: boolean } interface UserAvatarProps extends BaseUserAvatarProps { @@ -196,27 +206,38 @@ let UserAvatar = ({ usePlainRNImage = false, onLoad, style, + live, + hideLiveBadge, }: UserAvatarProps): React.ReactNode => { const t = useTheme() - const backgroundColor = t.palette.contrast_25 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { + let borderRadius if (finalShape === 'square') { - return { - width: size, - height: size, - borderRadius: size > 32 ? 8 : 3, - backgroundColor, - } + borderRadius = size > 32 ? 8 : 3 + } else { + borderRadius = Math.floor(size / 2) } + return { width: size, height: size, - borderRadius: Math.floor(size / 2), - backgroundColor, + borderRadius, + backgroundColor: t.palette.contrast_25, } - }, [finalShape, size, backgroundColor]) + }, [finalShape, size, t]) + + const borderStyle = useMemo(() => { + return [ + {borderRadius: aviStyle.borderRadius}, + live && { + borderColor: t.palette.negative_500, + borderWidth: size > 16 ? 2 : 1, + opacity: 1, + }, + ] + }, [aviStyle.borderRadius, live, t, size]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -277,12 +298,19 @@ let UserAvatar = ({ onLoad={onLoad} /> )} - <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) : ( <View style={containerStyle}> <DefaultAvatar type={type} shape={finalShape} size={size} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) @@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({ disableHoverCard, disableNavigation, onBeforePress, + live, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() + const status = useActorStatus(profile) + const liveControl = useDialogControl() + const playHaptic = useHaptics() - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { onBeforePress?.() unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) + const onOpenLiveStatus = useCallback(() => { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'post'}) + liveControl.open() + }, [liveControl, playHaptic, profile.did]) + const avatarEl = ( <UserAvatar avatar={profile.avatar} moderation={moderation} type={profile.associated?.labeler ? 'labeler' : 'user'} + live={status.isActive || live} {...rest} /> ) @@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({ <ProfileHoverCard did={profile.did} disable={disableHoverCard}> {disableNavigation ? ( avatarEl + ) : status.isActive && (isNative || isTouchDevice) ? ( + <> + <Button + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} + accessibilityHint={_(msg`Opens live status dialog`)} + onPress={onOpenLiveStatus}> + {avatarEl} + </Button> + <LiveStatusDialog + control={liveControl} + profile={profile} + status={status} + embed={status.embed} + /> + </> ) : ( <Link - label={_(msg`${profile.displayName || profile.handle}'s avatar`)} + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} accessibilityHint={_(msg`Opens this profile`)} to={makeProfileLink({ did: profile.did, diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index 66040c2e3..98c16d144 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -4,10 +4,10 @@ import {View} from 'react-native' import {atoms as a} from '#/alf' import { Button, - ButtonColor, + type ButtonColor, ButtonIcon, ButtonText, - ButtonVariant, + type ButtonVariant, } from '#/components/Button' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' @@ -19,31 +19,35 @@ export function Buttons() { <H1>Buttons</H1> <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> - {['primary', 'secondary', 'secondary_inverted', 'negative'].map( - color => ( - <View key={color} style={[a.gap_md, a.align_start]}> - {['solid', 'outline', 'ghost'].map(variant => ( - <React.Fragment key={variant}> - <Button - variant={variant as ButtonVariant} - color={color as ButtonColor} - size="large" - label="Click here"> - <ButtonText>Button</ButtonText> - </Button> - <Button - disabled - variant={variant as ButtonVariant} - color={color as ButtonColor} - size="large" - label="Click here"> - <ButtonText>Button</ButtonText> - </Button> - </React.Fragment> - ))} - </View> - ), - )} + {[ + 'primary', + 'secondary', + 'secondary_inverted', + 'negative', + 'negative_secondary', + ].map(color => ( + <View key={color} style={[a.gap_md, a.align_start]}> + {['solid', 'outline', 'ghost'].map(variant => ( + <React.Fragment key={variant}> + <Button + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + <ButtonText>Button</ButtonText> + </Button> + <Button + disabled + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + <ButtonText>Button</ButtonText> + </Button> + </React.Fragment> + ))} + </View> + ))} <View style={[a.flex_row, a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}> diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 0146bc3c6..a6c2ecdde 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View} from 'react-native' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {useSetThemePrefs} from '#/state/shell' import {ListContained} from '#/view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider} from '#/alf' @@ -115,7 +115,6 @@ function StorybookInner() { <Typography /> <Spacing /> <Shadows /> - <Buttons /> <Icons /> <Links /> <Dialogs /> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index d51db3960..c4624e8e1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {type PressableScale} from '#/lib/custom-animations/PressableScale' import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' @@ -67,6 +68,7 @@ let DrawerProfileCard = ({ const t = useTheme() const {data: profile} = useProfileQuery({did: account.did}) const verification = useSimpleVerificationState({profile}) + const {isActive: live} = useActorStatus(profile) return ( <TouchableOpacity @@ -81,6 +83,7 @@ let DrawerProfileCard = ({ // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View style={[a.gap_2xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index df6a045dc..92be6c67e 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react' import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' import {StackActions} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {BOTTOM_BAR_AVI} from '#/lib/demo' import {useHaptics} from '#/lib/haptics' @@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { }, [accountSwitchControl, playHaptic]) const [demoMode] = useDemoMode() + const {isActive: live} = useActorStatus(profile) return ( <> @@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) { pal.text, styles.profileIcon, styles.onProfile, - {borderColor: pal.text.color}, + { + borderColor: pal.text.color, + borderWidth: live ? 0 : 1, + }, ]}> <UserAvatar avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} - size={iconWidth - 3} + size={iconWidth - 2} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> ) : ( <View - style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + { + borderWidth: live ? 0 : 1, + }, + ]}> <UserAvatar avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} - size={iconWidth - 3} + size={iconWidth - 2} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> )} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 7d34a3d14..f6c852ca1 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -9,6 +9,7 @@ import { useNavigationState, } from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' @@ -100,6 +101,8 @@ function ProfileCard() { profile: profiles?.find(p => p.did === account.did), })) + const {isActive: live} = useActorStatus(profile) + return ( <View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}> {!isLoading && profile ? ( @@ -142,6 +145,7 @@ function ProfileCard() { avatar={profile.avatar} size={size} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> </View> {!leftNavMinimal && ( @@ -226,7 +230,6 @@ function SwitchMenuItems({ signOutPromptControl: DialogControlProps }) { const {_} = useLingui() - const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const closeEverything = useCloseAllActiveElements() @@ -243,35 +246,11 @@ function SwitchMenuItems({ <Trans>Switch account</Trans> </Menu.LabelText> {accounts.map(other => ( - <Menu.Item - disabled={!!pendingDid} - style={[{minWidth: 150}]} + <SwitchMenuItem key={other.account.did} - label={_( - msg`Switch to ${sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )}`, - )} - onPress={() => - onPressSwitchAccount(other.account, 'SwitchAccount') - }> - <View style={[{marginLeft: tokens.space._2xs * -1}]}> - <UserAvatar - avatar={other.profile?.avatar} - size={20} - type={ - other.profile?.associated?.labeler ? 'labeler' : 'user' - } - /> - </View> - <Menu.ItemText> - {sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )} - </Menu.ItemText> - </Menu.Item> + account={other.account} + profile={other.profile} + /> ))} </Menu.Group> <Menu.Divider /> @@ -295,6 +274,45 @@ function SwitchMenuItems({ ) } +function SwitchMenuItem({ + account, + profile, +}: { + account: SessionAccount + profile: AppBskyActorDefs.ProfileViewDetailed | undefined +}) { + const {_} = useLingui() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() + const {isActive: live} = useActorStatus(profile) + + return ( + <Menu.Item + disabled={!!pendingDid} + style={[a.gap_sm, {minWidth: 150}]} + key={account.did} + label={_( + msg`Switch to ${sanitizeHandle( + profile?.handle ?? account.handle, + '@', + )}`, + )} + onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}> + <View> + <UserAvatar + avatar={profile?.avatar} + size={20} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge + /> + </View> + <Menu.ItemText> + {sanitizeHandle(profile?.handle ?? account.handle, '@')} + </Menu.ItemText> + </Menu.Item> + ) +} + interface NavItemProps { count?: string hasNew?: boolean diff --git a/yarn.lock b/yarn.lock index 34fb2f698..261622c33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56,36 +56,36 @@ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8" integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA== -"@atproto-labs/xrpc-utils@0.0.13": - version "0.0.13" - resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.13.tgz#5d684bc574537066d3c3404b4d69c03b8672f9a4" - integrity sha512-uQnZhpHFa3EDHct+/slPl5+q2myMBTr6stZbdb6O877wtjEwN4C/7A0eMKaIqETmtxULYpZGapYJ7PG34aa7uQ== +"@atproto-labs/xrpc-utils@0.0.14": + version "0.0.14" + resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.14.tgz#eefe1ccf61a4288708601324496b0106d5ed4ae3" + integrity sha512-/f0Dhzi08w3Oqv38wdwQ5bw238GbxhYIcxg08kVReEMTlkyRDC6H5RuqHf8Ff9J3FKqjKHGdxaOdrPNM1hCgeQ== dependencies: - "@atproto/xrpc" "^0.6.12" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc" "^0.7.0" + "@atproto/xrpc-server" "^0.7.18" -"@atproto/api@^0.15.5": - version "0.15.5" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.5.tgz#cd7e12fd4d4546a73a8e0ea4e7737f883ac3d2a2" - integrity sha512-GiKOrjSXMm8OSpc+pfjFTBYQGX62jmorECkTx2VZbS6KtFKFY0cRQAI+JnQoOLF/8TvzpaAZB7+it73uIqDM7A== +"@atproto/api@^0.15.6": + version "0.15.6" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.6.tgz#3832f16641d89c687794cea14b4aba05ba5993c8" + integrity sha512-hKwrBf60LcI4BqArWyrhWJWIpjwAWUJpW3PVvNzUB1q2W/ByC0JAuwq/F8tZpCEiiVBzHjHVRx4QNA2TA1cG3g== dependencies: - "@atproto/common-web" "^0.4.1" - "@atproto/lexicon" "^0.4.10" + "@atproto/common-web" "^0.4.2" + "@atproto/lexicon" "^0.4.11" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc" "^0.6.12" + "@atproto/xrpc" "^0.7.0" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" zod "^3.23.8" -"@atproto/aws@^0.2.20": - version "0.2.20" - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.20.tgz#2b29c12738c8fb2c94f1e1775762319d859c8232" - integrity sha512-lTVkux1gqJuwub1GnyMqWtCc4OSCNKOG2MR+2QUx+qu0Jicqeda7J/7rs6nem+6ngsO7JWm2pfCc6GEUqTiHLQ== +"@atproto/aws@^0.2.21": + version "0.2.21" + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.21.tgz#06006a101c8004db11384a19366296cd87468326" + integrity sha512-bosExZ3YdFjOehNBcNWsC2mZBrAVLO8Ut/JquypXSahFeeXZP/9rd9F1VGf+vAmjFEKagHXQCb6CRFfJyN+I7A== dependencies: - "@atproto/common" "^0.4.10" + "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" - "@atproto/repo" "^0.8.0" + "@atproto/repo" "^0.8.1" "@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-s3" "^3.224.0" @@ -95,23 +95,23 @@ multiformats "^9.9.0" uint8arrays "3.0.0" -"@atproto/bsky@^0.0.147": - version "0.0.147" - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.147.tgz#4a35d80a9659703d1811f3d395e15b3d4d07ad0f" - integrity sha512-sMwzY8qsthlSY8NJRyQaO6dX6p6p9BuzU7pIbw8bhKlFwtdPpjSusIBVDp5XImJGs4Rm2eYqBWQUy3egS8Uytw== +"@atproto/bsky@^0.0.148": + version "0.0.148" + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.148.tgz#f864631e5a9726d3a40c15b0311f730bc16d6bd9" + integrity sha512-09Lzjz9kCK7kPOlJcVj6KbATtoPQwNeeU5s0J2apZYCQmA7wN2xRb5KMf9wr+wa1KO7FwbXKSunwer96dB6zrQ== dependencies: "@atproto-labs/fetch-node" "0.1.8" - "@atproto-labs/xrpc-utils" "0.0.13" - "@atproto/api" "^0.15.5" - "@atproto/common" "^0.4.10" + "@atproto-labs/xrpc-utils" "0.0.14" + "@atproto/api" "^0.15.6" + "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" "@atproto/did" "^0.1.5" - "@atproto/identity" "^0.4.7" - "@atproto/lexicon" "^0.4.10" - "@atproto/repo" "^0.8.0" - "@atproto/sync" "^0.1.22" + "@atproto/identity" "^0.4.8" + "@atproto/lexicon" "^0.4.11" + "@atproto/repo" "^0.8.1" + "@atproto/sync" "^0.1.23" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc-server" "^0.7.18" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" "@connectrpc/connect-express" "^1.1.4" @@ -141,12 +141,12 @@ uint8arrays "3.0.0" undici "^6.19.8" -"@atproto/bsync@^0.0.18": - version "0.0.18" - resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.18.tgz#83c7f061a057f6878b5f65e1b431237f512de072" - integrity sha512-MslGplA3HN9D+L1Ywj0QPmEmg9QyDWjlGSTpqGA+D+GNZ7kjRnf5/XYdQMyUqzD127EqjrbzgCBY82D/GonWoA== +"@atproto/bsync@^0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.19.tgz#bab3d5e4e7c1ca8de16d9b5efebc49dde12d7160" + integrity sha512-AF9aWbU0VlpT//lIuYKhNRplTv+99ld58kfHTS8jfXCpiOZwxwneTkB1hzE+slXJ63K8i/GyzsQCyvRHWzGWCQ== dependencies: - "@atproto/common" "^0.4.10" + "@atproto/common" "^0.4.11" "@atproto/syntax" "^0.4.0" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" @@ -157,10 +157,10 @@ pino-http "^8.2.1" typed-emitter "^2.1.0" -"@atproto/common-web@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.1.tgz#f31054f689f4f52b06da6ffd727e40ecd67a30b6" - integrity sha512-Ghh+djHYMAUCktLKwr2IuGgtjcwSWGudp+K7+N7KBA9pDDloOXUEY8Agjc5SHSo9B1QIEFkegClU5n+apn2e0w== +"@atproto/common-web@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259" + integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw== dependencies: graphemer "^1.4.0" multiformats "^9.9.0" @@ -187,12 +187,12 @@ pino "^8.6.1" zod "^3.14.2" -"@atproto/common@^0.4.10": - version "0.4.10" - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.10.tgz#9dc49364ad856f2833ce24afb5e5c7a07b57f888" - integrity sha512-/Yxnax3XOhf46jYpe8/6O3ORjTNMB4YCaxx3V1f+FKy6meTm3GNrJwo8d1CBs0UiTiheRiNATOV3u0s3C7Ydaw== +"@atproto/common@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6" + integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g== dependencies: - "@atproto/common-web" "^0.4.1" + "@atproto/common-web" "^0.4.2" "@ipld/dag-cbor" "^7.0.3" cbor-x "^1.5.1" iso-datestring-validator "^2.2.2" @@ -219,23 +219,23 @@ "@noble/hashes" "^1.6.1" uint8arrays "3.0.0" -"@atproto/dev-env@^0.3.128": - version "0.3.128" - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.128.tgz#0a06dc71be671eaa09464b15c65f2b27b9e60cfd" - integrity sha512-1qxPLQLaAUH6SOQJoje9O7LaZ0dp+P/oS/4OQ3I9hYJAZn78dDrFZ7YIC5n1yMTr1iSjHeXvFA9wt0Czcl/uUA== +"@atproto/dev-env@^0.3.129": + version "0.3.130" + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.130.tgz#444ad315c00bdcf8bdae036d1e6a56a1808b98c6" + integrity sha512-xRQb+b09lpdG1vGdvMk8Yf/AnO4SDQTjKLyPO+LYYeHuOrKKjJWiBorFC8Lp/rnraoM3AcwMKmW48wdd7cOL9g== dependencies: - "@atproto/api" "^0.15.5" - "@atproto/bsky" "^0.0.147" - "@atproto/bsync" "^0.0.18" - "@atproto/common-web" "^0.4.1" + "@atproto/api" "^0.15.6" + "@atproto/bsky" "^0.0.148" + "@atproto/bsync" "^0.0.19" + "@atproto/common-web" "^0.4.2" "@atproto/crypto" "^0.4.4" - "@atproto/identity" "^0.4.7" - "@atproto/lexicon" "^0.4.10" - "@atproto/ozone" "^0.1.108" - "@atproto/pds" "^0.4.134" - "@atproto/sync" "^0.1.22" + "@atproto/identity" "^0.4.8" + "@atproto/lexicon" "^0.4.11" + "@atproto/ozone" "^0.1.109" + "@atproto/pds" "^0.4.136" + "@atproto/sync" "^0.1.23" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc-server" "^0.7.18" "@did-plc/lib" "^0.0.1" "@did-plc/server" "^0.0.1" dotenv "^16.0.3" @@ -252,12 +252,12 @@ dependencies: zod "^3.23.8" -"@atproto/identity@^0.4.7": - version "0.4.7" - resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.7.tgz#1f8958e49f6046f3412463269e1fc961c0936ff2" - integrity sha512-A61OT9yc74dEFi1elODt/tzQNSwV3ZGZCY5cRl6NYO9t/0AVdaD+fyt81yh3mRxyI8HeVOecvXl3cPX5knz9rQ== +"@atproto/identity@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.8.tgz#28ae9f8fe0e83196c5b6747394e759a330a101d9" + integrity sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA== dependencies: - "@atproto/common-web" "^0.4.1" + "@atproto/common-web" "^0.4.2" "@atproto/crypto" "^0.4.4" "@atproto/jwk-jose@0.1.6": @@ -276,56 +276,56 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.10": - version "0.4.10" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.10.tgz#276790a1bca060a55c80d556ce763eaa81f6e944" - integrity sha512-uDbP20vetBgtXPuxoyRcvOGBt2gNe1dFc9yYKcb6jWmXfseHiGTnIlORJOLBXIT2Pz15Eap4fLxAu6zFAykD5A== +"@atproto/lexicon@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617" + integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw== dependencies: - "@atproto/common-web" "^0.4.1" + "@atproto/common-web" "^0.4.2" "@atproto/syntax" "^0.4.0" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" zod "^3.23.8" -"@atproto/oauth-provider-api@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.1.tgz#ccca589757cd652015db58dee645463290eddacc" - integrity sha512-Ry7viVoMHzzyohK0UKX/7gJgkWndCchydzAfVV1lmP+84sw7Foci+rXN/laE5EnpVB8QIUW3GmQ93jecbBiyeg== +"@atproto/oauth-provider-api@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.2.tgz#cdca03af4426f8cf9b09bc9eb57a8604f9513831" + integrity sha512-tNAuMrE6D3696euavxo1+Jh7Re0PPwJstbyY8SrdVPXgKJh/LrbpKUKiPNW/p5KyVfRs2tWeAxy+ReESu6SmXA== dependencies: "@atproto/jwk" "0.1.5" - "@atproto/oauth-types" "0.2.6" + "@atproto/oauth-types" "0.2.7" -"@atproto/oauth-provider-frontend@0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.3.tgz#3cd724bf3dfae99f2b3b152c96963762ff188b89" - integrity sha512-dN/WRMOmj1Bd32i6diX/J+zZ5bZWX+NbQ0BAMjMANpii2gFrgc/pk/zmutYwUhyQeIl7rLkag+nQC4Jhg1I6BQ== +"@atproto/oauth-provider-frontend@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.4.tgz#240a2e58c29d32fa7d4ea9d142c00c23d2469452" + integrity sha512-TLKL5lTmSieHx7+3RVIx7rIxRPP1SNCwzzdTvYB46yd1XrGHdPU//M6CP5OZ1BvcxF6H4JXIkOSWvFseol+gOw== optionalDependencies: - "@atproto/oauth-provider-api" "0.1.1" + "@atproto/oauth-provider-api" "0.1.2" -"@atproto/oauth-provider-ui@0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.3.tgz#dc130c308fe3a422a498abc7284e68a722d17ebb" - integrity sha512-Zxxm9nhGMS1ByvaA79zqalpBU2ub4+3gLPkRVQjp/F8jHOedxrocUAKM61B3KTtDdFGvfzvPZ28pxunwy/Rw4g== +"@atproto/oauth-provider-ui@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.4.tgz#5e092d30afa583fdab54fc78371aecb1cbfa017d" + integrity sha512-GTQnB7OUBFSeXcdRseAGYzKe9UUFB/kGjRcIA8+pO5pCMD7JdXI+WliUhsbdmQ2I+OK78aAlCrmygNWpLtpZgg== optionalDependencies: - "@atproto/oauth-provider-api" "0.1.1" + "@atproto/oauth-provider-api" "0.1.2" -"@atproto/oauth-provider@^0.7.5": - version "0.7.5" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.5.tgz#f645800bc76b51ff000a92d67a83c4313db6316d" - integrity sha512-vBRxX7mUDRVbe2rKzRmC3OfyoqAs8N7OA+MdSD+6b154UFp3+blF5phz5NgtxZzNyKSSeZnXy6xSh27YT8QfRg== +"@atproto/oauth-provider@^0.7.6": + version "0.7.6" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.6.tgz#68bc37303611d548bae9f653d41bc89bd8890152" + integrity sha512-4YcnddACznmpuRmHlt9G+kccdv2Gct5qQOF9Yyjse8cl2Td+Rg1gkchpRdWUnyr9fgZzmCsSBYzEfVXge3eUiQ== dependencies: "@atproto-labs/fetch" "0.2.2" "@atproto-labs/fetch-node" "0.1.8" "@atproto-labs/pipe" "0.1.0" "@atproto-labs/simple-store" "0.2.0" "@atproto-labs/simple-store-memory" "0.1.3" - "@atproto/common" "^0.4.10" + "@atproto/common" "^0.4.11" "@atproto/jwk" "0.1.5" "@atproto/jwk-jose" "0.1.6" - "@atproto/oauth-provider-api" "0.1.1" - "@atproto/oauth-provider-frontend" "0.1.3" - "@atproto/oauth-provider-ui" "0.1.3" - "@atproto/oauth-types" "0.2.6" + "@atproto/oauth-provider-api" "0.1.2" + "@atproto/oauth-provider-frontend" "0.1.4" + "@atproto/oauth-provider-ui" "0.1.4" + "@atproto/oauth-types" "0.2.7" "@atproto/syntax" "0.4.0" "@hapi/accept" "^6.0.3" "@hapi/address" "^5.1.1" @@ -340,27 +340,27 @@ psl "^1.9.0" zod "^3.23.8" -"@atproto/oauth-types@0.2.6": - version "0.2.6" - resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.6.tgz#3cea27b72a6ee274864bd5c791b0c7f369954b03" - integrity sha512-6rUmV7T1YKCgVYLLjm+FGv+dYC8S0+0AHji/azVGDEhTsiadSrlC0H9Pgxix1y89zI1FIf0piBqecBcPewdrJg== +"@atproto/oauth-types@0.2.7": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.7.tgz#c210868052f8babd98510c19816e3d9a156b33c7" + integrity sha512-2SlDveiSI0oowC+sfuNd/npV8jw/FhokSS26qyUyldTg1g9ZlhxXUfMP4IZOPeZcVn9EszzQRHs1H9ZJqVQIew== dependencies: "@atproto/jwk" "0.1.5" zod "^3.23.8" -"@atproto/ozone@^0.1.108": - version "0.1.108" - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.108.tgz#cac673301cebd159a13a8a72281bc917806f3694" - integrity sha512-AuMH/PYmtro8cVnseESRooAerz2EIPwnS4HZgBsSebwBf4HLEGE93yeX26jLAfr57XYgVyYChkXr71LX4t9Erg== +"@atproto/ozone@^0.1.109": + version "0.1.109" + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.109.tgz#538de28cb21c10afa3fbce0140cd695ef7948e09" + integrity sha512-KokZtu5mhYJdNmYqkI2JZ2hiehxXpi8bbULyWE3f0RKbQRBUBGDVBSF8WkuJUuLzaquyYJVtg3MZFp9ELBcg0g== dependencies: - "@atproto/api" "^0.15.5" - "@atproto/common" "^0.4.10" + "@atproto/api" "^0.15.6" + "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" - "@atproto/identity" "^0.4.7" - "@atproto/lexicon" "^0.4.10" + "@atproto/identity" "^0.4.8" + "@atproto/lexicon" "^0.4.11" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc" "^0.6.12" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc" "^0.7.0" + "@atproto/xrpc-server" "^0.7.18" "@did-plc/lib" "^0.0.1" compression "^1.7.4" cors "^2.8.5" @@ -378,24 +378,24 @@ undici "^6.14.1" ws "^8.12.0" -"@atproto/pds@^0.4.134": - version "0.4.134" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.134.tgz#0b9065f4ab6493cce4df7faf1167d2689b12c8b3" - integrity sha512-dS/OOspAv7L9kWqXVizCF64Af87DWF8bzu1QrclqaI29MTTnaaK/PPKd/kAELhBPBi5CVPirvySSqLj0W4Q93A== +"@atproto/pds@^0.4.136": + version "0.4.136" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.136.tgz#53989ff7784c4d1e68d745d69721e71ba82a111d" + integrity sha512-sao4iq/CRWwdM0gljw7XGg/ef4OTWFc6RU2g0nNgJLvxfPO3uMG8Ze1S6tfhr9wvhIKZWVCzzPruTglrlWMEYw== dependencies: "@atproto-labs/fetch-node" "0.1.8" - "@atproto-labs/xrpc-utils" "0.0.13" - "@atproto/api" "^0.15.5" - "@atproto/aws" "^0.2.20" - "@atproto/common" "^0.4.10" + "@atproto-labs/xrpc-utils" "0.0.14" + "@atproto/api" "^0.15.6" + "@atproto/aws" "^0.2.21" + "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" - "@atproto/identity" "^0.4.7" - "@atproto/lexicon" "^0.4.10" - "@atproto/oauth-provider" "^0.7.5" - "@atproto/repo" "^0.8.0" + "@atproto/identity" "^0.4.8" + "@atproto/lexicon" "^0.4.11" + "@atproto/oauth-provider" "^0.7.6" + "@atproto/repo" "^0.8.1" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc" "^0.6.12" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc" "^0.7.0" + "@atproto/xrpc-server" "^0.7.18" "@did-plc/lib" "^0.0.4" "@hapi/address" "^5.1.1" better-sqlite3 "^10.0.0" @@ -425,32 +425,32 @@ undici "^6.19.8" zod "^3.23.8" -"@atproto/repo@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.0.tgz#14261421e2c5fe95b6a8af0b1296fca50b216618" - integrity sha512-Er4Mpd8XWPwVLcUlKFxUpnyBC+J+oBxARoUGXMTLjdQyg0FmWtZzeYnse8FV/L36DeWV0+v/tqYYggJcOOe1HA== +"@atproto/repo@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.1.tgz#be8c6b93c000944b81aaa1026d6c50d82c025d74" + integrity sha512-d1NtHhXYJVJlFVI6mbVOUnpB0rnhqxPnZcALkJoYJjaDPVr4NNqRFAtrwb+GHzxT6DhijoXYQf24pKGfEFDd4g== dependencies: - "@atproto/common" "^0.4.10" - "@atproto/common-web" "^0.4.1" + "@atproto/common" "^0.4.11" + "@atproto/common-web" "^0.4.2" "@atproto/crypto" "^0.4.4" - "@atproto/lexicon" "^0.4.10" + "@atproto/lexicon" "^0.4.11" "@ipld/dag-cbor" "^7.0.0" multiformats "^9.9.0" uint8arrays "3.0.0" varint "^6.0.0" zod "^3.23.8" -"@atproto/sync@^0.1.22": - version "0.1.22" - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.22.tgz#3ba82a3c5f76cee372adc00ba162b65eaf7dadf9" - integrity sha512-FdBHDkNRqWmfUUG52EhkeHH13LdaZ6V5z8978fGMFxXPsTXDE6RSr/vNRt4FUDS5j7+9csKgNV3AyIFUmSYmOg== +"@atproto/sync@^0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.23.tgz#01d4ecf9d5ddc624d14e8fb98927f0b2a97eafeb" + integrity sha512-1ItRNHMLMcBeTziOZpxS4Q+ha2enQce3fSiAQaCpLCQ8VTNq1D1aRR6ePZCQFzab9jDDtBz0v4FufOnMByRIeg== dependencies: - "@atproto/common" "^0.4.10" - "@atproto/identity" "^0.4.7" - "@atproto/lexicon" "^0.4.10" - "@atproto/repo" "^0.8.0" + "@atproto/common" "^0.4.11" + "@atproto/identity" "^0.4.8" + "@atproto/lexicon" "^0.4.11" + "@atproto/repo" "^0.8.1" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc-server" "^0.7.17" + "@atproto/xrpc-server" "^0.7.18" multiformats "^9.9.0" p-queue "^6.6.2" ws "^8.12.0" @@ -460,15 +460,15 @@ resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== -"@atproto/xrpc-server@^0.7.17": - version "0.7.17" - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.17.tgz#39083754dbefd93a89a02e8f64334cb079a90c69" - integrity sha512-il32raoUc/5eqKMtlHMb+ndCx2nx0Fecjd8Fqw6KNTeS6HB6MYSZvIg3blwV/KdUehmOS6rMy6YrgtFK6GbSQQ== +"@atproto/xrpc-server@^0.7.18": + version "0.7.18" + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz#7cb6e517da2afec1c9bee70d92c07667a80718ec" + integrity sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag== dependencies: - "@atproto/common" "^0.4.10" + "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" - "@atproto/lexicon" "^0.4.10" - "@atproto/xrpc" "^0.6.12" + "@atproto/lexicon" "^0.4.11" + "@atproto/xrpc" "^0.7.0" cbor-x "^1.5.1" express "^4.17.2" http-errors "^2.0.0" @@ -478,12 +478,12 @@ ws "^8.12.0" zod "^3.23.8" -"@atproto/xrpc@^0.6.12": - version "0.6.12" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0" - integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w== +"@atproto/xrpc@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0" + integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw== dependencies: - "@atproto/lexicon" "^0.4.10" + "@atproto/lexicon" "^0.4.11" zod "^3.23.8" "@aws-crypto/crc32@3.0.0": |