about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-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
18 files changed, 1401 insertions, 24 deletions
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
+}