···606606type IPCCResponse struct {
607607 CC string `json:"countryCode"`
608608 AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
609609+ AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"`
609610}
610611611611-// IP address data is powered by IPinfo
612612-// https://ipinfo.io
612612+// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
613613func (srv *Server) WebIpCC(c echo.Context) error {
614614 realIP := c.RealIP()
615615 addr, err := netip.ParseAddr(realIP)
···207207 <Admonition type="tip" style={[a.pb_md]}>
208208 <Trans>
209209 Your declared age is under 18. Some settings below may be
210210- disabled. If this was a mistake, you may edit your bithdate in
210210+ disabled. If this was a mistake, you may edit your birthdate in
211211 your{' '}
212212 <InlineLinkText
213213 to="/settings/account"
···11+import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
22+import {useGate} from '#/lib/statsig/statsig'
33+import {isAndroid} from '#/platform/detection'
44+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
55+import {ProfileGrid} from '#/components/FeedInterstitials'
66+77+export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
88+ const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
99+ did: actorDid,
1010+ })
1111+1212+ return (
1313+ <ProfileGrid
1414+ isSuggestionsLoading={isLoading}
1515+ profiles={data?.suggestions ?? []}
1616+ recId={data?.recId}
1717+ error={error}
1818+ viewContext="profileHeader"
1919+ />
2020+ )
2121+}
2222+2323+export function AnimatedProfileHeaderSuggestedFollows({
2424+ isExpanded,
2525+ actorDid,
2626+}: {
2727+ isExpanded: boolean
2828+ actorDid: string
2929+}) {
3030+ const gate = useGate()
3131+ if (!gate('post_follow_profile_suggested_accounts')) return null
3232+3333+ /* NOTE (caidanw):
3434+ * Android does not work well with this feature yet.
3535+ * This issue stems from Android not allowing dragging on clickable elements in the profile header.
3636+ * Blocking the ability to scroll on Android is too much of a trade-off for now.
3737+ **/
3838+ if (isAndroid) return null
3939+4040+ return (
4141+ <AccordionAnimation isExpanded={isExpanded}>
4242+ <ProfileHeaderSuggestedFollows actorDid={actorDid} />
4343+ </AccordionAnimation>
4444+ )
4545+}
+15
src/state/cache/post-shadow.ts
···2424 isDeleted: boolean
2525 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
2626 pinned: boolean
2727+ optimisticReplyCount: number | undefined
2728}
28292930export const POST_TOMBSTONE = Symbol('PostTombstone')
···3334 AppBskyFeedDefs.PostView,
3435 Partial<PostShadow>
3536> = new WeakMap()
3737+3838+/**
3939+ * Use with caution! This function returns the raw shadow data for a post.
4040+ * Prefer using `usePostShadow`.
4141+ */
4242+export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) {
4343+ return shadows.get(post)
4444+}
36453746export function usePostShadow(
3847 post: AppBskyFeedDefs.PostView,
···95104 repostCount = Math.max(0, repostCount)
96105 }
97106107107+ let replyCount = post.replyCount ?? 0
108108+ if ('optimisticReplyCount' in shadow) {
109109+ replyCount = shadow.optimisticReplyCount ?? replyCount
110110+ }
111111+98112 let embed: typeof post.embed
99113 if ('embed' in shadow) {
100114 if (
···112126 embed: embed || post.embed,
113127 likeCount: likeCount,
114128 repostCount: repostCount,
129129+ replyCount: replyCount,
115130 viewer: {
116131 ...(post.viewer || {}),
117132 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
+4-1
src/state/feed-feedback.tsx
···1111import throttle from 'lodash.throttle'
12121313import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants'
1414+import {isNetworkError} from '#/lib/hooks/useCleanError'
1415import {logEvent} from '#/lib/statsig/statsig'
1516import {Logger} from '#/logger'
1617import {
···8384 },
8485 )
8586 .catch((e: any) => {
8686- logger.warn('Failed to send feed interactions', {error: e})
8787+ if (!isNetworkError(e)) {
8888+ logger.warn('Failed to send feed interactions', {error: e})
8989+ }
8790 })
88918992 // Send to Statsig
+93-7
src/state/geolocation.tsx
···11import React from 'react'
22import EventEmitter from 'eventemitter3'
3344+import {networkRetry} from '#/lib/async/retry'
45import {logger} from '#/logger'
56import {type Device, device} from '#/storage'
77+88+const IPCC_URL = `https://bsky.app/ipcc`
99+const BAPP_CONFIG_URL = `https://bapp-config.bsky.workers.dev/config`
610711const events = new EventEmitter()
812const EVENT = 'geolocation-updated'
···2428 */
2529export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
2630 countryCode: undefined,
3131+ isAgeBlockedGeo: undefined,
2732 isAgeRestrictedGeo: false,
2833}
29343030-/*async function getGeolocation(): Promise<Device['geolocation']> {
3131- const res = await fetch(`https://bsky.app/ipcc`)
3535+function sanitizeGeolocation(
3636+ geolocation: Device['geolocation'],
3737+): Device['geolocation'] {
3838+ return {
3939+ countryCode: geolocation?.countryCode ?? undefined,
4040+ isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false,
4141+ isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false,
4242+ }
4343+}
4444+4545+async function getGeolocation(url: string): Promise<Device['geolocation']> {
4646+ const res = await fetch(url)
32473348 if (!res.ok) {
3449 throw new Error(`geolocation: lookup failed ${res.status}`)
···3954 if (json.countryCode) {
4055 return {
4156 countryCode: json.countryCode,
5757+ isAgeBlockedGeo: json.isAgeBlockedGeo ?? false,
4258 isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
5959+ // @ts-ignore
6060+ regionCode: json.regionCode ?? undefined,
4361 }
4462 } else {
4563 return undefined
4664 }
4747-}*/
6565+}
6666+6767+async function compareWithIPCC(bapp: Device['geolocation']) {
6868+ try {
6969+ const ipcc = await getGeolocation(IPCC_URL)
7070+7171+ if (!ipcc || !bapp) return
7272+7373+ logger.metric(
7474+ 'geo:debug',
7575+ {
7676+ bappCountryCode: bapp.countryCode,
7777+ // @ts-ignore
7878+ bappRegionCode: bapp.regionCode,
7979+ bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo,
8080+ bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo,
8181+ ipccCountryCode: ipcc.countryCode,
8282+ ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo,
8383+ ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo,
8484+ },
8585+ {
8686+ statsig: false,
8787+ },
8888+ )
8989+ } catch {}
9090+}
48914992/**
5093 * Local promise used within this file only.
···73116 // }
74117 return
75118 }
7676-}
771197878-export function setGeolocation(geolocation: Device['geolocation']) {
7979- device.set(['geolocation'], geolocation)
8080- emitGeolocationUpdate(geolocation)
120120+ geolocationResolution = new Promise(async resolve => {
121121+ let success = true
122122+123123+ try {
124124+ // Try once, fail fast
125125+ const geolocation = await getGeolocation(BAPP_CONFIG_URL)
126126+ if (geolocation) {
127127+ device.set(['geolocation'], sanitizeGeolocation(geolocation))
128128+ emitGeolocationUpdate(geolocation)
129129+ logger.debug(`geolocation: success`, {geolocation})
130130+ compareWithIPCC(geolocation)
131131+ } else {
132132+ // endpoint should throw on all failures, this is insurance
133133+ throw new Error(`geolocation: nothing returned from initial request`)
134134+ }
135135+ } catch (e: any) {
136136+ success = false
137137+138138+ logger.debug(`geolocation: failed initial request`, {
139139+ safeMessage: e.message,
140140+ })
141141+142142+ // set to default
143143+ device.set(['geolocation'], DEFAULT_GEOLOCATION)
144144+145145+ // retry 3 times, but don't await, proceed with default
146146+ networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL))
147147+ .then(geolocation => {
148148+ if (geolocation) {
149149+ device.set(['geolocation'], sanitizeGeolocation(geolocation))
150150+ emitGeolocationUpdate(geolocation)
151151+ logger.debug(`geolocation: success`, {geolocation})
152152+ success = true
153153+ compareWithIPCC(geolocation)
154154+ } else {
155155+ // endpoint should throw on all failures, this is insurance
156156+ throw new Error(`geolocation: nothing returned from retries`)
157157+ }
158158+ })
159159+ .catch((e: any) => {
160160+ // complete fail closed
161161+ logger.debug(`geolocation: failed retries`, {safeMessage: e.message})
162162+ })
163163+ } finally {
164164+ resolve({success})
165165+ }
166166+ })
81167}
8216883169/**
+9-6
src/state/queries/suggested-follows.ts
···11import {
22- AppBskyActorDefs,
33- AppBskyActorGetSuggestions,
44- AppBskyGraphGetSuggestedFollowsByActor,
22+ type AppBskyActorDefs,
33+ type AppBskyActorGetSuggestions,
44+ type AppBskyGraphGetSuggestedFollowsByActor,
55 moderateProfile,
66} from '@atproto/api'
77import {
88- InfiniteData,
99- QueryClient,
1010- QueryKey,
88+ type InfiniteData,
99+ type QueryClient,
1010+ type QueryKey,
1111 useInfiniteQuery,
1212 useQuery,
1313} from '@tanstack/react-query'
···106106export function useSuggestedFollowsByActorQuery({
107107 did,
108108 enabled,
109109+ staleTime = STALE.MINUTES.FIVE,
109110}: {
110111 did: string
111112 enabled?: boolean
113113+ staleTime?: number
112114}) {
113115 const agent = useAgent()
114116 return useQuery({
117117+ staleTime,
115118 queryKey: suggestedFollowsByActorQueryKey(did),
116119 queryFn: async () => {
117120 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+25-4
src/state/queries/usePostThread/queryCache.ts
···99} from '@atproto/api'
1010import {type QueryClient} from '@tanstack/react-query'
11111212+import {
1313+ dangerousGetPostShadow,
1414+ updatePostShadow,
1515+} from '#/state/cache/post-shadow'
1216import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
1317import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
1418import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
···8589 /*
8690 * Update parent data
8791 */
8888- parent.value.post = {
8989- ...parent.value.post,
9090- replyCount: (parent.value.post.replyCount || 0) + 1,
9191- }
9292+ const shadow = dangerousGetPostShadow(parent.value.post)
9393+ const prevOptimisticCount = shadow?.optimisticReplyCount
9494+ const prevReplyCount = parent.value.post.replyCount
9595+ // prefer optimistic count, if we already have some
9696+ const currentReplyCount =
9797+ (prevOptimisticCount ?? prevReplyCount ?? 0) + 1
9898+9999+ /*
100100+ * We must update the value in the query cache in order for thread
101101+ * traversal to properly compute required metadata.
102102+ */
103103+ parent.value.post.replyCount = currentReplyCount
104104+105105+ /**
106106+ * Additionally, we need to update the post shadow to keep track of
107107+ * these new values, since mutating the post object above does not
108108+ * cause a re-render.
109109+ */
110110+ updatePostShadow(queryClient, parent.value.post.uri, {
111111+ optimisticReplyCount: currentReplyCount,
112112+ })
9211393114 const opDid = getRootPostAtUri(parent.value.post)?.host
94115 const nextPreexistingItem = thread.at(i + 1)
+9-2
src/state/queries/usePostThread/traversal.ts
···307307 metadata.isPartOfLastBranchFromDepth = metadata.depth
308308309309 /**
310310- * If the parent is part of the last branch of the sub-tree, so is the child.
310310+ * If the parent is part of the last branch of the sub-tree, so
311311+ * is the child. However, if the child is also a last sibling,
312312+ * then we need to start tracking `isPartOfLastBranchFromDepth`
313313+ * from this point onwards, always updating it to the depth of
314314+ * the last sibling as we go down.
311315 */
312312- if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
316316+ if (
317317+ !metadata.isLastSibling &&
318318+ metadata.parentMetadata.isPartOfLastBranchFromDepth
319319+ ) {
313320 metadata.isPartOfLastBranchFromDepth =
314321 metadata.parentMetadata.isPartOfLastBranchFromDepth
315322 }
+2-2
src/state/queries/usePostThread/types.ts
···151151 */
152152 isLastChild: boolean
153153 /**
154154- * Indicates if the post is the left/lower-most branch of the reply tree.
155155- * Value corresponds to the depth at which this branch started.
154154+ * Indicates if the post is the left-most AND lower-most branch of the reply
155155+ * tree. Value corresponds to the depth at which this branch started.
156156 */
157157 isPartOfLastBranchFromDepth?: number
158158 /**
···11import React from 'react'
22-import {AppBskyActorDefs} from '@atproto/api'
22+import {type AppBskyActorDefs} from '@atproto/api'
33import {msg, Trans} from '@lingui/macro'
44import {useLingui} from '@lingui/react'
55import {useNavigation} from '@react-navigation/native'
···126126 <ButtonText>
127127 {!isFollowing ? (
128128 isFollowedBy ? (
129129- <Trans>Follow Back</Trans>
129129+ <Trans>Follow back</Trans>
130130 ) : (
131131 <Trans>Follow</Trans>
132132 )
+5-5
src/view/com/profile/FollowButton.tsx
···11-import {StyleProp, TextStyle, View} from 'react-native'
11+import {type StyleProp, type TextStyle, View} from 'react-native'
22import {msg} from '@lingui/macro'
33import {useLingui} from '@lingui/react'
4455-import {Shadow} from '#/state/cache/types'
55+import {type Shadow} from '#/state/cache/types'
66import {useProfileFollowMutationQueue} from '#/state/queries/profile'
77-import * as bsky from '#/types/bsky'
88-import {Button, ButtonType} from '../util/forms/Button'
77+import type * as bsky from '#/types/bsky'
88+import {Button, type ButtonType} from '../util/forms/Button'
99import * as Toast from '../util/Toast'
10101111export function FollowButton({
···7878 type={unfollowedType}
7979 labelStyle={labelStyle}
8080 onPress={onPressFollow}
8181- label={_(msg({message: 'Follow Back', context: 'action'}))}
8181+ label={_(msg({message: 'Follow back', context: 'action'}))}
8282 />
8383 )
8484 }
+13-5
src/view/shell/index.tsx
···1313import {isStateAtTabRoot} from '#/lib/routes/helpers'
1414import {isAndroid, isIOS} from '#/platform/detection'
1515import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
1616+import {useGeolocation} from '#/state/geolocation'
1617import {useSession} from '#/state/session'
1718import {
1819 useIsDrawerOpen,
···2627import {atoms as a, select, useTheme} from '#/alf'
2728import {setSystemUITheme} from '#/alf/util/systemUI'
2829import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
3030+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
2931import {EmailDialog} from '#/components/dialogs/EmailDialog'
3032import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
3133import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
···180182 )
181183}
182184183183-export const Shell: React.FC = function ShellImpl() {
184184- const fullyExpandedCount = useDialogFullyExpandedCountContext()
185185+export function Shell() {
185186 const t = useTheme()
187187+ const {geolocation} = useGeolocation()
188188+ const fullyExpandedCount = useDialogFullyExpandedCountContext()
189189+186190 useIntentHandler()
187191188192 useEffect(() => {
···200204 navigationBar: t.name !== 'light' ? 'light' : 'dark',
201205 }}
202206 />
203203- <RoutesContainer>
204204- <ShellInner />
205205- </RoutesContainer>
207207+ {geolocation?.isAgeBlockedGeo ? (
208208+ <BlockedGeoOverlay />
209209+ ) : (
210210+ <RoutesContainer>
211211+ <ShellInner />
212212+ </RoutesContainer>
213213+ )}
206214 </View>
207215 )
208216}
+13-14
src/view/shell/index.web.tsx
···55import {useNavigation} from '@react-navigation/native'
66import {RemoveScrollBar} from 'react-remove-scroll-bar'
7788-import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
98import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
109import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
1110import {type NavigationProp} from '#/lib/routes/types'
1212-import {colors} from '#/lib/styles'
1111+import {useGeolocation} from '#/state/geolocation'
1312import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
1413import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
1514import {useCloseAllActiveElements} from '#/state/util'
···1817import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
1918import {atoms as a, select, useTheme} from '#/alf'
2019import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
2020+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
2121import {EmailDialog} from '#/components/dialogs/EmailDialog'
2222import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
2323import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
···130130 )
131131}
132132133133-export const Shell: React.FC = function ShellImpl() {
134134- const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
133133+export function Shell() {
134134+ const t = useTheme()
135135+ const {geolocation} = useGeolocation()
135136 return (
136136- <View style={[a.util_screen_outer, pageBg]}>
137137- <RoutesContainer>
138138- <ShellInner />
139139- </RoutesContainer>
137137+ <View style={[a.util_screen_outer, t.atoms.bg]}>
138138+ {geolocation?.isAgeBlockedGeo ? (
139139+ <BlockedGeoOverlay />
140140+ ) : (
141141+ <RoutesContainer>
142142+ <ShellInner />
143143+ </RoutesContainer>
144144+ )}
140145 </View>
141146 )
142147}
143148144149const styles = StyleSheet.create({
145145- bgLight: {
146146- backgroundColor: colors.white,
147147- },
148148- bgDark: {
149149- backgroundColor: colors.black, // TODO
150150- },
151150 drawerMask: {
152151 ...a.fixed,
153152 width: '100%',