Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5dab3f0c0 | |||
| 3b7b93a411 | |||
| 99d7570818 | |||
| 7b9bc52720 | |||
| 78e1e26a2c | |||
| d8c904d1d9 | |||
| 560c503aa6 | |||
| 5120c037d1 | |||
| 184df9cbfe | |||
| 912ab1bd9b | |||
| f038ac70da | |||
| e404ddfbcd | |||
| 0baa5198e0 | |||
| 7a1adfe67c | |||
| af55e578b2 | |||
| 994003e603 | |||
| 8ab49da6da | |||
| c097f68a7d | |||
| b1ad229771 | |||
| eabcd9150d | |||
| d900d0b7a7 | |||
| 16701392b9 | |||
| ef7c222e1b | |||
| 9835d0fb7b | |||
| bf6e01551e | |||
| ea7f984a40 | |||
| 4923786927 | |||
| 48baf7277d | |||
| 92ad887e38 |
@@ -606,10 +606,10 @@ type IPCCRequest struct {
|
||||
type IPCCResponse struct {
|
||||
CC string `json:"countryCode"`
|
||||
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
|
||||
AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"`
|
||||
}
|
||||
|
||||
// IP address data is powered by IPinfo
|
||||
// https://ipinfo.io
|
||||
// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
|
||||
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||
realIP := c.RealIP()
|
||||
addr, err := netip.ParseAddr(realIP)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<meta property="og:url" content="https://bsky.app" />
|
||||
<meta name="twitter:url" content="https://bsky.app" />
|
||||
<link rel="canonical" href="https://bsky.app" />
|
||||
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI }}" />
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI }}" />
|
||||
{% endif -%}
|
||||
{%- if profileView.DisplayName %}
|
||||
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Full as Logo, Mark} from '#/components/icons/Logo'
|
||||
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function BlockedGeoOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
}, [])
|
||||
|
||||
const textStyles = [a.text_md, a.leading_normal]
|
||||
const links = {
|
||||
blog: {
|
||||
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
|
||||
label: _(msg`Read our blog post`),
|
||||
overridePresentation: false,
|
||||
disableMismatchWarning: true,
|
||||
style: textStyles,
|
||||
},
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
|
||||
_(
|
||||
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.`,
|
||||
),
|
||||
_(
|
||||
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
|
||||
),
|
||||
_(
|
||||
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
|
||||
),
|
||||
<>
|
||||
To learn more, read our{' '}
|
||||
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
||||
</>,
|
||||
]
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
web({
|
||||
maxWidth: 440,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_md,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Mark fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_600,
|
||||
},
|
||||
]}>
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
|
||||
{blocks.map((block, index) => (
|
||||
<Text key={index} style={[textStyles]}>
|
||||
{block}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
@@ -46,11 +46,13 @@ function CardOuter({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
!gtMobile && {
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
@@ -63,11 +65,8 @@ function CardOuter({
|
||||
}
|
||||
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<CardOuter
|
||||
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
|
||||
<CardOuter>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
|
||||
@@ -78,24 +77,15 @@ export function SuggestedFollowPlaceholder() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label=""
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled
|
||||
style={[a.w_full, a.rounded_sm]}>
|
||||
<ButtonText>Follow</ButtonText>
|
||||
</Button>
|
||||
<ProfileCard.FollowButtonPlaceholder />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeedsCardPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<CardOuter style={[a.gap_sm]}>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.AvatarPlaceholder />
|
||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||
@@ -253,129 +243,133 @@ export function ProfileGrid({
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
recId?: number
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'feed'
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const maxLength = gtMobile ? 3 : 6
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
) : error || !profiles.length ? null : (
|
||||
<>
|
||||
{profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext:
|
||||
viewContext === 'feed'
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||
const isFeedContext = viewContext === 'feed'
|
||||
|
||||
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||
const minLength = gtMobile ? 3 : 4
|
||||
|
||||
const content = isLoading
|
||||
? Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
: error || !profiles.length
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.shadow_sm,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
web([
|
||||
a.flex_0,
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext:
|
||||
viewContext === 'feed'
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))
|
||||
|
||||
if (error || (!isLoading && profiles.length < 4)) {
|
||||
if (error || (!isLoading && profiles.length < minLength)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
style={[
|
||||
!isProfileHeaderContext && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
@@ -383,19 +377,22 @@ export function ProfileGrid({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
||||
{viewContext === 'profile' ? (
|
||||
<Trans>Similar accounts</Trans>
|
||||
) : (
|
||||
{isFeedContext ? (
|
||||
<Trans>Suggested for you</Trans>
|
||||
) : (
|
||||
<Trans>Similar accounts</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<InlineLinkText
|
||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||
to="/search">
|
||||
<Trans>See more</Trans>
|
||||
</InlineLinkText>
|
||||
{!isProfileHeaderContext && (
|
||||
<InlineLinkText
|
||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||
to="/search">
|
||||
<Trans>See more</Trans>
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
@@ -406,19 +403,16 @@ export function ProfileGrid({
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
<SeeMoreSuggestedProfilesCard />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
{!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
@@ -427,7 +421,6 @@ export function ProfileGrid({
|
||||
|
||||
function SeeMoreSuggestedProfilesCard() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
|
||||
<CardOuter>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_col, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.leading_snug, a.text_center]}>
|
||||
@@ -491,10 +484,7 @@ export function SuggestedFeeds() {
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={feed.avatar} />
|
||||
@@ -568,7 +558,7 @@ export function SuggestedFeeds() {
|
||||
navigation.navigate('SearchTab')
|
||||
}}
|
||||
style={[a.flex_col]}>
|
||||
<CardOuter style={[a.flex_1]}>
|
||||
<CardOuter>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
|
||||
+87
-1
@@ -1,5 +1,5 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {type GestureResponderEvent} from 'react-native'
|
||||
import {type GestureResponderEvent, Linking} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {
|
||||
type LinkProps as RNLinkProps,
|
||||
@@ -13,6 +13,7 @@ import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
createProxiedUrl,
|
||||
isBskyDownloadUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
@@ -407,6 +408,91 @@ export function InlineLinkText({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A barebones version of `InlineLinkText`, for use outside a
|
||||
* `react-navigation` context.
|
||||
*/
|
||||
export function SimpleInlineLinkText({
|
||||
children,
|
||||
to,
|
||||
style,
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
disableUnderline,
|
||||
shouldProxy,
|
||||
...rest
|
||||
}: Omit<
|
||||
InlineLinkProps,
|
||||
| 'to'
|
||||
| 'action'
|
||||
| 'disableMismatchWarning'
|
||||
| 'overridePresentation'
|
||||
| 'onPress'
|
||||
| 'onLongPress'
|
||||
| 'shareOnLongPress'
|
||||
> & {
|
||||
to: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style) || {}
|
||||
const isExternal = isExternalUrl(to)
|
||||
|
||||
let href = to
|
||||
if (shouldProxy) {
|
||||
href = createProxiedUrl(href)
|
||||
}
|
||||
|
||||
const onPress = () => {
|
||||
Linking.openURL(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
hovered &&
|
||||
!disableUnderline && {
|
||||
...web({
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor:
|
||||
flattenedStyle.color ?? t.palette.primary_500,
|
||||
}),
|
||||
},
|
||||
flattenedStyle,
|
||||
]}
|
||||
role="link"
|
||||
onPress={onPress}
|
||||
onMouseEnter={onHoverIn}
|
||||
onMouseLeave={onHoverOut}
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebOnlyInlineLinkText({
|
||||
children,
|
||||
to,
|
||||
|
||||
@@ -513,12 +513,19 @@ export function FollowButtonInner({
|
||||
comment: 'User is following this account, click to unfollow',
|
||||
}),
|
||||
)
|
||||
const followLabel = _(
|
||||
msg({
|
||||
message: 'Follow',
|
||||
comment: 'User is not following this account, click to follow',
|
||||
}),
|
||||
)
|
||||
const followLabel = profile.viewer?.followedBy
|
||||
? _(
|
||||
msg({
|
||||
message: 'Follow back',
|
||||
comment: 'User is not following this account, click to follow back',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: 'Follow',
|
||||
comment: 'User is not following this account, click to follow',
|
||||
}),
|
||||
)
|
||||
|
||||
if (!profile.viewer) return null
|
||||
if (
|
||||
@@ -561,6 +568,24 @@ export function FollowButtonInner({
|
||||
)
|
||||
}
|
||||
|
||||
export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.w_full,
|
||||
{
|
||||
height: 33,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Labels({
|
||||
profile,
|
||||
moderationOpts,
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {type Props, useCommonSVGProps} from './common'
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Mark = createSinglePathSVG({
|
||||
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',
|
||||
})
|
||||
|
||||
export function Full(
|
||||
props: Omit<Props, 'fill' | 'size' | 'height'> & {
|
||||
markFill?: Props['fill']
|
||||
textFill?: Props['fill']
|
||||
},
|
||||
) {
|
||||
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||
const ratio = 123 / 555
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
viewBox="0 0 555 123"
|
||||
width={size}
|
||||
height={size * ratio}
|
||||
style={[style]}>
|
||||
{gradient}
|
||||
<Path
|
||||
fill={props.markFill ?? fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
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"
|
||||
/>
|
||||
<Path
|
||||
fill={props.textFill ?? fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
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"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {useCallback, useEffect, useMemo} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -55,7 +55,7 @@ export function TrendingVideos() {
|
||||
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
|
||||
const trendingPrompt = Prompt.usePromptControl()
|
||||
|
||||
const onConfirmHide = React.useCallback(() => {
|
||||
const onConfirmHide = useCallback(() => {
|
||||
setTrendingVideoDisabled(true)
|
||||
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
|
||||
}, [setTrendingVideoDisabled])
|
||||
@@ -147,9 +147,7 @@ function VideoCards({
|
||||
}: {
|
||||
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const items = React.useMemo(() => {
|
||||
const items = useMemo(() => {
|
||||
return data.pages
|
||||
.flatMap(page => page.slices)
|
||||
.map(slice => slice.items[0])
|
||||
@@ -157,10 +155,6 @@ function VideoCards({
|
||||
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||
.slice(0, 8)
|
||||
}, [data])
|
||||
const href = React.useMemo(() => {
|
||||
const urip = new AtUri(VIDEO_FEED_URI)
|
||||
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -183,50 +177,58 @@ function VideoCards({
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||
<Link
|
||||
to={href}
|
||||
label={_(msg`View more`)}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.flex_1,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
]}>
|
||||
{({pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
{
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>View more</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}>
|
||||
<ButtonIcon icon={ChevronRight} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
<ViewMoreCard />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewMoreCard() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(VIDEO_FEED_URI)
|
||||
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||
<Link
|
||||
to={href}
|
||||
label={_(msg`View more`)}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.flex_1,
|
||||
a.rounded_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
]}>
|
||||
{({pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
{
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>View more</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
label={_(msg`View more trending videos`)}>
|
||||
<ButtonIcon icon={ChevronRight} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeInUp,
|
||||
FadeOutUp,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
|
||||
type AccordionAnimationProps = React.PropsWithChildren<{
|
||||
isExpanded: boolean
|
||||
duration?: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>
|
||||
|
||||
function WebAccordion({
|
||||
isExpanded,
|
||||
duration = 300,
|
||||
style,
|
||||
children,
|
||||
}: AccordionAnimationProps) {
|
||||
const heightValue = useSharedValue(0)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const targetHeight = isExpanded ? heightValue.get() : 0
|
||||
return {
|
||||
height: withTiming(targetHeight, {
|
||||
duration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}),
|
||||
overflow: 'hidden',
|
||||
}
|
||||
})
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
if (heightValue.get() === 0) {
|
||||
heightValue.set(e.nativeEvent.layout.height)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[animatedStyle, style]}>
|
||||
<View onLayout={onLayout}>{children}</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileAccordion({
|
||||
isExpanded,
|
||||
duration = 200,
|
||||
style,
|
||||
children,
|
||||
}: AccordionAnimationProps) {
|
||||
if (!isExpanded) return null
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={style}
|
||||
entering={FadeInUp.duration(duration)}
|
||||
exiting={FadeOutUp.duration(duration / 2)}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccordionAnimation(props: AccordionAnimationProps) {
|
||||
return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} />
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type Gate =
|
||||
| 'handle_suggestions'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'post_follow_profile_suggested_accounts'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'remove_show_latest_button'
|
||||
| 'test_gate_1'
|
||||
|
||||
+200
-156
File diff suppressed because it is too large
Load Diff
@@ -475,4 +475,11 @@ export type MetricEvents = {
|
||||
'ageAssurance:redirectDialogFail': {}
|
||||
'ageAssurance:appealDialogOpen': {}
|
||||
'ageAssurance:appealDialogSubmit': {}
|
||||
|
||||
/*
|
||||
* Specifically for the `BlockedGeoOverlay`
|
||||
*/
|
||||
'blockedGeoOverlay:shown': {}
|
||||
|
||||
'geo:debug': {}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -201,6 +202,24 @@ export function ModerationScreenInner({
|
||||
|
||||
return (
|
||||
<View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
|
||||
{isDeclaredUnderage && (
|
||||
<View style={[a.pb_2xl]}>
|
||||
<Admonition type="tip" style={[a.pb_md]}>
|
||||
<Trans>
|
||||
Your declared age is under 18. Some settings below may be
|
||||
disabled. If this was a mistake, you may edit your birthdate in
|
||||
your{' '}
|
||||
<InlineLinkText
|
||||
to="/settings/account"
|
||||
label={_(msg`Go to account settings`)}>
|
||||
account settings
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||
<Trans>Moderation tools</Trans>
|
||||
|
||||
@@ -90,10 +90,10 @@ export const ThreadItemReadMore = memo(function ThreadItemReadMore({
|
||||
interacted && a.underline,
|
||||
]}>
|
||||
<Trans>
|
||||
Read {item.moreReplies} more{' '}
|
||||
Read{' '}
|
||||
<Plural
|
||||
one="reply"
|
||||
other="replies"
|
||||
one="# more reply"
|
||||
other="# more replies"
|
||||
value={item.moreReplies}
|
||||
/>
|
||||
</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
@@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
@@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({
|
||||
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const unblockPromptControl = Prompt.usePromptControl()
|
||||
const requireAuth = useRequireAuth()
|
||||
const [showSuggestedFollows, setShowSuggestedFollows] = useState(false)
|
||||
const isBlockedUser =
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
@@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({
|
||||
const editProfileControl = useDialogControl()
|
||||
|
||||
const onPressFollow = () => {
|
||||
setShowSuggestedFollows(true)
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueFollow()
|
||||
@@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({
|
||||
}
|
||||
|
||||
const onPressUnfollow = () => {
|
||||
setShowSuggestedFollows(false)
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
@@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({
|
||||
})
|
||||
}
|
||||
|
||||
const unblockAccount = React.useCallback(async () => {
|
||||
const unblockAccount = useCallback(async () => {
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
||||
@@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({
|
||||
}, [profile])
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<>
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[
|
||||
{paddingLeft: 90},
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_end,
|
||||
a.gap_xs,
|
||||
a.pb_sm,
|
||||
a.flex_wrap,
|
||||
]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<>
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<EditProfileDialog
|
||||
profile={profile}
|
||||
control={editProfileControl}
|
||||
/>
|
||||
</>
|
||||
) : profile.viewer?.blocking ? (
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<Button
|
||||
testID="unblockBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
label={_(msg`Unblock`)}
|
||||
disabled={!hasSession}
|
||||
onPress={() => unblockPromptControl.open()}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans context="action">Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
) : !profile.viewer?.blockedBy ? (
|
||||
<>
|
||||
{hasSession && subscriptionsAllowed && (
|
||||
<SubscribeProfileButton
|
||||
<View
|
||||
style={[
|
||||
{paddingLeft: 90},
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_end,
|
||||
a.gap_xs,
|
||||
a.pb_sm,
|
||||
a.flex_wrap,
|
||||
]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<>
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<EditProfileDialog
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
control={editProfileControl}
|
||||
/>
|
||||
)}
|
||||
{hasSession && <MessageProfileButton profile={profile} />}
|
||||
|
||||
<Button
|
||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||
size="small"
|
||||
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profile.viewer?.following
|
||||
? _(msg`Unfollow ${profile.handle}`)
|
||||
: _(msg`Follow ${profile.handle}`)
|
||||
}
|
||||
onPress={
|
||||
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||
}
|
||||
style={[a.rounded_full]}>
|
||||
{!profile.viewer?.following && (
|
||||
<ButtonIcon position="left" icon={Plus} />
|
||||
)}
|
||||
<ButtonText>
|
||||
{profile.viewer?.following ? (
|
||||
<Trans>Following</Trans>
|
||||
) : profile.viewer?.followedBy ? (
|
||||
<Trans>Follow Back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
<View
|
||||
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
<Text
|
||||
emoji
|
||||
testID="profileHeaderDisplayName"
|
||||
style={[
|
||||
t.atoms.text,
|
||||
gtMobile ? a.text_4xl : a.text_3xl,
|
||||
a.self_start,
|
||||
a.font_heavy,
|
||||
a.leading_tight,
|
||||
]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: platform({ios: 2}),
|
||||
},
|
||||
]}>
|
||||
<VerificationCheckButton profile={profile} size="lg" />
|
||||
</View>
|
||||
</Text>
|
||||
</View>
|
||||
<ProfileHeaderHandle profile={profile} />
|
||||
</View>
|
||||
{!isPlaceholderProfile && !isBlockedUser && (
|
||||
<View style={a.gap_md}>
|
||||
<ProfileHeaderMetrics profile={profile} />
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
enableTags
|
||||
authorHandle={profile.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
{!isMe &&
|
||||
!isBlockedUser &&
|
||||
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<KnownFollowers
|
||||
</>
|
||||
) : profile.viewer?.blocking ? (
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<Button
|
||||
testID="unblockBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
label={_(msg`Unblock`)}
|
||||
disabled={!hasSession}
|
||||
onPress={() => unblockPromptControl.open()}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
<Trans context="action">Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
) : !profile.viewer?.blockedBy ? (
|
||||
<>
|
||||
{hasSession && subscriptionsAllowed && (
|
||||
<SubscribeProfileButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
{hasSession && <MessageProfileButton profile={profile} />}
|
||||
|
||||
<Button
|
||||
testID={
|
||||
profile.viewer?.following ? 'unfollowBtn' : 'followBtn'
|
||||
}
|
||||
size="small"
|
||||
color={profile.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profile.viewer?.following
|
||||
? _(msg`Unfollow ${profile.handle}`)
|
||||
: _(msg`Follow ${profile.handle}`)
|
||||
}
|
||||
onPress={
|
||||
profile.viewer?.following ? onPressUnfollow : onPressFollow
|
||||
}
|
||||
style={[a.rounded_full]}>
|
||||
{!profile.viewer?.following && (
|
||||
<ButtonIcon position="left" icon={Plus} />
|
||||
)}
|
||||
<ButtonText>
|
||||
{profile.viewer?.following ? (
|
||||
<Trans>Following</Trans>
|
||||
) : profile.viewer?.followedBy ? (
|
||||
<Trans>Follow back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Prompt.Basic
|
||||
control={unblockPromptControl}
|
||||
title={_(msg`Unblock Account?`)}
|
||||
description={_(
|
||||
msg`The account will be able to interact with you after unblocking.`,
|
||||
)}
|
||||
onConfirm={unblockAccount}
|
||||
confirmButtonCta={
|
||||
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
||||
}
|
||||
confirmButtonColor="negative"
|
||||
<View
|
||||
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
<Text
|
||||
emoji
|
||||
testID="profileHeaderDisplayName"
|
||||
style={[
|
||||
t.atoms.text,
|
||||
gtMobile ? a.text_4xl : a.text_3xl,
|
||||
a.self_start,
|
||||
a.font_heavy,
|
||||
a.leading_tight,
|
||||
]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: platform({ios: 2}),
|
||||
},
|
||||
]}>
|
||||
<VerificationCheckButton profile={profile} size="lg" />
|
||||
</View>
|
||||
</Text>
|
||||
</View>
|
||||
<ProfileHeaderHandle profile={profile} />
|
||||
</View>
|
||||
{!isPlaceholderProfile && !isBlockedUser && (
|
||||
<View style={a.gap_md}>
|
||||
<ProfileHeaderMetrics profile={profile} />
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
enableTags
|
||||
authorHandle={profile.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
{!isMe &&
|
||||
!isBlockedUser &&
|
||||
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<KnownFollowers
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Prompt.Basic
|
||||
control={unblockPromptControl}
|
||||
title={_(msg`Unblock Account?`)}
|
||||
description={_(
|
||||
msg`The account will be able to interact with you after unblocking.`,
|
||||
)}
|
||||
onConfirm={unblockAccount}
|
||||
confirmButtonCta={
|
||||
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
|
||||
}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</ProfileHeaderShell>
|
||||
|
||||
<AnimatedProfileHeaderSuggestedFollows
|
||||
isExpanded={showSuggestedFollows}
|
||||
actorDid={profile.did}
|
||||
/>
|
||||
</ProfileHeaderShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ProfileHeaderStandard = memo(ProfileHeaderStandard)
|
||||
export {ProfileHeaderStandard}
|
||||
|
||||
@@ -211,7 +211,7 @@ let ProfileHeaderShell = ({
|
||||
|
||||
{!isPlaceholderProfile && (
|
||||
<View
|
||||
style={[a.px_lg, a.py_xs]}
|
||||
style={[a.px_lg, a.pt_xs, a.pb_sm]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<LabelsOnMe type="account" labels={profile.labels} />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {ProfileGrid} from '#/components/FeedInterstitials'
|
||||
|
||||
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
|
||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={data?.suggestions ?? []}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profileHeader"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedProfileHeaderSuggestedFollows({
|
||||
isExpanded,
|
||||
actorDid,
|
||||
}: {
|
||||
isExpanded: boolean
|
||||
actorDid: string
|
||||
}) {
|
||||
const gate = useGate()
|
||||
if (!gate('post_follow_profile_suggested_accounts')) return null
|
||||
|
||||
/* NOTE (caidanw):
|
||||
* Android does not work well with this feature yet.
|
||||
* This issue stems from Android not allowing dragging on clickable elements in the profile header.
|
||||
* Blocking the ability to scroll on Android is too much of a trade-off for now.
|
||||
**/
|
||||
if (isAndroid) return null
|
||||
|
||||
return (
|
||||
<AccordionAnimation isExpanded={isExpanded}>
|
||||
<ProfileHeaderSuggestedFollows actorDid={actorDid} />
|
||||
</AccordionAnimation>
|
||||
)
|
||||
}
|
||||
Vendored
+15
@@ -24,6 +24,7 @@ export interface PostShadow {
|
||||
isDeleted: boolean
|
||||
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
|
||||
pinned: boolean
|
||||
optimisticReplyCount: number | undefined
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
@@ -34,6 +35,14 @@ const shadows: WeakMap<
|
||||
Partial<PostShadow>
|
||||
> = new WeakMap()
|
||||
|
||||
/**
|
||||
* Use with caution! This function returns the raw shadow data for a post.
|
||||
* Prefer using `usePostShadow`.
|
||||
*/
|
||||
export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) {
|
||||
return shadows.get(post)
|
||||
}
|
||||
|
||||
export function usePostShadow(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
|
||||
@@ -95,6 +104,11 @@ function mergeShadow(
|
||||
repostCount = Math.max(0, repostCount)
|
||||
}
|
||||
|
||||
let replyCount = post.replyCount ?? 0
|
||||
if ('optimisticReplyCount' in shadow) {
|
||||
replyCount = shadow.optimisticReplyCount ?? replyCount
|
||||
}
|
||||
|
||||
let embed: typeof post.embed
|
||||
if ('embed' in shadow) {
|
||||
if (
|
||||
@@ -112,6 +126,7 @@ function mergeShadow(
|
||||
embed: embed || post.embed,
|
||||
likeCount: likeCount,
|
||||
repostCount: repostCount,
|
||||
replyCount: replyCount,
|
||||
viewer: {
|
||||
...(post.viewer || {}),
|
||||
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
@@ -83,7 +84,9 @@ export function useFeedFeedback(
|
||||
},
|
||||
)
|
||||
.catch((e: any) => {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
if (!isNetworkError(e)) {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
}
|
||||
})
|
||||
|
||||
// Send to Statsig
|
||||
|
||||
@@ -5,6 +5,9 @@ import {networkRetry} from '#/lib/async/retry'
|
||||
import {logger} from '#/logger'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
const IPCC_URL = `https://bsky.app/ipcc`
|
||||
const BAPP_CONFIG_URL = `https://ip.bsky.app/config`
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-updated'
|
||||
const emitGeolocationUpdate = (geolocation: Device['geolocation']) => {
|
||||
@@ -25,11 +28,22 @@ const onGeolocationUpdate = (
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
|
||||
countryCode: undefined,
|
||||
isAgeBlockedGeo: undefined,
|
||||
isAgeRestrictedGeo: false,
|
||||
}
|
||||
|
||||
async function getGeolocation(): Promise<Device['geolocation']> {
|
||||
const res = await fetch(`https://bsky.app/ipcc`)
|
||||
function sanitizeGeolocation(
|
||||
geolocation: Device['geolocation'],
|
||||
): Device['geolocation'] {
|
||||
return {
|
||||
countryCode: geolocation?.countryCode ?? undefined,
|
||||
isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false,
|
||||
isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeolocation(url: string): Promise<Device['geolocation']> {
|
||||
const res = await fetch(url)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`geolocation: lookup failed ${res.status}`)
|
||||
@@ -40,13 +54,41 @@ async function getGeolocation(): Promise<Device['geolocation']> {
|
||||
if (json.countryCode) {
|
||||
return {
|
||||
countryCode: json.countryCode,
|
||||
isAgeBlockedGeo: json.isAgeBlockedGeo ?? false,
|
||||
isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
|
||||
// @ts-ignore
|
||||
regionCode: json.regionCode ?? undefined,
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function compareWithIPCC(bapp: Device['geolocation']) {
|
||||
try {
|
||||
const ipcc = await getGeolocation(IPCC_URL)
|
||||
|
||||
if (!ipcc || !bapp) return
|
||||
|
||||
logger.metric(
|
||||
'geo:debug',
|
||||
{
|
||||
bappCountryCode: bapp.countryCode,
|
||||
// @ts-ignore
|
||||
bappRegionCode: bapp.regionCode,
|
||||
bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo,
|
||||
bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo,
|
||||
ipccCountryCode: ipcc.countryCode,
|
||||
ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo,
|
||||
ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo,
|
||||
},
|
||||
{
|
||||
statsig: false,
|
||||
},
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
@@ -79,11 +121,12 @@ export function beginResolveGeolocation() {
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const geolocation = await getGeolocation()
|
||||
const geolocation = await getGeolocation(BAPP_CONFIG_URL)
|
||||
if (geolocation) {
|
||||
device.set(['geolocation'], geolocation)
|
||||
device.set(['geolocation'], sanitizeGeolocation(geolocation))
|
||||
emitGeolocationUpdate(geolocation)
|
||||
logger.debug(`geolocation: success`, {geolocation})
|
||||
compareWithIPCC(geolocation)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`geolocation: nothing returned from initial request`)
|
||||
@@ -99,13 +142,14 @@ export function beginResolveGeolocation() {
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, getGeolocation)
|
||||
networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL))
|
||||
.then(geolocation => {
|
||||
if (geolocation) {
|
||||
device.set(['geolocation'], geolocation)
|
||||
device.set(['geolocation'], sanitizeGeolocation(geolocation))
|
||||
emitGeolocationUpdate(geolocation)
|
||||
logger.debug(`geolocation: success`, {geolocation})
|
||||
success = true
|
||||
compareWithIPCC(geolocation)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`geolocation: nothing returned from retries`)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyActorGetSuggestions,
|
||||
AppBskyGraphGetSuggestedFollowsByActor,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyActorGetSuggestions,
|
||||
type AppBskyGraphGetSuggestedFollowsByActor,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
@@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({
|
||||
did,
|
||||
enabled,
|
||||
staleTime = STALE.MINUTES.FIVE,
|
||||
}: {
|
||||
did: string
|
||||
enabled?: boolean
|
||||
staleTime?: number
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
return useQuery({
|
||||
staleTime,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {type QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
dangerousGetPostShadow,
|
||||
updatePostShadow,
|
||||
} from '#/state/cache/post-shadow'
|
||||
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||
@@ -85,10 +89,27 @@ export function createCacheMutator({
|
||||
/*
|
||||
* Update parent data
|
||||
*/
|
||||
parent.value.post = {
|
||||
...parent.value.post,
|
||||
replyCount: (parent.value.post.replyCount || 0) + 1,
|
||||
}
|
||||
const shadow = dangerousGetPostShadow(parent.value.post)
|
||||
const prevOptimisticCount = shadow?.optimisticReplyCount
|
||||
const prevReplyCount = parent.value.post.replyCount
|
||||
// prefer optimistic count, if we already have some
|
||||
const currentReplyCount =
|
||||
(prevOptimisticCount ?? prevReplyCount ?? 0) + 1
|
||||
|
||||
/*
|
||||
* We must update the value in the query cache in order for thread
|
||||
* traversal to properly compute required metadata.
|
||||
*/
|
||||
parent.value.post.replyCount = currentReplyCount
|
||||
|
||||
/**
|
||||
* Additionally, we need to update the post shadow to keep track of
|
||||
* these new values, since mutating the post object above does not
|
||||
* cause a re-render.
|
||||
*/
|
||||
updatePostShadow(queryClient, parent.value.post.uri, {
|
||||
optimisticReplyCount: currentReplyCount,
|
||||
})
|
||||
|
||||
const opDid = getRootPostAtUri(parent.value.post)?.host
|
||||
const nextPreexistingItem = thread.at(i + 1)
|
||||
|
||||
@@ -307,9 +307,16 @@ export function sortAndAnnotateThreadItems(
|
||||
metadata.isPartOfLastBranchFromDepth = metadata.depth
|
||||
|
||||
/**
|
||||
* If the parent is part of the last branch of the sub-tree, so is the child.
|
||||
* If the parent is part of the last branch of the sub-tree, so
|
||||
* is the child. However, if the child is also a last sibling,
|
||||
* then we need to start tracking `isPartOfLastBranchFromDepth`
|
||||
* from this point onwards, always updating it to the depth of
|
||||
* the last sibling as we go down.
|
||||
*/
|
||||
if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
|
||||
if (
|
||||
!metadata.isLastSibling &&
|
||||
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
||||
) {
|
||||
metadata.isPartOfLastBranchFromDepth =
|
||||
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
||||
}
|
||||
|
||||
@@ -151,8 +151,8 @@ export type TraversalMetadata = {
|
||||
*/
|
||||
isLastChild: boolean
|
||||
/**
|
||||
* Indicates if the post is the left/lower-most branch of the reply tree.
|
||||
* Value corresponds to the depth at which this branch started.
|
||||
* Indicates if the post is the left-most AND lower-most branch of the reply
|
||||
* tree. Value corresponds to the depth at which this branch started.
|
||||
*/
|
||||
isPartOfLastBranchFromDepth?: number
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Device = {
|
||||
geolocation?: {
|
||||
countryCode: string | undefined
|
||||
isAgeRestrictedGeo: boolean | undefined
|
||||
isAgeBlockedGeo: boolean | undefined
|
||||
}
|
||||
trendingBetaEnabled: boolean
|
||||
devMode: boolean
|
||||
|
||||
@@ -76,6 +76,7 @@ function LightboxInner({
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
onPressLeft()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -126,7 +126,7 @@ function PostThreadFollowBtnLoaded({
|
||||
<ButtonText>
|
||||
{!isFollowing ? (
|
||||
isFollowedBy ? (
|
||||
<Trans>Follow Back</Trans>
|
||||
<Trans>Follow back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {StyleProp, TextStyle, View} from 'react-native'
|
||||
import {type StyleProp, type TextStyle, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {Button, ButtonType} from '../util/forms/Button'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {Button, type ButtonType} from '../util/forms/Button'
|
||||
import * as Toast from '../util/Toast'
|
||||
|
||||
export function FollowButton({
|
||||
@@ -78,7 +78,7 @@ export function FollowButton({
|
||||
type={unfollowedType}
|
||||
labelStyle={labelStyle}
|
||||
onPress={onPressFollow}
|
||||
label={_(msg({message: 'Follow Back', context: 'action'}))}
|
||||
label={_(msg({message: 'Follow back', context: 'action'}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
||||
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
||||
import {useGeolocation} from '#/state/geolocation'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useIsDrawerOpen,
|
||||
@@ -26,6 +27,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||
import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
|
||||
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
||||
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
|
||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||
@@ -180,9 +182,11 @@ function ShellInner() {
|
||||
)
|
||||
}
|
||||
|
||||
export const Shell: React.FC = function ShellImpl() {
|
||||
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
||||
export function Shell() {
|
||||
const t = useTheme()
|
||||
const {geolocation} = useGeolocation()
|
||||
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
||||
|
||||
useIntentHandler()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,9 +204,13 @@ export const Shell: React.FC = function ShellImpl() {
|
||||
navigationBar: t.name !== 'light' ? 'light' : 'dark',
|
||||
}}
|
||||
/>
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
{geolocation?.isAgeBlockedGeo ? (
|
||||
<BlockedGeoOverlay />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useGeolocation} from '#/state/geolocation'
|
||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
@@ -18,6 +17,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||
import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
|
||||
import {EmailDialog} from '#/components/dialogs/EmailDialog'
|
||||
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
@@ -130,24 +130,23 @@ function ShellInner() {
|
||||
)
|
||||
}
|
||||
|
||||
export const Shell: React.FC = function ShellImpl() {
|
||||
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
export function Shell() {
|
||||
const t = useTheme()
|
||||
const {geolocation} = useGeolocation()
|
||||
return (
|
||||
<View style={[a.util_screen_outer, pageBg]}>
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
<View style={[a.util_screen_outer, t.atoms.bg]}>
|
||||
{geolocation?.isAgeBlockedGeo ? (
|
||||
<BlockedGeoOverlay />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bgLight: {
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
bgDark: {
|
||||
backgroundColor: colors.black, // TODO
|
||||
},
|
||||
drawerMask: {
|
||||
...a.fixed,
|
||||
width: '100%',
|
||||
|
||||
Reference in New Issue
Block a user