From 38c6216082e67581e83d04b3096cdb020ad0edea Mon Sep 17 00:00:00 2001
From: fusagiko / takayamaki <24884114+takayamaki@users.noreply.github.com>
Date: Thu, 25 May 2023 22:42:37 +0900
Subject: [PATCH] Rewrite actions/modal and reducers/modal with typescript
 (#24833)

---
 app/javascript/mastodon/actions/blocks.js     |  2 +-
 app/javascript/mastodon/actions/boosts.js     |  5 +-
 app/javascript/mastodon/actions/compose.js    |  5 +-
 app/javascript/mastodon/actions/filters.js    |  9 +-
 app/javascript/mastodon/actions/modal.js      | 18 ----
 app/javascript/mastodon/actions/modal.ts      | 17 ++++
 app/javascript/mastodon/actions/mutes.js      |  2 +-
 app/javascript/mastodon/actions/reports.js    |  9 +-
 .../components/edited_timestamp/index.jsx     |  5 +-
 .../mastodon/containers/account_container.jsx | 11 ++-
 .../mastodon/containers/domain_container.jsx  | 11 ++-
 .../containers/dropdown_menu_container.js     | 16 +++-
 .../mastodon/containers/status_container.jsx  | 73 +++++++++-----
 .../features/account_gallery/index.jsx        | 15 ++-
 .../containers/header_container.jsx           | 67 ++++++++-----
 .../containers/navigation_container.js        | 13 ++-
 .../containers/privacy_dropdown_container.js  | 10 +-
 .../mastodon/features/compose/index.jsx       | 13 ++-
 .../containers/conversation_container.js      | 11 ++-
 .../directory/components/account_card.jsx     | 35 ++++---
 .../features/interaction_modal/index.jsx      |  7 +-
 .../mastodon/features/list_timeline/index.jsx | 30 +++---
 .../containers/column_settings_container.js   | 11 ++-
 .../picture_in_picture/components/footer.jsx  | 44 +++++----
 .../containers/detailed_status_container.js   | 41 +++++---
 .../mastodon/features/status/index.jsx        | 81 ++++++++++------
 .../features/ui/components/block_modal.jsx    |  5 +-
 .../ui/components/compare_history_modal.jsx   |  5 +-
 .../ui/components/disabled_account_banner.jsx | 13 ++-
 .../features/ui/components/header.jsx         |  2 +-
 .../features/ui/components/link_footer.jsx    | 13 ++-
 .../features/ui/components/modal_root.jsx     |  2 +-
 .../features/ui/components/mute_modal.jsx     |  5 +-
 .../features/ui/components/sign_in_banner.jsx |  2 +-
 .../features/ui/containers/modal_container.js | 20 ++--
 app/javascript/mastodon/reducers/index.ts     |  4 +-
 app/javascript/mastodon/reducers/modal.js     | 40 --------
 app/javascript/mastodon/reducers/modal.ts     | 94 +++++++++++++++++++
 38 files changed, 504 insertions(+), 262 deletions(-)
 delete mode 100644 app/javascript/mastodon/actions/modal.js
 create mode 100644 app/javascript/mastodon/actions/modal.ts
 delete mode 100644 app/javascript/mastodon/reducers/modal.js
 create mode 100644 app/javascript/mastodon/reducers/modal.ts

diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js
index 66421ed455..e293657ad3 100644
--- a/app/javascript/mastodon/actions/blocks.js
+++ b/app/javascript/mastodon/actions/blocks.js
@@ -95,6 +95,6 @@ export function initBlockModal(account) {
       account,
     });
 
-    dispatch(openModal('BLOCK'));
+    dispatch(openModal({ modalType: 'BLOCK' }));
   };
 }
diff --git a/app/javascript/mastodon/actions/boosts.js b/app/javascript/mastodon/actions/boosts.js
index c0f0f3acc5..1fc2e391e2 100644
--- a/app/javascript/mastodon/actions/boosts.js
+++ b/app/javascript/mastodon/actions/boosts.js
@@ -14,7 +14,10 @@ export function initBoostModal(props) {
       privacy,
     });
 
-    dispatch(openModal('BOOST', props));
+    dispatch(openModal({
+      modalType: 'BOOST',
+      modalProps: props,
+    }));
   };
 }
 
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 22454cf3df..2ad7678caa 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -381,7 +381,10 @@ export function initMediaEditModal(id) {
       id,
     });
 
-    dispatch(openModal('FOCAL_POINT', { id }));
+    dispatch(openModal({
+      modalType: 'FOCAL_POINT',
+      modalProps: { id },
+    }));
   };
 }
 
diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js
index 3774ee042a..a11956ac56 100644
--- a/app/javascript/mastodon/actions/filters.js
+++ b/app/javascript/mastodon/actions/filters.js
@@ -15,9 +15,12 @@ export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
 export const FILTERS_CREATE_FAIL    = 'FILTERS_CREATE_FAIL';
 
 export const initAddFilter = (status, { contextType }) => dispatch =>
