Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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: t.palette.yellow, 264 dim: t.palette.yellow, 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}