Live (#8354)
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2 12a9.97 9.97 0 0 1 2.929-7.07l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12a7.97 7.97 0 0 0 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12a9.97 9.97 0 0 1-2.929 7.07 1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A4 4 0 0 0 8 12.001a3.98 3.98 0 0 0 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a6 6 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A4 4 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 879 B |
+2
-2
@@ -69,7 +69,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.5",
|
"@atproto/api": "^0.15.6",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/dev-env": "^0.3.128",
|
"@atproto/dev-env": "^0.3.129",
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"@babel/preset-env": "^7.26.0",
|
"@babel/preset-env": "^7.26.0",
|
||||||
"@babel/runtime": "^7.26.0",
|
"@babel/runtime": "^7.26.0",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const atoms = {
|
|||||||
relative: {
|
relative: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
|
static: {
|
||||||
|
position: 'static',
|
||||||
|
},
|
||||||
sticky: web({
|
sticky: web({
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {type AppBskyActorDefs} from '@atproto/api'
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {useProfilesQuery} from '#/state/queries/profile'
|
import {useProfilesQuery} from '#/state/queries/profile'
|
||||||
@@ -110,6 +111,7 @@ function AccountItem({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const verification = useSimpleVerificationState({profile})
|
const verification = useSimpleVerificationState({profile})
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
const onPress = useCallback(() => {
|
||||||
onSelect(account)
|
onSelect(account)
|
||||||
@@ -141,6 +143,8 @@ function AccountItem({
|
|||||||
avatar={profile?.avatar}
|
avatar={profile?.avatar}
|
||||||
size={36}
|
size={36}
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
|
<View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type ButtonColor =
|
|||||||
| 'secondary'
|
| 'secondary'
|
||||||
| 'secondary_inverted'
|
| 'secondary_inverted'
|
||||||
| 'negative'
|
| 'negative'
|
||||||
|
| 'negative_secondary'
|
||||||
| 'gradient_primary'
|
| 'gradient_primary'
|
||||||
| 'gradient_sky'
|
| 'gradient_sky'
|
||||||
| 'gradient_midnight'
|
| 'gradient_midnight'
|
||||||
@@ -336,6 +337,57 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
borderWidth: 1,
|
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) {
|
if (!disabled) {
|
||||||
baseStyles.push(a.border, {
|
baseStyles.push(a.border, {
|
||||||
borderColor: t.palette.negative_500,
|
borderColor: t.palette.negative_500,
|
||||||
@@ -425,6 +477,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
secondary: tokens.gradients.sky,
|
secondary: tokens.gradients.sky,
|
||||||
secondary_inverted: tokens.gradients.sky,
|
secondary_inverted: tokens.gradients.sky,
|
||||||
negative: tokens.gradients.sky,
|
negative: tokens.gradients.sky,
|
||||||
|
negative_secondary: tokens.gradients.sky,
|
||||||
gradient_primary: tokens.gradients.primary,
|
gradient_primary: tokens.gradients.primary,
|
||||||
gradient_sky: tokens.gradients.sky,
|
gradient_sky: tokens.gradients.sky,
|
||||||
gradient_midnight: tokens.gradients.midnight,
|
gradient_midnight: tokens.gradients.midnight,
|
||||||
@@ -645,6 +698,39 @@ export function useSharedButtonTextStyles() {
|
|||||||
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
|
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 {
|
} else {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
baseStyles.push({color: t.palette.white})
|
baseStyles.push({color: t.palette.white})
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export function Handle() {
|
export function Handle({difference = false}: {difference?: boolean}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {screenReaderEnabled} = useA11y()
|
const {screenReaderEnabled} = useA11y()
|
||||||
@@ -328,9 +328,19 @@ export function Handle() {
|
|||||||
width: 35,
|
width: 35,
|
||||||
height: 5,
|
height: 5,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
backgroundColor: t.palette.contrast_975,
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
},
|
||||||
|
difference
|
||||||
|
? {
|
||||||
|
// TODO: mixBlendMode is only available on the new architecture -sfn
|
||||||
|
// backgroundColor: t.palette.white,
|
||||||
|
// mixBlendMode: 'difference',
|
||||||
|
backgroundColor: t.palette.white,
|
||||||
|
opacity: 0.75,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
backgroundColor: t.palette.contrast_975,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -195,13 +195,7 @@ export function Inner({
|
|||||||
onDismiss={close}
|
onDismiss={close}
|
||||||
style={{display: 'flex', flexDirection: 'column'}}>
|
style={{display: 'flex', flexDirection: 'column'}}>
|
||||||
{header}
|
{header}
|
||||||
<View
|
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||||
style={[
|
|
||||||
gtMobile ? a.p_2xl : a.p_xl,
|
|
||||||
a.overflow_hidden,
|
|
||||||
a.rounded_md,
|
|
||||||
contentContainerStyle,
|
|
||||||
]}>
|
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</DismissableLayer.DismissableLayer>
|
</DismissableLayer.DismissableLayer>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {getModerationCauseKey} from '#/lib/moderation'
|
import {getModerationCauseKey} from '#/lib/moderation'
|
||||||
import {type LogEvents} from '#/lib/statsig/statsig'
|
import {type LogEvents} from '#/lib/statsig/statsig'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
@@ -132,20 +133,25 @@ export function Avatar({
|
|||||||
moderationOpts,
|
moderationOpts,
|
||||||
onPress,
|
onPress,
|
||||||
disabledPreview,
|
disabledPreview,
|
||||||
|
liveOverride,
|
||||||
}: {
|
}: {
|
||||||
profile: bsky.profile.AnyProfileView
|
profile: bsky.profile.AnyProfileView
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
disabledPreview?: boolean
|
disabledPreview?: boolean
|
||||||
|
liveOverride?: boolean
|
||||||
}) {
|
}) {
|
||||||
const moderation = moderateProfile(profile, moderationOpts)
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
return disabledPreview ? (
|
return disabledPreview ? (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={40}
|
size={40}
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
|
live={liveOverride ?? live}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
@@ -153,6 +159,7 @@ export function Avatar({
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
onBeforePress={onPress}
|
onBeforePress={onPress}
|
||||||
|
live={liveOverride ?? live}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
type AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||||
import {msg, plural} from '@lingui/macro'
|
import {msg, plural} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {isTouchDevice} from '#/lib/browser'
|
import {isTouchDevice} from '#/lib/browser'
|
||||||
import {getModerationCauseKey} from '#/lib/moderation'
|
import {getModerationCauseKey} from '#/lib/moderation'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
@@ -32,6 +35,7 @@ import {
|
|||||||
shouldShowKnownFollowers,
|
shouldShowKnownFollowers,
|
||||||
} from '#/components/KnownFollowers'
|
} from '#/components/KnownFollowers'
|
||||||
import {InlineLinkText, Link} from '#/components/Link'
|
import {InlineLinkText, Link} from '#/components/Link'
|
||||||
|
import {LiveStatus} from '#/components/live/LiveStatusDialog'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Pills from '#/components/Pills'
|
import * as Pills from '#/components/Pills'
|
||||||
import {Portal} from '#/components/Portal'
|
import {Portal} from '#/components/Portal'
|
||||||
@@ -105,6 +109,8 @@ const HIDE_DELAY = 150
|
|||||||
const HIDE_DURATION = 200
|
const HIDE_DURATION = 200
|
||||||
|
|
||||||
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const {refs, floatingStyles} = useFloating({
|
const {refs, floatingStyles} = useFloating({
|
||||||
middleware: floatingMiddlewares,
|
middleware: floatingMiddlewares,
|
||||||
})
|
})
|
||||||
@@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
|||||||
onPointerEnter={onPointerEnterCard}
|
onPointerEnter={onPointerEnterCard}
|
||||||
onPointerLeave={onPointerLeaveCard}>
|
onPointerLeave={onPointerLeaveCard}>
|
||||||
<div style={{willChange: 'transform', ...animationStyle}}>
|
<div style={{willChange: 'transform', ...animationStyle}}>
|
||||||
<Card did={props.did} hide={onPress} />
|
<Card did={props.did} hide={onPress} navigation={navigation} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
@@ -339,7 +345,15 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
|
let Card = ({
|
||||||
|
did,
|
||||||
|
hide,
|
||||||
|
navigation,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
hide: () => void
|
||||||
|
navigation: NavigationProp
|
||||||
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const profile = useProfileQuery({did})
|
const profile = useProfileQuery({did})
|
||||||
@@ -347,24 +361,42 @@ let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
|
|||||||
|
|
||||||
const data = profile.data
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.p_lg,
|
!status.isActive && a.p_lg,
|
||||||
a.border,
|
a.border,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
t.atoms.shadow_lg,
|
t.atoms.shadow_lg,
|
||||||
{
|
a.w_full,
|
||||||
width: 300,
|
{maxWidth: status.isActive ? 500 : 300},
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
{data && moderationOpts ? (
|
{data && moderationOpts ? (
|
||||||
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
|
status.isActive ? (
|
||||||
|
<LiveStatus
|
||||||
|
profile={data}
|
||||||
|
embed={status.embed}
|
||||||
|
padding="lg"
|
||||||
|
onPressOpenProfile={onPressOpenProfile}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<View style={[a.justify_center]}>
|
<View style={[a.justify_center, a.align_center, {minHeight: 200}]}>
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
export type ProfileHoverCardProps = {
|
export type ProfileHoverCardProps = {
|
||||||
children: React.ReactElement
|
children: React.ReactElement
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export function VerificationReminder({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.pt_md,
|
a.pt_md,
|
||||||
t.atoms.bg_contrast_100,
|
t.atoms.bg_contrast_100,
|
||||||
|
{
|
||||||
|
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||||
|
},
|
||||||
]}>
|
]}>
|
||||||
<GradientFill gradient={tokens.gradients.primary} />
|
<GradientFill gradient={tokens.gradients.primary} />
|
||||||
<ShieldIcon width={64} fill="white" style={[a.z_10]} />
|
<ShieldIcon width={64} fill="white" style={[a.z_10]} />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {useCallback, useImperativeHandle, useState} from 'react'
|
import {useCallback, useImperativeHandle, useState} from 'react'
|
||||||
import {Keyboard} from 'react-native'
|
import {Keyboard} from 'react-native'
|
||||||
import DatePicker from 'react-native-date-picker'
|
import DatePicker from 'react-native-date-picker'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useTheme} from '#/alf'
|
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 {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {DateFieldButton} from './index.shared'
|
import {DateFieldButton} from './index.shared'
|
||||||
@@ -21,6 +22,7 @@ export function DateField({
|
|||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
maximumDate,
|
maximumDate,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
|
const {i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ export function DateField({
|
|||||||
onConfirm={onChangeInternal}
|
onConfirm={onChangeInternal}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
mode="date"
|
mode="date"
|
||||||
|
locale={i18n.locale}
|
||||||
|
is24hourSource="locale"
|
||||||
testID={`${testID}-datepicker`}
|
testID={`${testID}-datepicker`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
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 {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {DateFieldButton} from './index.shared'
|
import {DateFieldButton} from './index.shared'
|
||||||
@@ -33,7 +33,7 @@ export function DateField({
|
|||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
maximumDate,
|
maximumDate,
|
||||||
}: DateFieldProps) {
|
}: DateFieldProps) {
|
||||||
const {_} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const control = Dialog.useDialogControl()
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
@@ -83,10 +83,11 @@ export function DateField({
|
|||||||
<View style={[a.relative, a.w_full, a.align_center]}>
|
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
timeZoneOffsetInMinutes={0}
|
timeZoneOffsetInMinutes={0}
|
||||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
theme={t.scheme}
|
||||||
date={new Date(toSimpleDateString(value))}
|
date={new Date(toSimpleDateString(value))}
|
||||||
onDateChange={onChangeInternal}
|
onDateChange={onChangeInternal}
|
||||||
mode="date"
|
mode="date"
|
||||||
|
locale={i18n.locale}
|
||||||
testID={`${testID}-datepicker`}
|
testID={`${testID}-datepicker`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Live_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M2 12A9.97 9.97 0 0 1 4.929 4.93l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12c0 2.072.786 3.958 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12c0 2.761-1.12 5.262-2.929 7.07a1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A3.99 3.99 0 0 0 8 12.001c0 1.035.393 1.978 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a5.99 5.99 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A3.99 3.99 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import {useMemo, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {
|
||||||
|
type AppBskyActorDefs,
|
||||||
|
AppBskyActorStatus,
|
||||||
|
type AppBskyEmbedExternal,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
import {differenceInMinutes} from 'date-fns'
|
||||||
|
|
||||||
|
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
|
import {definitelyUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
|
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
|
import {atoms as a, platform, useTheme, web} from '#/alf'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
|
||||||
|
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
|
||||||
|
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||||
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
useRemoveLiveStatusMutation,
|
||||||
|
useUpsertLiveStatusMutation,
|
||||||
|
} from './queries'
|
||||||
|
import {displayDuration, useDebouncedValue} from './utils'
|
||||||
|
|
||||||
|
export function EditLiveDialog({
|
||||||
|
control,
|
||||||
|
status,
|
||||||
|
embed,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
status: AppBskyActorDefs.StatusView
|
||||||
|
embed: AppBskyEmbedExternal.View
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<DialogInner status={status} embed={embed} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogInner({
|
||||||
|
status,
|
||||||
|
embed,
|
||||||
|
}: {
|
||||||
|
status: AppBskyActorDefs.StatusView
|
||||||
|
embed: AppBskyEmbedExternal.View
|
||||||
|
}) {
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const agent = useAgent()
|
||||||
|
const [liveLink, setLiveLink] = useState(embed.external.uri)
|
||||||
|
const [liveLinkError, setLiveLinkError] = useState('')
|
||||||
|
const [imageLoadError, setImageLoadError] = useState(false)
|
||||||
|
const tick = useTickEveryMinute()
|
||||||
|
|
||||||
|
const liveLinkUrl = definitelyUrl(liveLink)
|
||||||
|
const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
|
||||||
|
|
||||||
|
const isDirty = liveLinkUrl !== embed.external.uri
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: linkMeta,
|
||||||
|
isSuccess: hasValidLinkMeta,
|
||||||
|
isLoading: linkMetaLoading,
|
||||||
|
error: linkMetaError,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!debouncedUrl,
|
||||||
|
queryKey: ['link-meta', debouncedUrl],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!debouncedUrl) return null
|
||||||
|
return getLinkMeta(agent, debouncedUrl)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const record = useMemo(() => {
|
||||||
|
if (!AppBskyActorStatus.isRecord(status.record)) return null
|
||||||
|
const validation = AppBskyActorStatus.validateRecord(status.record)
|
||||||
|
if (validation.success) {
|
||||||
|
return validation.value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [status])
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: goLive,
|
||||||
|
isPending: isGoingLive,
|
||||||
|
error: goLiveError,
|
||||||
|
} = useUpsertLiveStatusMutation(
|
||||||
|
record?.durationMinutes ?? 0,
|
||||||
|
linkMeta,
|
||||||
|
record?.createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: removeLiveStatus,
|
||||||
|
isPending: isRemovingLiveStatus,
|
||||||
|
error: removeLiveStatusError,
|
||||||
|
} = useRemoveLiveStatusMutation()
|
||||||
|
|
||||||
|
const {minutesUntilExpiry, expiryDateTime} = useMemo(() => {
|
||||||
|
tick!
|
||||||
|
|
||||||
|
const expiry = new Date(status.expiresAt ?? new Date())
|
||||||
|
return {
|
||||||
|
expiryDateTime: expiry,
|
||||||
|
minutesUntilExpiry: differenceInMinutes(expiry, new Date()),
|
||||||
|
}
|
||||||
|
}, [tick, status.expiresAt])
|
||||||
|
|
||||||
|
const submitDisabled =
|
||||||
|
isGoingLive ||
|
||||||
|
!hasValidLinkMeta ||
|
||||||
|
debouncedUrl !== liveLinkUrl ||
|
||||||
|
isRemovingLiveStatus
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`You are Live`)}
|
||||||
|
style={web({maxWidth: 420})}>
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>You are Live</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<ClockIcon style={[t.atoms.text_contrast_high]} size="sm" />
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
{typeof record?.durationMinutes === 'number' ? (
|
||||||
|
<Trans>
|
||||||
|
Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
|
||||||
|
{i18n.date(expiryDateTime, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>No expiry set</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Live link</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
|
||||||
|
<TextField.Input
|
||||||
|
label={_(msg`Live link`)}
|
||||||
|
placeholder={_(msg`www.mylivestream.tv`)}
|
||||||
|
value={liveLink}
|
||||||
|
onChangeText={setLiveLink}
|
||||||
|
onFocus={() => setLiveLinkError('')}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!definitelyUrl(liveLink)) {
|
||||||
|
setLiveLinkError('Invalid URL')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="url"
|
||||||
|
autoCorrect={false}
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (isDirty && !submitDisabled) {
|
||||||
|
goLive()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
{(liveLinkError || linkMetaError) && (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||||
|
<WarningIcon
|
||||||
|
style={[{color: t.palette.negative_500}]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
a.flex_1,
|
||||||
|
a.font_bold,
|
||||||
|
{color: t.palette.negative_500},
|
||||||
|
]}>
|
||||||
|
{liveLinkError ? (
|
||||||
|
<Trans>This is not a valid link</Trans>
|
||||||
|
) : (
|
||||||
|
cleanError(linkMetaError)
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(linkMeta || linkMetaLoading) && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.flex_row,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_stretch,
|
||||||
|
]}>
|
||||||
|
{(!linkMeta || linkMeta.image) && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{minHeight: 64, width: 114},
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
{linkMeta?.image && (
|
||||||
|
<Image
|
||||||
|
source={linkMeta.image}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
transition={200}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
contentFit="cover"
|
||||||
|
onLoad={() => setImageLoadError(false)}
|
||||||
|
onError={() => setImageLoadError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{linkMeta && imageLoadError && (
|
||||||
|
<CircleXIcon
|
||||||
|
style={[t.atoms.text_contrast_low]}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.justify_center,
|
||||||
|
a.py_sm,
|
||||||
|
a.gap_xs,
|
||||||
|
a.px_md,
|
||||||
|
]}>
|
||||||
|
{linkMeta ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[a.leading_snug, a.font_bold, a.text_md]}>
|
||||||
|
{linkMeta.title || linkMeta.url}
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||||
|
<GlobeIcon
|
||||||
|
size="xs"
|
||||||
|
style={[t.atoms.text_contrast_low]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{toNiceDomain(linkMeta.url)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LoadingPlaceholder height={16} width={128} />
|
||||||
|
<LoadingPlaceholder height={12} width={72} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{goLiveError && (
|
||||||
|
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
|
||||||
|
)}
|
||||||
|
{removeLiveStatusError && (
|
||||||
|
<Admonition type="error">
|
||||||
|
{cleanError(removeLiveStatusError)}
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={platform({
|
||||||
|
native: [a.gap_md, a.pt_lg],
|
||||||
|
web: [a.flex_row_reverse, a.gap_md, a.align_center],
|
||||||
|
})}>
|
||||||
|
{isDirty ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={() => goLive()}
|
||||||
|
disabled={submitDisabled}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isGoingLive && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove live status`)}
|
||||||
|
onPress={() => removeLiveStatus()}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="negative_secondary"
|
||||||
|
variant="solid"
|
||||||
|
disabled={isRemovingLiveStatus || isGoingLive}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Remove live status</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import {useCallback, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
|
import {definitelyUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
|
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
|
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
|
||||||
|
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||||
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import * as Select from '#/components/Select'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {useUpsertLiveStatusMutation} from './queries'
|
||||||
|
import {displayDuration, useDebouncedValue} from './utils'
|
||||||
|
|
||||||
|
export function GoLiveDialog({
|
||||||
|
control,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<DialogInner profile={profile} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Possible durations: max 4 hours, 5 minute intervals
|
||||||
|
const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5)
|
||||||
|
|
||||||
|
function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const agent = useAgent()
|
||||||
|
const [liveLink, setLiveLink] = useState('')
|
||||||
|
const [liveLinkError, setLiveLinkError] = useState('')
|
||||||
|
const [imageLoadError, setImageLoadError] = useState(false)
|
||||||
|
const [duration, setDuration] = useState(60)
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const tick = useTickEveryMinute()
|
||||||
|
|
||||||
|
const time = useCallback(
|
||||||
|
(offset: number) => {
|
||||||
|
tick!
|
||||||
|
|
||||||
|
const date = new Date()
|
||||||
|
date.setMinutes(date.getMinutes() + offset)
|
||||||
|
return i18n
|
||||||
|
.date(date, {hour: 'numeric', minute: '2-digit', hour12: true})
|
||||||
|
.toLocaleUpperCase()
|
||||||
|
.replace(' ', '')
|
||||||
|
},
|
||||||
|
[tick, i18n],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChangeDuration = useCallback((newDuration: string) => {
|
||||||
|
setDuration(Number(newDuration))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const liveLinkUrl = definitelyUrl(liveLink)
|
||||||
|
const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
|
||||||
|
const hasLink = !!debouncedUrl
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: linkMeta,
|
||||||
|
isSuccess: hasValidLinkMeta,
|
||||||
|
isLoading: linkMetaLoading,
|
||||||
|
error: linkMetaError,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!debouncedUrl,
|
||||||
|
queryKey: ['link-meta', debouncedUrl],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!debouncedUrl) return null
|
||||||
|
return getLinkMeta(agent, debouncedUrl)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: goLive,
|
||||||
|
isPending: isGoingLive,
|
||||||
|
error: goLiveError,
|
||||||
|
} = useUpsertLiveStatusMutation(duration, linkMeta)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Go Live`)}
|
||||||
|
style={web({maxWidth: 420})}>
|
||||||
|
<View style={[a.gap_xl]}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Go Live</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||||
|
<Trans>
|
||||||
|
Add a temporary live status to your profile. When someone clicks
|
||||||
|
on your avatar, they’ll see information about your live event.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{moderationOpts && (
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
liveOverride
|
||||||
|
disabledPreview
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
)}
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Live link</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
|
||||||
|
<TextField.Input
|
||||||
|
label={_(msg`Live link`)}
|
||||||
|
placeholder={_(msg`www.mylivestream.tv`)}
|
||||||
|
value={liveLink}
|
||||||
|
onChangeText={setLiveLink}
|
||||||
|
onFocus={() => setLiveLinkError('')}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!definitelyUrl(liveLink)) {
|
||||||
|
setLiveLinkError('Invalid URL')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="url"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
{(liveLinkError || linkMetaError) && (
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||||
|
<WarningIcon
|
||||||
|
style={[{color: t.palette.negative_500}]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
a.flex_1,
|
||||||
|
a.font_bold,
|
||||||
|
{color: t.palette.negative_500},
|
||||||
|
]}>
|
||||||
|
{liveLinkError ? (
|
||||||
|
<Trans>This is not a valid link</Trans>
|
||||||
|
) : (
|
||||||
|
cleanError(linkMetaError)
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(linkMeta || linkMetaLoading) && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.flex_row,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_stretch,
|
||||||
|
]}>
|
||||||
|
{(!linkMeta || linkMeta.image) && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{minHeight: 64, width: 114},
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
{linkMeta?.image && (
|
||||||
|
<Image
|
||||||
|
source={linkMeta.image}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
transition={200}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
contentFit="cover"
|
||||||
|
onLoad={() => setImageLoadError(false)}
|
||||||
|
onError={() => setImageLoadError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{linkMeta && imageLoadError && (
|
||||||
|
<CircleXIcon
|
||||||
|
style={[t.atoms.text_contrast_low]}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.justify_center,
|
||||||
|
a.py_sm,
|
||||||
|
a.gap_xs,
|
||||||
|
a.px_md,
|
||||||
|
]}>
|
||||||
|
{linkMeta ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[a.leading_snug, a.font_bold, a.text_md]}>
|
||||||
|
{linkMeta.title || linkMeta.url}
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||||
|
<GlobeIcon
|
||||||
|
size="xs"
|
||||||
|
style={[t.atoms.text_contrast_low]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{toNiceDomain(linkMeta.url)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LoadingPlaceholder height={16} width={128} />
|
||||||
|
<LoadingPlaceholder height={12} width={72} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{hasLink && (
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Go live for</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<Select.Root
|
||||||
|
value={String(duration)}
|
||||||
|
onValueChange={onChangeDuration}>
|
||||||
|
<Select.Trigger label={_(msg`Select duration`)}>
|
||||||
|
<Text style={[ios(a.py_xs)]}>
|
||||||
|
{displayDuration(i18n, duration)}
|
||||||
|
{' '}
|
||||||
|
<Text style={[t.atoms.text_contrast_low]}>
|
||||||
|
{time(duration)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Select.Icon />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content
|
||||||
|
renderItem={(item, _i, selectedValue) => {
|
||||||
|
const label = displayDuration(i18n, item)
|
||||||
|
return (
|
||||||
|
<Select.Item value={String(item)} label={label}>
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
<Select.ItemText>
|
||||||
|
{label}
|
||||||
|
{' '}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
native(a.text_md),
|
||||||
|
web(a.ml_xs),
|
||||||
|
selectedValue === String(item)
|
||||||
|
? t.atoms.text_contrast_medium
|
||||||
|
: t.atoms.text_contrast_low,
|
||||||
|
a.font_normal,
|
||||||
|
]}>
|
||||||
|
{time(item)}
|
||||||
|
</Text>
|
||||||
|
</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
items={DURATIONS}
|
||||||
|
valueExtractor={d => String(d)}
|
||||||
|
/>
|
||||||
|
</Select.Root>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{goLiveError && (
|
||||||
|
<Admonition type="error">{cleanError(goLiveError)}</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={platform({
|
||||||
|
native: [a.gap_md, a.pt_lg],
|
||||||
|
web: [a.flex_row_reverse, a.gap_md, a.align_center],
|
||||||
|
})}>
|
||||||
|
{hasLink && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Go Live`)}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={() => goLive()}
|
||||||
|
disabled={
|
||||||
|
isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl
|
||||||
|
}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Go Live</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isGoingLive && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="secondary"
|
||||||
|
variant={platform({native: 'solid', web: 'ghost'})}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function LiveIndicator({
|
||||||
|
size = 'small',
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
size?: 'tiny' | 'small' | 'large'
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const fontSize = {
|
||||||
|
tiny: {fontSize: 7, letterSpacing: tokens.TRACKING},
|
||||||
|
small: a.text_2xs,
|
||||||
|
large: a.text_xs,
|
||||||
|
}[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.w_full,
|
||||||
|
a.align_center,
|
||||||
|
a.pointer_events_none,
|
||||||
|
{bottom: size === 'large' ? -8 : -5},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.palette.negative_500,
|
||||||
|
paddingVertical: size === 'large' ? 2 : 1,
|
||||||
|
paddingHorizontal: size === 'large' ? 4 : 3,
|
||||||
|
borderRadius: size === 'large' ? 5 : tokens.borderRadius.xs,
|
||||||
|
}}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_center,
|
||||||
|
a.font_bold,
|
||||||
|
fontSize,
|
||||||
|
{color: t.palette.white},
|
||||||
|
]}>
|
||||||
|
<Trans comment="Live status indicator on avatar. Should be extremely short, not much space for more than 4 characters">
|
||||||
|
LIVE
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||||
|
import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
|
||||||
|
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
|
||||||
|
import {LiveIndicator} from './LiveIndicator'
|
||||||
|
|
||||||
|
export function LiveStatusDialog({
|
||||||
|
control,
|
||||||
|
profile,
|
||||||
|
embed,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
status: AppBskyActorDefs.StatusView
|
||||||
|
embed: AppBskyEmbedExternal.View
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle difference={!!embed.external.thumb} />
|
||||||
|
<DialogInner profile={profile} embed={embed} navigation={navigation} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogInner({
|
||||||
|
profile,
|
||||||
|
embed,
|
||||||
|
navigation,
|
||||||
|
}: {
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
embed: AppBskyEmbedExternal.View
|
||||||
|
navigation: NavigationProp
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
|
const onPressOpenProfile = useCallback(() => {
|
||||||
|
control.close(() => {
|
||||||
|
navigation.push('Profile', {
|
||||||
|
name: profile.handle,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [navigation, profile.handle, control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
|
||||||
|
contentContainerStyle={[a.pt_0, a.px_0]}
|
||||||
|
style={[web({maxWidth: 420}), a.overflow_hidden]}>
|
||||||
|
<LiveStatus
|
||||||
|
profile={profile}
|
||||||
|
embed={embed}
|
||||||
|
onPressOpenProfile={onPressOpenProfile}
|
||||||
|
/>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveStatus({
|
||||||
|
profile,
|
||||||
|
embed,
|
||||||
|
padding = 'xl',
|
||||||
|
onPressOpenProfile,
|
||||||
|
}: {
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
embed: AppBskyEmbedExternal.View
|
||||||
|
padding?: 'lg' | 'xl'
|
||||||
|
onPressOpenProfile: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const openLink = useOpenLink()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{embed.external.thumb && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.w_full,
|
||||||
|
{aspectRatio: 1.91},
|
||||||
|
android([
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||||
|
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={embed.external.thumb}
|
||||||
|
contentFit="cover"
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
<LiveIndicator
|
||||||
|
size="large"
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{top: tokens.space.lg, left: tokens.space.lg},
|
||||||
|
a.align_start,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.gap_lg,
|
||||||
|
padding === 'xl'
|
||||||
|
? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
|
||||||
|
: a.p_lg,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.justify_center, a.gap_2xs]}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={3}
|
||||||
|
style={[a.leading_snug, a.font_bold, a.text_xl]}>
|
||||||
|
{embed.external.title || embed.external.uri}
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||||
|
<Globe_Stroke2_Corner0_Rounded
|
||||||
|
size="xs"
|
||||||
|
style={[t.atoms.text_contrast_medium]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
|
{toNiceDomain(embed.external.uri)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Watch now`)}
|
||||||
|
size={platform({native: 'large', web: 'small'})}
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric('live:card:watch', {subject: profile.did})
|
||||||
|
openLink(embed.external.uri, false)
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Watch now</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={SquareArrowTopRightIcon} />
|
||||||
|
</Button>
|
||||||
|
<View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
|
||||||
|
{moderationOpts && (
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
disabledPreview
|
||||||
|
/>
|
||||||
|
{/* Ensure wide enough on web hover */}
|
||||||
|
<View style={[a.flex_1, web({minWidth: 100})]}>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Open profile`)}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric('live:card:openProfile', {subject: profile.did})
|
||||||
|
unstableCacheProfileView(queryClient, profile)
|
||||||
|
onPressOpenProfile()
|
||||||
|
}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Open profile</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.text_center,
|
||||||
|
t.atoms.text_contrast_low,
|
||||||
|
a.text_sm,
|
||||||
|
]}>
|
||||||
|
<Trans>Live feature is in beta testing</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
type $Typed,
|
||||||
|
type AppBskyActorStatus,
|
||||||
|
type AppBskyEmbedExternal,
|
||||||
|
ComAtprotoRepoPutRecord,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {retry} from '@atproto/common-web'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {uploadBlob} from '#/lib/api'
|
||||||
|
import {imageToThumb} from '#/lib/api/resolve'
|
||||||
|
import {type LinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {useDialogContext} from '#/components/Dialog'
|
||||||
|
|
||||||
|
export function useUpsertLiveStatusMutation(
|
||||||
|
duration: number,
|
||||||
|
linkMeta: LinkMeta | null | undefined,
|
||||||
|
createdAt?: string,
|
||||||
|
) {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const control = useDialogContext()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!currentAccount) throw new Error('Not logged in')
|
||||||
|
|
||||||
|
let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
|
||||||
|
|
||||||
|
if (linkMeta) {
|
||||||
|
let thumb
|
||||||
|
|
||||||
|
if (linkMeta.image) {
|
||||||
|
try {
|
||||||
|
const img = await imageToThumb(linkMeta.image)
|
||||||
|
if (img) {
|
||||||
|
const blob = await uploadBlob(
|
||||||
|
agent,
|
||||||
|
img.source.path,
|
||||||
|
img.source.mime,
|
||||||
|
)
|
||||||
|
thumb = blob.data.blob
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to upload thumbnail for live status`, {
|
||||||
|
url: linkMeta.url,
|
||||||
|
image: linkMeta.image,
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embed = {
|
||||||
|
$type: 'app.bsky.embed.external',
|
||||||
|
external: {
|
||||||
|
$type: 'app.bsky.embed.external#external',
|
||||||
|
title: linkMeta.title ?? '',
|
||||||
|
description: linkMeta.description ?? '',
|
||||||
|
uri: linkMeta.url,
|
||||||
|
thumb,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
$type: 'app.bsky.actor.status',
|
||||||
|
createdAt: createdAt ?? new Date().toISOString(),
|
||||||
|
status: 'app.bsky.actor.status#live',
|
||||||
|
durationMinutes: duration,
|
||||||
|
embed,
|
||||||
|
} satisfies AppBskyActorStatus.Record
|
||||||
|
|
||||||
|
const upsert = async () => {
|
||||||
|
const repo = currentAccount.did
|
||||||
|
const collection = 'app.bsky.actor.status'
|
||||||
|
|
||||||
|
const existing = await agent.com.atproto.repo
|
||||||
|
.getRecord({repo, collection, rkey: 'self'})
|
||||||
|
.catch(_e => undefined)
|
||||||
|
|
||||||
|
await agent.com.atproto.repo.putRecord({
|
||||||
|
repo,
|
||||||
|
collection,
|
||||||
|
rkey: 'self',
|
||||||
|
record,
|
||||||
|
swapRecord: existing?.data.cid || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await retry(upsert, {
|
||||||
|
maxRetries: 5,
|
||||||
|
retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
image: linkMeta?.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
logger.error(`Failed to upsert live status`, {
|
||||||
|
url: linkMeta?.url,
|
||||||
|
image: linkMeta?.image,
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: ({record, image}) => {
|
||||||
|
if (createdAt) {
|
||||||
|
logger.metric('live:edit', {duration: record.durationMinutes})
|
||||||
|
} else {
|
||||||
|
logger.metric('live:create', {duration: record.durationMinutes})
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.show(_(msg`You are now live!`))
|
||||||
|
control.close(() => {
|
||||||
|
if (!currentAccount) return
|
||||||
|
|
||||||
|
const expiresAt = new Date(record.createdAt)
|
||||||
|
expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
|
||||||
|
|
||||||
|
updateProfileShadow(queryClient, currentAccount.did, {
|
||||||
|
status: {
|
||||||
|
$type: 'app.bsky.actor.defs#statusView',
|
||||||
|
status: 'app.bsky.actor.status#live',
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
embed:
|
||||||
|
record.embed && image
|
||||||
|
? {
|
||||||
|
$type: 'app.bsky.embed.external#view',
|
||||||
|
external: {
|
||||||
|
...record.embed.external,
|
||||||
|
$type: 'app.bsky.embed.external#viewExternal',
|
||||||
|
thumb: image,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
record,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveLiveStatusMutation() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const control = useDialogContext()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!currentAccount) throw new Error('Not logged in')
|
||||||
|
|
||||||
|
await agent.app.bsky.actor.status.delete({
|
||||||
|
repo: currentAccount.did,
|
||||||
|
rkey: 'self',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
logger.error(`Failed to remove live status`, {
|
||||||
|
safeMessage: e,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
logger.metric('live:remove', {})
|
||||||
|
Toast.show(_(msg`You are no longer live`))
|
||||||
|
control.close(() => {
|
||||||
|
if (!currentAccount) return
|
||||||
|
|
||||||
|
updateProfileShadow(queryClient, currentAccount.did, {
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api'
|
||||||
|
|
||||||
|
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export const LIVE_DIDS: Record<string, true> = {
|
||||||
|
'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com
|
||||||
|
'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIVE_SOURCES: Record<string, true> = {
|
||||||
|
'nba.com': true,
|
||||||
|
'twitch.tv': true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEMP: dumb gating
|
||||||
|
export function temp__canBeLive(profile: bsky.profile.AnyProfileView) {
|
||||||
|
if (__DEV__)
|
||||||
|
return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did]
|
||||||
|
return !!LIVE_DIDS[profile.did]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function temp__canGoLive(profile: bsky.profile.AnyProfileView) {
|
||||||
|
if (__DEV__) return true
|
||||||
|
return !!LIVE_DIDS[profile.did]
|
||||||
|
}
|
||||||
|
|
||||||
|
// status must have a embed, and the embed must be an approved host for the status to be valid
|
||||||
|
export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) {
|
||||||
|
if (status.status !== 'app.bsky.actor.status#live') return false
|
||||||
|
try {
|
||||||
|
if (AppBskyEmbedExternal.isView(status.embed)) {
|
||||||
|
const url = new URL(status.embed.external.uri)
|
||||||
|
return !!LIVE_SOURCES[url.hostname]
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {type I18n} from '@lingui/core'
|
||||||
|
import {plural} from '@lingui/macro'
|
||||||
|
|
||||||
|
export function displayDuration(i18n: I18n, durationInMinutes: number) {
|
||||||
|
const roundedDurationInMinutes = Math.round(durationInMinutes)
|
||||||
|
const hours = Math.floor(roundedDurationInMinutes / 60)
|
||||||
|
const minutes = roundedDurationInMinutes % 60
|
||||||
|
const minutesString = i18n._(
|
||||||
|
plural(minutes, {one: '# minute', other: '# minutes'}),
|
||||||
|
)
|
||||||
|
return hours > 0
|
||||||
|
? i18n._(
|
||||||
|
minutes > 0
|
||||||
|
? plural(hours, {
|
||||||
|
one: `# hour ${minutesString}`,
|
||||||
|
other: `# hours ${minutesString}`,
|
||||||
|
})
|
||||||
|
: plural(hours, {
|
||||||
|
one: '# hour',
|
||||||
|
other: '# hours',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: minutesString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing debounce
|
||||||
|
export function useDebouncedValue<T>(val: T, delayMs: number): T {
|
||||||
|
const [prev, setPrev] = useState(val)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setPrev(val), delayMs)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [val, delayMs])
|
||||||
|
|
||||||
|
return prev
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {
|
||||||
|
type $Typed,
|
||||||
|
type AppBskyActorDefs,
|
||||||
|
type AppBskyEmbedExternal,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {isAfter, parseISO} from 'date-fns'
|
||||||
|
|
||||||
|
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
|
import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
|
||||||
|
const shadowed = useMaybeProfileShadow(actor)
|
||||||
|
const tick = useTickEveryMinute()
|
||||||
|
return useMemo(() => {
|
||||||
|
tick! // revalidate every minute
|
||||||
|
|
||||||
|
if (
|
||||||
|
shadowed &&
|
||||||
|
temp__canBeLive(shadowed) &&
|
||||||
|
'status' in shadowed &&
|
||||||
|
shadowed.status &&
|
||||||
|
temp__isStatusValid(shadowed.status) &&
|
||||||
|
isStatusStillActive(shadowed.status.expiresAt)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isActive: true,
|
||||||
|
status: 'app.bsky.actor.status#live',
|
||||||
|
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
|
||||||
|
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
|
||||||
|
record: shadowed.status.record,
|
||||||
|
} satisfies AppBskyActorDefs.StatusView
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: '',
|
||||||
|
isActive: false,
|
||||||
|
record: {},
|
||||||
|
} satisfies AppBskyActorDefs.StatusView
|
||||||
|
}
|
||||||
|
}, [shadowed, tick])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStatusStillActive(timeStr: string | undefined) {
|
||||||
|
if (!timeStr) return false
|
||||||
|
const now = new Date()
|
||||||
|
const expiry = parseISO(timeStr)
|
||||||
|
|
||||||
|
return isAfter(expiry, now)
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
ComAtprotoRepoStrongRef,
|
type ComAtprotoRepoStrongRef,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} 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 {POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
isBskyStartUrl,
|
isBskyStartUrl,
|
||||||
isShortLink,
|
isShortLink,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {createComposerImage} 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 {createGIFDescription} from '../gif-alt-text'
|
||||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ async function resolveExternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imageToThumb(
|
export async function imageToThumb(
|
||||||
imageUri: string,
|
imageUri: string,
|
||||||
): Promise<ComposerImage | undefined> {
|
): Promise<ComposerImage | undefined> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
|
|||||||
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
|
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
|
||||||
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
|
||||||
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
|
'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
|
||||||
|
'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
|
|||||||
@@ -372,3 +372,33 @@ export function getServiceAuthAudFromUrl(url: string | URL): string | null {
|
|||||||
}
|
}
|
||||||
return `did:web:${hostname}`
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -383,4 +383,13 @@ export type MetricEvents = {
|
|||||||
}
|
}
|
||||||
'verification:settings:hideBadges': {}
|
'verification:settings:hideBadges': {}
|
||||||
'verification:settings:unHideBadges': {}
|
'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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -138,6 +139,8 @@ let ProfileHeaderStandard = ({
|
|||||||
[currentAccount, profile],
|
[currentAccount, profile],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileHeaderShell
|
<ProfileHeaderShell
|
||||||
profile={profile}
|
profile={profile}
|
||||||
@@ -228,7 +231,8 @@ let ProfileHeaderStandard = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<ProfileMenu profile={profile} />
|
<ProfileMenu profile={profile} />
|
||||||
</View>
|
</View>
|
||||||
<View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}>
|
<View
|
||||||
|
style={[a.flex_col, a.gap_2xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import React, {memo} from 'react'
|
import React, {memo, useEffect} from 'react'
|
||||||
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
||||||
import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
|
import {
|
||||||
|
type MeasuredDimensions,
|
||||||
|
runOnJS,
|
||||||
|
runOnUI,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {BACK_HITSLOP} from '#/lib/constants'
|
import {BACK_HITSLOP} from '#/lib/constants'
|
||||||
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
|
import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {useLightboxControls} from '#/state/lightbox'
|
import {useLightboxControls} from '#/state/lightbox'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {UserBanner} from '#/view/com/util/UserBanner'
|
import {UserBanner} from '#/view/com/util/UserBanner'
|
||||||
import {atoms as a, platform, useTheme} from '#/alf'
|
import {atoms as a, platform, useTheme} from '#/alf'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
|
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
|
||||||
|
import {LiveIndicator} from '#/components/live/LiveIndicator'
|
||||||
|
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
|
||||||
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
|
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
|
||||||
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
|
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
|
||||||
import {GrowableAvatar} from './GrowableAvatar'
|
import {GrowableAvatar} from './GrowableAvatar'
|
||||||
@@ -45,6 +56,8 @@ let ProfileHeaderShell = ({
|
|||||||
const {openLightbox} = useLightboxControls()
|
const {openLightbox} = useLightboxControls()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {top: topInset} = useSafeAreaInsets()
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
|
const playHaptic = useHaptics()
|
||||||
|
const liveStatusControl = useDialogControl()
|
||||||
|
|
||||||
const aviRef = useHandleRef()
|
const aviRef = useHandleRef()
|
||||||
|
|
||||||
@@ -79,24 +92,46 @@ let ProfileHeaderShell = ({
|
|||||||
[openLightbox],
|
[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(
|
const isMe = React.useMemo(
|
||||||
() => currentAccount?.did === profile.did,
|
() => currentAccount?.did === profile.did,
|
||||||
[currentAccount, profile],
|
[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 (
|
return (
|
||||||
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<View
|
<View
|
||||||
@@ -170,21 +205,44 @@ let ProfileHeaderShell = ({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
{borderColor: t.atoms.bg.backgroundColor},
|
a.rounded_full,
|
||||||
|
{
|
||||||
|
borderWidth: live.isActive ? 3 : 2,
|
||||||
|
borderColor: live.isActive
|
||||||
|
? t.palette.negative_500
|
||||||
|
: t.atoms.bg.backgroundColor,
|
||||||
|
},
|
||||||
styles.avi,
|
styles.avi,
|
||||||
profile.associated?.labeler && styles.aviLabeler,
|
profile.associated?.labeler && styles.aviLabeler,
|
||||||
]}>
|
]}>
|
||||||
<View ref={aviRef} collapsable={false}>
|
<View ref={aviRef} collapsable={false}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
size={90}
|
size={live.isActive ? 88 : 90}
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
/>
|
/>
|
||||||
|
{live.isActive && <LiveIndicator size="large" />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
</GrowableAvatar>
|
</GrowableAvatar>
|
||||||
|
|
||||||
|
{live.isActive &&
|
||||||
|
(isMe ? (
|
||||||
|
<EditLiveDialog
|
||||||
|
control={liveStatusControl}
|
||||||
|
status={live}
|
||||||
|
embed={live.embed}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LiveStatusDialog
|
||||||
|
control={liveStatusControl}
|
||||||
|
status={live}
|
||||||
|
embed={live.embed}
|
||||||
|
profile={profile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -219,8 +277,6 @@ const styles = StyleSheet.create({
|
|||||||
avi: {
|
avi: {
|
||||||
width: 94,
|
width: 94,
|
||||||
height: 94,
|
height: 94,
|
||||||
borderRadius: 47,
|
|
||||||
borderWidth: 2,
|
|
||||||
},
|
},
|
||||||
aviLabeler: {
|
aviLabeler: {
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {IS_INTERNAL} from '#/lib/app-info'
|
import {IS_INTERNAL} from '#/lib/app-info'
|
||||||
import {HELP_DESK_URL} from '#/lib/constants'
|
import {HELP_DESK_URL} from '#/lib/constants'
|
||||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||||
@@ -287,6 +288,7 @@ function ProfilePreview({
|
|||||||
const verificationState = useFullVerificationState({
|
const verificationState = useFullVerificationState({
|
||||||
profile: shadow,
|
profile: shadow,
|
||||||
})
|
})
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
if (!moderationOpts) return null
|
if (!moderationOpts) return null
|
||||||
|
|
||||||
@@ -303,6 +305,7 @@ function ProfilePreview({
|
|||||||
avatar={shadow.avatar}
|
avatar={shadow.avatar}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
type={shadow.associated?.labeler ? 'labeler' : 'user'}
|
type={shadow.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -468,6 +471,7 @@ function AccountRow({
|
|||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const removePromptControl = Prompt.usePromptControl()
|
const removePromptControl = Prompt.usePromptControl()
|
||||||
const {removeAccount} = useSessionApi()
|
const {removeAccount} = useSessionApi()
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
const onSwitchAccount = () => {
|
const onSwitchAccount = () => {
|
||||||
if (pendingDid) return
|
if (pendingDid) return
|
||||||
@@ -485,6 +489,8 @@ function AccountRow({
|
|||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[{width: 28}]} />
|
<View style={[{width: 28}]} />
|
||||||
|
|||||||
Vendored
+7
@@ -31,6 +31,7 @@ export interface ProfileShadow {
|
|||||||
muted: boolean | undefined
|
muted: boolean | undefined
|
||||||
blockingUri: string | undefined
|
blockingUri: string | undefined
|
||||||
verification: AppBskyActorDefs.VerificationState
|
verification: AppBskyActorDefs.VerificationState
|
||||||
|
status: AppBskyActorDefs.StatusView | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const shadows: WeakMap<
|
const shadows: WeakMap<
|
||||||
@@ -138,6 +139,12 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
|
|||||||
},
|
},
|
||||||
verification:
|
verification:
|
||||||
'verification' in shadow ? shadow.verification : profile.verification,
|
'verification' in shadow ? shadow.verification : profile.verification,
|
||||||
|
status:
|
||||||
|
'status' in shadow
|
||||||
|
? shadow.status
|
||||||
|
: 'status' in profile
|
||||||
|
? profile.status
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import {msg, Plural, Trans} from '@lingui/macro'
|
import {msg, Plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {MAX_POST_LINES} from '#/lib/constants'
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
@@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({
|
|||||||
setLimitLines(false)
|
setLimitLines(false)
|
||||||
}, [setLimitLines])
|
}, [setLimitLines])
|
||||||
|
|
||||||
|
const {isActive: live} = useActorStatus(post.author)
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
|
return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
|
||||||
}
|
}
|
||||||
@@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({
|
|||||||
profile={post.author}
|
profile={post.author}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
/>
|
/>
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
@@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({
|
|||||||
profile={post.author}
|
profile={post.author}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showChildReplyLine && (
|
{showChildReplyLine && (
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {precacheProfile} from '#/state/queries/profile'
|
import {precacheProfile} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -174,14 +173,12 @@ function PostInner({
|
|||||||
{showReplyLine && <View style={styles.replyLine} />}
|
{showReplyLine && <View style={styles.replyLine} />}
|
||||||
<View style={styles.layout}>
|
<View style={styles.layout}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<AviFollowButton author={post.author} moderation={moderation}>
|
<PreviewableUserAvatar
|
||||||
<PreviewableUserAvatar
|
size={42}
|
||||||
size={42}
|
profile={post.author}
|
||||||
profile={post.author}
|
moderation={moderation.ui('avatar')}
|
||||||
moderation={moderation.ui('avatar')}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
/>
|
||||||
/>
|
|
||||||
</AviFollowButton>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
<PostMeta
|
<PostMeta
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
|
||||||
import {msg} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
|
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
|
||||||
import {useSession} from '#/state/session'
|
|
||||||
import {
|
|
||||||
DropdownItem,
|
|
||||||
NativeDropdown,
|
|
||||||
} from '#/view/com/util/forms/NativeDropdown'
|
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
|
||||||
import {atoms as a, select, useTheme} from '#/alf'
|
|
||||||
import {Button} from '#/components/Button'
|
|
||||||
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
|
|
||||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
|
||||||
|
|
||||||
export function AviFollowButton({
|
|
||||||
author,
|
|
||||||
moderation,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
author: AppBskyActorDefs.ProfileViewBasic
|
|
||||||
moderation: ModerationDecision
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
const profile = useProfileShadow(author)
|
|
||||||
const {follow} = useFollowMethods({
|
|
||||||
profile: profile,
|
|
||||||
logContext: 'AvatarButton',
|
|
||||||
})
|
|
||||||
const {currentAccount, hasSession} = useSession()
|
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
|
|
||||||
const name = sanitizeDisplayName(
|
|
||||||
profile.displayName || profile.handle,
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)
|
|
||||||
const isFollowing =
|
|
||||||
profile.viewer?.following || profile.did === currentAccount?.did
|
|
||||||
|
|
||||||
function onPress() {
|
|
||||||
follow()
|
|
||||||
Toast.show(_(msg`Following ${name}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
label: _(msg`View profile`),
|
|
||||||
onPress: () => {
|
|
||||||
navigation.navigate('Profile', {name: profile.did})
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'arrow.up.right.square',
|
|
||||||
},
|
|
||||||
android: '',
|
|
||||||
web: ['far', 'arrow-up-right-from-square'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: _(msg`Follow ${name}`),
|
|
||||||
onPress: onPress,
|
|
||||||
icon: {
|
|
||||||
ios: {
|
|
||||||
name: 'person.badge.plus',
|
|
||||||
},
|
|
||||||
android: '',
|
|
||||||
web: ['far', 'user-plus'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return hasSession ? (
|
|
||||||
<View style={a.relative}>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{!isFollowing && (
|
|
||||||
<Button
|
|
||||||
label={_(msg`Open ${name} profile shortcut menu`)}
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
bottom: -7,
|
|
||||||
right: -7,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<NativeDropdown items={items}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
// An asymmetric hit slop
|
|
||||||
// to prioritize bottom right taps.
|
|
||||||
paddingTop: 2,
|
|
||||||
paddingLeft: 2,
|
|
||||||
paddingBottom: 6,
|
|
||||||
paddingRight: 6,
|
|
||||||
},
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
a.rounded_full,
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.align_center,
|
|
||||||
select(t.name, {
|
|
||||||
light: t.atoms.bg_contrast_100,
|
|
||||||
dim: t.atoms.bg_contrast_100,
|
|
||||||
dark: t.atoms.bg_contrast_200,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: t.atoms.bg.backgroundColor,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Plus
|
|
||||||
size="sm"
|
|
||||||
fill={
|
|
||||||
select(t.name, {
|
|
||||||
light: t.atoms.bg_contrast_600,
|
|
||||||
dim: t.atoms.bg_contrast_500,
|
|
||||||
dark: t.atoms.bg_contrast_600,
|
|
||||||
}).backgroundColor
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</NativeDropdown>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export function AviFollowButton({children}: {children: React.ReactNode}) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {memo, useCallback} from 'react'
|
import React, {memo, useCallback, useRef} from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
AppState,
|
AppState,
|
||||||
@@ -19,6 +19,7 @@ import {msg} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {isStatusStillActive} from '#/lib/actor-status'
|
||||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||||
|
import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
|
||||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||||
@@ -775,6 +777,31 @@ let PostFeed = ({
|
|||||||
)
|
)
|
||||||
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
|
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
|
||||||
|
|
||||||
|
const seenActorWithStatusRef = useRef<Set<string>>(new Set())
|
||||||
|
const onItemSeen = useCallback(
|
||||||
|
(item: FeedRow) => {
|
||||||
|
feedFeedback.onItemSeen(item)
|
||||||
|
if (item.type === 'sliceItem') {
|
||||||
|
const actor = item.slice.items[item.indexInSlice].post.author
|
||||||
|
if (
|
||||||
|
actor.status &&
|
||||||
|
temp__canBeLive(actor) &&
|
||||||
|
temp__isStatusValid(actor.status) &&
|
||||||
|
isStatusStillActive(actor.status.expiresAt)
|
||||||
|
) {
|
||||||
|
if (!seenActorWithStatusRef.current.has(actor.did)) {
|
||||||
|
seenActorWithStatusRef.current.add(actor.did)
|
||||||
|
logger.metric('live:view:post', {
|
||||||
|
subject: actor.did,
|
||||||
|
feed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[feedFeedback, feed],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={style}>
|
<View testID={testID} style={style}>
|
||||||
<List
|
<List
|
||||||
@@ -797,7 +824,6 @@ let PostFeed = ({
|
|||||||
onEndReachedThreshold={2} // number of posts left to trigger load more
|
onEndReachedThreshold={2} // number of posts left to trigger load more
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
// @ts-ignore our .web version only -prf
|
|
||||||
desktopFixedHeight={
|
desktopFixedHeight={
|
||||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||||
}
|
}
|
||||||
@@ -805,7 +831,7 @@ let PostFeed = ({
|
|||||||
windowSize={9}
|
windowSize={9}
|
||||||
maxToRenderPerBatch={isIOS ? 5 : 1}
|
maxToRenderPerBatch={isIOS ? 5 : 1}
|
||||||
updateCellsBatchingPeriod={40}
|
updateCellsBatchingPeriod={40}
|
||||||
onItemSeen={feedFeedback.onItemSeen}
|
onItemSeen={onItemSeen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
|
import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
|
||||||
import {MAX_POST_LINES} from '#/lib/constants'
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
@@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText'
|
|||||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||||
import {AviFollowButton} from './AviFollowButton'
|
|
||||||
|
|
||||||
interface FeedItemProps {
|
interface FeedItemProps {
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
@@ -251,6 +251,8 @@ let FeedItemInner = ({
|
|||||||
? rootPost.threadgate.record
|
? rootPost.threadgate.record
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const {isActive: live} = useActorStatus(post.author)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${post.author.handle}`}
|
testID={`feedItem-by-${post.author.handle}`}
|
||||||
@@ -381,15 +383,14 @@ let FeedItemInner = ({
|
|||||||
|
|
||||||
<View style={styles.layout}>
|
<View style={styles.layout}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<AviFollowButton author={post.author} moderation={moderation}>
|
<PreviewableUserAvatar
|
||||||
<PreviewableUserAvatar
|
size={42}
|
||||||
size={42}
|
profile={post.author}
|
||||||
profile={post.author}
|
moderation={moderation.ui('avatar')}
|
||||||
moderation={moderation.ui('avatar')}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
onBeforePress={onOpenAuthor}
|
||||||
onBeforePress={onOpenAuthor}
|
live={live}
|
||||||
/>
|
/>
|
||||||
</AviFollowButton>
|
|
||||||
{isThreadParent && (
|
{isThreadParent && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -397,7 +398,7 @@ let FeedItemInner = ({
|
|||||||
{
|
{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: pal.colors.replyLine,
|
backgroundColor: pal.colors.replyLine,
|
||||||
marginTop: 4,
|
marginTop: live ? 8 : 4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {HITSLOP_20} from '#/lib/constants'
|
import {HITSLOP_20} from '#/lib/constants'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
@@ -23,12 +24,14 @@ import {useSession} from '#/state/session'
|
|||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
||||||
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
|
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
|
||||||
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||||
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
|
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 {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
|
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
|
||||||
@@ -38,6 +41,9 @@ import {
|
|||||||
} from '#/components/icons/Person'
|
} from '#/components/icons/Person'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
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 * as Menu from '#/components/Menu'
|
||||||
import {
|
import {
|
||||||
ReportDialog,
|
ReportDialog,
|
||||||
@@ -77,6 +83,7 @@ let ProfileMenu = ({
|
|||||||
|
|
||||||
const blockPromptControl = Prompt.usePromptControl()
|
const blockPromptControl = Prompt.usePromptControl()
|
||||||
const loggedOutWarningPromptControl = Prompt.usePromptControl()
|
const loggedOutWarningPromptControl = Prompt.usePromptControl()
|
||||||
|
const goLiveDialogControl = useDialogControl()
|
||||||
|
|
||||||
const showLoggedOutWarning = React.useMemo(() => {
|
const showLoggedOutWarning = React.useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -201,6 +208,8 @@ let ProfileMenu = ({
|
|||||||
return v.issuer === currentAccount?.did
|
return v.issuer === currentAccount?.did
|
||||||
}) ?? []
|
}) ?? []
|
||||||
|
|
||||||
|
const status = useActorStatus(profile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventStopper onKeyDown={false}>
|
<EventStopper onKeyDown={false}>
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
@@ -290,6 +299,25 @@ let ProfileMenu = ({
|
|||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={List} />
|
<Menu.ItemIcon icon={List} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{isSelf && temp__canGoLive(profile) && (
|
||||||
|
<Menu.Item
|
||||||
|
testID="profileHeaderDropdownListAddRemoveBtn"
|
||||||
|
label={
|
||||||
|
status.isActive
|
||||||
|
? _(msg`Edit live status`)
|
||||||
|
: _(msg`Go live`)
|
||||||
|
}
|
||||||
|
onPress={goLiveDialogControl.open}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
{status.isActive ? (
|
||||||
|
<Trans>Edit live status</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Go live</Trans>
|
||||||
|
)}
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={LiveIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{verification.viewer.role === 'verifier' &&
|
{verification.viewer.role === 'verifier' &&
|
||||||
!verification.profile.isViewer &&
|
!verification.profile.isViewer &&
|
||||||
(verification.viewer.hasIssuedVerification ? (
|
(verification.viewer.hasIssuedVerification ? (
|
||||||
@@ -456,6 +484,16 @@ let ProfileMenu = ({
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
verifications={currentAccountVerifications}
|
verifications={currentAccountVerifications}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{status.isActive ? (
|
||||||
|
<EditLiveDialog
|
||||||
|
control={goLiveDialogControl}
|
||||||
|
status={status}
|
||||||
|
embed={status.embed}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GoLiveDialog control={goLiveDialogControl} profile={profile} />
|
||||||
|
)}
|
||||||
</EventStopper>
|
</EventStopper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {forceLTR} from '#/lib/strings/bidi'
|
import {forceLTR} from '#/lib/strings/bidi'
|
||||||
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
|
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 timestampLabel = niceDate(i18n, opts.timestamp)
|
||||||
const verification = useSimpleVerificationState({profile: author})
|
const verification = useSimpleVerificationState({profile: author})
|
||||||
|
const {isActive: live} = useActorStatus(author)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||||||
profile={author}
|
profile={author}
|
||||||
moderation={opts.moderation?.ui('avatar')}
|
moderation={opts.moderation?.ui('avatar')}
|
||||||
type={author.associated?.labeler ? 'labeler' : 'user'}
|
type={author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
|
import {isTouchDevice} from '#/lib/browser'
|
||||||
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {
|
import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
@@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip'
|
|||||||
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
|||||||
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +48,8 @@ import {
|
|||||||
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
|
import {LiveIndicator} from '#/components/live/LiveIndicator'
|
||||||
|
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
@@ -54,6 +62,8 @@ interface BaseUserAvatarProps {
|
|||||||
shape?: 'circle' | 'square'
|
shape?: 'circle' | 'square'
|
||||||
size: number
|
size: number
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
|
live?: boolean
|
||||||
|
hideLiveBadge?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserAvatarProps extends BaseUserAvatarProps {
|
interface UserAvatarProps extends BaseUserAvatarProps {
|
||||||
@@ -196,27 +206,38 @@ let UserAvatar = ({
|
|||||||
usePlainRNImage = false,
|
usePlainRNImage = false,
|
||||||
onLoad,
|
onLoad,
|
||||||
style,
|
style,
|
||||||
|
live,
|
||||||
|
hideLiveBadge,
|
||||||
}: UserAvatarProps): React.ReactNode => {
|
}: UserAvatarProps): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const backgroundColor = t.palette.contrast_25
|
|
||||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
|
let borderRadius
|
||||||
if (finalShape === 'square') {
|
if (finalShape === 'square') {
|
||||||
return {
|
borderRadius = size > 32 ? 8 : 3
|
||||||
width: size,
|
} else {
|
||||||
height: size,
|
borderRadius = Math.floor(size / 2)
|
||||||
borderRadius: size > 32 ? 8 : 3,
|
|
||||||
backgroundColor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: Math.floor(size / 2),
|
borderRadius,
|
||||||
backgroundColor,
|
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(() => {
|
const alert = useMemo(() => {
|
||||||
if (!moderation?.alert) {
|
if (!moderation?.alert) {
|
||||||
@@ -277,12 +298,19 @@ let UserAvatar = ({
|
|||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
|
<MediaInsetBorder style={borderStyle} />
|
||||||
|
{live && size > 16 && !hideLiveBadge && (
|
||||||
|
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
|
||||||
|
)}
|
||||||
{alert}
|
{alert}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<DefaultAvatar type={type} shape={finalShape} size={size} />
|
<DefaultAvatar type={type} shape={finalShape} size={size} />
|
||||||
|
<MediaInsetBorder style={borderStyle} />
|
||||||
|
{live && size > 16 && !hideLiveBadge && (
|
||||||
|
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
|
||||||
|
)}
|
||||||
{alert}
|
{alert}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({
|
|||||||
disableHoverCard,
|
disableHoverCard,
|
||||||
disableNavigation,
|
disableNavigation,
|
||||||
onBeforePress,
|
onBeforePress,
|
||||||
|
live,
|
||||||
...rest
|
...rest
|
||||||
}: PreviewableUserAvatarProps): React.ReactNode => {
|
}: PreviewableUserAvatarProps): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const status = useActorStatus(profile)
|
||||||
|
const liveControl = useDialogControl()
|
||||||
|
const playHaptic = useHaptics()
|
||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = useCallback(() => {
|
||||||
onBeforePress?.()
|
onBeforePress?.()
|
||||||
unstableCacheProfileView(queryClient, profile)
|
unstableCacheProfileView(queryClient, profile)
|
||||||
}, [profile, queryClient, onBeforePress])
|
}, [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 = (
|
const avatarEl = (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={status.isActive || live}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({
|
|||||||
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
||||||
{disableNavigation ? (
|
{disableNavigation ? (
|
||||||
avatarEl
|
avatarEl
|
||||||
|
) : status.isActive && (isNative || isTouchDevice) ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={_(
|
||||||
|
msg`${sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
)}'s avatar`,
|
||||||
|
)}
|
||||||
|
accessibilityHint={_(msg`Opens live status dialog`)}
|
||||||
|
onPress={onOpenLiveStatus}>
|
||||||
|
{avatarEl}
|
||||||
|
</Button>
|
||||||
|
<LiveStatusDialog
|
||||||
|
control={liveControl}
|
||||||
|
profile={profile}
|
||||||
|
status={status}
|
||||||
|
embed={status.embed}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
|
label={_(
|
||||||
|
msg`${sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
)}'s avatar`,
|
||||||
|
)}
|
||||||
accessibilityHint={_(msg`Opens this profile`)}
|
accessibilityHint={_(msg`Opens this profile`)}
|
||||||
to={makeProfileLink({
|
to={makeProfileLink({
|
||||||
did: profile.did,
|
did: profile.did,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {View} from 'react-native'
|
|||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonColor,
|
type ButtonColor,
|
||||||
ButtonIcon,
|
ButtonIcon,
|
||||||
ButtonText,
|
ButtonText,
|
||||||
ButtonVariant,
|
type ButtonVariant,
|
||||||
} from '#/components/Button'
|
} from '#/components/Button'
|
||||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||||
@@ -19,31 +19,35 @@ export function Buttons() {
|
|||||||
<H1>Buttons</H1>
|
<H1>Buttons</H1>
|
||||||
|
|
||||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
|
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
|
||||||
{['primary', 'secondary', 'secondary_inverted', 'negative'].map(
|
{[
|
||||||
color => (
|
'primary',
|
||||||
<View key={color} style={[a.gap_md, a.align_start]}>
|
'secondary',
|
||||||
{['solid', 'outline', 'ghost'].map(variant => (
|
'secondary_inverted',
|
||||||
<React.Fragment key={variant}>
|
'negative',
|
||||||
<Button
|
'negative_secondary',
|
||||||
variant={variant as ButtonVariant}
|
].map(color => (
|
||||||
color={color as ButtonColor}
|
<View key={color} style={[a.gap_md, a.align_start]}>
|
||||||
size="large"
|
{['solid', 'outline', 'ghost'].map(variant => (
|
||||||
label="Click here">
|
<React.Fragment key={variant}>
|
||||||
<ButtonText>Button</ButtonText>
|
<Button
|
||||||
</Button>
|
variant={variant as ButtonVariant}
|
||||||
<Button
|
color={color as ButtonColor}
|
||||||
disabled
|
size="large"
|
||||||
variant={variant as ButtonVariant}
|
label="Click here">
|
||||||
color={color as ButtonColor}
|
<ButtonText>Button</ButtonText>
|
||||||
size="large"
|
</Button>
|
||||||
label="Click here">
|
<Button
|
||||||
<ButtonText>Button</ButtonText>
|
disabled
|
||||||
</Button>
|
variant={variant as ButtonVariant}
|
||||||
</React.Fragment>
|
color={color as ButtonColor}
|
||||||
))}
|
size="large"
|
||||||
</View>
|
label="Click here">
|
||||||
),
|
<ButtonText>Button</ButtonText>
|
||||||
)}
|
</Button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
<View style={[a.flex_row, a.gap_md, a.align_start]}>
|
<View style={[a.flex_row, a.gap_md, a.align_start]}>
|
||||||
<View style={[a.gap_md, a.align_start]}>
|
<View style={[a.gap_md, a.align_start]}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/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 {useSetThemePrefs} from '#/state/shell'
|
||||||
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
||||||
import {atoms as a, ThemeProvider} from '#/alf'
|
import {atoms as a, ThemeProvider} from '#/alf'
|
||||||
@@ -115,7 +115,6 @@ function StorybookInner() {
|
|||||||
<Typography />
|
<Typography />
|
||||||
<Spacing />
|
<Spacing />
|
||||||
<Shadows />
|
<Shadows />
|
||||||
<Buttons />
|
|
||||||
<Icons />
|
<Icons />
|
||||||
<Links />
|
<Links />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
||||||
import {type PressableScale} from '#/lib/custom-animations/PressableScale'
|
import {type PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||||
@@ -67,6 +68,7 @@ let DrawerProfileCard = ({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {data: profile} = useProfileQuery({did: account.did})
|
const {data: profile} = useProfileQuery({did: account.did})
|
||||||
const verification = useSimpleVerificationState({profile})
|
const verification = useSimpleVerificationState({profile})
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -81,6 +83,7 @@ let DrawerProfileCard = ({
|
|||||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||||
usePlainRNImage={true}
|
usePlainRNImage={true}
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
/>
|
/>
|
||||||
<View style={[a.gap_2xs]}>
|
<View style={[a.gap_2xs]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
||||||
import {StackActions} from '@react-navigation/native'
|
import {StackActions} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||||
import {BOTTOM_BAR_AVI} from '#/lib/demo'
|
import {BOTTOM_BAR_AVI} from '#/lib/demo'
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
@@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
}, [accountSwitchControl, playHaptic])
|
}, [accountSwitchControl, playHaptic])
|
||||||
|
|
||||||
const [demoMode] = useDemoMode()
|
const [demoMode] = useDemoMode()
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
pal.text,
|
pal.text,
|
||||||
styles.profileIcon,
|
styles.profileIcon,
|
||||||
styles.onProfile,
|
styles.onProfile,
|
||||||
{borderColor: pal.text.color},
|
{
|
||||||
|
borderColor: pal.text.color,
|
||||||
|
borderWidth: live ? 0 : 1,
|
||||||
|
},
|
||||||
]}>
|
]}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||||
size={iconWidth - 3}
|
size={iconWidth - 2}
|
||||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||||
usePlainRNImage={true}
|
usePlainRNImage={true}
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
|
style={[
|
||||||
|
styles.ctrlIcon,
|
||||||
|
pal.text,
|
||||||
|
styles.profileIcon,
|
||||||
|
{
|
||||||
|
borderWidth: live ? 0 : 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||||
size={iconWidth - 3}
|
size={iconWidth - 2}
|
||||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||||
usePlainRNImage={true}
|
usePlainRNImage={true}
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useNavigationState,
|
useNavigationState,
|
||||||
} from '@react-navigation/native'
|
} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useActorStatus} from '#/lib/actor-status'
|
||||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
@@ -100,6 +101,8 @@ function ProfileCard() {
|
|||||||
profile: profiles?.find(p => p.did === account.did),
|
profile: profiles?.find(p => p.did === account.did),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}>
|
<View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}>
|
||||||
{!isLoading && profile ? (
|
{!isLoading && profile ? (
|
||||||
@@ -142,6 +145,7 @@ function ProfileCard() {
|
|||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
size={size}
|
size={size}
|
||||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{!leftNavMinimal && (
|
{!leftNavMinimal && (
|
||||||
@@ -226,7 +230,6 @@ function SwitchMenuItems({
|
|||||||
signOutPromptControl: DialogControlProps
|
signOutPromptControl: DialogControlProps
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
|
||||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||||
const closeEverything = useCloseAllActiveElements()
|
const closeEverything = useCloseAllActiveElements()
|
||||||
|
|
||||||
@@ -243,35 +246,11 @@ function SwitchMenuItems({
|
|||||||
<Trans>Switch account</Trans>
|
<Trans>Switch account</Trans>
|
||||||
</Menu.LabelText>
|
</Menu.LabelText>
|
||||||
{accounts.map(other => (
|
{accounts.map(other => (
|
||||||
<Menu.Item
|
<SwitchMenuItem
|
||||||
disabled={!!pendingDid}
|
|
||||||
style={[{minWidth: 150}]}
|
|
||||||
key={other.account.did}
|
key={other.account.did}
|
||||||
label={_(
|
account={other.account}
|
||||||
msg`Switch to ${sanitizeHandle(
|
profile={other.profile}
|
||||||
other.profile?.handle ?? other.account.handle,
|
/>
|
||||||
'@',
|
|
||||||
)}`,
|
|
||||||
)}
|
|
||||||
onPress={() =>
|
|
||||||
onPressSwitchAccount(other.account, 'SwitchAccount')
|
|
||||||
}>
|
|
||||||
<View style={[{marginLeft: tokens.space._2xs * -1}]}>
|
|
||||||
<UserAvatar
|
|
||||||
avatar={other.profile?.avatar}
|
|
||||||
size={20}
|
|
||||||
type={
|
|
||||||
other.profile?.associated?.labeler ? 'labeler' : 'user'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Menu.ItemText>
|
|
||||||
{sanitizeHandle(
|
|
||||||
other.profile?.handle ?? other.account.handle,
|
|
||||||
'@',
|
|
||||||
)}
|
|
||||||
</Menu.ItemText>
|
|
||||||
</Menu.Item>
|
|
||||||
))}
|
))}
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
@@ -295,6 +274,45 @@ function SwitchMenuItems({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SwitchMenuItem({
|
||||||
|
account,
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
account: SessionAccount
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed | undefined
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||||
|
const {isActive: live} = useActorStatus(profile)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
disabled={!!pendingDid}
|
||||||
|
style={[a.gap_sm, {minWidth: 150}]}
|
||||||
|
key={account.did}
|
||||||
|
label={_(
|
||||||
|
msg`Switch to ${sanitizeHandle(
|
||||||
|
profile?.handle ?? account.handle,
|
||||||
|
'@',
|
||||||
|
)}`,
|
||||||
|
)}
|
||||||
|
onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}>
|
||||||
|
<View>
|
||||||
|
<UserAvatar
|
||||||
|
avatar={profile?.avatar}
|
||||||
|
size={20}
|
||||||
|
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
live={live}
|
||||||
|
hideLiveBadge
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Menu.ItemText>
|
||||||
|
{sanitizeHandle(profile?.handle ?? account.handle, '@')}
|
||||||
|
</Menu.ItemText>
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
count?: string
|
count?: string
|
||||||
hasNew?: boolean
|
hasNew?: boolean
|
||||||
|
|||||||
@@ -56,36 +56,36 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8"
|
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8"
|
||||||
integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==
|
integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA==
|
||||||
|
|
||||||
"@atproto-labs/xrpc-utils@0.0.13":
|
"@atproto-labs/xrpc-utils@0.0.14":
|
||||||
version "0.0.13"
|
version "0.0.14"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.13.tgz#5d684bc574537066d3c3404b4d69c03b8672f9a4"
|
resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.14.tgz#eefe1ccf61a4288708601324496b0106d5ed4ae3"
|
||||||
integrity sha512-uQnZhpHFa3EDHct+/slPl5+q2myMBTr6stZbdb6O877wtjEwN4C/7A0eMKaIqETmtxULYpZGapYJ7PG34aa7uQ==
|
integrity sha512-/f0Dhzi08w3Oqv38wdwQ5bw238GbxhYIcxg08kVReEMTlkyRDC6H5RuqHf8Ff9J3FKqjKHGdxaOdrPNM1hCgeQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/xrpc" "^0.6.12"
|
"@atproto/xrpc" "^0.7.0"
|
||||||
"@atproto/xrpc-server" "^0.7.17"
|
"@atproto/xrpc-server" "^0.7.18"
|
||||||
|
|
||||||
"@atproto/api@^0.15.5":
|
"@atproto/api@^0.15.6":
|
||||||
version "0.15.5"
|
version "0.15.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.5.tgz#cd7e12fd4d4546a73a8e0ea4e7737f883ac3d2a2"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.6.tgz#3832f16641d89c687794cea14b4aba05ba5993c8"
|
||||||
integrity sha512-GiKOrjSXMm8OSpc+pfjFTBYQGX62jmorECkTx2VZbS6KtFKFY0cRQAI+JnQoOLF/8TvzpaAZB7+it73uIqDM7A==
|
integrity sha512-hKwrBf60LcI4BqArWyrhWJWIpjwAWUJpW3PVvNzUB1q2W/ByC0JAuwq/F8tZpCEiiVBzHjHVRx4QNA2TA1cG3g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc" "^0.6.12"
|
"@atproto/xrpc" "^0.7.0"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/aws@^0.2.20":
|
"@atproto/aws@^0.2.21":
|
||||||
version "0.2.20"
|
version "0.2.21"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.20.tgz#2b29c12738c8fb2c94f1e1775762319d859c8232"
|
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.21.tgz#06006a101c8004db11384a19366296cd87468326"
|
||||||
integrity sha512-lTVkux1gqJuwub1GnyMqWtCc4OSCNKOG2MR+2QUx+qu0Jicqeda7J/7rs6nem+6ngsO7JWm2pfCc6GEUqTiHLQ==
|
integrity sha512-bosExZ3YdFjOehNBcNWsC2mZBrAVLO8Ut/JquypXSahFeeXZP/9rd9F1VGf+vAmjFEKagHXQCb6CRFfJyN+I7A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@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-cloudfront" "^3.261.0"
|
||||||
"@aws-sdk/client-kms" "^3.196.0"
|
"@aws-sdk/client-kms" "^3.196.0"
|
||||||
"@aws-sdk/client-s3" "^3.224.0"
|
"@aws-sdk/client-s3" "^3.224.0"
|
||||||
@@ -95,23 +95,23 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
|
|
||||||
"@atproto/bsky@^0.0.147":
|
"@atproto/bsky@^0.0.148":
|
||||||
version "0.0.147"
|
version "0.0.148"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.147.tgz#4a35d80a9659703d1811f3d395e15b3d4d07ad0f"
|
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.148.tgz#f864631e5a9726d3a40c15b0311f730bc16d6bd9"
|
||||||
integrity sha512-sMwzY8qsthlSY8NJRyQaO6dX6p6p9BuzU7pIbw8bhKlFwtdPpjSusIBVDp5XImJGs4Rm2eYqBWQUy3egS8Uytw==
|
integrity sha512-09Lzjz9kCK7kPOlJcVj6KbATtoPQwNeeU5s0J2apZYCQmA7wN2xRb5KMf9wr+wa1KO7FwbXKSunwer96dB6zrQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto-labs/fetch-node" "0.1.8"
|
"@atproto-labs/fetch-node" "0.1.8"
|
||||||
"@atproto-labs/xrpc-utils" "0.0.13"
|
"@atproto-labs/xrpc-utils" "0.0.14"
|
||||||
"@atproto/api" "^0.15.5"
|
"@atproto/api" "^0.15.6"
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/did" "^0.1.5"
|
"@atproto/did" "^0.1.5"
|
||||||
"@atproto/identity" "^0.4.7"
|
"@atproto/identity" "^0.4.8"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/repo" "^0.8.0"
|
"@atproto/repo" "^0.8.1"
|
||||||
"@atproto/sync" "^0.1.22"
|
"@atproto/sync" "^0.1.23"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc-server" "^0.7.17"
|
"@atproto/xrpc-server" "^0.7.18"
|
||||||
"@bufbuild/protobuf" "^1.5.0"
|
"@bufbuild/protobuf" "^1.5.0"
|
||||||
"@connectrpc/connect" "^1.1.4"
|
"@connectrpc/connect" "^1.1.4"
|
||||||
"@connectrpc/connect-express" "^1.1.4"
|
"@connectrpc/connect-express" "^1.1.4"
|
||||||
@@ -141,12 +141,12 @@
|
|||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
undici "^6.19.8"
|
undici "^6.19.8"
|
||||||
|
|
||||||
"@atproto/bsync@^0.0.18":
|
"@atproto/bsync@^0.0.19":
|
||||||
version "0.0.18"
|
version "0.0.19"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.18.tgz#83c7f061a057f6878b5f65e1b431237f512de072"
|
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.19.tgz#bab3d5e4e7c1ca8de16d9b5efebc49dde12d7160"
|
||||||
integrity sha512-MslGplA3HN9D+L1Ywj0QPmEmg9QyDWjlGSTpqGA+D+GNZ7kjRnf5/XYdQMyUqzD127EqjrbzgCBY82D/GonWoA==
|
integrity sha512-AF9aWbU0VlpT//lIuYKhNRplTv+99ld58kfHTS8jfXCpiOZwxwneTkB1hzE+slXJ63K8i/GyzsQCyvRHWzGWCQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@bufbuild/protobuf" "^1.5.0"
|
"@bufbuild/protobuf" "^1.5.0"
|
||||||
"@connectrpc/connect" "^1.1.4"
|
"@connectrpc/connect" "^1.1.4"
|
||||||
@@ -157,10 +157,10 @@
|
|||||||
pino-http "^8.2.1"
|
pino-http "^8.2.1"
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
|
|
||||||
"@atproto/common-web@^0.4.1":
|
"@atproto/common-web@^0.4.2":
|
||||||
version "0.4.1"
|
version "0.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.1.tgz#f31054f689f4f52b06da6ffd727e40ecd67a30b6"
|
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259"
|
||||||
integrity sha512-Ghh+djHYMAUCktLKwr2IuGgtjcwSWGudp+K7+N7KBA9pDDloOXUEY8Agjc5SHSo9B1QIEFkegClU5n+apn2e0w==
|
integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==
|
||||||
dependencies:
|
dependencies:
|
||||||
graphemer "^1.4.0"
|
graphemer "^1.4.0"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
@@ -187,12 +187,12 @@
|
|||||||
pino "^8.6.1"
|
pino "^8.6.1"
|
||||||
zod "^3.14.2"
|
zod "^3.14.2"
|
||||||
|
|
||||||
"@atproto/common@^0.4.10":
|
"@atproto/common@^0.4.11":
|
||||||
version "0.4.10"
|
version "0.4.11"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.10.tgz#9dc49364ad856f2833ce24afb5e5c7a07b57f888"
|
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6"
|
||||||
integrity sha512-/Yxnax3XOhf46jYpe8/6O3ORjTNMB4YCaxx3V1f+FKy6meTm3GNrJwo8d1CBs0UiTiheRiNATOV3u0s3C7Ydaw==
|
integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@ipld/dag-cbor" "^7.0.3"
|
"@ipld/dag-cbor" "^7.0.3"
|
||||||
cbor-x "^1.5.1"
|
cbor-x "^1.5.1"
|
||||||
iso-datestring-validator "^2.2.2"
|
iso-datestring-validator "^2.2.2"
|
||||||
@@ -219,23 +219,23 @@
|
|||||||
"@noble/hashes" "^1.6.1"
|
"@noble/hashes" "^1.6.1"
|
||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
|
|
||||||
"@atproto/dev-env@^0.3.128":
|
"@atproto/dev-env@^0.3.129":
|
||||||
version "0.3.128"
|
version "0.3.130"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.128.tgz#0a06dc71be671eaa09464b15c65f2b27b9e60cfd"
|
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.130.tgz#444ad315c00bdcf8bdae036d1e6a56a1808b98c6"
|
||||||
integrity sha512-1qxPLQLaAUH6SOQJoje9O7LaZ0dp+P/oS/4OQ3I9hYJAZn78dDrFZ7YIC5n1yMTr1iSjHeXvFA9wt0Czcl/uUA==
|
integrity sha512-xRQb+b09lpdG1vGdvMk8Yf/AnO4SDQTjKLyPO+LYYeHuOrKKjJWiBorFC8Lp/rnraoM3AcwMKmW48wdd7cOL9g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/api" "^0.15.5"
|
"@atproto/api" "^0.15.6"
|
||||||
"@atproto/bsky" "^0.0.147"
|
"@atproto/bsky" "^0.0.148"
|
||||||
"@atproto/bsync" "^0.0.18"
|
"@atproto/bsync" "^0.0.19"
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/identity" "^0.4.7"
|
"@atproto/identity" "^0.4.8"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/ozone" "^0.1.108"
|
"@atproto/ozone" "^0.1.109"
|
||||||
"@atproto/pds" "^0.4.134"
|
"@atproto/pds" "^0.4.136"
|
||||||
"@atproto/sync" "^0.1.22"
|
"@atproto/sync" "^0.1.23"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@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/lib" "^0.0.1"
|
||||||
"@did-plc/server" "^0.0.1"
|
"@did-plc/server" "^0.0.1"
|
||||||
dotenv "^16.0.3"
|
dotenv "^16.0.3"
|
||||||
@@ -252,12 +252,12 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/identity@^0.4.7":
|
"@atproto/identity@^0.4.8":
|
||||||
version "0.4.7"
|
version "0.4.8"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.7.tgz#1f8958e49f6046f3412463269e1fc961c0936ff2"
|
resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.8.tgz#28ae9f8fe0e83196c5b6747394e759a330a101d9"
|
||||||
integrity sha512-A61OT9yc74dEFi1elODt/tzQNSwV3ZGZCY5cRl6NYO9t/0AVdaD+fyt81yh3mRxyI8HeVOecvXl3cPX5knz9rQ==
|
integrity sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
|
|
||||||
"@atproto/jwk-jose@0.1.6":
|
"@atproto/jwk-jose@0.1.6":
|
||||||
@@ -276,56 +276,56 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/lexicon@^0.4.10":
|
"@atproto/lexicon@^0.4.11":
|
||||||
version "0.4.10"
|
version "0.4.11"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.10.tgz#276790a1bca060a55c80d556ce763eaa81f6e944"
|
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617"
|
||||||
integrity sha512-uDbP20vetBgtXPuxoyRcvOGBt2gNe1dFc9yYKcb6jWmXfseHiGTnIlORJOLBXIT2Pz15Eap4fLxAu6zFAykD5A==
|
integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
iso-datestring-validator "^2.2.2"
|
iso-datestring-validator "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/oauth-provider-api@0.1.1":
|
"@atproto/oauth-provider-api@0.1.2":
|
||||||
version "0.1.1"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.1.tgz#ccca589757cd652015db58dee645463290eddacc"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.2.tgz#cdca03af4426f8cf9b09bc9eb57a8604f9513831"
|
||||||
integrity sha512-Ry7viVoMHzzyohK0UKX/7gJgkWndCchydzAfVV1lmP+84sw7Foci+rXN/laE5EnpVB8QIUW3GmQ93jecbBiyeg==
|
integrity sha512-tNAuMrE6D3696euavxo1+Jh7Re0PPwJstbyY8SrdVPXgKJh/LrbpKUKiPNW/p5KyVfRs2tWeAxy+ReESu6SmXA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/jwk" "0.1.5"
|
"@atproto/jwk" "0.1.5"
|
||||||
"@atproto/oauth-types" "0.2.6"
|
"@atproto/oauth-types" "0.2.7"
|
||||||
|
|
||||||
"@atproto/oauth-provider-frontend@0.1.3":
|
"@atproto/oauth-provider-frontend@0.1.4":
|
||||||
version "0.1.3"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.3.tgz#3cd724bf3dfae99f2b3b152c96963762ff188b89"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.4.tgz#240a2e58c29d32fa7d4ea9d142c00c23d2469452"
|
||||||
integrity sha512-dN/WRMOmj1Bd32i6diX/J+zZ5bZWX+NbQ0BAMjMANpii2gFrgc/pk/zmutYwUhyQeIl7rLkag+nQC4Jhg1I6BQ==
|
integrity sha512-TLKL5lTmSieHx7+3RVIx7rIxRPP1SNCwzzdTvYB46yd1XrGHdPU//M6CP5OZ1BvcxF6H4JXIkOSWvFseol+gOw==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@atproto/oauth-provider-api" "0.1.1"
|
"@atproto/oauth-provider-api" "0.1.2"
|
||||||
|
|
||||||
"@atproto/oauth-provider-ui@0.1.3":
|
"@atproto/oauth-provider-ui@0.1.4":
|
||||||
version "0.1.3"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.3.tgz#dc130c308fe3a422a498abc7284e68a722d17ebb"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.4.tgz#5e092d30afa583fdab54fc78371aecb1cbfa017d"
|
||||||
integrity sha512-Zxxm9nhGMS1ByvaA79zqalpBU2ub4+3gLPkRVQjp/F8jHOedxrocUAKM61B3KTtDdFGvfzvPZ28pxunwy/Rw4g==
|
integrity sha512-GTQnB7OUBFSeXcdRseAGYzKe9UUFB/kGjRcIA8+pO5pCMD7JdXI+WliUhsbdmQ2I+OK78aAlCrmygNWpLtpZgg==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@atproto/oauth-provider-api" "0.1.1"
|
"@atproto/oauth-provider-api" "0.1.2"
|
||||||
|
|
||||||
"@atproto/oauth-provider@^0.7.5":
|
"@atproto/oauth-provider@^0.7.6":
|
||||||
version "0.7.5"
|
version "0.7.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.5.tgz#f645800bc76b51ff000a92d67a83c4313db6316d"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.6.tgz#68bc37303611d548bae9f653d41bc89bd8890152"
|
||||||
integrity sha512-vBRxX7mUDRVbe2rKzRmC3OfyoqAs8N7OA+MdSD+6b154UFp3+blF5phz5NgtxZzNyKSSeZnXy6xSh27YT8QfRg==
|
integrity sha512-4YcnddACznmpuRmHlt9G+kccdv2Gct5qQOF9Yyjse8cl2Td+Rg1gkchpRdWUnyr9fgZzmCsSBYzEfVXge3eUiQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto-labs/fetch" "0.2.2"
|
"@atproto-labs/fetch" "0.2.2"
|
||||||
"@atproto-labs/fetch-node" "0.1.8"
|
"@atproto-labs/fetch-node" "0.1.8"
|
||||||
"@atproto-labs/pipe" "0.1.0"
|
"@atproto-labs/pipe" "0.1.0"
|
||||||
"@atproto-labs/simple-store" "0.2.0"
|
"@atproto-labs/simple-store" "0.2.0"
|
||||||
"@atproto-labs/simple-store-memory" "0.1.3"
|
"@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" "0.1.5"
|
||||||
"@atproto/jwk-jose" "0.1.6"
|
"@atproto/jwk-jose" "0.1.6"
|
||||||
"@atproto/oauth-provider-api" "0.1.1"
|
"@atproto/oauth-provider-api" "0.1.2"
|
||||||
"@atproto/oauth-provider-frontend" "0.1.3"
|
"@atproto/oauth-provider-frontend" "0.1.4"
|
||||||
"@atproto/oauth-provider-ui" "0.1.3"
|
"@atproto/oauth-provider-ui" "0.1.4"
|
||||||
"@atproto/oauth-types" "0.2.6"
|
"@atproto/oauth-types" "0.2.7"
|
||||||
"@atproto/syntax" "0.4.0"
|
"@atproto/syntax" "0.4.0"
|
||||||
"@hapi/accept" "^6.0.3"
|
"@hapi/accept" "^6.0.3"
|
||||||
"@hapi/address" "^5.1.1"
|
"@hapi/address" "^5.1.1"
|
||||||
@@ -340,27 +340,27 @@
|
|||||||
psl "^1.9.0"
|
psl "^1.9.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/oauth-types@0.2.6":
|
"@atproto/oauth-types@0.2.7":
|
||||||
version "0.2.6"
|
version "0.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.6.tgz#3cea27b72a6ee274864bd5c791b0c7f369954b03"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.7.tgz#c210868052f8babd98510c19816e3d9a156b33c7"
|
||||||
integrity sha512-6rUmV7T1YKCgVYLLjm+FGv+dYC8S0+0AHji/azVGDEhTsiadSrlC0H9Pgxix1y89zI1FIf0piBqecBcPewdrJg==
|
integrity sha512-2SlDveiSI0oowC+sfuNd/npV8jw/FhokSS26qyUyldTg1g9ZlhxXUfMP4IZOPeZcVn9EszzQRHs1H9ZJqVQIew==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/jwk" "0.1.5"
|
"@atproto/jwk" "0.1.5"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/ozone@^0.1.108":
|
"@atproto/ozone@^0.1.109":
|
||||||
version "0.1.108"
|
version "0.1.109"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.108.tgz#cac673301cebd159a13a8a72281bc917806f3694"
|
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.109.tgz#538de28cb21c10afa3fbce0140cd695ef7948e09"
|
||||||
integrity sha512-AuMH/PYmtro8cVnseESRooAerz2EIPwnS4HZgBsSebwBf4HLEGE93yeX26jLAfr57XYgVyYChkXr71LX4t9Erg==
|
integrity sha512-KokZtu5mhYJdNmYqkI2JZ2hiehxXpi8bbULyWE3f0RKbQRBUBGDVBSF8WkuJUuLzaquyYJVtg3MZFp9ELBcg0g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/api" "^0.15.5"
|
"@atproto/api" "^0.15.6"
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/identity" "^0.4.7"
|
"@atproto/identity" "^0.4.8"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc" "^0.6.12"
|
"@atproto/xrpc" "^0.7.0"
|
||||||
"@atproto/xrpc-server" "^0.7.17"
|
"@atproto/xrpc-server" "^0.7.18"
|
||||||
"@did-plc/lib" "^0.0.1"
|
"@did-plc/lib" "^0.0.1"
|
||||||
compression "^1.7.4"
|
compression "^1.7.4"
|
||||||
cors "^2.8.5"
|
cors "^2.8.5"
|
||||||
@@ -378,24 +378,24 @@
|
|||||||
undici "^6.14.1"
|
undici "^6.14.1"
|
||||||
ws "^8.12.0"
|
ws "^8.12.0"
|
||||||
|
|
||||||
"@atproto/pds@^0.4.134":
|
"@atproto/pds@^0.4.136":
|
||||||
version "0.4.134"
|
version "0.4.136"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.134.tgz#0b9065f4ab6493cce4df7faf1167d2689b12c8b3"
|
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.136.tgz#53989ff7784c4d1e68d745d69721e71ba82a111d"
|
||||||
integrity sha512-dS/OOspAv7L9kWqXVizCF64Af87DWF8bzu1QrclqaI29MTTnaaK/PPKd/kAELhBPBi5CVPirvySSqLj0W4Q93A==
|
integrity sha512-sao4iq/CRWwdM0gljw7XGg/ef4OTWFc6RU2g0nNgJLvxfPO3uMG8Ze1S6tfhr9wvhIKZWVCzzPruTglrlWMEYw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto-labs/fetch-node" "0.1.8"
|
"@atproto-labs/fetch-node" "0.1.8"
|
||||||
"@atproto-labs/xrpc-utils" "0.0.13"
|
"@atproto-labs/xrpc-utils" "0.0.14"
|
||||||
"@atproto/api" "^0.15.5"
|
"@atproto/api" "^0.15.6"
|
||||||
"@atproto/aws" "^0.2.20"
|
"@atproto/aws" "^0.2.21"
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/identity" "^0.4.7"
|
"@atproto/identity" "^0.4.8"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/oauth-provider" "^0.7.5"
|
"@atproto/oauth-provider" "^0.7.6"
|
||||||
"@atproto/repo" "^0.8.0"
|
"@atproto/repo" "^0.8.1"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc" "^0.6.12"
|
"@atproto/xrpc" "^0.7.0"
|
||||||
"@atproto/xrpc-server" "^0.7.17"
|
"@atproto/xrpc-server" "^0.7.18"
|
||||||
"@did-plc/lib" "^0.0.4"
|
"@did-plc/lib" "^0.0.4"
|
||||||
"@hapi/address" "^5.1.1"
|
"@hapi/address" "^5.1.1"
|
||||||
better-sqlite3 "^10.0.0"
|
better-sqlite3 "^10.0.0"
|
||||||
@@ -425,32 +425,32 @@
|
|||||||
undici "^6.19.8"
|
undici "^6.19.8"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/repo@^0.8.0":
|
"@atproto/repo@^0.8.1":
|
||||||
version "0.8.0"
|
version "0.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.0.tgz#14261421e2c5fe95b6a8af0b1296fca50b216618"
|
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.1.tgz#be8c6b93c000944b81aaa1026d6c50d82c025d74"
|
||||||
integrity sha512-Er4Mpd8XWPwVLcUlKFxUpnyBC+J+oBxARoUGXMTLjdQyg0FmWtZzeYnse8FV/L36DeWV0+v/tqYYggJcOOe1HA==
|
integrity sha512-d1NtHhXYJVJlFVI6mbVOUnpB0rnhqxPnZcALkJoYJjaDPVr4NNqRFAtrwb+GHzxT6DhijoXYQf24pKGfEFDd4g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/common-web" "^0.4.1"
|
"@atproto/common-web" "^0.4.2"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@ipld/dag-cbor" "^7.0.0"
|
"@ipld/dag-cbor" "^7.0.0"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
varint "^6.0.0"
|
varint "^6.0.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/sync@^0.1.22":
|
"@atproto/sync@^0.1.23":
|
||||||
version "0.1.22"
|
version "0.1.23"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.22.tgz#3ba82a3c5f76cee372adc00ba162b65eaf7dadf9"
|
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.23.tgz#01d4ecf9d5ddc624d14e8fb98927f0b2a97eafeb"
|
||||||
integrity sha512-FdBHDkNRqWmfUUG52EhkeHH13LdaZ6V5z8978fGMFxXPsTXDE6RSr/vNRt4FUDS5j7+9csKgNV3AyIFUmSYmOg==
|
integrity sha512-1ItRNHMLMcBeTziOZpxS4Q+ha2enQce3fSiAQaCpLCQ8VTNq1D1aRR6ePZCQFzab9jDDtBz0v4FufOnMByRIeg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/identity" "^0.4.7"
|
"@atproto/identity" "^0.4.8"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/repo" "^0.8.0"
|
"@atproto/repo" "^0.8.1"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc-server" "^0.7.17"
|
"@atproto/xrpc-server" "^0.7.18"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
p-queue "^6.6.2"
|
p-queue "^6.6.2"
|
||||||
ws "^8.12.0"
|
ws "^8.12.0"
|
||||||
@@ -460,15 +460,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
|
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
|
||||||
integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
|
integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
|
||||||
|
|
||||||
"@atproto/xrpc-server@^0.7.17":
|
"@atproto/xrpc-server@^0.7.18":
|
||||||
version "0.7.17"
|
version "0.7.18"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.17.tgz#39083754dbefd93a89a02e8f64334cb079a90c69"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz#7cb6e517da2afec1c9bee70d92c07667a80718ec"
|
||||||
integrity sha512-il32raoUc/5eqKMtlHMb+ndCx2nx0Fecjd8Fqw6KNTeS6HB6MYSZvIg3blwV/KdUehmOS6rMy6YrgtFK6GbSQQ==
|
integrity sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common" "^0.4.10"
|
"@atproto/common" "^0.4.11"
|
||||||
"@atproto/crypto" "^0.4.4"
|
"@atproto/crypto" "^0.4.4"
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
"@atproto/xrpc" "^0.6.12"
|
"@atproto/xrpc" "^0.7.0"
|
||||||
cbor-x "^1.5.1"
|
cbor-x "^1.5.1"
|
||||||
express "^4.17.2"
|
express "^4.17.2"
|
||||||
http-errors "^2.0.0"
|
http-errors "^2.0.0"
|
||||||
@@ -478,12 +478,12 @@
|
|||||||
ws "^8.12.0"
|
ws "^8.12.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/xrpc@^0.6.12":
|
"@atproto/xrpc@^0.7.0":
|
||||||
version "0.6.12"
|
version "0.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0"
|
||||||
integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==
|
integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/lexicon" "^0.4.10"
|
"@atproto/lexicon" "^0.4.11"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@aws-crypto/crc32@3.0.0":
|
"@aws-crypto/crc32@3.0.0":
|
||||||
|
|||||||
Reference in New Issue
Block a user