diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx
index 0b1255c33..8dcda3b0a 100644
--- a/app/javascript/mastodon/containers/mastodon.jsx
+++ b/app/javascript/mastodon/containers/mastodon.jsx
@@ -18,6 +18,7 @@ import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';
import { isProduction } from 'mastodon/utils/environment';
+import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
@@ -58,6 +59,7 @@ export default class Mastodon extends PureComponent {
+
diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx
index e826dbfa9..a4f79fcf9 100644
--- a/app/javascript/mastodon/containers/media_container.jsx
+++ b/app/javascript/mastodon/containers/media_container.jsx
@@ -14,7 +14,6 @@ import MediaModal from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video';
import { IntlProvider } from 'mastodon/locales';
import { createPollFromServerJSON } from 'mastodon/models/poll';
-import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
@@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent {
};
handleOpenMedia = (media, index, lang) => {
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
-
this.setState({ media, index, lang });
};
@@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent {
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
const mediaList = fromJS(media);
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
-
this.setState({ media: mediaList, lang, options });
};
handleCloseMedia = () => {
- document.body.classList.remove('with-modals--active');
- document.documentElement.style.marginRight = '0';
-
this.setState({
media: null,
index: null,
diff --git a/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx b/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx
new file mode 100644
index 000000000..289c295aa
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/body_scroll_lock.tsx
@@ -0,0 +1,71 @@
+import { useLayoutEffect, useEffect, useState } from 'react';
+
+import { createAppSelector, useAppSelector } from 'mastodon/store';
+import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
+
+const getShouldLockBodyScroll = createAppSelector(
+ [
+ (state) => state.navigation.open,
+ (state) => state.modal.get('stack').size > 0,
+ ],
+ (isMobileMenuOpen: boolean, isModalOpen: boolean) => {
+ return isMobileMenuOpen || isModalOpen;
+ },
+);
+
+/**
+ * This component locks scrolling on the `body` element when
+ * `getShouldLockBodyScroll` returns true.
+ *
+ * The scrollbar width is taken into account and written to
+ * a CSS custom property `--root-scrollbar-width`
+ */
+
+export const BodyScrollLock: React.FC = () => {
+ const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll);
+
+ useLayoutEffect(() => {
+ document.body.classList.toggle('with-modals--active', shouldLockBodyScroll);
+ }, [shouldLockBodyScroll]);
+
+ const [scrollbarWidth, setScrollbarWidth] = useState(() =>
+ getScrollbarWidth(),
+ );
+
+ useEffect(() => {
+ const handleResize = () => {
+ setScrollbarWidth(getScrollbarWidth());
+ };
+ window.addEventListener('resize', handleResize, { passive: true });
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []);
+
+ // Inject style element to make scrollbar width available
+ // as CSS custom property
+ useLayoutEffect(() => {
+ const nonce = document
+ .querySelector('meta[name=style-nonce]')
+ ?.getAttribute('content');
+
+ if (nonce) {
+ const styleEl = document.createElement('style');
+ styleEl.nonce = nonce;
+ styleEl.innerHTML = `
+ :root {
+ --root-scrollbar-width: ${scrollbarWidth}px;
+ }
+ `;
+ document.head.appendChild(styleEl);
+
+ return () => {
+ document.head.removeChild(styleEl);
+ };
+ }
+
+ return () => '';
+ }, [scrollbarWidth]);
+
+ return null;
+};
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index ff1337517..4a98de0a3 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -20,7 +20,6 @@ import {
IgnoreNotificationsModal,
AnnualReportModal,
} from 'mastodon/features/ui/util/async-components';
-import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import BundleContainer from '../containers/bundle_container';
@@ -90,20 +89,6 @@ export default class ModalRoot extends PureComponent {
backgroundColor: null,
};
- getSnapshotBeforeUpdate () {
- return { visible: !!this.props.type };
- }
-
- componentDidUpdate (prevProps, prevState, { visible }) {
- if (visible) {
- document.body.classList.add('with-modals--active');
- document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
- } else {
- document.body.classList.remove('with-modals--active');
- document.documentElement.style.marginRight = '0';
- }
- }
-
setBackgroundColor = color => {
this.setState({ backgroundColor: color });
};
diff --git a/app/javascript/mastodon/utils/scrollbar.ts b/app/javascript/mastodon/utils/scrollbar.ts
index d505df124..268236c21 100644
--- a/app/javascript/mastodon/utils/scrollbar.ts
+++ b/app/javascript/mastodon/utils/scrollbar.ts
@@ -1,8 +1,9 @@
import { isMobile } from '../is_mobile';
-let cachedScrollbarWidth: number | null = null;
-
-const getActualScrollbarWidth = () => {
+export const getScrollbarWidth = () => {
+ if (isMobile(window.innerWidth)) {
+ return 0;
+ }
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
@@ -16,16 +17,3 @@ const getActualScrollbarWidth = () => {
return scrollbarWidth;
};
-
-export const getScrollbarWidth = () => {
- if (cachedScrollbarWidth !== null) {
- return cachedScrollbarWidth;
- }
-
- const scrollbarWidth = isMobile(window.innerWidth)
- ? 0
- : getActualScrollbarWidth();
- cachedScrollbarWidth = scrollbarWidth;
-
- return scrollbarWidth;
-};
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index f7dd06e7b..e6820c58f 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -68,6 +68,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
overscroll-behavior: none;
+ margin-right: var(--root-scrollbar-width, 0);
}
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c32110be8..3bd65c8c1 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2894,6 +2894,11 @@ a.account__display-name {
background: var(--background-color);
backdrop-filter: var(--background-filter);
border-top: 1px solid var(--background-border-color);
+ box-sizing: border-box;
+
+ .with-modals--active & {
+ padding-right: var(--root-scrollbar-width);
+ }
.layout-multiple-columns & {
display: none;