about summary refs log tree commit diff
path: root/src/components/live
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-05-10 00:06:06 +0300
committerGitHub <noreply@github.com>2025-05-10 00:06:06 +0300
commita0bd8042621e108f47e09dd096cf0d73fe1cee53 (patch)
tree0cc120c864ae8fea7f513ff242a1097ece0f1b8b /src/components/live
parent2e80fa3dac4d869640f5bce8ad43eb401c8e3141 (diff)
downloadvoidsky-a0bd8042621e108f47e09dd096cf0d73fe1cee53.tar.zst
Live (#8354)
Diffstat (limited to 'src/components/live')
-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
7 files changed, 1230 insertions, 0 deletions
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
+}