From a18b25d16c7ff4e5233cc6ca45511ba42b12f55f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 20 May 2025 18:49:20 +0300 Subject: [Live] Add warning if link is missing image (#8393) --- src/components/Admonition.tsx | 12 ++-- src/components/live/EditLiveDialog.tsx | 102 ++--------------------------- src/components/live/GoLiveDialog.tsx | 114 ++------------------------------- src/components/live/LinkPreview.tsx | 98 ++++++++++++++++++++++++++++ src/components/live/queries.ts | 30 ++++++++- 5 files changed, 141 insertions(+), 215 deletions(-) create mode 100644 src/components/live/LinkPreview.tsx (limited to 'src') diff --git a/src/components/Admonition.tsx b/src/components/Admonition.tsx index 8df4934be..cdb1f0b8b 100644 --- a/src/components/Admonition.tsx +++ b/src/components/Admonition.tsx @@ -1,13 +1,13 @@ -import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {createContext, useContext} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' 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 {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' 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 = { warning: { @@ -20,13 +20,13 @@ type Context = { type: 'info' | 'tip' | 'warning' | 'error' } -const Context = React.createContext({ +const Context = createContext({ type: 'info', }) export function Icon() { const t = useTheme() - const {type} = React.useContext(Context) + const {type} = useContext(Context) const Icon = { info: InfoIcon, tip: TipIcon, 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({ )} - {(linkMeta || linkMetaLoading) && ( - - {(!linkMeta || linkMeta.image) && ( - - {linkMeta?.image && ( - setImageLoadError(false)} - onError={() => setImageLoadError(true)} - /> - )} - {linkMeta && imageLoadError && ( - - )} - - )} - - {linkMeta ? ( - <> - - {linkMeta.title || linkMeta.url} - - - - - {toNiceDomain(linkMeta.url)} - - - - ) : ( - <> - - - - )} - - - )} + {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}) { )} - {(linkMeta || linkMetaLoading) && ( - - {(!linkMeta || linkMeta.image) && ( - - {linkMeta?.image && ( - setImageLoadError(false)} - onError={() => setImageLoadError(true)} - /> - )} - {linkMeta && imageLoadError && ( - - )} - - )} - - {linkMeta ? ( - <> - - {linkMeta.title || linkMeta.url} - - - - - {toNiceDomain(linkMeta.url)} - - - - ) : ( - <> - - - - )} - - - )} + {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 ( + + + {linkMeta?.image && ( + setImageLoadError(false)} + onError={() => setImageLoadError(true)} + /> + )} + {linkMeta && (!linkMeta.image || imageLoadError) && ( + <> + + + No image + + + )} + + + {linkMeta ? ( + <> + + {linkMeta.title || linkMeta.url} + + + + + {toNiceDomain(linkMeta.url)} + + + + ) : ( + <> + + + + )} + + + ) +} 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, -- cgit 1.4.1