~cytrogen/masto-fe

0669783da8cfff14870f18671fb371d23de7dc2e — Claire 2 years ago e7dc375 + 55785b1
Merge branch 'main' into glitch-soc/merge-upstream
59 files changed, 634 insertions(+), 333 deletions(-)

M .rubocop_todo.yml
M app/javascript/mastodon/actions/blocks.js
M app/javascript/mastodon/actions/boosts.js
M app/javascript/mastodon/actions/compose.js
M app/javascript/mastodon/actions/filters.js
D app/javascript/mastodon/actions/modal.js
A app/javascript/mastodon/actions/modal.ts
M app/javascript/mastodon/actions/mutes.js
M app/javascript/mastodon/actions/reports.js
M app/javascript/mastodon/components/account.jsx
M app/javascript/mastodon/components/edited_timestamp/index.jsx
M app/javascript/mastodon/components/load_gap.tsx
M app/javascript/mastodon/components/status.jsx
M app/javascript/mastodon/containers/account_container.jsx
M app/javascript/mastodon/containers/domain_container.jsx
M app/javascript/mastodon/containers/dropdown_menu_container.js
M app/javascript/mastodon/containers/status_container.jsx
M app/javascript/mastodon/features/account_gallery/index.jsx
M app/javascript/mastodon/features/account_timeline/containers/header_container.jsx
M app/javascript/mastodon/features/compose/components/action_bar.jsx
M app/javascript/mastodon/features/compose/containers/navigation_container.js
M app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
M app/javascript/mastodon/features/compose/index.jsx
M app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
M app/javascript/mastodon/features/directory/components/account_card.jsx
M app/javascript/mastodon/features/getting_started/index.jsx
M app/javascript/mastodon/features/interaction_modal/index.jsx
M app/javascript/mastodon/features/list_timeline/index.jsx
M app/javascript/mastodon/features/notifications/containers/column_settings_container.js
M app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
M app/javascript/mastodon/features/report/components/status_check_box.jsx
M app/javascript/mastodon/features/status/components/action_bar.jsx
M app/javascript/mastodon/features/status/components/detailed_status.jsx
M app/javascript/mastodon/features/status/containers/detailed_status_container.js
M app/javascript/mastodon/features/status/index.jsx
M app/javascript/mastodon/features/ui/components/block_modal.jsx
M app/javascript/mastodon/features/ui/components/boost_modal.jsx
M app/javascript/mastodon/features/ui/components/compare_history_modal.jsx
M app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx
M app/javascript/mastodon/features/ui/components/header.jsx
M app/javascript/mastodon/features/ui/components/link_footer.jsx
M app/javascript/mastodon/features/ui/components/modal_root.jsx
M app/javascript/mastodon/features/ui/components/mute_modal.jsx
M app/javascript/mastodon/features/ui/components/sign_in_banner.jsx
M app/javascript/mastodon/features/ui/containers/modal_container.js
M app/javascript/mastodon/locales/defaultMessages.json
M app/javascript/mastodon/reducers/index.ts
D app/javascript/mastodon/reducers/modal.js
A app/javascript/mastodon/reducers/modal.ts
M app/models/account.rb
M app/models/instance.rb
A db/migrate/20230524190515_add_index_accounts_on_domain_and_id.rb
A db/migrate/20230524192812_fix_account_domain_casing.rb
A db/migrate/20230524194155_add_index_instances_on_reverse_domain.rb
M db/schema.rb
M lib/mastodon/cli/maintenance.rb
M lib/tasks/tests.rake
M spec/controllers/auth/registrations_controller_spec.rb
M spec/services/precompute_feed_service_spec.rb
M .rubocop_todo.yml => .rubocop_todo.yml +0 -7
@@ 531,13 531,6 @@ RSpec/MultipleMemoizedHelpers:
RSpec/NestedGroups:
  Max: 6

# Configuration parameters: AllowedPatterns.
# AllowedPatterns: ^expect_, ^assert_
RSpec/NoExpectationExample:
  Exclude:
    - 'spec/controllers/auth/registrations_controller_spec.rb'
    - 'spec/services/precompute_feed_service_spec.rb'

RSpec/PendingWithoutReason:
  Exclude:
    - 'spec/models/account_spec.rb'

M app/javascript/mastodon/actions/blocks.js => app/javascript/mastodon/actions/blocks.js +1 -1
@@ 95,6 95,6 @@ export function initBlockModal(account) {
      account,
    });

    dispatch(openModal('BLOCK'));
    dispatch(openModal({ modalType: 'BLOCK' }));
  };
}

