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/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 {DotGrid_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 <RichText
99 style={[a.text_md, a.leading_snug, a.pointer_events_none]}
100 value={post.text}
101 enableTags
102 disableMentionFacetValidation
103 />
104
105 {!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
106
107 {hasMetadata && (
108 <View style={[a.gap_xs]}>
109 {mediaExistsOnOtherDevice && (
110 <DraftMetadataTag
111 icon={WarningIcon}
112 text={
113 isUnknownDevice
114 ? _(msg`Media stored on another device`)
115 : _(
116 msg({
117 message: `Media stored on ${draft.draft.deviceName}`,
118 comment: `Example: "Media stored on John's iPhone"`,
119 }),
120 )
121 }
122 />
123 )}
124 {mediaIsMissing && (
125 <DraftMetadataTag
126 display="warning"
127 icon={WarningIcon}
128 text={_(msg`Missing media`)}
129 />
130 )}
131 {draft.meta.hasQuotes && (
132 <DraftMetadataTag
133 icon={CloseQuoteIcon}
134 text={_(msg`Quote post`)}
135 />
136 )}
137 {draft.meta.replyCount > 0 && (
138 <DraftMetadataTag
139 icon={CirclePlusIcon}
140 text={plural(draft.meta.replyCount, {
141 one: '1 more post',
142 other: '# more posts',
143 })}
144 />
145 )}
146 </View>
147 )}
148 </View>
149 </Pressable>
150
151 {/* Timestamp */}
152 <View
153 pointerEvents="none"
154 style={[
155 a.absolute,
156 a.pointer_events_none,
157 {
158 top: a.pt_md.paddingTop,
159 left: a.pl_lg.paddingLeft,
160 },
161 ]}>
162 <TimeElapsed timestamp={draft.updatedAt}>
163 {({timeElapsed}) => (
164 <Text
165 style={[
166 a.text_sm,
167 t.atoms.text_contrast_medium,
168 a.leading_tight,
169 ]}
170 numberOfLines={1}>
171 {timeElapsed}
172 </Text>
173 )}
174 </TimeElapsed>
175 </View>
176
177 {/* Menu button */}
178 <View
179 style={[
180 a.absolute,
181 {
182 top: a.pt_md.paddingTop,
183 right: a.pr_md.paddingRight,
184 },
185 ]}>
186 <Button
187 label={_(msg`More options`)}
188 hitSlop={8}
189 onPress={e => {
190 e.stopPropagation()
191 discardPromptControl.open()
192 }}
193 style={[
194 a.pointer,
195 a.rounded_full,
196 {
197 height: 20,
198 width: 20,
199 },
200 ]}>
201 {({pressed, hovered}) => (
202 <>
203 <View
204 style={[
205 a.absolute,
206 a.rounded_full,
207 {
208 top: -4,
209 bottom: -4,
210 left: -4,
211 right: -4,
212 backgroundColor:
213 pressed || hovered
214 ? select(t.name, {
215 light: t.atoms.bg_contrast_50.backgroundColor,
216 dark: t.atoms.bg_contrast_100.backgroundColor,
217 dim: t.atoms.bg_contrast_100.backgroundColor,
218 })
219 : 'transparent',
220 },
221 ]}
222 />
223 <DotsIcon
224 width={16}
225 fill={t.atoms.text_contrast_low.color}
226 style={[a.z_20]}
227 />
228 </>
229 )}
230 </Button>
231 </View>
232 </View>
233
234 <Prompt.Basic
235 control={discardPromptControl}
236 title={_(msg`Discard draft?`)}
237 description={_(msg`This draft will be permanently deleted.`)}
238 onConfirm={handleDelete}
239 confirmButtonCta={_(msg`Discard`)}
240 confirmButtonColor="negative"
241 />
242 </>
243 )
244}
245
246function DraftMetadataTag({
247 display = 'info',
248 icon: Icon,
249 text,
250}: {
251 display?: 'info' | 'warning'
252 icon: React.ComponentType<SVGIconProps>
253 text: string
254}) {
255 const t = useTheme()
256 const color = {
257 info: t.atoms.text_contrast_medium.color,
258 warning: select(t.name, {
259 light: '#C99A00',
260 dark: '#FFC404',
261 dim: '#FFC404',
262 }),
263 }[display]
264 return (
265 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
266 <Icon size="sm" fill={color} />
267 <Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
268 </View>
269 )
270}
271
272type LoadedImage = {
273 url: string
274 alt: string
275}
276
277function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
278 const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([])
279 const [videoThumbnail, setVideoThumbnail] = useState<string | undefined>()
280
281 useEffect(() => {
282 async function loadMedia() {
283 if (post.images && post.images.length > 0) {
284 const loaded: LoadedImage[] = []
285 for (const image of post.images) {
286 try {
287 const url = await storage.loadMediaFromLocal(image.localPath)
288 loaded.push({url, alt: image.altText || ''})
289 } catch (e) {
290 // Image doesn't exist locally, skip it
291 }
292 }
293 setLoadedImages(loaded)
294 }
295
296 if (post.video?.exists && post.video.localPath) {
297 try {
298 const url = await storage.loadMediaFromLocal(post.video.localPath)
299 if (IS_WEB) {
300 // can't generate thumbnails on web
301 setVideoThumbnail("yep, there's a video")
302 } else {
303 logger.debug('generating thumbnail of ', {url})
304 const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
305 time: 0,
306 quality: 0.2,
307 })
308 logger.debug('thumbnail generated', {thumbnail})
309 setVideoThumbnail(thumbnail.uri)
310 }
311 } catch (e) {
312 // Video doesn't exist locally
313 }
314 }
315 }
316
317 void loadMedia()
318 }, [post.images, post.video])
319
320 // Nothing to show
321 if (loadedImages.length === 0 && !post.gif && !post.video) {
322 return null
323 }
324
325 return (
326 <MediaPreview.Outer>
327 {loadedImages.map((image, i) => (
328 <MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
329 ))}
330 {post.gif && (
331 <MediaPreview.GifItem thumbnail={post.gif.url} alt={post.gif.alt} />
332 )}
333 {post.video && videoThumbnail && (
334 <MediaPreview.VideoItem
335 thumbnail={IS_WEB ? undefined : videoThumbnail}
336 alt={post.video.altText}
337 />
338 )}
339 </MediaPreview.Outer>
340 )
341}