Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub a18b25d1 c7101870

+141 -215
+6 -6
src/components/Admonition.tsx
··· 1 - import React from 'react' 2 - import {StyleProp, View, ViewStyle} from 'react-native' 1 + import {createContext, useContext} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 3 4 4 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 5 - import {Button as BaseButton, ButtonProps} from '#/components/Button' 5 + import {Button as BaseButton, type ButtonProps} from '#/components/Button' 6 6 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 7 7 import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye' 8 8 import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf' 9 9 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 10 - import {Text as BaseText, TextProps} from '#/components/Typography' 10 + import {Text as BaseText, type TextProps} from '#/components/Typography' 11 11 12 12 export const colors = { 13 13 warning: { ··· 20 20 type: 'info' | 'tip' | 'warning' | 'error' 21 21 } 22 22 23 - const Context = React.createContext<Context>({ 23 + const Context = createContext<Context>({ 24 24 type: 'info', 25 25 }) 26 26 27 27 export function Icon() { 28 28 const t = useTheme() 29 - const {type} = React.useContext(Context) 29 + const {type} = useContext(Context) 30 30 const Icon = { 31 31 info: InfoIcon, 32 32 tip: TipIcon,
+5 -97
src/components/live/EditLiveDialog.tsx
··· 1 1 import {useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 - import {Image} from 'expo-image' 4 3 import { 5 4 type AppBskyActorDefs, 6 5 AppBskyActorStatus, ··· 8 7 } from '@atproto/api' 9 8 import {msg, Trans} from '@lingui/macro' 10 9 import {useLingui} from '@lingui/react' 11 - import {useQuery} from '@tanstack/react-query' 12 10 import {differenceInMinutes} from 'date-fns' 13 11 14 - import {getLinkMeta} from '#/lib/link-meta/link-meta' 15 12 import {cleanError} from '#/lib/strings/errors' 16 - import {toNiceDomain} from '#/lib/strings/url-helpers' 17 13 import {definitelyUrl} from '#/lib/strings/url-helpers' 18 - import {useAgent} from '#/state/session' 19 14 import {useTickEveryMinute} from '#/state/shell' 20 - import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 21 15 import {atoms as a, platform, useTheme, web} from '#/alf' 22 16 import {Admonition} from '#/components/Admonition' 23 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24 18 import * as Dialog from '#/components/Dialog' 25 19 import * as TextField from '#/components/forms/TextField' 26 - import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 27 20 import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 28 - import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 29 21 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 30 22 import {Loader} from '#/components/Loader' 31 23 import {Text} from '#/components/Typography' 24 + import {LinkPreview} from './LinkPreview' 32 25 import { 26 + useLiveLinkMetaQuery, 33 27 useRemoveLiveStatusMutation, 34 28 useUpsertLiveStatusMutation, 35 29 } from './queries' ··· 62 56 const control = Dialog.useDialogContext() 63 57 const {_, i18n} = useLingui() 64 58 const t = useTheme() 65 - const agent = useAgent() 59 + 66 60 const [liveLink, setLiveLink] = useState(embed.external.uri) 67 61 const [liveLinkError, setLiveLinkError] = useState('') 68 - const [imageLoadError, setImageLoadError] = useState(false) 69 62 const tick = useTickEveryMinute() 70 63 71 64 const liveLinkUrl = definitelyUrl(liveLink) ··· 78 71 isSuccess: hasValidLinkMeta, 79 72 isLoading: linkMetaLoading, 80 73 error: linkMetaError, 81 - } = useQuery({ 82 - enabled: !!debouncedUrl, 83 - queryKey: ['link-meta', debouncedUrl], 84 - queryFn: async () => { 85 - if (!debouncedUrl) return null 86 - return getLinkMeta(agent, debouncedUrl) 87 - }, 88 - }) 74 + } = useLiveLinkMetaQuery(debouncedUrl) 89 75 90 76 const record = useMemo(() => { 91 77 if (!AppBskyActorStatus.isRecord(status.record)) return null ··· 208 194 </View> 209 195 )} 210 196 211 - {(linkMeta || linkMetaLoading) && ( 212 - <View 213 - style={[ 214 - a.w_full, 215 - a.border, 216 - t.atoms.border_contrast_low, 217 - t.atoms.bg, 218 - a.flex_row, 219 - a.rounded_sm, 220 - a.overflow_hidden, 221 - a.align_stretch, 222 - ]}> 223 - {(!linkMeta || linkMeta.image) && ( 224 - <View 225 - style={[ 226 - t.atoms.bg_contrast_25, 227 - {minHeight: 64, width: 114}, 228 - a.justify_center, 229 - a.align_center, 230 - ]}> 231 - {linkMeta?.image && ( 232 - <Image 233 - source={linkMeta.image} 234 - accessibilityIgnoresInvertColors 235 - transition={200} 236 - style={[a.absolute, a.inset_0]} 237 - contentFit="cover" 238 - onLoad={() => setImageLoadError(false)} 239 - onError={() => setImageLoadError(true)} 240 - /> 241 - )} 242 - {linkMeta && imageLoadError && ( 243 - <CircleXIcon 244 - style={[t.atoms.text_contrast_low]} 245 - size="xl" 246 - /> 247 - )} 248 - </View> 249 - )} 250 - <View 251 - style={[ 252 - a.flex_1, 253 - a.justify_center, 254 - a.py_sm, 255 - a.gap_xs, 256 - a.px_md, 257 - ]}> 258 - {linkMeta ? ( 259 - <> 260 - <Text 261 - numberOfLines={2} 262 - style={[a.leading_snug, a.font_bold, a.text_md]}> 263 - {linkMeta.title || linkMeta.url} 264 - </Text> 265 - <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 266 - <GlobeIcon 267 - size="xs" 268 - style={[t.atoms.text_contrast_low]} 269 - /> 270 - <Text 271 - numberOfLines={1} 272 - style={[ 273 - a.text_xs, 274 - a.leading_snug, 275 - t.atoms.text_contrast_medium, 276 - ]}> 277 - {toNiceDomain(linkMeta.url)} 278 - </Text> 279 - </View> 280 - </> 281 - ) : ( 282 - <> 283 - <LoadingPlaceholder height={16} width={128} /> 284 - <LoadingPlaceholder height={12} width={72} /> 285 - </> 286 - )} 287 - </View> 288 - </View> 289 - )} 197 + <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} /> 290 198 </View> 291 199 292 200 {goLiveError && (
+4 -110
src/components/live/GoLiveDialog.tsx
··· 1 1 import {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 - import {Image} from 'expo-image' 4 3 import {msg, Trans} from '@lingui/macro' 5 4 import {useLingui} from '@lingui/react' 6 - import {useQuery} from '@tanstack/react-query' 7 5 8 - import {getLinkMeta} from '#/lib/link-meta/link-meta' 9 6 import {cleanError} from '#/lib/strings/errors' 10 - import {toNiceDomain} from '#/lib/strings/url-helpers' 11 7 import {definitelyUrl} from '#/lib/strings/url-helpers' 12 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 - import {useLiveNowConfig} from '#/state/service-config' 14 - import {useAgent, useSession} from '#/state/session' 15 9 import {useTickEveryMinute} from '#/state/shell' 16 - import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 17 10 import {atoms as a, ios, native, platform, useTheme, web} from '#/alf' 18 11 import {Admonition} from '#/components/Admonition' 19 12 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 20 13 import * as Dialog from '#/components/Dialog' 21 14 import * as TextField from '#/components/forms/TextField' 22 - import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 23 - import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 24 15 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 25 16 import {Loader} from '#/components/Loader' 26 17 import * as ProfileCard from '#/components/ProfileCard' 27 18 import * as Select from '#/components/Select' 28 19 import {Text} from '#/components/Typography' 29 20 import type * as bsky from '#/types/bsky' 30 - import {useUpsertLiveStatusMutation} from './queries' 21 + import {LinkPreview} from './LinkPreview' 22 + import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries' 31 23 import {displayDuration, useDebouncedValue} from './utils' 32 24 33 25 export function GoLiveDialog({ ··· 52 44 const control = Dialog.useDialogContext() 53 45 const {_, i18n} = useLingui() 54 46 const t = useTheme() 55 - const agent = useAgent() 56 47 const [liveLink, setLiveLink] = useState('') 57 48 const [liveLinkError, setLiveLinkError] = useState('') 58 - const [imageLoadError, setImageLoadError] = useState(false) 59 49 const [duration, setDuration] = useState(60) 60 50 const moderationOpts = useModerationOpts() 61 51 const tick = useTickEveryMinute() 62 - const liveNowConfig = useLiveNowConfig() 63 - const {currentAccount} = useSession() 64 - 65 - const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did) 66 52 67 53 const time = useCallback( 68 54 (offset: number) => { ··· 90 76 isSuccess: hasValidLinkMeta, 91 77 isLoading: linkMetaLoading, 92 78 error: linkMetaError, 93 - } = useQuery({ 94 - enabled: !!debouncedUrl, 95 - queryKey: ['link-meta', debouncedUrl], 96 - queryFn: async () => { 97 - if (!debouncedUrl) return null 98 - if (!config) throw new Error(_(msg`You are not allowed to go live`)) 99 - 100 - const urlp = new URL(debouncedUrl) 101 - if (!config.domains.includes(urlp.hostname)) { 102 - throw new Error(_(msg`${urlp.hostname} is not a valid URL`)) 103 - } 104 - 105 - return getLinkMeta(agent, debouncedUrl) 106 - }, 107 - }) 79 + } = useLiveLinkMetaQuery(debouncedUrl) 108 80 109 81 const { 110 82 mutate: goLive, ··· 193 165 </View> 194 166 )} 195 167 196 - {(linkMeta || linkMetaLoading) && ( 197 - <View 198 - style={[ 199 - a.w_full, 200 - a.border, 201 - t.atoms.border_contrast_low, 202 - t.atoms.bg, 203 - a.flex_row, 204 - a.rounded_sm, 205 - a.overflow_hidden, 206 - a.align_stretch, 207 - ]}> 208 - {(!linkMeta || linkMeta.image) && ( 209 - <View 210 - style={[ 211 - t.atoms.bg_contrast_25, 212 - {minHeight: 64, width: 114}, 213 - a.justify_center, 214 - a.align_center, 215 - ]}> 216 - {linkMeta?.image && ( 217 - <Image 218 - source={linkMeta.image} 219 - accessibilityIgnoresInvertColors 220 - transition={200} 221 - style={[a.absolute, a.inset_0]} 222 - contentFit="cover" 223 - onLoad={() => setImageLoadError(false)} 224 - onError={() => setImageLoadError(true)} 225 - /> 226 - )} 227 - {linkMeta && imageLoadError && ( 228 - <CircleXIcon 229 - style={[t.atoms.text_contrast_low]} 230 - size="xl" 231 - /> 232 - )} 233 - </View> 234 - )} 235 - <View 236 - style={[ 237 - a.flex_1, 238 - a.justify_center, 239 - a.py_sm, 240 - a.gap_xs, 241 - a.px_md, 242 - ]}> 243 - {linkMeta ? ( 244 - <> 245 - <Text 246 - numberOfLines={2} 247 - style={[a.leading_snug, a.font_bold, a.text_md]}> 248 - {linkMeta.title || linkMeta.url} 249 - </Text> 250 - <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 251 - <GlobeIcon 252 - size="xs" 253 - style={[t.atoms.text_contrast_low]} 254 - /> 255 - <Text 256 - numberOfLines={1} 257 - style={[ 258 - a.text_xs, 259 - a.leading_snug, 260 - t.atoms.text_contrast_medium, 261 - ]}> 262 - {toNiceDomain(linkMeta.url)} 263 - </Text> 264 - </View> 265 - </> 266 - ) : ( 267 - <> 268 - <LoadingPlaceholder height={16} width={128} /> 269 - <LoadingPlaceholder height={12} width={72} /> 270 - </> 271 - )} 272 - </View> 273 - </View> 274 - )} 168 + <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} /> 275 169 </View> 276 170 277 171 {hasLink && (
+98
src/components/live/LinkPreview.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {Trans} from '@lingui/macro' 5 + 6 + import {type LinkMeta} from '#/lib/link-meta/link-meta' 7 + import {toNiceDomain} from '#/lib/strings/url-helpers' 8 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 11 + import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 12 + import {Text} from '#/components/Typography' 13 + 14 + export function LinkPreview({ 15 + linkMeta, 16 + loading, 17 + }: { 18 + linkMeta?: LinkMeta 19 + loading: boolean 20 + }) { 21 + const t = useTheme() 22 + const [imageLoadError, setImageLoadError] = useState(false) 23 + 24 + if (!linkMeta && !loading) { 25 + return null 26 + } 27 + 28 + return ( 29 + <View 30 + style={[ 31 + a.w_full, 32 + a.border, 33 + t.atoms.border_contrast_low, 34 + t.atoms.bg, 35 + a.flex_row, 36 + a.rounded_sm, 37 + a.overflow_hidden, 38 + a.align_stretch, 39 + ]}> 40 + <View 41 + style={[ 42 + t.atoms.bg_contrast_25, 43 + {minHeight: 64, width: 114}, 44 + a.justify_center, 45 + a.align_center, 46 + a.gap_xs, 47 + ]}> 48 + {linkMeta?.image && ( 49 + <Image 50 + source={linkMeta.image} 51 + accessibilityIgnoresInvertColors 52 + transition={200} 53 + style={[a.absolute, a.inset_0]} 54 + contentFit="cover" 55 + onLoad={() => setImageLoadError(false)} 56 + onError={() => setImageLoadError(true)} 57 + /> 58 + )} 59 + {linkMeta && (!linkMeta.image || imageLoadError) && ( 60 + <> 61 + <ImageIcon style={[t.atoms.text_contrast_low]} /> 62 + <Text style={[t.atoms.text_contrast_low, a.text_xs, a.text_center]}> 63 + <Trans>No image</Trans> 64 + </Text> 65 + </> 66 + )} 67 + </View> 68 + <View style={[a.flex_1, a.justify_center, a.py_sm, a.gap_xs, a.px_md]}> 69 + {linkMeta ? ( 70 + <> 71 + <Text 72 + numberOfLines={2} 73 + style={[a.leading_snug, a.font_bold, a.text_md]}> 74 + {linkMeta.title || linkMeta.url} 75 + </Text> 76 + <View style={[a.flex_row, a.align_center, a.gap_2xs]}> 77 + <GlobeIcon size="xs" style={[t.atoms.text_contrast_low]} /> 78 + <Text 79 + numberOfLines={1} 80 + style={[ 81 + a.text_xs, 82 + a.leading_snug, 83 + t.atoms.text_contrast_medium, 84 + ]}> 85 + {toNiceDomain(linkMeta.url)} 86 + </Text> 87 + </View> 88 + </> 89 + ) : ( 90 + <> 91 + <LoadingPlaceholder height={16} width={128} /> 92 + <LoadingPlaceholder height={12} width={72} /> 93 + </> 94 + )} 95 + </View> 96 + </View> 97 + ) 98 + }
+28 -2
src/components/live/queries.ts
··· 7 7 import {retry} from '@atproto/common-web' 8 8 import {msg} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 - import {useMutation, useQueryClient} from '@tanstack/react-query' 10 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 11 11 12 12 import {uploadBlob} from '#/lib/api' 13 13 import {imageToThumb} from '#/lib/api/resolve' 14 - import {type LinkMeta} from '#/lib/link-meta/link-meta' 14 + import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 15 15 import {logger} from '#/logger' 16 16 import {updateProfileShadow} from '#/state/cache/profile-shadow' 17 + import {useLiveNowConfig} from '#/state/service-config' 17 18 import {useAgent, useSession} from '#/state/session' 18 19 import * as Toast from '#/view/com/util/Toast' 19 20 import {useDialogContext} from '#/components/Dialog' 21 + 22 + export function useLiveLinkMetaQuery(url: string | null) { 23 + const liveNowConfig = useLiveNowConfig() 24 + const {currentAccount} = useSession() 25 + const {_} = useLingui() 26 + 27 + const agent = useAgent() 28 + return useQuery({ 29 + enabled: !!url, 30 + queryKey: ['link-meta', url], 31 + queryFn: async () => { 32 + if (!url) return undefined 33 + const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did) 34 + 35 + if (!config) throw new Error(_(msg`You are not allowed to go live`)) 36 + 37 + const urlp = new URL(url) 38 + if (!config.domains.includes(urlp.hostname)) { 39 + throw new Error(_(msg`${urlp.hostname} is not a valid URL`)) 40 + } 41 + 42 + return await getLinkMeta(agent, url) 43 + }, 44 + }) 45 + } 20 46 21 47 export function useUpsertLiveStatusMutation( 22 48 duration: number,