about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/alf/atoms.ts3
-rw-r--r--src/components/AccountList.tsx4
-rw-r--r--src/components/Button.tsx86
-rw-r--r--src/components/Dialog/index.tsx16
-rw-r--r--src/components/Dialog/index.web.tsx8
-rw-r--r--src/components/ProfileCard.tsx7
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx50
-rw-r--r--src/components/ProfileHoverCard/types.ts2
-rw-r--r--src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx4
-rw-r--r--src/components/forms/DateField/index.android.tsx6
-rw-r--r--src/components/forms/DateField/index.tsx7
-rw-r--r--src/components/icons/Live.tsx5
-rw-r--r--src/components/live/EditLiveDialog.tsx348
-rw-r--r--src/components/live/GoLiveDialog.tsx352
-rw-r--r--src/components/live/LiveIndicator.tsx53
-rw-r--r--src/components/live/LiveStatusDialog.tsx212
-rw-r--r--src/components/live/queries.ts187
-rw-r--r--src/components/live/temp.ts41
-rw-r--r--src/components/live/utils.ts37
-rw-r--r--src/lib/actor-status.ts51
-rw-r--r--src/lib/api/resolve.ts14
-rw-r--r--src/lib/constants.ts1
-rw-r--r--src/lib/strings/url-helpers.ts30
-rw-r--r--src/logger/metrics.ts9
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx6
-rw-r--r--src/screens/Profile/Header/Shell.tsx100
-rw-r--r--src/screens/Settings/Settings.tsx6
-rw-r--r--src/state/cache/profile-shadow.ts7
-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
42 files changed, 1869 insertions, 300 deletions
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 68aa3cc88..02ad98c5f 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -27,6 +27,9 @@ export const atoms = {
   relative: {
     position: 'relative',
   },
+  static: {
+    position: 'static',
+  },
   sticky: web({
     position: 'sticky',
   }),
diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx
index f2e781ccf..eb770eeca 100644
--- a/src/components/AccountList.tsx
+++ b/src/components/AccountList.tsx
@@ -4,6 +4,7 @@ import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {useProfilesQuery} from '#/state/queries/profile'
@@ -110,6 +111,7 @@ function AccountItem({
   const t = useTheme()
   const {_} = useLingui()
   const verification = useSimpleVerificationState({profile})
+  const {isActive: live} = useActorStatus(profile)
 
   const onPress = useCallback(() => {
     onSelect(account)
@@ -141,6 +143,8 @@ function AccountItem({
             avatar={profile?.avatar}
             size={36}
             type={profile?.associated?.labeler ? 'labeler' : 'user'}
+            live={live}
+            hideLiveBadge
           />
 
           <View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}>
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 2d6ddc834..42eb64844 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -26,6 +26,7 @@ export type ButtonColor =
   | 'secondary'
   | 'secondary_inverted'
   | 'negative'
+  | 'negative_secondary'
   | 'gradient_primary'
   | 'gradient_sky'
   | 'gradient_midnight'
@@ -356,6 +357,57 @@ export const Button = React.forwardRef<View, ButtonProps>(
             })
           }
         }
+      } else if (color === 'negative_secondary') {
+        if (variant === 'solid') {
+          if (!disabled) {
+            baseStyles.push({
+              backgroundColor: select(t.name, {
+                light: t.palette.negative_50,
+                dim: t.palette.negative_100,
+                dark: t.palette.negative_100,
+              }),
+            })
+            hoverStyles.push({
+              backgroundColor: select(t.name, {
+                light: t.palette.negative_100,
+                dim: t.palette.negative_200,
+                dark: t.palette.negative_200,
+              }),
+            })
+          } else {
+            baseStyles.push({
+              backgroundColor: select(t.name, {
+                light: t.palette.negative_100,
+                dim: t.palette.negative_50,
+                dark: t.palette.negative_50,
+              }),
+            })
+          }
+        } else if (variant === 'outline') {
+          baseStyles.push(a.border, t.atoms.bg, {
+            borderWidth: 1,
+          })
+
+          if (!disabled) {
+            baseStyles.push(a.border, {
+              borderColor: t.palette.negative_500,
+            })
+            hoverStyles.push(a.border, {
+              backgroundColor: t.palette.negative_50,
+            })
+          } else {
+            baseStyles.push(a.border, {
+              borderColor: t.palette.negative_200,
+            })
+          }
+        } else if (variant === 'ghost') {
+          if (!disabled) {
+            baseStyles.push(t.atoms.bg)
+            hoverStyles.push({
+              backgroundColor: t.palette.negative_100,
+            })
+          }
+        }
       }
 
       if (shape === 'default') {
@@ -425,6 +477,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
         secondary: tokens.gradients.sky,
         secondary_inverted: tokens.gradients.sky,
         negative: tokens.gradients.sky,
+        negative_secondary: tokens.gradients.sky,
         gradient_primary: tokens.gradients.primary,
         gradient_sky: tokens.gradients.sky,
         gradient_midnight: tokens.gradients.midnight,
@@ -645,6 +698,39 @@ export function useSharedButtonTextStyles() {
           baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
         }
       }
+    } else if (color === 'negative_secondary') {
+      if (variant === 'solid' || variant === 'gradient') {
+        if (!disabled) {
+          baseStyles.push({
+            color: select(t.name, {
+              light: t.palette.negative_500,
+              dim: t.palette.negative_950,
+              dark: t.palette.negative_900,
+            }),
+          })
+        } else {
+          baseStyles.push({
+            color: select(t.name, {
+              light: t.palette.negative_500,
+              dim: t.palette.negative_700,
+              dark: t.palette.negative_700,
+            }),
+            opacity: 0.5,
+          })
+        }
+      } else if (variant === 'outline') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.negative_400})
+        } else {
+          baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
+        }
+      } else if (variant === 'ghost') {
+        if (!disabled) {
+          baseStyles.push({color: t.palette.negative_400})
+        } else {
+          baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
+        }
+      }
     } else {
       if (!disabled) {
         baseStyles.push({color: t.palette.white})
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 4c6c5816c..4795385ee 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -307,7 +307,7 @@ export const InnerFlatList = React.forwardRef<
   )
 })
 