-  dispatch(openModal('FILTER', {
-    statusId: status?.get('id'),
-    contextType: contextType,
+  dispatch(openModal({
+    modalType: 'FILTER',
+    modalProps: {
+      statusId: status?.get('id'),
+      contextType: contextType,
+    },
   }));
 
 export const fetchFilters = () => (dispatch, getState) => {
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
deleted file mode 100644
index ef2ae0e4c7..0000000000
--- a/app/javascript/mastodon/actions/modal.js
+++ /dev/null
@@ -1,18 +0,0 @@
-export const MODAL_OPEN  = 'MODAL_OPEN';
-export const MODAL_CLOSE = 'MODAL_CLOSE';
-
-export function openModal(type, props) {
-  return {
-    type: MODAL_OPEN,
-    modalType: type,
-    modalProps: props,
-  };
-}
-
-export function closeModal(type, options = { ignoreFocus: false }) {
-  return {
-    type: MODAL_CLOSE,
-    modalType: type,
-    ignoreFocus: options.ignoreFocus,
-  };
-}
diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts
new file mode 100644
index 0000000000..af34f5d6af
--- /dev/null
+++ b/app/javascript/mastodon/actions/modal.ts
@@ -0,0 +1,17 @@
+import { createAction } from '@reduxjs/toolkit';
+
+import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root';
+
+export type ModalType = keyof typeof MODAL_COMPONENTS;
+
+interface OpenModalPayload {
+  modalType: ModalType;
+  modalProps: unknown;
+}
+export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
+
+interface CloseModalPayload {
+  modalType: ModalType | undefined;
+  ignoreFocus: boolean;
+}
+export const closeModal = createAction<CloseModalPayload>('MODAL_CLOSE');
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index e61a14af24..fb041078b8 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -97,7 +97,7 @@ export function initMuteModal(account) {
       account,
     });
 
-    dispatch(openModal('MUTE'));
+    dispatch(openModal({ modalType: 'MUTE' }));
   };
 }
 
diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js
index b3f9bf1ac3..756b8cd05e 100644
--- a/app/javascript/mastodon/actions/reports.js
+++ b/app/javascript/mastodon/actions/reports.js
@@ -7,9 +7,12 @@ export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
 export const REPORT_SUBMIT_FAIL    = 'REPORT_SUBMIT_FAIL';
 
 export const initReport = (account, status) => dispatch =>
-  dispatch(openModal('REPORT', {
-    accountId: account.get('id'),
-    statusId: status?.get('id'),
+  dispatch(openModal({
+    modalType: 'REPORT',
+    modalProps: {
+      accountId: account.get('id'),
+      statusId: status?.get('id'),
+    },
   }));
 
 export const submitReport = (params, onSuccess, onFail) => (dispatch, getState) => {
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx
index 42d3d93fe8..987b7c8272 100644
--- a/app/javascript/mastodon/components/edited_timestamp/index.jsx
+++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx
@@ -15,7 +15,10 @@ import DropdownMenu from './containers/dropdown_menu_container';
 const mapDispatchToProps = (dispatch, { statusId }) => ({
 
   onItemClick (index) {
-    dispatch(openModal('COMPARE_HISTORY', { index, statusId }));
+    dispatch(openModal({
+      modalType: 'COMPARE_HISTORY',
+      modalProps: { index, statusId },
+    }));
   },
 
 });
diff --git a/app/javascript/mastodon/containers/account_container.jsx b/app/javascript/mastodon/containers/account_container.jsx
index 09a755e877..a134452e77 100644
--- a/app/javascript/mastodon/containers/account_container.jsx
+++ b/app/javascript/mastodon/containers/account_container.jsx
@@ -35,10 +35,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   onFollow (account) {
     if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
       if (unfollowModal) {
-        dispatch(openModal('CONFIRM', {
-          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.unfollowConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+            confirm: intl.formatMessage(messages.unfollowConfirm),
+            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+          },
         }));
       } else {
         dispatch(unfollowAccount(account.get('id')));
diff --git a/app/javascript/mastodon/containers/domain_container.jsx b/app/javascript/mastodon/containers/domain_container.jsx
index 9a55b72cc3..c719a5775c 100644
--- a/app/javascript/mastodon/containers/domain_container.jsx
+++ b/app/javascript/mastodon/containers/domain_container.jsx
@@ -18,10 +18,13 @@ const makeMapStateToProps = () => {
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
   onBlockDomain (domain) {
-    dispatch(openModal('CONFIRM', {
-      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
-      confirm: intl.formatMessage(messages.blockDomainConfirm),
-      onConfirm: () => dispatch(blockDomain(domain)),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockDomainConfirm),
+        onConfirm: () => dispatch(blockDomain(domain)),
+      },
     }));
   },
 
diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js
index 5dd5273b27..6cf180cd53 100644
--- a/app/javascript/mastodon/containers/dropdown_menu_container.js
+++ b/app/javascript/mastodon/containers/dropdown_menu_container.js
@@ -18,15 +18,21 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
       dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
     }
 
-    dispatch(isUserTouching() ? openModal('ACTIONS', {
-      status,
-      actions: items,
-      onClick: onItemClick,
+    dispatch(isUserTouching() ? openModal({
+      modalType: 'ACTIONS',
+      modalProps: {
+        status,
+        actions: items,
+        onClick: onItemClick,
+      },
     }) : openDropdownMenu(id, keyboard, scrollKey));
   },
 
   onClose(id) {
-    dispatch(closeModal('ACTIONS'));
+    dispatch(closeModal({
+      modalType: 'ACTIONS',
+      ignoreFocus: false,
+    }));
     dispatch(closeDropdownMenu(id));
   },
 });
diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx
index 2d4baafa1b..3026dde0a8 100644
--- a/app/javascript/mastodon/containers/status_container.jsx
+++ b/app/javascript/mastodon/containers/status_container.jsx
@@ -82,10 +82,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
       let state = getState();
 
       if (state.getIn(['compose', 'text']).trim().length !== 0) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.replyMessage),
-          confirm: intl.formatMessage(messages.replyConfirm),
-          onConfirm: () => dispatch(replyCompose(status, router)),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.replyMessage),
+            confirm: intl.formatMessage(messages.replyConfirm),
+            onConfirm: () => dispatch(replyCompose(status, router)) },
         }));
       } else {
         dispatch(replyCompose(status, router));
@@ -134,9 +136,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
   },
 
   onEmbed (status) {
-    dispatch(openModal('EMBED', {
-      url: status.get('url'),
-      onError: error => dispatch(showAlertForError(error)),
+    dispatch(openModal({
+      modalType: 'EMBED',
+      modalProps: {
+        url: status.get('url'),
+        onError: error => dispatch(showAlertForError(error)),
+      },
     }));
   },
 
@@ -144,10 +149,13 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     if (!deleteModal) {
       dispatch(deleteStatus(status.get('id'), history, withRedraft));
     } else {
-      dispatch(openModal('CONFIRM', {
-        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
-        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
-        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      dispatch(openModal({
+        modalType: 'CONFIRM',
+        modalProps: {
+          message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+          confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+          onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+        },
       }));
     }
   },
@@ -156,10 +164,13 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     dispatch((_, getState) => {
       let state = getState();
       if (state.getIn(['compose', 'text']).trim().length !== 0) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.editMessage),
-          confirm: intl.formatMessage(messages.editConfirm),
-          onConfirm: () => dispatch(editStatus(status.get('id'), history)),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.editMessage),
+            confirm: intl.formatMessage(messages.editConfirm),
+            onConfirm: () => dispatch(editStatus(status.get('id'), history)),
+          },
         }));
       } else {
         dispatch(editStatus(status.get('id'), history));
@@ -184,11 +195,17 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
   },
 
   onOpenMedia (statusId, media, index, lang) {
-    dispatch(openModal('MEDIA', { statusId, media, index, lang }));
+    dispatch(openModal({
+      modalType: 'MEDIA',
+      modalProps: { statusId, media, index, lang },
+    }));
   },
 
   onOpenVideo (statusId, media, lang, options) {
-    dispatch(openModal('VIDEO', { statusId, media, lang, options }));
+    dispatch(openModal({
+      modalType: 'VIDEO',
+      modalProps: { statusId, media, lang, options },
+    }));
   },
 
   onBlock (status) {
@@ -237,10 +254,13 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
   },
 
   onBlockDomain (domain) {
-    dispatch(openModal('CONFIRM', {
-      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
-      confirm: intl.formatMessage(messages.blockDomainConfirm),
-      onConfirm: () => dispatch(blockDomain(domain)),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockDomainConfirm),
+        onConfirm: () => dispatch(blockDomain(domain)),
+      },
     }));
   },
 
