about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/ScrollContext.tsx5
-rw-r--r--src/lib/api/feed/custom.ts10
-rw-r--r--src/lib/api/feed/home.ts11
-rw-r--r--src/lib/api/feed/merge.ts14
-rw-r--r--src/lib/api/feed/utils.ts21
-rw-r--r--src/lib/app-info.ts16
-rw-r--r--src/lib/app-info.web.ts15
-rw-r--r--src/lib/functions.ts87
-rw-r--r--src/lib/hooks/useAccountSwitcher.ts38
-rw-r--r--src/lib/hooks/useNavigationTabState.ts3
-rw-r--r--src/lib/hooks/useNavigationTabState.web.ts2
-rw-r--r--src/lib/routes/types.ts6
-rw-r--r--src/lib/statsig/gates.ts3
-rw-r--r--src/lib/strings/embed-player.ts3
14 files changed, 209 insertions, 25 deletions
diff --git a/src/lib/ScrollContext.tsx b/src/lib/ScrollContext.tsx
index 00b197bed..d55b8cdab 100644
--- a/src/lib/ScrollContext.tsx
+++ b/src/lib/ScrollContext.tsx
@@ -5,6 +5,7 @@ const ScrollContext = createContext<ScrollHandlers<any>>({
   onBeginDrag: undefined,
   onEndDrag: undefined,
   onScroll: undefined,
+  onMomentumEnd: undefined,
 })
 
 export function useScrollHandlers(): ScrollHandlers<any> {
@@ -20,14 +21,16 @@ export function ScrollProvider({
   onBeginDrag,
   onEndDrag,
   onScroll,
+  onMomentumEnd,
 }: ProviderProps) {
   const handlers = useMemo(
     () => ({
       onBeginDrag,
       onEndDrag,
       onScroll,
+      onMomentumEnd,
     }),
-    [onBeginDrag, onEndDrag, onScroll],
+    [onBeginDrag, onEndDrag, onScroll, onMomentumEnd],
   )
   return (
     <ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index 75182c41f..87e45ceba 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -7,20 +7,25 @@ import {
 
 import {getContentLanguages} from '#/state/preferences/languages'
 import {FeedAPI, FeedAPIResponse} from './types'
+import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
 
 export class CustomFeedAPI implements FeedAPI {
   getAgent: () => BskyAgent
   params: GetCustomFeed.QueryParams
+  userInterests?: string
 
   constructor({
     getAgent,
     feedParams,
+    userInterests,
   }: {
     getAgent: () => BskyAgent
     feedParams: GetCustomFeed.QueryParams
+    userInterests?: string
   }) {
     this.getAgent = getAgent
     this.params = feedParams
+    this.userInterests = userInterests
   }
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
@@ -44,6 +49,8 @@ export class CustomFeedAPI implements FeedAPI {
   }): Promise<FeedAPIResponse> {
     const contentLangs = getContentLanguages().join(',')
     const agent = this.getAgent()
+    const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
+
     const res = agent.session
       ? await this.getAgent().app.bsky.feed.getFeed(
           {
@@ -53,6 +60,9 @@ export class CustomFeedAPI implements FeedAPI {
           },
           {
             headers: {
+              ...(isBlueskyOwned
+                ? createBskyTopicsHeader(this.userInterests)
+                : {}),
               'Accept-Language': contentLangs,
             },
           },
diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts
index 4a5308346..270f3aacb 100644
--- a/src/lib/api/feed/home.ts
+++ b/src/lib/api/feed/home.ts
@@ -32,14 +32,22 @@ export class HomeFeedAPI implements FeedAPI {
   discover: CustomFeedAPI
   usingDiscover = false
   itemCursor = 0
+  userInterests?: string
 
-  constructor({getAgent}: {getAgent: () => BskyAgent}) {
+  constructor({
+    userInterests,
+    getAgent,
+  }: {
+    userInterests?: string
+    getAgent: () => BskyAgent
+  }) {
     this.getAgent = getAgent
     this.following = new FollowingFeedAPI({getAgent})
     this.discover = new CustomFeedAPI({
       getAgent,
       feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
     })
+    this.userInterests = userInterests
   }
 
   reset() {
@@ -47,6 +55,7 @@ export class HomeFeedAPI implements FeedAPI {
     this.discover = new CustomFeedAPI({
       getAgent: this.getAgent,
       feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
+      userInterests: this.userInterests,
     })
     this.usingDiscover = false
     this.itemCursor = 0
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index c85de0306..b7ac8bce1 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -9,11 +9,13 @@ import {feedUriToHref} from 'lib/strings/url-helpers'
 import {FeedTuner} from '../feed-manip'
 import {FeedTunerFn} from '../feed-manip'
 import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
+import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
 
 const REQUEST_WAIT_MS = 500 // 500ms
 const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
 
 export class MergeFeedAPI implements FeedAPI {
+  userInterests?: string
   getAgent: () => BskyAgent
   params: FeedParams
   feedTuners: FeedTunerFn[]
@@ -27,14 +29,17 @@ export class MergeFeedAPI implements FeedAPI {
     getAgent,
     feedParams,
     feedTuners,
+    userInterests,
   }: {
     getAgent: () => BskyAgent
     feedParams: FeedParams
     feedTuners: FeedTunerFn[]
+    userInterests?: string
   }) {
     this.getAgent = getAgent
     this.params = feedParams
     this.feedTuners = feedTuners
+    this.userInterests = userInterests
     this.following = new MergeFeedSource_Following({
       getAgent: this.getAgent,
       feedTuners: this.feedTuners,
@@ -58,6 +63,7 @@ export class MergeFeedAPI implements FeedAPI {
               getAgent: this.getAgent,
               feedUri,
               feedTuners: this.feedTuners,
+              userInterests: this.userInterests,
             }),
         ),
       )
@@ -254,15 +260,18 @@ class MergeFeedSource_Custom extends MergeFeedSource {
   getAgent: () => BskyAgent
   minDate: Date
   feedUri: string
+  userInterests?: string
 
   constructor({
     getAgent,
     feedUri,
     feedTuners,
+    userInterests,
   }: {
     getAgent: () => BskyAgent
     feedUri: string
     feedTuners: FeedTunerFn[]
+    userInterests?: string
   }) {
     super({
       getAgent,
@@ -270,6 +279,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
     })
     this.getAgent = getAgent
     this.feedUri = feedUri
+    this.userInterests = userInterests
     this.sourceInfo = {
       $type: 'reasonFeedSource',
       uri: feedUri,
@@ -284,6 +294,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
   ): Promise<AppBskyFeedGetTimeline.Response> {
     try {
       const contentLangs = getContentLanguages().join(',')
+      const isBlueskyOwned = isBlueskyOwnedFeed(this.feedUri)
       const res = await this.getAgent().app.bsky.feed.getFeed(
         {
           cursor,
@@ -292,6 +303,9 @@ class MergeFeedSource_Custom extends MergeFeedSource {
         },
         {
           headers: {
+            ...(isBlueskyOwned
+              ? createBskyTopicsHeader(this.userInterests)
+              : {}),
             'Accept-Language': contentLangs,
           },
         },
diff --git a/src/lib/api/feed/utils.ts b/src/lib/api/feed/utils.ts
new file mode 100644
index 000000000..50162ed2a
--- /dev/null
+++ b/src/lib/api/feed/utils.ts
@@ -0,0 +1,21 @@
+import {AtUri} from '@atproto/api'
+
+import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
+
+export function createBskyTopicsHeader(userInterests?: string) {
+  return {
+    'X-Bsky-Topics': userInterests || '',
+  }
+}
+
+export function aggregateUserInterests(
+  preferences?: UsePreferencesQueryResponse,
+) {
+  return preferences?.interests?.tags?.join(',') || ''
+}
+
+export function isBlueskyOwnedFeed(feedUri: string) {
+  const uri = new AtUri(feedUri)
+  return BSKY_FEED_OWNER_DIDS.includes(uri.host)
+}
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
index af265bfcb..00b0d7eca 100644
--- a/src/lib/app-info.ts
+++ b/src/lib/app-info.ts
@@ -4,7 +4,17 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
 export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
 export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
 
-const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
-export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
-  IS_DEV ? 'development' : UPDATES_CHANNEL
+// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
+// along with the other version info. Useful for debugging/reporting.
+export const BUNDLE_IDENTIFIER =
+  process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
+
+// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
+// for Statsig reporting and shouldn't be used to identify a specific bundle.
+export const BUNDLE_DATE =
+  IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
+
+export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
+export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
+  IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
 })`
diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts
index 5739b8783..fe2bc5fff 100644
--- a/src/lib/app-info.web.ts
+++ b/src/lib/app-info.web.ts
@@ -1,2 +1,17 @@
 import {version} from '../../package.json'
+
+export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
+
+// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
+// along with the other version info. Useful for debugging/reporting.
+export const BUNDLE_IDENTIFIER =
+  process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
+
+// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
+// for Statsig reporting and shouldn't be used to identify a specific bundle.
+export const BUNDLE_DATE = IS_DEV
+  ? 0
+  : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
+
 export const appVersion = version
+export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
diff --git a/src/lib/functions.ts b/src/lib/functions.ts
index b45c7fa6d..e0d44ce2d 100644
--- a/src/lib/functions.ts
+++ b/src/lib/functions.ts
@@ -9,3 +9,90 @@ export function dedupArray<T>(arr: T[]): T[] {
   const s = new Set(arr)
   return [...s]
 }
+
+/**
+ * Taken from @tanstack/query-core utils.ts
+ * Modified to support Date object comparisons
+ *
+ * This function returns `a` if `b` is deeply equal.
+ * If not, it will replace any deeply equal children of `b` with those of `a`.
+ * This can be used for structural sharing between JSON values for example.
+ */
+export function replaceEqualDeep(a: any, b: any): any {
+  if (a === b) {
+    return a
+  }
+
+  if (a instanceof Date && b instanceof Date) {
+    return a.getTime() === b.getTime() ? a : b
+  }
+
+  const array = isPlainArray(a) && isPlainArray(b)
+
+  if (array || (isPlainObject(a) && isPlainObject(b))) {
+    const aItems = array ? a : Object.keys(a)
+    const aSize = aItems.length
+    const bItems = array ? b : Object.keys(b)
+    const bSize = bItems.length
+    const copy: any = array ? [] : {}
+
+    let equalItems = 0
+
+    for (let i = 0; i < bSize; i++) {
+      const key = array ? i : bItems[i]
+      if (
+        !array &&
+        a[key] === undefined &&
+        b[key] === undefined &&
+        aItems.includes(key)
+      ) {
+        copy[key] = undefined
+        equalItems++
+      } else {
+        copy[key] = replaceEqualDeep(a[key], b[key])
+        if (copy[key] === a[key] && a[key] !== undefined) {
+          equalItems++
+        }
+      }
+    }
+
+    return aSize === bSize && equalItems === aSize ? a : copy
+  }
+
+  return b
+}
+
+export function isPlainArray(value: unknown) {
+  return Array.isArray(value) && value.length === Object.keys(value).length
+}
+
+// Copied from: https://github.com/jonschlinkert/is-plain-object
+export function isPlainObject(o: any): o is Object {
+  if (!hasObjectPrototype(o)) {
+    return false
+  }
+
+  // If has no constructor
+  const ctor = o.constructor
+  if (ctor === undefined) {
+    return true
+  }
+
+  // If has modified prototype
+  const prot = ctor.prototype
+  if (!hasObjectPrototype(prot)) {
+    return false
+  }
+
+  // If constructor does not have an Object-specific method
+  if (!prot.hasOwnProperty('isPrototypeOf')) {
+    return false
+  }
+
+  // Most likely a plain Object
+  return true
+}
+
+function hasObjectPrototype(o: any): boolean {
+  return Object.prototype.toString.call(o) === '[object Object]'
+}
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 6a1cea234..ad529f912 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -1,15 +1,21 @@
-import {useCallback} from 'react'
+import {useCallback, useState} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
+import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
 import {SessionAccount, useSessionApi} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import * as Toast from '#/view/com/util/Toast'
+import {logEvent} from '../statsig/statsig'
 import {LogEvents} from '../statsig/statsig'
 
 export function useAccountSwitcher() {
+  const [pendingDid, setPendingDid] = useState<string | null>(null)
+  const {_} = useLingui()
   const {track} = useAnalytics()
-  const {selectAccount, clearCurrentAccount} = useSessionApi()
+  const {initSession} = useSessionApi()
   const {requestSwitchToAccount} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
@@ -18,8 +24,12 @@ export function useAccountSwitcher() {
       logContext: LogEvents['account:loggedIn']['logContext'],
     ) => {
       track('Settings:SwitchAccountButtonClicked')
-
+      if (pendingDid) {
+        // The session API isn't resilient to race conditions so let's just ignore this.
+        return
+      }
       try {
+        setPendingDid(account.did)
         if (account.accessJwt) {
           if (isWeb) {
             // We're switching accounts, which remounts the entire app.
@@ -29,24 +39,26 @@ export function useAccountSwitcher() {
             // So we change the URL ourselves. The navigator will pick it up on remount.
             history.pushState(null, '', '/')
           }
-          await selectAccount(account, logContext)
-          setTimeout(() => {
-            Toast.show(`Signed in as @${account.handle}`)
-          }, 100)
+          await initSession(account)
+          logEvent('account:loggedIn', {logContext, withPassword: false})
+          Toast.show(_(msg`Signed in as @${account.handle}`))
         } else {
           requestSwitchToAccount({requestedAccount: account.did})
           Toast.show(
-            `Please sign in as @${account.handle}`,
+            _(msg`Please sign in as @${account.handle}`),
             'circle-exclamation',
           )
         }
-      } catch (e) {
-        Toast.show('Sorry! We need you to enter your password.')
-        clearCurrentAccount() // back user out to login
+      } catch (e: any) {
+        logger.error(`switch account: selectAccount failed`, {
+          message: e.message,
+        })
+      } finally {
+        setPendingDid(null)
       }
     },
-    [track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
+    [_, track, initSession, requestSwitchToAccount, pendingDid],
   )
 
-  return {onPressSwitchAccount}
+  return {onPressSwitchAccount, pendingDid}
 }
diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts
index 7fc0c65be..c70653e3a 100644
--- a/src/lib/hooks/useNavigationTabState.ts
+++ b/src/lib/hooks/useNavigationTabState.ts
@@ -11,8 +11,9 @@ export function useNavigationTabState() {
       isAtNotifications:
         getTabState(state, 'Notifications') !== TabState.Outside,
       isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
-      isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
+      isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
     }
+
     if (
       !res.isAtHome &&
       !res.isAtSearch &&
diff --git a/src/lib/hooks/useNavigationTabState.web.ts b/src/lib/hooks/useNavigationTabState.web.ts
index 704424781..e86d6c6c3 100644
--- a/src/lib/hooks/useNavigationTabState.web.ts
+++ b/src/lib/hooks/useNavigationTabState.web.ts
@@ -1,4 +1,5 @@
 import {useNavigationState} from '@react-navigation/native'
+
 import {getCurrentRoute} from 'lib/routes/helpers'
 
 export function useNavigationTabState() {
@@ -9,6 +10,7 @@ export function useNavigationTabState() {
       isAtSearch: currentRoute === 'Search',
       isAtNotifications: currentRoute === 'Notifications',
       isAtMyProfile: currentRoute === 'MyProfile',
+      isAtMessages: currentRoute === 'Messages',
     }
   })
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index f9a592711..f7e8544b8 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
 }
 
 export type MessagesTabNavigatorParams = CommonNavigatorParams & {
-  MessagesList: undefined
+  Messages: undefined
 }
 
 export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Feeds: undefined
   Notifications: undefined
   Hashtag: {tag: string; author?: string}
-  MessagesList: undefined
+  Messages: undefined
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   MyProfileTab: undefined
   Hashtag: {tag: string; author?: string}
   MessagesTab: undefined
-  MessagesList: undefined
+  Messages: undefined
 }
 
 // NOTE
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 5cd603920..43e2086c2 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,10 +1,9 @@
 export type Gate =
   // Keep this alphabetic please.
   | 'autoexpand_suggestions_on_profile_follow_v2'
-  | 'disable_min_shell_on_foregrounding_v2'
+  | 'disable_min_shell_on_foregrounding_v3'
   | 'disable_poll_on_discover_v2'
   | 'dms'
-  | 'hide_vertical_scroll_indicators'
   | 'show_follow_back_label_v2'
   | 'start_session_with_following_v2'
   | 'test_gate_1'
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 5848f2af9..d84ccc726 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -95,7 +95,8 @@ export function parseEmbedPlayerFromUrl(
   if (
     urlp.hostname === 'www.youtube.com' ||
     urlp.hostname === 'youtube.com' ||
-    urlp.hostname === 'm.youtube.com'
+    urlp.hostname === 'm.youtube.com' ||
+    urlp.hostname === 'music.youtube.com'
   ) {
     const [_, page, shortVideoId] = urlp.pathname.split('/')
     const videoId =