-export function Handle() {
+export function Handle({difference = false}: {difference?: boolean}) {
   const t = useTheme()
   const {_} = useLingui()
   const {screenReaderEnabled} = useA11y()
@@ -328,9 +328,19 @@ export function Handle() {
               width: 35,
               height: 5,
               alignSelf: 'center',
-              backgroundColor: t.palette.contrast_975,
-              opacity: 0.5,
             },
+            difference
+              ? {
+                  // TODO: mixBlendMode is only available on the new architecture -sfn
+                  // backgroundColor: t.palette.white,
+                  // mixBlendMode: 'difference',
+                  backgroundColor: t.palette.white,
+                  opacity: 0.75,
+                }
+              : {
+                  backgroundColor: t.palette.contrast_975,
+                  opacity: 0.5,
+                },
           ]}
         />
       </Pressable>
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index c43e9c5c0..12bd8819b 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -195,13 +195,7 @@ export function Inner({
           onDismiss={close}
           style={{display: 'flex', flexDirection: 'column'}}>
           {header}
-          <View
-            style={[
-              gtMobile ? a.p_2xl : a.p_xl,
-              a.overflow_hidden,
-              a.rounded_md,
-              contentContainerStyle,
-            ]}>
+          <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
             {children}
           </View>
         </DismissableLayer.DismissableLayer>
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index aa5830eb9..30b26bead 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -8,6 +8,7 @@ import {
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {getModerationCauseKey} from '#/lib/moderation'
 import {type LogEvents} from '#/lib/statsig/statsig'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -132,20 +133,25 @@ export function Avatar({
   moderationOpts,
   onPress,
   disabledPreview,
+  liveOverride,
 }: {
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
   onPress?: () => void
   disabledPreview?: boolean
+  liveOverride?: boolean
 }) {
   const moderation = moderateProfile(profile, moderationOpts)
 
+  const {isActive: live} = useActorStatus(profile)
+
   return disabledPreview ? (
     <UserAvatar
       size={40}
       avatar={profile.avatar}
       type={profile.associated?.labeler ? 'labeler' : 'user'}
       moderation={moderation.ui('avatar')}
+      live={liveOverride ?? live}
     />
   ) : (
     <PreviewableUserAvatar
@@ -153,6 +159,7 @@ export function Avatar({
       profile={profile}
       moderation={moderation.ui('avatar')}
       onBeforePress={onPress}
+      live={liveOverride ?? live}
     />
   )
 }
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 09b587c5e..4f6545a2e 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {View} from 'react-native'
 import {
   type AppBskyActorDefs,
@@ -8,10 +8,13 @@ import {
 import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
 import {msg, plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {isTouchDevice} from '#/lib/browser'
 import {getModerationCauseKey} from '#/lib/moderation'
 import {makeProfileLink} from '#/lib/routes/links'
+import {type NavigationProp} from '#/lib/routes/types'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -32,6 +35,7 @@ import {
   shouldShowKnownFollowers,
 } from '#/components/KnownFollowers'
 import {InlineLinkText, Link} from '#/components/Link'
+import {LiveStatus} from '#/components/live/LiveStatusDialog'
 import {Loader} from '#/components/Loader'
 import * as Pills from '#/components/Pills'
 import {Portal} from '#/components/Portal'
@@ -105,6 +109,8 @@ const HIDE_DELAY = 150
 const HIDE_DURATION = 200
 
 export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
+  const navigation = useNavigation<NavigationProp>()
+
   const {refs, floatingStyles} = useFloating({
     middleware: floatingMiddlewares,
   })
@@ -330,7 +336,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
             onPointerEnter={onPointerEnterCard}
             onPointerLeave={onPointerLeaveCard}>
             <div style={{willChange: 'transform', ...animationStyle}}>
-              <Card did={props.did} hide={onPress} />
+              <Card did={props.did} hide={onPress} navigation={navigation} />
             </div>
           </div>
         </Portal>
@@ -339,7 +345,15 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
   )
 }
 
-let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
+let Card = ({
+  did,
+  hide,
+  navigation,
+}: {
+  did: string
+  hide: () => void
+  navigation: NavigationProp
+}): React.ReactNode => {
   const t = useTheme()
 
   const profile = useProfileQuery({did})
@@ -347,24 +361,42 @@ let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
 
   const data = profile.data
 
+  const status = useActorStatus(data)
+
+  const onPressOpenProfile = useCallback(() => {
+    if (!status.isActive || !data) return
+    hide()
+    navigation.push('Profile', {
+      name: data.handle,
+    })
+  }, [hide, navigation, status, data])
+
   return (
     <View
       style={[
-        a.p_lg,
+        !status.isActive && a.p_lg,
         a.border,
         a.rounded_md,
         a.overflow_hidden,
         t.atoms.bg,
         t.atoms.border_contrast_low,
         t.atoms.shadow_lg,
-        {
-          width: 300,
-        },
+        a.w_full,
+        {maxWidth: status.isActive ? 500 : 300},
       ]}>
       {data && moderationOpts ? (
-        <Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
+        status.isActive ? (
+          <LiveStatus
+            profile={data}
+            embed={status.embed}
+            padding="lg"
+            onPressOpenProfile={onPressOpenProfile}
+          />
+        ) : (
+          <Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
+        )
       ) : (
-        <View style={[a.justify_center]}>
+        <View style={[a.justify_center, a.align_center, {minHeight: 200}]}>
           <Loader size="xl" />
         </View>
       )}
diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts
index 2fa064383..37087dc95 100644
--- a/src/components/ProfileHoverCard/types.ts
+++ b/src/components/ProfileHoverCard/types.ts
@@ -1,4 +1,4 @@
-import React from 'react'
+import type React from 'react'
 
 export type ProfileHoverCardProps = {
   children: React.ReactElement
diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
index 267b784b0..d6c946956 100644
--- a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
+++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
@@ -45,6 +45,10 @@ export function VerificationReminder({
             a.overflow_hidden,
             a.pt_md,
             t.atoms.bg_contrast_100,
+            {
+              borderTopLeftRadius: a.rounded_md.borderRadius,
+              borderTopRightRadius: a.rounded_md.borderRadius,
+            },
           ]}>
           <GradientFill gradient={tokens.gradients.primary} />
           <ShieldIcon width={64} fill="white" style={[a.z_10]} />
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
index 3be555238..2a89be7d3 100644
--- a/src/components/forms/DateField/index.android.tsx
+++ b/src/components/forms/DateField/index.android.tsx
@@ -1,9 +1,10 @@
 import {useCallback, useImperativeHandle, useState} from 'react'
 import {Keyboard} from 'react-native'
 import DatePicker from 'react-native-date-picker'
+import {useLingui} from '@lingui/react'
 
 import {useTheme} from '#/alf'
-import {DateFieldProps} from '#/components/forms/DateField/types'
+import {type DateFieldProps} from '#/components/forms/DateField/types'
 import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import * as TextField from '#/components/forms/TextField'
 import {DateFieldButton} from './index.shared'
@@ -21,6 +22,7 @@ export function DateField({
   accessibilityHint,
   maximumDate,
 }: DateFieldProps) {
+  const {i18n} = useLingui()
   const t = useTheme()
   const [open, setOpen] = useState(false)
 
@@ -80,6 +82,8 @@ export function DateField({
           onConfirm={onChangeInternal}
           onCancel={onCancel}
           mode="date"
+          locale={i18n.locale}
+          is24hourSource="locale"
           testID={`${testID}-datepicker`}
           aria-label={label}
           accessibilityLabel={label}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index b8ecf2e6f..3683ee9e6 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {DateFieldProps} from '#/components/forms/DateField/types'
+import {type DateFieldProps} from '#/components/forms/DateField/types'
 import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import * as TextField from '#/components/forms/TextField'
 import {DateFieldButton} from './index.shared'
@@ -33,7 +33,7 @@ export function DateField({
   accessibilityHint,
   maximumDate,
 }: DateFieldProps) {
-  const {_} = useLingui()
+  const {_, i18n} = useLingui()
   const t = useTheme()
   const control = Dialog.useDialogControl()
 
@@ -83,10 +83,11 @@ export function DateField({
             <View style={[a.relative, a.w_full, a.align_center]}>
               <DatePicker
                 timeZoneOffsetInMinutes={0}
-                theme={t.name === 'light' ? 'light' : 'dark'}
+                theme={t.scheme}
                 date={new Date(toSimpleDateString(value))}
                 onDateChange={onChangeInternal}
                 mode="date"
+                locale={i18n.locale}
                 testID={`${testID}-datepicker`}
                 aria-label={label}
                 accessibilityLabel={label}
diff --git a/src/components/icons/Live.tsx b/src/components/icons/Live.tsx
new file mode 100644
index 000000000..609aa2127
--- /dev/null
+++ b/src/components/icons/Live.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Live_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M2 12A9.97 9.97 0 0 1 4.929 4.93l.076-.068a1 1 0 0 1 1.407 1.406l-.07.076A7.97 7.97 0 0 0 4 12c0 2.072.786 3.958 2.078 5.38l.265.277.07.076a1 1 0 0 1-1.408 1.407l-.076-.07-.331-.346A9.97 9.97 0 0 1 2 12Zm18 0a7.97 7.97 0 0 0-2.078-5.379l-.265-.278-.07-.076a1 1 0 0 1 1.408-1.406l.076.068.331.347A9.97 9.97 0 0 1 22 12c0 2.761-1.12 5.262-2.929 7.07a1 1 0 1 1-1.414-1.413A7.97 7.97 0 0 0 20 12ZM6 12c0-1.656.673-3.158 1.758-4.243a1 1 0 0 1 1.414 1.414A3.99 3.99 0 0 0 8 12.001c0 1.035.393 1.978 1.04 2.689l.132.138.068.077a1 1 0 0 1-1.407 1.406l-.075-.069-.2-.208A5.98 5.98 0 0 1 6 12Zm10 0a3.98 3.98 0 0 0-1.04-2.69l-.132-.139-.068-.075a1 1 0 0 1 1.407-1.407l.075.068.2.208A5.98 5.98 0 0 1 18 12a5.99 5.99 0 0 1-1.758 4.243 1 1 0 0 1-1.414-1.415A3.99 3.99 0 0 0 16 12Zm-6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z',
+})
diff --git a/src/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx
new file mode 100644
index 000000000..36c292cb5
--- /dev/null
+++ b/src/components/live/EditLiveDialog.tsx
@@ -0,0 +1,348 @@
+import {useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {
+  type AppBskyActorDefs,
+  AppBskyActorStatus,
+  type AppBskyEmbedExternal,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQuery} from '@tanstack/react-query'
+import {differenceInMinutes} from 'date-fns'
+
+import {getLinkMeta} from '#/lib/link-meta/link-meta'
+import {cleanError} from '#/lib/strings/errors'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {definitelyUrl} from '#/lib/strings/url-helpers'
+import {useAgent} from '#/state/session'
+import {useTickEveryMinute} from '#/state/shell'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {atoms as a, platform, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
+import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {
+  useRemoveLiveStatusMutation,
+  useUpsertLiveStatusMutation,
+} from './queries'
+import {displayDuration, useDebouncedValue} from './utils'
+
+export function EditLiveDialog({
+  control,
+  status,
+  embed,
+}: {
+  control: Dialog.DialogControlProps
+  status: AppBskyActorDefs.StatusView
+  embed: AppBskyEmbedExternal.View
+}) {
+  return (
+    <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+      <Dialog.Handle />
+      <DialogInner status={status} embed={embed} />
+    </Dialog.Outer>
+  )
+}
+
+function DialogInner({
+  status,
+  embed,
+}: {
+  status: AppBskyActorDefs.StatusView
+  embed: AppBskyEmbedExternal.View
+}) {
+  const control = Dialog.useDialogContext()
+  const {_, i18n} = useLingui()
+  const t = useTheme()
+  const agent = useAgent()
+  const [liveLink, setLiveLink] = useState(embed.external.uri)
+  const [liveLinkError, setLiveLinkError] = useState('')
+  const [imageLoadError, setImageLoadError] = useState(false)
+  const tick = useTickEveryMinute()
+
+  const liveLinkUrl = definitelyUrl(liveLink)
+  const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
+
+  const isDirty = liveLinkUrl !== embed.external.uri
+
+  const {
+    data: linkMeta,
+    isSuccess: hasValidLinkMeta,
+    isLoading: linkMetaLoading,
+    error: linkMetaError,
+  } = useQuery({
+    enabled: !!debouncedUrl,
+    queryKey: ['link-meta', debouncedUrl],
+    queryFn: async () => {
+      if (!debouncedUrl) return null
+      return getLinkMeta(agent, debouncedUrl)
+    },
+  })
+
+  const record = useMemo(() => {
+    if (!AppBskyActorStatus.isRecord(status.record)) return null
+    const validation = AppBskyActorStatus.validateRecord(status.record)
+    if (validation.success) {
+      return validation.value
+    }
+    return null
+  }, [status])
+
+  const {
+    mutate: goLive,
+    isPending: isGoingLive,
+    error: goLiveError,
+  } = useUpsertLiveStatusMutation(
+    record?.durationMinutes ?? 0,
+    linkMeta,
+    record?.createdAt,
+  )
+
+  const {
+    mutate: removeLiveStatus,
+    isPending: isRemovingLiveStatus,
+    error: removeLiveStatusError,
+  } = useRemoveLiveStatusMutation()
+
+  const {minutesUntilExpiry, expiryDateTime} = useMemo(() => {
+    tick!
+
+    const expiry = new Date(status.expiresAt ?? new Date())
+    return {
+      expiryDateTime: expiry,
+      minutesUntilExpiry: differenceInMinutes(expiry, new Date()),
+    }
+  }, [tick, status.expiresAt])
+
+  const submitDisabled =
+    isGoingLive ||
+    !hasValidLinkMeta ||
+    debouncedUrl !== liveLinkUrl ||
+    isRemovingLiveStatus
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`You are Live`)}
+      style={web({maxWidth: 420})}>
+      <View style={[a.gap_lg]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_bold, a.text_2xl]}>
+            <Trans>You are Live</Trans>
+          </Text>
+          <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+            <ClockIcon style={[t.atoms.text_contrast_high]} size="sm" />
+            <Text
+              style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
+              {typeof record?.durationMinutes === 'number' ? (
+                <Trans>
+                  Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
+                  {i18n.date(expiryDateTime, {
+                    hour: 'numeric',
+                    minute: '2-digit',
+                    hour12: true,
+                  })}
+                </Trans>
+              ) : (
+                <Trans>No expiry set</Trans>
+              )}
+            </Text>
+          </View>
+        </View>
+        <View style={[a.gap_sm]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>Live link</Trans>
+            </TextField.LabelText>
+            <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
+              <TextField.Input
+                label={_(msg`Live link`)}
+                placeholder={_(msg`www.mylivestream.tv`)}
+                value={liveLink}
+                onChangeText={setLiveLink}
+                onFocus={() => setLiveLinkError('')}
+                onBlur={() => {
+                  if (!definitelyUrl(liveLink)) {
+                    setLiveLinkError('Invalid URL')
+                  }
+                }}
+                returnKeyType="done"
+                autoCapitalize="none"
+                autoComplete="url"
+                autoCorrect={false}
+                onSubmitEditing={() => {
+                  if (isDirty && !submitDisabled) {
+                    goLive()
+                  }
+                }}
+              />
+            </TextField.Root>
+          </View>
+          {(liveLinkError || linkMetaError) && (
+            <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+              <WarningIcon
+                style={[{color: t.palette.negative_500}]}
+                size="sm"
+              />
+              <Text
+                style={[
+                  a.text_sm,
+                  a.leading_snug,
+                  a.flex_1,
+                  a.font_bold,
+                  {color: t.palette.negative_500},
+                ]}>
+                {liveLinkError ? (
+                  <Trans>This is not a valid link</Trans>
+                ) : (
+                  cleanError(linkMetaError)
+                )}
+              </Text>
+            </View>
+          )}
+
+          {(linkMeta || linkMetaLoading) && (
+            <View
+              style={[
+                a.w_full,
+                a.border,
+                t.atoms.border_contrast_low,
+                t.atoms.bg,
+                a.flex_row,
+                a.rounded_sm,
+                a.overflow_hidden,
+                a.align_stretch,
+              ]}>
+              {(!linkMeta || linkMeta.image) && (
+                <View
+                  style={[
+                    t.atoms.bg_contrast_25,
+                    {minHeight: 64, width: 114},
+                    a.justify_center,
+                    a.align_center,
+                  ]}>
+                  {linkMeta?.image && (
+                    <Image
+                      source={linkMeta.image}
+                      accessibilityIgnoresInvertColors
+                      transition={200}
+                      style={[a.absolute, a.inset_0]}
+                      contentFit="cover"
+                      onLoad={() => setImageLoadError(false)}
+                      onError={() => setImageLoadError(true)}
+                    />
+                  )}
+                  {linkMeta && imageLoadError && (
+                    <CircleXIcon
+                      style={[t.atoms.text_contrast_low]}
+                      size="xl"
+                    />
+                  )}
+                </View>
+              )}
+              <View
+                style={[
+                  a.flex_1,
+                  a.justify_center,
+                  a.py_sm,
+                  a.gap_xs,
+                  a.px_md,
+                ]}>
+                {linkMeta ? (
+                  <>
+                    <Text
+                      numberOfLines={2}
+                      style={[a.leading_snug, a.font_bold, a.text_md]}>
+                      {linkMeta.title || linkMeta.url}
+                    </Text>
+                    <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
+                      <GlobeIcon
+                        size="xs"
+                        style={[t.atoms.text_contrast_low]}
+                      />
+                      <Text
+                        numberOfLines={1}
+                        style={[
+                          a.text_xs,
+                          a.leading_snug,
+                          t.atoms.text_contrast_medium,
+                        ]}>
+                        {toNiceDomain(linkMeta.url)}
+                      </Text>
+                    </View>
+                  </>
+                ) : (
+                  <>
+                    <LoadingPlaceholder height={16} width={128} />
+                    <LoadingPlaceholder height={12} width={72} />
+                  </>
+                )}
+              </View>
+            </View>
+          )}
+        </View>
+
+        {goLiveError && (
+          <Admonition type="error">{cleanError(goLiveError)}</Admonition>
+        )}
+        {removeLiveStatusError && (
+          <Admonition type="error">
+            {cleanError(removeLiveStatusError)}
+          </Admonition>
+        )}
+
+        <View
+          style={platform({
+            native: [a.gap_md, a.pt_lg],
+            web: [a.flex_row_reverse, a.gap_md, a.align_center],
+          })}>
+          {isDirty ? (
+            <Button
+              label={_(msg`Save`)}
+              size={platform({native: 'large', web: 'small'})}
+              color="primary"
+              variant="solid"
+              onPress={() => goLive()}
+              disabled={submitDisabled}>
+              <ButtonText>
+                <Trans>Save</Trans>
+              </ButtonText>
+              {isGoingLive && <ButtonIcon icon={Loader} />}
+            </Button>
+          ) : (
+            <Button
+              label={_(msg`Close`)}
+              size={platform({native: 'large', web: 'small'})}
+              color="primary"
+              variant="solid"
+              onPress={() => control.close()}>
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
+          )}
+          <Button
+            label={_(msg`Remove live status`)}
+            onPress={() => removeLiveStatus()}
+            size={platform({native: 'large', web: 'small'})}
+            color="negative_secondary"
+            variant="solid"
+            disabled={isRemovingLiveStatus || isGoingLive}>
+            <ButtonText>
+              <Trans>Remove live status</Trans>
+            </ButtonText>
+            {isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
+          </Button>
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx
new file mode 100644
index 000000000..2fad009fd
--- /dev/null
+++ b/src/components/live/GoLiveDialog.tsx
@@ -0,0 +1,352 @@
+import {useCallback, useState} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQuery} from '@tanstack/react-query'
+
+import {getLinkMeta} from '#/lib/link-meta/link-meta'
+import {cleanError} from '#/lib/strings/errors'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {definitelyUrl} from '#/lib/strings/url-helpers'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useAgent} from '#/state/session'
+import {useTickEveryMinute} from '#/state/shell'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import * as Select from '#/components/Select'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+import {useUpsertLiveStatusMutation} from './queries'
+import {displayDuration, useDebouncedValue} from './utils'
+
+export function GoLiveDialog({
+  control,
+  profile,
+}: {
+  control: Dialog.DialogControlProps
+  profile: bsky.profile.AnyProfileView
+}) {
+  return (
+    <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+      <Dialog.Handle />
+      <DialogInner profile={profile} />
+    </Dialog.Outer>
+  )
+}
+
+// Possible durations: max 4 hours, 5 minute intervals
+const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5)
+
+function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
+  const control = Dialog.useDialogContext()
+  const {_, i18n} = useLingui()
+  const t = useTheme()
+  const agent = useAgent()
+  const [liveLink, setLiveLink] = useState('')
+  const [liveLinkError, setLiveLinkError] = useState('')
+  const [imageLoadError, setImageLoadError] = useState(false)
+  const [duration, setDuration] = useState(60)
+  const moderationOpts = useModerationOpts()
+  const tick = useTickEveryMinute()
+
+  const time = useCallback(
+    (offset: number) => {
+      tick!
+
+      const date = new Date()
+      date.setMinutes(date.getMinutes() + offset)
+      return i18n
+        .date(date, {hour: 'numeric', minute: '2-digit', hour12: true})
+        .toLocaleUpperCase()
+        .replace(' ', '')
+    },
+    [tick, i18n],
+  )
+
+  const onChangeDuration = useCallback((newDuration: string) => {
+    setDuration(Number(newDuration))
+  }, [])
+
+  const liveLinkUrl = definitelyUrl(liveLink)
+  const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
+  const hasLink = !!debouncedUrl
+
+  const {
+    data: linkMeta,
+    isSuccess: hasValidLinkMeta,
+    isLoading: linkMetaLoading,
+    error: linkMetaError,
+  } = useQuery({
+    enabled: !!debouncedUrl,
+    queryKey: ['link-meta', debouncedUrl],
+    queryFn: async () => {
+      if (!debouncedUrl) return null
+      return getLinkMeta(agent, debouncedUrl)
+    },
+  })
+
+  const {
+    mutate: goLive,
+    isPending: isGoingLive,
+    error: goLiveError,
+  } = useUpsertLiveStatusMutation(duration, linkMeta)
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Go Live`)}
+      style={web({maxWidth: 420})}>
+      <View style={[a.gap_xl]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_bold, a.text_2xl]}>
+            <Trans>Go Live</Trans>
+          </Text>
+          <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
+            <Trans>
+              Add a temporary live status to your profile. When someone clicks
+              on your avatar, they’ll see information about your live event.
+            </Trans>
+          </Text>
+        </View>
+        {moderationOpts && (
+          <ProfileCard.Header>
+            <ProfileCard.Avatar
+              profile={profile}
+              moderationOpts={moderationOpts}
+              liveOverride
+              disabledPreview
+            />
+            <ProfileCard.NameAndHandle
+              profile={profile}
+              moderationOpts={moderationOpts}
+            />
+          </ProfileCard.Header>
+        )}
+        <View style={[a.gap_sm]}>
+          <View>
+            <TextField.LabelText>
+              <Trans>Live link</Trans>
+            </TextField.LabelText>
+            <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
+              <TextField.Input
+                label={_(msg`Live link`)}
+                placeholder={_(msg`www.mylivestream.tv`)}
+                value={liveLink}
+                onChangeText={setLiveLink}
+                onFocus={() => setLiveLinkError('')}
+                onBlur={() => {
+                  if (!definitelyUrl(liveLink)) {
+                    setLiveLinkError('Invalid URL')
+                  }
+                }}
+                returnKeyType="done"
+                autoCapitalize="none"
+                autoComplete="url"
+                autoCorrect={false}
+              />
+            </TextField.Root>
+          </View>
+          {(liveLinkError || linkMetaError) && (
+            <View style={[a.flex_row, a.gap_xs, a.align_center]}>
+              <WarningIcon
+                style={[{color: t.palette.negative_500}]}
+                size="sm"
+              />
+              <Text
+                style={[
+                  a.text_sm,
+                  a.leading_snug,
+                  a.flex_1,
+                  a.font_bold,
+                  {color: t.palette.negative_500},
+                ]}>
+                {liveLinkError ? (
+                  <Trans>This is not a valid link</Trans>
+                ) : (
+                  cleanError(linkMetaError)
+                )}
+              </Text>
+            </View>
+          )}
+
+          {(linkMeta || linkMetaLoading) && (
+            <View
+              style={[
+                a.w_full,
+                a.border,
+                t.atoms.border_contrast_low,
+                t.atoms.bg,
+                a.flex_row,
+                a.rounded_sm,
+                a.overflow_hidden,
+                a.align_stretch,
+              ]}>
+              {(!linkMeta || linkMeta.image) && (
+                <View
+                  style={[
+                    t.atoms.bg_contrast_25,
+                    {minHeight: 64, width: 114},
+                    a.justify_center,
+                    a.align_center,
+                  ]}>
+                  {linkMeta?.image && (
+                    <Image
+                      source={linkMeta.image}
+                      accessibilityIgnoresInvertColors
+                      transition={200}
+                      style={[a.absolute, a.inset_0]}
+                      contentFit="cover"
+                      onLoad={() => setImageLoadError(false)}
+                      onError={() => setImageLoadError(true)}
+                    />
+                  )}
+                  {linkMeta && imageLoadError && (
+                    <CircleXIcon
+                      style={[t.atoms.text_contrast_low]}
+                      size="xl"
+                    />
+                  )}
+                </View>
+              )}
+              <View
+                style={[
+                  a.flex_1,
+                  a.justify_center,
+                  a.py_sm,
+                  a.gap_xs,
+                  a.px_md,
+                ]}>
+                {linkMeta ? (
+                  <>
+                    <Text
+                      numberOfLines={2}
+                      style={[a.leading_snug, a.font_bold, a.text_md]}>
+                      {linkMeta.title || linkMeta.url}
+                    </Text>
+                    <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
+                      <GlobeIcon
+                        size="xs"
+                        style={[t.atoms.text_contrast_low]}
+                      />
+                      <Text
+                        numberOfLines={1}
+                        style={[
+                          a.text_xs,
+                          a.leading_snug,
+                          t.atoms.text_contrast_medium,
+                        ]}>
+                        {toNiceDomain(linkMeta.url)}
+                      </Text>
+                    </View>
+                  </>
+                ) : (
+                  <>
+                    <LoadingPlaceholder height={16} width={128} />
+                    <LoadingPlaceholder height={12} width={72} />
+                  </>
+                )}
+              </View>
+            </View>
+          )}
+        </View>
+
+        {hasLink && (
+          <View>
+            <TextField.LabelText>
+              <Trans>Go live for</Trans>
+            </TextField.LabelText>
+            <Select.Root
+              value={String(duration)}
+              onValueChange={onChangeDuration}>
+              <Select.Trigger label={_(msg`Select duration`)}>
+                <Text style={[ios(a.py_xs)]}>
+                  {displayDuration(i18n, duration)}
+                  {'  '}
+                  <Text style={[t.atoms.text_contrast_low]}>
+                    {time(duration)}
+                  </Text>
+                </Text>
+
+                <Select.Icon />
+              </Select.Trigger>
+              <Select.Content
+                renderItem={(item, _i, selectedValue) => {
+                  const label = displayDuration(i18n, item)
+                  return (
+                    <Select.Item value={String(item)} label={label}>
+                      <Select.ItemIndicator />
+                      <Select.ItemText>
+                        {label}
+                        {'  '}
+                        <Text
+                          style={[
+                            native(a.text_md),
+                            web(a.ml_xs),
+                            selectedValue === String(item)
+                              ? t.atoms.text_contrast_medium
+                              : t.atoms.text_contrast_low,
+                            a.font_normal,
+                          ]}>
+                          {time(item)}
+                        </Text>
+                      </Select.ItemText>
+                    </Select.Item>
+                  )
+                }}
+                items={DURATIONS}
+                valueExtractor={d => String(d)}
+              />
+            </Select.Root>
+          </View>
+        )}
+
+        {goLiveError && (
+          <Admonition type="error">{cleanError(goLiveError)}</Admonition>
+        )}
+
+        <View
+          style={platform({
+            native: [a.gap_md, a.pt_lg],
+            web: [a.flex_row_reverse, a.gap_md, a.align_center],
+          })}>
+          {hasLink && (
+            <Button
+              label={_(msg`Go Live`)}
+              size={platform({native: 'large', web: 'small'})}
+              color="primary"
+              variant="solid"
+              onPress={() => goLive()}
+              disabled={
+                isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl
+              }>
+              <ButtonText>
+                <Trans>Go Live</Trans>
+              </ButtonText>
+              {isGoingLive && <ButtonIcon icon={Loader} />}
+            </Button>
+          )}
+          <Button
+            label={_(msg`Cancel`)}
+            onPress={() => control.close()}
+            size={platform({native: 'large', web: 'small'})}
+            color="secondary"
+            variant={platform({native: 'solid', web: 'ghost'})}>
+            <ButtonText>
+              <Trans>Cancel</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/live/LiveIndicator.tsx b/src/components/live/LiveIndicator.tsx
new file mode 100644
index 000000000..c237e8c83
--- /dev/null
+++ b/src/components/live/LiveIndicator.tsx
@@ -0,0 +1,53 @@
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function LiveIndicator({
+  size = 'small',
+  style,
+}: {
+  size?: 'tiny' | 'small' | 'large'
+  style?: StyleProp<ViewStyle>
+}) {
+  const t = useTheme()
+
+  const fontSize = {
+    tiny: {fontSize: 7, letterSpacing: tokens.TRACKING},
+    small: a.text_2xs,
+    large: a.text_xs,
+  }[size]
+
+  return (
+    <View
+      style={[
+        a.absolute,
+        a.w_full,
+        a.align_center,
+        a.pointer_events_none,
+        {bottom: size === 'large' ? -8 : -5},
+        style,
+      ]}>
+      <View
+        style={{
+          backgroundColor: t.palette.negative_500,
+          paddingVertical: size === 'large' ? 2 : 1,
+          paddingHorizontal: size === 'large' ? 4 : 3,
+          borderRadius: size === 'large' ? 5 : tokens.borderRadius.xs,
+        }}>
+        <Text
+          style={[
+            a.text_center,
+            a.font_bold,
+            fontSize,
+            {color: t.palette.white},
+          ]}>
+          <Trans comment="Live status indicator on avatar. Should be extremely short, not much space for more than 4 characters">
+            LIVE
+          </Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/live/LiveStatusDialog.tsx b/src/components/live/LiveStatusDialog.tsx
new file mode 100644
index 000000000..c892dea58
--- /dev/null
+++ b/src/components/live/LiveStatusDialog.tsx
@@ -0,0 +1,212 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {type NavigationProp} from '#/lib/routes/types'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {unstableCacheProfileView} from '#/state/queries/profile'
+import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
+import {LiveIndicator} from './LiveIndicator'
+
+export function LiveStatusDialog({
+  control,
+  profile,
+  embed,
+}: {
+  control: Dialog.DialogControlProps
+  profile: bsky.profile.AnyProfileView
+  status: AppBskyActorDefs.StatusView
+  embed: AppBskyEmbedExternal.View
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  return (
+    <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+      <Dialog.Handle difference={!!embed.external.thumb} />
+      <DialogInner profile={profile} embed={embed} navigation={navigation} />
+    </Dialog.Outer>
+  )
+}
+
+function DialogInner({
+  profile,
+  embed,
+  navigation,
+}: {
+  profile: bsky.profile.AnyProfileView
+  embed: AppBskyEmbedExternal.View
+  navigation: NavigationProp
+}) {
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
+
+  const onPressOpenProfile = useCallback(() => {
+    control.close(() => {
+      navigation.push('Profile', {
+        name: profile.handle,
+      })
+    })
+  }, [navigation, profile.handle, control])
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`${sanitizeHandle(profile.handle)} is live`)}
+      contentContainerStyle={[a.pt_0, a.px_0]}
+      style={[web({maxWidth: 420}), a.overflow_hidden]}>
+      <LiveStatus
+        profile={profile}
+        embed={embed}
+        onPressOpenProfile={onPressOpenProfile}
+      />
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+export function LiveStatus({
+  profile,
+  embed,
+  padding = 'xl',
+  onPressOpenProfile,
+}: {
+  profile: bsky.profile.AnyProfileView
+  embed: AppBskyEmbedExternal.View
+  padding?: 'lg' | 'xl'
+  onPressOpenProfile: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const queryClient = useQueryClient()
+  const openLink = useOpenLink()
+  const moderationOpts = useModerationOpts()
+
+  return (
+    <>
+      {embed.external.thumb && (
+        <View
+          style={[
+            t.atoms.bg_contrast_25,
+            a.w_full,
+            {aspectRatio: 1.91},
+            android([
+              a.overflow_hidden,
+              {
+                borderTopLeftRadius: a.rounded_md.borderRadius,
+                borderTopRightRadius: a.rounded_md.borderRadius,
+              },
+            ]),
+          ]}>
+          <Image
+            source={embed.external.thumb}
+            contentFit="cover"
+            style={[a.absolute, a.inset_0]}
+            accessibilityIgnoresInvertColors
+          />
+          <LiveIndicator
+            size="large"
+            style={[
+              a.absolute,
+              {top: tokens.space.lg, left: tokens.space.lg},
+              a.align_start,
+            ]}
+          />
+        </View>
+      )}
+      <View
+        style={[
+          a.gap_lg,
+          padding === 'xl'
+            ? [a.px_xl, !embed.external.thumb ? a.pt_2xl : a.pt_lg]
+            : a.p_lg,
+        ]}>
+        <View style={[a.flex_1, a.justify_center, a.gap_2xs]}>
+          <Text
+            numberOfLines={3}
+            style={[a.leading_snug, a.font_bold, a.text_xl]}>
+            {embed.external.title || embed.external.uri}
+          </Text>
+          <View style={[a.flex_row, a.align_center, a.gap_2xs]}>
+            <Globe_Stroke2_Corner0_Rounded
+              size="xs"
+              style={[t.atoms.text_contrast_medium]}
+            />
+            <Text
+              numberOfLines={1}
+              style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+              {toNiceDomain(embed.external.uri)}
+            </Text>
+          </View>
+        </View>
+        <Button
+          label={_(msg`Watch now`)}
+          size={platform({native: 'large', web: 'small'})}
+          color="primary"
+          variant="solid"
+          onPress={() => {
+            logger.metric('live:card:watch', {subject: profile.did})
+            openLink(embed.external.uri, false)
+          }}>
+          <ButtonText>
+            <Trans>Watch now</Trans>
+          </ButtonText>
+          <ButtonIcon icon={SquareArrowTopRightIcon} />
+        </Button>
+        <View style={[t.atoms.border_contrast_low, a.border_t, a.w_full]} />
+        {moderationOpts && (
+          <ProfileCard.Header>
+            <ProfileCard.Avatar
+              profile={profile}
+              moderationOpts={moderationOpts}
+              disabledPreview
+            />
+            {/* Ensure wide enough on web hover */}
+            <View style={[a.flex_1, web({minWidth: 100})]}>
+              <ProfileCard.NameAndHandle
+                profile={profile}
+                moderationOpts={moderationOpts}
+              />
+            </View>
+            <Button
+              label={_(msg`Open profile`)}
+              size="small"
+              color="secondary"
+              variant="solid"
+              onPress={() => {
+                logger.metric('live:card:openProfile', {subject: profile.did})
+                unstableCacheProfileView(queryClient, profile)
+                onPressOpenProfile()
+              }}>
+              <ButtonText>
+                <Trans>Open profile</Trans>
+              </ButtonText>
+            </Button>
+          </ProfileCard.Header>
+        )}
+        <Text
+          style={[
+            a.w_full,
+            a.text_center,
+            t.atoms.text_contrast_low,
+            a.text_sm,
+          ]}>
+          <Trans>Live feature is in beta testing</Trans>
+        </Text>
+      </View>
+    </>
+  )
+}
diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts
new file mode 100644
index 000000000..1958ab49d
--- /dev/null
+++ b/src/components/live/queries.ts
@@ -0,0 +1,187 @@
+import {
+  type $Typed,
+  type AppBskyActorStatus,
+  type AppBskyEmbedExternal,
+  ComAtprotoRepoPutRecord,
+} from '@atproto/api'
+import {retry} from '@atproto/common-web'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {uploadBlob} from '#/lib/api'
+import {imageToThumb} from '#/lib/api/resolve'
+import {type LinkMeta} from '#/lib/link-meta/link-meta'
+import {logger} from '#/logger'
+import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useDialogContext} from '#/components/Dialog'
+
+export function useUpsertLiveStatusMutation(
+  duration: number,
+  linkMeta: LinkMeta | null | undefined,
+  createdAt?: string,
+) {
+  const {currentAccount} = useSession()
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const control = useDialogContext()
+  const {_} = useLingui()
+
+  return useMutation({
+    mutationFn: async () => {
+      if (!currentAccount) throw new Error('Not logged in')
+
+      let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
+
+      if (linkMeta) {
+        let thumb
+
+        if (linkMeta.image) {
+          try {
+            const img = await imageToThumb(linkMeta.image)
+            if (img) {
+              const blob = await uploadBlob(
+                agent,
+                img.source.path,
+                img.source.mime,
+              )
+              thumb = blob.data.blob
+            }
+          } catch (e: any) {
+            logger.error(`Failed to upload thumbnail for live status`, {
+              url: linkMeta.url,
+              image: linkMeta.image,
+              safeMessage: e,
+            })
+          }
+        }
+
+        embed = {
+          $type: 'app.bsky.embed.external',
+          external: {
+            $type: 'app.bsky.embed.external#external',
+            title: linkMeta.title ?? '',
+            description: linkMeta.description ?? '',
+            uri: linkMeta.url,
+            thumb,
+          },
+        }
+      }
+
+      const record = {
+        $type: 'app.bsky.actor.status',
+        createdAt: createdAt ?? new Date().toISOString(),
+        status: 'app.bsky.actor.status#live',
+        durationMinutes: duration,
+        embed,
+      } satisfies AppBskyActorStatus.Record
+
+      const upsert = async () => {
+        const repo = currentAccount.did
+        const collection = 'app.bsky.actor.status'
+
+        const existing = await agent.com.atproto.repo
+          .getRecord({repo, collection, rkey: 'self'})
+          .catch(_e => undefined)
+
+        await agent.com.atproto.repo.putRecord({
+          repo,
+          collection,
+          rkey: 'self',
+          record,
+          swapRecord: existing?.data.cid || null,
+        })
+      }
+
+      await retry(upsert, {
+        maxRetries: 5,
+        retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
+      })
+
+      return {
+        record,
+        image: linkMeta?.image,
+      }
+    },
+    onError: (e: any) => {
+      logger.error(`Failed to upsert live status`, {
+        url: linkMeta?.url,
+        image: linkMeta?.image,
+        safeMessage: e,
+      })
+    },
+    onSuccess: ({record, image}) => {
+      if (createdAt) {
+        logger.metric('live:edit', {duration: record.durationMinutes})
+      } else {
+        logger.metric('live:create', {duration: record.durationMinutes})
+      }
+
+      Toast.show(_(msg`You are now live!`))
+      control.close(() => {
+        if (!currentAccount) return
+
+        const expiresAt = new Date(record.createdAt)
+        expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
+
+        updateProfileShadow(queryClient, currentAccount.did, {
+          status: {
+            $type: 'app.bsky.actor.defs#statusView',
+            status: 'app.bsky.actor.status#live',
+            isActive: true,
+            expiresAt: expiresAt.toISOString(),
+            embed:
+              record.embed && image
+                ? {
+                    $type: 'app.bsky.embed.external#view',
+                    external: {
+                      ...record.embed.external,
+                      $type: 'app.bsky.embed.external#viewExternal',
+                      thumb: image,
+                    },
+                  }
+                : undefined,
+            record,
+          },
+        })
+      })
+    },
+  })
+}
+
+export function useRemoveLiveStatusMutation() {
+  const {currentAccount} = useSession()
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const control = useDialogContext()
+  const {_} = useLingui()
+
+  return useMutation({
+    mutationFn: async () => {
+      if (!currentAccount) throw new Error('Not logged in')
+
+      await agent.app.bsky.actor.status.delete({
+        repo: currentAccount.did,
+        rkey: 'self',
+      })
+    },
+    onError: (e: any) => {
+      logger.error(`Failed to remove live status`, {
+        safeMessage: e,
+      })
+    },
+    onSuccess: () => {
+      logger.metric('live:remove', {})
+      Toast.show(_(msg`You are no longer live`))
+      control.close(() => {
+        if (!currentAccount) return
+
+        updateProfileShadow(queryClient, currentAccount.did, {
+          status: undefined,
+        })
+      })
+    },
+  })
+}
diff --git a/src/components/live/temp.ts b/src/components/live/temp.ts
new file mode 100644
index 000000000..fb26b8c06
--- /dev/null
+++ b/src/components/live/temp.ts
@@ -0,0 +1,41 @@
+import {type AppBskyActorDefs, AppBskyEmbedExternal} from '@atproto/api'
+
+import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
+import type * as bsky from '#/types/bsky'
+
+export const LIVE_DIDS: Record<string, true> = {
+  'did:plc:7sfnardo5xxznxc6esxc5ooe': true, // nba.com
+  'did:plc:gx6fyi3jcfxd7ammq2t7mzp2': true, // rtgame.bsky.social
+}
+
+export const LIVE_SOURCES: Record<string, true> = {
+  'nba.com': true,
+  'twitch.tv': true,
+}
+
+// TEMP: dumb gating
+export function temp__canBeLive(profile: bsky.profile.AnyProfileView) {
+  if (__DEV__)
+    return !!DISCOVER_DEBUG_DIDS[profile.did] || !!LIVE_DIDS[profile.did]
+  return !!LIVE_DIDS[profile.did]
+}
+
+export function temp__canGoLive(profile: bsky.profile.AnyProfileView) {
+  if (__DEV__) return true
+  return !!LIVE_DIDS[profile.did]
+}
+
+// status must have a embed, and the embed must be an approved host for the status to be valid
+export function temp__isStatusValid(status: AppBskyActorDefs.StatusView) {
+  if (status.status !== 'app.bsky.actor.status#live') return false
+  try {
+    if (AppBskyEmbedExternal.isView(status.embed)) {
+      const url = new URL(status.embed.external.uri)
+      return !!LIVE_SOURCES[url.hostname]
+    } else {
+      return false
+    }
+  } catch {
+    return false
+  }
+}
diff --git a/src/components/live/utils.ts b/src/components/live/utils.ts
new file mode 100644
index 000000000..6b4267cb0
--- /dev/null
+++ b/src/components/live/utils.ts
@@ -0,0 +1,37 @@
+import {useEffect, useState} from 'react'
+import {type I18n} from '@lingui/core'
+import {plural} from '@lingui/macro'
+
+export function displayDuration(i18n: I18n, durationInMinutes: number) {
+  const roundedDurationInMinutes = Math.round(durationInMinutes)
+  const hours = Math.floor(roundedDurationInMinutes / 60)
+  const minutes = roundedDurationInMinutes % 60
+  const minutesString = i18n._(
+    plural(minutes, {one: '# minute', other: '# minutes'}),
+  )
+  return hours > 0
+    ? i18n._(
+        minutes > 0
+          ? plural(hours, {
+              one: `# hour ${minutesString}`,
+              other: `# hours ${minutesString}`,
+            })
+          : plural(hours, {
+              one: '# hour',
+              other: '# hours',
+            }),
+      )
+    : minutesString
+}
+
+// Trailing debounce
+export function useDebouncedValue<T>(val: T, delayMs: number): T {
+  const [prev, setPrev] = useState(val)
+
+  useEffect(() => {
+    const timeout = setTimeout(() => setPrev(val), delayMs)
+    return () => clearTimeout(timeout)
+  }, [val, delayMs])
+
+  return prev
+}
diff --git a/src/lib/actor-status.ts b/src/lib/actor-status.ts
new file mode 100644
index 000000000..30921a88a
--- /dev/null
+++ b/src/lib/actor-status.ts
@@ -0,0 +1,51 @@
+import {useMemo} from 'react'
+import {
+  type $Typed,
+  type AppBskyActorDefs,
+  type AppBskyEmbedExternal,
+} from '@atproto/api'
+import {isAfter, parseISO} from 'date-fns'
+
+import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
+import {useTickEveryMinute} from '#/state/shell'
+import {temp__canBeLive, temp__isStatusValid} from '#/components/live/temp'
+import type * as bsky from '#/types/bsky'
+
+export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
+  const shadowed = useMaybeProfileShadow(actor)
+  const tick = useTickEveryMinute()
+  return useMemo(() => {
+    tick! // revalidate every minute
+
+    if (
+      shadowed &&
+      temp__canBeLive(shadowed) &&
+      'status' in shadowed &&
+      shadowed.status &&
+      temp__isStatusValid(shadowed.status) &&
+      isStatusStillActive(shadowed.status.expiresAt)
+    ) {
+      return {
+        isActive: true,
+        status: 'app.bsky.actor.status#live',
+        embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
+        expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
+        record: shadowed.status.record,
+      } satisfies AppBskyActorDefs.StatusView
+    } else {
+      return {
+        status: '',
+        isActive: false,
+        record: {},
+      } satisfies AppBskyActorDefs.StatusView
+    }
+  }, [shadowed, tick])
+}
+
+export function isStatusStillActive(timeStr: string | undefined) {
+  if (!timeStr) return false
+  const now = new Date()
+  const expiry = parseISO(timeStr)
+
+  return isAfter(expiry, now)
+}
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
index 371062350..93d16ff0c 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -1,10 +1,10 @@
 import {
-  AppBskyFeedDefs,
-  AppBskyGraphDefs,
-  ComAtprotoRepoStrongRef,
+  type AppBskyFeedDefs,
+  type AppBskyGraphDefs,
+  type ComAtprotoRepoStrongRef,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
-import {BskyAgent} from '@atproto/api'
+import {type BskyAgent} from '@atproto/api'
 
 import {POST_IMG_MAX} from '#/lib/constants'
 import {getLinkMeta} from '#/lib/link-meta/link-meta'
@@ -22,9 +22,9 @@ import {
   isBskyStartUrl,
   isShortLink,
 } from '#/lib/strings/url-helpers'
-import {ComposerImage} from '#/state/gallery'
+import {type ComposerImage} from '#/state/gallery'
 import {createComposerImage} from '#/state/gallery'
-import {Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/state/queries/tenor'
 import {createGIFDescription} from '../gif-alt-text'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 
@@ -213,7 +213,7 @@ async function resolveExternal(
   }
 }
 
-async function imageToThumb(
+export async function imageToThumb(
   imageUri: string,
 ): Promise<ComposerImage | undefined> {
   try {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index bb98f9fc8..dca03647a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -31,6 +31,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
   'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
   'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
   'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
+  'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social
 }
 
 const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 20c3fabbc..ad194714a 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -372,3 +372,33 @@ export function getServiceAuthAudFromUrl(url: string | URL): string | null {
   }
   return `did:web:${hostname}`
 }
+
+// passes URL.parse, and has a TLD etc
+export function definitelyUrl(maybeUrl: string) {
+  try {
+    if (maybeUrl.endsWith('.')) return null
+
+    // Prepend 'https://' if the input doesn't start with a protocol
+    if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) {
+      maybeUrl = 'https://' + maybeUrl
+    }
+
+    const url = new URL(maybeUrl)
+
+    // Extract the hostname and split it into labels
+    const hostname = url.hostname
+    const labels = hostname.split('.')
+
+    // Ensure there are at least two labels (e.g., 'example' and 'com')
+    if (labels.length < 2) return null
+
+    const tld = labels[labels.length - 1]
+
+    // Check that the TLD is at least two characters long and contains only letters
+    if (!/^[a-z]{2,}$/i.test(tld)) return null
+
+    return url.toString()
+  } catch {
+    return null
+  }
+}
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 42b0d6ef3..665633d7c 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -383,4 +383,13 @@ export type MetricEvents = {
   }
   'verification:settings:hideBadges': {}
   'verification:settings:unHideBadges': {}
+
+  'live:create': {duration: number}
+  'live:edit': {}
+  'live:remove': {}
+  'live:card:open': {subject: string; from: 'post' | 'profile'}
+  'live:card:watch': {subject: string}
+  'live:card:openProfile': {subject: string}
+  'live:view:profile': {subject: string}
+  'live:view:post': {subject: string; feed?: string}
 }
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2dff101e6..1639abaf0 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -9,6 +9,7 @@ import {
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
@@ -138,6 +139,8 @@ let ProfileHeaderStandard = ({
     [currentAccount, profile],
   )
 
+  const {isActive: live} = useActorStatus(profile)
+
   return (
     <ProfileHeaderShell
       profile={profile}
@@ -228,7 +231,8 @@ let ProfileHeaderStandard = ({
           ) : null}
           <ProfileMenu profile={profile} />
         </View>
-        <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}>
+        <View
+          style={[a.flex_col, a.gap_2xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
           <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
             <Text
               emoji
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index a3efdedf5..9e868c474 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -1,24 +1,35 @@
-import React, {memo} from 'react'
+import React, {memo, useEffect} from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated'
+import {
+  type MeasuredDimensions,
+  runOnJS,
+  runOnUI,
+} from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {BACK_HITSLOP} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
 import {measureHandle, useHandleRef} from '#/lib/hooks/useHandleRef'
-import {NavigationProp} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
+import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
-import {Shadow} from '#/state/cache/types'
+import {type Shadow} from '#/state/cache/types'
 import {useLightboxControls} from '#/state/lightbox'
 import {useSession} from '#/state/session'
 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {UserBanner} from '#/view/com/util/UserBanner'
 import {atoms as a, platform, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
+import {EditLiveDialog} from '#/components/live/EditLiveDialog'
+import {LiveIndicator} from '#/components/live/LiveIndicator'
+import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
 import {GrowableAvatar} from './GrowableAvatar'
@@ -45,6 +56,8 @@ let ProfileHeaderShell = ({
   const {openLightbox} = useLightboxControls()
   const navigation = useNavigation<NavigationProp>()
   const {top: topInset} = useSafeAreaInsets()
+  const playHaptic = useHaptics()
+  const liveStatusControl = useDialogControl()
 
   const aviRef = useHandleRef()
 
@@ -79,24 +92,46 @@ let ProfileHeaderShell = ({
     [openLightbox],
   )
 
-  const onPressAvi = React.useCallback(() => {
-    const modui = moderation.ui('avatar')
-    const avatar = profile.avatar
-    if (avatar && !(modui.blur && modui.noOverride)) {
-      const aviHandle = aviRef.current
-      runOnUI(() => {
-        'worklet'
-        const rect = measureHandle(aviHandle)
-        runOnJS(_openLightbox)(avatar, rect)
-      })()
-    }
-  }, [profile, moderation, _openLightbox, aviRef])
-
   const isMe = React.useMemo(
     () => currentAccount?.did === profile.did,
     [currentAccount, profile],
   )
 
+  const live = useActorStatus(profile)
+
+  useEffect(() => {
+    if (live.isActive) {
+      logger.metric('live:view:profile', {subject: profile.did})
+    }
+  }, [live.isActive, profile.did])
+
+  const onPressAvi = React.useCallback(() => {
+    if (live.isActive) {
+      playHaptic('Light')
+      logger.metric('live:card:open', {subject: profile.did, from: 'profile'})
+      liveStatusControl.open()
+    } else {
+      const modui = moderation.ui('avatar')
+      const avatar = profile.avatar
+      if (avatar && !(modui.blur && modui.noOverride)) {
+        const aviHandle = aviRef.current
+        runOnUI(() => {
+          'worklet'
+          const rect = measureHandle(aviHandle)
+          runOnJS(_openLightbox)(avatar, rect)
+        })()
+      }
+    }
+  }, [
+    profile,
+    moderation,
+    _openLightbox,
+    aviRef,
+    liveStatusControl,
+    live,
+    playHaptic,
+  ])
+
   return (
     <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
       <View
@@ -170,21 +205,44 @@ let ProfileHeaderShell = ({
           <View
             style={[
               t.atoms.bg,
-              {borderColor: t.atoms.bg.backgroundColor},
+              a.rounded_full,
+              {
+                borderWidth: live.isActive ? 3 : 2,
+                borderColor: live.isActive
+                  ? t.palette.negative_500
+                  : t.atoms.bg.backgroundColor,
+              },
               styles.avi,
               profile.associated?.labeler && styles.aviLabeler,
             ]}>
             <View ref={aviRef} collapsable={false}>
               <UserAvatar
                 type={profile.associated?.labeler ? 'labeler' : 'user'}
-                size={90}
+                size={live.isActive ? 88 : 90}
                 avatar={profile.avatar}
                 moderation={moderation.ui('avatar')}
               />
+              {live.isActive && <LiveIndicator size="large" />}
             </View>
           </View>
         </TouchableWithoutFeedback>
       </GrowableAvatar>
+
+      {live.isActive &&
+        (isMe ? (
+          <EditLiveDialog
+            control={liveStatusControl}
+            status={live}
+            embed={live.embed}
+          />
+        ) : (
+          <LiveStatusDialog
+            control={liveStatusControl}
+            status={live}
+            embed={live.embed}
+            profile={profile}
+          />
+        ))}
     </View>
   )
 }
@@ -219,8 +277,6 @@ const styles = StyleSheet.create({
   avi: {
     width: 94,
     height: 94,
-    borderRadius: 47,
-    borderWidth: 2,
   },
   aviLabeler: {
     borderRadius: 10,
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 76eb48203..9f36c27ac 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
+import {useActorStatus} from '#/lib/actor-status'
 import {IS_INTERNAL} from '#/lib/app-info'
 import {HELP_DESK_URL} from '#/lib/constants'
 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
@@ -287,6 +288,7 @@ function ProfilePreview({
   const verificationState = useFullVerificationState({
     profile: shadow,
   })
+  const {isActive: live} = useActorStatus(profile)
 
   if (!moderationOpts) return null
 
@@ -303,6 +305,7 @@ function ProfilePreview({
         avatar={shadow.avatar}
         moderation={moderation.ui('avatar')}
         type={shadow.associated?.labeler ? 'labeler' : 'user'}
+        live={live}
       />
 
       <View
@@ -468,6 +471,7 @@ function AccountRow({
   const moderationOpts = useModerationOpts()
   const removePromptControl = Prompt.usePromptControl()
   const {removeAccount} = useSessionApi()
+  const {isActive: live} = useActorStatus(profile)
 
   const onSwitchAccount = () => {
     if (pendingDid) return
@@ -485,6 +489,8 @@ function AccountRow({
             avatar={profile.avatar}
             moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
             type={profile.associated?.labeler ? 'labeler' : 'user'}
+            live={live}
+            hideLiveBadge
           />
         ) : (
           <View style={[{width: 28}]} />
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 9c23e4550..a1212d8a2 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -31,6 +31,7 @@ export interface ProfileShadow {
   muted: boolean | undefined
   blockingUri: string | undefined
   verification: AppBskyActorDefs.VerificationState
+  status: AppBskyActorDefs.StatusView | undefined
 }
 
 const shadows: WeakMap<
@@ -138,6 +139,12 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
     },
     verification:
       'verification' in shadow ? shadow.verification : profile.verification,
+    status:
+      'status' in shadow
+        ? shadow.status
+        : 'status' in profile
+        ? profile.status
+        : undefined,
   })
 }
 
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 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