diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-05-10 00:06:06 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-05-10 00:06:06 +0300 |
commit | a0bd8042621e108f47e09dd096cf0d73fe1cee53 (patch) | |
tree | 0cc120c864ae8fea7f513ff242a1097ece0f1b8b /src/view | |
parent | 2e80fa3dac4d869640f5bce8ad43eb401c8e3141 (diff) | |
download | voidsky-a0bd8042621e108f47e09dd096cf0d73fe1cee53.tar.zst |
Live (#8354)
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 15 | ||||
-rw-r--r-- | src/view/com/posts/AviFollowButton.tsx | 143 | ||||
-rw-r--r-- | src/view/com/posts/AviFollowButton.web.tsx | 5 | ||||
-rw-r--r-- | src/view/com/posts/PostFeed.tsx | 32 | ||||
-rw-r--r-- | src/view/com/posts/PostFeedItem.tsx | 23 | ||||
-rw-r--r-- | src/view/com/profile/ProfileMenu.tsx | 38 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 88 | ||||
-rw-r--r-- | src/view/screens/Storybook/Buttons.tsx | 58 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 3 | ||||
-rw-r--r-- | src/view/shell/Drawer.tsx | 3 | ||||
-rw-r--r-- | src/view/shell/bottom-bar/BottomBar.tsx | 24 | ||||
-rw-r--r-- | src/view/shell/desktop/LeftNav.tsx | 76 |
14 files changed, 271 insertions, 246 deletions
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 10c3e6b4d..3925ce9bd 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -16,6 +16,7 @@ import { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useActorStatus} from '#/lib/actor-status' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' @@ -287,6 +288,8 @@ let PostThreadItemLoaded = ({ setLimitLines(false) }, [setLimitLines]) + const {isActive: live} = useActorStatus(post.author) + if (!record) { return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> } @@ -330,6 +333,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View style={[a.flex_1]}> <View style={[a.flex_row, a.align_center]}> @@ -575,6 +579,7 @@ let PostThreadItemLoaded = ({ profile={post.author} moderation={moderation.ui('avatar')} type={post.author.associated?.labeler ? 'labeler' : 'user'} + live={live} /> {showChildReplyLine && ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index c6cf254f3..03463f977 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,7 +27,6 @@ import { import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' -import {AviFollowButton} from '#/view/com/posts/AviFollowButton' import {atoms as a} from '#/alf' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' @@ -174,14 +173,12 @@ function PostInner({ {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={styles.layoutContent}> <PostMeta diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx deleted file mode 100644 index 1c894bffe..000000000 --- a/src/view/com/posts/AviFollowButton.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' - -import {NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useSession} from '#/state/session' -import { - DropdownItem, - NativeDropdown, -} from '#/view/com/util/forms/NativeDropdown' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, select, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useFollowMethods} from '#/components/hooks/useFollowMethods' -import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' - -export function AviFollowButton({ - author, - moderation, - children, -}: { - author: AppBskyActorDefs.ProfileViewBasic - moderation: ModerationDecision - children: React.ReactNode -}) { - const {_} = useLingui() - const t = useTheme() - const profile = useProfileShadow(author) - const {follow} = useFollowMethods({ - profile: profile, - logContext: 'AvatarButton', - }) - const {currentAccount, hasSession} = useSession() - const navigation = useNavigation<NavigationProp>() - - const name = sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - const isFollowing = - profile.viewer?.following || profile.did === currentAccount?.did - - function onPress() { - follow() - Toast.show(_(msg`Following ${name}`)) - } - - const items: DropdownItem[] = [ - { - label: _(msg`View profile`), - onPress: () => { - navigation.navigate('Profile', {name: profile.did}) - }, - icon: { - ios: { - name: 'arrow.up.right.square', - }, - android: '', - web: ['far', 'arrow-up-right-from-square'], - }, - }, - { - label: _(msg`Follow ${name}`), - onPress: onPress, - icon: { - ios: { - name: 'person.badge.plus', - }, - android: '', - web: ['far', 'user-plus'], - }, - }, - ] - - return hasSession ? ( - <View style={a.relative}> - {children} - - {!isFollowing && ( - <Button - label={_(msg`Open ${name} profile shortcut menu`)} - style={[ - a.rounded_full, - a.absolute, - { - bottom: -7, - right: -7, - }, - ]}> - <NativeDropdown items={items}> - <View - style={[ - { - // An asymmetric hit slop - // to prioritize bottom right taps. - paddingTop: 2, - paddingLeft: 2, - paddingBottom: 6, - paddingRight: 6, - }, - a.align_center, - a.justify_center, - a.rounded_full, - ]}> - <View - style={[ - a.rounded_full, - a.align_center, - select(t.name, { - light: t.atoms.bg_contrast_100, - dim: t.atoms.bg_contrast_100, - dark: t.atoms.bg_contrast_200, - }), - { - borderWidth: 1, - borderColor: t.atoms.bg.backgroundColor, - }, - ]}> - <Plus - size="sm" - fill={ - select(t.name, { - light: t.atoms.bg_contrast_600, - dim: t.atoms.bg_contrast_500, - dark: t.atoms.bg_contrast_600, - }).backgroundColor - } - /> - </View> - </View> - </NativeDropdown> - </Button> - )} - </View> - ) : ( - children - ) -} diff --git a/src/view/com/posts/AviFollowButton.web.tsx b/src/view/com/posts/AviFollowButton.web.tsx deleted file mode 100644 index 90b2ddeec..000000000 --- a/src/view/com/posts/AviFollowButton.web.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react' - -export function AviFollowButton({children}: {children: React.ReactNode}) { - return children -} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 181b35026..b4c2b2710 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -1,4 +1,4 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useCallback, useRef} from 'react' import { ActivityIndicator, AppState, @@ -19,6 +19,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {isStatusStillActive} from '#/lib/actor-status' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {logEvent} from '#/lib/statsig/statsig' @@ -52,6 +53,7 @@ import { } from '#/components/feeds/PostFeedVideoGridRow' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' +import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedShutdownMsg} from './FeedShutdownMsg' import {PostFeedErrorMessage} from './PostFeedErrorMessage' @@ -775,6 +777,31 @@ let PostFeed = ({ ) }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) + const seenActorWithStatusRef = useRef<Set<string>>(new Set()) + const onItemSeen = useCallback( + (item: FeedRow) => { + feedFeedback.onItemSeen(item) + if (item.type === 'sliceItem') { + const actor = item.slice.items[item.indexInSlice].post.author + if ( + actor.status && + temp__canBeLive(actor) && + temp__isStatusValid(actor.status) && + isStatusStillActive(actor.status.expiresAt) + ) { + if (!seenActorWithStatusRef.current.has(actor.did)) { + seenActorWithStatusRef.current.add(actor.did) + logger.metric('live:view:post', { + subject: actor.did, + feed, + }) + } + } + } + }, + [feedFeedback, feed], + ) + return ( <View testID={testID} style={style}> <List @@ -797,7 +824,6 @@ let PostFeed = ({ onEndReachedThreshold={2} // number of posts left to trigger load more removeClippedSubviews={true} extraData={extraData} - // @ts-ignore our .web version only -prf desktopFixedHeight={ desktopFixedHeightOffset ? desktopFixedHeightOffset : true } @@ -805,7 +831,7 @@ let PostFeed = ({ windowSize={9} maxToRenderPerBatch={isIOS ? 5 : 1} updateCellsBatchingPeriod={40} - onItemSeen={feedFeedback.onItemSeen} + onItemSeen={onItemSeen} /> </View> ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 123a8b0c2..ceb653b9c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -17,6 +17,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' @@ -53,7 +54,6 @@ import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' -import {AviFollowButton} from './AviFollowButton' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -251,6 +251,8 @@ let FeedItemInner = ({ ? rootPost.threadgate.record : undefined + const {isActive: live} = useActorStatus(post.author) + return ( <Link testID={`feedItem-by-${post.author.handle}`} @@ -381,15 +383,14 @@ let FeedItemInner = ({ <View style={styles.layout}> <View style={styles.layoutAvi}> - <AviFollowButton author={post.author} moderation={moderation}> - <PreviewableUserAvatar - size={42} - profile={post.author} - moderation={moderation.ui('avatar')} - type={post.author.associated?.labeler ? 'labeler' : 'user'} - onBeforePress={onOpenAuthor} - /> - </AviFollowButton> + <PreviewableUserAvatar + size={42} + profile={post.author} + moderation={moderation.ui('avatar')} + type={post.author.associated?.labeler ? 'labeler' : 'user'} + onBeforePress={onOpenAuthor} + live={live} + /> {isThreadParent && ( <View style={[ @@ -397,7 +398,7 @@ let FeedItemInner = ({ { flexGrow: 1, backgroundColor: pal.colors.replyLine, - marginTop: 4, + marginTop: live ? 8 : 4, }, ]} /> diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 43ec44834..1c2a7d62d 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' @@ -23,12 +24,14 @@ import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' +import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' @@ -38,6 +41,9 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {EditLiveDialog} from '#/components/live/EditLiveDialog' +import {GoLiveDialog} from '#/components/live/GoLiveDialog' +import {temp__canGoLive} from '#/components/live/temp' import * as Menu from '#/components/Menu' import { ReportDialog, @@ -77,6 +83,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() + const goLiveDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -201,6 +208,8 @@ let ProfileMenu = ({ return v.issuer === currentAccount?.did }) ?? [] + const status = useActorStatus(profile) + return ( <EventStopper onKeyDown={false}> <Menu.Root> @@ -290,6 +299,25 @@ let ProfileMenu = ({ </Menu.ItemText> <Menu.ItemIcon icon={List} /> </Menu.Item> + {isSelf && temp__canGoLive(profile) && ( + <Menu.Item + testID="profileHeaderDropdownListAddRemoveBtn" + label={ + status.isActive + ? _(msg`Edit live status`) + : _(msg`Go live`) + } + onPress={goLiveDialogControl.open}> + <Menu.ItemText> + {status.isActive ? ( + <Trans>Edit live status</Trans> + ) : ( + <Trans>Go live</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={LiveIcon} /> + </Menu.Item> + )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( @@ -456,6 +484,16 @@ let ProfileMenu = ({ profile={profile} verifications={currentAccountVerifications} /> + + {status.isActive ? ( + <EditLiveDialog + control={goLiveDialogControl} + status={status} + embed={status.embed} + /> + ) : ( + <GoLiveDialog control={goLiveDialogControl} profile={profile} /> + )} </EventStopper> ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index fd8e3a38b..62ba32c9b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import type React from 'react' +import {useActorStatus} from '#/lib/actor-status' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' @@ -55,6 +56,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const timestampLabel = niceDate(i18n, opts.timestamp) const verification = useSimpleVerificationState({profile: author}) + const {isActive: live} = useActorStatus(author) return ( <View @@ -74,6 +76,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { profile={author} moderation={opts.moderation?.ui('avatar')} type={author.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 326a2fff8..b3bf144f7 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -14,6 +14,9 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {useActorStatus} from '#/lib/actor-status' +import {isTouchDevice} from '#/lib/browser' +import {useHaptics} from '#/lib/haptics' import { useCameraPermission, usePhotoLibraryPermission, @@ -22,6 +25,8 @@ import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' import { @@ -33,6 +38,7 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { @@ -42,6 +48,8 @@ import { import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' +import {LiveIndicator} from '#/components/live/LiveIndicator' +import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -54,6 +62,8 @@ interface BaseUserAvatarProps { shape?: 'circle' | 'square' size: number avatar?: string | null + live?: boolean + hideLiveBadge?: boolean } interface UserAvatarProps extends BaseUserAvatarProps { @@ -196,27 +206,38 @@ let UserAvatar = ({ usePlainRNImage = false, onLoad, style, + live, + hideLiveBadge, }: UserAvatarProps): React.ReactNode => { const t = useTheme() - const backgroundColor = t.palette.contrast_25 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { + let borderRadius if (finalShape === 'square') { - return { - width: size, - height: size, - borderRadius: size > 32 ? 8 : 3, - backgroundColor, - } + borderRadius = size > 32 ? 8 : 3 + } else { + borderRadius = Math.floor(size / 2) } + return { width: size, height: size, - borderRadius: Math.floor(size / 2), - backgroundColor, + borderRadius, + backgroundColor: t.palette.contrast_25, } - }, [finalShape, size, backgroundColor]) + }, [finalShape, size, t]) + + const borderStyle = useMemo(() => { + return [ + {borderRadius: aviStyle.borderRadius}, + live && { + borderColor: t.palette.negative_500, + borderWidth: size > 16 ? 2 : 1, + opacity: 1, + }, + ] + }, [aviStyle.borderRadius, live, t, size]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -277,12 +298,19 @@ let UserAvatar = ({ onLoad={onLoad} /> )} - <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) : ( <View style={containerStyle}> <DefaultAvatar type={type} shape={finalShape} size={size} /> + <MediaInsetBorder style={borderStyle} /> + {live && size > 16 && !hideLiveBadge && ( + <LiveIndicator size={size > 32 ? 'small' : 'tiny'} /> + )} {alert} </View> ) @@ -486,21 +514,32 @@ let PreviewableUserAvatar = ({ disableHoverCard, disableNavigation, onBeforePress, + live, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() + const status = useActorStatus(profile) + const liveControl = useDialogControl() + const playHaptic = useHaptics() - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { onBeforePress?.() unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) + const onOpenLiveStatus = useCallback(() => { + playHaptic('Light') + logger.metric('live:card:open', {subject: profile.did, from: 'post'}) + liveControl.open() + }, [liveControl, playHaptic, profile.did]) + const avatarEl = ( <UserAvatar avatar={profile.avatar} moderation={moderation} type={profile.associated?.labeler ? 'labeler' : 'user'} + live={status.isActive || live} {...rest} /> ) @@ -509,9 +548,32 @@ let PreviewableUserAvatar = ({ <ProfileHoverCard did={profile.did} disable={disableHoverCard}> {disableNavigation ? ( avatarEl + ) : status.isActive && (isNative || isTouchDevice) ? ( + <> + <Button + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} + accessibilityHint={_(msg`Opens live status dialog`)} + onPress={onOpenLiveStatus}> + {avatarEl} + </Button> + <LiveStatusDialog + control={liveControl} + profile={profile} + status={status} + embed={status.embed} + /> + </> ) : ( <Link - label={_(msg`${profile.displayName || profile.handle}'s avatar`)} + label={_( + msg`${sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}'s avatar`, + )} accessibilityHint={_(msg`Opens this profile`)} to={makeProfileLink({ did: profile.did, diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index 66040c2e3..98c16d144 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -4,10 +4,10 @@ import {View} from 'react-native' import {atoms as a} from '#/alf' import { Button, - ButtonColor, + type ButtonColor, ButtonIcon, ButtonText, - ButtonVariant, + type ButtonVariant, } from '#/components/Button' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' @@ -19,31 +19,35 @@ export function Buttons() { <H1>Buttons</H1> <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> - {['primary', 'secondary', 'secondary_inverted', 'negative'].map( - color => ( - <View key={color} style={[a.gap_md, a.align_start]}> - {['solid', 'outline', 'ghost'].map(variant => ( - <React.Fragment key={variant}> - <Button - variant={variant as ButtonVariant} - color={color as ButtonColor} - size="large" - label="Click here"> - <ButtonText>Button</ButtonText> - </Button> - <Button - disabled - variant={variant as ButtonVariant} - color={color as ButtonColor} - size="large" - label="Click here"> - <ButtonText>Button</ButtonText> - </Button> - </React.Fragment> - ))} - </View> - ), - )} + {[ + 'primary', + 'secondary', + 'secondary_inverted', + 'negative', + 'negative_secondary', + ].map(color => ( + <View key={color} style={[a.gap_md, a.align_start]}> + {['solid', 'outline', 'ghost'].map(variant => ( + <React.Fragment key={variant}> + <Button + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + <ButtonText>Button</ButtonText> + </Button> + <Button + disabled + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + <ButtonText>Button</ButtonText> + </Button> + </React.Fragment> + ))} + </View> + ))} <View style={[a.flex_row, a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}> diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 0146bc3c6..a6c2ecdde 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View} from 'react-native' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {useSetThemePrefs} from '#/state/shell' import {ListContained} from '#/view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider} from '#/alf' @@ -115,7 +115,6 @@ function StorybookInner() { <Typography /> <Spacing /> <Shadows /> - <Buttons /> <Icons /> <Links /> <Dialogs /> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index d51db3960..c4624e8e1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -5,6 +5,7 @@ import {msg, Plural, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {type PressableScale} from '#/lib/custom-animations/PressableScale' import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' @@ -67,6 +68,7 @@ let DrawerProfileCard = ({ const t = useTheme() const {data: profile} = useProfileQuery({did: account.did}) const verification = useSimpleVerificationState({profile}) + const {isActive: live} = useActorStatus(profile) return ( <TouchableOpacity @@ -81,6 +83,7 @@ let DrawerProfileCard = ({ // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> <View style={[a.gap_2xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index df6a045dc..92be6c67e 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -7,6 +7,7 @@ import {useLingui} from '@lingui/react' import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' import {StackActions} from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {BOTTOM_BAR_AVI} from '#/lib/demo' import {useHaptics} from '#/lib/haptics' @@ -127,6 +128,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { }, [accountSwitchControl, playHaptic]) const [demoMode] = useDemoMode() + const {isActive: live} = useActorStatus(profile) return ( <> @@ -260,25 +262,39 @@ export function BottomBar({navigation}: BottomTabBarProps) { pal.text, styles.profileIcon, styles.onProfile, - {borderColor: pal.text.color}, + { + borderColor: pal.text.color, + borderWidth: live ? 0 : 1, + }, ]}> <UserAvatar avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} - size={iconWidth - 3} + size={iconWidth - 2} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> ) : ( <View - style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + { + borderWidth: live ? 0 : 1, + }, + ]}> <UserAvatar avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} - size={iconWidth - 3} + size={iconWidth - 2} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge /> </View> )} diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 7d34a3d14..f6c852ca1 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -9,6 +9,7 @@ import { useNavigationState, } from '@react-navigation/native' +import {useActorStatus} from '#/lib/actor-status' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' @@ -100,6 +101,8 @@ function ProfileCard() { profile: profiles?.find(p => p.did === account.did), })) + const {isActive: live} = useActorStatus(profile) + return ( <View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}> {!isLoading && profile ? ( @@ -142,6 +145,7 @@ function ProfileCard() { avatar={profile.avatar} size={size} type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} /> </View> {!leftNavMinimal && ( @@ -226,7 +230,6 @@ function SwitchMenuItems({ signOutPromptControl: DialogControlProps }) { const {_} = useLingui() - const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const closeEverything = useCloseAllActiveElements() @@ -243,35 +246,11 @@ function SwitchMenuItems({ <Trans>Switch account</Trans> </Menu.LabelText> {accounts.map(other => ( - <Menu.Item - disabled={!!pendingDid} - style={[{minWidth: 150}]} + <SwitchMenuItem key={other.account.did} - label={_( - msg`Switch to ${sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )}`, - )} - onPress={() => - onPressSwitchAccount(other.account, 'SwitchAccount') - }> - <View style={[{marginLeft: tokens.space._2xs * -1}]}> - <UserAvatar - avatar={other.profile?.avatar} - size={20} - type={ - other.profile?.associated?.labeler ? 'labeler' : 'user' - } - /> - </View> - <Menu.ItemText> - {sanitizeHandle( - other.profile?.handle ?? other.account.handle, - '@', - )} - </Menu.ItemText> - </Menu.Item> + account={other.account} + profile={other.profile} + /> ))} </Menu.Group> <Menu.Divider /> @@ -295,6 +274,45 @@ function SwitchMenuItems({ ) } +function SwitchMenuItem({ + account, + profile, +}: { + account: SessionAccount + profile: AppBskyActorDefs.ProfileViewDetailed | undefined +}) { + const {_} = useLingui() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() + const {isActive: live} = useActorStatus(profile) + + return ( + <Menu.Item + disabled={!!pendingDid} + style={[a.gap_sm, {minWidth: 150}]} + key={account.did} + label={_( + msg`Switch to ${sanitizeHandle( + profile?.handle ?? account.handle, + '@', + )}`, + )} + onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}> + <View> + <UserAvatar + avatar={profile?.avatar} + size={20} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + live={live} + hideLiveBadge + /> + </View> + <Menu.ItemText> + {sanitizeHandle(profile?.handle ?? account.handle, '@')} + </Menu.ItemText> + </Menu.Item> + ) +} + interface NavItemProps { count?: string hasNew?: boolean |