M app/javascript/mastodon/actions/boosts.js => app/javascript/mastodon/actions/boosts.js +4 -1
@@ 14,7 14,10 @@ export function initBoostModal(props) {
      privacy,
    });

    dispatch(openModal('BOOST', props));
    dispatch(openModal({
      modalType: 'BOOST',
      modalProps: props,
    }));
  };
}


M app/javascript/mastodon/actions/compose.js => app/javascript/mastodon/actions/compose.js +4 -1
@@ 383,7 383,10 @@ export function initMediaEditModal(id) {
      id,
    });

    dispatch(openModal('FOCAL_POINT', { id }));
    dispatch(openModal({
      modalType: 'FOCAL_POINT',
      modalProps: { id },
    }));
  };
}


M app/javascript/mastodon/actions/filters.js => app/javascript/mastodon/actions/filters.js +6 -3
@@ 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) => {

D app/javascript/mastodon/actions/modal.js => app/javascript/mastodon/actions/modal.js +0 -18
@@ 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,
  };
}

A app/javascript/mastodon/actions/modal.ts => app/javascript/mastodon/actions/modal.ts +17 -0
@@ 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');

M app/javascript/mastodon/actions/mutes.js => app/javascript/mastodon/actions/mutes.js +1 -1
@@ 97,7 97,7 @@ export function initMuteModal(account) {
      account,
    });

    dispatch(openModal('MUTE'));
    dispatch(openModal({ modalType: 'MUTE' }));
  };
}


M app/javascript/mastodon/actions/reports.js => app/javascript/mastodon/actions/reports.js +6 -3
@@ 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) => {

M app/javascript/mastodon/components/account.jsx => app/javascript/mastodon/components/account.jsx +1 -1
@@ 23,7 23,7 @@ import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
  follow: { id: 'account.follow', defaultMessage: 'Follow' },
  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
  mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },

M app/javascript/mastodon/components/edited_timestamp/index.jsx => app/javascript/mastodon/components/edited_timestamp/index.jsx +4 -1
@@ 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 },
    }));
  },

});

M app/javascript/mastodon/components/load_gap.tsx => app/javascript/mastodon/components/load_gap.tsx +17 -17
@@ 16,21 16,21 @@ interface Props {
  intl: InjectedIntl;
}

export const LoadGap = injectIntl<Props>(
  ({ disabled, maxId, onClick, intl }) => {
    const handleClick = useCallback(() => {
      onClick(maxId);
    }, [maxId, onClick]);
const _LoadGap: React.FC<Props> = ({ disabled, maxId, onClick, intl }) => {
  const handleClick = useCallback(() => {
    onClick(maxId);
  }, [maxId, onClick]);

    return (
      <button
        className='load-more load-gap'
        disabled={disabled}
        onClick={handleClick}
        aria-label={intl.formatMessage(messages.load_more)}
      >
        <Icon id='ellipsis-h' />
      </button>
    );
  }
);
  return (
    <button
      className='load-more load-gap'
      disabled={disabled}
      onClick={handleClick}
      aria-label={intl.formatMessage(messages.load_more)}
    >
      <Icon id='ellipsis-h' />
    </button>
  );
};

export const LoadGap = injectIntl(_LoadGap);

M app/javascript/mastodon/components/status.jsx => app/javascript/mastodon/components/status.jsx +1 -1
@@ 59,7 59,7 @@ export const defaultMediaVisibility = (status) => {
const messages = defineMessages({
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});

M app/javascript/mastodon/containers/account_container.jsx => app/javascript/mastodon/containers/account_container.jsx +7 -4
@@ 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')));

M app/javascript/mastodon/containers/domain_container.jsx => app/javascript/mastodon/containers/domain_container.jsx +7 -4
@@ 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.' 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)),
      },
    }));
  },


M app/javascript/mastodon/containers/dropdown_menu_container.js => app/javascript/mastodon/containers/dropdown_menu_container.js +11 -5
@@ 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));
  },
});

M app/javascript/mastodon/containers/status_container.jsx => app/javascript/mastodon/containers/status_container.jsx +49 -26
@@ 59,7 59,7 @@ const messages = defineMessages({
  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
  editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
  editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});

const makeMapStateToProps = () => {


@@ 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'),
      },
    }));
  },


