An ATproto social media client -- with an independent Appview.
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 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 showLikeCount = false 394 const {thumbnail} = embed 395 const black = getBlackColor(t) 396 397 return ( 398 <Link 399 label={_(msg`View video`)} 400 to={{ 401 screen: 'VideoFeed', 402 params: { 403 ...sourceContext, 404 initialPostUri: post.uri, 405 }, 406 }} 407 onPress={() => { 408 onInteract?.() 409 }} 410 onPressIn={onPressIn} 411 onPressOut={onPressOut} 412 style={[ 413 a.flex_col, 414 t.atoms.shadow_sm, 415 { 416 alignItems: undefined, 417 justifyContent: undefined, 418 }, 419 ]}> 420 <Hider.Outer modui={mergedModui}> 421 <Hider.Mask> 422 <View 423 style={[ 424 a.justify_center, 425 a.rounded_lg, 426 a.overflow_hidden, 427 a.border, 428 t.atoms.border_contrast_low, 429 { 430 backgroundColor: black, 431 aspectRatio: 9 / 16, 432 }, 433 ]}> 434 <Image 435 source={{uri: thumbnail}} 436 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 437 accessibilityIgnoresInvertColors 438 blurRadius={100} 439 /> 440 <MediaInsetBorder /> 441 <View 442 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 443 <View 444 style={[ 445 a.absolute, 446 a.inset_0, 447 a.justify_center, 448 a.align_center, 449 a.border, 450 t.atoms.border_contrast_low, 451 { 452 backgroundColor: 'black', 453 opacity: 0.2, 454 }, 455 ]} 456 /> 457 <View style={[a.align_center, a.gap_xs]}> 458 <Eye size="lg" fill="white" /> 459 <Text style={[a.text_sm, {color: 'white'}]}> 460 {_(msg`Hidden`)} 461 </Text> 462 </View> 463 </View> 464 </View> 465 </Hider.Mask> 466 <Hider.Content> 467 <View 468 style={[ 469 a.justify_center, 470 a.rounded_lg, 471 a.overflow_hidden, 472 a.border, 473 t.atoms.border_contrast_low, 474 { 475 backgroundColor: black, 476 aspectRatio: 9 / 16, 477 }, 478 ]}> 479 <Image 480 source={{uri: thumbnail}} 481 style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]} 482 accessibilityIgnoresInvertColors 483 /> 484 <MediaInsetBorder /> 485 486 <View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}> 487 <View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}> 488 <View 489 style={[a.relative, a.rounded_full, {width: 24, height: 24}]}> 490 <UserAvatar 491 type="user" 492 size={24} 493 avatar={post.author.avatar} 494 /> 495 <MediaInsetBorder /> 496 </View> 497 </View> 498 499 {showLikeCount && ( 500 <View 501 style={[ 502 a.absolute, 503 a.inset_0, 504 a.pt_2xl, 505 { 506 top: 'auto', 507 }, 508 ]}> 509 <LinearGradient 510 colors={[black, 'rgba(0, 0, 0, 0)']} 511 locations={[0.02, 1]} 512 start={{x: 0, y: 1}} 513 end={{x: 0, y: 0}} 514 style={[a.absolute, a.inset_0, {opacity: 0.9}]} 515 /> 516 517 <View 518 style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}> 519 {likeCount > 0 && ( 520 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 521 <Heart size="sm" fill="white" /> 522 <Text 523 style={[a.text_sm, a.font_bold, {color: 'white'}]}> 524 {formatCount(i18n, likeCount)} 525 </Text> 526 </View> 527 )} 528 </View> 529 </View> 530 )} 531 </View> 532 </View> 533 </Hider.Content> 534 </Hider.Outer> 535 </Link> 536 ) 537} 538 539export function CompactVideoPostCardPlaceholder() { 540 const t = useTheme() 541 const black = getBlackColor(t) 542 543 return ( 544 <View style={[a.flex_1, t.atoms.shadow_sm]}> 545 <View 546 style={[ 547 a.rounded_lg, 548 a.overflow_hidden, 549 a.border, 550 t.atoms.border_contrast_low, 551 { 552 backgroundColor: black, 553 aspectRatio: 9 / 16, 554 }, 555 ]}> 556 <MediaInsetBorder /> 557 </View> 558 </View> 559 ) 560}