my fork of the bluesky client

Image/video border + tweaks (#5324)

* Image/video border (#5253)

* Update AutoSizedImage.tsx

* Update AutoSizedImage.tsx

* Update Gallery.tsx

* Update ExternalLinkEmbed.tsx

* Update MediaPreview.tsx

* Update UserAvatar.tsx

* Update ExternalLinkEmbed.tsx

* Update ExternalPlayerEmbed.tsx

* Update ExternalGifEmbed.tsx

* Update GifEmbed.tsx

* Update ExternalGifEmbed.tsx

* Update GifEmbed.tsx

* Update UserAvatar.tsx

* Update ExternalPlayerEmbed.tsx

* Update ExternalPlayerEmbed.tsx

* video

* Update QuoteEmbed.tsx

* Tweaks, abstract components

---------

Co-authored-by: Minseo Lee <itoupluk427@gmail.com>

authored by

Eric Bailey
Minseo Lee
and committed by
GitHub
b3381da1 c7231537

+214 -58
+11
src/components/Fill.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {atoms as a, ViewStyleProp} from '#/alf' 5 + 6 + export function Fill({ 7 + children, 8 + style, 9 + }: {children?: React.ReactNode} & ViewStyleProp) { 10 + return <View style={[a.absolute, a.inset_0, style]}>{children}</View> 11 + }
+42
src/components/MediaInsetBorder.tsx
··· 1 + import React from 'react' 2 + 3 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 4 + import {Fill} from '#/components/Fill' 5 + 6 + /** 7 + * Applies and thin border within a bounding box. Used to contrast media from 8 + * bg of the container. 9 + */ 10 + export function MediaInsetBorder({ 11 + children, 12 + style, 13 + opaque, 14 + }: { 15 + children?: React.ReactNode 16 + /** 17 + * Used where this border needs to match adjacent borders, such as in 18 + * external link previews 19 + */ 20 + opaque?: boolean 21 + } & ViewStyleProp) { 22 + const t = useTheme() 23 + const isLight = t.name === 'light' 24 + return ( 25 + <Fill 26 + style={[ 27 + a.rounded_sm, 28 + a.border, 29 + opaque 30 + ? [t.atoms.border_contrast_low] 31 + : [ 32 + isLight 33 + ? t.atoms.border_contrast_low 34 + : t.atoms.border_contrast_high, 35 + {opacity: 0.6}, 36 + ], 37 + style, 38 + ]}> 39 + {children} 40 + </Fill> 41 + ) 42 + }
+2
src/components/MediaPreview.tsx
··· 11 11 12 12 import {parseTenorGif} from '#/lib/strings/embed-player' 13 13 import {atoms as a, useTheme} from '#/alf' 14 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 14 15 import {Text} from '#/components/Typography' 15 16 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 16 17 ··· 104 105 accessibilityHint={alt} 105 106 accessibilityLabel="" 106 107 /> 108 + <MediaInsetBorder style={[a.rounded_xs]} /> 107 109 {children} 108 110 </View> 109 111 )
+3 -1
src/view/com/util/UserAvatar.tsx
··· 19 19 import {isAndroid, isNative, isWeb} from 'platform/detection' 20 20 import {precacheProfile} from 'state/queries/profile' 21 21 import {HighPriorityImage} from 'view/com/util/images/Image' 22 - import {tokens, useTheme} from '#/alf' 22 + import {atoms as a, tokens, useTheme} from '#/alf' 23 23 import { 24 24 Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, 25 25 Camera_Stroke2_Corner0_Rounded as Camera, ··· 27 27 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' 28 28 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 29 29 import {Link} from '#/components/Link' 30 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 30 31 import * as Menu from '#/components/Menu' 31 32 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 32 33 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' ··· 240 241 onLoad={onLoad} 241 242 /> 242 243 )} 244 + <MediaInsetBorder style={[a.rounded_full]} /> 243 245 {alert} 244 246 </View> 245 247 ) : (
+2
src/view/com/util/images/AutoSizedImage.tsx
··· 11 11 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 12 12 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 13 13 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 14 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 14 15 import {Text} from '#/components/Typography' 15 16 16 17 export function useImageAspectRatio({ ··· 140 141 accessibilityLabel={image.alt} 141 142 accessibilityHint="" 142 143 /> 144 + <MediaInsetBorder /> 143 145 144 146 {(hasAlt || isCropped) && !hideBadge ? ( 145 147 <View
+3 -1
src/view/com/util/images/Gallery.tsx
··· 8 8 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 9 9 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' 10 10 import {atoms as a, useTheme} from '#/alf' 11 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 11 12 import {Text} from '#/components/Typography' 12 13 13 14 type EventFunction = (index: number) => void ··· 46 47 onLongPress={onLongPress ? () => onLongPress(index) : undefined} 47 48 style={[ 48 49 a.flex_1, 49 - a.rounded_xs, 50 + a.rounded_sm, 50 51 a.overflow_hidden, 51 52 t.atoms.bg_contrast_25, 52 53 imageStyle, ··· 62 63 accessibilityHint="" 63 64 accessibilityIgnoresInvertColors 64 65 /> 66 + <MediaInsetBorder /> 65 67 </Pressable> 66 68 {hasAlt && !hideBadges ? ( 67 69 <View
+40 -16
src/view/com/util/post-embeds/ExternalGifEmbed.tsx
··· 5 5 LayoutChangeEvent, 6 6 Pressable, 7 7 StyleSheet, 8 - View, 9 8 } from 'react-native' 10 9 import {Image, ImageLoadEventData} from 'expo-image' 11 10 import {AppBskyEmbedExternal} from '@atproto/api' 12 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 11 import {msg} from '@lingui/macro' 14 12 import {useLingui} from '@lingui/react' 15 13 16 14 import {EmbedPlayerParams, getGifDims} from '#/lib/strings/embed-player' 17 15 import {isIOS, isNative, isWeb} from '#/platform/detection' 18 16 import {useExternalEmbedsPrefs} from '#/state/preferences' 17 + import {atoms as a, useTheme} from '#/alf' 19 18 import {useDialogControl} from '#/components/Dialog' 20 19 import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' 20 + import {Fill} from '#/components/Fill' 21 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 22 + import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 21 23 22 24 export function ExternalGifEmbed({ 23 25 link, ··· 26 28 link: AppBskyEmbedExternal.ViewExternal 27 29 params: EmbedPlayerParams 28 30 }) { 31 + const t = useTheme() 29 32 const externalEmbedsPrefs = useExternalEmbedsPrefs() 30 33 31 34 const {_} = useLingui() ··· 113 116 <Pressable 114 117 style={[ 115 118 {height: imageDims.height}, 116 - styles.topRadius, 117 119 styles.gifContainer, 120 + a.rounded_sm, 121 + a.overflow_hidden, 122 + { 123 + borderBottomLeftRadius: 0, 124 + borderBottomRightRadius: 0, 125 + }, 118 126 ]} 119 127 onPress={onPlayPress} 120 128 onLayout={onLayout} 121 129 accessibilityRole="button" 122 130 accessibilityHint={_(msg`Plays the GIF`)} 123 131 accessibilityLabel={_(msg`Play ${link.title}`)}> 124 - {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay 125 - <View style={[styles.layer, styles.overlayLayer]}> 126 - <View style={[styles.overlayContainer, styles.topRadius]}> 127 - {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active 128 - <FontAwesomeIcon icon="play" size={42} color="white" /> 129 - ) : ( 130 - // Activity indicator while gif loads 131 - <ActivityIndicator size="large" color="white" /> 132 - )} 133 - </View> 134 - </View> 135 - )} 136 132 <Image 137 133 source={{ 138 134 uri: ··· 150 146 accessibilityHint={link.title} 151 147 cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios 152 148 /> 149 + 150 + {(!isPrefetched || !isAnimating) && ( 151 + <Fill style={[a.align_center, a.justify_center]}> 152 + <Fill 153 + style={[ 154 + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, 155 + { 156 + opacity: 0.3, 157 + }, 158 + ]} 159 + /> 160 + 161 + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active 162 + <PlayButtonIcon /> 163 + ) : ( 164 + // Activity indicator while gif loads 165 + <ActivityIndicator size="large" color="white" /> 166 + )} 167 + </Fill> 168 + )} 169 + <MediaInsetBorder 170 + opaque 171 + style={[ 172 + { 173 + borderBottomLeftRadius: 0, 174 + borderBottomRightRadius: 0, 175 + }, 176 + ]} 177 + /> 153 178 </Pressable> 154 179 </> 155 180 ) ··· 171 196 flex: 1, 172 197 justifyContent: 'center', 173 198 alignItems: 'center', 174 - backgroundColor: 'rgba(0,0,0,0.5)', 175 199 }, 176 200 overlayLayer: { 177 201 zIndex: 2,
+34 -23
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 21 21 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' 22 22 import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed' 23 23 import {atoms as a, useTheme} from '#/alf' 24 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 24 25 import {Text} from '../text/Text' 25 26 26 27 export const ExternalLinkEmbed = ({ ··· 36 37 }) => { 37 38 const {_} = useLingui() 38 39 const pal = usePalette('default') 40 + const t = useTheme() 39 41 const {isMobile} = useWebMediaQueries() 40 42 const externalEmbedPrefs = useExternalEmbedsPrefs() 41 43 ··· 60 62 <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}> 61 63 <LinkWrapper link={link} onOpen={onOpen} style={style}> 62 64 {imageUri && !embedPlayerParams ? ( 63 - <Image 64 - style={{ 65 - aspectRatio: 1.91, 66 - borderTopRightRadius: 6, 67 - borderTopLeftRadius: 6, 68 - }} 69 - source={{uri: imageUri}} 70 - accessibilityIgnoresInvertColors 71 - accessibilityLabel={starterPackParsed ? link.title : undefined} 72 - accessibilityHint={ 73 - starterPackParsed ? _(msg`Navigate to starter pack`) : undefined 74 - } 75 - /> 65 + <View> 66 + <Image 67 + style={{ 68 + aspectRatio: 1.91, 69 + borderTopRightRadius: 8, 70 + borderTopLeftRadius: 8, 71 + }} 72 + source={{uri: imageUri}} 73 + accessibilityIgnoresInvertColors 74 + accessibilityLabel={starterPackParsed ? link.title : undefined} 75 + accessibilityHint={ 76 + starterPackParsed ? _(msg`Navigate to starter pack`) : undefined 77 + } 78 + /> 79 + <MediaInsetBorder 80 + opaque 81 + style={[ 82 + { 83 + borderBottomLeftRadius: 0, 84 + borderBottomRightRadius: 0, 85 + }, 86 + ]} 87 + /> 88 + </View> 76 89 ) : undefined} 77 90 {embedPlayerParams?.isGif ? ( 78 91 <ExternalGifEmbed link={link} params={embedPlayerParams} /> ··· 81 94 ) : undefined} 82 95 <View 83 96 style={[ 97 + a.border_b, 98 + a.border_l, 99 + a.border_r, 84 100 a.flex_1, 85 101 a.py_sm, 102 + t.atoms.border_contrast_low, 86 103 { 104 + borderBottomRightRadius: 8, 105 + borderBottomLeftRadius: 8, 87 106 paddingHorizontal: isMobile ? 10 : 14, 88 107 }, 108 + !imageUri && !embedPlayerParams && [a.border, a.rounded_sm], 89 109 ]}> 90 110 <Text 91 111 type="sm" ··· 124 144 style?: StyleProp<ViewStyle> 125 145 children: React.ReactNode 126 146 }) { 127 - const t = useTheme() 128 - 129 147 const onShareExternal = useCallback(() => { 130 148 if (link.uri && isNative) { 131 149 shareUrl(link.uri) ··· 137 155 asAnchor 138 156 anchorNoUnderline 139 157 href={link.uri} 140 - style={[ 141 - a.flex_1, 142 - a.border, 143 - a.rounded_sm, 144 - t.atoms.border_contrast_medium, 145 - style, 146 - ]} 147 - hoverStyle={t.atoms.border_contrast_high} 158 + style={[a.flex_1, a.rounded_sm, style]} 148 159 onBeforePress={onOpen} 149 160 onLongPress={onShareExternal}> 150 161 {children}
+49 -10
src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
··· 25 25 import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player' 26 26 import {isNative} from '#/platform/detection' 27 27 import {useExternalEmbedsPrefs} from '#/state/preferences' 28 - import {atoms as a} from '#/alf' 28 + import {atoms as a, useTheme} from '#/alf' 29 29 import {useDialogControl} from '#/components/Dialog' 30 30 import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent' 31 + import {Fill} from '#/components/Fill' 32 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 31 33 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 32 34 import {EventStopper} from '../EventStopper' 33 35 ··· 106 108 style={styles.webview} 107 109 setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) 108 110 /> 111 + 112 + <MediaInsetBorder 113 + opaque 114 + style={[ 115 + { 116 + borderBottomLeftRadius: 0, 117 + borderBottomRightRadius: 0, 118 + }, 119 + ]} 120 + /> 109 121 </EventStopper> 110 122 ) 111 123 } ··· 118 130 link: AppBskyEmbedExternal.ViewExternal 119 131 params: EmbedPlayerParams 120 132 }) { 133 + const t = useTheme() 121 134 const navigation = useNavigation<NavigationProp>() 122 135 const insets = useSafeAreaInsets() 123 136 const windowDims = useWindowDimensions() ··· 211 224 onAccept={onAcceptConsent} 212 225 /> 213 226 214 - <Animated.View ref={viewRef} collapsable={false} style={[aspect]}> 227 + <Animated.View 228 + ref={viewRef} 229 + collapsable={false} 230 + style={[aspect, a.rounded_sm]}> 215 231 {link.thumb && (!isPlayerActive || isLoading) && ( 216 - <Image 217 - style={[a.flex_1, styles.topRadius]} 218 - source={{uri: link.thumb}} 219 - accessibilityIgnoresInvertColors 220 - /> 232 + <> 233 + <Image 234 + style={[a.flex_1, styles.topRadius]} 235 + source={{uri: link.thumb}} 236 + accessibilityIgnoresInvertColors 237 + /> 238 + <Fill 239 + style={[ 240 + a.rounded_sm, 241 + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, 242 + { 243 + borderBottomLeftRadius: 0, 244 + borderBottomRightRadius: 0, 245 + opacity: 0.3, 246 + }, 247 + ]} 248 + /> 249 + <MediaInsetBorder 250 + opaque 251 + style={[ 252 + { 253 + borderBottomLeftRadius: 0, 254 + borderBottomRightRadius: 0, 255 + }, 256 + ]} 257 + /> 258 + </> 221 259 )} 222 260 <PlaceholderOverlay 223 261 isLoading={isLoading} ··· 236 274 237 275 const styles = StyleSheet.create({ 238 276 topRadius: { 239 - borderTopLeftRadius: 6, 240 - borderTopRightRadius: 6, 277 + borderTopLeftRadius: 8, 278 + borderTopRightRadius: 8, 241 279 }, 242 280 overlayContainer: { 243 281 flex: 1, 244 282 justifyContent: 'center', 245 283 alignItems: 'center', 246 - backgroundColor: 'rgba(0,0,0,0.5)', 247 284 }, 248 285 overlayLayer: { 249 286 zIndex: 2, ··· 252 289 zIndex: 3, 253 290 }, 254 291 webview: { 292 + borderTopRightRadius: 8, 293 + borderTopLeftRadius: 8, 255 294 backgroundColor: 'transparent', 256 295 }, 257 296 gifContainer: {
+14 -2
src/view/com/util/post-embeds/GifEmbed.tsx
··· 18 18 import {EmbedPlayerParams} from 'lib/strings/embed-player' 19 19 import {useAutoplayDisabled} from 'state/preferences' 20 20 import {atoms as a, useTheme} from '#/alf' 21 + import {Fill} from '#/components/Fill' 21 22 import {Loader} from '#/components/Loader' 23 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 22 24 import * as Prompt from '#/components/Prompt' 23 25 import {Text} from '#/components/Typography' 24 26 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' ··· 56 58 zIndex: 2, 57 59 backgroundColor: !isLoaded 58 60 ? t.atoms.bg_contrast_25.backgroundColor 59 - : !isPlaying 60 - ? 'rgba(0, 0, 0, 0.3)' 61 61 : undefined, 62 62 }, 63 63 ]} ··· 86 86 hideAlt?: boolean 87 87 style?: StyleProp<ViewStyle> 88 88 }) { 89 + const t = useTheme() 89 90 const {_} = useLingui() 90 91 const autoplayDisabled = useAutoplayDisabled() 91 92 ··· 138 139 accessibilityHint={_(msg`Animated GIF`)} 139 140 accessibilityLabel={parsedAlt.alt} 140 141 /> 142 + {!playerState.isPlaying && ( 143 + <Fill 144 + style={[ 145 + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, 146 + { 147 + opacity: 0.3, 148 + }, 149 + ]} 150 + /> 151 + )} 152 + <MediaInsetBorder /> 141 153 {!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />} 142 154 </View> 143 155 </View>
+10 -5
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 33 33 import {makeProfileLink} from 'lib/routes/links' 34 34 import {precacheProfile} from 'state/queries/profile' 35 35 import {ComposerOptsQuote} from 'state/shell/composer' 36 - import {atoms as a} from '#/alf' 36 + import {atoms as a, useTheme} from '#/alf' 37 37 import {RichText} from '#/components/RichText' 38 38 import {ContentHider} from '../../../../components/moderation/ContentHider' 39 39 import {PostAlerts} from '../../../../components/moderation/PostAlerts' ··· 56 56 allowNestedQuotes?: boolean 57 57 viewContext?: QuoteEmbedViewContext 58 58 }) { 59 + const t = useTheme() 59 60 const pal = usePalette('default') 60 61 const {currentAccount} = useSession() 61 62 if ( ··· 75 76 ) 76 77 } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { 77 78 return ( 78 - <View style={[styles.errorContainer, pal.borderDark]}> 79 + <View 80 + style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 79 81 <InfoCircleIcon size={18} style={pal.text} /> 80 82 <Text type="lg" style={pal.text}> 81 83 <Trans>Blocked</Trans> ··· 84 86 ) 85 87 } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { 86 88 return ( 87 - <View style={[styles.errorContainer, pal.borderDark]}> 89 + <View 90 + style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 88 91 <InfoCircleIcon size={18} style={pal.text} /> 89 92 <Text type="lg" style={pal.text}> 90 93 <Trans>Deleted</Trans> ··· 96 99 ? embed.record.uri.includes(currentAccount.did) 97 100 : false 98 101 return ( 99 - <View style={[styles.errorContainer, pal.borderDark]}> 102 + <View 103 + style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 100 104 <InfoCircleIcon size={18} style={pal.text} /> 101 105 <Text type="lg" style={pal.text}> 102 106 {isViewerOwner ? ( ··· 169 173 allowNestedQuotes?: boolean 170 174 viewContext?: QuoteEmbedViewContext 171 175 }) { 176 + const t = useTheme() 172 177 const queryClient = useQueryClient() 173 178 const pal = usePalette('default') 174 179 const itemUrip = new AtUri(quote.uri) ··· 214 219 return ( 215 220 <ContentHider 216 221 modui={moderation?.ui('contentList')} 217 - style={[styles.container, pal.borderDark, style]} 222 + style={[styles.container, a.border, t.atoms.border_contrast_low, style]} 218 223 childContainerStyle={[a.pt_sm]}> 219 224 <Link 220 225 hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+2
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 13 13 import {atoms as a, useTheme} from '#/alf' 14 14 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 15 15 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 16 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 16 17 import { 17 18 AudioCategory, 18 19 PlatformInfo, ··· 84 85 isMuted={isMuted} 85 86 timeRemaining={timeRemaining} 86 87 /> 88 + <MediaInsetBorder /> 87 89 </View> 88 90 ) 89 91 }
+2
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 4 4 import Hls from 'hls.js' 5 5 6 6 import {atoms as a} from '#/alf' 7 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 7 8 import {Controls} from './VideoWebControls' 8 9 9 10 export function VideoEmbedInnerWeb({ ··· 119 120 fullscreenRef={containerRef} 120 121 hasSubtitleTrack={hasSubtitleTrack} 121 122 /> 123 + <MediaInsetBorder /> 122 124 </div> 123 125 </View> 124 126 )