M app/javascript/mastodon/features/account_gallery/index.jsx => app/javascript/mastodon/features/account_gallery/index.jsx +12 -3
@@ 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 },
      }));
    }
  };


M app/javascript/mastodon/features/account_timeline/containers/header_container.jsx => app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +45 -24
@@ 28,7 28,7 @@ import Header from '../components/header';
const messages = defineMessages({
  cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});

const makeMapStateToProps = () => {


@@ 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'),
      },
    }));
  },


M app/javascript/mastodon/features/compose/components/action_bar.jsx => app/javascript/mastodon/features/compose/components/action_bar.jsx +1 -1
@@ 16,7 16,7 @@ const messages = defineMessages({
  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
  filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },

M app/javascript/mastodon/features/compose/containers/navigation_container.js => app/javascript/mastodon/features/compose/containers/navigation_container.js +8 -5
@@ 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(),
      },
    }));
  },
});

M app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js => app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js +8 -2
@@ 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,
  })),

});


M app/javascript/mastodon/features/compose/index.jsx => app/javascript/mastodon/features/compose/index.jsx +8 -5
@@ 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;

M app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js => app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js +7 -4
@@ 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));

M app/javascript/mastodon/features/directory/components/account_card.jsx => app/javascript/mastodon/features/directory/components/account_card.jsx +20 -15
@@ 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')));

M app/javascript/mastodon/features/getting_started/index.jsx => app/javascript/mastodon/features/getting_started/index.jsx +1 -1
@@ 34,7 34,7 @@ const messages = defineMessages({
  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },

M app/javascript/mastodon/features/interaction_modal/index.jsx => app/javascript/mastodon/features/interaction_modal/index.jsx +5 -2
@@ 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' }));
  },
});


M app/javascript/mastodon/features/list_timeline/index.jsx => app/javascript/mastodon/features/list_timeline/index.jsx +18 -12
@@ 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');
          }
        },
      },
    }));
  };

M app/javascript/mastodon/features/notifications/containers/column_settings_container.js => app/javascript/mastodon/features/notifications/containers/column_settings_container.js +7 -4
@@ 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()),
      },
    }));
  },


M app/javascript/mastodon/features/picture_in_picture/components/footer.jsx => app/javascript/mastodon/features/picture_in_picture/components/footer.jsx +28 -16
@@ 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'),
        },
      }));
    }
  };

M app/javascript/mastodon/features/report/components/status_check_box.jsx => app/javascript/mastodon/features/report/components/status_check_box.jsx +1 -1
@@ 17,7 17,7 @@ import Option from './option';
const messages = defineMessages({
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});


M app/javascript/mastodon/features/status/components/action_bar.jsx => app/javascript/mastodon/features/status/components/action_bar.jsx +2 -2
@@ 38,9 38,9 @@ const messages = defineMessages({
  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
  embed: { id: 'status.embed', defaultMessage: 'Embed' },
  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
  copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
  copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },

M app/javascript/mastodon/features/status/components/detailed_status.jsx => app/javascript/mastodon/features/status/components/detailed_status.jsx +2 -2
@@ 26,8 26,8 @@ import Card from './card';
const messages = defineMessages({
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});

class DetailedStatus extends ImmutablePureComponent {

M app/javascript/mastodon/features/status/containers/detailed_status_container.js => app/javascript/mastodon/features/status/containers/detailed_status_container.js +28 -13
@@ 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) {

M app/javascript/mastodon/features/status/index.jsx => app/javascript/mastodon/features/status/index.jsx +55 -28
@@ 79,7 79,7 @@ const messages = defineMessages({
  detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});

const makeMapStateToProps = () => {


@@ 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)),
      },
    }));
  };


M app/javascript/mastodon/features/ui/components/block_modal.jsx => app/javascript/mastodon/features/ui/components/block_modal.jsx +4 -1
@@ 33,7 33,10 @@ const mapDispatchToProps = dispatch => {
    },

    onClose() {
      dispatch(closeModal());
      dispatch(closeModal({
        modalType: undefined,
        ignoreFocus: false,
      }));
    },
  };
};

M app/javascript/mastodon/features/ui/components/boost_modal.jsx => app/javascript/mastodon/features/ui/components/boost_modal.jsx +2 -2
@@ 24,8 24,8 @@ const messages = defineMessages({
  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});

