diff --git a/app/javascript/flavours/glitch/components/router.tsx b/app/javascript/flavours/glitch/components/router.tsx index 0175e1b44f..f027d6cd35 100644 --- a/app/javascript/flavours/glitch/components/router.tsx +++ b/app/javascript/flavours/glitch/components/router.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react'; import type React from 'react'; +import type { useLocation } from 'react-router'; import { Router as OriginalRouter, useHistory } from 'react-router'; import type { @@ -18,7 +19,9 @@ interface MastodonLocationState { mastodonModalKey?: string; } -type LocationState = MastodonLocationState | null | undefined; +export type LocationState = MastodonLocationState | null | undefined; + +export type MastodonLocation = ReturnType>; type HistoryPath = Path | LocationDescriptor; diff --git a/app/javascript/flavours/glitch/components/scrollable_list.jsx b/app/javascript/flavours/glitch/components/scrollable_list.jsx index 836cc5ee20..d1a0ab991c 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.jsx +++ b/app/javascript/flavours/glitch/components/scrollable_list.jsx @@ -10,7 +10,7 @@ import { connect } from 'react-redux'; import { supportsPassiveEvents } from 'detect-passive-events'; import { throttle } from 'lodash'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; diff --git a/app/javascript/flavours/glitch/containers/mastodon.jsx b/app/javascript/flavours/glitch/containers/mastodon.jsx index d300be1f39..f715ebf9d1 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.jsx +++ b/app/javascript/flavours/glitch/containers/mastodon.jsx @@ -5,7 +5,6 @@ import { Route } from 'react-router-dom'; import { Provider as ReduxProvider } from 'react-redux'; -import { ScrollContext } from 'react-router-scroll-4'; import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings'; @@ -21,6 +20,8 @@ import { store } from 'flavours/glitch/store'; import { isProduction } from 'flavours/glitch/utils/environment'; import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock'; +import { ScrollContext } from './scroll_container/scroll_context'; + const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`; const hydrateAction = hydrateStore(initialState); @@ -50,10 +51,6 @@ export default class Mastodon extends PureComponent { } } - shouldUpdateScroll (prevRouterProps, { location }) { - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); - } - render () { return ( @@ -61,7 +58,7 @@ export default class Mastodon extends PureComponent { - + diff --git a/app/javascript/flavours/glitch/containers/scroll_container.js b/app/javascript/flavours/glitch/containers/scroll_container.js deleted file mode 100644 index d21ff63687..0000000000 --- a/app/javascript/flavours/glitch/containers/scroll_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4'; - -// ScrollContainer is used to automatically scroll to the top when pushing a -// new history state and remembering the scroll position when going back. -// There are a few things we need to do differently, though. -const defaultShouldUpdateScroll = (prevRouterProps, { location }) => { - // If the change is caused by opening a modal, do not scroll to top - return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey); -}; - -export default -class ScrollContainer extends OriginalScrollContainer { - - static defaultProps = { - shouldUpdateScroll: defaultShouldUpdateScroll, - }; - -} diff --git a/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx b/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx new file mode 100644 index 0000000000..5e5035d973 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/default_should_update_scroll.tsx @@ -0,0 +1,25 @@ +import type { MastodonLocation } from 'flavours/glitch/components/router'; + +export type ShouldUpdateScrollFn = ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, +) => boolean; + +/** + * ScrollBehavior will automatically scroll to the top on navigations + * or restore saved scroll positions, but on some location changes we + * need to prevent this. + */ + +export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = ( + prevLocation, + location, +) => { + // If the change is caused by opening a modal, do not scroll to top + const shouldUpdateScroll = !( + location.state?.mastodonModalKey && + location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey + ); + + return shouldUpdateScroll; +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/index.tsx b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx new file mode 100644 index 0000000000..e7d2726715 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/index.tsx @@ -0,0 +1,62 @@ +import React, { useContext, useEffect, useRef } from 'react'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { ScrollBehaviorContext } from './scroll_context'; + +interface ScrollContainerProps { + /** + * This key must be static for the element & not change + * while the component is mounted. + */ + scrollKey: string; + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * `ScrollContainer` is used to manage the scroll position of elements on the page + * that can be scrolled independently of the page body. + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContainer: React.FC = ({ + children, + scrollKey, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const scrollBehaviorContext = useContext(ScrollBehaviorContext); + + const containerRef = useRef(); + + /** + * Register/unregister scrollable element with ScrollBehavior + */ + useEffect(() => { + if (!scrollBehaviorContext || !containerRef.current) { + return; + } + + scrollBehaviorContext.registerElement( + scrollKey, + containerRef.current, + (prevLocation, location) => { + // Hack to allow accessing scrollBehavior._stateStorage + return shouldUpdateScroll.call( + scrollBehaviorContext.scrollBehavior, + prevLocation, + location, + ); + }, + ); + + return () => { + scrollBehaviorContext.unregisterElement(scrollKey); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return React.Children.only( + React.cloneElement(children, { ref: containerRef }), + ); +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx b/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx new file mode 100644 index 0000000000..1f73ffa6f5 --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/scroll_context.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { useLocation, useHistory } from 'react-router-dom'; + +import type { LocationBase } from 'scroll-behavior'; +import ScrollBehavior from 'scroll-behavior'; + +import type { + LocationState, + MastodonLocation, +} from 'flavours/glitch/components/router'; +import { usePrevious } from 'flavours/glitch/hooks/usePrevious'; + +import { defaultShouldUpdateScroll } from './default_should_update_scroll'; +import type { ShouldUpdateScrollFn } from './default_should_update_scroll'; +import { SessionStorage } from './state_storage'; + +type ScrollBehaviorInstance = InstanceType< + typeof ScrollBehavior +>; + +export interface ScrollBehaviorContextType { + registerElement: ( + key: string, + element: HTMLElement, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => boolean, + ) => void; + unregisterElement: (key: string) => void; + scrollBehavior?: ScrollBehaviorInstance; +} + +export const ScrollBehaviorContext = + React.createContext(null); + +interface ScrollContextProps { + shouldUpdateScroll?: ShouldUpdateScrollFn; + children: React.ReactElement; +} + +/** + * A top-level wrapper that provides the app with an instance of the + * ScrollBehavior object. scroll-behavior is a library for managing the + * scroll position of a single-page app in the same way the browser would + * normally do for a multi-page app. This means it'll scroll back to top + * when navigating to a new page, and will restore the scroll position + * when navigating e.g. using `history.back`. + * The library keeps a record of scroll positions in session storage. + * + * This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/ + */ + +export const ScrollContext: React.FC = ({ + children, + shouldUpdateScroll = defaultShouldUpdateScroll, +}) => { + const location = useLocation(); + const history = useHistory(); + + /** + * Keep the current location in a mutable ref so that ScrollBehavior's + * `getCurrentLocation` can access it without having to recreate the + * whole ScrollBehavior object + */ + const currentLocationRef = useRef(location); + useEffect(() => { + currentLocationRef.current = location; + }, [location]); + + /** + * Initialise ScrollBehavior object once – using state rather + * than a ref to simplify the types and ensure it's defined immediately. + */ + const [scrollBehavior] = useState( + (): ScrollBehaviorInstance => + new ScrollBehavior({ + addNavigationListener: history.listen.bind(history), + stateStorage: new SessionStorage(), + getCurrentLocation: () => + currentLocationRef.current as unknown as LocationBase, + shouldUpdateScroll: ( + prevLocationContext: MastodonLocation | null, + locationContext: MastodonLocation, + ) => + // Hack to allow accessing scrollBehavior._stateStorage + shouldUpdateScroll.call( + scrollBehavior, + prevLocationContext, + locationContext, + ), + }), + ); + + /** + * Handle scroll update when location changes + */ + const prevLocation = usePrevious(location) ?? null; + useEffect(() => { + scrollBehavior.updateScroll(prevLocation, location); + }, [location, prevLocation, scrollBehavior]); + + /** + * Stop Scrollbehavior on unmount + */ + useEffect(() => { + return () => { + scrollBehavior.stop(); + }; + }, [scrollBehavior]); + + /** + * Provide the app with a way to register separately scrollable + * elements to also be tracked by ScrollBehavior. (By default + * ScrollBehavior only handles scrolling on the main document body.) + */ + const contextValue = useMemo( + () => ({ + registerElement: (key, element, shouldUpdateScroll) => { + scrollBehavior.registerElement( + key, + element, + shouldUpdateScroll, + location, + ); + }, + unregisterElement: (key) => { + scrollBehavior.unregisterElement(key); + }, + scrollBehavior, + }), + [location, scrollBehavior], + ); + + return ( + + {React.Children.only(children)} + + ); +}; diff --git a/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts b/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts new file mode 100644 index 0000000000..fe8a208aae --- /dev/null +++ b/app/javascript/flavours/glitch/containers/scroll_container/state_storage.ts @@ -0,0 +1,46 @@ +import type { LocationBase, ScrollPosition } from 'scroll-behavior'; + +const STATE_KEY_PREFIX = '@@scroll|'; + +interface LocationBaseWithKey extends LocationBase { + key?: string; +} + +/** + * This module is part of our port of https://github.com/ytase/react-router-scroll/ + * and handles storing scroll positions in SessionStorage. + * Stored positions (`[x, y]`) are keyed by the location key and an optional + * `scrollKey` that's used for to track separately scrollable elements other + * than the document body. + */ + +export class SessionStorage { + read( + location: LocationBaseWithKey, + key: string | null, + ): ScrollPosition | null { + const stateKey = this.getStateKey(location, key); + + try { + const value = sessionStorage.getItem(stateKey); + return value ? (JSON.parse(value) as ScrollPosition) : null; + } catch { + return null; + } + } + + save(location: LocationBaseWithKey, key: string | null, value: unknown) { + const stateKey = this.getStateKey(location, key); + const storedValue = JSON.stringify(value); + + try { + sessionStorage.setItem(stateKey, storedValue); + } catch {} + } + + getStateKey(location: LocationBaseWithKey, key: string | null) { + const locationKey = location.key; + const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`; + return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; + } +} diff --git a/app/javascript/flavours/glitch/features/directory/index.tsx b/app/javascript/flavours/glitch/features/directory/index.tsx index 689f924b8f..3b402bd391 100644 --- a/app/javascript/flavours/glitch/features/directory/index.tsx +++ b/app/javascript/flavours/glitch/features/directory/index.tsx @@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header'; import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { RadioButton } from 'flavours/glitch/components/radio_button'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; @@ -209,7 +209,6 @@ export const Directory: React.FC<{ /> {multiColumn && !pinned ? ( - // @ts-expect-error ScrollContainer is not properly typed yet {scrollableArea} diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index d196dff11a..a04fce60e4 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -17,7 +17,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Icon } from 'flavours/glitch/components/icon'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; -import ScrollContainer from 'flavours/glitch/containers/scroll_container'; +import { ScrollContainer } from 'flavours/glitch/containers/scroll_container'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; @@ -551,9 +551,9 @@ class Status extends ImmutablePureComponent { this.setState({ fullscreen: isFullscreen() }); }; - shouldUpdateScroll = (prevRouterProps, { location }) => { + shouldUpdateScroll = (prevLocation, location) => { // Do not change scroll when opening a modal - if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) { + if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) { return false; }