[Live] Add warning if link is missing image (#8393)

This commit is contained in:
Samuel Newman
2025-05-20 18:49:20 +03:00
committed by Hailey
parent c323f628ae
commit 64eed158b7
5 changed files with 141 additions and 215 deletions
+6 -6
View File
@@ -1,13 +1,13 @@
import React from 'react' import {createContext, useContext} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button as BaseButton, ButtonProps} from '#/components/Button' import {Button as BaseButton, type ButtonProps} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Text as BaseText, TextProps} from '#/components/Typography' import {Text as BaseText, type TextProps} from '#/components/Typography'
export const colors = { export const colors = {
warning: { warning: {
@@ -20,13 +20,13 @@ type Context = {
type: 'info' | 'tip' | 'warning' | 'error' type: 'info' | 'tip' | 'warning' | 'error'
} }
const Context = React.createContext<Context>({ const Context = createContext<Context>({
type: 'info', type: 'info',
}) })
export function Icon() { export function Icon() {
const t = useTheme() const t = useTheme()
const {type} = React.useContext(Context) const {type} = useContext(Context)
const Icon = { const Icon = {
info: InfoIcon, info: InfoIcon,
tip: TipIcon, tip: TipIcon,
+5 -97
View File
@@ -1,6 +1,5 @@
import {useMemo, useState} from 'react' import {useMemo, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
AppBskyActorStatus, AppBskyActorStatus,
@@ -8,28 +7,23 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {differenceInMinutes} from 'date-fns' import {differenceInMinutes} from 'date-fns'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {definitelyUrl} from '#/lib/strings/url-helpers' import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, platform, useTheme, web} from '#/alf' import {atoms as a, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' 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 {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 {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
import { import {
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation, useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation, useUpsertLiveStatusMutation,
} from './queries' } from './queries'
@@ -62,10 +56,9 @@ function DialogInner({
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const agent = useAgent()
const [liveLink, setLiveLink] = useState(embed.external.uri) const [liveLink, setLiveLink] = useState(embed.external.uri)
const [liveLinkError, setLiveLinkError] = useState('') const [liveLinkError, setLiveLinkError] = useState('')
const [imageLoadError, setImageLoadError] = useState(false)
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const liveLinkUrl = definitelyUrl(liveLink) const liveLinkUrl = definitelyUrl(liveLink)
@@ -78,14 +71,7 @@ function DialogInner({
isSuccess: hasValidLinkMeta, isSuccess: hasValidLinkMeta,
isLoading: linkMetaLoading, isLoading: linkMetaLoading,
error: linkMetaError, error: linkMetaError,
} = useQuery({ } = useLiveLinkMetaQuery(debouncedUrl)
enabled: !!debouncedUrl,
queryKey: ['link-meta', debouncedUrl],
queryFn: async () => {
if (!debouncedUrl) return null
return getLinkMeta(agent, debouncedUrl)
},
})
const record = useMemo(() => { const record = useMemo(() => {
if (!AppBskyActorStatus.isRecord(status.record)) return null if (!AppBskyActorStatus.isRecord(status.record)) return null
@@ -208,85 +194,7 @@ function DialogInner({
</View> </View>
)} )}
{(linkMeta || linkMetaLoading) && ( <LinkPreview linkMeta={linkMeta} loading={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> </View>
{goLiveError && ( {goLiveError && (
+4 -110
View File
@@ -1,33 +1,25 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {cleanError} from '#/lib/strings/errors'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {definitelyUrl} from '#/lib/strings/url-helpers' import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' 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 {useTickEveryMinute} from '#/state/shell'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' 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 {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select' import * as Select from '#/components/Select'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' 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' import {displayDuration, useDebouncedValue} from './utils'
export function GoLiveDialog({ export function GoLiveDialog({
@@ -52,17 +44,11 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const agent = useAgent()
const [liveLink, setLiveLink] = useState('') const [liveLink, setLiveLink] = useState('')
const [liveLinkError, setLiveLinkError] = useState('') const [liveLinkError, setLiveLinkError] = useState('')
const [imageLoadError, setImageLoadError] = useState(false)
const [duration, setDuration] = useState(60) const [duration, setDuration] = useState(60)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const liveNowConfig = useLiveNowConfig()
const {currentAccount} = useSession()
const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
const time = useCallback( const time = useCallback(
(offset: number) => { (offset: number) => {
@@ -90,21 +76,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
isSuccess: hasValidLinkMeta, isSuccess: hasValidLinkMeta,
isLoading: linkMetaLoading, isLoading: linkMetaLoading,
error: linkMetaError, error: linkMetaError,
} = useQuery({ } = useLiveLinkMetaQuery(debouncedUrl)
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)
},
})
const { const {
mutate: goLive, mutate: goLive,
@@ -193,85 +165,7 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
</View> </View>
)} )}
{(linkMeta || linkMetaLoading) && ( <LinkPreview linkMeta={linkMeta} loading={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> </View>
{hasLink && ( {hasLink && (
+98
View File
@@ -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>
)
}
+28 -2
View File
@@ -7,17 +7,43 @@ import {
import {retry} from '@atproto/common-web' import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve' 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 {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow' import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog' 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( export function useUpsertLiveStatusMutation(
duration: number, duration: number,
linkMeta: LinkMeta | null | undefined, linkMeta: LinkMeta | null | undefined,