mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 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 AppBskyActorDefs, 7 AppBskyEmbedVideo, 8 AppBskyFeedDefs, 9 AppBskyFeedPost, 10 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 {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 style={[a.text_sm, a.font_bold, {color: 'white'}]}> 231 {formatCount(i18n, likeCount)} 232 </Text> 233 </View> 234 )} 235 {repostCount > 0 && ( 236 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 237 <Repost size="sm" fill="white" /> 238 <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> 239 {formatCount(i18n, repostCount)} 240 </Text> 241 </View> 242 )} 243 </View> 244 </View> 245 </View> 246 </View> 247 {textAndAuthor} 248 </Hider.Content> 249 </Hider.Outer> 250 </Link> 251 ) 252} 253 254export function VideoPostCardPlaceholder() { 255 const t = useTheme() 256 const black = getBlackColor(t) 257 258 return ( 259 <View style={[a.flex_1]}> 260 <View 261 style={[ 262 a.rounded_md, 263 a.overflow_hidden, 264 { 265 backgroundColor: black, 266 aspectRatio: 9 / 16, 267 }, 268 ]}> 269 <MediaInsetBorder /> 270 </View> 271 <VideoPostCardTextPlaceholder /> 272 </View> 273 ) 274} 275 276export function VideoPostCardTextPlaceholder({ 277 author, 278}: { 279 author?: AppBskyActorDefs.ProfileViewBasic 280}) { 281 const t = useTheme() 282 283 return ( 284 <View style={[a.flex_1]}> 285 <View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}> 286 <View 287 style={[ 288 a.w_full, 289 a.rounded_xs, 290 t.atoms.bg_contrast_50, 291 { 292 height: 14, 293 }, 294 ]} 295 /> 296 <View 297 style={[ 298 a.w_full, 299 a.rounded_xs, 300 t.atoms.bg_contrast_50, 301 { 302 height: 14, 303 width: '70%', 304 }, 305 ]} 306 /> 307 {author ? ( 308 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 309 <View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 310 <UserAvatar type="user" size={20} avatar={author.avatar} /> 311 <MediaInsetBorder /> 312 </View> 313 <Text 314 style={[ 315 a.flex_1, 316 a.text_sm, 317 a.leading_tight, 318 t.atoms.text_contrast_medium, 319 ]} 320 numberOfLines={1}> 321 {sanitizeHandle(author.handle, '@')} 322 </Text> 323 </View> 324 ) : ( 325 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 326 <View 327 style={[ 328 a.rounded_full, 329 t.atoms.bg_contrast_50, 330 { 331 width: 20, 332 height: 20, 333 }, 334 ]} 335 /> 336 <View 337 style={[ 338 a.rounded_xs, 339 t.atoms.bg_contrast_25, 340 { 341 height: 12, 342 width: '75%', 343 }, 344 ]} 345 /> 346 </View> 347 )} 348 </View> 349 </View> 350 ) 351} 352 353export function CompactVideoPostCard({ 354 post, 355 sourceContext, 356 moderation, 357 onInteract, 358}: { 359 post: AppBskyFeedDefs.PostView 360 sourceContext: VideoFeedSourceContext 361 moderation: ModerationDecision 362 /** 363 * Callback for metrics etc 364 */ 365 onInteract?: () => void 366}) { 367 const t = useTheme() 368 const {_, i18n} = useLingui() 369 const embed = post.embed 370 const { 371 state: pressed, 372 onIn: onPressIn, 373 onOut: onPressOut, 374 } = useInteractionState() 375 376 const mergedModui = useMemo(() => { 377 const modui = moderation.ui('contentList') 378 const mediaModui = moderation.ui('contentMedia') 379 modui.alerts = [...modui.alerts, ...mediaModui.alerts] 380 modui.blurs = [...modui.blurs, ...mediaModui.blurs] 381 modui.filters = [...modui.filters, ...mediaModui.filters] 382 modui.informs = [...modui.informs, ...mediaModui.informs] 383 return modui 384 }, [moderation]) 385 386 /** 387 * Filtering should be done at a higher level, such as `PostFeed` or 388 * `PostFeedVideoGridRow`, but we need to protect here as well. 389 */ 390 if (!AppBskyEmbedVideo.isView(embed)) return null 391 392 const likeCount = post?.likeCount ?? 0 393 const {thumbnail} = embed 394 const black = getBlackColor(t) 395 396 return ( 397 <Link 398 label={_(msg`View video`)} 399 to={{ 400 screen: 'VideoFeed', 401 params: { 402 ...sourceContext, 403 initialPostUri: post.uri, 404 }, 405 }} 406 onPress={() => { 407 onInteract?.() 408 }} 409 onPressIn={onPressIn} 410 onPressOut={onPressOut} 411 style={[ 412 a.flex_col, 413 { 414 alignItems: undefined, 415 justifyContent: undefined, 416 }, 417 ]}> 418 <Hider.Outer modui={mergedModui}> 419 <Hider.Mask> 420 <View 421 style={[ 422 a.justify_center, 423 a.rounded_md, 424 a.overflow_hidden, 425 { 426 backgroundColor: black, 427 aspectRatio: 9 / 16, 428 }, 429 ]}> 430 <Image 431 source={{uri: thumbnail}} 432 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 433 accessibilityIgnoresInvertColors 434 blurRadius={100} 435 /> 436 <MediaInsetBorder /> 437 <View 438 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 439 <View 440 style={[ 441 a.absolute, 442 a.inset_0, 443 a.justify_center, 444 a.align_center, 445 { 446 backgroundColor: 'black', 447 opacity: 0.2, 448 }, 449 ]} 450 /> 451 <View style={[a.align_center, a.gap_xs]}> 452 <Eye size="lg" fill="white" /> 453 <Text style={[a.text_sm, {color: 'white'}]}> 454 {_(msg`Hidden`)} 455 </Text> 456 </View> 457 </View> 458 </View> 459 </Hider.Mask> 460 <Hider.Content> 461 <View 462 style={[ 463 a.justify_center, 464 a.rounded_md, 465 a.overflow_hidden, 466 { 467 backgroundColor: black, 468 aspectRatio: 9 / 16, 469 }, 470 ]}> 471 <Image 472 source={{uri: thumbnail}} 473 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 474 accessibilityIgnoresInvertColors 475 /> 476 <MediaInsetBorder /> 477 478 <View style={[a.absolute, a.inset_0]}> 479 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}> 480 <View 481 style={[a.relative, a.rounded_full, {width: 20, height: 20}]}> 482 <UserAvatar 483 type="user" 484 size={20} 485 avatar={post.author.avatar} 486 /> 487 <MediaInsetBorder /> 488 </View> 489 </View> 490 <View 491 style={[ 492 a.absolute, 493 a.inset_0, 494 a.pt_2xl, 495 { 496 top: 'auto', 497 }, 498 ]}> 499 <LinearGradient 500 colors={[black, 'rgba(0, 0, 0, 0)']} 501 locations={[0.02, 1]} 502 start={{x: 0, y: 1}} 503 end={{x: 0, y: 0}} 504 style={[a.absolute, a.inset_0, {opacity: 0.9}]} 505 /> 506 507 <View 508 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}> 509 {likeCount > 0 && ( 510 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 511 <Heart size="sm" fill="white" /> 512 <Text style={[a.text_sm, a.font_bold, {color: 'white'}]}> 513 {formatCount(i18n, likeCount)} 514 </Text> 515 </View> 516 )} 517 </View> 518 </View> 519 </View> 520 </View> 521 </Hider.Content> 522 </Hider.Outer> 523 </Link> 524 ) 525} 526 527export function CompactVideoPostCardPlaceholder() { 528 const t = useTheme() 529 const black = getBlackColor(t) 530 531 return ( 532 <View style={[a.flex_1]}> 533 <View 534 style={[ 535 a.rounded_md, 536 a.overflow_hidden, 537 { 538 backgroundColor: black, 539 aspectRatio: 9 / 16, 540 }, 541 ]}> 542 <MediaInsetBorder /> 543 </View> 544 </View> 545 ) 546}