Bluesky app fork with some witchin' additions 馃挮
at main 16 kB view raw
1import {useMemo} from 'react' 2import {View} from 'react-native' 3import {Image} from 'expo-image' 4import {LinearGradient} from 'expo-linear-gradient' 5import { 6 type AppBskyActorDefs, 7 AppBskyEmbedVideo, 8 type AppBskyFeedDefs, 9 AppBskyFeedPost, 10 type ModerationDecision, 11} from '@atproto/api' 12import {msg} from '@lingui/macro' 13import {useLingui} from '@lingui/react' 14 15import {sanitizeHandle} from '#/lib/strings/handles' 16import {formatCount} from '#/view/com/util/numeric/format' 17import {UserAvatar} from '#/view/com/util/UserAvatar' 18import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 19import {atoms as a, useTheme} from '#/alf' 20import {BLUE_HUE} from '#/alf/util/colorGeneration' 21import {select} from '#/alf/util/themeSelector' 22import {useInteractionState} from '#/components/hooks/useInteractionState' 23import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' 24import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2' 25import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 26import {Link} from '#/components/Link' 27import {MediaInsetBorder} from '#/components/MediaInsetBorder' 28import * as Hider from '#/components/moderation/Hider' 29import {Text} from '#/components/Typography' 30import * as bsky from '#/types/bsky' 31 32function getBlackColor(t: ReturnType<typeof useTheme>) { 33 return select(t.name, { 34 light: t.palette.black, 35 dark: t.atoms.bg_contrast_25.backgroundColor, 36 dim: `hsl(${BLUE_HUE}, 28%, 6%)`, 37 }) 38} 39 40export function VideoPostCard({ 41 post, 42 sourceContext, 43 moderation, 44 onInteract, 45}: { 46 post: AppBskyFeedDefs.PostView 47 sourceContext: VideoFeedSourceContext 48 moderation: ModerationDecision 49 /** 50 * Callback for metrics etc 51 */ 52 onInteract?: () => void 53}) { 54 const t = useTheme() 55 const {_, i18n} = useLingui() 56 const embed = post.embed 57 const { 58 state: pressed, 59 onIn: onPressIn, 60 onOut: onPressOut, 61 } = useInteractionState() 62 63 const listModUi = moderation.ui('contentList') 64 65 const mergedModui = useMemo(() => { 66 const modui = moderation.ui('contentList') 67 const mediaModui = moderation.ui('contentMedia') 68 modui.alerts = [...modui.alerts, ...mediaModui.alerts] 69 modui.blurs = [...modui.blurs, ...mediaModui.blurs] 70 modui.filters = [...modui.filters, ...mediaModui.filters] 71 modui.informs = [...modui.informs, ...mediaModui.informs] 72 return modui 73 }, [moderation]) 74 75 /** 76 * Filtering should be done at a higher level, such as `PostFeed` or 77 * `PostFeedVideoGridRow`, but we need to protect here as well. 78 */ 79 if (!AppBskyEmbedVideo.isView(embed)) return null 80 81 const author = post.author 82 const text = bsky.dangerousIsType<AppBskyFeedPost.Record>( 83 post.record, 84 AppBskyFeedPost.isRecord, 85 ) 86 ? post.record?.text 87 : '' 88 const likeCount = post?.likeCount ?? 0 89 const repostCount = post?.repostCount ?? 0 90 const {thumbnail} = embed 91 const black = getBlackColor(t) 92 93 const textAndAuthor = ( 94 <View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}> 95 {text && ( 96 <Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji> 97 {text} 98 </Text> 99 )} 100 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 101 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 102 <UserAvatar type="user" size={20} avatar={post.author.avatar} /> 103 <MediaInsetBorder /> 104 </View> 105 <Text 106 style={[ 107 a.flex_1, 108 a.text_sm, 109 a.leading_tight, 110 t.atoms.text_contrast_medium, 111 ]} 112 numberOfLines={1}> 113 {sanitizeHandle(post.author.handle, '@')} 114 </Text> 115 </View> 116 </View> 117 ) 118 119 return ( 120 <Link 121 accessibilityHint={_(msg`Views video in immersive mode`)} 122 label={_(msg`Video from ${author.handle}: ${text}`)} 123 to={{ 124 screen: 'VideoFeed', 125 params: { 126 ...sourceContext, 127 initialPostUri: post.uri, 128 }, 129 }} 130 onPress={() => { 131 onInteract?.() 132 }} 133 onPressIn={onPressIn} 134 onPressOut={onPressOut} 135 style={[ 136 a.flex_col, 137 { 138 alignItems: undefined, 139 justifyContent: undefined, 140 }, 141 ]}> 142 <Hider.Outer modui={mergedModui}> 143 <Hider.Mask> 144 <View 145 style={[ 146 a.justify_center, 147 a.rounded_md, 148 a.overflow_hidden, 149 { 150 backgroundColor: black, 151 aspectRatio: 9 / 16, 152 }, 153 ]}> 154 <Image 155 source={{uri: thumbnail}} 156 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 157 accessibilityIgnoresInvertColors 158 blurRadius={100} 159 /> 160 <MediaInsetBorder /> 161 <View 162 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 163 <View 164 style={[ 165 a.absolute, 166 a.inset_0, 167 a.justify_center, 168 a.align_center, 169 { 170 backgroundColor: 'black', 171 opacity: 0.2, 172 }, 173 ]} 174 /> 175 <View style={[a.align_center, a.gap_xs]}> 176 <Eye size="lg" fill="white" /> 177 <Text style={[a.text_sm, {color: 'white'}]}> 178 {_(msg`Hidden`)} 179 </Text> 180 </View> 181 </View> 182 </View> 183 {listModUi.blur ? ( 184 <VideoPostCardTextPlaceholder author={post.author} /> 185 ) : ( 186 textAndAuthor 187 )} 188 </Hider.Mask> 189 <Hider.Content> 190 <View 191 style={[ 192 a.justify_center, 193 a.rounded_md, 194 a.overflow_hidden, 195 { 196 backgroundColor: black, 197 aspectRatio: 9 / 16, 198 }, 199 ]}> 200 <Image 201 source={{uri: thumbnail}} 202 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 203 accessibilityIgnoresInvertColors 204 /> 205 <MediaInsetBorder /> 206 207 <View style={[a.absolute, a.inset_0]}> 208 <View 209 style={[ 210 a.absolute, 211 a.inset_0, 212 a.pt_2xl, 213 { 214 top: 'auto', 215 }, 216 ]}> 217 <LinearGradient 218 colors={[black, 'rgba(0, 0, 0, 0)']} 219 locations={[0.02, 1]} 220 start={{x: 0, y: 1}} 221 end={{x: 0, y: 0}} 222 style={[a.absolute, a.inset_0, {opacity: 0.9}]} 223 /> 224 225 <View 226 style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}> 227 {likeCount > 0 && ( 228 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 229 <Heart size="sm" fill="white" /> 230 <Text 231 style={[a.text_sm, a.font_semi_bold, {color: 'white'}]}> 232 {formatCount(i18n, likeCount)} 233 </Text> 234 </View> 235 )} 236 {repostCount > 0 && ( 237 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 238 <Repost size="sm" fill="white" /> 239 <Text 240 style={[a.text_sm, a.font_semi_bold, {color: 'white'}]}> 241 {formatCount(i18n, repostCount)} 242 </Text> 243 </View> 244 )} 245 </View> 246 </View> 247 </View> 248 </View> 249 {textAndAuthor} 250 </Hider.Content> 251 </Hider.Outer> 252 </Link> 253 ) 254} 255 256export function VideoPostCardPlaceholder() { 257 const t = useTheme() 258 const black = getBlackColor(t) 259 260 return ( 261 <View style={[a.flex_1]}> 262 <View 263 style={[ 264 a.rounded_md, 265 a.overflow_hidden, 266 { 267 backgroundColor: black, 268 aspectRatio: 9 / 16, 269 }, 270 ]}> 271 <MediaInsetBorder /> 272 </View> 273 <VideoPostCardTextPlaceholder /> 274 </View> 275 ) 276} 277 278export function VideoPostCardTextPlaceholder({ 279 author, 280}: { 281 author?: AppBskyActorDefs.ProfileViewBasic 282}) { 283 const t = useTheme() 284 285 return ( 286 <View style={[a.flex_1]}> 287 <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}> 288 <View 289 style={[ 290 a.w_full, 291 a.rounded_xs, 292 t.atoms.bg_contrast_50, 293 { 294 height: 14, 295 }, 296 ]} 297 /> 298 <View 299 style={[ 300 a.w_full, 301 a.rounded_xs, 302 t.atoms.bg_contrast_50, 303 { 304 height: 14, 305 width: '70%', 306 }, 307 ]} 308 /> 309 {author ? ( 310 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 311 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 312 <UserAvatar type="user" size={20} avatar={author.avatar} /> 313 <MediaInsetBorder /> 314 </View> 315 <Text 316 style={[ 317 a.flex_1, 318 a.text_sm, 319 a.leading_tight, 320 t.atoms.text_contrast_medium, 321 ]} 322 numberOfLines={1}> 323 {sanitizeHandle(author.handle, '@')} 324 </Text> 325 </View> 326 ) : ( 327 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 328 <View 329 style={[ 330 a.rounded_full, 331 t.atoms.bg_contrast_50, 332 { 333 width: 20, 334 height: 20, 335 }, 336 ]} 337 /> 338 <View 339 style={[ 340 a.rounded_xs, 341 t.atoms.bg_contrast_25, 342 { 343 height: 12, 344 width: '75%', 345 }, 346 ]} 347 /> 348 </View> 349 )} 350 </View> 351 </View> 352 ) 353} 354 355export function CompactVideoPostCard({ 356 post, 357 sourceContext, 358 moderation, 359 onInteract, 360}: { 361 post: AppBskyFeedDefs.PostView 362 sourceContext: VideoFeedSourceContext 363 moderation: ModerationDecision 364 /** 365 * Callback for metrics etc 366 */ 367 onInteract?: () => void 368}) { 369 const t = useTheme() 370 const {_, i18n} = useLingui() 371 const embed = post.embed 372 const { 373 state: pressed, 374 onIn: onPressIn, 375 onOut: onPressOut, 376 } = useInteractionState() 377 378 const mergedModui = useMemo(() => { 379 const modui = moderation.ui('contentList') 380 const mediaModui = moderation.ui('contentMedia') 381 modui.alerts = [...modui.alerts, ...mediaModui.alerts] 382 modui.blurs = [...modui.blurs, ...mediaModui.blurs] 383 modui.filters = [...modui.filters, ...mediaModui.filters] 384 modui.informs = [...modui.informs, ...mediaModui.informs] 385 return modui 386 }, [moderation]) 387 388 /** 389 * Filtering should be done at a higher level, such as `PostFeed` or 390 * `PostFeedVideoGridRow`, but we need to protect here as well. 391 */ 392 if (!AppBskyEmbedVideo.isView(embed)) return null 393 394 const likeCount = post?.likeCount ?? 0 395 const showLikeCount = false 396 const {thumbnail} = embed 397 const black = getBlackColor(t) 398 399 return ( 400 <Link 401 label={_(msg`View video`)} 402 to={{ 403 screen: 'VideoFeed', 404 params: { 405 ...sourceContext, 406 initialPostUri: post.uri, 407 }, 408 }} 409 onPress={() => { 410 onInteract?.() 411 }} 412 onPressIn={onPressIn} 413 onPressOut={onPressOut} 414 style={[ 415 a.flex_col, 416 t.atoms.shadow_sm, 417 { 418 alignItems: undefined, 419 justifyContent: undefined, 420 }, 421 ]}> 422 <Hider.Outer modui={mergedModui}> 423 <Hider.Mask> 424 <View 425 style={[ 426 a.justify_center, 427 a.rounded_lg, 428 a.overflow_hidden, 429 a.border, 430 t.atoms.border_contrast_low, 431 { 432 backgroundColor: black, 433 aspectRatio: 9 / 16, 434 }, 435 ]}> 436 <Image 437 source={{uri: thumbnail}} 438 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 439 accessibilityIgnoresInvertColors 440 blurRadius={100} 441 /> 442 <MediaInsetBorder /> 443 <View 444 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 445 <View 446 style={[ 447 a.absolute, 448 a.inset_0, 449 a.justify_center, 450 a.align_center, 451 a.border, 452 t.atoms.border_contrast_low, 453 { 454 backgroundColor: 'black', 455 opacity: 0.2, 456 }, 457 ]} 458 /> 459 <View style={[a.align_center, a.gap_xs]}> 460 <Eye size="lg" fill="white" /> 461 <Text style={[a.text_sm, {color: 'white'}]}> 462 {_(msg`Hidden`)} 463 </Text> 464 </View> 465 </View> 466 </View> 467 </Hider.Mask> 468 <Hider.Content> 469 <View 470 style={[ 471 a.justify_center, 472 a.rounded_lg, 473 a.overflow_hidden, 474 a.border, 475 t.atoms.border_contrast_low, 476 { 477 backgroundColor: black, 478 aspectRatio: 9 / 16, 479 }, 480 ]}> 481 <Image 482 source={{uri: thumbnail}} 483 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 484 accessibilityIgnoresInvertColors 485 /> 486 <MediaInsetBorder /> 487 488 <View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}> 489 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}> 490 <View 491 style={[a.relative, a.rounded_full, {width: 24, height: 24}]}> 492 <UserAvatar 493 type="user" 494 size={24} 495 avatar={post.author.avatar} 496 /> 497 <MediaInsetBorder /> 498 </View> 499 </View> 500 501 {showLikeCount && ( 502 <View 503 style={[ 504 a.absolute, 505 a.inset_0, 506 a.pt_2xl, 507 { 508 top: 'auto', 509 }, 510 ]}> 511 <LinearGradient 512 colors={[black, 'rgba(0, 0, 0, 0)']} 513 locations={[0.02, 1]} 514 start={{x: 0, y: 1}} 515 end={{x: 0, y: 0}} 516 style={[a.absolute, a.inset_0, {opacity: 0.9}]} 517 /> 518 519 <View 520 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}> 521 {likeCount > 0 && ( 522 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 523 <Heart size="sm" fill="white" /> 524 <Text 525 style={[ 526 a.text_sm, 527 a.font_semi_bold, 528 {color: 'white'}, 529 ]}> 530 {formatCount(i18n, likeCount)} 531 </Text> 532 </View> 533 )} 534 </View> 535 </View> 536 )} 537 </View> 538 </View> 539 </Hider.Content> 540 </Hider.Outer> 541 </Link> 542 ) 543} 544 545export function CompactVideoPostCardPlaceholder() { 546 const t = useTheme() 547 const black = getBlackColor(t) 548 549 return ( 550 <View style={[a.flex_1, t.atoms.shadow_sm]}> 551 <View 552 style={[ 553 a.rounded_lg, 554 a.overflow_hidden, 555 a.border, 556 t.atoms.border_contrast_low, 557 { 558 backgroundColor: black, 559 aspectRatio: 9 / 16, 560 }, 561 ]}> 562 <MediaInsetBorder /> 563 </View> 564 </View> 565 ) 566}