about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-05-10 00:06:06 +0300
committerGitHub <noreply@github.com>2025-05-10 00:06:06 +0300
commita0bd8042621e108f47e09dd096cf0d73fe1cee53 (patch)
tree0cc120c864ae8fea7f513ff242a1097ece0f1b8b /src/view
parent2e80fa3dac4d869640f5bce8ad43eb401c8e3141 (diff)
downloadvoidsky-a0bd8042621e108f47e09dd096cf0d73fe1cee53.tar.zst
Live (#8354)
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx5
-rw-r--r--src/view/com/post/Post.tsx15
-rw-r--r--src/view/com/posts/AviFollowButton.tsx143
-rw-r--r--src/view/com/posts/AviFollowButton.web.tsx5
-rw-r--r--src/view/com/posts/PostFeed.tsx32
-rw-r--r--src/view/com/posts/PostFeedItem.tsx23
-rw-r--r--src/view/com/profile/ProfileMenu.tsx38
-rw-r--r--src/view/com/util/PostMeta.tsx4
-rw-r--r--src/view/com/util/UserAvatar.tsx88
-rw-r--r--src/view/screens/Storybook/Buttons.tsx58
-rw-r--r--src/view/screens/Storybook/index.tsx3
-rw-r--r--src/view/shell/Drawer.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx24
-rw-r--r--src/view/shell/desktop/LeftNav.tsx76
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