diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/ScrollContext.tsx | 5 | ||||
-rw-r--r-- | src/lib/api/feed/custom.ts | 10 | ||||
-rw-r--r-- | src/lib/api/feed/home.ts | 11 | ||||
-rw-r--r-- | src/lib/api/feed/merge.ts | 14 | ||||
-rw-r--r-- | src/lib/api/feed/utils.ts | 21 | ||||
-rw-r--r-- | src/lib/app-info.ts | 16 | ||||
-rw-r--r-- | src/lib/app-info.web.ts | 15 | ||||
-rw-r--r-- | src/lib/functions.ts | 87 | ||||
-rw-r--r-- | src/lib/hooks/useAccountSwitcher.ts | 38 | ||||
-rw-r--r-- | src/lib/hooks/useNavigationTabState.ts | 3 | ||||
-rw-r--r-- | src/lib/hooks/useNavigationTabState.web.ts | 2 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 6 | ||||
-rw-r--r-- | src/lib/statsig/gates.ts | 3 | ||||
-rw-r--r-- | src/lib/strings/embed-player.ts | 3 |
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 = |