forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useMemo, useState} from 'react'
2import {Pressable, View} from 'react-native'
3import * as VideoThumbnails from 'expo-video-thumbnails'
4import {msg, plural} from '@lingui/core/macro'
5import {useLingui} from '@lingui/react'
6
7import * as device from '#/lib/deviceName'
8import {logger} from '#/view/com/composer/drafts/state/logger'
9import {TimeElapsed} from '#/view/com/util/TimeElapsed'
10import {atoms as a, select, useTheme} from '#/alf'
11import {Button} from '#/components/Button'
12import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
13import {type Props as SVGIconProps} from '#/components/icons/common'
14import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
15import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
16import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
17import * as MediaPreview from '#/components/MediaPreview'
18import * as Prompt from '#/components/Prompt'
19import {RichText} from '#/components/RichText'
20import {Text} from '#/components/Typography'
21import {IS_WEB} from '#/env'
22import {type DraftPostDisplay, type DraftSummary} from './state/schema'
23import * as storage from './state/storage'
24
25export function DraftItem({
26 draft,
27 onSelect,
28 onDelete,
29}: {
30 draft: DraftSummary
31 onSelect: (draft: DraftSummary) => void
32 onDelete: (draft: DraftSummary) => void
33}) {
34 const {_} = useLingui()
35 const t = useTheme()
36 const discardPromptControl = Prompt.usePromptControl()
37 const post = draft.posts[0]
38
39 const mediaExistsOnOtherDevice =
40 !draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
41 const mediaIsMissing =
42 draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
43 const hasMetadata =
44 draft.meta.replyCount > 0 ||
45 mediaExistsOnOtherDevice ||
46 draft.meta.hasQuotes
47
48 const isUnknownDevice = useMemo(() => {
49 const raw = draft.draft.deviceName
50 switch (raw) {
51 case device.FALLBACK_IOS:
52 case device.FALLBACK_ANDROID:
53 case device.FALLBACK_WEB:
54 return true
55 default:
56 return false
57 }
58 }, [draft])
59
60 const handleDelete = useCallback(() => {
61 onDelete(draft)
62 }, [onDelete, draft])
63
64 return (
65 <>
66 <View style={[a.relative]}>
67 <Pressable
68 accessibilityRole="button"
69 accessibilityLabel={_(msg`Open draft`)}
70 accessibilityHint={_(msg`Opens this draft in the composer`)}
71 onPress={() => onSelect(draft)}
72 style={({pressed, hovered}) => [
73 a.rounded_md,
74 a.border,
75 t.atoms.shadow_sm,
76 pressed || hovered
77 ? t.atoms.border_contrast_medium
78 : t.atoms.border_contrast_low,
79 {
80 backgroundColor: select(t.name, {
81 light: t.atoms.bg.backgroundColor,
82 dark: t.atoms.bg_contrast_25.backgroundColor,
83 dim: t.atoms.bg_contrast_25.backgroundColor,
84 }),
85 },
86 ]}>
87 <View
88 style={[
89 a.rounded_md,
90 a.overflow_hidden,
91 a.p_lg,
92 a.pb_md,
93 a.gap_sm,
94 {
95 paddingTop: 20 + a.pt_md.paddingTop,
96 },
97 ]}>
98 {!!post.text.trim().length && (
99 <RichText
100 style={[a.text_md, a.leading_snug, a.pointer_events_none]}
101 numberOfLines={8}
102 value={post.text}
103 enableTags
104 disableMentionFacetValidation
105 />
106 )}
107
108 {!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
109
110 {hasMetadata && (
111 <View style={[a.gap_xs]}>
112 {mediaExistsOnOtherDevice && (
113 <DraftMetadataTag
114 icon={WarningIcon}
115 text={
116 isUnknownDevice
117 ? _(msg`Media stored on another device`)
118 : _(
119 msg({
120 message: `Media stored on ${draft.draft.deviceName}`,
121 comment: `Example: "Media stored on John's iPhone"`,
122 }),
123 )
124 }
125 />
126 )}
127 {mediaIsMissing && (
128 <DraftMetadataTag
129 display="warning"
130 icon={WarningIcon}
131 text={_(msg`Missing media`)}
132 />
133 )}
134 {draft.meta.hasQuotes && (
135 <DraftMetadataTag
136 icon={CloseQuoteIcon}
137 text={_(msg`Quote post`)}
138 />
139 )}
140 {draft.meta.replyCount > 0 && (
141 <DraftMetadataTag
142 icon={CirclePlusIcon}
143 text={plural(draft.meta.replyCount, {
144 one: '1 more post',
145 other: '# more posts',
146 })}
147 />
148 )}
149 </View>
150 )}
151 </View>
152 </Pressable>
153
154 {/* Timestamp */}
155 <View
156 pointerEvents="none"
157 style={[
158 a.absolute,
159 a.pointer_events_none,
160 {
161 top: a.pt_md.paddingTop,
162 left: a.pl_lg.paddingLeft,
163 },
164 ]}>
165 <TimeElapsed timestamp={draft.updatedAt}>
166 {({timeElapsed}) => (
167 <Text
168 style={[
169 a.text_sm,
170 t.atoms.text_contrast_medium,
171 a.leading_tight,
172 ]}
173 numberOfLines={1}>
174 {timeElapsed}
175 </Text>
176 )}
177 </TimeElapsed>
178 </View>
179
180 {/* Menu button */}
181 <View
182 style={[
183 a.absolute,
184 {
185 top: a.pt_md.paddingTop,
186 right: a.pr_md.paddingRight,
187 },
188 ]}>
189 <Button
190 label={_(msg`More options`)}
191 hitSlop={8}
192 onPress={e => {
193 e.stopPropagation()
194 discardPromptControl.open()
195 }}
196 style={[
197 a.pointer,
198 a.rounded_full,
199 {
200 height: 20,
201 width: 20,
202 },
203 ]}>
204 {({pressed, hovered}) => (
205 <>
206 <View
207 style={[
208 a.absolute,
209 a.rounded_full,
210 {
211 top: -4,
212 bottom: -4,
213 left: -4,
214 right: -4,
215 backgroundColor:
216 pressed || hovered
217 ? select(t.name, {
218 light: t.atoms.bg_contrast_50.backgroundColor,
219 dark: t.atoms.bg_contrast_100.backgroundColor,
220 dim: t.atoms.bg_contrast_100.backgroundColor,
221 })
222 : 'transparent',
223 },
224 ]}
225 />
226 <DotsIcon
227 width={16}
228 fill={t.atoms.text_contrast_low.color}
229 style={[a.z_20]}
230 />
231 </>
232 )}
233 </Button>
234 </View>
235 </View>
236
237 <Prompt.Basic
238 control={discardPromptControl}
239 title={_(msg`Discard draft?`)}
240 description={_(msg`This draft will be permanently deleted.`)}
241 onConfirm={handleDelete}
242 confirmButtonCta={_(msg`Discard`)}
243 confirmButtonColor="negative"
244 />
245 </>
246 )
247}
248
249function DraftMetadataTag({
250 display = 'info',
251 icon: Icon,
252 text,
253}: {
254 display?: 'info' | 'warning'
255 icon: React.ComponentType<SVGIconProps>
256 text: string
257}) {
258 const t = useTheme()
259 const color = {
260 info: t.atoms.text_contrast_medium.color,
261 warning: select(t.name, {
262 light: '#C99A00',
263 dark: '#FFC404',
264 dim: '#FFC404',
265 }),
266 }[display]
267 return (
268 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
269 <Icon size="sm" fill={color} />
270 <Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
271 </View>
272 )
273}
274
275type LoadedImage = {
276 url: string
277 alt: string
278}
279
280function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
281 const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([])
282 const [videoThumbnail, setVideoThumbnail] = useState<string | undefined>()
283
284 useEffect(() => {
285 async function loadMedia() {
286 if (post.images && post.images.length > 0) {
287 const loaded: LoadedImage[] = []
288 for (const image of post.images) {
289 try {
290 const url = await storage.loadMediaFromLocal(image.localPath)
291 loaded.push({url, alt: image.altText || ''})
292 } catch (e) {
293 // Image doesn't exist locally, skip it
294 }
295 }
296 setLoadedImages(loaded)
297 }
298
299 if (post.video?.exists && post.video.localPath) {
300 try {
301 const url = await storage.loadMediaFromLocal(post.video.localPath)
302 if (IS_WEB) {
303 // can't generate thumbnails on web
304 setVideoThumbnail("yep, there's a video")
305 } else {
306 logger.debug('generating thumbnail of ', {url})
307 const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
308 time: 0,
309 quality: 0.2,
310 })
311 logger.debug('thumbnail generated', {thumbnail})
312 setVideoThumbnail(thumbnail.uri)
313 }
314 } catch (e) {
315 // Video doesn't exist locally
316 }
317 }
318 }
319
320 void loadMedia()
321 }, [post.images, post.video])
322
323 // Nothing to show
324 if (loadedImages.length === 0 && !post.gif && !post.video) {
325 return null
326 }
327
328 return (
329 <MediaPreview.Outer>
330 {loadedImages.map((image, i) => (
331 <MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
332 ))}
333 {post.gif && (
334 <MediaPreview.GifItem thumbnail={post.gif.url} alt={post.gif.alt} />
335 )}
336 {post.video && videoThumbnail && (
337 <MediaPreview.VideoItem
338 thumbnail={IS_WEB ? undefined : videoThumbnail}
339 alt={post.video.altText}
340 />
341 )}
342 </MediaPreview.Outer>
343 )
344}