const mapStateToProps = state => {

M app/javascript/mastodon/features/ui/components/compare_history_modal.jsx => app/javascript/mastodon/features/ui/components/compare_history_modal.jsx +4 -1
@@ 23,7 23,10 @@ const mapStateToProps = (state, { statusId }) => ({
const mapDispatchToProps = dispatch => ({

  onClose() {
    dispatch(closeModal());
    dispatch(closeModal({
      modalType: undefined,
      ignoreFocus: false,
    }));
  },

});

M app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx => app/javascript/mastodon/features/ui/components/disabled_account_banner.jsx +8 -5
@@ 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(),
      },
    }));
  },
});

M app/javascript/mastodon/features/ui/components/header.jsx => app/javascript/mastodon/features/ui/components/header.jsx +1 -1
@@ 26,7 26,7 @@ const mapStateToProps = (state) => ({

const mapDispatchToProps = (dispatch) => ({
  openClosedRegistrationsModal() {
    dispatch(openModal('CLOSED_REGISTRATIONS'));
    dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
  },
});


M app/javascript/mastodon/features/ui/components/link_footer.jsx => app/javascript/mastodon/features/ui/components/link_footer.jsx +8 -5
@@ 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(),
      },
    }));
  },
});

M app/javascript/mastodon/features/ui/components/modal_root.jsx => app/javascript/mastodon/features/ui/components/modal_root.jsx +1 -1
@@ 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 }),

M app/javascript/mastodon/features/ui/components/mute_modal.jsx => app/javascript/mastodon/features/ui/components/mute_modal.jsx +4 -1
@@ 34,7 34,10 @@ const mapDispatchToProps = dispatch => {
    },

    onClose() {
      dispatch(closeModal());
      dispatch(closeModal({
        modalType: undefined,
        ignoreFocus: false,
      }));
    },

    onToggleNotifications() {

M app/javascript/mastodon/features/ui/components/sign_in_banner.jsx => app/javascript/mastodon/features/ui/components/sign_in_banner.jsx +1 -1
@@ 11,7 11,7 @@ const SignInBanner = () => {
  const dispatch = useAppDispatch();

  const openClosedRegistrationsModal = useCallback(
    () => dispatch(openModal('CLOSED_REGISTRATIONS')),
    () => dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })),
    [dispatch],
  );


M app/javascript/mastodon/features/ui/containers/modal_container.js => app/javascript/mastodon/features/ui/containers/modal_container.js +14 -6
@@ 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 },
      }));
    }
  },
});

M app/javascript/mastodon/locales/defaultMessages.json => app/javascript/mastodon/locales/defaultMessages.json +15 -15
@@ 57,7 57,7 @@
        "id": "account.unfollow"
      },
      {
        "defaultMessage": "Awaiting approval",
        "defaultMessage": "Awaiting approval. Click to cancel follow request",
        "id": "account.requested"
      },
      {


@@ 721,7 721,7 @@
        "id": "privacy.unlisted.short"
      },
      {
        "defaultMessage": "Followers-only",
        "defaultMessage": "Followers only",
        "id": "privacy.private.short"
      },
      {


@@ 792,7 792,7 @@
        "id": "confirmations.domain_block.confirm"
      },
      {
        "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.",
        "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.",
        "id": "confirmations.domain_block.message"
      }
    ],


