about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/post-shadow.ts15
-rw-r--r--src/state/feed-feedback.tsx5
-rw-r--r--src/state/geolocation.tsx56
-rw-r--r--src/state/queries/suggested-follows.ts15
-rw-r--r--src/state/queries/usePostThread/queryCache.ts29
-rw-r--r--src/state/queries/usePostThread/traversal.ts11
-rw-r--r--src/state/queries/usePostThread/types.ts4
7 files changed, 114 insertions, 21 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index d7f1eb8b9..8cc3dca1a 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -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,
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index ee381259d..8b235f492 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -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
diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx
index 4581996a0..c4d8cb946 100644
--- a/src/state/geolocation.tsx
+++ b/src/state/geolocation.tsx
@@ -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`)
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 0a2343150..c7a6e5f75 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -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({
diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts
index 826932349..5e27ebb87 100644
--- a/src/state/queries/usePostThread/queryCache.ts
+++ b/src/state/queries/usePostThread/queryCache.ts
@@ -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)
diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts
index 2809d32e9..2e7693fab 100644
--- a/src/state/queries/usePostThread/traversal.ts
+++ b/src/state/queries/usePostThread/traversal.ts
@@ -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
               }
diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts
index 2f370b0ab..5df7c2e42 100644
--- a/src/state/queries/usePostThread/types.ts
+++ b/src/state/queries/usePostThread/types.ts
@@ -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
   /**