@@ -253,10 +273,13 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
   },
 
   onInteractionModal (type, status) {
-    dispatch(openModal('INTERACTION', {
-      type,
-      accountId: status.getIn(['account', 'id']),
-      url: status.get('url'),
+    dispatch(openModal({
+      modalType: 'INTERACTION',
+      modalProps: {
+        type,
+        accountId: status.getIn(['account', 'id']),
+        url: status.get('url'),
+      },
     }));
   },
 
diff --git a/app/javascript/mastodon/features/account_gallery/index.jsx b/app/javascript/mastodon/features/account_gallery/index.jsx
index 16fb9ef577..27de4740ca 100644
--- a/app/javascript/mastodon/features/account_gallery/index.jsx
+++ b/app/javascript/mastodon/features/account_gallery/index.jsx
@@ -143,14 +143,23 @@ class AccountGallery extends ImmutablePureComponent {
     const lang = attachment.getIn(['status', 'language']);
 
     if (attachment.get('type') === 'video') {
-      dispatch(openModal('VIDEO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
+      dispatch(openModal({
+        modalType: 'VIDEO',
+        modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
+      }));
     } else if (attachment.get('type') === 'audio') {
-      dispatch(openModal('AUDIO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
+      dispatch(openModal({
+        modalType: 'AUDIO',
+        modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
+      }));
     } else {
       const media = attachment.getIn(['status', 'media_attachments']);
       const index = media.findIndex(x => x.get('id') === attachment.get('id'));
 
-      dispatch(openModal('MEDIA', { media, index, statusId, lang }));
+      dispatch(openModal({
+        modalType: 'MEDIA',
+        modalProps: { media, index, statusId, lang },
+      }));
     }
   };
 
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
index 6442a4f82f..2b3a66c55e 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
@@ -48,20 +48,26 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   onFollow (account) {
     if (account.getIn(['relationship', 'following'])) {
       if (unfollowModal) {
-        dispatch(openModal('CONFIRM', {
-          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.unfollowConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+            confirm: intl.formatMessage(messages.unfollowConfirm),
+            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+          },
         }));
       } else {
         dispatch(unfollowAccount(account.get('id')));
       }
     } else if (account.getIn(['relationship', 'requested'])) {
       if (unfollowModal) {
-        dispatch(openModal('CONFIRM', {
-          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+            confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+          },
         }));
       } else {
         dispatch(unfollowAccount(account.get('id')));
@@ -72,10 +78,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onInteractionModal (account) {
-    dispatch(openModal('INTERACTION', {
-      type: 'follow',
-      accountId: account.get('id'),
-      url: account.get('url'),
+    dispatch(openModal({
+      modalType: 'INTERACTION',
+      modalProps: {
+        type: 'follow',
+        accountId: account.get('id'),
+        url: account.get('url'),
+      },
     }));
   },
 
@@ -132,10 +141,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onBlockDomain (domain) {
-    dispatch(openModal('CONFIRM', {
-      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
-      confirm: intl.formatMessage(messages.blockDomainConfirm),
-      onConfirm: () => dispatch(blockDomain(domain)),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockDomainConfirm),
+        onConfirm: () => dispatch(blockDomain(domain)),
+      },
     }));
   },
 
@@ -144,21 +156,30 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onAddToList (account) {
-    dispatch(openModal('LIST_ADDER', {
-      accountId: account.get('id'),
+    dispatch(openModal({
+      modalType: 'LIST_ADDER',
+      modalProps: {
+        accountId: account.get('id'),
+      },
     }));
   },
 
   onChangeLanguages (account) {
-    dispatch(openModal('SUBSCRIBED_LANGUAGES', {
-      accountId: account.get('id'),
+    dispatch(openModal({
+      modalType: 'SUBSCRIBED_LANGUAGES',
+      modalProps: {
+        accountId: account.get('id'),
+      },
     }));
   },
 
   onOpenAvatar (account) {
-    dispatch(openModal('IMAGE', {
-      src: account.get('avatar'),
-      alt: account.get('acct'),
+    dispatch(openModal({
+      modalType: 'IMAGE',
+      modalProps: {
+        src: account.get('avatar'),
+        alt: account.get('acct'),
+      },
     }));
   },
 
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
index f881c4fa13..70b4026d14 100644
--- a/app/javascript/mastodon/features/compose/containers/navigation_container.js
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -21,11 +21,14 @@ const mapStateToProps = state => {
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
   onLogout () {
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.logoutMessage),
-      confirm: intl.formatMessage(messages.logoutConfirm),
-      closeWhenConfirm: false,
-      onConfirm: () => logOut(),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.logoutMessage),
+        confirm: intl.formatMessage(messages.logoutConfirm),
+        closeWhenConfirm: false,
+        onConfirm: () => logOut(),
+      },
     }));
   },
 });
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
index 1ca3fe550a..6d26abf4f6 100644
--- a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -16,8 +16,14 @@ const mapDispatchToProps = dispatch => ({
   },
 
   isUserTouching,
-  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
-  onModalClose: () => dispatch(closeModal()),
+  onModalOpen: props => dispatch(openModal({
+    modalType: 'ACTIONS',
+    modalProps: props,
+  })),
+  onModalClose: () => dispatch(closeModal({
+    modalType: undefined,
+    ignoreFocus: false,
+  })),
 
 });
 
diff --git a/app/javascript/mastodon/features/compose/index.jsx b/app/javascript/mastodon/features/compose/index.jsx
index 1923558862..8997f1bc6c 100644
--- a/app/javascript/mastodon/features/compose/index.jsx
+++ b/app/javascript/mastodon/features/compose/index.jsx
@@ -71,11 +71,14 @@ class Compose extends PureComponent {
     e.preventDefault();
     e.stopPropagation();
 
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.logoutMessage),
-      confirm: intl.formatMessage(messages.logoutConfirm),
-      closeWhenConfirm: false,
-      onConfirm: () => logOut(),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.logoutMessage),
+        confirm: intl.formatMessage(messages.logoutConfirm),
+        closeWhenConfirm: false,
+        onConfirm: () => logOut(),
+      },
     }));
 
     return false;
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
index 978c031aab..456fc7d7cc 100644
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
@@ -41,10 +41,13 @@ const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
       let state = getState();
 
       if (state.getIn(['compose', 'text']).trim().length !== 0) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.replyMessage),
-          confirm: intl.formatMessage(messages.replyConfirm),
-          onConfirm: () => dispatch(replyCompose(status, router)),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.replyMessage),
+            confirm: intl.formatMessage(messages.replyConfirm),
+            onConfirm: () => dispatch(replyCompose(status, router)),
+          },
         }));
       } else {
         dispatch(replyCompose(status, router));
diff --git a/app/javascript/mastodon/features/directory/components/account_card.jsx b/app/javascript/mastodon/features/directory/components/account_card.jsx
index 87ddcf94f7..cf1c63f9e4 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.jsx
+++ b/app/javascript/mastodon/features/directory/components/account_card.jsx
@@ -50,27 +50,32 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     if (account.getIn(['relationship', 'following'])) {
       if (unfollowModal) {
         dispatch(
-          openModal('CONFIRM', {
-            message: (
-              <FormattedMessage
-                id='confirmations.unfollow.message'
-                defaultMessage='Are you sure you want to unfollow {name}?'
-                values={{ name: <strong>@{account.get('acct')}</strong> }}
-              />
-            ),
-            confirm: intl.formatMessage(messages.unfollowConfirm),
-            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
-          }),
+          openModal({
+            modalType: 'CONFIRM',
+            modalProps: {
+              message: (
+                <FormattedMessage
+                  id='confirmations.unfollow.message'
+                  defaultMessage='Are you sure you want to unfollow {name}?'
+                  values={{ name: <strong>@{account.get('acct')}</strong> }}
+                />
+              ),
+              confirm: intl.formatMessage(messages.unfollowConfirm),
+              onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+            } }),
         );
       } else {
         dispatch(unfollowAccount(account.get('id')));
       }
     } else if (account.getIn(['relationship', 'requested'])) {
       if (unfollowModal) {
-        dispatch(openModal('CONFIRM', {
-          message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
-          confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
-          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+            confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
+            onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+          },
         }));
       } else {
         dispatch(unfollowAccount(account.get('id')));
diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx
index db6a3c4a53..be03b8f5fe 100644
--- a/app/javascript/mastodon/features/interaction_modal/index.jsx
+++ b/app/javascript/mastodon/features/interaction_modal/index.jsx
@@ -18,8 +18,11 @@ const mapStateToProps = (state, { accountId }) => ({
 
 const mapDispatchToProps = (dispatch) => ({
   onSignupClick() {
-    dispatch(closeModal());
-    dispatch(openModal('CLOSED_REGISTRATIONS'));
+    dispatch(closeModal({
+      modalType: undefined,
+      ignoreFocus: false,
+    }));
+    dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
   },
 });
 
diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx
index 1d80a9cd50..f41e8e6f23 100644
--- a/app/javascript/mastodon/features/list_timeline/index.jsx
+++ b/app/javascript/mastodon/features/list_timeline/index.jsx
@@ -114,24 +114,30 @@ class ListTimeline extends PureComponent {
   };
 
   handleEditClick = () => {
-    this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id }));
+    this.props.dispatch(openModal({
+      modalType: 'LIST_EDITOR',
+      modalProps: { listId: this.props.params.id },
+    }));
   };
 
   handleDeleteClick = () => {
     const { dispatch, columnId, intl } = this.props;
     const { id } = this.props.params;
 
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.deleteMessage),
-      confirm: intl.formatMessage(messages.deleteConfirm),
-      onConfirm: () => {
-        dispatch(deleteList(id));
-
-        if (columnId) {
-          dispatch(removeColumn(columnId));
-        } else {
-          this.context.router.history.push('/lists');
-        }
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.deleteMessage),
+        confirm: intl.formatMessage(messages.deleteConfirm),
+        onConfirm: () => {
+          dispatch(deleteList(id));
+
+          if (columnId) {
+            dispatch(removeColumn(columnId));
+          } else {
+            this.context.router.history.push('/lists');
+          }
+        },
       },
     }));
   };
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index 292767badb..b63796a8b2 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -59,10 +59,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onClear () {
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.clearMessage),
-      confirm: intl.formatMessage(messages.clearConfirm),
-      onConfirm: () => dispatch(clearNotifications()),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.clearMessage),
+        confirm: intl.formatMessage(messages.clearConfirm),
+        onConfirm: () => dispatch(clearNotifications()),
+      },
     }));
   },
 
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
index 1743edae99..c167d93dce 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
@@ -74,19 +74,25 @@ class Footer extends ImmutablePureComponent {
 
     if (signedIn) {
       if (askReplyConfirmation) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.replyMessage),
-          confirm: intl.formatMessage(messages.replyConfirm),
-          onConfirm: this._performReply,
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.replyMessage),
+            confirm: intl.formatMessage(messages.replyConfirm),
+            onConfirm: this._performReply,
+          },
         }));
       } else {
         this._performReply();
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'reply',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'reply',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
@@ -102,10 +108,13 @@ class Footer extends ImmutablePureComponent {
         dispatch(favourite(status));
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'favourite',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'favourite',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
@@ -128,10 +137,13 @@ class Footer extends ImmutablePureComponent {
         dispatch(initBoostModal({ status, onReblog: this._performReblog }));
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'reblog',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'reblog',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
index ddae8c0283..e76790a9f6 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -60,10 +60,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch((_, getState) => {
       let state = getState();
       if (state.getIn(['compose', 'text']).trim().length !== 0) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.replyMessage),
-          confirm: intl.formatMessage(messages.replyConfirm),
-          onConfirm: () => dispatch(replyCompose(status, router)),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.replyMessage),
+            confirm: intl.formatMessage(messages.replyConfirm),
+            onConfirm: () => dispatch(replyCompose(status, router)),
+          },
         }));
       } else {
         dispatch(replyCompose(status, router));
@@ -104,9 +107,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onEmbed (status) {
-    dispatch(openModal('EMBED', {
-      url: status.get('url'),
-      onError: error => dispatch(showAlertForError(error)),
+    dispatch(openModal({
+      modalType: 'EMBED',
+      modalProps: {
+        url: status.get('url'),
+        onError: error => dispatch(showAlertForError(error)),
+      },
     }));
   },
 
@@ -114,10 +120,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     if (!deleteModal) {
       dispatch(deleteStatus(status.get('id'), history, withRedraft));
     } else {
-      dispatch(openModal('CONFIRM', {
-        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
-        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
-        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      dispatch(openModal({
+        modalType: 'CONFIRM',
+        modalProps: {
+          message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+          confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+          onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+        },
       }));
     }
   },
@@ -131,11 +140,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onOpenMedia (media, index, lang) {
-    dispatch(openModal('MEDIA', { media, index, lang }));
+    dispatch(openModal({
+      modalType: 'MEDIA',
+      modalProps: { media, index, lang },
+    }));
   },
 
   onOpenVideo (media, lang, options) {
-    dispatch(openModal('VIDEO', { media, lang, options }));
+    dispatch(openModal({
+      modalType: 'VIDEO',
+      modalProps: { media, lang, options },
+    }));
   },
 
   onBlock (status) {
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index 1d26f7a69d..0ed94d34c0 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -251,10 +251,13 @@ class Status extends ImmutablePureComponent {
         dispatch(favourite(status));
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'favourite',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'favourite',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
@@ -273,19 +276,25 @@ class Status extends ImmutablePureComponent {
 
     if (signedIn) {
       if (askReplyConfirmation) {
-        dispatch(openModal('CONFIRM', {
-          message: intl.formatMessage(messages.replyMessage),
-          confirm: intl.formatMessage(messages.replyConfirm),
-          onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
+        dispatch(openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: intl.formatMessage(messages.replyMessage),
+            confirm: intl.formatMessage(messages.replyConfirm),
+            onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
+          },
         }));
       } else {
         dispatch(replyCompose(status, this.context.router.history));
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'reply',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'reply',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
@@ -309,10 +318,13 @@ class Status extends ImmutablePureComponent {
         }
       }
     } else {
-      dispatch(openModal('INTERACTION', {
-        type: 'reblog',
-        accountId: status.getIn(['account', 'id']),
-        url: status.get('url'),
+      dispatch(openModal({
+        modalType: 'INTERACTION',
+        modalProps: {
+          type: 'reblog',
+          accountId: status.getIn(['account', 'id']),
+          url: status.get('url'),
+        },
       }));
     }
   };
@@ -331,10 +343,13 @@ class Status extends ImmutablePureComponent {
     if (!deleteModal) {
       dispatch(deleteStatus(status.get('id'), history, withRedraft));
     } else {
-      dispatch(openModal('CONFIRM', {
-        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
-        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
-        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      dispatch(openModal({
+        modalType: 'CONFIRM',
+        modalProps: {
+          message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+          confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+          onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+        },
       }));
     }
   };
@@ -352,11 +367,17 @@ class Status extends ImmutablePureComponent {
   };
 
   handleOpenMedia = (media, index, lang) => {
-    this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index, lang }));
+    this.props.dispatch(openModal({
+      modalType: 'MEDIA',
+      modalProps: { statusId: this.props.status.get('id'), media, index, lang },
+    }));
   };
 
   handleOpenVideo = (media, lang, options) => {
-    this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, lang, options }));
+    this.props.dispatch(openModal({
+      modalType: 'VIDEO',
+      modalProps: { statusId: this.props.status.get('id'), media, lang, options },
+    }));
   };
 
   handleHotkeyOpenMedia = e => {
@@ -425,7 +446,10 @@ class Status extends ImmutablePureComponent {
   };
 
   handleEmbed = (status) => {
-    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+    this.props.dispatch(openModal({
+      modalType: 'EMBED',
+      modalProps: { url: status.get('url') },
+    }));
   };
 
   handleUnmuteClick = account => {
@@ -437,10 +461,13 @@ class Status extends ImmutablePureComponent {
   };
 
   handleBlockDomainClick = domain => {
-    this.props.dispatch(openModal('CONFIRM', {
-      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
-      confirm: this.props.intl.formatMessage(messages.blockDomainConfirm),
-      onConfirm: () => this.props.dispatch(blockDomain(domain)),
+    this.props.dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
+        confirm: this.props.intl.formatMessage(messages.blockDomainConfirm),
+        onConfirm: () => this.props.dispatch(blockDomain(domain)),
+      },
     }));
   };
 
diff --git a/app/javascript/mastodon/features/ui/components/block_modal.jsx b/app/javascript/mastodon/features/ui/components/block_modal.jsx
index 962b19344e..7cfd252e0b 100644
--- a/app/javascript/mastodon/features/ui/components/block_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/block_modal.jsx
@@ -33,7 +33,10 @@ const mapDispatchToProps = dispatch => {
     },
 
     onClose() {
-      dispatch(closeModal());
+      dispatch(closeModal({
+        modalType: undefined,
+        ignoreFocus: false,
+      }));
     },
   };
 };
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
index 4a55f25cbe..740537fa6e 100644
--- a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
@@ -23,7 +23,10 @@ const mapStateToProps = (state, { statusId }) => ({
 const mapDispatchToProps = dispatch => ({
 
   onClose() {
-    dispatch(closeModal());
+    dispatch(closeModal({
+      modalType: undefined,
+      ignoreFocus: false,
+    }));
   },
 
 });
diff --git a/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx b/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
index 6c4fefb0d5..6a71bb2465 100644
--- a/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
+++ b/app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
@@ -23,11 +23,14 @@ const mapStateToProps = (state) => ({
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
   onLogout () {
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.logoutMessage),
-      confirm: intl.formatMessage(messages.logoutConfirm),
-      closeWhenConfirm: false,
-      onConfirm: () => logOut(),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.logoutMessage),
+        confirm: intl.formatMessage(messages.logoutConfirm),
+        closeWhenConfirm: false,
+        onConfirm: () => logOut(),
+      },
     }));
   },
 });
diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx
index 04651bab18..bb6747c00c 100644
--- a/app/javascript/mastodon/features/ui/components/header.jsx
+++ b/app/javascript/mastodon/features/ui/components/header.jsx
@@ -26,7 +26,7 @@ const mapStateToProps = (state) => ({
 
 const mapDispatchToProps = (dispatch) => ({
   openClosedRegistrationsModal() {
-    dispatch(openModal('CLOSED_REGISTRATIONS'));
+    dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
   },
 });
 
diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx
index 4ae6f1dcf4..b025174409 100644
--- a/app/javascript/mastodon/features/ui/components/link_footer.jsx
+++ b/app/javascript/mastodon/features/ui/components/link_footer.jsx
@@ -19,11 +19,14 @@ const messages = defineMessages({
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
   onLogout () {
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.logoutMessage),
-      confirm: intl.formatMessage(messages.logoutConfirm),
-      closeWhenConfirm: false,
-      onConfirm: () => logOut(),
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.logoutMessage),
+        confirm: intl.formatMessage(messages.logoutConfirm),
+        closeWhenConfirm: false,
+        onConfirm: () => logOut(),
+      },
     }));
   },
 });
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index 5700e0f54c..d5edb45b36 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -32,7 +32,7 @@ import MediaModal from './media_modal';
 import ModalLoading from './modal_loading';
 import VideoModal from './video_modal';
 