@@ 833,7 833,7 @@
        "id": "confirmations.edit.message"
      },
      {
        "defaultMessage": "Hide entire domain",
        "defaultMessage": "Block entire domain",
        "id": "confirmations.domain_block.confirm"
      },
      {


@@ 980,7 980,7 @@
        "id": "confirmations.unfollow.confirm"
      },
      {
        "defaultMessage": "Hide entire domain",
        "defaultMessage": "Block entire domain",
        "id": "confirmations.domain_block.confirm"
      },
      {


@@ 1412,7 1412,7 @@
        "id": "navigation_bar.blocks"
      },
      {
        "defaultMessage": "Hidden domains",
        "defaultMessage": "Blocked domains",
        "id": "navigation_bar.domain_blocks"
      },
      {


@@ 2430,7 2430,7 @@
        "id": "navigation_bar.blocks"
      },
      {
        "defaultMessage": "Hidden domains",
        "defaultMessage": "Blocked domains",
        "id": "navigation_bar.domain_blocks"
      },
      {


@@ 3535,7 3535,7 @@
        "id": "privacy.unlisted.short"
      },
      {
        "defaultMessage": "Followers-only",
        "defaultMessage": "Followers only",
        "id": "privacy.private.short"
      },
      {


@@ 3743,7 3743,7 @@
        "id": "status.admin_account"
      },
      {
        "defaultMessage": "Open this status in the moderation interface",
        "defaultMessage": "Open this post in the moderation interface",
        "id": "status.admin_status"
      },
      {


@@ 3751,7 3751,7 @@
        "id": "status.admin_domain"
      },
      {
        "defaultMessage": "Copy link to status",
        "defaultMessage": "Copy link to post",
        "id": "status.copy"
      },
      {


@@ 3797,11 3797,11 @@
        "id": "privacy.unlisted.short"
      },
      {
        "defaultMessage": "Followers-only",
        "defaultMessage": "Followers only",
        "id": "privacy.private.short"
      },
      {
        "defaultMessage": "Direct",
        "defaultMessage": "Mentioned people only",
        "id": "privacy.direct.short"
      },
      {


@@ 3854,7 3854,7 @@
        "id": "confirmations.reply.message"
      },
      {
        "defaultMessage": "Hide entire domain",
        "defaultMessage": "Block entire domain",
        "id": "confirmations.domain_block.confirm"
      },
      {


@@ 3925,11 3925,11 @@
        "id": "privacy.unlisted.short"
      },
      {
        "defaultMessage": "Followers-only",
        "defaultMessage": "Followers only",
        "id": "privacy.private.short"
      },
      {
        "defaultMessage": "Direct",
        "defaultMessage": "Mentioned people only",
        "id": "privacy.direct.short"
      },
      {

M app/javascript/mastodon/reducers/index.ts => app/javascript/mastodon/reducers/index.ts +2 -2
@@ 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,

D app/javascript/mastodon/reducers/modal.js => app/javascript/mastodon/reducers/modal.js +0 -40
@@ 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;
  }
}

A app/javascript/mastodon/reducers/modal.ts => app/javascript/mastodon/reducers/modal.ts +94 -0
@@ 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;
  }
}

M app/models/account.rb => app/models/account.rb +1 -1
@@ 123,7 123,7 @@ class Account < ApplicationRecord
  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
  scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
  scope :popular, -> { order('account_stats.followers_count desc') }
  scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) }
  scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomain(domain).select(:domain)) }
  scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
  scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }


M app/models/instance.rb => app/models/instance.rb +1 -0
@@ 22,6 22,7 @@ class Instance < ApplicationRecord
  end

  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
  scope :by_domain_and_subdomain, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") }

  def self.refresh
    Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)

A db/migrate/20230524190515_add_index_accounts_on_domain_and_id.rb => db/migrate/20230524190515_add_index_accounts_on_domain_and_id.rb +9 -0
@@ 0,0 1,9 @@
# frozen_string_literal: true

class AddIndexAccountsOnDomainAndId < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!

  def change
    add_index :accounts, [:domain, :id], name: :index_accounts_on_domain_and_id, algorithm: :concurrently
  end
end

A db/migrate/20230524192812_fix_account_domain_casing.rb => db/migrate/20230524192812_fix_account_domain_casing.rb +13 -0
@@ 0,0 1,13 @@
# frozen_string_literal: true

class FixAccountDomainCasing < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!

  def up
    safety_assured do
      execute 'UPDATE accounts SET domain = lower(domain) WHERE domain IS NOT NULL AND domain != lower(domain)'
    end
  end

  def down; end
end

A db/migrate/20230524194155_add_index_instances_on_reverse_domain.rb => db/migrate/20230524194155_add_index_instances_on_reverse_domain.rb +9 -0
@@ 0,0 1,9 @@
# frozen_string_literal: true

class AddIndexInstancesOnReverseDomain < ActiveRecord::Migration[6.1]
  disable_ddl_transaction!

  def change
    add_index :instances, "reverse('.' || domain), domain", name: :index_instances_on_reverse_domain, algorithm: :concurrently
  end
end

M db/schema.rb => db/schema.rb +3 -1
@@ 10,7 10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2023_03_30_155710) do
ActiveRecord::Schema.define(version: 2023_05_24_194155) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"


@@ 188,6 188,7 @@ ActiveRecord::Schema.define(version: 2023_03_30_155710) do
    t.datetime "requested_review_at"
    t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
    t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
    t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"
    t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id", where: "(moved_to_account_id IS NOT NULL)"
    t.index ["uri"], name: "index_accounts_on_uri"
    t.index ["url"], name: "index_accounts_on_url", opclass: :text_pattern_ops, where: "(url IS NOT NULL)"


