Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 6bfe758d2a9ea376552fb45e5e589bccd0cf4df5 341 lines 10 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/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}