about summary refs log tree commit diff
path: root/src/components/live/EditLiveDialog.tsx
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/EditLiveDialog.tsx
parent2e80fa3dac4d869640f5bce8ad43eb401c8e3141 (diff)
downloadvoidsky-a0bd8042621e108f47e09dd096cf0d73fe1cee53.tar.zst
Live (#8354)
Diffstat (limited to 'src/components/live/EditLiveDialog.tsx')
-rw-r--r--src/components/live/EditLiveDialog.tsx348
1 files changed, 348 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>
+  )
+}