From cf1b0b18811c3ef6cd86ea6a4f3bfdd3e56cfeb8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 28 Nov 2023 10:43:25 -0600 Subject: [PATCH 01/26] Handle other feed auth response (#2012) --- src/state/queries/feed.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 58c1261d..693a7d53 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -181,6 +181,9 @@ export function useIsFeedPublicQuery({uri}: {uri: string}) { if (msg.includes('missing jwt')) { return false + } else if (msg.includes('This feed requires being logged-in')) { + // e.g. https://github.com/bluesky-social/atproto/blob/99ab1ae55c463e8d5321a1eaad07a175bdd56fea/packages/bsky/src/feed-gen/best-of-follows.ts#L13 + return false } return true From bdabfa9d38ab776b5bc7eedafecd11c396dd451b Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 28 Nov 2023 10:44:25 -0600 Subject: [PATCH 02/26] Guard against following tab stub being used un-authed (#2013) --- src/view/com/pager/FeedsTabBar.web.tsx | 3 ++- src/view/com/pager/FeedsTabBarMobile.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index a39499b2..fdb4df17 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -81,9 +81,10 @@ function FeedsTabBarTablet( ) { const feeds = usePinnedFeedsInfos() const pal = usePalette('default') + const {hasSession} = useSession() const {headerMinimalShellTransform} = useMinimalShellMode() const {headerHeight} = useShellLayout() - const items = feeds.map(f => f.displayName) + const items = hasSession ? feeds.map(f => f.displayName) : [] return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 2983a457..735aa1ba 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -30,7 +30,7 @@ export function FeedsTabBar( const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const {headerHeight} = useShellLayout() const {headerMinimalShellTransform} = useMinimalShellMode() - const items = feeds.map(f => f.displayName) + const items = hasSession ? feeds.map(f => f.displayName) : [] const onPressAvi = React.useCallback(() => { setDrawerOpen(true) From 0b2c85b967ea7a2dd18261773449260b80004423 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 28 Nov 2023 11:04:15 -0600 Subject: [PATCH 03/26] Fix pinned feeds re-ordering (#2014) --- src/view/screens/Home.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 28f01b68..476c69fc 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -40,6 +40,12 @@ function HomeScreenReady({ const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const [selectedPage, setSelectedPage] = React.useState(0) + /** + * Used to ensure that we re-compute `customFeeds` AND force a re-render of + * the pager with the new order of feeds. + */ + const pinnedFeedOrderKey = JSON.stringify(preferences.feeds.pinned) + const customFeeds = React.useMemo(() => { const pinned = preferences.feeds.pinned const feeds: FeedDescriptor[] = [] @@ -51,7 +57,9 @@ function HomeScreenReady({ } } return feeds - }, [preferences.feeds.pinned]) + // TODO careful, needed to disabled this -esb + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preferences.feeds.pinned, pinnedFeedOrderKey]) const homeFeedParams = React.useMemo(() => { return { @@ -83,7 +91,6 @@ function HomeScreenReady({ emitSoftReset() }, []) - // TODO(pwi) may need this in public view const onPageScrollStateChanged = React.useCallback( (state: 'idle' | 'dragging' | 'settling') => { if (state === 'dragging') { @@ -118,6 +125,7 @@ function HomeScreenReady({ return hasSession ? ( Date: Tue, 28 Nov 2023 12:50:41 -0600 Subject: [PATCH 04/26] Fix pinned feeds mutation issue (#2016) --- src/state/queries/feed.ts | 9 +-------- src/view/screens/Home.tsx | 4 +--- src/view/screens/SavedFeeds.tsx | 6 ++++-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 693a7d53..b5d491a5 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -252,7 +252,6 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] { FOLLOWING_FEED_STUB, ]) const {data: preferences} = usePreferencesQuery() - const pinnedFeedsKey = JSON.stringify(preferences?.feeds?.pinned) React.useEffect(() => { if (!preferences?.feeds?.pinned) return @@ -299,13 +298,7 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] { } fetchFeedInfo() - }, [ - queryClient, - setTabs, - preferences?.feeds?.pinned, - // ensure we react to re-ordering - pinnedFeedsKey, - ]) + }, [queryClient, setTabs, preferences?.feeds?.pinned]) return tabs } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 476c69fc..e5a3035a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -57,9 +57,7 @@ function HomeScreenReady({ } } return feeds - // TODO careful, needed to disabled this -esb - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [preferences.feeds.pinned, pinnedFeedOrderKey]) + }, [preferences.feeds.pinned]) const homeFeedParams = React.useMemo(() => { return { diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 640d76a5..ce668877 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -185,7 +185,8 @@ function ListItem({ queryClient.getQueryData( preferencesQueryKey, )?.feeds - const pinned = feeds?.pinned ?? [] + // create new array, do not mutate + const pinned = feeds?.pinned ? [...feeds.pinned] : [] const index = pinned.indexOf(feedUri) if (index === -1 || index === 0) return @@ -210,7 +211,8 @@ function ListItem({ queryClient.getQueryData( preferencesQueryKey, )?.feeds - const pinned = feeds?.pinned ?? [] + // create new array, do not mutate + const pinned = feeds?.pinned ? [...feeds.pinned] : [] const index = pinned.indexOf(feedUri) if (index === -1 || index >= pinned.length - 1) return From b778017000eeed028edc5cd8fa89d64d4d90dc32 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 28 Nov 2023 22:48:00 +0000 Subject: [PATCH 05/26] Fix memory leak on mobile web tab navigation (#2021) * Add navigationAction prop to Link * Bottom bar should use navigate() action --- src/view/com/util/Link.tsx | 41 +++++++++++++++++++--- src/view/shell/bottom-bar/BottomBarWeb.tsx | 2 +- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 074ab232..dcbec7cb 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -46,6 +46,7 @@ interface Props extends ComponentProps { noFeedback?: boolean asAnchor?: boolean anchorNoUnderline?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } export const Link = memo(function Link({ @@ -58,6 +59,7 @@ export const Link = memo(function Link({ asAnchor, accessible, anchorNoUnderline, + navigationAction, ...props }: Props) { const {closeModal} = useModalControls() @@ -67,10 +69,16 @@ export const Link = memo(function Link({ const onPress = React.useCallback( (e?: Event) => { if (typeof href === 'string') { - return onPressInner(closeModal, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) } }, - [closeModal, navigation, href], + [closeModal, navigation, navigationAction, href], ) if (noFeedback) { @@ -146,6 +154,7 @@ export const TextLink = memo(function TextLink({ title, onPress, warnOnMismatchingLabel, + navigationAction, ...orgProps }: { testID?: string @@ -158,6 +167,7 @@ export const TextLink = memo(function TextLink({ dataSet?: any title?: string warnOnMismatchingLabel?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } & TextProps) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) const navigation = useNavigation() @@ -185,7 +195,13 @@ export const TextLink = memo(function TextLink({ // @ts-ignore function signature differs by platform -prf return onPress() } - return onPressInner(closeModal, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) }, [ onPress, @@ -195,6 +211,7 @@ export const TextLink = memo(function TextLink({ href, text, warnOnMismatchingLabel, + navigationAction, ], ) const hrefAttrs = useMemo(() => { @@ -241,6 +258,7 @@ interface TextLinkOnWebOnlyProps extends TextProps { accessibilityLabel?: string accessibilityHint?: string title?: string + navigationAction?: 'push' | 'replace' | 'navigate' } export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ testID, @@ -250,6 +268,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ text, numberOfLines, lineHeight, + navigationAction, ...props }: TextLinkOnWebOnlyProps) { if (isWeb) { @@ -263,6 +282,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ numberOfLines={numberOfLines} lineHeight={lineHeight} title={props.title} + navigationAction={navigationAction} {...props} /> ) @@ -296,6 +316,7 @@ function onPressInner( closeModal = () => {}, navigation: NavigationProp, href: string, + navigationAction: 'push' | 'replace' | 'navigate' = 'push', e?: Event, ) { let shouldHandle = false @@ -328,8 +349,18 @@ function onPressInner( } else { closeModal() // close any active modals - // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.push(...router.matchPath(href))) + if (navigationAction === 'push') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (navigationAction === 'replace') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.replace(...router.matchPath(href))) + } else if (navigationAction === 'navigate') { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } } } } diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 8efd7b6b..3a60bd3b 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -137,7 +137,7 @@ const NavItem: React.FC<{ : isTab(currentRoute.name, routeName) return ( - + {children({isActive})} ) From 6f7032d42b85298b95f9a55d2dd4809450c6fa64 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 28 Nov 2023 21:49:37 -0600 Subject: [PATCH 06/26] Full send Sentry (#2018) * Update build profiles, sentry config * Enable sentry * Ok actually enable in dev * Remove debug * Add TF build * Fix typo * Remove debug * Remove unecessary config * Fix typo * Set env in Expo * Remove scripts * Clarify * Replace invalid character * Align on release/dist * Add build version * Just use package version * Align dist --- app.config.js | 44 +++++++++++++++++++++++++++++++++++++++----- eas.json | 25 ++++--------------------- package.json | 1 - src/lib/sentry.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- src/logger/index.ts | 9 +++------ 5 files changed, 86 insertions(+), 35 deletions(-) diff --git a/app.config.js b/app.config.js index d7a0aa21..bb79260e 100644 --- a/app.config.js +++ b/app.config.js @@ -1,12 +1,41 @@ +const pkg = require('./package.json') + module.exports = function () { - const hasSentryToken = !!process.env.SENTRY_AUTH_TOKEN + /** + * App version number. Should be incremented as part of a release cycle. + */ + const VERSION = pkg.version + + /** + * iOS build number. Must be incremented for each TestFlight version. + */ + const IOS_BUILD_NUMBER = '4' + + /** + * Android build number. Must be incremented for each release. + */ + const ANDROID_VERSION_CODE = 46 + + /** + * Uses built-in Expo env vars + * + * @see https://docs.expo.dev/build-reference/variables/#built-in-environment-variables + */ + const PLATFORM = process.env.EAS_BUILD_PLATFORM + + /** + * Additional granularity for the `dist` field + */ + const DIST_BUILD_NUMBER = + PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER + return { expo: { + version: VERSION, name: 'Bluesky', slug: 'bluesky', scheme: 'bluesky', owner: 'blueskysocial', - version: '1.57.0', runtimeVersion: { policy: 'appVersion', }, @@ -19,7 +48,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, ios: { - buildNumber: '4', + buildNumber: IOS_BUILD_NUMBER, supportsTablet: false, bundleIdentifier: 'xyz.blueskyweb.app', config: { @@ -43,7 +72,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, android: { - versionCode: 46, + versionCode: ANDROID_VERSION_CODE, adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', backgroundColor: '#ffffff', @@ -74,7 +103,7 @@ module.exports = function () { }, plugins: [ 'expo-localization', - hasSentryToken && 'sentry-expo', + Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', [ 'expo-build-properties', { @@ -100,11 +129,16 @@ module.exports = function () { }, hooks: { postPublish: [ + /* + * @see https://docs.expo.dev/guides/using-sentry/#app-configuration + */ { file: 'sentry-expo/upload-sourcemaps', config: { organization: 'blueskyweb', project: 'react-native', + release: VERSION, + dist: `${PLATFORM}.${VERSION}.${DIST_BUILD_NUMBER}`, }, }, ], diff --git a/eas.json b/eas.json index a66b6c07..25fee4ea 100644 --- a/eas.json +++ b/eas.json @@ -11,28 +11,19 @@ "extends": "base", "developmentClient": true, "distribution": "internal", + "channel": "development", "ios": { "simulator": true, "resourceClass": "large" - }, - "channel": "development" - }, - "development-device": { - "extends": "base", - "developmentClient": true, - "distribution": "internal", - "ios": { - "resourceClass": "large" - }, - "channel": "development" + } }, "preview": { "extends": "base", "distribution": "internal", + "channel": "preview", "ios": { "resourceClass": "large" - }, - "channel": "preview" + } }, "production": { "extends": "base", @@ -40,14 +31,6 @@ "resourceClass": "large" }, "channel": "production" - }, - "dev-android-apk": { - "extends": "base", - "developmentClient": true, - "android": { - "buildType": "apk", - "gradleCommand": ":app:assembleRelease" - } } }, "submit": { diff --git a/package.json b/package.json index 814464a9..6c75054b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json", "perf:test:results": "NODE_ENV=test flashlight report .perf/results.json", "perf:measure": "NODE_ENV=test flashlight measure", - "build:apk": "eas build -p android --profile dev-android-apk", "intl:extract": "lingui extract", "intl:compile": "lingui compile" }, diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index b080bcc5..63a21a43 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -1,8 +1,46 @@ +/** + * Importing these separately from `platform/detection` and `lib/app-info` to + * avoid future conflicts and/or circular deps + */ + +import {Platform} from 'react-native' +import app from 'react-native-version-number' +import * as info from 'expo-updates' import {init} from 'sentry-expo' +/** + * Matches the build profile `channel` props in `eas.json` + */ +const buildChannel = (info.channel || 'development') as + | 'development' + | 'preview' + | 'production' + +/** + * Examples: + * - `dev` + * - `1.57.0` + */ +const release = app.appVersion ?? 'dev' + +/** + * Examples: + * - `web.dev` + * - `ios.dev` + * - `android.dev` + * - `web.1.57.0` + * - `ios.1.57.0.3` + * - `android.1.57.0.46` + */ +const dist = `${Platform.OS}.${release}${ + app.buildVersion ? `.${app.buildVersion}` : '' +}` + init({ dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', - enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode. debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production - environment: __DEV__ ? 'development' : 'production', // Set the environment + enableInExpoDevelopment: true, + environment: buildChannel, + dist, + release, }) diff --git a/src/logger/index.ts b/src/logger/index.ts index 9f79a781..59cb84ff 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -288,16 +288,13 @@ export class Logger { */ export const logger = new Logger() -/** - * Report to console in dev, Sentry in prod, nothing in test. - */ if (env.IS_DEV && !env.IS_TEST) { logger.addTransport(consoleTransport) /** - * Uncomment this to test Sentry in dev + * Comment this out to disable Sentry transport in dev */ - // logger.addTransport(sentryTransport); + logger.addTransport(sentryTransport) } else if (env.IS_PROD) { - // logger.addTransport(sentryTransport) + logger.addTransport(sentryTransport) } From 9fb2c29c672f6be07410ae1ca4d7e47b6c98f914 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 29 Nov 2023 04:48:08 +0000 Subject: [PATCH 07/26] Disable RQ structural sharing (#2022) --- src/lib/react-query.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 6b425d3b..9886e87b 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -8,6 +8,10 @@ export const queryClient = new QueryClient({ // so we NEVER want to enable this // -prf refetchOnWindowFocus: false, + // Structural sharing between responses makes it impossible to rely on + // "first seen" timestamps on objects to determine if they're fresh. + // Disable this optimization so that we can rely on "first seen" timestamps. + structuralSharing: false, }, }, }) From ca357ecbcf855f30416917ff840d2d138efdde63 Mon Sep 17 00:00:00 2001 From: Cooper Edmunds Date: Wed, 29 Nov 2023 09:05:26 -0500 Subject: [PATCH 08/26] Add pinOnSave and use it in discover feeds list --- src/view/com/feeds/FeedSourceCard.tsx | 17 ++++++++++++++--- src/view/screens/Feeds.tsx | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index d8b67767..64871ca6 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -17,6 +17,7 @@ import {useModalControls} from '#/state/modals' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { + usePinFeedMutation, UsePreferencesQueryResponse, usePreferencesQuery, useSaveFeedMutation, @@ -30,12 +31,14 @@ export function FeedSourceCard({ showSaveBtn = false, showDescription = false, showLikes = false, + pinOnSave = false, }: { feedUri: string style?: StyleProp showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + pinOnSave?: boolean }) { const {data: preferences} = usePreferencesQuery() const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) @@ -50,6 +53,7 @@ export function FeedSourceCard({ showSaveBtn={showSaveBtn} showDescription={showDescription} showLikes={showLikes} + pinOnSave={pinOnSave} /> ) } @@ -61,6 +65,7 @@ export function FeedSourceCardLoaded({ showSaveBtn = false, showDescription = false, showLikes = false, + pinOnSave = false, }: { feed: FeedSourceInfo preferences: UsePreferencesQueryResponse @@ -68,6 +73,7 @@ export function FeedSourceCardLoaded({ showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + pinOnSave?: boolean }) { const pal = usePalette('default') const {_} = useLingui() @@ -78,6 +84,7 @@ export function FeedSourceCardLoaded({ useSaveFeedMutation() const {isPending: isRemovePending, mutateAsync: removeFeed} = useRemoveFeedMutation() + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri)) @@ -103,14 +110,18 @@ export function FeedSourceCardLoaded({ }) } else { try { - await saveFeed({uri: feed.uri}) + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') logger.error('Failed to save feed', {error: e}) } } - }, [isSaved, openModal, feed, removeFeed, saveFeed, _]) + }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) if (!feed || !preferences) return null @@ -150,7 +161,7 @@ export function FeedSourceCardLoaded({ {showSaveBtn && feed.type === 'feed' && ( ) } else if (item.type === 'popularFeedsNoResults') { From 6fe2b52f6860916a62bf9a4d680a0a3b91b50d91 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 29 Nov 2023 10:10:04 -0800 Subject: [PATCH 09/26] Get more rigorous about getAgent() consistency (#2026) * Get more rigorous about getAgent() consistency * Update the feed wrapper API to use getAgent() directly --- src/lib/api/feed/author.ts | 11 +++--- src/lib/api/feed/custom.ts | 11 +++--- src/lib/api/feed/following.ts | 9 ++--- src/lib/api/feed/likes.ts | 11 +++--- src/lib/api/feed/list.ts | 11 +++--- src/lib/api/feed/merge.ts | 36 ++++++++----------- src/state/queries/list.ts | 40 +++++++--------------- src/state/queries/notifications/feed.ts | 8 ++--- src/state/queries/notifications/unread.tsx | 6 ++-- src/state/queries/post-feed.ts | 18 +++++----- src/state/queries/profile.ts | 6 ++-- src/state/session/index.tsx | 6 ++++ 12 files changed, 70 insertions(+), 103 deletions(-) diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index 77c16786..92df84f8 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -1,18 +1,15 @@ import { AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, - BskyAgent, } from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class AuthorFeedAPI implements FeedAPI { - constructor( - public agent: BskyAgent, - public params: GetAuthorFeed.QueryParams, - ) {} + constructor(public params: GetAuthorFeed.QueryParams) {} async peekLatest(): Promise { - const res = await this.agent.getAuthorFeed({ + const res = await getAgent().getAuthorFeed({ ...this.params, limit: 1, }) @@ -26,7 +23,7 @@ export class AuthorFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - const res = await this.agent.getAuthorFeed({ + const res = await getAgent().getAuthorFeed({ ...this.params, cursor, limit, diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 0be98fb4..47ffc65e 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,18 +1,15 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, - BskyAgent, } from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class CustomFeedAPI implements FeedAPI { - constructor( - public agent: BskyAgent, - public params: GetCustomFeed.QueryParams, - ) {} + constructor(public params: GetCustomFeed.QueryParams) {} async peekLatest(): Promise { - const res = await this.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, limit: 1, }) @@ -26,7 +23,7 @@ export class CustomFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - const res = await this.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, cursor, limit, diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index 13f06c7a..24389b5e 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -1,11 +1,12 @@ -import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' +import {AppBskyFeedDefs} from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class FollowingFeedAPI implements FeedAPI { - constructor(public agent: BskyAgent) {} + constructor() {} async peekLatest(): Promise { - const res = await this.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] @@ -18,7 +19,7 @@ export class FollowingFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - const res = await this.agent.getTimeline({ + const res = await getAgent().getTimeline({ cursor, limit, }) diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index 434ed771..2b0afdf1 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -1,18 +1,15 @@ import { AppBskyFeedDefs, AppBskyFeedGetActorLikes as GetActorLikes, - BskyAgent, } from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class LikesFeedAPI implements FeedAPI { - constructor( - public agent: BskyAgent, - public params: GetActorLikes.QueryParams, - ) {} + constructor(public params: GetActorLikes.QueryParams) {} async peekLatest(): Promise { - const res = await this.agent.getActorLikes({ + const res = await getAgent().getActorLikes({ ...this.params, limit: 1, }) @@ -26,7 +23,7 @@ export class LikesFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - const res = await this.agent.getActorLikes({ + const res = await getAgent().getActorLikes({ ...this.params, cursor, limit, diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index 6cb0730e..19f2ff17 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -1,18 +1,15 @@ import { AppBskyFeedDefs, AppBskyFeedGetListFeed as GetListFeed, - BskyAgent, } from '@atproto/api' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class ListFeedAPI implements FeedAPI { - constructor( - public agent: BskyAgent, - public params: GetListFeed.QueryParams, - ) {} + constructor(public params: GetListFeed.QueryParams) {} async peekLatest(): Promise { - const res = await this.agent.app.bsky.feed.getListFeed({ + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, limit: 1, }) @@ -26,7 +23,7 @@ export class ListFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - const res = await this.agent.app.bsky.feed.getListFeed({ + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, cursor, limit, diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 7a0f0288..bc1b0883 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,4 +1,4 @@ -import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api' +import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' import shuffle from 'lodash.shuffle' import {timeout} from 'lib/async/timeout' import {bundleAsync} from 'lib/async/bundle' @@ -7,6 +7,7 @@ import {FeedTuner} from '../feed-manip' import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types' import {FeedParams} from '#/state/queries/post-feed' import {FeedTunerFn} from '../feed-manip' +import {getAgent} from '#/state/session' const REQUEST_WAIT_MS = 500 // 500ms const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours @@ -18,16 +19,12 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor( - public agent: BskyAgent, - public params: FeedParams, - public feedTuners: FeedTunerFn[], - ) { - this.following = new MergeFeedSource_Following(this.agent, this.feedTuners) + constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) { + this.following = new MergeFeedSource_Following(this.feedTuners) } reset() { - this.following = new MergeFeedSource_Following(this.agent, this.feedTuners) + this.following = new MergeFeedSource_Following(this.feedTuners) this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.feedCursor = 0 this.itemCursor = 0 @@ -35,8 +32,7 @@ export class MergeFeedAPI implements FeedAPI { if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) { this.customFeeds = shuffle( this.params.mergeFeedSources.map( - feedUri => - new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners), + feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners), ), ) } else { @@ -45,7 +41,7 @@ export class MergeFeedAPI implements FeedAPI { } async peekLatest(): Promise { - const res = await this.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] @@ -137,7 +133,7 @@ class MergeFeedSource { queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {} + constructor(public feedTuners: FeedTunerFn[]) {} get numReady() { return this.queue.length @@ -199,7 +195,7 @@ class MergeFeedSource_Following extends MergeFeedSource { cursor: string | undefined, limit: number, ): Promise { - const res = await this.agent.getTimeline({cursor, limit}) + const res = await getAgent().getTimeline({cursor, limit}) // run the tuner pre-emptively to ensure better mixing const slices = this.tuner.tune(res.data.feed, this.feedTuners, { dryRun: false, @@ -213,20 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor( - public agent: BskyAgent, - public feedUri: string, - public feedTuners: FeedTunerFn[], - ) { - super(agent, feedTuners) + constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) { + super(feedTuners) this.sourceInfo = { $type: 'reasonFeedSource', displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) - this.agent.app.bsky.feed - .getFeedGenerator({ + getAgent() + .app.bsky.feed.getFeedGenerator({ feed: feedUri, }) .then( @@ -244,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource { limit: number, ): Promise { try { - const res = await this.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ cursor, limit, feed: this.feedUri, diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index ef05009d..550baecb 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -3,7 +3,6 @@ import { AppBskyGraphGetList, AppBskyGraphList, AppBskyGraphDefs, - BskyAgent, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' @@ -75,13 +74,9 @@ export function useListCreateMutation() { ) // wait for the appview to update - await whenAppViewReady( - getAgent(), - res.uri, - (v: AppBskyGraphGetList.Response) => { - return typeof v?.data?.list.uri === 'string' - }, - ) + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + return typeof v?.data?.list.uri === 'string' + }) return res }, onSuccess() { @@ -142,16 +137,12 @@ export function useListMetadataMutation() { ).data // wait for the appview to update - await whenAppViewReady( - getAgent(), - res.uri, - (v: AppBskyGraphGetList.Response) => { - const list = v.data.list - return ( - list.name === record.name && list.description === record.description - ) - }, - ) + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + const list = v.data.list + return ( + list.name === record.name && list.description === record.description + ) + }) return res }, onSuccess(data, variables) { @@ -216,13 +207,9 @@ export function useListDeleteMutation() { } // wait for the appview to update - await whenAppViewReady( - getAgent(), - uri, - (v: AppBskyGraphGetList.Response) => { - return !v?.success - }, - ) + await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { + return !v?.success + }) }, onSuccess() { invalidateMyLists(queryClient) @@ -271,7 +258,6 @@ export function useListBlockMutation() { } async function whenAppViewReady( - agent: BskyAgent, uri: string, fn: (res: AppBskyGraphGetList.Response) => boolean, ) { @@ -280,7 +266,7 @@ async function whenAppViewReady( 1e3, // 1s delay between tries fn, () => - agent.app.bsky.graph.getList({ + getAgent().app.bsky.graph.getList({ list: uri, limit: 1, }), diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 54bd8754..68396143 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -4,7 +4,6 @@ import { AppBskyFeedRepost, AppBskyFeedLike, AppBskyNotificationListNotifications, - BskyAgent, } from '@atproto/api' import chunk from 'lodash.chunk' import { @@ -84,7 +83,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { // we fetch subjects of notifications (usually posts) now instead of lazily // in the UI to avoid relayouts - const subjects = await fetchSubjects(getAgent(), notifsGrouped) + const subjects = await fetchSubjects(notifsGrouped) for (const notif of notifsGrouped) { if (notif.subjectUri) { notif.subject = subjects.get(notif.subjectUri) @@ -173,7 +172,6 @@ function groupNotifications( } async function fetchSubjects( - agent: BskyAgent, groupedNotifs: FeedNotification[], ): Promise> { const uris = new Set() @@ -185,7 +183,9 @@ async function fetchSubjects( const uriChunks = chunk(Array.from(uris), 25) const postsChunks = await Promise.all( uriChunks.map(uris => - agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), + getAgent() + .app.bsky.feed.getPosts({uris}) + .then(res => res.data.posts), ), ) const map = new Map() diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 36bc6528..b93e1dc8 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -70,12 +70,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, async checkUnread() { - const agent = getAgent() - - if (!agent.session) return + if (!getAgent().session) return // count - const res = await agent.listNotifications({limit: 40}) + const res = await getAgent().listNotifications({limit: 40}) const filtered = res.data.notifications.filter( notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts), ) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 113e6f2f..c3f0c758 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -7,7 +7,6 @@ import { QueryClient, useQueryClient, } from '@tanstack/react-query' -import {getAgent} from '../session' import {useFeedTuners} from '../preferences/feed-tuners' import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' @@ -77,30 +76,29 @@ export function usePostFeedQuery( const feedTuners = useFeedTuners(feedDesc) const enabled = opts?.enabled !== false const moderationOpts = useModerationOpts() - const agent = getAgent() const api: FeedAPI = useMemo(() => { if (feedDesc === 'home') { - return new MergeFeedAPI(agent, params || {}, feedTuners) + return new MergeFeedAPI(params || {}, feedTuners) } else if (feedDesc === 'following') { - return new FollowingFeedAPI(agent) + return new FollowingFeedAPI() } else if (feedDesc.startsWith('author')) { const [_, actor, filter] = feedDesc.split('|') - return new AuthorFeedAPI(agent, {actor, filter}) + return new AuthorFeedAPI({actor, filter}) } else if (feedDesc.startsWith('likes')) { const [_, actor] = feedDesc.split('|') - return new LikesFeedAPI(agent, {actor}) + return new LikesFeedAPI({actor}) } else if (feedDesc.startsWith('feedgen')) { const [_, feed] = feedDesc.split('|') - return new CustomFeedAPI(agent, {feed}) + return new CustomFeedAPI({feed}) } else if (feedDesc.startsWith('list')) { const [_, list] = feedDesc.split('|') - return new ListFeedAPI(agent, {list}) + return new ListFeedAPI({list}) } else { // shouldnt happen - return new FollowingFeedAPI(agent) + return new FollowingFeedAPI() } - }, [feedDesc, params, feedTuners, agent]) + }, [feedDesc, params, feedTuners]) const disableTuner = !!params?.disableTuner const tuner = useMemo( diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index e27bac9a..9467a255 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -4,7 +4,6 @@ import { AppBskyActorDefs, AppBskyActorProfile, AppBskyActorGetProfile, - BskyAgent, } from '@atproto/api' import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' import {Image as RNImage} from 'react-native-image-crop-picker' @@ -68,7 +67,7 @@ export function useProfileUpdateMutation() { } return existing }) - await whenAppViewReady(getAgent(), profile.did, res => { + await whenAppViewReady(profile.did, res => { if (typeof newUserAvatar !== 'undefined') { if (newUserAvatar === null && res.data.avatar) { // url hasnt cleared yet @@ -464,7 +463,6 @@ function useProfileUnblockMutation() { } async function whenAppViewReady( - agent: BskyAgent, actor: string, fn: (res: AppBskyActorGetProfile.Response) => boolean, ) { @@ -472,6 +470,6 @@ async function whenAppViewReady( 5, // 5 tries 1e3, // 1s delay between tries fn, - () => agent.app.bsky.actor.getProfile({actor}), + () => getAgent().app.bsky.actor.getProfile({actor}), ) } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 946c742a..e6def1fa 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -13,6 +13,12 @@ import {useCloseAllActiveElements} from '#/state/util' let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT +/** + * NOTE + * Never hold on to the object returned by this function. + * Call `getAgent()` at the time of invocation to ensure + * that you never have a stale agent. + */ export function getAgent() { return __globalAgent } From 620e002841e6dfc0f03579502b6d0e268b1b3a04 Mon Sep 17 00:00:00 2001 From: Ansh Date: Wed, 29 Nov 2023 10:11:06 -0800 Subject: [PATCH 10/26] Show logged out view when adding accounts (#2020) * show logged out view when adding accounts * Handle existing signed-in account * Show which account is currently logged in * Fix showing toasts --------- Co-authored-by: Eric Bailey --- src/lib/hooks/useAccountSwitcher.ts | 23 ++++++-- src/view/com/auth/login/ChooseAccountForm.tsx | 53 ++++++++++++++----- src/view/screens/Settings.tsx | 18 +++---- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 82f4565e..3851fe60 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -7,22 +7,34 @@ import {useAnalytics} from '#/lib/analytics/analytics' import {useSessionApi, SessionAccount} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {useCloseAllActiveElements} from '#/state/util' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' export function useAccountSwitcher() { const {track} = useAnalytics() const {selectAccount, clearCurrentAccount} = useSessionApi() const closeAllActiveElements = useCloseAllActiveElements() const navigation = useNavigation() + const {setShowLoggedOut} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( - async (acct: SessionAccount) => { + async (account: SessionAccount) => { track('Settings:SwitchAccountButtonClicked') try { - closeAllActiveElements() - navigation.navigate(isWeb ? 'Home' : 'HomeTab') - await selectAccount(acct) - Toast.show(`Signed in as ${acct.handle}`) + if (account.accessJwt) { + closeAllActiveElements() + navigation.navigate(isWeb ? 'Home' : 'HomeTab') + await selectAccount(account) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } else { + setShowLoggedOut(true) + Toast.show( + `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 @@ -34,6 +46,7 @@ export function useAccountSwitcher() { selectAccount, closeAllActiveElements, navigation, + setShowLoggedOut, ], ) diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx index 8c94ef2d..73ddfc9d 100644 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -1,23 +1,30 @@ import React from 'react' import {ScrollView, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {UserAvatar} from '../../util/UserAvatar' -import {s} from 'lib/styles' +import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {styles} from './styles' import {useSession, useSessionApi, SessionAccount} from '#/state/session' import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' function AccountItem({ account, onSelect, + isCurrentAccount, }: { account: SessionAccount onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean }) { const pal = usePalette('default') const {_} = useLingui() @@ -48,11 +55,19 @@ function AccountItem({ {account.handle} - + {isCurrentAccount ? ( + + ) : ( + + )} ) @@ -67,8 +82,9 @@ export const ChooseAccountForm = ({ const {track, screen} = useAnalytics() const pal = usePalette('default') const {_} = useLingui() - const {accounts} = useSession() + const {accounts, currentAccount} = useSession() const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() React.useEffect(() => { screen('Choose Account') @@ -77,13 +93,21 @@ export const ChooseAccountForm = ({ const onSelect = React.useCallback( async (account: SessionAccount) => { if (account.accessJwt) { - await initSession(account) - track('Sign In', {resumedSession: true}) + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(`Already signed in as @${account.handle}`) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } } else { onSelectAccount(account) } }, - [track, initSession, onSelectAccount], + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut], ) return ( @@ -94,7 +118,12 @@ export const ChooseAccountForm = ({ Sign in as... {accounts.map(account => ( - + ))} ({ light: {backgroundColor: colors.blue0}, @@ -190,10 +189,9 @@ export function SettingsScreen({}: Props) { const onPressAddAccount = React.useCallback(() => { track('Settings:AddAccountButtonClicked') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - clearCurrentAccount() - }, [track, navigation, clearCurrentAccount]) + setShowLoggedOut(true) + closeAllActiveElements() + }, [track, setShowLoggedOut, closeAllActiveElements]) const onPressChangeHandle = React.useCallback(() => { track('Settings:ChangeHandleButtonClicked') From 9239efac9c5339093f2742099b33834053635fc9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 29 Nov 2023 10:20:14 -0800 Subject: [PATCH 11/26] Refactor the notifications to cache and reuse results from the unread-notifs checks (#2017) * Refactor the notifications to cache and reuse results from the unread-notifs checks * Fix types --- src/state/queries/notifications/feed.ts | 219 ++++----------------- src/state/queries/notifications/types.ts | 34 ++++ src/state/queries/notifications/unread.tsx | 127 ++++++++---- src/state/queries/notifications/util.ts | 182 ++++++++++++++++- src/view/com/notifications/Feed.tsx | 31 +-- src/view/screens/Notifications.tsx | 22 ++- 6 files changed, 369 insertions(+), 246 deletions(-) create mode 100644 src/state/queries/notifications/types.ts diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 68396143..5c519d04 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -1,11 +1,22 @@ -import { - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyNotificationListNotifications, -} from '@atproto/api' -import chunk from 'lodash.chunk' +/** + * NOTE + * The ./unread.ts API: + * + * - Provides a `checkUnread()` function to sync with the server, + * - Periodically calls `checkUnread()`, and + * - Caches the first page of notifications. + * + * IMPORTANT: This query uses ./unread.ts's cache as its first page, + * IMPORTANT: which means the cache-freshness of this query is driven by the unread API. + * + * Follow these rules: + * + * 1. Call `checkUnread()` if you want to fetch latest in the background. + * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately. + * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. + */ + +import {AppBskyFeedDefs} from '@atproto/api' import { useInfiniteQuery, InfiniteData, @@ -13,50 +24,27 @@ import { useQueryClient, QueryClient, } from '@tanstack/react-query' -import {getAgent} from '../../session' import {useModerationOpts} from '../preferences' -import {shouldFilterNotif} from './util' +import {useUnreadNotificationsApi} from './unread' +import {fetchPage} from './util' +import {FeedPage} from './types' import {useMutedThreads} from '#/state/muted-threads' -import {precacheProfile as precacheResolvedUri} from '../resolve-uri' -const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +export type {NotificationType, FeedNotification, FeedPage} from './types' + const PAGE_SIZE = 30 -const MS_1HR = 1e3 * 60 * 60 -const MS_2DAY = MS_1HR * 48 type RQPageParam = string | undefined -type NotificationType = - | 'post-like' - | 'feedgen-like' - | 'repost' - | 'mention' - | 'reply' - | 'quote' - | 'follow' - | 'unknown' export function RQKEY() { return ['notification-feed'] } -export interface FeedNotification { - _reactKey: string - type: NotificationType - notification: AppBskyNotificationListNotifications.Notification - additional?: AppBskyNotificationListNotifications.Notification[] - subjectUri?: string - subject?: AppBskyFeedDefs.PostView -} - -export interface FeedPage { - cursor: string | undefined - items: FeedNotification[] -} - export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { const queryClient = useQueryClient() const moderationOpts = useModerationOpts() const threadMutes = useMutedThreads() + const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false return useInfiniteQuery< @@ -68,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { >({ queryKey: RQKEY(), async queryFn({pageParam}: {pageParam: RQPageParam}) { - const res = await getAgent().listNotifications({ - limit: PAGE_SIZE, - cursor: pageParam, - }) - - // filter out notifs by mod rules - const notifs = res.data.notifications.filter( - notif => !shouldFilterNotif(notif, moderationOpts), - ) - - // group notifications which are essentially similar (follows, likes on a post) - let notifsGrouped = groupNotifications(notifs) - - // we fetch subjects of notifications (usually posts) now instead of lazily - // in the UI to avoid relayouts - const subjects = await fetchSubjects(notifsGrouped) - for (const notif of notifsGrouped) { - if (notif.subjectUri) { - notif.subject = subjects.get(notif.subjectUri) - if (notif.subject) { - precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution - } + // for the first page, we check the cached page held by the unread-checker first + if (!pageParam) { + const cachedPage = unreads.getCachedUnreadPage() + if (cachedPage) { + return cachedPage } } - - // apply thread muting - notifsGrouped = notifsGrouped.filter( - notif => !isThreadMuted(notif, threadMutes), - ) - - return { - cursor: res.data.cursor, - items: notifsGrouped, - } + // do a normal fetch + return fetchPage({ + limit: PAGE_SIZE, + cursor: pageParam, + queryClient, + moderationOpts, + threadMutes, + }) }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, @@ -134,115 +103,3 @@ export function findPostInQueryData( } return undefined } - -function groupNotifications( - notifs: AppBskyNotificationListNotifications.Notification[], -): FeedNotification[] { - const groupedNotifs: FeedNotification[] = [] - for (const notif of notifs) { - const ts = +new Date(notif.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(notif.reason)) { - for (const groupedNotif of groupedNotifs) { - const ts2 = +new Date(groupedNotif.notification.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - notif.reason === groupedNotif.notification.reason && - notif.reasonSubject === groupedNotif.notification.reasonSubject && - notif.author.did !== groupedNotif.notification.author.did - ) { - groupedNotif.additional = groupedNotif.additional || [] - groupedNotif.additional.push(notif) - grouped = true - break - } - } - } - if (!grouped) { - const type = toKnownType(notif) - groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, - type, - notification: notif, - subjectUri: getSubjectUri(type, notif), - }) - } - } - return groupedNotifs -} - -async function fetchSubjects( - groupedNotifs: FeedNotification[], -): Promise> { - const uris = new Set() - for (const notif of groupedNotifs) { - if (notif.subjectUri) { - uris.add(notif.subjectUri) - } - } - const uriChunks = chunk(Array.from(uris), 25) - const postsChunks = await Promise.all( - uriChunks.map(uris => - getAgent() - .app.bsky.feed.getPosts({uris}) - .then(res => res.data.posts), - ), - ) - const map = new Map() - for (const post of postsChunks.flat()) { - if ( - AppBskyFeedPost.isRecord(post.record) && - AppBskyFeedPost.validateRecord(post.record).success - ) { - map.set(post.uri, post) - } - } - return map -} - -function toKnownType( - notif: AppBskyNotificationListNotifications.Notification, -): NotificationType { - if (notif.reason === 'like') { - if (notif.reasonSubject?.includes('feed.generator')) { - return 'feedgen-like' - } - return 'post-like' - } - if ( - notif.reason === 'repost' || - notif.reason === 'mention' || - notif.reason === 'reply' || - notif.reason === 'quote' || - notif.reason === 'follow' - ) { - return notif.reason as NotificationType - } - return 'unknown' -} - -function getSubjectUri( - type: NotificationType, - notif: AppBskyNotificationListNotifications.Notification, -): string | undefined { - if (type === 'reply' || type === 'quote' || type === 'mention') { - return notif.uri - } else if (type === 'post-like' || type === 'repost') { - if ( - AppBskyFeedRepost.isRecord(notif.record) || - AppBskyFeedLike.isRecord(notif.record) - ) { - return typeof notif.record.subject?.uri === 'string' - ? notif.record.subject?.uri - : undefined - } - } -} - -function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { - if (!notif.subject) { - return false - } - const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() - return mutes.includes(record.reply?.root.uri || notif.subject.uri) -} diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts new file mode 100644 index 00000000..0e88f107 --- /dev/null +++ b/src/state/queries/notifications/types.ts @@ -0,0 +1,34 @@ +import { + AppBskyNotificationListNotifications, + AppBskyFeedDefs, +} from '@atproto/api' + +export type NotificationType = + | 'post-like' + | 'feedgen-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'unknown' + +export interface FeedNotification { + _reactKey: string + type: NotificationType + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView +} + +export interface FeedPage { + cursor: string | undefined + items: FeedNotification[] +} + +export interface CachedFeedPage { + sessDid: string // used to invalidate on session changes + syncedAt: Date + data: FeedPage | undefined +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index b93e1dc8..d41cfee2 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -1,10 +1,19 @@ +/** + * A kind of companion API to ./feed.ts. See that file for more info. + */ + import React from 'react' import * as Notifications from 'expo-notifications' +import {useQueryClient} from '@tanstack/react-query' import BroadcastChannel from '#/lib/broadcast' import {useSession, getAgent} from '#/state/session' import {useModerationOpts} from '../preferences' -import {shouldFilterNotif} from './util' +import {fetchPage} from './util' +import {CachedFeedPage, FeedPage} from './types' import {isNative} from '#/platform/detection' +import {useMutedThreads} from '#/state/muted-threads' +import {RQKEY as RQKEY_NOTIFS} from './feed' +import {logger} from '#/logger' const UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -14,7 +23,8 @@ type StateContext = string interface ApiContext { markAllRead: () => Promise - checkUnread: () => Promise + checkUnread: (opts?: {invalidate?: boolean}) => Promise + getCachedUnreadPage: () => FeedPage | undefined } const stateContext = React.createContext('') @@ -22,16 +32,23 @@ const stateContext = React.createContext('') const apiContext = React.createContext({ async markAllRead() {}, async checkUnread() {}, + getCachedUnreadPage: () => undefined, }) export function Provider({children}: React.PropsWithChildren<{}>) { - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() + const queryClient = useQueryClient() const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() const [numUnread, setNumUnread] = React.useState('') - const checkUnreadRef = React.useRef<(() => Promise) | null>(null) - const lastSyncRef = React.useRef(new Date()) + const checkUnreadRef = React.useRef(null) + const cacheRef = React.useRef({ + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + }) // periodic sync React.useEffect(() => { @@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // listen for broadcasts React.useEffect(() => { const listener = ({data}: MessageEvent) => { - lastSyncRef.current = new Date() + cacheRef.current = { + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + } setNumUnread(data.event) } broadcast.addEventListener('message', listener) return () => { broadcast.removeEventListener('message', listener) } - }, [setNumUnread]) + }, [setNumUnread, currentAccount]) // create API const api = React.useMemo(() => { @@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { async markAllRead() { // update server await getAgent().updateSeenNotifications( - lastSyncRef.current.toISOString(), + cacheRef.current.syncedAt.toISOString(), ) // update & broadcast @@ -69,36 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) { broadcast.postMessage({event: ''}) }, - async checkUnread() { - if (!getAgent().session) return + async checkUnread({invalidate}: {invalidate?: boolean} = {}) { + try { + if (!getAgent().session) return - // count - const res = await getAgent().listNotifications({limit: 40}) - const filtered = res.data.notifications.filter( - notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts), - ) - const num = - filtered.length >= 30 - ? '30+' - : filtered.length === 0 - ? '' - : String(filtered.length) - if (isNative) { - Notifications.setBadgeCountAsync(Math.min(filtered.length, 30)) + // count + const page = await fetchPage({ + cursor: undefined, + limit: 40, + queryClient, + moderationOpts, + threadMutes, + }) + const unreadCount = countUnread(page) + const unreadCountStr = + unreadCount >= 30 + ? '30+' + : unreadCount === 0 + ? '' + : String(unreadCount) + if (isNative) { + Notifications.setBadgeCountAsync(Math.min(unreadCount, 30)) + } + + // track last sync + const now = new Date() + const lastIndexed = + page.items[0] && new Date(page.items[0].notification.indexedAt) + cacheRef.current = { + sessDid: currentAccount?.did || '', + data: page, + syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, + } + + // update & broadcast + setNumUnread(unreadCountStr) + if (invalidate) { + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + } + broadcast.postMessage({event: unreadCountStr}) + } catch (e) { + logger.error('Failed to check unread notifications', {error: e}) } + }, - // track last sync - const now = new Date() - const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt) - lastSyncRef.current = - !lastIndexed || now > lastIndexed ? now : lastIndexed - - // update & broadcast - setNumUnread(num) - broadcast.postMessage({event: num}) + getCachedUnreadPage() { + // return cached page if was for the current user + // (protects against session changes serving data from the past session) + if (cacheRef.current.sessDid === currentAccount?.did) { + return cacheRef.current.data + } }, } - }, [setNumUnread, moderationOpts]) + }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount]) checkUnreadRef.current = api.checkUnread return ( @@ -115,3 +159,20 @@ export function useUnreadNotifications() { export function useUnreadNotificationsApi() { return React.useContext(apiContext) } + +function countUnread(page: FeedPage) { + let num = 0 + for (const item of page.items) { + if (!item.notification.isRead) { + num++ + } + if (item.additional) { + for (const item2 of item.additional) { + if (!item2.isRead) { + num++ + } + } + } + } + return num +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index c49d1851..48e1b8dd 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -3,10 +3,78 @@ import { ModerationOpts, moderateProfile, moderatePost, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, } from '@atproto/api' +import chunk from 'lodash.chunk' +import {QueryClient} from '@tanstack/react-query' +import {getAgent} from '../../session' +import {precacheProfile as precacheResolvedUri} from '../resolve-uri' +import {NotificationType, FeedNotification, FeedPage} from './types' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +const MS_1HR = 1e3 * 60 * 60 +const MS_2DAY = MS_1HR * 48 + +// exported api +// = + +export async function fetchPage({ + cursor, + limit, + queryClient, + moderationOpts, + threadMutes, +}: { + cursor: string | undefined + limit: number + queryClient: QueryClient + moderationOpts: ModerationOpts | undefined + threadMutes: string[] +}): Promise { + const res = await getAgent().listNotifications({ + limit, + cursor, + }) + + // filter out notifs by mod rules + const notifs = res.data.notifications.filter( + notif => !shouldFilterNotif(notif, moderationOpts), + ) + + // group notifications which are essentially similar (follows, likes on a post) + let notifsGrouped = groupNotifications(notifs) + + // we fetch subjects of notifications (usually posts) now instead of lazily + // in the UI to avoid relayouts + const subjects = await fetchSubjects(notifsGrouped) + for (const notif of notifsGrouped) { + if (notif.subjectUri) { + notif.subject = subjects.get(notif.subjectUri) + if (notif.subject) { + precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution + } + } + } + + // apply thread muting + notifsGrouped = notifsGrouped.filter( + notif => !isThreadMuted(notif, threadMutes), + ) + + return { + cursor: res.data.cursor, + items: notifsGrouped, + } +} + +// internal methods +// = // TODO this should be in the sdk as moderateNotification -prf -export function shouldFilterNotif( +function shouldFilterNotif( notif: AppBskyNotificationListNotifications.Notification, moderationOpts: ModerationOpts | undefined, ): boolean { @@ -36,3 +104,115 @@ export function shouldFilterNotif( // (this requires fetching the post) return false } + +function groupNotifications( + notifs: AppBskyNotificationListNotifications.Notification[], +): FeedNotification[] { + const groupedNotifs: FeedNotification[] = [] + for (const notif of notifs) { + const ts = +new Date(notif.indexedAt) + let grouped = false + if (GROUPABLE_REASONS.includes(notif.reason)) { + for (const groupedNotif of groupedNotifs) { + const ts2 = +new Date(groupedNotif.notification.indexedAt) + if ( + Math.abs(ts2 - ts) < MS_2DAY && + notif.reason === groupedNotif.notification.reason && + notif.reasonSubject === groupedNotif.notification.reasonSubject && + notif.author.did !== groupedNotif.notification.author.did + ) { + groupedNotif.additional = groupedNotif.additional || [] + groupedNotif.additional.push(notif) + grouped = true + break + } + } + } + if (!grouped) { + const type = toKnownType(notif) + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } + } + return groupedNotifs +} + +async function fetchSubjects( + groupedNotifs: FeedNotification[], +): Promise> { + const uris = new Set() + for (const notif of groupedNotifs) { + if (notif.subjectUri) { + uris.add(notif.subjectUri) + } + } + const uriChunks = chunk(Array.from(uris), 25) + const postsChunks = await Promise.all( + uriChunks.map(uris => + getAgent() + .app.bsky.feed.getPosts({uris}) + .then(res => res.data.posts), + ), + ) + const map = new Map() + for (const post of postsChunks.flat()) { + if ( + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + map.set(post.uri, post) + } + } + return map +} + +function toKnownType( + notif: AppBskyNotificationListNotifications.Notification, +): NotificationType { + if (notif.reason === 'like') { + if (notif.reasonSubject?.includes('feed.generator')) { + return 'feedgen-like' + } + return 'post-like' + } + if ( + notif.reason === 'repost' || + notif.reason === 'mention' || + notif.reason === 'reply' || + notif.reason === 'quote' || + notif.reason === 'follow' + ) { + return notif.reason as NotificationType + } + return 'unknown' +} + +function getSubjectUri( + type: NotificationType, + notif: AppBskyNotificationListNotifications.Notification, +): string | undefined { + if (type === 'reply' || type === 'quote' || type === 'mention') { + return notif.uri + } else if (type === 'post-like' || type === 'repost') { + if ( + AppBskyFeedRepost.isRecord(notif.record) || + AppBskyFeedLike.isRecord(notif.record) + ) { + return typeof notif.record.subject?.uri === 'string' + ? notif.record.subject?.uri + : undefined + } + } +} + +function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { + if (!notif.subject) { + return false + } + const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() + return mutes.includes(record.reply?.root.uri || notif.subject.uri) +} diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index ba88f78c..c496d5f7 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -35,15 +35,13 @@ export function Feed({ const [isPTRing, setIsPTRing] = React.useState(false) const moderationOpts = useModerationOpts() - const {markAllRead} = useUnreadNotificationsApi() + const {markAllRead, checkUnread} = useUnreadNotificationsApi() const { data, - isLoading, isFetching, isFetched, isError, error, - refetch, hasNextPage, isFetchingNextPage, fetchNextPage, @@ -52,13 +50,11 @@ export function Feed({ const firstItem = data?.pages[0]?.items[0] // mark all read on fresh data + // (this will fire each time firstItem changes) React.useEffect(() => { - let cleanup if (firstItem) { - const to = setTimeout(() => markAllRead(), 250) - cleanup = () => clearTimeout(to) + markAllRead() } - return cleanup }, [firstItem, markAllRead]) const items = React.useMemo(() => { @@ -83,7 +79,7 @@ export function Feed({ const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await refetch() + await checkUnread({invalidate: true}) } catch (err) { logger.error('Failed to refresh notifications feed', { error: err, @@ -91,7 +87,7 @@ export function Feed({ } finally { setIsPTRing(false) } - }, [refetch, setIsPTRing]) + }, [checkUnread, setIsPTRing]) const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return @@ -136,21 +132,6 @@ export function Feed({ [onPressRetryLoadMore, moderationOpts], ) - const showHeaderSpinner = !isPTRing && isFetching && !isLoading - const FeedHeader = React.useCallback( - () => ( - - {ListHeaderComponent ? : null} - {showHeaderSpinner ? ( - - - - ) : null} - - ), - [ListHeaderComponent, showHeaderSpinner], - ) - const FeedFooter = React.useCallback( () => isFetchingNextPage ? ( @@ -180,7 +161,7 @@ export function Feed({ data={items} keyExtractor={item => item._reactKey} renderItem={renderItem} - ListHeaderComponent={FeedHeader} + ListHeaderComponent={ListHeaderComponent} ListFooterComponent={FeedFooter} refreshControl={ { scrollToTop() - queryClient.invalidateQueries({ - queryKey: NOTIFS_RQKEY(), - }) - }, [scrollToTop, queryClient]) + if (hasNew) { + // render what we have now + queryClient.invalidateQueries({ + queryKey: NOTIFS_RQKEY(), + }) + } else { + // check with the server + unreadApi.checkUnread({invalidate: true}) + } + }, [scrollToTop, queryClient, unreadApi, hasNew]) // on-visible setup // = From 4b3ec54add3ddc4b6fc3a0045977c692bdb1842f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 29 Nov 2023 11:15:35 -0800 Subject: [PATCH 12/26] Fix infinite query reloading behavior (reset, not invalidate) (#2031) * Reset, not invalidate, notification queries * Reset, not invalidate, feed queries --- src/lib/notifications/notifications.ts | 4 ++-- src/state/queries/notifications/unread.tsx | 2 +- src/view/com/feeds/FeedPage.tsx | 4 ++-- src/view/screens/Notifications.tsx | 2 +- src/view/screens/Profile.tsx | 2 +- src/view/screens/ProfileFeed.tsx | 2 +- src/view/screens/ProfileList.tsx | 4 ++-- src/view/shell/Drawer.tsx | 2 +- src/view/shell/bottom-bar/BottomBar.tsx | 2 +- src/view/shell/desktop/LeftNav.tsx | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 2320e1c7..9c499be0 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -83,7 +83,7 @@ export function init(queryClient: QueryClient) { ) if (event.request.trigger.type === 'push') { // refresh notifications in the background - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + queryClient.resetQueries({queryKey: RQKEY_NOTIFS()}) // handle payload-based deeplinks let payload if (isIOS) { @@ -121,7 +121,7 @@ export function init(queryClient: QueryClient) { logger.DebugContext.notifications, ) track('Notificatons:OpenApp') - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + queryClient.resetQueries({queryKey: RQKEY_NOTIFS()}) resetToTab('NotificationsTab') // open notifications tab } }, diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index d41cfee2..e0510e79 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -126,7 +126,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // update & broadcast setNumUnread(unreadCountStr) if (invalidate) { - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + queryClient.resetQueries({queryKey: RQKEY_NOTIFS()}) } broadcast.postMessage({event: unreadCountStr}) } catch (e) { diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 885cd2a1..1a32d29c 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -62,7 +62,7 @@ export function FeedPage({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) setHasNew(false) } }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew]) @@ -83,7 +83,7 @@ export function FeedPage({ const onPressLoadLatest = React.useCallback(() => { scrollToTop() - queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) setHasNew(false) }, [scrollToTop, feed, queryClient, setHasNew]) diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index a5226bb6..0f442038 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -54,7 +54,7 @@ export function NotificationsScreen({}: Props) { scrollToTop() if (hasNew) { // render what we have now - queryClient.invalidateQueries({ + queryClient.resetQueries({ queryKey: NOTIFS_RQKEY(), }) } else { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 7ddcf17a..89dec5f9 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -403,7 +403,7 @@ const FeedSection = React.forwardRef( const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) setHasNew(false) }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 95589b22..6d4c0c23 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -501,7 +501,7 @@ const FeedSection = React.forwardRef( const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) setHasNew(false) }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index cc6d85e6..9be49956 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -127,7 +127,7 @@ function ProfileListScreenLoaded({ list, onChange() { if (isCurateList) { - queryClient.invalidateQueries({ + queryClient.resetQueries({ // TODO(eric) should construct these strings with a fn too queryKey: FEED_RQKEY(`list|${list.uri}`), }) @@ -530,7 +530,7 @@ const FeedSection = React.forwardRef( const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)}) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) setHasNew(false) }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 9df9b70b..b2bb6ea1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -141,7 +141,7 @@ export function DrawerContent() { } else { if (tab === 'Notifications') { // fetch new notifs on view - queryClient.invalidateQueries({ + queryClient.resetQueries({ queryKey: NOTIFS_RQKEY(), }) } diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index dfb18cc4..a97ff8af 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -62,7 +62,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { } else { if (tab === 'Notifications') { // fetch new notifs on view - queryClient.invalidateQueries({ + queryClient.resetQueries({ queryKey: NOTIFS_RQKEY(), }) } diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index a0052e0c..8daa381d 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -150,7 +150,7 @@ function NavItem({count, href, icon, iconFilled, label}: NavItemProps) { } else { if (href === '/notifications') { // fetch new notifs on view - queryClient.invalidateQueries({ + queryClient.resetQueries({ queryKey: NOTIFS_RQKEY(), }) } From ed391c346d6e6858d0d24c08def2974df8ccbde7 Mon Sep 17 00:00:00 2001 From: Cooper Edmunds Date: Wed, 29 Nov 2023 14:23:19 -0500 Subject: [PATCH 13/26] Add hasPinnedCustomFeedOrList to usePinnedFeedsInfos hook --- src/state/queries/feed.ts | 11 +++++++++-- src/view/com/pager/FeedsTabBar.web.tsx | 2 +- src/view/com/pager/FeedsTabBarMobile.tsx | 2 +- src/view/shell/desktop/Feeds.tsx | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index b5d491a5..3266b0f6 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -246,13 +246,20 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = { likeUri: '', } -export function usePinnedFeedsInfos(): FeedSourceInfo[] { +export function usePinnedFeedsInfos(): { + feeds: FeedSourceInfo[] + hasPinnedCustomFeedOrList: boolean +} { const queryClient = useQueryClient() const [tabs, setTabs] = React.useState([ FOLLOWING_FEED_STUB, ]) const {data: preferences} = usePreferencesQuery() + const hasPinnedCustomFeedOrList = React.useMemo(() => { + return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) + }, [tabs]) + React.useEffect(() => { if (!preferences?.feeds?.pinned) return const uris = preferences.feeds.pinned @@ -300,5 +307,5 @@ export function usePinnedFeedsInfos(): FeedSourceInfo[] { fetchFeedInfo() }, [queryClient, setTabs, preferences?.feeds?.pinned]) - return tabs + return {feeds: tabs, hasPinnedCustomFeedOrList} } diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index fdb4df17..5ec6c68c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -79,7 +79,7 @@ function FeedsTabBarPublic() { function FeedsTabBarTablet( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const feeds = usePinnedFeedsInfos() + const {feeds} = usePinnedFeedsInfos() const pal = usePalette('default') const {hasSession} = useSession() const {headerMinimalShellTransform} = useMinimalShellMode() diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 735aa1ba..46cb488d 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -26,7 +26,7 @@ export function FeedsTabBar( const {isSandbox, hasSession} = useSession() const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() - const feeds = usePinnedFeedsInfos() + const {feeds} = usePinnedFeedsInfos() const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const {headerHeight} = useShellLayout() const {headerMinimalShellTransform} = useMinimalShellMode() diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index eeeca4fd..ff51ffe2 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -11,7 +11,7 @@ import {usePinnedFeedsInfos} from '#/state/queries/feed' export function DesktopFeeds() { const pal = usePalette('default') const {_} = useLingui() - const feeds = usePinnedFeedsInfos() + const {feeds} = usePinnedFeedsInfos() const route = useNavigationState(state => { if (!state) { From 8ceabe2a11742973447a6e3b4489c8a5660f48c3 Mon Sep 17 00:00:00 2001 From: Cooper Edmunds Date: Wed, 29 Nov 2023 14:49:18 -0500 Subject: [PATCH 14/26] Conditionally render feeds link in feeds tab bar --- src/view/com/pager/FeedsTabBar.web.tsx | 33 +++++++++++++++++++++-- src/view/com/pager/FeedsTabBarMobile.tsx | 34 +++++++++++++++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 5ec6c68c..2445d452 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -12,6 +12,9 @@ import {usePinnedFeedsInfos} from '#/state/queries/feed' import {useSession} from '#/state/session' import {TextLink} from '#/view/com/util/Link' import {CenteredView} from '../util/Views' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -79,12 +82,37 @@ function FeedsTabBarPublic() { function FeedsTabBarTablet( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const {feeds} = usePinnedFeedsInfos() + const {feeds, hasPinnedCustomFeedOrList} = usePinnedFeedsInfos() const pal = usePalette('default') const {hasSession} = useSession() + const navigation = useNavigation() const {headerMinimalShellTransform} = useMinimalShellMode() const {headerHeight} = useShellLayout() - const items = hasSession ? feeds.map(f => f.displayName) : [] + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustomFeedOrList + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressDiscoverFeeds() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar], + ) return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf @@ -96,6 +124,7 @@ function FeedsTabBarTablet( diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 46cb488d..fc9eb8f1 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -18,6 +18,9 @@ import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useShellLayout} from '#/state/shell/shell-layout' import {useSession} from '#/state/session' import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, @@ -26,11 +29,36 @@ export function FeedsTabBar( const {isSandbox, hasSession} = useSession() const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() - const {feeds} = usePinnedFeedsInfos() + const navigation = useNavigation() + const {feeds, hasPinnedCustomFeedOrList} = usePinnedFeedsInfos() const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const {headerHeight} = useShellLayout() const {headerMinimalShellTransform} = useMinimalShellMode() - const items = hasSession ? feeds.map(f => f.displayName) : [] + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustomFeedOrList + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressFeedsLink = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressFeedsLink() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar], + ) const onPressAvi = React.useCallback(() => { setDrawerOpen(true) @@ -84,7 +112,7 @@ export function FeedsTabBar( key={items.join(',')} onPressSelected={props.onPressSelected} selectedPage={props.selectedPage} - onSelect={props.onSelect} + onSelect={onSelect} testID={props.testID} items={items} indicatorColor={pal.colors.link} From 34759798ebb2aaa4c292f000df8f19c7b9f75cb6 Mon Sep 17 00:00:00 2001 From: Cooper Edmunds Date: Wed, 29 Nov 2023 15:24:14 -0500 Subject: [PATCH 15/26] Stop adding whats-hot for new users --- src/lib/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f8f65130..aa5983be 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -116,8 +116,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [PROD_DEFAULT_FEED('whats-hot')], - saved: [PROD_DEFAULT_FEED('whats-hot')], + pinned: [], + saved: [], } } } From 3ca4bd805a0efa35a9b1d3c4b0a8cebb6cbdeef9 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 29 Nov 2023 18:00:13 -0600 Subject: [PATCH 16/26] Re-enable fetch monkey-patch (#2036) * Re-enable fetch monkey-patch * Reorder --- index.js | 6 ++++-- index.web.js | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 4faf36dc..4fc95bd1 100644 --- a/index.js +++ b/index.js @@ -3,10 +3,12 @@ import 'react-native-gesture-handler' // must be first import {LogBox} from 'react-native' LogBox.ignoreLogs(['Require cycle:']) // suppress require-cycle warnings, it's fine -import 'platform/polyfills' +import '#/platform/polyfills' import {registerRootComponent} from 'expo' +import {doPolyfill} from '#/lib/api/api-polyfill' +doPolyfill() -import App from './src/App' +import App from '#/App' // registerRootComponent calls AppRegistry.registerComponent('main', () => App); // It also ensures that whether you load the app in Expo Go or in a native build, diff --git a/index.web.js b/index.web.js index 4ceb656f..4dee831c 100644 --- a/index.web.js +++ b/index.web.js @@ -1,4 +1,7 @@ -import 'platform/polyfills' +import '#/platform/polyfills' import {registerRootComponent} from 'expo' -import App from './src/App' +import {doPolyfill} from '#/lib/api/api-polyfill' +import App from '#/App' + +doPolyfill() registerRootComponent(App) From a59d235e8b319f6c33e0a0221c99c915128826a6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 29 Nov 2023 18:17:27 -0600 Subject: [PATCH 17/26] Improve feed reordering with optimistic updates (#2032) * Optimisticaly update order of saved feeds * Better show disabled state for pin button * Improve loading/disabled states * Improve placeholder * Simplify loading components --- src/view/com/feeds/FeedSourceCard.tsx | 11 +- src/view/com/util/LoadingPlaceholder.tsx | 28 +++-- src/view/screens/SavedFeeds.tsx | 133 +++++++++++++++-------- 3 files changed, 115 insertions(+), 57 deletions(-) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index d8b67767..9acbf361 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -23,6 +23,7 @@ import { useRemoveFeedMutation, } from '#/state/queries/preferences' import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' export function FeedSourceCard({ feedUri, @@ -30,17 +31,25 @@ export function FeedSourceCard({ showSaveBtn = false, showDescription = false, showLikes = false, + LoadingComponent, }: { feedUri: string style?: StyleProp showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + LoadingComponent?: JSX.Element }) { const {data: preferences} = usePreferencesQuery() const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) - if (!feed || !preferences) return null + if (!feed || !preferences) { + return LoadingComponent ? ( + LoadingComponent + ) : ( + + ) + } return ( + showTopBorder?: boolean + showLowerPlaceholder?: boolean }) { const pal = usePalette('default') return ( @@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({ - - - - + {showLowerPlaceholder && ( + + + + + )} ) } diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index ce668877..858a58a3 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -1,14 +1,7 @@ import React from 'react' -import { - StyleSheet, - View, - ActivityIndicator, - Pressable, - TouchableOpacity, -} from 'react-native' +import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useQueryClient} from '@tanstack/react-query' import {track} from '#/lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' @@ -32,9 +25,8 @@ import { usePinFeedMutation, useUnpinFeedMutation, useSetSaveFeedsMutation, - preferencesQueryKey, - UsePreferencesQueryResponse, } from '#/state/queries/preferences' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' const HITSLOP_TOP = { top: 20, @@ -57,6 +49,24 @@ export function SavedFeeds({}: Props) { const {screen} = useAnalytics() const setMinimalShellMode = useSetMinimalShellMode() const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: setSavedFeeds, + variables: optimisticSavedFeedsResponse, + reset: resetSaveFeedsMutationState, + error: setSavedFeedsError, + } = useSetSaveFeedsMutation() + + /* + * Use optimistic data if exists and no error, otherwise fallback to remote + * data + */ + const currentFeeds = + optimisticSavedFeedsResponse && !setSavedFeedsError + ? optimisticSavedFeedsResponse + : preferences?.feeds || {saved: [], pinned: []} + const unpinned = currentFeeds.saved.filter(f => { + return !currentFeeds.pinned?.includes(f) + }) useFocusEffect( React.useCallback(() => { @@ -80,7 +90,7 @@ export function SavedFeeds({}: Props) { {preferences?.feeds ? ( - !preferences.feeds.pinned.length ? ( + !currentFeeds.pinned.length ? ( ) : ( - preferences?.feeds?.pinned?.map(uri => ( - + currentFeeds.pinned.map(uri => ( + )) ) ) : ( @@ -106,7 +123,7 @@ export function SavedFeeds({}: Props) { {preferences?.feeds ? ( - !preferences.feeds.unpinned.length ? ( + !unpinned.length ? ( ) : ( - preferences.feeds.unpinned.map(uri => ( - + unpinned.map(uri => ( + )) ) ) : ( @@ -151,22 +175,30 @@ export function SavedFeeds({}: Props) { function ListItem({ feedUri, isPinned, + currentFeeds, + setSavedFeeds, + resetSaveFeedsMutationState, }: { feedUri: string // uri isPinned: boolean + currentFeeds: {saved: string[]; pinned: string[]} + setSavedFeeds: ReturnType['mutateAsync'] + resetSaveFeedsMutationState: ReturnType< + typeof useSetSaveFeedsMutation + >['reset'] }) { const pal = usePalette('default') - const queryClient = useQueryClient() const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() const {isPending: isUnpinPending, mutateAsync: unpinFeed} = useUnpinFeedMutation() - const {isPending: isMovePending, mutateAsync: setSavedFeeds} = - useSetSaveFeedsMutation() + const isPending = isPinPending || isUnpinPending const onTogglePinned = React.useCallback(async () => { Haptics.default() try { + resetSaveFeedsMutationState() + if (isPinned) { await unpinFeed({uri: feedUri}) } else { @@ -176,24 +208,20 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to toggle pinned feed', {error: e}) } - }, [feedUri, isPinned, pinFeed, unpinFeed]) + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) const onPressUp = React.useCallback(async () => { if (!isPinned) return - const feeds = - queryClient.getQueryData( - preferencesQueryKey, - )?.feeds // create new array, do not mutate - const pinned = feeds?.pinned ? [...feeds.pinned] : [] + const pinned = [...currentFeeds.pinned] const index = pinned.indexOf(feedUri) if (index === -1 || index === 0) return ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] try { - await setSavedFeeds({saved: feeds?.saved ?? [], pinned}) + await setSavedFeeds({saved: currentFeeds.saved, pinned}) track('CustomFeed:Reorder', { uri: feedUri, index: pinned.indexOf(feedUri), @@ -202,24 +230,19 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, queryClient, setSavedFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) const onPressDown = React.useCallback(async () => { if (!isPinned) return - const feeds = - queryClient.getQueryData( - preferencesQueryKey, - )?.feeds - // create new array, do not mutate - const pinned = feeds?.pinned ? [...feeds.pinned] : [] + const pinned = [...currentFeeds.pinned] const index = pinned.indexOf(feedUri) if (index === -1 || index >= pinned.length - 1) return ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] try { - await setSavedFeeds({saved: feeds?.saved ?? [], pinned}) + await setSavedFeeds({saved: currentFeeds.saved, pinned}) track('CustomFeed:Reorder', { uri: feedUri, index: pinned.indexOf(feedUri), @@ -228,7 +251,7 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, queryClient, setSavedFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) return ( {isPinned ? ( - + hitSlop={HITSLOP_TOP} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - - + + hitSlop={HITSLOP_BOTTOM} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - + ) : null} + } /> - + onPress={onTogglePinned} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - + ) } From 3fbac466ac8554fcb690fccbe0053a5be07801d7 Mon Sep 17 00:00:00 2001 From: Cooper Edmunds Date: Wed, 29 Nov 2023 19:28:35 -0500 Subject: [PATCH 18/26] Update home-screen tests related to feeds --- __e2e__/tests/home-screen.test.ts | 25 +++++++++++++++++++++---- src/view/com/pager/PagerWithHeader.tsx | 2 ++ src/view/com/pager/TabBar.tsx | 2 ++ src/view/screens/Profile.tsx | 1 + src/view/screens/ProfileFeed.tsx | 1 + 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts index 7647b55c..b6cd1919 100644 --- a/__e2e__/tests/home-screen.test.ts +++ b/__e2e__/tests/home-screen.test.ts @@ -4,7 +4,7 @@ import {openApp, loginAsAlice, createServer} from '../util' describe('Home screen', () => { beforeAll(async () => { - await createServer('?users&follows&posts') + await createServer('?users&follows&posts&feeds') await openApp({permissions: {notifications: 'YES'}}) }) @@ -13,6 +13,23 @@ describe('Home screen', () => { await element(by.id('homeScreenFeedTabs-Following')).tap() }) + it('Can go to feeds page using feeds button in tab bar', async () => { + await element(by.id('homeScreenFeedTabs-Feeds ✨')).tap() + await expect(element(by.text('Discover new feeds'))).toBeVisible() + }) + + it('Feeds button disappears after pinning a feed', async () => { + await element(by.id('bottomBarProfileBtn')).tap() + await element(by.id('profilePager-selector')).swipe('left') + await element(by.id('profilePager-selector-4')).tap() + await element(by.id('feed-alice-favs')).tap() + await element(by.id('pinBtn')).tap() + await element(by.id('bottomBarHomeBtn')).tap() + await expect( + element(by.id('homeScreenFeedTabs-Feeds ✨')), + ).not.toBeVisible() + }) + it('Can like posts', async () => { const carlaPosts = by.id('feedItem-by-carla.test') await expect( @@ -65,14 +82,14 @@ describe('Home screen', () => { it('Can swipe between feeds', async () => { await element(by.id('homeScreen')).swipe('left', 'fast', 0.75) - await expect(element(by.id('whatshotFeedPage'))).toBeVisible() + await expect(element(by.id('customFeedPage'))).toBeVisible() await element(by.id('homeScreen')).swipe('right', 'fast', 0.75) await expect(element(by.id('followingFeedPage'))).toBeVisible() }) it('Can tap between feeds', async () => { - await element(by.id("homeScreenFeedTabs-What's hot")).tap() - await expect(element(by.id('whatshotFeedPage'))).toBeVisible() + await element(by.id('homeScreenFeedTabs-alice-favs')).tap() + await expect(element(by.id('customFeedPage'))).toBeVisible() await element(by.id('homeScreenFeedTabs-Following')).tap() await expect(element(by.id('followingFeedPage'))).toBeVisible() }) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 3d2a3c55..2c7640c4 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -108,6 +108,7 @@ export const PagerWithHeader = React.forwardRef( pointerEvents: isHeaderReady ? 'auto' : 'none', }}> ( isMobile, onTabBarLayout, onHeaderOnlyLayout, + testID, ], ) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0e08b22d..c3a95c5c 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -68,6 +68,7 @@ export function TabBar({ return ( onItemLayout(e, i)} style={[styles.item, selected && indicatorStyle]} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 7ddcf17a..35efe3a0 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -267,6 +267,7 @@ function ProfileScreenLoaded({ screenDescription="profile" moderation={moderation.account}>