From a0bd8042621e108f47e09dd096cf0d73fe1cee53 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 10 May 2025 00:06:06 +0300 Subject: Live (#8354) --- assets/icons/live_stroke2_corner0_rounded.svg | 1 + package.json | 4 +- src/alf/atoms.ts | 3 + src/components/AccountList.tsx | 4 + src/components/Button.tsx | 86 +++++ src/components/Dialog/index.tsx | 16 +- src/components/Dialog/index.web.tsx | 8 +- src/components/ProfileCard.tsx | 7 + src/components/ProfileHoverCard/index.web.tsx | 50 ++- src/components/ProfileHoverCard/types.ts | 2 +- .../EmailDialog/screens/VerificationReminder.tsx | 4 + src/components/forms/DateField/index.android.tsx | 6 +- src/components/forms/DateField/index.tsx | 7 +- src/components/icons/Live.tsx | 5 + src/components/live/EditLiveDialog.tsx | 348 ++++++++++++++++++++ src/components/live/GoLiveDialog.tsx | 352 +++++++++++++++++++++ src/components/live/LiveIndicator.tsx | 53 ++++ src/components/live/LiveStatusDialog.tsx | 212 +++++++++++++ src/components/live/queries.ts | 187 +++++++++++ src/components/live/temp.ts | 41 +++ src/components/live/utils.ts | 37 +++ src/lib/actor-status.ts | 51 +++ src/lib/api/resolve.ts | 14 +- src/lib/constants.ts | 1 + src/lib/strings/url-helpers.ts | 30 ++ src/logger/metrics.ts | 9 + .../Profile/Header/ProfileHeaderStandard.tsx | 6 +- src/screens/Profile/Header/Shell.tsx | 100 ++++-- src/screens/Settings/Settings.tsx | 6 + src/state/cache/profile-shadow.ts | 7 + src/view/com/post-thread/PostThreadItem.tsx | 5 + src/view/com/post/Post.tsx | 15 +- src/view/com/posts/AviFollowButton.tsx | 143 --------- src/view/com/posts/AviFollowButton.web.tsx | 5 - src/view/com/posts/PostFeed.tsx | 32 +- src/view/com/posts/PostFeedItem.tsx | 23 +- src/view/com/profile/ProfileMenu.tsx | 38 +++ src/view/com/util/PostMeta.tsx | 4 + src/view/com/util/UserAvatar.tsx | 88 +++++- src/view/screens/Storybook/Buttons.tsx | 58 ++-- src/view/screens/Storybook/index.tsx | 3 +- src/view/shell/Drawer.tsx | 3 + src/view/shell/bottom-bar/BottomBar.tsx | 24 +- src/view/shell/desktop/LeftNav.tsx | 76 +++-- yarn.lock | 298 ++++++++--------- 45 files changed, 2021 insertions(+), 451 deletions(-) create mode 100644 assets/icons/live_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Live.tsx create mode 100644 src/components/live/EditLiveDialog.tsx create mode 100644 src/components/live/GoLiveDialog.tsx create mode 100644 src/components/live/LiveIndicator.tsx create mode 100644 src/components/live/LiveStatusDialog.tsx create mode 100644 src/components/live/queries.ts create mode 100644 src/components/live/temp.ts create mode 100644 src/components/live/utils.ts create mode 100644 src/lib/actor-status.ts delete mode 100644 src/view/com/posts/AviFollowButton.tsx delete mode 100644 src/view/com/posts/AviFollowButton.web.tsx 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 @@ + 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 /> 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' @@ -336,6 +337,57 @@ export const Button = React.forwardRef( borderWidth: 1, }) + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_500, + }) + hoverStyles.push(a.border, { + backgroundColor: t.palette.negative_50, + }) + } else { + baseStyles.push(a.border, { + borderColor: t.palette.negative_200, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: t.palette.negative_100, + }) + } + } + } else if (color === 'negative_secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_50, + dim: t.palette.negative_100, + dark: t.palette.negative_100, + }), + }) + hoverStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_100, + dim: t.palette.negative_200, + dark: t.palette.negative_200, + }), + }) + } else { + baseStyles.push({ + backgroundColor: select(t.name, { + light: t.palette.negative_100, + dim: t.palette.negative_50, + dark: t.palette.negative_50, + }), + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, @@ -425,6 +477,7 @@ export const Button = React.forwardRef( 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, + }, ]} /> 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} - + {children} 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 ? ( ) : ( ) } 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() + const {refs, floatingStyles} = useFloating({ middleware: floatingMiddlewares, }) @@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { onPointerEnter={onPointerEnterCard} onPointerLeave={onPointerLeaveCard}>
- +
@@ -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 ( {data && moderationOpts ? ( - + status.isActive ? ( + + ) : ( + + ) ) : ( - + )} 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, + }, ]}> 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({ + + + + ) +} + +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 ( + + + + + You are Live + + + + + {typeof record?.durationMinutes === 'number' ? ( + + Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '} + {i18n.date(expiryDateTime, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} + + ) : ( + No expiry set + )} + + + + + + + Live link + + + setLiveLinkError('')} + onBlur={() => { + if (!definitelyUrl(liveLink)) { + setLiveLinkError('Invalid URL') + } + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="url" + autoCorrect={false} + onSubmitEditing={() => { + if (isDirty && !submitDisabled) { + goLive() + } + }} + /> + + + {(liveLinkError || linkMetaError) && ( + + + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + + )} + + {(linkMeta || linkMetaLoading) && ( + + {(!linkMeta || linkMeta.image) && ( + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + + )} + + )} + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + )} + + + {goLiveError && ( + {cleanError(goLiveError)} + )} + {removeLiveStatusError && ( + + {cleanError(removeLiveStatusError)} + + )} + + + {isDirty ? ( + + ) : ( + + )} + + + + + + ) +} 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 ( + + + + + ) +} + +// 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 ( + + + + + Go Live + + + + Add a temporary live status to your profile. When someone clicks + on your avatar, they’ll see information about your live event. + + + + {moderationOpts && ( + + + + + )} + + + + Live link + + + setLiveLinkError('')} + onBlur={() => { + if (!definitelyUrl(liveLink)) { + setLiveLinkError('Invalid URL') + } + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="url" + autoCorrect={false} + /> + + + {(liveLinkError || linkMetaError) && ( + + + + {liveLinkError ? ( + This is not a valid link + ) : ( + cleanError(linkMetaError) + )} + + + )} + + {(linkMeta || linkMetaLoading) && ( + + {(!linkMeta || linkMeta.image) && ( + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && imageLoadError && ( + + )} + + )} + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + )} + + + {hasLink && ( + + + Go live for + + + + + {displayDuration(i18n, duration)} + {' '} + + {time(duration)} + + + + + + { + const label = displayDuration(i18n, item) + return ( + + + + {label} + {' '} + + {time(item)} + + + + ) + }} + items={DURATIONS} + valueExtractor={d => String(d)} + /> + + + )} + + {goLiveError && ( + {cleanError(goLiveError)} + )} + + + {hasLink && ( + + )} + + + + + + ) +} 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 +}) { + const t = useTheme() + + const fontSize = { + tiny: {fontSize: 7, letterSpacing: tokens.TRACKING}, + small: a.text_2xs, + large: a.text_xs, + }[size] + + return ( + + + + + LIVE + + + + + ) +} 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() + return ( + + + + + ) +} + +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 ( + + + + + ) +} + +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 && ( + + + + + )} + + + + {embed.external.title || embed.external.uri} + + + + + {toNiceDomain(embed.external.uri)} + + + + + + {moderationOpts && ( + + + {/* Ensure wide enough on web hover */} + + + + + + )} + + Live feature is in beta testing + + + + ) +} 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 | 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 = { + 'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com + 'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social +} + +export const LIVE_SOURCES: Record = { + '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(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, // 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 { 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 = { '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 ( - + () 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 ( + {live.isActive && } + + {live.isActive && + (isMe ? ( + + ) : ( + + ))} ) } @@ -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} /> { 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 /> ) : ( 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( }, 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 } @@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> @@ -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 && } - - - + () - - 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 ? ( - - {children} - - {!isFollowing && ( - - )} - - ) : ( - 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>(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 ( ) 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 ( - - - + {isThreadParent && ( 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 ( @@ -290,6 +299,25 @@ let ProfileMenu = ({ + {isSelf && temp__canGoLive(profile) && ( + + + {status.isActive ? ( + Edit live status + ) : ( + Go live + )} + + + + )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( @@ -456,6 +484,16 @@ let ProfileMenu = ({ profile={profile} verifications={currentAccountVerifications} /> + + {status.isActive ? ( + + ) : ( + + )} ) } 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 ( { profile={author} moderation={opts.moderation?.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> )} 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} /> )} - + + {live && size > 16 && !hideLiveBadge && ( + 32 ? 'small' : 'tiny'} /> + )} {alert} ) : ( + + {live && size > 16 && !hideLiveBadge && ( + 32 ? 'small' : 'tiny'} /> + )} {alert} ) @@ -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 = ( ) @@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({ {disableNavigation ? ( avatarEl + ) : status.isActive && (isNative || isTouchDevice) ? ( + <> + + + ) : ( Buttons - {['primary', 'secondary', 'secondary_inverted', 'negative'].map( - color => ( - - {['solid', 'outline', 'ghost'].map(variant => ( - - - - - ))} - - ), - )} + {[ + 'primary', + 'secondary', + 'secondary_inverted', + 'negative', + 'negative_secondary', + ].map(color => ( + + {['solid', 'outline', 'ghost'].map(variant => ( + + + + + ))} + + ))} 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() { - 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 ( 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, + }, ]}> ) : ( + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + { + borderWidth: live ? 0 : 1, + }, + ]}> )} 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 ( {!isLoading && profile ? ( @@ -142,6 +145,7 @@ function ProfileCard() { avatar={profile.avatar} size={size} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {!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({ Switch account {accounts.map(other => ( - - onPressSwitchAccount(other.account, 'SwitchAccount') - }> - - - - - {sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )} - - + account={other.account} + profile={other.profile} + /> ))} @@ -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 ( + onPressSwitchAccount(account, 'SwitchAccount')}> + + + + + {sanitizeHandle(profile?.handle ?? account.handle, '@')} + + + ) +} + 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": -- cgit 1.4.1