An ATproto social media client -- with an independent Appview.
at main 3.9 kB view raw
1import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' 2import {Image} from 'expo-image' 3import {type AppBskyFeedDefs} from '@atproto/api' 4import {Trans} from '@lingui/macro' 5 6import {isTenorGifUri} from '#/lib/strings/embed-player' 7import {atoms as a, useTheme} from '#/alf' 8import {MediaInsetBorder} from '#/components/MediaInsetBorder' 9import {Text} from '#/components/Typography' 10import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 11import * as bsky from '#/types/bsky' 12 13/** 14 * Streamlined MediaPreview component which just handles images, gifs, and videos 15 */ 16export function Embed({ 17 embed, 18 style, 19}: { 20 embed: AppBskyFeedDefs.PostView['embed'] 21 style?: StyleProp<ViewStyle> 22}) { 23 const e = bsky.post.parseEmbed(embed) 24 25 if (!e) return null 26 27 if (e.type === 'images') { 28 return ( 29 <Outer style={style}> 30 {e.view.images.map(image => ( 31 <ImageItem 32 key={image.thumb} 33 thumbnail={image.thumb} 34 alt={image.alt} 35 /> 36 ))} 37 </Outer> 38 ) 39 } else if (e.type === 'link') { 40 if (!e.view.external.thumb) return null 41 if (!isTenorGifUri(e.view.external.uri)) return null 42 return ( 43 <Outer style={style}> 44 <GifItem 45 thumbnail={e.view.external.thumb} 46 alt={e.view.external.title} 47 /> 48 </Outer> 49 ) 50 } else if (e.type === 'video') { 51 return ( 52 <Outer style={style}> 53 <VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 54 </Outer> 55 ) 56 } else if ( 57 e.type === 'post_with_media' && 58 // ignore further "nested" RecordWithMedia 59 e.media.type !== 'post_with_media' && 60 // ignore any unknowns 61 e.media.view !== null 62 ) { 63 return <Embed embed={e.media.view} style={style} /> 64 } 65 66 return null 67} 68 69export function Outer({ 70 children, 71 style, 72}: { 73 children?: React.ReactNode 74 style?: StyleProp<ViewStyle> 75}) { 76 return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View> 77} 78 79export function ImageItem({ 80 thumbnail, 81 alt, 82 children, 83}: { 84 thumbnail: string 85 alt?: string 86 children?: React.ReactNode 87}) { 88 const t = useTheme() 89 return ( 90 <View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}> 91 <Image 92 key={thumbnail} 93 source={{uri: thumbnail}} 94 alt={alt} 95 style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]} 96 contentFit="cover" 97 accessible={true} 98 accessibilityIgnoresInvertColors 99 /> 100 <MediaInsetBorder style={[a.rounded_xs]} /> 101 {children} 102 </View> 103 ) 104} 105 106export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) { 107 return ( 108 <ImageItem thumbnail={thumbnail} alt={alt}> 109 <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 110 <PlayButtonIcon size={24} /> 111 </View> 112 <View style={styles.altContainer}> 113 <Text style={styles.alt}> 114 <Trans>GIF</Trans> 115 </Text> 116 </View> 117 </ImageItem> 118 ) 119} 120 121export function VideoItem({ 122 thumbnail, 123 alt, 124}: { 125 thumbnail?: string 126 alt?: string 127}) { 128 if (!thumbnail) { 129 return ( 130 <View 131 style={[ 132 {backgroundColor: 'black'}, 133 a.flex_1, 134 {aspectRatio: 1, maxWidth: 100}, 135 a.justify_center, 136 a.align_center, 137 ]}> 138 <PlayButtonIcon size={24} /> 139 </View> 140 ) 141 } 142 return ( 143 <ImageItem thumbnail={thumbnail} alt={alt}> 144 <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 145 <PlayButtonIcon size={24} /> 146 </View> 147 </ImageItem> 148 ) 149} 150 151const styles = StyleSheet.create({ 152 altContainer: { 153 backgroundColor: 'rgba(0, 0, 0, 0.75)', 154 borderRadius: 6, 155 paddingHorizontal: 6, 156 paddingVertical: 3, 157 position: 'absolute', 158 right: 5, 159 bottom: 5, 160 zIndex: 2, 161 }, 162 alt: { 163 color: 'white', 164 fontSize: 7, 165 fontWeight: '600', 166 }, 167})