@@ 1286,6 1287,7 @@ ActiveRecord::Schema.define(version: 2023_03_30_155710) do
     FROM (domain_allows
       LEFT JOIN domain_counts ON (((domain_counts.domain)::text = (domain_allows.domain)::text)));
  SQL
  add_index "instances", "reverse(('.'::text || (domain)::text)), domain", name: "index_instances_on_reverse_domain"
  add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true

  create_view "user_ips", sql_definition: <<-SQL

M lib/mastodon/cli/maintenance.rb => lib/mastodon/cli/maintenance.rb +21 -11
@@ 231,6 231,23 @@ module Mastodon::CLI
        end
      end

      deduplicate_users_process_confirmation_token
      deduplicate_users_process_remember_token
      deduplicate_users_process_password_token

      @prompt.say 'Restoring users indexes…'
      ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
      ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
      ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if ActiveRecord::Migrator.current_version < 2022_01_18_183010

      if ActiveRecord::Migrator.current_version < 2022_03_10_060641
        ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
      else
        ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops
      end
    end

    def deduplicate_users_process_confirmation_token
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row|
        users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1)
        @prompt.warn "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"


@@ 239,7 256,9 @@ module Mastodon::CLI
          user.update!(confirmation_token: nil)
        end
      end
    end

    def deduplicate_users_process_remember_token
      if ActiveRecord::Migrator.current_version < 2022_01_18_183010
        ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
          users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)


@@ 250,7 269,9 @@ module Mastodon::CLI
          end
        end
      end
    end

    def deduplicate_users_process_password_token
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row|
        users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
        @prompt.warn "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"


@@ 259,17 280,6 @@ module Mastodon::CLI
          user.update!(reset_password_token: nil)
        end
      end

      @prompt.say 'Restoring users indexes…'
      ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
      ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
      ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if ActiveRecord::Migrator.current_version < 2022_01_18_183010

      if ActiveRecord::Migrator.current_version < 2022_03_10_060641
        ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
      else
        ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops
      end
    end

    def deduplicate_account_domain_blocks!

M lib/tasks/tests.rake => lib/tasks/tests.rake +6 -1
@@ 58,6 58,11 @@ namespace :tests do
        puts 'User settings not kept as expected'
        exit(1)
      end

      unless Account.find_remote('bob', 'ActivityPub.com').domain == 'activitypub.com'
        puts 'Account domains not properly normalized'
        exit(1)
      end
    end

    desc 'Populate the database with test data for 2.4.3'


@@ 160,7 165,7 @@ namespace :tests do
        INSERT INTO "accounts"
          (id, username, domain, private_key, public_key, created_at, updated_at, protocol, inbox_url, outbox_url, followers_url)
        VALUES
          (6, 'bob', 'activitypub.com', NULL, #{remote_public_key_ap}, now(), now(),
          (6, 'bob', 'ActivityPub.com', NULL, #{remote_public_key_ap}, now(), now(),
           1, 'https://activitypub.com/users/bob/inbox', 'https://activitypub.com/users/bob/outbox', 'https://activitypub.com/users/bob/followers');

        INSERT INTO "accounts"

M spec/controllers/auth/registrations_controller_spec.rb => spec/controllers/auth/registrations_controller_spec.rb +20 -3
@@ 244,9 244,26 @@ RSpec.describe Auth::RegistrationsController do
      end
    end

    it 'does nothing if user already exists' do
      Fabricate(:account, username: 'test')
      subject
    context 'with an already taken username' do
      subject do
        Setting.registrations_mode = 'open'
        post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', agreement: 'true' } }
      end

      before do
        Fabricate(:account, username: 'test')
      end

      it 'responds with an error message about the username' do
        subject

        expect(response).to have_http_status(:success)
        expect(username_error_text).to eq(I18n.t('errors.messages.taken'))
      end

      def username_error_text
        Nokogiri::Slop(response.body).css('.user_account_username .error').text
      end
    end

    include_examples 'checks for enabled registrations', :create

M spec/services/precompute_feed_service_spec.rb => spec/services/precompute_feed_service_spec.rb +1 -1
@@ 19,7 19,7 @@ RSpec.describe PrecomputeFeedService, type: :service do

    it 'does not raise an error even if it could not find any status' do
      account = Fabricate(:account)
      subject.call(account)
      expect { subject.call(account) }.to_not raise_error
    end

    it 'filters statuses' do