Live (#8354)
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
@@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({
|
||||
setLimitLines(false)
|
||||
}, [setLimitLines])
|
||||
|
||||
const {isActive: live} = useActorStatus(post.author)
|
||||
|
||||
if (!record) {
|
||||
return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
|
||||
}
|
||||
@@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
@@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
/>
|
||||
|
||||
{showChildReplyLine && (
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -174,14 +173,12 @@ function PostInner({
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<AviFollowButton author={post.author} moderation={moderation}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</AviFollowButton>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<PostMeta
|
||||
|
||||
@@ -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 {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
@@ -19,6 +19,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isStatusStillActive} from '#/lib/actor-status'
|
||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||
import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||
@@ -775,6 +777,31 @@ let PostFeed = ({
|
||||
)
|
||||
}, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset])
|
||||
|
||||
const seenActorWithStatusRef = useRef<Set<string>>(new Set())
|
||||
const onItemSeen = useCallback(
|
||||
(item: FeedRow) => {
|
||||
feedFeedback.onItemSeen(item)
|
||||
if (item.type === 'sliceItem') {
|
||||
const actor = item.slice.items[item.indexInSlice].post.author
|
||||
if (
|
||||
actor.status &&
|
||||
temp__canBeLive(actor) &&
|
||||
temp__isStatusValid(actor.status) &&
|
||||
isStatusStillActive(actor.status.expiresAt)
|
||||
) {
|
||||
if (!seenActorWithStatusRef.current.has(actor.did)) {
|
||||
seenActorWithStatusRef.current.add(actor.did)
|
||||
logger.metric('live:view:post', {
|
||||
subject: actor.did,
|
||||
feed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[feedFeedback, feed],
|
||||
)
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<List
|
||||
@@ -797,7 +824,6 @@ let PostFeed = ({
|
||||
onEndReachedThreshold={2} // number of posts left to trigger load more
|
||||
removeClippedSubviews={true}
|
||||
extraData={extraData}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight={
|
||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||
}
|
||||
@@ -805,7 +831,7 @@ let PostFeed = ({
|
||||
windowSize={9}
|
||||
maxToRenderPerBatch={isIOS ? 5 : 1}
|
||||
updateCellsBatchingPeriod={40}
|
||||
onItemSeen={feedFeedback.onItemSeen}
|
||||
onItemSeen={onItemSeen}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
@@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText'
|
||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||
import {AviFollowButton} from './AviFollowButton'
|
||||
|
||||
interface FeedItemProps {
|
||||
record: AppBskyFeedPost.Record
|
||||
@@ -251,6 +251,8 @@ let FeedItemInner = ({
|
||||
? rootPost.threadgate.record
|
||||
: undefined
|
||||
|
||||
const {isActive: live} = useActorStatus(post.author)
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
@@ -381,15 +383,14 @@ let FeedItemInner = ({
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<AviFollowButton author={post.author} moderation={moderation}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
/>
|
||||
</AviFollowButton>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
onBeforePress={onOpenAuthor}
|
||||
live={live}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
style={[
|
||||
@@ -397,7 +398,7 @@ let FeedItemInner = ({
|
||||
{
|
||||
flexGrow: 1,
|
||||
backgroundColor: pal.colors.replyLine,
|
||||
marginTop: 4,
|
||||
marginTop: live ? 8 : 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
@@ -23,12 +24,14 @@ import {useSession} from '#/state/session'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
||||
import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
|
||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
|
||||
@@ -38,6 +41,9 @@ import {
|
||||
} from '#/components/icons/Person'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
|
||||
import {GoLiveDialog} from '#/components/live/GoLiveDialog'
|
||||
import {temp__canGoLive} from '#/components/live/temp'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {
|
||||
ReportDialog,
|
||||
@@ -77,6 +83,7 @@ let ProfileMenu = ({
|
||||
|
||||
const blockPromptControl = Prompt.usePromptControl()
|
||||
const loggedOutWarningPromptControl = Prompt.usePromptControl()
|
||||
const goLiveDialogControl = useDialogControl()
|
||||
|
||||
const showLoggedOutWarning = React.useMemo(() => {
|
||||
return (
|
||||
@@ -201,6 +208,8 @@ let ProfileMenu = ({
|
||||
return v.issuer === currentAccount?.did
|
||||
}) ?? []
|
||||
|
||||
const status = useActorStatus(profile)
|
||||
|
||||
return (
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
@@ -290,6 +299,25 @@ let ProfileMenu = ({
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={List} />
|
||||
</Menu.Item>
|
||||
{isSelf && temp__canGoLive(profile) && (
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownListAddRemoveBtn"
|
||||
label={
|
||||
status.isActive
|
||||
? _(msg`Edit live status`)
|
||||
: _(msg`Go live`)
|
||||
}
|
||||
onPress={goLiveDialogControl.open}>
|
||||
<Menu.ItemText>
|
||||
{status.isActive ? (
|
||||
<Trans>Edit live status</Trans>
|
||||
) : (
|
||||
<Trans>Go live</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={LiveIcon} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
{verification.viewer.role === 'verifier' &&
|
||||
!verification.profile.isViewer &&
|
||||
(verification.viewer.hasIssuedVerification ? (
|
||||
@@ -456,6 +484,16 @@ let ProfileMenu = ({
|
||||
profile={profile}
|
||||
verifications={currentAccountVerifications}
|
||||
/>
|
||||
|
||||
{status.isActive ? (
|
||||
<EditLiveDialog
|
||||
control={goLiveDialogControl}
|
||||
status={status}
|
||||
embed={status.embed}
|
||||
/>
|
||||
) : (
|
||||
<GoLiveDialog control={goLiveDialogControl} profile={profile} />
|
||||
)}
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import type React from 'react'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
|
||||
@@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
|
||||
const timestampLabel = niceDate(i18n, opts.timestamp)
|
||||
const verification = useSimpleVerificationState({profile: author})
|
||||
const {isActive: live} = useActorStatus(author)
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
profile={author}
|
||||
moderation={opts.moderation?.ui('avatar')}
|
||||
type={author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {isTouchDevice} from '#/lib/browser'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
@@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
@@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
import {
|
||||
@@ -42,6 +48,8 @@ import {
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {Link} from '#/components/Link'
|
||||
import {LiveIndicator} from '#/components/live/LiveIndicator'
|
||||
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
@@ -54,6 +62,8 @@ interface BaseUserAvatarProps {
|
||||
shape?: 'circle' | 'square'
|
||||
size: number
|
||||
avatar?: string | null
|
||||
live?: boolean
|
||||
hideLiveBadge?: boolean
|
||||
}
|
||||
|
||||
interface UserAvatarProps extends BaseUserAvatarProps {
|
||||
@@ -196,27 +206,38 @@ let UserAvatar = ({
|
||||
usePlainRNImage = false,
|
||||
onLoad,
|
||||
style,
|
||||
live,
|
||||
hideLiveBadge,
|
||||
}: UserAvatarProps): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const backgroundColor = t.palette.contrast_25
|
||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
let borderRadius
|
||||
if (finalShape === 'square') {
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size > 32 ? 8 : 3,
|
||||
backgroundColor,
|
||||
}
|
||||
borderRadius = size > 32 ? 8 : 3
|
||||
} else {
|
||||
borderRadius = Math.floor(size / 2)
|
||||
}
|
||||
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.floor(size / 2),
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
}
|
||||
}, [finalShape, size, backgroundColor])
|
||||
}, [finalShape, size, t])
|
||||
|
||||
const borderStyle = useMemo(() => {
|
||||
return [
|
||||
{borderRadius: aviStyle.borderRadius},
|
||||
live && {
|
||||
borderColor: t.palette.negative_500,
|
||||
borderWidth: size > 16 ? 2 : 1,
|
||||
opacity: 1,
|
||||
},
|
||||
]
|
||||
}, [aviStyle.borderRadius, live, t, size])
|
||||
|
||||
const alert = useMemo(() => {
|
||||
if (!moderation?.alert) {
|
||||
@@ -277,12 +298,19 @@ let UserAvatar = ({
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
|
||||
<MediaInsetBorder style={borderStyle} />
|
||||
{live && size > 16 && !hideLiveBadge && (
|
||||
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
|
||||
)}
|
||||
{alert}
|
||||
</View>
|
||||
) : (
|
||||
<View style={containerStyle}>
|
||||
<DefaultAvatar type={type} shape={finalShape} size={size} />
|
||||
<MediaInsetBorder style={borderStyle} />
|
||||
{live && size > 16 && !hideLiveBadge && (
|
||||
<LiveIndicator size={size > 32 ? 'small' : 'tiny'} />
|
||||
)}
|
||||
{alert}
|
||||
</View>
|
||||
)
|
||||
@@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({
|
||||
disableHoverCard,
|
||||
disableNavigation,
|
||||
onBeforePress,
|
||||
live,
|
||||
...rest
|
||||
}: PreviewableUserAvatarProps): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const status = useActorStatus(profile)
|
||||
const liveControl = useDialogControl()
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
onBeforePress?.()
|
||||
unstableCacheProfileView(queryClient, profile)
|
||||
}, [profile, queryClient, onBeforePress])
|
||||
|
||||
const onOpenLiveStatus = useCallback(() => {
|
||||
playHaptic('Light')
|
||||
logger.metric('live:card:open', {subject: profile.did, from: 'post'})
|
||||
liveControl.open()
|
||||
}, [liveControl, playHaptic, profile.did])
|
||||
|
||||
const avatarEl = (
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={status.isActive || live}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
@@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({
|
||||
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
||||
{disableNavigation ? (
|
||||
avatarEl
|
||||
) : status.isActive && (isNative || isTouchDevice) ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(
|
||||
msg`${sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)}'s avatar`,
|
||||
)}
|
||||
accessibilityHint={_(msg`Opens live status dialog`)}
|
||||
onPress={onOpenLiveStatus}>
|
||||
{avatarEl}
|
||||
</Button>
|
||||
<LiveStatusDialog
|
||||
control={liveControl}
|
||||
profile={profile}
|
||||
status={status}
|
||||
embed={status.embed}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
|
||||
label={_(
|
||||
msg`${sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
)}'s avatar`,
|
||||
)}
|
||||
accessibilityHint={_(msg`Opens this profile`)}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
|
||||
@@ -4,10 +4,10 @@ import {View} from 'react-native'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonColor,
|
||||
type ButtonColor,
|
||||
ButtonIcon,
|
||||
ButtonText,
|
||||
ButtonVariant,
|
||||
type ButtonVariant,
|
||||
} from '#/components/Button'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
@@ -19,31 +19,35 @@ export function Buttons() {
|
||||
<H1>Buttons</H1>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}>
|
||||
{['primary', 'secondary', 'secondary_inverted', 'negative'].map(
|
||||
color => (
|
||||
<View key={color} style={[a.gap_md, a.align_start]}>
|
||||
{['solid', 'outline', 'ghost'].map(variant => (
|
||||
<React.Fragment key={variant}>
|
||||
<Button
|
||||
variant={variant as ButtonVariant}
|
||||
color={color as ButtonColor}
|
||||
size="large"
|
||||
label="Click here">
|
||||
<ButtonText>Button</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
variant={variant as ButtonVariant}
|
||||
color={color as ButtonColor}
|
||||
size="large"
|
||||
label="Click here">
|
||||
<ButtonText>Button</ButtonText>
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
),
|
||||
)}
|
||||
{[
|
||||
'primary',
|
||||
'secondary',
|
||||
'secondary_inverted',
|
||||
'negative',
|
||||
'negative_secondary',
|
||||
].map(color => (
|
||||
<View key={color} style={[a.gap_md, a.align_start]}>
|
||||
{['solid', 'outline', 'ghost'].map(variant => (
|
||||
<React.Fragment key={variant}>
|
||||
<Button
|
||||
variant={variant as ButtonVariant}
|
||||
color={color as ButtonColor}
|
||||
size="large"
|
||||
label="Click here">
|
||||
<ButtonText>Button</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
variant={variant as ButtonVariant}
|
||||
color={color as ButtonColor}
|
||||
size="large"
|
||||
label="Click here">
|
||||
<ButtonText>Button</ButtonText>
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={[a.flex_row, a.gap_md, a.align_start]}>
|
||||
<View style={[a.gap_md, a.align_start]}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {useSetThemePrefs} from '#/state/shell'
|
||||
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
||||
import {atoms as a, ThemeProvider} from '#/alf'
|
||||
@@ -115,7 +115,6 @@ function StorybookInner() {
|
||||
<Typography />
|
||||
<Spacing />
|
||||
<Shadows />
|
||||
<Buttons />
|
||||
<Icons />
|
||||
<Links />
|
||||
<Dialogs />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
||||
import {type PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
@@ -67,6 +68,7 @@ let DrawerProfileCard = ({
|
||||
const t = useTheme()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -81,6 +83,7 @@ let DrawerProfileCard = ({
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
/>
|
||||
<View style={[a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {BOTTOM_BAR_AVI} from '#/lib/demo'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
@@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
}, [accountSwitchControl, playHaptic])
|
||||
|
||||
const [demoMode] = useDemoMode()
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
styles.onProfile,
|
||||
{borderColor: pal.text.color},
|
||||
{
|
||||
borderColor: pal.text.color,
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 3}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[styles.ctrlIcon, pal.text, styles.profileIcon]}>
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
{
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 3}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useNavigationState,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
@@ -100,6 +101,8 @@ function ProfileCard() {
|
||||
profile: profiles?.find(p => p.did === account.did),
|
||||
}))
|
||||
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
|
||||
return (
|
||||
<View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}>
|
||||
{!isLoading && profile ? (
|
||||
@@ -142,6 +145,7 @@ function ProfileCard() {
|
||||
avatar={profile.avatar}
|
||||
size={size}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
/>
|
||||
</View>
|
||||
{!leftNavMinimal && (
|
||||
@@ -226,7 +230,6 @@ function SwitchMenuItems({
|
||||
signOutPromptControl: DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeEverything = useCloseAllActiveElements()
|
||||
|
||||
@@ -243,35 +246,11 @@ function SwitchMenuItems({
|
||||
<Trans>Switch account</Trans>
|
||||
</Menu.LabelText>
|
||||
{accounts.map(other => (
|
||||
<Menu.Item
|
||||
disabled={!!pendingDid}
|
||||
style={[{minWidth: 150}]}
|
||||
<SwitchMenuItem
|
||||
key={other.account.did}
|
||||
label={_(
|
||||
msg`Switch to ${sanitizeHandle(
|
||||
other.profile?.handle ?? other.account.handle,
|
||||
'@',
|
||||
)}`,
|
||||
)}
|
||||
onPress={() =>
|
||||
onPressSwitchAccount(other.account, 'SwitchAccount')
|
||||
}>
|
||||
<View style={[{marginLeft: tokens.space._2xs * -1}]}>
|
||||
<UserAvatar
|
||||
avatar={other.profile?.avatar}
|
||||
size={20}
|
||||
type={
|
||||
other.profile?.associated?.labeler ? 'labeler' : 'user'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Menu.ItemText>
|
||||
{sanitizeHandle(
|
||||
other.profile?.handle ?? other.account.handle,
|
||||
'@',
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
account={other.account}
|
||||
profile={other.profile}
|
||||
/>
|
||||
))}
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
@@ -295,6 +274,45 @@ function SwitchMenuItems({
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchMenuItem({
|
||||
account,
|
||||
profile,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed | undefined
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
disabled={!!pendingDid}
|
||||
style={[a.gap_sm, {minWidth: 150}]}
|
||||
key={account.did}
|
||||
label={_(
|
||||
msg`Switch to ${sanitizeHandle(
|
||||
profile?.handle ?? account.handle,
|
||||
'@',
|
||||
)}`,
|
||||
)}
|
||||
onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}>
|
||||
<View>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={20}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
<Menu.ItemText>
|
||||
{sanitizeHandle(profile?.handle ?? account.handle, '@')}
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
count?: string
|
||||
hasNew?: boolean
|
||||
|
||||
Reference in New Issue
Block a user