Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 344 lines 11 kB view raw
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}