Add preview of followers removed in domain block modal in web UI (#32032)

This commit is contained in:
Eugen Rochko 2024-09-25 20:13:36 +02:00 committed by GitHub
parent 28c4eca0af
commit 3426ea2912
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 274 additions and 108 deletions

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Api::V1::DomainBlocks::PreviewsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }
before_action :require_user!
before_action :set_domain
before_action :set_domain_block_preview
def show
render json: @domain_block_preview, serializer: REST::DomainBlockPreviewSerializer
end
private
def set_domain
@domain = TagManager.instance.normalize_domain(params[:domain])
end
def set_domain_block_preview
@domain_block_preview = with_read_replica do
DomainBlockPreviewPresenter.new(
following_count: current_account.following.where(domain: @domain).count,
followers_count: current_account.followers.where(domain: @domain).count
)
end
end
end

View File

@ -70,6 +70,7 @@ export async function apiRequest<ApiResponse = unknown>(
args: { args: {
params?: RequestParamsOrData; params?: RequestParamsOrData;
data?: RequestParamsOrData; data?: RequestParamsOrData;
timeout?: number;
} = {}, } = {},
) { ) {
const { data } = await api().request<ApiResponse>({ const { data } = await api().request<ApiResponse>({

View File

@ -7,6 +7,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
dangerous?: boolean;
} }
interface PropsChildren extends PropsWithChildren<BaseProps> { interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -26,6 +27,7 @@ export const Button: React.FC<Props> = ({
disabled, disabled,
block, block,
secondary, secondary,
dangerous,
className, className,
title, title,
text, text,
@ -46,6 +48,7 @@ export const Button: React.FC<Props> = ({
className={classNames('button', className, { className={classNames('button', className, {
'button-secondary': secondary, 'button-secondary': secondary,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous,
})} })}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}

View File

@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => {
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /> <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button> </button>
<Button onClick={handleClick} autoFocus> <Button onClick={handleClick} dangerous autoFocus>
<FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' /> <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
</Button> </Button>
</div> </div>

View File

@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { blockAccount } from 'mastodon/actions/accounts';
import { blockDomain } from 'mastodon/actions/domain_blocks';
import { closeModal } from 'mastodon/actions/modal';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
export const DomainBlockModal = ({ domain, accountId, acct }) => {
const dispatch = useDispatch();
const handleClick = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
dispatch(blockDomain(domain));
}, [dispatch, domain]);
const handleSecondaryClick = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
dispatch(blockAccount(accountId));
}, [dispatch, accountId]);
const handleCancel = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
}, [dispatch]);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__header'>
<div className='safety-action-modal__header__icon'>
<Icon icon={DomainDisabledIcon} />
</div>
<div>
<h1><FormattedMessage id='domain_block_modal.title' defaultMessage='Block domain?' /></h1>
<div>{domain}</div>
</div>
</div>
<div className='safety-action-modal__bullet-points'>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={CampaignIcon} /></div>
<div><FormattedMessage id='domain_block_modal.they_wont_know' defaultMessage="They won't know they've been blocked." /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={VisibilityOffIcon} /></div>
<div><FormattedMessage id='domain_block_modal.you_wont_see_posts' defaultMessage="You won't see posts or notifications from users on this server." /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={PersonRemoveIcon} /></div>
<div><FormattedMessage id='domain_block_modal.you_will_lose_followers' defaultMessage='All your followers from this server will be removed.' /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={ReplyIcon} /></div>
<div><FormattedMessage id='domain_block_modal.they_cant_follow' defaultMessage='Nobody from this server can follow you.' /></div>
</div>
<div>
<div className='safety-action-modal__bullet-points__icon'><Icon icon={HistoryIcon} /></div>
<div><FormattedMessage id='domain_block_modal.they_can_interact_with_old_posts' defaultMessage='People from this server can interact with your old posts.' /></div>
</div>
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<Button onClick={handleSecondaryClick} secondary>
<FormattedMessage id='domain_block_modal.block_account_instead' defaultMessage='Block @{name} instead' values={{ name: acct.split('@')[0] }} />
</Button>
<div className='spacer' />
<button onClick={handleCancel} className='link-button'>
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
</button>
<Button onClick={handleClick} autoFocus>
<FormattedMessage id='domain_block_modal.block' defaultMessage='Block server' />
</Button>
</div>
</div>
</div>
);
};
DomainBlockModal.propTypes = {
domain: PropTypes.string.isRequired,
accountId: PropTypes.string.isRequired,
acct: PropTypes.string.isRequired,
};
export default DomainBlockModal;

View File

@ -0,0 +1,204 @@
import { useCallback, useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react';
import DomainDisabledIcon from '@/material-icons/400-24px/domain_disabled.svg?react';
import HistoryIcon from '@/material-icons/400-24px/history.svg?react';
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { blockAccount } from 'mastodon/actions/accounts';
import { blockDomain } from 'mastodon/actions/domain_blocks';
import { closeModal } from 'mastodon/actions/modal';
import { apiRequest } from 'mastodon/api';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number';
import { useAppDispatch } from 'mastodon/store';
interface DomainBlockPreviewResponse {
following_count: number;
followers_count: number;
}
export const DomainBlockModal: React.FC<{
domain: string;
accountId: string;
acct: string;
}> = ({ domain, accountId, acct }) => {
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [preview, setPreview] = useState<DomainBlockPreviewResponse | null>(
null,
);
const handleClick = useCallback(() => {
if (loading) {
return; // Prevent destructive action before the preview finishes loading or times out
}
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
dispatch(blockDomain(domain));
}, [dispatch, loading, domain]);
const handleSecondaryClick = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
dispatch(blockAccount(accountId));
}, [dispatch, accountId]);
const handleCancel = useCallback(() => {
dispatch(closeModal({ modalType: undefined, ignoreFocus: false }));
}, [dispatch]);
useEffect(() => {
setLoading(true);
apiRequest<DomainBlockPreviewResponse>('GET', 'v1/domain_blocks/preview', {
params: { domain },
timeout: 5000,
})
.then((data) => {
setPreview(data);
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, [setPreview, setLoading, domain]);
return (
<div className='modal-root__modal safety-action-modal' aria-live='polite'>
<div className='safety-action-modal__top'>
<div className='safety-action-modal__header'>
<div className='safety-action-modal__header__icon'>
<Icon id='' icon={DomainDisabledIcon} />
</div>
<div>
<h1>
<FormattedMessage
id='domain_block_modal.title'
defaultMessage='Block domain?'
/>
</h1>
<div>{domain}</div>
</div>
</div>
<div className='safety-action-modal__bullet-points'>
{preview && preview.followers_count + preview.following_count > 0 && (
<div>
<div className='safety-action-modal__bullet-points__icon'>
<Icon id='' icon={PersonRemoveIcon} />
</div>
<div>
<strong>
<FormattedMessage
id='domain_block_modal.you_will_lose_num_followers'
defaultMessage='You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.'
values={{
followersCount: preview.followers_count,
followersCountDisplay: (
<ShortNumber value={preview.followers_count} />
),
followingCount: preview.following_count,
followingCountDisplay: (
<ShortNumber value={preview.following_count} />
),
}}
/>
</strong>
</div>
</div>
)}
<div className='safety-action-modal__bullet-points--deemphasized'>
<div className='safety-action-modal__bullet-points__icon'>
<Icon id='' icon={CampaignIcon} />
</div>
<div>
<FormattedMessage
id='domain_block_modal.they_wont_know'
defaultMessage="They won't know they've been blocked."
/>
</div>
</div>
<div className='safety-action-modal__bullet-points--deemphasized'>
<div className='safety-action-modal__bullet-points__icon'>
<Icon id='' icon={VisibilityOffIcon} />
</div>
<div>
<FormattedMessage
id='domain_block_modal.you_wont_see_posts'
defaultMessage="You won't see posts or notifications from users on this server."
/>
</div>
</div>
<div className='safety-action-modal__bullet-points--deemphasized'>
<div className='safety-action-modal__bullet-points__icon'>
<Icon id='' icon={ReplyIcon} />
</div>
<div>
<FormattedMessage
id='domain_block_modal.they_cant_follow'
defaultMessage='Nobody from this server can follow you.'
/>
</div>
</div>
<div className='safety-action-modal__bullet-points--deemphasized'>
<div className='safety-action-modal__bullet-points__icon'>
<Icon id='' icon={HistoryIcon} />
</div>
<div>
<FormattedMessage
id='domain_block_modal.they_can_interact_with_old_posts'
defaultMessage='People from this server can interact with your old posts.'
/>
</div>
</div>
</div>
</div>
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<Button onClick={handleSecondaryClick} secondary>
<FormattedMessage
id='domain_block_modal.block_account_instead'
defaultMessage='Block @{name} instead'
values={{ name: acct.split('@')[0] }}
/>
</Button>
<div className='spacer' />
<button onClick={handleCancel} className='link-button'>
<FormattedMessage
id='confirmation_modal.cancel'
defaultMessage='Cancel'
/>
</button>
<Button onClick={handleClick} dangerous aria-busy={loading}>
{loading ? (
<LoadingIndicator />
) : (
<FormattedMessage
id='domain_block_modal.block'
defaultMessage='Block server'
/>
)}
</Button>
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default DomainBlockModal;

View File

@ -221,7 +221,7 @@
"domain_block_modal.they_cant_follow": "Nobody from this server can follow you.", "domain_block_modal.they_cant_follow": "Nobody from this server can follow you.",
"domain_block_modal.they_wont_know": "They won't know they've been blocked.", "domain_block_modal.they_wont_know": "They won't know they've been blocked.",
"domain_block_modal.title": "Block domain?", "domain_block_modal.title": "Block domain?",
"domain_block_modal.you_will_lose_followers": "All your followers from this server will be removed.", "domain_block_modal.you_will_lose_num_followers": "You will lose {followersCount, plural, one {{followersCountDisplay} follower} other {{followersCountDisplay} followers}} and {followingCount, plural, one {{followingCountDisplay} person you follow} other {{followingCountDisplay} people you follow}}.",
"domain_block_modal.you_wont_see_posts": "You won't see posts or notifications from users on this server.", "domain_block_modal.you_wont_see_posts": "You won't see posts or notifications from users on this server.",
"domain_pill.activitypub_lets_connect": "It lets you connect and interact with people not just on Mastodon, but across different social apps too.", "domain_pill.activitypub_lets_connect": "It lets you connect and interact with people not just on Mastodon, but across different social apps too.",
"domain_pill.activitypub_like_language": "ActivityPub is like the language Mastodon speaks with other social networks.", "domain_pill.activitypub_like_language": "ActivityPub is like the language Mastodon speaks with other social networks.",

View File

@ -81,6 +81,18 @@
outline: $ui-button-icon-focus-outline; outline: $ui-button-icon-focus-outline;
} }
&--dangerous {
background-color: var(--error-background-color);
color: var(--on-error-color);
&:active,
&:focus,
&:hover {
background-color: var(--error-active-background-color);
transition: none;
}
}
&--destructive { &--destructive {
&:active, &:active,
&:focus, &:focus,
@ -6237,6 +6249,14 @@ a.status-card {
display: flex; display: flex;
gap: 16px; gap: 16px;
align-items: center; align-items: center;
strong {
font-weight: 700;
}
}
&--deemphasized {
color: $secondary-text-color;
} }
&__icon { &__icon {

View File

@ -111,4 +111,7 @@ $font-monospace: 'mastodon-font-monospace' !default;
--surface-variant-active-background-color: #{lighten($ui-base-color, 4%)}; --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
--on-surface-color: #{transparentize($ui-base-color, 0.5)}; --on-surface-color: #{transparentize($ui-base-color, 0.5)};
--avatar-border-radius: 8px; --avatar-border-radius: 8px;
--error-background-color: #{darken($error-red, 16%)};
--error-active-background-color: #{darken($error-red, 12%)};
--on-error-color: #fff;
} }

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class DomainBlockPreviewPresenter < ActiveModelSerializers::Model
attributes :followers_count, :following_count
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::DomainBlockPreviewSerializer < ActiveModel::Serializer
attributes :following_count, :followers_count
end

View File

@ -125,6 +125,10 @@ namespace :api, format: false do
get :search, to: 'search#index' get :search, to: 'search#index'
end end
namespace :domain_blocks do
resource :preview, only: [:show]
end
resource :domain_blocks, only: [:show, :create, :destroy] resource :domain_blocks, only: [:show, :create, :destroy]
resource :directory, only: [:show] resource :directory, only: [:show]