about summary refs log tree commit diff
path: root/src/components/live
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-05-20 18:49:20 +0300
committerGitHub <noreply@github.com>2025-05-20 18:49:20 +0300
commita18b25d16c7ff4e5233cc6ca45511ba42b12f55f (patch)
tree6aca1c86eac4b69fdc1aecd3f081e7e5135c7ebb /src/components/live
parentc7101870944a34f874fd80b18c16e38e24d6b51b (diff)
downloadvoidsky-a18b25d16c7ff4e5233cc6ca45511ba42b12f55f.tar.zst
[Live] Add warning if link is missing image (#8393)
Diffstat (limited to 'src/components/live')
-rw-r--r--src/components/live/EditLiveDialog.tsx102
-rw-r--r--src/components/live/GoLiveDialog.tsx114
-rw-r--r--src/components/live/LinkPreview.tsx98
-rw-r--r--src/components/live/queries.ts30
4 files changed, 135 insertions, 209 deletions
diff --git a/src/components/live/EditLiveDialog.tsx b/src/components/live/EditLiveDialog.tsx
index 36c292cb5..cdffb3286 100644
--- a/src/components/live/EditLiveDialog.tsx
+++ b/src/components/live/EditLiveDialog.tsx
@@ -1,6 +1,5 @@
 import {useMemo, useState} from 'react'
 import {View} from 'react-native'
-import {Image} from 'expo-image'
 import {
   type AppBskyActorDefs,
   AppBskyActorStatus,
@@ -8,28 +7,23 @@ import {
 } 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 {LinkPreview} from './LinkPreview'
 import {
+  useLiveLinkMetaQuery,
   useRemoveLiveStatusMutation,
   useUpsertLiveStatusMutation,
 } from './queries'
@@ -62,10 +56,9 @@ function DialogInner({
   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)
@@ -78,14 +71,7 @@ function DialogInner({
     isSuccess: hasValidLinkMeta,
     isLoading: linkMetaLoading,
     error: linkMetaError,
-  } = useQuery({
-    enabled: !!debouncedUrl,
-    queryKey: ['link-meta', debouncedUrl],
-    queryFn: async () => {
-      if (!debouncedUrl) return null
-      return getLinkMeta(agent, debouncedUrl)
-    },
-  })
+  } = useLiveLinkMetaQuery(debouncedUrl)
 
   const record = useMemo(() => {
     if (!AppBskyActorStatus.isRecord(status.record)) return null
@@ -208,85 +194,7 @@ function DialogInner({
             </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>
-          )}
+          <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
         </View>
 
         {goLiveError && (
diff --git a/src/components/live/GoLiveDialog.tsx b/src/components/live/GoLiveDialog.tsx
index 027447272..1c5fa27a7 100644
--- a/src/components/live/GoLiveDialog.tsx
+++ b/src/components/live/GoLiveDialog.tsx
@@ -1,33 +1,25 @@
 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 {useLiveNowConfig} from '#/state/service-config'
-import {useAgent, useSession} 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 {LinkPreview} from './LinkPreview'
+import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
 import {displayDuration, useDebouncedValue} from './utils'
 
 export function GoLiveDialog({
@@ -52,17 +44,11 @@ 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 liveNowConfig = useLiveNowConfig()
-  const {currentAccount} = useSession()
-
-  const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
 
   const time = useCallback(
     (offset: number) => {
@@ -90,21 +76,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
     isSuccess: hasValidLinkMeta,
     isLoading: linkMetaLoading,
     error: linkMetaError,
-  } = useQuery({
-    enabled: !!debouncedUrl,
-    queryKey: ['link-meta', debouncedUrl],
-    queryFn: async () => {
-      if (!debouncedUrl) return null
-      if (!config) throw new Error(_(msg`You are not allowed to go live`))
-
-      const urlp = new URL(debouncedUrl)
-      if (!config.domains.includes(urlp.hostname)) {
-        throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
-      }
-
-      return getLinkMeta(agent, debouncedUrl)
-    },
-  })
+  } = useLiveLinkMetaQuery(debouncedUrl)
 
   const {
     mutate: goLive,
@@ -193,85 +165,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
             </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>
-          )}
+          <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
         </View>
 
         {hasLink && (
diff --git a/src/components/live/LinkPreview.tsx b/src/components/live/LinkPreview.tsx
new file mode 100644
index 000000000..98320a9e8
--- /dev/null
+++ b/src/components/live/LinkPreview.tsx
@@ -0,0 +1,98 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {Trans} from '@lingui/macro'
+
+import {type LinkMeta} from '#/lib/link-meta/link-meta'
+import {toNiceDomain} from '#/lib/strings/url-helpers'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {atoms as a, useTheme} from '#/alf'
+import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
+import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
+import {Text} from '#/components/Typography'
+
+export function LinkPreview({
+  linkMeta,
+  loading,
+}: {
+  linkMeta?: LinkMeta
+  loading: boolean
+}) {
+  const t = useTheme()
+  const [imageLoadError, setImageLoadError] = useState(false)
+
+  if (!linkMeta && !loading) {
+    return null
+  }
+
+  return (
+    <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,
+      ]}>
+      <View
+        style={[
+          t.atoms.bg_contrast_25,
+          {minHeight: 64, width: 114},
+          a.justify_center,
+          a.align_center,
+          a.gap_xs,
+        ]}>
+        {linkMeta?.image && (
+          <Image
+            source={linkMeta.image}
+            accessibilityIgnoresInvertColors
+            transition={200}
+            style={[a.absolute, a.inset_0]}
+            contentFit="cover"
+            onLoad={() => setImageLoadError(false)}
+            onError={() => setImageLoadError(true)}
+          />
+        )}
+        {linkMeta && (!linkMeta.image || imageLoadError) && (
+          <>
+            <ImageIcon style={[t.atoms.text_contrast_low]} />
+            <Text style={[t.atoms.text_contrast_low, a.text_xs, a.text_center]}>
+              <Trans>No image</Trans>
+            </Text>
+          </>
+        )}
+      </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>
+  )
+}
diff --git a/src/components/live/queries.ts b/src/components/live/queries.ts
index 1958ab49d..08cb0fc5a 100644
--- a/src/components/live/queries.ts
+++ b/src/components/live/queries.ts
@@ -7,17 +7,43 @@ import {
 import {retry} from '@atproto/common-web'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useMutation, useQueryClient} from '@tanstack/react-query'
+import {useMutation, useQuery, 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 {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
 import {logger} from '#/logger'
 import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {useLiveNowConfig} from '#/state/service-config'
 import {useAgent, useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {useDialogContext} from '#/components/Dialog'
 
+export function useLiveLinkMetaQuery(url: string | null) {
+  const liveNowConfig = useLiveNowConfig()
+  const {currentAccount} = useSession()
+  const {_} = useLingui()
+
+  const agent = useAgent()
+  return useQuery({
+    enabled: !!url,
+    queryKey: ['link-meta', url],
+    queryFn: async () => {
+      if (!url) return undefined
+      const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
+
+      if (!config) throw new Error(_(msg`You are not allowed to go live`))
+
+      const urlp = new URL(url)
+      if (!config.domains.includes(urlp.hostname)) {
+        throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
+      }
+
+      return await getLinkMeta(agent, url)
+    },
+  })
+}
+
 export function useUpsertLiveStatusMutation(
   duration: number,
   linkMeta: LinkMeta | null | undefined,