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

Configure Feed

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

at main 566 lines 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/core/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}