diff --git a/assets/icons/live_stroke2_corner0_rounded.svg b/assets/icons/live_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..d0e7080564
--- /dev/null
+++ b/assets/icons/live_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 7213da03d1..f50651a878 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 68aa3cc882..02ad98c5f0 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 f2e781ccf9..eb770eeca7 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 2d6ddc8343..42eb648443 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 4c6c5816c5..4795385eec 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 c43e9c5c04..12bd8819b1 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 aa5830eb9b..30b26beadf 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 09b587c5e2..4f6545a2e4 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 2fa0643838..37087dc95a 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 267b784b05..d6c946956f 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 3be555238b..2a89be7d3c 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 b8ecf2e6f5..3683ee9e60 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 0000000000..2fad009fdb
--- /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 0000000000..c237e8c832
--- /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 0000000000..c892dea580
--- /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 0000000000..1958ab49d9
--- /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 0000000000..fb26b8c06c
--- /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 0000000000..6b4267cb01
--- /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 0000000000..30921a88a3
--- /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 3710623500..93d16ff0ca 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 bb98f9fc8e..dca03647a6 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 20c3fabbcb..ad194714a3 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 42b0d6ef35..665633d7c0 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 2dff101e6d..1639abaf0c 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 76eb482034..9f36c27acc 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 9c23e4550a..a1212d8a29 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 10c3e6b4d1..3925ce9bd5 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 c6cf254f34..03463f9771 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 90b2ddeec8..0000000000
--- 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 181b35026d..b4c2b27102 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 123a8b0c29..ceb653b9c5 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 43ec448342..1c2a7d62df 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 fd8e3a38bd..62ba32c9b3 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 326a2fff8a..b3bf144f7d 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 0146bc3c65..a6c2ecdde3 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 d51db3960c..c4624e8e19 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 df6a045dc4..92be6c67e7 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 7d34a3d14f..f6c852ca12 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 34fb2f6980..261622c33a 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":