From 70b0dc070a6c48060164e35490ec0d81d13957d3 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 9 Feb 2023 16:10:22 -0600 Subject: [PATCH] Add scroll-to-top for all screens (#177) --- __mocks__/state-mock.ts | 1 + src/state/models/root-store.ts | 10 ++++++++++ src/view/com/notifications/Feed.tsx | 5 ++++- src/view/routes.ts | 4 +--- src/view/screens/Home.tsx | 15 +++++++++------ src/view/screens/Notifications.tsx | 15 +++++++++++++-- src/view/screens/Search.tsx | 13 ++++++++++++- src/view/shell/mobile/index.tsx | 19 ++++++++++--------- 8 files changed, 60 insertions(+), 22 deletions(-) diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts index b0b6e05a66..d19cfb4a53 100644 --- a/__mocks__/state-mock.ts +++ b/__mocks__/state-mock.ts @@ -658,6 +658,7 @@ export const mockedRootStore = { profiles: mockedProfilesStore, linkMetas: mockedLinkMetasStore, log: mockedLogStore, + onScreenSoftReset: jest.fn().mockReturnValue({remove: jest.fn()}), } as RootStoreModel export const mockedProfileUiStore = { diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index ffd5139a7d..fe7dac8f7c 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -166,6 +166,16 @@ export class RootStoreModel { DeviceEventEmitter.emit('navigation') } + // a "soft reset" typically means scrolling to top and loading latest + // but it can depend on the screen + onScreenSoftReset(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('screen-soft-reset', handler) + } + + emitScreenSoftReset() { + DeviceEventEmitter.emit('screen-soft-reset') + } + // background fetch // = // - we use this to poll for unread notifications, which is not "ideal" behavior but diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 5f9cb129d5..110eef769a 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {MutableRefObject} from 'react' import {observer} from 'mobx-react-lite' import {FlatList, StyleSheet, View} from 'react-native' import {NotificationsViewModel} from '../../../state/models/notifications-view' @@ -13,10 +13,12 @@ const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} export const Feed = observer(function Feed({ view, + scrollElRef, onPressTryAgain, onScroll, }: { view: NotificationsViewModel + scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollCb }) { @@ -66,6 +68,7 @@ export const Feed = observer(function Feed({ )} {data && ( item._reactKey} renderItem={renderItem} diff --git a/src/view/routes.ts b/src/view/routes.ts index b5cc014ff8..1cd9ef8e2e 100644 --- a/src/view/routes.ts +++ b/src/view/routes.ts @@ -1,5 +1,4 @@ -import React, {MutableRefObject} from 'react' -import {FlatList} from 'react-native' +import React from 'react' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {Home} from './screens/Home' import {Contacts} from './screens/Contacts' @@ -21,7 +20,6 @@ export type ScreenParams = { navIdx: string params: Record visible: boolean - scrollElRef?: MutableRefObject | undefined> } export type Route = [React.FC, string, IconProp, RegExp] export type MatchResult = { diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index fff9554a46..940ce19558 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,5 +1,5 @@ import React, {useEffect} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {FlatList, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' import LinearGradient from 'react-native-linear-gradient' @@ -17,15 +17,12 @@ import {useAnalytics} from '@segment/analytics-react-native' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const Home = observer(function Home({ - navIdx, - visible, - scrollElRef, -}: ScreenParams) { +export const Home = observer(function Home({navIdx, visible}: ScreenParams) { const store = useStores() const onMainScroll = useOnMainScroll(store) const {track} = useAnalytics() const safeAreaInsets = useSafeAreaInsets() + const scrollElRef = React.useRef(null) const [wasVisible, setWasVisible] = React.useState(false) const {appState} = useAppState({ onForeground: () => doPoll(true), @@ -45,11 +42,17 @@ export const Home = observer(function Home({ [appState, visible, store], ) + const onSoftReset = () => { + scrollElRef.current?.scrollToOffset({offset: 0}) + } + useEffect(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) const feedCleanup = store.me.mainFeed.registerListeners() const pollInterval = setInterval(() => doPoll(), 15e3) const cleanup = () => { clearInterval(pollInterval) + softResetSub.remove() feedCleanup() } diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index e4c0d1382a..bc5949c40b 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,5 +1,5 @@ import React, {useEffect} from 'react' -import {View} from 'react-native' +import {FlatList, View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {useStores} from '../../state' @@ -11,21 +11,31 @@ import {useAnalytics} from '@segment/analytics-react-native' export const Notifications = ({navIdx, visible}: ScreenParams) => { const store = useStores() const onMainScroll = useOnMainScroll(store) + const scrollElRef = React.useRef(null) const {screen} = useAnalytics() useEffect(() => { screen('Notifications') }, [screen]) + const onSoftReset = () => { + scrollElRef.current?.scrollToOffset({offset: 0}) + } + useEffect(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } if (!visible) { - return + return cleanup } store.log.debug('Updating notifications feed') store.me.notifications.update().then(() => { store.me.notifications.updateReadState() }) store.nav.setTitle(navIdx, 'Notifications') + return cleanup }, [visible, store, navIdx]) const onPressTryAgain = () => { @@ -39,6 +49,7 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => { view={store.me.notifications} onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} + scrollElRef={scrollElRef} /> ) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index d38cd4016d..996d9cfca4 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -29,6 +29,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() + const scrollElRef = React.useRef(null) const textInput = React.useRef(null) const [lastRenderTime, setRenderTime] = React.useState(0) // used to trigger reloads const [isInputFocused, setIsInputFocused] = React.useState(false) @@ -39,7 +40,16 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { ) const {name} = params + const onSoftReset = () => { + scrollElRef.current?.scrollTo({x: 0, y: 0}) + } + React.useEffect(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } + if (visible) { const now = Date.now() if (lastRenderTime - now > FIVE_MIN) { @@ -49,6 +59,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { autocompleteView.setup() store.nav.setTitle(navIdx, 'Search') } + return cleanup }, [store, visible, name, navIdx, autocompleteView, lastRenderTime]) const onPressMenu = () => { @@ -143,7 +154,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => { ) : ( - + diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index 8360016114..a1039bda26 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -1,9 +1,8 @@ -import React, {useState, useEffect, useRef} from 'react' +import React, {useState, useEffect} from 'react' import {observer} from 'mobx-react-lite' import { Animated, Easing, - FlatList, GestureResponderEvent, StatusBar, StyleSheet, @@ -148,7 +147,6 @@ export const MobileShell: React.FC = observer(() => { const pal = usePalette('default') const store = useStores() const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) - const scrollElRef = useRef() const winDim = useWindowDimensions() const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) const swipeGestureInterp = useAnimatedValue(0) @@ -164,8 +162,8 @@ export const MobileShell: React.FC = observer(() => { const onPressHome = () => { track('MobileShell:HomeButtonPressed') if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) { - if (store.nav.tab.current.url === '/') { - scrollElRef.current?.scrollToOffset({offset: 0}) + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() } else { store.nav.tab.fixedTabReset() } @@ -179,8 +177,8 @@ export const MobileShell: React.FC = observer(() => { const onPressSearch = () => { track('MobileShell:SearchButtonPressed') if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) { - if (store.nav.tab.current.url === '/') { - scrollElRef.current?.scrollToOffset({offset: 0}) + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() } else { store.nav.tab.fixedTabReset() } @@ -194,7 +192,11 @@ export const MobileShell: React.FC = observer(() => { const onPressNotifications = () => { track('MobileShell:NotificationsButtonPressed') if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) { - store.nav.tab.fixedTabReset() + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() + } else { + store.nav.tab.fixedTabReset() + } } else { store.nav.switchTo(TabPurpose.Notifs, false) if (store.nav.tab.index === 0) { @@ -444,7 +446,6 @@ export const MobileShell: React.FC = observer(() => { params={params} navIdx={navIdx} visible={current} - scrollElRef={current ? scrollElRef : undefined} />