Bluesky app fork with some witchin' additions 💫

Merge branch 'main' of https://github.com/bluesky-social/social-app

+1026 -461
+2 -2
bskyweb/cmd/bskyweb/server.go
··· 606 606 type IPCCResponse struct { 607 607 CC string `json:"countryCode"` 608 608 AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"` 609 + AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"` 609 610 } 610 611 611 - // IP address data is powered by IPinfo 612 - // https://ipinfo.io 612 + // This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com. 613 613 func (srv *Server) WebIpCC(c echo.Context) error { 614 614 realIP := c.RealIP() 615 615 addr, err := netip.ParseAddr(realIP)
+1
bskyweb/templates/home.html
··· 12 12 13 13 <meta property="og:url" content="https://bsky.app" /> 14 14 <meta name="twitter:url" content="https://bsky.app" /> 15 + <link rel="canonical" href="https://bsky.app" /> 15 16 16 17 <meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" /> 17 18 <meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
+1
bskyweb/templates/post.html
··· 14 14 <meta property="profile:username" content="{{ profileView.Handle }}"> 15 15 {%- if requestURI %} 16 16 <meta property="og:url" content="{{ requestURI }}"> 17 + <link rel="canonical" href="{{ requestURI }}" /> 17 18 {% endif -%} 18 19 {%- if postView.Author.DisplayName %} 19 20 <meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
+1
bskyweb/templates/profile.html
··· 15 15 <meta property="profile:username" content="{{ profileView.Handle }}"> 16 16 {%- if requestURI %} 17 17 <meta property="og:url" content="{{ requestURI }}"> 18 + <link rel="canonical" href="{{ requestURI }}" /> 18 19 {% endif -%} 19 20 {%- if profileView.DisplayName %} 20 21 <meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
+109
src/components/BlockedGeoOverlay.tsx
··· 1 + import {useEffect} from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {logger} from '#/logger' 8 + import {isWeb} from '#/platform/detection' 9 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 10 + import {Full as Logo, Mark} from '#/components/icons/Logo' 11 + import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' 12 + import {Text} from '#/components/Typography' 13 + 14 + export function BlockedGeoOverlay() { 15 + const t = useTheme() 16 + const {_} = useLingui() 17 + const {gtPhone} = useBreakpoints() 18 + const insets = useSafeAreaInsets() 19 + 20 + useEffect(() => { 21 + // just counting overall hits here 22 + logger.metric(`blockedGeoOverlay:shown`, {}) 23 + }, []) 24 + 25 + const textStyles = [a.text_md, a.leading_normal] 26 + const links = { 27 + blog: { 28 + to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`, 29 + label: _(msg`Read our blog post`), 30 + overridePresentation: false, 31 + disableMismatchWarning: true, 32 + style: textStyles, 33 + }, 34 + } 35 + 36 + const blocks = [ 37 + _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`), 38 + _( 39 + msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`, 40 + ), 41 + _( 42 + msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`, 43 + ), 44 + _( 45 + msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`, 46 + ), 47 + <> 48 + To learn more, read our{' '} 49 + <InlineLinkText {...links.blog}>blog post</InlineLinkText>. 50 + </>, 51 + ] 52 + 53 + return ( 54 + <ScrollView 55 + contentContainerStyle={[ 56 + a.px_2xl, 57 + { 58 + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 59 + paddingBottom: 100, 60 + }, 61 + ]}> 62 + <View 63 + style={[ 64 + a.mx_auto, 65 + web({ 66 + maxWidth: 440, 67 + paddingTop: gtPhone ? '8vh' : undefined, 68 + }), 69 + ]}> 70 + <View style={[a.align_start]}> 71 + <View 72 + style={[ 73 + a.pl_md, 74 + a.pr_lg, 75 + a.py_sm, 76 + a.rounded_full, 77 + a.flex_row, 78 + a.align_center, 79 + a.gap_xs, 80 + { 81 + backgroundColor: t.palette.primary_25, 82 + }, 83 + ]}> 84 + <Mark fill={t.palette.primary_600} width={14} /> 85 + <Text 86 + style={[ 87 + a.font_bold, 88 + { 89 + color: t.palette.primary_600, 90 + }, 91 + ]}> 92 + <Trans>Announcement</Trans> 93 + </Text> 94 + </View> 95 + </View> 96 + 97 + <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}> 98 + {blocks.map((block, index) => ( 99 + <Text key={index} style={[textStyles]}> 100 + {block} 101 + </Text> 102 + ))} 103 + </View> 104 + 105 + <Logo width={120} textFill={t.atoms.text.color} /> 106 + </View> 107 + </ScrollView> 108 + ) 109 + }
+136 -146
src/components/FeedInterstitials.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 3 - import {ScrollView} from 'react-native-gesture-handler' 2 + import {ScrollView, View} from 'react-native' 4 3 import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 5 4 import {msg, Trans} from '@lingui/macro' 6 5 import {useLingui} from '@lingui/react' ··· 9 8 import {type NavigationProp} from '#/lib/routes/types' 10 9 import {logEvent} from '#/lib/statsig/statsig' 11 10 import {logger} from '#/logger' 11 + import {isIOS} from '#/platform/detection' 12 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 13 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 14 14 import {type FeedDescriptor} from '#/state/queries/post-feed' ··· 25 25 type ViewStyleProp, 26 26 web, 27 27 } from '#/alf' 28 - import {Button, ButtonText} from '#/components/Button' 28 + import {Button} from '#/components/Button' 29 29 import * as FeedCard from '#/components/FeedCard' 30 30 import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 31 31 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' ··· 46 46 return ( 47 47 <View 48 48 style={[ 49 + a.flex_1, 49 50 a.w_full, 50 51 a.p_md, 51 52 a.rounded_lg, 52 53 a.border, 53 54 t.atoms.bg, 55 + t.atoms.shadow_sm, 54 56 t.atoms.border_contrast_low, 55 57 !gtMobile && { 56 58 width: MOBILE_CARD_WIDTH, ··· 63 65 } 64 66 65 67 export function SuggestedFollowPlaceholder() { 66 - const t = useTheme() 67 - 68 68 return ( 69 - <CardOuter 70 - style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}> 69 + <CardOuter> 71 70 <ProfileCard.Outer> 72 71 <View 73 72 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> ··· 78 77 </View> 79 78 </View> 80 79 81 - <Button 82 - label="" 83 - size="small" 84 - variant="solid" 85 - color="secondary" 86 - disabled 87 - style={[a.w_full, a.rounded_sm]}> 88 - <ButtonText>Follow</ButtonText> 89 - </Button> 80 + <ProfileCard.FollowButtonPlaceholder /> 90 81 </ProfileCard.Outer> 91 82 </CardOuter> 92 83 ) 93 84 } 94 85 95 86 export function SuggestedFeedsCardPlaceholder() { 96 - const t = useTheme() 97 87 return ( 98 - <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> 88 + <CardOuter style={[a.gap_sm]}> 99 89 <FeedCard.Header> 100 90 <FeedCard.AvatarPlaceholder /> 101 91 <FeedCard.TitleAndBylinePlaceholder creator /> ··· 253 243 profiles: bsky.profile.AnyProfileView[] 254 244 recId?: number 255 245 error: Error | null 256 - viewContext: 'profile' | 'feed' 246 + viewContext: 'profile' | 'profileHeader' | 'feed' 257 247 }) { 258 248 const t = useTheme() 259 249 const {_} = useLingui() 260 250 const moderationOpts = useModerationOpts() 261 251 const {gtMobile} = useBreakpoints() 252 + 262 253 const isLoading = isSuggestionsLoading || !moderationOpts 263 - const maxLength = gtMobile ? 3 : 6 254 + const isProfileHeaderContext = viewContext === 'profileHeader' 255 + const isFeedContext = viewContext === 'feed' 264 256 265 - const content = isLoading ? ( 266 - Array(maxLength) 267 - .fill(0) 268 - .map((_, i) => ( 269 - <View 270 - key={i} 271 - style={[ 272 - gtMobile && 273 - web([ 274 - a.flex_0, 275 - a.flex_grow, 276 - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 277 - ]), 278 - ]}> 279 - <SuggestedFollowPlaceholder /> 280 - </View> 281 - )) 282 - ) : error || !profiles.length ? null : ( 283 - <> 284 - {profiles.slice(0, maxLength).map((profile, index) => ( 285 - <ProfileCard.Link 286 - key={profile.did} 287 - profile={profile} 288 - onPress={() => { 289 - logEvent('suggestedUser:press', { 290 - logContext: 291 - viewContext === 'feed' 257 + const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 258 + const minLength = gtMobile ? 3 : 4 259 + 260 + const content = isLoading 261 + ? Array(maxLength) 262 + .fill(0) 263 + .map((_, i) => ( 264 + <View 265 + key={i} 266 + style={[ 267 + a.flex_1, 268 + gtMobile && 269 + web([ 270 + a.flex_0, 271 + a.flex_grow, 272 + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 273 + ]), 274 + ]}> 275 + <SuggestedFollowPlaceholder /> 276 + </View> 277 + )) 278 + : error || !profiles.length 279 + ? null 280 + : profiles.slice(0, maxLength).map((profile, index) => ( 281 + <ProfileCard.Link 282 + key={profile.did} 283 + profile={profile} 284 + onPress={() => { 285 + logEvent('suggestedUser:press', { 286 + logContext: isFeedContext 292 287 ? 'InterstitialDiscover' 293 288 : 'InterstitialProfile', 294 - recId, 295 - position: index, 296 - }) 297 - }} 298 - style={[ 299 - a.flex_1, 300 - gtMobile && 301 - web([ 302 - a.flex_0, 303 - a.flex_grow, 304 - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 305 - ]), 306 - ]}> 307 - {({hovered, pressed}) => ( 308 - <CardOuter 309 - style={[ 310 - a.flex_1, 311 - t.atoms.shadow_sm, 312 - (hovered || pressed) && t.atoms.border_contrast_high, 313 - ]}> 314 - <ProfileCard.Outer> 315 - <View 316 - style={[ 317 - a.flex_col, 318 - a.align_center, 319 - a.gap_sm, 320 - a.pb_sm, 321 - a.mb_auto, 322 - ]}> 323 - <ProfileCard.Avatar 324 - profile={profile} 325 - moderationOpts={moderationOpts} 326 - size={88} 327 - /> 328 - <View style={[a.flex_col, a.align_center, a.max_w_full]}> 329 - <ProfileCard.Name 289 + recId, 290 + position: index, 291 + }) 292 + }} 293 + style={[ 294 + a.flex_1, 295 + gtMobile && 296 + web([ 297 + a.flex_0, 298 + a.flex_grow, 299 + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, 300 + ]), 301 + ]}> 302 + {({hovered, pressed}) => ( 303 + <CardOuter 304 + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 305 + <ProfileCard.Outer> 306 + <View 307 + style={[ 308 + a.flex_col, 309 + a.align_center, 310 + a.gap_sm, 311 + a.pb_sm, 312 + a.mb_auto, 313 + ]}> 314 + <ProfileCard.Avatar 330 315 profile={profile} 331 316 moderationOpts={moderationOpts} 332 - /> 333 - <ProfileCard.Description 334 - profile={profile} 335 - numberOfLines={2} 336 - style={[ 337 - t.atoms.text_contrast_medium, 338 - a.text_center, 339 - a.text_xs, 340 - ]} 317 + disabledPreview 318 + size={88} 341 319 /> 320 + <View style={[a.flex_col, a.align_center, a.max_w_full]}> 321 + <ProfileCard.Name 322 + profile={profile} 323 + moderationOpts={moderationOpts} 324 + /> 325 + <ProfileCard.Description 326 + profile={profile} 327 + numberOfLines={2} 328 + style={[ 329 + t.atoms.text_contrast_medium, 330 + a.text_center, 331 + a.text_xs, 332 + ]} 333 + /> 334 + </View> 342 335 </View> 343 - </View> 344 336 345 - <ProfileCard.FollowButton 346 - profile={profile} 347 - moderationOpts={moderationOpts} 348 - logContext="FeedInterstitial" 349 - withIcon={false} 350 - style={[a.rounded_sm]} 351 - onFollow={() => { 352 - logEvent('suggestedUser:follow', { 353 - logContext: 354 - viewContext === 'feed' 337 + <ProfileCard.FollowButton 338 + profile={profile} 339 + moderationOpts={moderationOpts} 340 + logContext="FeedInterstitial" 341 + withIcon={false} 342 + style={[a.rounded_sm]} 343 + onFollow={() => { 344 + logEvent('suggestedUser:follow', { 345 + logContext: isFeedContext 355 346 ? 'InterstitialDiscover' 356 347 : 'InterstitialProfile', 357 - location: 'Card', 358 - recId, 359 - position: index, 360 - }) 361 - }} 362 - /> 363 - </ProfileCard.Outer> 364 - </CardOuter> 365 - )} 366 - </ProfileCard.Link> 367 - ))} 368 - </> 369 - ) 348 + location: 'Card', 349 + recId, 350 + position: index, 351 + }) 352 + }} 353 + /> 354 + </ProfileCard.Outer> 355 + </CardOuter> 356 + )} 357 + </ProfileCard.Link> 358 + )) 370 359 371 - if (error || (!isLoading && profiles.length < 4)) { 360 + if (error || (!isLoading && profiles.length < minLength)) { 372 361 logger.debug(`Not enough profiles to show suggested follows`) 373 362 return null 374 363 } 375 364 376 365 return ( 377 366 <View 378 - style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 367 + style={[ 368 + !isProfileHeaderContext && a.border_t, 369 + t.atoms.border_contrast_low, 370 + t.atoms.bg_contrast_25, 371 + ]} 372 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 379 373 <View 380 374 style={[ 381 375 a.px_lg, ··· 383 377 a.flex_row, 384 378 a.align_center, 385 379 a.justify_between, 386 - ]}> 380 + ]} 381 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 387 382 <Text style={[a.text_sm, a.font_bold, t.atoms.text]}> 388 - {viewContext === 'profile' ? ( 389 - <Trans>Similar accounts</Trans> 383 + {isFeedContext ? ( 384 + <Trans>Suggested for you</Trans> 390 385 ) : ( 391 - <Trans>Suggested for you</Trans> 386 + <Trans>Similar accounts</Trans> 392 387 )} 393 388 </Text> 394 - <InlineLinkText 395 - label={_(msg`See more suggested profiles on the Explore page`)} 396 - to="/search"> 397 - <Trans>See more</Trans> 398 - </InlineLinkText> 389 + {!isProfileHeaderContext && ( 390 + <InlineLinkText 391 + label={_(msg`See more suggested profiles on the Explore page`)} 392 + to="/search"> 393 + <Trans>See more</Trans> 394 + </InlineLinkText> 395 + )} 399 396 </View> 400 397 401 398 {gtMobile ? ( ··· 406 403 </View> 407 404 ) : ( 408 405 <BlockDrawerGesture> 409 - <View> 410 - <ScrollView 411 - horizontal 412 - showsHorizontalScrollIndicator={false} 413 - snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 414 - decelerationRate="fast"> 415 - <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}> 416 - {content} 406 + <ScrollView 407 + horizontal 408 + showsHorizontalScrollIndicator={false} 409 + contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} 410 + snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} 411 + decelerationRate="fast"> 412 + {content} 417 413 418 - <SeeMoreSuggestedProfilesCard /> 419 - </View> 420 - </ScrollView> 421 - </View> 414 + {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />} 415 + </ScrollView> 422 416 </BlockDrawerGesture> 423 417 )} 424 418 </View> ··· 427 421 428 422 function SeeMoreSuggestedProfilesCard() { 429 423 const navigation = useNavigation<NavigationProp>() 430 - const t = useTheme() 431 424 const {_} = useLingui() 432 425 433 426 return ( ··· 437 430 onPress={() => { 438 431 navigation.navigate('SearchTab') 439 432 }}> 440 - <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}> 433 + <CardOuter> 441 434 <View style={[a.flex_1, a.justify_center]}> 442 435 <View style={[a.flex_col, a.align_center, a.gap_md]}> 443 436 <Text style={[a.leading_snug, a.text_center]}> ··· 491 484 }}> 492 485 {({hovered, pressed}) => ( 493 486 <CardOuter 494 - style={[ 495 - a.flex_1, 496 - (hovered || pressed) && t.atoms.border_contrast_high, 497 - ]}> 487 + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> 498 488 <FeedCard.Outer> 499 489 <FeedCard.Header> 500 490 <FeedCard.Avatar src={feed.avatar} /> ··· 568 558 navigation.navigate('SearchTab') 569 559 }} 570 560 style={[a.flex_col]}> 571 - <CardOuter style={[a.flex_1]}> 561 + <CardOuter> 572 562 <View style={[a.flex_1, a.justify_center]}> 573 563 <View style={[a.flex_row, a.px_lg]}> 574 564 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
+87 -1
src/components/Link.tsx
··· 1 1 import React, {useMemo} from 'react' 2 - import {type GestureResponderEvent} from 'react-native' 2 + import {type GestureResponderEvent, Linking} from 'react-native' 3 3 import {sanitizeUrl} from '@braintree/sanitize-url' 4 4 import { 5 5 type LinkProps as RNLinkProps, ··· 13 13 import {shareUrl} from '#/lib/sharing' 14 14 import { 15 15 convertBskyAppUrlIfNeeded, 16 + createProxiedUrl, 16 17 isBskyDownloadUrl, 17 18 isExternalUrl, 18 19 linkRequiresWarning, ··· 392 393 role="link" 393 394 onPress={download ? undefined : onPress} 394 395 onLongPress={onLongPress} 396 + onMouseEnter={onHoverIn} 397 + onMouseLeave={onHoverOut} 398 + accessibilityRole="link" 399 + href={href} 400 + {...web({ 401 + hrefAttrs: { 402 + target: download ? undefined : isExternal ? 'blank' : undefined, 403 + rel: isExternal ? 'noopener noreferrer' : undefined, 404 + download, 405 + }, 406 + dataSet: { 407 + // default to no underline, apply this ourselves 408 + noUnderline: '1', 409 + }, 410 + })}> 411 + {children} 412 + </Text> 413 + ) 414 + } 415 + 416 + /** 417 + * A barebones version of `InlineLinkText`, for use outside a 418 + * `react-navigation` context. 419 + */ 420 + export function SimpleInlineLinkText({ 421 + children, 422 + to, 423 + style, 424 + download, 425 + selectable, 426 + label, 427 + disableUnderline, 428 + shouldProxy, 429 + ...rest 430 + }: Omit< 431 + InlineLinkProps, 432 + | 'to' 433 + | 'action' 434 + | 'disableMismatchWarning' 435 + | 'overridePresentation' 436 + | 'onPress' 437 + | 'onLongPress' 438 + | 'shareOnLongPress' 439 + > & { 440 + to: string 441 + }) { 442 + const t = useTheme() 443 + const { 444 + state: hovered, 445 + onIn: onHoverIn, 446 + onOut: onHoverOut, 447 + } = useInteractionState() 448 + const flattenedStyle = flatten(style) || {} 449 + const isExternal = isExternalUrl(to) 450 + 451 + let href = to 452 + if (shouldProxy) { 453 + href = createProxiedUrl(href) 454 + } 455 + 456 + const onPress = () => { 457 + Linking.openURL(href) 458 + } 459 + 460 + return ( 461 + <Text 462 + selectable={selectable} 463 + accessibilityHint="" 464 + accessibilityLabel={label} 465 + {...rest} 466 + style={[ 467 + {color: t.palette.primary_500}, 468 + hovered && 469 + !disableUnderline && { 470 + ...web({ 471 + outline: 0, 472 + textDecorationLine: 'underline', 473 + textDecorationColor: 474 + flattenedStyle.color ?? t.palette.primary_500, 475 + }), 476 + }, 477 + flattenedStyle, 478 + ]} 479 + role="link" 480 + onPress={onPress} 395 481 onMouseEnter={onHoverIn} 396 482 onMouseLeave={onHoverOut} 397 483 accessibilityRole="link"
+31 -6
src/components/ProfileCard.tsx
··· 513 513 comment: 'User is following this account, click to unfollow', 514 514 }), 515 515 ) 516 - const followLabel = _( 517 - msg({ 518 - message: 'Follow', 519 - comment: 'User is not following this account, click to follow', 520 - }), 521 - ) 516 + const followLabel = profile.viewer?.followedBy 517 + ? _( 518 + msg({ 519 + message: 'Follow back', 520 + comment: 'User is not following this account, click to follow back', 521 + }), 522 + ) 523 + : _( 524 + msg({ 525 + message: 'Follow', 526 + comment: 'User is not following this account, click to follow', 527 + }), 528 + ) 522 529 523 530 if (!profile.viewer) return null 524 531 if ( ··· 558 565 </Button> 559 566 )} 560 567 </View> 568 + ) 569 + } 570 + 571 + export function FollowButtonPlaceholder({style}: ViewStyleProp) { 572 + const t = useTheme() 573 + 574 + return ( 575 + <View 576 + style={[ 577 + a.rounded_sm, 578 + t.atoms.bg_contrast_25, 579 + a.w_full, 580 + { 581 + height: 33, 582 + }, 583 + style, 584 + ]} 585 + /> 561 586 ) 562 587 } 563 588
+37
src/components/icons/Logo.tsx
··· 1 + import Svg, {Path} from 'react-native-svg' 2 + 3 + import {type Props, useCommonSVGProps} from './common' 1 4 import {createSinglePathSVG} from './TEMPLATE' 2 5 3 6 export const Mark = createSinglePathSVG({ 4 7 path: 'M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z', 5 8 }) 9 + 10 + export function Full( 11 + props: Omit<Props, 'fill' | 'size' | 'height'> & { 12 + markFill?: Props['fill'] 13 + textFill?: Props['fill'] 14 + }, 15 + ) { 16 + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) 17 + const ratio = 123 / 555 18 + 19 + return ( 20 + <Svg 21 + fill="none" 22 + {...rest} 23 + viewBox="0 0 555 123" 24 + width={size} 25 + height={size * ratio} 26 + style={[style]}> 27 + {gradient} 28 + <Path 29 + fill={props.markFill ?? fill} 30 + fillRule="evenodd" 31 + clipRule="evenodd" 32 + d="M101.821 7.673C112.575-.367 130-6.589 130 13.21c0 3.953-2.276 33.214-3.611 37.965-4.641 16.516-21.549 20.729-36.591 18.179 26.292 4.457 32.979 19.218 18.535 33.98-27.433 28.035-39.428-7.034-42.502-16.02-.563-1.647-.827-2.418-.831-1.763-.004-.655-.268.116-.831 1.763-3.074 8.986-15.07 44.055-42.502 16.02C7.223 88.571 13.91 73.81 40.202 69.353c-15.041 2.55-31.95-1.663-36.59-18.179C2.275 46.424 0 17.162 0 13.21 0-6.59 17.426-.368 28.18 7.673 43.084 18.817 59.114 41.413 65 53.54c5.886-12.125 21.917-34.722 36.821-45.866Z" 33 + /> 34 + <Path 35 + fill={props.textFill ?? fill} 36 + fillRule="evenodd" 37 + clipRule="evenodd" 38 + d="m454.459 63.823 24.128-25.056h32.638l4.825 15.104c3.561 11.357 6.664 22.598 9.422 33.72 2.527-9.6 5.744-20.84 9.536-33.603l4.826-15.221H555l-22.864 65.335c-2.413 6.673-5.4 11.475-9.192 14.168-3.791 2.693-9.192 3.98-16.315 3.98-2.413 0-4.481-.117-6.319-.352v-11.59h5.514c6.549 0 9.767-4.099 9.767-9.719 0-2.81-.92-6.908-2.758-12.177l-17.177-49.478-22.239 22.665L497.2 99.184h-16.545l-17.234-28.101-8.962 9.133v18.968h-14.246V15.817h14.246v48.006Zm-48.373-26.46c16.889 0 25.622 6.79 26.196 20.49h-13.673c-.344-7.377-4.595-9.954-12.523-9.954-6.894 0-10.341 2.342-10.341 7.026 0 4.215 2.987 6.089 9.881 7.377l7.469 1.17c14.361 2.694 20.566 8.08 20.566 18.384 0 12.176-9.652 18.967-26.311 18.967-17.235 0-26.311-6.908-27.116-20.842h14.132c.804 7.494 4.481 10.304 13.213 10.304 7.813 0 11.72-2.459 11.72-7.26 0-4.332-2.758-6.44-11.605-7.962l-6.778-1.17c-12.983-2.224-19.418-8.313-19.418-18.265 0-11.358 8.847-18.266 24.588-18.266ZM270.534 76.351c0 7.61 3.677 11.474 11.145 11.474 7.008 0 13.212-5.268 13.213-15.22v-33.84h14.476v60.418h-14.016v-8.782c-4.481 6.791-10.686 10.187-18.614 10.187-12.523 0-20.68-7.728-20.68-21.778V38.767h14.476v37.585Zm75.432-38.99c8.961 0 16.085 3.045 21.37 9.016s7.928 13.933 7.928 23.651v3.513h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.663 0 11.144-2.927 13.557-8.664h13.903c-3.103 12.294-13.443 20.139-27.575 20.139-8.847 0-15.971-2.927-21.371-8.664-5.4-5.737-8.157-13.348-8.157-22.95 0-9.483 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664ZM195.628 15.817c17.809 0 26.426 9.251 26.426 21.545 0 8.196-3.677 14.168-10.915 17.914 9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.767 23.886-28.494 23.886h-38.26V15.817h36.996Zm51.264 83.367h-14.477V15.817h14.477v83.367ZM174.143 86.07h21.944c8.732 0 13.443-4.098 13.443-11.474 0-7.728-4.481-11.592-13.443-11.592h-21.944V86.07Zm171.708-37.233c-7.928 0-13.443 4.683-14.822 14.401h29.758c-1.264-8.781-6.549-14.401-14.936-14.401Zm-171.708 1.756h20.336c7.927 0 12.178-4.215 12.178-11.24 0-6.44-4.366-10.539-12.178-10.539h-20.336v21.779Z" 39 + /> 40 + </Svg> 41 + ) 42 + }
+55 -53
src/components/interstitials/TrendingVideos.tsx
··· 1 - import React, {useEffect} from 'react' 1 + import {useCallback, useEffect, useMemo} from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 3 import {AppBskyEmbedVideo, AtUri} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' ··· 55 55 const {setTrendingVideoDisabled} = useTrendingSettingsApi() 56 56 const trendingPrompt = Prompt.usePromptControl() 57 57 58 - const onConfirmHide = React.useCallback(() => { 58 + const onConfirmHide = useCallback(() => { 59 59 setTrendingVideoDisabled(true) 60 60 logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) 61 61 }, [setTrendingVideoDisabled]) ··· 147 147 }: { 148 148 data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 149 149 }) { 150 - const t = useTheme() 151 - const {_} = useLingui() 152 - const items = React.useMemo(() => { 150 + const items = useMemo(() => { 153 151 return data.pages 154 152 .flatMap(page => page.slices) 155 153 .map(slice => slice.items[0]) ··· 157 155 .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) 158 156 .slice(0, 8) 159 157 }, [data]) 160 - const href = React.useMemo(() => { 161 - const urip = new AtUri(VIDEO_FEED_URI) 162 - return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') 163 - }, []) 164 158 165 159 return ( 166 160 <> ··· 183 177 </View> 184 178 ))} 185 179 186 - <View style={[{width: CARD_WIDTH * 2}]}> 187 - <Link 188 - to={href} 189 - label={_(msg`View more`)} 190 - style={[ 191 - a.justify_center, 192 - a.align_center, 193 - a.flex_1, 194 - a.rounded_lg, 195 - a.border, 196 - t.atoms.border_contrast_low, 197 - t.atoms.bg, 198 - t.atoms.shadow_sm, 199 - ]}> 200 - {({pressed}) => ( 201 - <View 202 - style={[ 203 - a.flex_row, 204 - a.align_center, 205 - a.gap_md, 206 - { 207 - opacity: pressed ? 0.6 : 1, 208 - }, 209 - ]}> 210 - <Text style={[a.text_md]}> 211 - <Trans>View more</Trans> 212 - </Text> 213 - <View 214 - style={[ 215 - a.align_center, 216 - a.justify_center, 217 - a.rounded_full, 218 - { 219 - width: 34, 220 - height: 34, 221 - backgroundColor: t.palette.primary_500, 222 - }, 223 - ]}> 224 - <ButtonIcon icon={ChevronRight} /> 225 - </View> 226 - </View> 227 - )} 228 - </Link> 229 - </View> 180 + <ViewMoreCard /> 230 181 </> 231 182 ) 232 183 } 184 + 185 + function ViewMoreCard() { 186 + const t = useTheme() 187 + const {_} = useLingui() 188 + 189 + const href = useMemo(() => { 190 + const urip = new AtUri(VIDEO_FEED_URI) 191 + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') 192 + }, []) 193 + 194 + return ( 195 + <View style={[{width: CARD_WIDTH * 2}]}> 196 + <Link 197 + to={href} 198 + label={_(msg`View more`)} 199 + style={[ 200 + a.justify_center, 201 + a.align_center, 202 + a.flex_1, 203 + a.rounded_lg, 204 + a.border, 205 + t.atoms.border_contrast_low, 206 + t.atoms.bg, 207 + t.atoms.shadow_sm, 208 + ]}> 209 + {({pressed}) => ( 210 + <View 211 + style={[ 212 + a.flex_row, 213 + a.align_center, 214 + a.gap_md, 215 + { 216 + opacity: pressed ? 0.6 : 1, 217 + }, 218 + ]}> 219 + <Text style={[a.text_md]}> 220 + <Trans>View more</Trans> 221 + </Text> 222 + <Button 223 + color="primary" 224 + size="small" 225 + shape="round" 226 + label={_(msg`View more trending videos`)}> 227 + <ButtonIcon icon={ChevronRight} /> 228 + </Button> 229 + </View> 230 + )} 231 + </Link> 232 + </View> 233 + ) 234 + }
+77
src/lib/custom-animations/AccordionAnimation.tsx
··· 1 + import { 2 + type LayoutChangeEvent, 3 + type StyleProp, 4 + View, 5 + type ViewStyle, 6 + } from 'react-native' 7 + import Animated, { 8 + Easing, 9 + FadeInUp, 10 + FadeOutUp, 11 + useAnimatedStyle, 12 + useSharedValue, 13 + withTiming, 14 + } from 'react-native-reanimated' 15 + 16 + import {isIOS, isWeb} from '#/platform/detection' 17 + 18 + type AccordionAnimationProps = React.PropsWithChildren<{ 19 + isExpanded: boolean 20 + duration?: number 21 + style?: StyleProp<ViewStyle> 22 + }> 23 + 24 + function WebAccordion({ 25 + isExpanded, 26 + duration = 300, 27 + style, 28 + children, 29 + }: AccordionAnimationProps) { 30 + const heightValue = useSharedValue(0) 31 + 32 + const animatedStyle = useAnimatedStyle(() => { 33 + const targetHeight = isExpanded ? heightValue.get() : 0 34 + return { 35 + height: withTiming(targetHeight, { 36 + duration, 37 + easing: Easing.out(Easing.cubic), 38 + }), 39 + overflow: 'hidden', 40 + } 41 + }) 42 + 43 + const onLayout = (e: LayoutChangeEvent) => { 44 + if (heightValue.get() === 0) { 45 + heightValue.set(e.nativeEvent.layout.height) 46 + } 47 + } 48 + 49 + return ( 50 + <Animated.View style={[animatedStyle, style]}> 51 + <View onLayout={onLayout}>{children}</View> 52 + </Animated.View> 53 + ) 54 + } 55 + 56 + function MobileAccordion({ 57 + isExpanded, 58 + duration = 200, 59 + style, 60 + children, 61 + }: AccordionAnimationProps) { 62 + if (!isExpanded) return null 63 + 64 + return ( 65 + <Animated.View 66 + style={style} 67 + entering={FadeInUp.duration(duration)} 68 + exiting={FadeOutUp.duration(duration / 2)} 69 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 70 + {children} 71 + </Animated.View> 72 + ) 73 + } 74 + 75 + export function AccordionAnimation(props: AccordionAnimationProps) { 76 + return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} /> 77 + }
+1
src/lib/statsig/gates.ts
··· 8 8 | 'handle_suggestions' 9 9 | 'old_postonboarding' 10 10 | 'onboarding_add_video_feed' 11 + | 'post_follow_profile_suggested_accounts' 11 12 | 'post_threads_v2_unspecced' 12 13 | 'remove_show_latest_button' 13 14 | 'test_gate_1'
+67 -43
src/locale/locales/en/messages.po
··· 524 524 msgid "A new form of verification" 525 525 msgstr "" 526 526 527 + #: src/components/BlockedGeoOverlay.tsx:39 528 + msgid "A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies." 529 + msgstr "" 530 + 527 531 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:113 528 532 msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature." 529 533 msgstr "" ··· 602 606 msgid "Account removed from quick access" 603 607 msgstr "" 604 608 605 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:128 609 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132 606 610 #: src/view/com/profile/ProfileMenu.tsx:148 607 611 msgctxt "toast" 608 612 msgid "Account unblocked" ··· 1012 1016 msgid "Animated GIF" 1013 1017 msgstr "" 1014 1018 1019 + #: src/components/BlockedGeoOverlay.tsx:92 1015 1020 #: src/components/PolicyUpdateOverlay/Badge.tsx:33 1016 1021 msgid "Announcement" 1017 1022 msgstr "" ··· 1190 1195 msgid "Artistic or non-erotic nudity." 1191 1196 msgstr "" 1192 1197 1198 + #: src/components/BlockedGeoOverlay.tsx:42 1199 + msgid "As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending." 1200 + msgstr "" 1201 + 1193 1202 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:491 1194 1203 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:493 1195 1204 msgid "Assign topic for algo" ··· 1280 1289 msgstr "" 1281 1290 1282 1291 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:753 1283 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320 1292 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328 1284 1293 #: src/view/com/profile/ProfileMenu.tsx:473 1285 1294 msgid "Block" 1286 1295 msgstr "" ··· 1449 1458 msgid "Books" 1450 1459 msgstr "" 1451 1460 1452 - #: src/components/FeedInterstitials.tsx:435 1461 + #: src/components/FeedInterstitials.tsx:428 1453 1462 msgid "Browse more accounts on the Explore page" 1454 1463 msgstr "" 1455 1464 1456 - #: src/components/FeedInterstitials.tsx:566 1465 + #: src/components/FeedInterstitials.tsx:556 1457 1466 msgid "Browse more feeds on the Explore page" 1458 1467 msgstr "" 1459 1468 1460 - #: src/components/FeedInterstitials.tsx:547 1461 - #: src/components/FeedInterstitials.tsx:550 1469 + #: src/components/FeedInterstitials.tsx:537 1470 + #: src/components/FeedInterstitials.tsx:540 1462 1471 msgid "Browse more suggestions" 1463 1472 msgstr "" 1464 1473 1465 - #: src/components/FeedInterstitials.tsx:575 1474 + #: src/components/FeedInterstitials.tsx:565 1466 1475 msgid "Browse more suggestions on the Explore page" 1467 1476 msgstr "" 1468 1477 ··· 1905 1914 msgid "Close image" 1906 1915 msgstr "" 1907 1916 1908 - #: src/view/com/lightbox/Lightbox.web.tsx:109 1917 + #: src/view/com/lightbox/Lightbox.web.tsx:110 1909 1918 msgid "Close image viewer" 1910 1919 msgstr "" 1911 1920 ··· 2906 2915 #: src/screens/Profile/Header/EditProfileDialog.tsx:276 2907 2916 #: src/screens/Profile/Header/EditProfileDialog.tsx:282 2908 2917 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:183 2909 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:185 2918 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:190 2910 2919 msgid "Edit profile" 2911 2920 msgstr "" 2912 2921 2913 2922 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:186 2914 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:188 2923 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:193 2915 2924 msgid "Edit Profile" 2916 2925 msgstr "" 2917 2926 ··· 3207 3216 msgid "Exits image cropping process" 3208 3217 msgstr "" 3209 3218 3210 - #: src/view/com/lightbox/Lightbox.web.tsx:110 3219 + #: src/view/com/lightbox/Lightbox.web.tsx:111 3211 3220 msgid "Exits image view" 3212 3221 msgstr "" 3213 3222 3214 - #: src/view/com/lightbox/Lightbox.web.tsx:184 3223 + #: src/view/com/lightbox/Lightbox.web.tsx:185 3215 3224 msgid "Expand alt text" 3216 3225 msgstr "" 3217 3226 ··· 3627 3636 msgstr "" 3628 3637 3629 3638 #. User is not following this account, click to follow 3630 - #: src/components/ProfileCard.tsx:517 3639 + #: src/components/ProfileCard.tsx:524 3631 3640 #: src/components/ProfileHoverCard/index.web.tsx:496 3632 3641 #: src/components/ProfileHoverCard/index.web.tsx:507 3633 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:245 3642 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:252 3634 3643 #: src/screens/VideoFeed/index.tsx:851 3635 3644 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:131 3636 3645 msgid "Follow" ··· 3641 3650 msgid "Follow" 3642 3651 msgstr "" 3643 3652 3644 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:230 3653 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:237 3645 3654 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:113 3646 3655 msgid "Follow {0}" 3647 3656 msgstr "" ··· 3669 3678 msgid "Follow all" 3670 3679 msgstr "" 3671 3680 3672 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:243 3681 + #. User is not following this account, click to follow back 3682 + #: src/components/ProfileCard.tsx:518 3683 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:250 3673 3684 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:129 3674 - msgid "Follow Back" 3685 + msgid "Follow back" 3675 3686 msgstr "" 3676 3687 3677 3688 #: src/view/com/profile/FollowButton.tsx:81 3678 3689 msgctxt "action" 3679 - msgid "Follow Back" 3690 + msgid "Follow back" 3680 3691 msgstr "" 3681 3692 3682 3693 #: src/components/KnownFollowers.tsx:238 ··· 3708 3719 #: src/components/ProfileCard.tsx:511 3709 3720 #: src/components/ProfileHoverCard/index.web.tsx:495 3710 3721 #: src/components/ProfileHoverCard/index.web.tsx:506 3711 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:241 3722 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:248 3712 3723 #: src/screens/VideoFeed/index.tsx:849 3713 3724 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:134 3714 3725 msgid "Following" ··· 3721 3732 msgstr "" 3722 3733 3723 3734 #: src/components/ProfileCard.tsx:474 3724 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:89 3735 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:92 3725 3736 msgid "Following {0}" 3726 3737 msgstr "" 3727 3738 ··· 3758 3769 #: src/screens/Onboarding/index.tsx:40 3759 3770 #: src/screens/Onboarding/state.ts:105 3760 3771 msgid "Food" 3772 + msgstr "" 3773 + 3774 + #: src/components/BlockedGeoOverlay.tsx:45 3775 + msgid "For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi." 3761 3776 msgstr "" 3762 3777 3763 3778 #: src/view/com/modals/DeleteAccount.tsx:125 ··· 4234 4249 msgid "I understand" 4235 4250 msgstr "" 4236 4251 4237 - #: src/view/com/lightbox/Lightbox.web.tsx:186 4252 + #: src/view/com/lightbox/Lightbox.web.tsx:187 4238 4253 msgid "If alt text is long, toggles alt text expanded state" 4239 4254 msgstr "" 4240 4255 ··· 5444 5459 msgid "Next" 5445 5460 msgstr "" 5446 5461 5447 - #: src/view/com/lightbox/Lightbox.web.tsx:169 5462 + #: src/view/com/lightbox/Lightbox.web.tsx:170 5448 5463 msgid "Next image" 5449 5464 msgstr "" 5450 5465 ··· 5479 5494 msgstr "" 5480 5495 5481 5496 #: src/components/ProfileCard.tsx:496 5482 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:110 5497 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:114 5483 5498 msgid "No longer following {0}" 5484 5499 msgstr "" 5485 5500 ··· 6451 6466 msgid "Press to view followers of this account that you also follow" 6452 6467 msgstr "" 6453 6468 6454 - #: src/view/com/lightbox/Lightbox.web.tsx:150 6469 + #: src/view/com/lightbox/Lightbox.web.tsx:151 6455 6470 msgid "Previous image" 6456 6471 msgstr "" 6457 6472 ··· 6651 6666 msgstr "" 6652 6667 6653 6668 #: src/screens/PostThread/components/ThreadItemReadMore.tsx:92 6654 - msgid "Read {0} more {1, plural, one {reply} other {replies}}" 6669 + msgid "Read {0, plural, one {# more reply} other {# more replies}}" 6655 6670 msgstr "" 6656 6671 6657 6672 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:158 ··· 6671 6686 msgid "Read more replies" 6672 6687 msgstr "" 6673 6688 6689 + #: src/components/BlockedGeoOverlay.tsx:29 6674 6690 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112 6675 6691 msgid "Read our blog post" 6676 6692 msgstr "" ··· 7432 7448 msgid "See jobs at Bluesky" 7433 7449 msgstr "" 7434 7450 7435 - #: src/components/FeedInterstitials.tsx:397 7451 + #: src/components/FeedInterstitials.tsx:393 7436 7452 msgid "See more" 7437 7453 msgstr "" 7438 7454 7439 - #: src/components/FeedInterstitials.tsx:444 7455 + #: src/components/FeedInterstitials.tsx:437 7440 7456 msgid "See more accounts you might like" 7441 7457 msgstr "" 7442 7458 7443 - #: src/components/FeedInterstitials.tsx:395 7459 + #: src/components/FeedInterstitials.tsx:391 7444 7460 msgid "See more suggested profiles on the Explore page" 7445 7461 msgstr "" 7446 7462 ··· 8023 8039 msgid "Signed in as @{0}" 8024 8040 msgstr "" 8025 8041 8026 - #: src/components/FeedInterstitials.tsx:389 8042 + #: src/components/FeedInterstitials.tsx:386 8027 8043 msgid "Similar accounts" 8028 8044 msgstr "" 8029 8045 ··· 8053 8069 msgid "Some of your verifications are invalid." 8054 8070 msgstr "" 8055 8071 8056 - #: src/components/FeedInterstitials.tsx:529 8072 + #: src/components/FeedInterstitials.tsx:519 8057 8073 msgid "Some other feeds you might like" 8058 8074 msgstr "" 8059 8075 ··· 8285 8301 msgid "Suggested Accounts" 8286 8302 msgstr "" 8287 8303 8288 - #: src/components/FeedInterstitials.tsx:391 8304 + #: src/components/FeedInterstitials.tsx:384 8289 8305 msgid "Suggested for you" 8290 8306 msgstr "" 8291 8307 ··· 8475 8491 msgid "That's everything!" 8476 8492 msgstr "" 8477 8493 8478 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:316 8494 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:324 8479 8495 #: src/view/com/profile/ProfileMenu.tsx:461 8480 8496 msgid "The account will be able to interact with you after unblocking." 8481 8497 msgstr "" ··· 8645 8661 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:361 8646 8662 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:374 8647 8663 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:384 8648 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:98 8649 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:119 8650 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132 8664 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:101 8665 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:123 8666 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:136 8651 8667 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:90 8652 8668 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:101 8653 8669 #: src/view/com/profile/ProfileMenu.tsx:128 ··· 9095 9111 #: src/components/dms/MessagesListBlockedFooter.tsx:104 9096 9112 #: src/components/dms/MessagesListBlockedFooter.tsx:112 9097 9113 #: src/components/dms/MessagesListBlockedFooter.tsx:119 9098 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:203 9099 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320 9114 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208 9115 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328 9100 9116 #: src/view/com/profile/ProfileMenu.tsx:473 9101 9117 #: src/view/screens/ProfileList.tsx:723 9102 9118 msgid "Unblock" 9103 9119 msgstr "" 9104 9120 9105 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208 9121 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:213 9106 9122 msgctxt "action" 9107 9123 msgid "Unblock" 9108 9124 msgstr "" ··· 9114 9130 msgid "Unblock account" 9115 9131 msgstr "" 9116 9132 9117 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314 9133 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:322 9118 9134 #: src/view/com/profile/ProfileMenu.tsx:455 9119 9135 msgid "Unblock Account?" 9120 9136 msgstr "" ··· 9138 9154 msgid "Unfollow" 9139 9155 msgstr "" 9140 9156 9141 - #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:229 9157 + #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:236 9142 9158 msgid "Unfollow {0}" 9143 9159 msgstr "" 9144 9160 ··· 9151 9167 msgid "Unfollows the user" 9152 9168 msgstr "" 9153 9169 9170 + #: src/components/BlockedGeoOverlay.tsx:37 9171 + msgid "Unfortunately, Bluesky is unavailable in Mississippi right now." 9172 + msgstr "" 9173 + 9154 9174 #: src/components/moderation/ReportDialog/index.tsx:372 9155 9175 msgid "Unfortunately, none of your subscribed labelers supports this report type." 9156 9176 msgstr "" ··· 9674 9694 msgid "View information about these labels" 9675 9695 msgstr "" 9676 9696 9677 - #: src/components/interstitials/TrendingVideos.tsx:189 9678 - #: src/components/interstitials/TrendingVideos.tsx:211 9697 + #: src/components/interstitials/TrendingVideos.tsx:198 9698 + #: src/components/interstitials/TrendingVideos.tsx:220 9679 9699 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:194 9680 9700 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:213 9681 9701 msgid "View more" 9702 + msgstr "" 9703 + 9704 + #: src/components/interstitials/TrendingVideos.tsx:226 9705 + msgid "View more trending videos" 9682 9706 msgstr "" 9683 9707 9684 9708 #: src/components/ProfileHoverCard/index.web.tsx:466 ··· 10541 10565 msgstr "" 10542 10566 10543 10567 #: src/screens/Moderation/index.tsx:208 10544 - msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your bithdate in your <0>account settings</0>." 10568 + msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your birthdate in your <0>account settings</0>." 10545 10569 msgstr "" 10546 10570 10547 10571 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:253
+7
src/logger/metrics.ts
··· 475 475 'ageAssurance:redirectDialogFail': {} 476 476 'ageAssurance:appealDialogOpen': {} 477 477 'ageAssurance:appealDialogSubmit': {} 478 + 479 + /* 480 + * Specifically for the `BlockedGeoOverlay` 481 + */ 482 + 'blockedGeoOverlay:shown': {} 483 + 484 + 'geo:debug': {} 478 485 }
+1 -1
src/screens/Moderation/index.tsx
··· 207 207 <Admonition type="tip" style={[a.pb_md]}> 208 208 <Trans> 209 209 Your declared age is under 18. Some settings below may be 210 - disabled. If this was a mistake, you may edit your bithdate in 210 + disabled. If this was a mistake, you may edit your birthdate in 211 211 your{' '} 212 212 <InlineLinkText 213 213 to="/settings/account"
+3 -3
src/screens/PostThread/components/ThreadItemReadMore.tsx
··· 90 90 interacted && a.underline, 91 91 ]}> 92 92 <Trans> 93 - Read {item.moreReplies} more{' '} 93 + Read{' '} 94 94 <Plural 95 - one="reply" 96 - other="replies" 95 + one="# more reply" 96 + other="# more replies" 97 97 value={item.moreReplies} 98 98 /> 99 99 </Trans>
+172 -157
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 1 - import React, {memo, useMemo} from 'react' 1 + import {memo, useCallback, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, ··· 40 40 import {ProfileHeaderHandle} from './Handle' 41 41 import {ProfileHeaderMetrics} from './Metrics' 42 42 import {ProfileHeaderShell} from './Shell' 43 + import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows' 43 44 44 45 interface Props { 45 46 profile: AppBskyActorDefs.ProfileViewDetailed ··· 73 74 const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 74 75 const unblockPromptControl = Prompt.usePromptControl() 75 76 const requireAuth = useRequireAuth() 77 + const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) 76 78 const isBlockedUser = 77 79 profile.viewer?.blocking || 78 80 profile.viewer?.blockedBy || ··· 81 83 const editProfileControl = useDialogControl() 82 84 83 85 const onPressFollow = () => { 86 + setShowSuggestedFollows(true) 84 87 requireAuth(async () => { 85 88 try { 86 89 await queueFollow() ··· 102 105 } 103 106 104 107 const onPressUnfollow = () => { 108 + setShowSuggestedFollows(false) 105 109 requireAuth(async () => { 106 110 try { 107 111 await queueUnfollow() ··· 122 126 }) 123 127 } 124 128 125 - const unblockAccount = React.useCallback(async () => { 129 + const unblockAccount = useCallback(async () => { 126 130 try { 127 131 await queueUnblock() 128 132 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) ··· 155 159 }, [profile]) 156 160 157 161 return ( 158 - <ProfileHeaderShell 159 - profile={profile} 160 - moderation={moderation} 161 - hideBackButton={hideBackButton} 162 - isPlaceholderProfile={isPlaceholderProfile}> 163 - <View 164 - style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} 165 - pointerEvents={isIOS ? 'auto' : 'box-none'}> 162 + <> 163 + <ProfileHeaderShell 164 + profile={profile} 165 + moderation={moderation} 166 + hideBackButton={hideBackButton} 167 + isPlaceholderProfile={isPlaceholderProfile}> 166 168 <View 167 - style={[ 168 - {paddingLeft: 90}, 169 - a.flex_row, 170 - a.align_center, 171 - a.justify_end, 172 - a.gap_xs, 173 - a.pb_sm, 174 - a.flex_wrap, 175 - ]} 169 + style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} 176 170 pointerEvents={isIOS ? 'auto' : 'box-none'}> 177 - {isMe ? ( 178 - <> 179 - <Button 180 - testID="profileHeaderEditProfileButton" 181 - size="small" 182 - color="secondary" 183 - variant="solid" 184 - onPress={editProfileControl.open} 185 - label={_(msg`Edit profile`)} 186 - style={[a.rounded_full]}> 187 - <ButtonText> 188 - <Trans>Edit Profile</Trans> 189 - </ButtonText> 190 - </Button> 191 - <EditProfileDialog 192 - profile={profile} 193 - control={editProfileControl} 194 - /> 195 - </> 196 - ) : profile.viewer?.blocking ? ( 197 - profile.viewer?.blockingByList ? null : ( 198 - <Button 199 - testID="unblockBtn" 200 - size="small" 201 - color="secondary" 202 - variant="solid" 203 - label={_(msg`Unblock`)} 204 - disabled={!hasSession} 205 - onPress={() => unblockPromptControl.open()} 206 - style={[a.rounded_full]}> 207 - <ButtonText> 208 - <Trans context="action">Unblock</Trans> 209 - </ButtonText> 210 - </Button> 211 - ) 212 - ) : !profile.viewer?.blockedBy ? ( 213 - <> 214 - {hasSession && subscriptionsAllowed && ( 215 - <SubscribeProfileButton 171 + <View 172 + style={[ 173 + {paddingLeft: 90}, 174 + a.flex_row, 175 + a.align_center, 176 + a.justify_end, 177 + a.gap_xs, 178 + a.pb_sm, 179 + a.flex_wrap, 180 + ]} 181 + pointerEvents={isIOS ? 'auto' : 'box-none'}> 182 + {isMe ? ( 183 + <> 184 + <Button 185 + testID="profileHeaderEditProfileButton" 186 + size="small" 187 + color="secondary" 188 + variant="solid" 189 + onPress={editProfileControl.open} 190 + label={_(msg`Edit profile`)} 191 + style={[a.rounded_full]}> 192 + <ButtonText> 193 + <Trans>Edit Profile</Trans> 194 + </ButtonText> 195 + </Button> 196 + <EditProfileDialog 216 197 profile={profile} 217 - moderationOpts={moderationOpts} 198 + control={editProfileControl} 218 199 /> 219 - )} 220 - {hasSession && <MessageProfileButton profile={profile} />} 221 - 222 - <Button 223 - testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 224 - size="small" 225 - color={profile.viewer?.following ? 'secondary' : 'primary'} 226 - variant="solid" 227 - label={ 228 - profile.viewer?.following 229 - ? _(msg`Unfollow ${profile.handle}`) 230 - : _(msg`Follow ${profile.handle}`) 231 - } 232 - onPress={ 233 - profile.viewer?.following ? onPressUnfollow : onPressFollow 234 - } 235 - style={[a.rounded_full]}> 236 - {!profile.viewer?.following && ( 237 - <ButtonIcon position="left" icon={Plus} /> 200 + </> 201 + ) : profile.viewer?.blocking ? ( 202 + profile.viewer?.blockingByList ? null : ( 203 + <Button 204 + testID="unblockBtn" 205 + size="small" 206 + color="secondary" 207 + variant="solid" 208 + label={_(msg`Unblock`)} 209 + disabled={!hasSession} 210 + onPress={() => unblockPromptControl.open()} 211 + style={[a.rounded_full]}> 212 + <ButtonText> 213 + <Trans context="action">Unblock</Trans> 214 + </ButtonText> 215 + </Button> 216 + ) 217 + ) : !profile.viewer?.blockedBy ? ( 218 + <> 219 + {hasSession && subscriptionsAllowed && ( 220 + <SubscribeProfileButton 221 + profile={profile} 222 + moderationOpts={moderationOpts} 223 + /> 238 224 )} 239 - <ButtonText> 240 - {profile.viewer?.following ? ( 241 - <Trans>Following</Trans> 242 - ) : profile.viewer?.followedBy ? ( 243 - <Trans>Follow Back</Trans> 244 - ) : ( 245 - <Trans>Follow</Trans> 225 + {hasSession && <MessageProfileButton profile={profile} />} 226 + 227 + <Button 228 + testID={ 229 + profile.viewer?.following ? 'unfollowBtn' : 'followBtn' 230 + } 231 + size="small" 232 + color={profile.viewer?.following ? 'secondary' : 'primary'} 233 + variant="solid" 234 + label={ 235 + profile.viewer?.following 236 + ? _(msg`Unfollow ${profile.handle}`) 237 + : _(msg`Follow ${profile.handle}`) 238 + } 239 + onPress={ 240 + profile.viewer?.following ? onPressUnfollow : onPressFollow 241 + } 242 + style={[a.rounded_full]}> 243 + {!profile.viewer?.following && ( 244 + <ButtonIcon position="left" icon={Plus} /> 246 245 )} 247 - </ButtonText> 248 - </Button> 249 - </> 250 - ) : null} 251 - <ProfileMenu profile={profile} /> 252 - </View> 253 - <View 254 - style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> 255 - <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 256 - <Text 257 - emoji 258 - testID="profileHeaderDisplayName" 259 - style={[ 260 - t.atoms.text, 261 - gtMobile ? a.text_4xl : a.text_3xl, 262 - a.self_start, 263 - a.font_heavy, 264 - a.leading_tight, 265 - ]}> 266 - {sanitizeDisplayName( 267 - profile.displayName || sanitizeHandle(profile.handle), 268 - moderation.ui('displayName'), 269 - )} 270 - <View 246 + <ButtonText> 247 + {profile.viewer?.following ? ( 248 + <Trans>Following</Trans> 249 + ) : profile.viewer?.followedBy ? ( 250 + <Trans>Follow back</Trans> 251 + ) : ( 252 + <Trans>Follow</Trans> 253 + )} 254 + </ButtonText> 255 + </Button> 256 + </> 257 + ) : null} 258 + <ProfileMenu profile={profile} /> 259 + </View> 260 + <View 261 + style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> 262 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 263 + <Text 264 + emoji 265 + testID="profileHeaderDisplayName" 271 266 style={[ 272 - a.pl_xs, 273 - { 274 - marginTop: platform({ios: 2}), 275 - }, 267 + t.atoms.text, 268 + gtMobile ? a.text_4xl : a.text_3xl, 269 + a.self_start, 270 + a.font_heavy, 271 + a.leading_tight, 276 272 ]}> 277 - <VerificationCheckButton profile={profile} size="lg" /> 278 - </View> 279 - </Text> 273 + {sanitizeDisplayName( 274 + profile.displayName || sanitizeHandle(profile.handle), 275 + moderation.ui('displayName'), 276 + )} 277 + <View 278 + style={[ 279 + a.pl_xs, 280 + { 281 + marginTop: platform({ios: 2}), 282 + }, 283 + ]}> 284 + <VerificationCheckButton profile={profile} size="lg" /> 285 + </View> 286 + </Text> 287 + </View> 288 + <ProfileHeaderHandle profile={profile} /> 280 289 </View> 281 - <ProfileHeaderHandle profile={profile} /> 282 - </View> 283 - {!isPlaceholderProfile && !isBlockedUser && ( 284 - <View style={a.gap_md}> 285 - <ProfileHeaderMetrics profile={profile} /> 286 - {descriptionRT && !moderation.ui('profileView').blur ? ( 287 - <View pointerEvents="auto"> 288 - <RichText 289 - testID="profileHeaderDescription" 290 - style={[a.text_md]} 291 - numberOfLines={15} 292 - value={descriptionRT} 293 - enableTags 294 - authorHandle={profile.handle} 295 - /> 296 - </View> 297 - ) : undefined} 298 - 299 - {!isMe && 300 - !isBlockedUser && 301 - shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 302 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 303 - <KnownFollowers 304 - profile={profile} 305 - moderationOpts={moderationOpts} 290 + {!isPlaceholderProfile && !isBlockedUser && ( 291 + <View style={a.gap_md}> 292 + <ProfileHeaderMetrics profile={profile} /> 293 + {descriptionRT && !moderation.ui('profileView').blur ? ( 294 + <View pointerEvents="auto"> 295 + <RichText 296 + testID="profileHeaderDescription" 297 + style={[a.text_md]} 298 + numberOfLines={15} 299 + value={descriptionRT} 300 + enableTags 301 + authorHandle={profile.handle} 306 302 /> 307 303 </View> 308 - )} 309 - </View> 310 - )} 311 - </View> 312 - <Prompt.Basic 313 - control={unblockPromptControl} 314 - title={_(msg`Unblock Account?`)} 315 - description={_( 316 - msg`The account will be able to interact with you after unblocking.`, 317 - )} 318 - onConfirm={unblockAccount} 319 - confirmButtonCta={ 320 - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 321 - } 322 - confirmButtonColor="negative" 304 + ) : undefined} 305 + 306 + {!isMe && 307 + !isBlockedUser && 308 + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 309 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 310 + <KnownFollowers 311 + profile={profile} 312 + moderationOpts={moderationOpts} 313 + /> 314 + </View> 315 + )} 316 + </View> 317 + )} 318 + </View> 319 + 320 + <Prompt.Basic 321 + control={unblockPromptControl} 322 + title={_(msg`Unblock Account?`)} 323 + description={_( 324 + msg`The account will be able to interact with you after unblocking.`, 325 + )} 326 + onConfirm={unblockAccount} 327 + confirmButtonCta={ 328 + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) 329 + } 330 + confirmButtonColor="negative" 331 + /> 332 + </ProfileHeaderShell> 333 + 334 + <AnimatedProfileHeaderSuggestedFollows 335 + isExpanded={showSuggestedFollows} 336 + actorDid={profile.did} 323 337 /> 324 - </ProfileHeaderShell> 338 + </> 325 339 ) 326 340 } 341 + 327 342 ProfileHeaderStandard = memo(ProfileHeaderStandard) 328 343 export {ProfileHeaderStandard}
+1 -1
src/screens/Profile/Header/Shell.tsx
··· 211 211 212 212 {!isPlaceholderProfile && ( 213 213 <View 214 - style={[a.px_lg, a.py_xs]} 214 + style={[a.px_lg, a.pt_xs, a.pb_sm]} 215 215 pointerEvents={isIOS ? 'auto' : 'box-none'}> 216 216 {isMe ? ( 217 217 <LabelsOnMe type="account" labels={profile.labels} />
+45
src/screens/Profile/Header/SuggestedFollows.tsx
··· 1 + import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' 2 + import {useGate} from '#/lib/statsig/statsig' 3 + import {isAndroid} from '#/platform/detection' 4 + import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 5 + import {ProfileGrid} from '#/components/FeedInterstitials' 6 + 7 + export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) { 8 + const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ 9 + did: actorDid, 10 + }) 11 + 12 + return ( 13 + <ProfileGrid 14 + isSuggestionsLoading={isLoading} 15 + profiles={data?.suggestions ?? []} 16 + recId={data?.recId} 17 + error={error} 18 + viewContext="profileHeader" 19 + /> 20 + ) 21 + } 22 + 23 + export function AnimatedProfileHeaderSuggestedFollows({ 24 + isExpanded, 25 + actorDid, 26 + }: { 27 + isExpanded: boolean 28 + actorDid: string 29 + }) { 30 + const gate = useGate() 31 + if (!gate('post_follow_profile_suggested_accounts')) return null 32 + 33 + /* NOTE (caidanw): 34 + * Android does not work well with this feature yet. 35 + * This issue stems from Android not allowing dragging on clickable elements in the profile header. 36 + * Blocking the ability to scroll on Android is too much of a trade-off for now. 37 + **/ 38 + if (isAndroid) return null 39 + 40 + return ( 41 + <AccordionAnimation isExpanded={isExpanded}> 42 + <ProfileHeaderSuggestedFollows actorDid={actorDid} /> 43 + </AccordionAnimation> 44 + ) 45 + }
+15
src/state/cache/post-shadow.ts
··· 24 24 isDeleted: boolean 25 25 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 26 26 pinned: boolean 27 + optimisticReplyCount: number | undefined 27 28 } 28 29 29 30 export const POST_TOMBSTONE = Symbol('PostTombstone') ··· 33 34 AppBskyFeedDefs.PostView, 34 35 Partial<PostShadow> 35 36 > = new WeakMap() 37 + 38 + /** 39 + * Use with caution! This function returns the raw shadow data for a post. 40 + * Prefer using `usePostShadow`. 41 + */ 42 + export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) { 43 + return shadows.get(post) 44 + } 36 45 37 46 export function usePostShadow( 38 47 post: AppBskyFeedDefs.PostView, ··· 95 104 repostCount = Math.max(0, repostCount) 96 105 } 97 106 107 + let replyCount = post.replyCount ?? 0 108 + if ('optimisticReplyCount' in shadow) { 109 + replyCount = shadow.optimisticReplyCount ?? replyCount 110 + } 111 + 98 112 let embed: typeof post.embed 99 113 if ('embed' in shadow) { 100 114 if ( ··· 112 126 embed: embed || post.embed, 113 127 likeCount: likeCount, 114 128 repostCount: repostCount, 129 + replyCount: replyCount, 115 130 viewer: { 116 131 ...(post.viewer || {}), 117 132 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
+4 -1
src/state/feed-feedback.tsx
··· 11 11 import throttle from 'lodash.throttle' 12 12 13 13 import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 + import {isNetworkError} from '#/lib/hooks/useCleanError' 14 15 import {logEvent} from '#/lib/statsig/statsig' 15 16 import {Logger} from '#/logger' 16 17 import { ··· 83 84 }, 84 85 ) 85 86 .catch((e: any) => { 86 - logger.warn('Failed to send feed interactions', {error: e}) 87 + if (!isNetworkError(e)) { 88 + logger.warn('Failed to send feed interactions', {error: e}) 89 + } 87 90 }) 88 91 89 92 // Send to Statsig
+93 -7
src/state/geolocation.tsx
··· 1 1 import React from 'react' 2 2 import EventEmitter from 'eventemitter3' 3 3 4 + import {networkRetry} from '#/lib/async/retry' 4 5 import {logger} from '#/logger' 5 6 import {type Device, device} from '#/storage' 7 + 8 + const IPCC_URL = `https://bsky.app/ipcc` 9 + const BAPP_CONFIG_URL = `https://bapp-config.bsky.workers.dev/config` 6 10 7 11 const events = new EventEmitter() 8 12 const EVENT = 'geolocation-updated' ··· 24 28 */ 25 29 export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 26 30 countryCode: undefined, 31 + isAgeBlockedGeo: undefined, 27 32 isAgeRestrictedGeo: false, 28 33 } 29 34 30 - /*async function getGeolocation(): Promise<Device['geolocation']> { 31 - const res = await fetch(`https://bsky.app/ipcc`) 35 + function sanitizeGeolocation( 36 + geolocation: Device['geolocation'], 37 + ): Device['geolocation'] { 38 + return { 39 + countryCode: geolocation?.countryCode ?? undefined, 40 + isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false, 41 + isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false, 42 + } 43 + } 44 + 45 + async function getGeolocation(url: string): Promise<Device['geolocation']> { 46 + const res = await fetch(url) 32 47 33 48 if (!res.ok) { 34 49 throw new Error(`geolocation: lookup failed ${res.status}`) ··· 39 54 if (json.countryCode) { 40 55 return { 41 56 countryCode: json.countryCode, 57 + isAgeBlockedGeo: json.isAgeBlockedGeo ?? false, 42 58 isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, 59 + // @ts-ignore 60 + regionCode: json.regionCode ?? undefined, 43 61 } 44 62 } else { 45 63 return undefined 46 64 } 47 - }*/ 65 + } 66 + 67 + async function compareWithIPCC(bapp: Device['geolocation']) { 68 + try { 69 + const ipcc = await getGeolocation(IPCC_URL) 70 + 71 + if (!ipcc || !bapp) return 72 + 73 + logger.metric( 74 + 'geo:debug', 75 + { 76 + bappCountryCode: bapp.countryCode, 77 + // @ts-ignore 78 + bappRegionCode: bapp.regionCode, 79 + bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo, 80 + bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo, 81 + ipccCountryCode: ipcc.countryCode, 82 + ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo, 83 + ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo, 84 + }, 85 + { 86 + statsig: false, 87 + }, 88 + ) 89 + } catch {} 90 + } 48 91 49 92 /** 50 93 * Local promise used within this file only. ··· 73 116 // } 74 117 return 75 118 } 76 - } 77 119 78 - export function setGeolocation(geolocation: Device['geolocation']) { 79 - device.set(['geolocation'], geolocation) 80 - emitGeolocationUpdate(geolocation) 120 + geolocationResolution = new Promise(async resolve => { 121 + let success = true 122 + 123 + try { 124 + // Try once, fail fast 125 + const geolocation = await getGeolocation(BAPP_CONFIG_URL) 126 + if (geolocation) { 127 + device.set(['geolocation'], sanitizeGeolocation(geolocation)) 128 + emitGeolocationUpdate(geolocation) 129 + logger.debug(`geolocation: success`, {geolocation}) 130 + compareWithIPCC(geolocation) 131 + } else { 132 + // endpoint should throw on all failures, this is insurance 133 + throw new Error(`geolocation: nothing returned from initial request`) 134 + } 135 + } catch (e: any) { 136 + success = false 137 + 138 + logger.debug(`geolocation: failed initial request`, { 139 + safeMessage: e.message, 140 + }) 141 + 142 + // set to default 143 + device.set(['geolocation'], DEFAULT_GEOLOCATION) 144 + 145 + // retry 3 times, but don't await, proceed with default 146 + networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL)) 147 + .then(geolocation => { 148 + if (geolocation) { 149 + device.set(['geolocation'], sanitizeGeolocation(geolocation)) 150 + emitGeolocationUpdate(geolocation) 151 + logger.debug(`geolocation: success`, {geolocation}) 152 + success = true 153 + compareWithIPCC(geolocation) 154 + } else { 155 + // endpoint should throw on all failures, this is insurance 156 + throw new Error(`geolocation: nothing returned from retries`) 157 + } 158 + }) 159 + .catch((e: any) => { 160 + // complete fail closed 161 + logger.debug(`geolocation: failed retries`, {safeMessage: e.message}) 162 + }) 163 + } finally { 164 + resolve({success}) 165 + } 166 + }) 81 167 } 82 168 83 169 /**
+9 -6
src/state/queries/suggested-follows.ts
··· 1 1 import { 2 - AppBskyActorDefs, 3 - AppBskyActorGetSuggestions, 4 - AppBskyGraphGetSuggestedFollowsByActor, 2 + type AppBskyActorDefs, 3 + type AppBskyActorGetSuggestions, 4 + type AppBskyGraphGetSuggestedFollowsByActor, 5 5 moderateProfile, 6 6 } from '@atproto/api' 7 7 import { 8 - InfiniteData, 9 - QueryClient, 10 - QueryKey, 8 + type InfiniteData, 9 + type QueryClient, 10 + type QueryKey, 11 11 useInfiniteQuery, 12 12 useQuery, 13 13 } from '@tanstack/react-query' ··· 106 106 export function useSuggestedFollowsByActorQuery({ 107 107 did, 108 108 enabled, 109 + staleTime = STALE.MINUTES.FIVE, 109 110 }: { 110 111 did: string 111 112 enabled?: boolean 113 + staleTime?: number 112 114 }) { 113 115 const agent = useAgent() 114 116 return useQuery({ 117 + staleTime, 115 118 queryKey: suggestedFollowsByActorQueryKey(did), 116 119 queryFn: async () => { 117 120 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+25 -4
src/state/queries/usePostThread/queryCache.ts
··· 9 9 } from '@atproto/api' 10 10 import {type QueryClient} from '@tanstack/react-query' 11 11 12 + import { 13 + dangerousGetPostShadow, 14 + updatePostShadow, 15 + } from '#/state/cache/post-shadow' 12 16 import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 13 17 import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' 14 18 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' ··· 85 89 /* 86 90 * Update parent data 87 91 */ 88 - parent.value.post = { 89 - ...parent.value.post, 90 - replyCount: (parent.value.post.replyCount || 0) + 1, 91 - } 92 + const shadow = dangerousGetPostShadow(parent.value.post) 93 + const prevOptimisticCount = shadow?.optimisticReplyCount 94 + const prevReplyCount = parent.value.post.replyCount 95 + // prefer optimistic count, if we already have some 96 + const currentReplyCount = 97 + (prevOptimisticCount ?? prevReplyCount ?? 0) + 1 98 + 99 + /* 100 + * We must update the value in the query cache in order for thread 101 + * traversal to properly compute required metadata. 102 + */ 103 + parent.value.post.replyCount = currentReplyCount 104 + 105 + /** 106 + * Additionally, we need to update the post shadow to keep track of 107 + * these new values, since mutating the post object above does not 108 + * cause a re-render. 109 + */ 110 + updatePostShadow(queryClient, parent.value.post.uri, { 111 + optimisticReplyCount: currentReplyCount, 112 + }) 92 113 93 114 const opDid = getRootPostAtUri(parent.value.post)?.host 94 115 const nextPreexistingItem = thread.at(i + 1)
+9 -2
src/state/queries/usePostThread/traversal.ts
··· 307 307 metadata.isPartOfLastBranchFromDepth = metadata.depth 308 308 309 309 /** 310 - * If the parent is part of the last branch of the sub-tree, so is the child. 310 + * If the parent is part of the last branch of the sub-tree, so 311 + * is the child. However, if the child is also a last sibling, 312 + * then we need to start tracking `isPartOfLastBranchFromDepth` 313 + * from this point onwards, always updating it to the depth of 314 + * the last sibling as we go down. 311 315 */ 312 - if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { 316 + if ( 317 + !metadata.isLastSibling && 318 + metadata.parentMetadata.isPartOfLastBranchFromDepth 319 + ) { 313 320 metadata.isPartOfLastBranchFromDepth = 314 321 metadata.parentMetadata.isPartOfLastBranchFromDepth 315 322 }
+2 -2
src/state/queries/usePostThread/types.ts
··· 151 151 */ 152 152 isLastChild: boolean 153 153 /** 154 - * Indicates if the post is the left/lower-most branch of the reply tree. 155 - * Value corresponds to the depth at which this branch started. 154 + * Indicates if the post is the left-most AND lower-most branch of the reply 155 + * tree. Value corresponds to the depth at which this branch started. 156 156 */ 157 157 isPartOfLastBranchFromDepth?: number 158 158 /**
+1
src/storage/schema.ts
··· 10 10 geolocation?: { 11 11 countryCode: string | undefined 12 12 isAgeRestrictedGeo: boolean | undefined 13 + isAgeBlockedGeo: boolean | undefined 13 14 } 14 15 trendingBetaEnabled: boolean 15 16 devMode: boolean
+1
src/view/com/lightbox/Lightbox.web.tsx
··· 76 76 const onKeyDown = useCallback( 77 77 (e: KeyboardEvent) => { 78 78 if (e.key === 'Escape') { 79 + e.preventDefault() 79 80 onClose() 80 81 } else if (e.key === 'ArrowLeft') { 81 82 onPressLeft()
+2 -2
src/view/com/post-thread/PostThreadFollowBtn.tsx
··· 1 1 import React from 'react' 2 - import {AppBskyActorDefs} from '@atproto/api' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {useNavigation} from '@react-navigation/native' ··· 126 126 <ButtonText> 127 127 {!isFollowing ? ( 128 128 isFollowedBy ? ( 129 - <Trans>Follow Back</Trans> 129 + <Trans>Follow back</Trans> 130 130 ) : ( 131 131 <Trans>Follow</Trans> 132 132 )
+5 -5
src/view/com/profile/FollowButton.tsx
··· 1 - import {StyleProp, TextStyle, View} from 'react-native' 1 + import {type StyleProp, type TextStyle, View} from 'react-native' 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {Shadow} from '#/state/cache/types' 5 + import {type Shadow} from '#/state/cache/types' 6 6 import {useProfileFollowMutationQueue} from '#/state/queries/profile' 7 - import * as bsky from '#/types/bsky' 8 - import {Button, ButtonType} from '../util/forms/Button' 7 + import type * as bsky from '#/types/bsky' 8 + import {Button, type ButtonType} from '../util/forms/Button' 9 9 import * as Toast from '../util/Toast' 10 10 11 11 export function FollowButton({ ··· 78 78 type={unfollowedType} 79 79 labelStyle={labelStyle} 80 80 onPress={onPressFollow} 81 - label={_(msg({message: 'Follow Back', context: 'action'}))} 81 + label={_(msg({message: 'Follow back', context: 'action'}))} 82 82 /> 83 83 ) 84 84 }
+13 -5
src/view/shell/index.tsx
··· 13 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 14 import {isAndroid, isIOS} from '#/platform/detection' 15 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 + import {useGeolocation} from '#/state/geolocation' 16 17 import {useSession} from '#/state/session' 17 18 import { 18 19 useIsDrawerOpen, ··· 26 27 import {atoms as a, select, useTheme} from '#/alf' 27 28 import {setSystemUITheme} from '#/alf/util/systemUI' 28 29 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 30 + import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 29 31 import {EmailDialog} from '#/components/dialogs/EmailDialog' 30 32 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 31 33 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' ··· 180 182 ) 181 183 } 182 184 183 - export const Shell: React.FC = function ShellImpl() { 184 - const fullyExpandedCount = useDialogFullyExpandedCountContext() 185 + export function Shell() { 185 186 const t = useTheme() 187 + const {geolocation} = useGeolocation() 188 + const fullyExpandedCount = useDialogFullyExpandedCountContext() 189 + 186 190 useIntentHandler() 187 191 188 192 useEffect(() => { ··· 200 204 navigationBar: t.name !== 'light' ? 'light' : 'dark', 201 205 }} 202 206 /> 203 - <RoutesContainer> 204 - <ShellInner /> 205 - </RoutesContainer> 207 + {geolocation?.isAgeBlockedGeo ? ( 208 + <BlockedGeoOverlay /> 209 + ) : ( 210 + <RoutesContainer> 211 + <ShellInner /> 212 + </RoutesContainer> 213 + )} 206 214 </View> 207 215 ) 208 216 }
+13 -14
src/view/shell/index.web.tsx
··· 5 5 import {useNavigation} from '@react-navigation/native' 6 6 import {RemoveScrollBar} from 'react-remove-scroll-bar' 7 7 8 - import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 9 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 10 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 10 import {type NavigationProp} from '#/lib/routes/types' 12 - import {colors} from '#/lib/styles' 11 + import {useGeolocation} from '#/state/geolocation' 13 12 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 14 13 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 15 14 import {useCloseAllActiveElements} from '#/state/util' ··· 18 17 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 18 import {atoms as a, select, useTheme} from '#/alf' 20 19 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 20 + import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 21 21 import {EmailDialog} from '#/components/dialogs/EmailDialog' 22 22 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 23 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' ··· 130 130 ) 131 131 } 132 132 133 - export const Shell: React.FC = function ShellImpl() { 134 - const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) 133 + export function Shell() { 134 + const t = useTheme() 135 + const {geolocation} = useGeolocation() 135 136 return ( 136 - <View style={[a.util_screen_outer, pageBg]}> 137 - <RoutesContainer> 138 - <ShellInner /> 139 - </RoutesContainer> 137 + <View style={[a.util_screen_outer, t.atoms.bg]}> 138 + {geolocation?.isAgeBlockedGeo ? ( 139 + <BlockedGeoOverlay /> 140 + ) : ( 141 + <RoutesContainer> 142 + <ShellInner /> 143 + </RoutesContainer> 144 + )} 140 145 </View> 141 146 ) 142 147 } 143 148 144 149 const styles = StyleSheet.create({ 145 - bgLight: { 146 - backgroundColor: colors.white, 147 - }, 148 - bgDark: { 149 - backgroundColor: colors.black, // TODO 150 - }, 151 150 drawerMask: { 152 151 ...a.fixed, 153 152 width: '100%',