-const MODAL_COMPONENTS = {
+export const MODAL_COMPONENTS = {
   'MEDIA': () => Promise.resolve({ default: MediaModal }),
   'VIDEO': () => Promise.resolve({ default: VideoModal }),
   'AUDIO': () => Promise.resolve({ default: AudioModal }),
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.jsx b/app/javascript/mastodon/features/ui/components/mute_modal.jsx
index dd21fa0465..708bd757cf 100644
--- a/app/javascript/mastodon/features/ui/components/mute_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.jsx
@@ -34,7 +34,10 @@ const mapDispatchToProps = dispatch => {
     },
 
     onClose() {
-      dispatch(closeModal());
+      dispatch(closeModal({
+        modalType: undefined,
+        ignoreFocus: false,
+      }));
     },
 
     onToggleNotifications() {
diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
index 25de95fd37..dad36134cf 100644
--- a/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
+++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
@@ -11,7 +11,7 @@ const SignInBanner = () => {
   const dispatch = useAppDispatch();
 
   const openClosedRegistrationsModal = useCallback(
-    () => dispatch(openModal('CLOSED_REGISTRATIONS')),
+    () => dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })),
     [dispatch],
   );
 
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
index b14d0410d1..1c3872cd50 100644
--- a/app/javascript/mastodon/features/ui/containers/modal_container.js
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -13,14 +13,22 @@ const mapDispatchToProps = dispatch => ({
   onClose (confirmationMessage, ignoreFocus = false) {
     if (confirmationMessage) {
       dispatch(
-        openModal('CONFIRM', {
-          message: confirmationMessage.message,
-          confirm: confirmationMessage.confirm,
-          onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })),
-        }),
+        openModal({
+          modalType: 'CONFIRM',
+          modalProps: {
+            message: confirmationMessage.message,
+            confirm: confirmationMessage.confirm,
+            onConfirm: () => dispatch(closeModal({
+              modalType: undefined,
+              ignoreFocus: { ignoreFocus },
+            })),
+          } }),
       );
     } else {
-      dispatch(closeModal(undefined, { ignoreFocus }));
+      dispatch(closeModal({
+        modalType: undefined,
+        ignoreFocus: { ignoreFocus },
+      }));
     }
   },
 });
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index 29c9abe68b..16047b26d8 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -25,7 +25,7 @@ import markers from './markers';
 import media_attachments from './media_attachments';
 import meta from './meta';
 import { missedUpdatesReducer } from './missed_updates';
