An ATproto social media client -- with an independent Appview.
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})