-import modal from './modal';
+import { modalReducer } from './modal';
 import mutes from './mutes';
 import notifications from './notifications';
 import picture_in_picture from './picture_in_picture';
@@ -50,7 +50,7 @@ const reducers = {
   meta,
   alerts,
   loadingBar: loadingBarReducer,
-  modal,
+  modal: modalReducer,
   user_lists,
   domain_lists,
   status_lists,
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
deleted file mode 100644
index 348f538991..0000000000
--- a/app/javascript/mastodon/reducers/modal.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
-
-import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
-import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
-import { TIMELINE_DELETE } from '../actions/timelines';
-
-const initialState = ImmutableMap({
-  ignoreFocus: false,
-  stack: ImmutableStack(),
-});
-
-const popModal = (state, { modalType, ignoreFocus }) => {
-  if (modalType === undefined || modalType === state.getIn(['stack', 0, 'modalType'])) {
-    return state.set('ignoreFocus', !!ignoreFocus).update('stack', stack => stack.shift());
-  } else {
-    return state;
-  }
-};
-
-const pushModal = (state, modalType, modalProps) => {
-  return state.withMutations(map => {
-    map.set('ignoreFocus', false);
-    map.update('stack', stack => stack.unshift(ImmutableMap({ modalType, modalProps })));
-  });
-};
-
-export default function modal(state = initialState, action) {
-  switch(action.type) {
-  case MODAL_OPEN:
-    return pushModal(state, action.modalType, action.modalProps);
-  case MODAL_CLOSE:
-    return popModal(state, action);
-  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
-    return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
-  case TIMELINE_DELETE:
-    return state.update('stack', stack => stack.filterNot((modal) => modal.get('modalProps').statusId === action.id));
-  default:
-    return state;
-  }
-}
diff --git a/app/javascript/mastodon/reducers/modal.ts b/app/javascript/mastodon/reducers/modal.ts
new file mode 100644
index 0000000000..6afbcc367c
--- /dev/null
+++ b/app/javascript/mastodon/reducers/modal.ts
@@ -0,0 +1,94 @@
+import { Record as ImmutableRecord, Stack } from 'immutable';
+
+import type { PayloadAction } from '@reduxjs/toolkit';
+
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
+import type { ModalType } from '../actions/modal';
+import { openModal, closeModal } from '../actions/modal';
+import { TIMELINE_DELETE } from '../actions/timelines';
+
+type ModalProps = Record<string, unknown>;
+interface Modal {
+  modalType: ModalType;
+  modalProps: ModalProps;
+}
+
+const Modal = ImmutableRecord<Modal>({
+  modalType: 'ACTIONS',
+  modalProps: ImmutableRecord({})(),
+});
+
+interface ModalState {
+  ignoreFocus: boolean;
+  stack: Stack<ImmutableRecord<Modal>>;
+}
+
+const initialState = ImmutableRecord<ModalState>({
+  ignoreFocus: false,
+  stack: Stack(),
+})();
+type State = typeof initialState;
+
+interface PopModalOption {
+  modalType: ModalType | undefined;
+  ignoreFocus: boolean;
+}
+const popModal = (
+  state: State,
+  { modalType, ignoreFocus }: PopModalOption
+): State => {
+  if (
+    modalType === undefined ||
+    modalType === state.get('stack').get(0)?.get('modalType')
+  ) {
+    return state
+      .set('ignoreFocus', !!ignoreFocus)
+      .update('stack', (stack) => stack.shift());
+  } else {
+    return state;
+  }
+};
+
+const pushModal = (
+  state: State,
+  modalType: ModalType,
+  modalProps: ModalProps
+): State => {
+  return state.withMutations((record) => {
+    record.set('ignoreFocus', false);
+    record.update('stack', (stack) =>
+      stack.unshift(Modal({ modalType, modalProps }))
+    );
+  });
+};
+
+export function modalReducer(
+  state: State = initialState,
+  action: PayloadAction<{
+    modalType: ModalType;
+    ignoreFocus: boolean;
+    modalProps: Record<string, unknown>;
+  }>
+) {
+  switch (action.type) {
+    case openModal.type:
+      return pushModal(
+        state,
+        action.payload.modalType,
+        action.payload.modalProps
+      );
+    case closeModal.type:
+      return popModal(state, action.payload);
+    case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+      return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
+    case TIMELINE_DELETE:
+      return state.update('stack', (stack) =>
+        stack.filterNot(
+          // @ts-expect-error TIMELINE_DELETE action is not typed yet.
+          (modal) => modal.get('modalProps').statusId === action.id
+        )
+      );
+    default:
+      return state;
+  }
+}
-- 
GitLab