From 3426ea2912d1cd69ebdfa4e43a119dc6e7374c49 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 25 Sep 2024 20:13:36 +0200 Subject: [PATCH] Add preview of followers removed in domain block modal in web UI (#32032) --- .../v1/domain_blocks/previews_controller.rb | 27 +++ app/javascript/mastodon/api.ts | 1 + app/javascript/mastodon/components/button.tsx | 3 + .../features/ui/components/block_modal.jsx | 2 +- .../ui/components/domain_block_modal.jsx | 106 --------- .../ui/components/domain_block_modal.tsx | 204 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 2 +- .../styles/mastodon/components.scss | 20 ++ app/javascript/styles/mastodon/variables.scss | 3 + .../domain_block_preview_presenter.rb | 5 + .../rest/domain_block_preview_serializer.rb | 5 + config/routes/api.rb | 4 + 12 files changed, 274 insertions(+), 108 deletions(-) create mode 100644 app/controllers/api/v1/domain_blocks/previews_controller.rb delete mode 100644 app/javascript/mastodon/features/ui/components/domain_block_modal.jsx create mode 100644 app/javascript/mastodon/features/ui/components/domain_block_modal.tsx create mode 100644 app/presenters/domain_block_preview_presenter.rb create mode 100644 app/serializers/rest/domain_block_preview_serializer.rb diff --git a/app/controllers/api/v1/domain_blocks/previews_controller.rb b/app/controllers/api/v1/domain_blocks/previews_controller.rb new file mode 100644 index 000000000..a917bddd9 --- /dev/null +++ b/app/controllers/api/v1/domain_blocks/previews_controller.rb @@ -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 diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 25bb25547..51cbe0b69 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -70,6 +70,7 @@ export async function apiRequest( args: { params?: RequestParamsOrData; data?: RequestParamsOrData; + timeout?: number; } = {}, ) { const { data } = await api().request({ diff --git a/app/javascript/mastodon/components/button.tsx b/app/javascript/mastodon/components/button.tsx index c76aaea42..3e720f7ce 100644 --- a/app/javascript/mastodon/components/button.tsx +++ b/app/javascript/mastodon/components/button.tsx @@ -7,6 +7,7 @@ interface BaseProps extends Omit, 'children'> { block?: boolean; secondary?: boolean; + dangerous?: boolean; } interface PropsChildren extends PropsWithChildren { @@ -26,6 +27,7 @@ export const Button: React.FC = ({ disabled, block, secondary, + dangerous, className, title, text, @@ -46,6 +48,7 @@ export const Button: React.FC = ({ className={classNames('button', className, { 'button-secondary': secondary, 'button--block': block, + 'button--dangerous': dangerous, })} disabled={disabled} onClick={handleClick} diff --git a/app/javascript/mastodon/features/ui/components/block_modal.jsx b/app/javascript/mastodon/features/ui/components/block_modal.jsx index d6fc6c415..21a984f97 100644 --- a/app/javascript/mastodon/features/ui/components/block_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/block_modal.jsx @@ -99,7 +99,7 @@ export const BlockModal = ({ accountId, acct }) => { - diff --git a/app/javascript/mastodon/features/ui/components/domain_block_modal.jsx b/app/javascript/mastodon/features/ui/components/domain_block_modal.jsx deleted file mode 100644 index 78d5cbb13..000000000 --- a/app/javascript/mastodon/features/ui/components/domain_block_modal.jsx +++ /dev/null @@ -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 ( -
-
-
-
- -
- -
-

-
{domain}
-
-
- -
-
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
- -
-
-
-
-
-
- -
-
- - -
- - - - -
-
-
- ); -}; - -DomainBlockModal.propTypes = { - domain: PropTypes.string.isRequired, - accountId: PropTypes.string.isRequired, - acct: PropTypes.string.isRequired, -}; - -export default DomainBlockModal; diff --git a/app/javascript/mastodon/features/ui/components/domain_block_modal.tsx b/app/javascript/mastodon/features/ui/components/domain_block_modal.tsx new file mode 100644 index 000000000..7e6715990 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/domain_block_modal.tsx @@ -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( + 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('GET', 'v1/domain_blocks/preview', { + params: { domain }, + timeout: 5000, + }) + .then((data) => { + setPreview(data); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, [setPreview, setLoading, domain]); + + return ( +
+
+
+
+ +
+ +
+

+ +

+
{domain}
+
+
+ +
+ {preview && preview.followers_count + preview.following_count > 0 && ( +
+
+ +
+
+ + + ), + followingCount: preview.following_count, + followingCountDisplay: ( + + ), + }} + /> + +
+
+ )} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ + +
+ + + + +
+
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default DomainBlockModal; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d0563bb1b..0dc4ccee8 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -221,7 +221,7 @@ "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.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_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.", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 847c594ae..1d710546c 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -81,6 +81,18 @@ 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 { &:active, &:focus, @@ -6237,6 +6249,14 @@ a.status-card { display: flex; gap: 16px; align-items: center; + + strong { + font-weight: 700; + } + } + + &--deemphasized { + color: $secondary-text-color; } &__icon { diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss index c477e7a75..2601113d3 100644 --- a/app/javascript/styles/mastodon/variables.scss +++ b/app/javascript/styles/mastodon/variables.scss @@ -111,4 +111,7 @@ $font-monospace: 'mastodon-font-monospace' !default; --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)}; --on-surface-color: #{transparentize($ui-base-color, 0.5)}; --avatar-border-radius: 8px; + --error-background-color: #{darken($error-red, 16%)}; + --error-active-background-color: #{darken($error-red, 12%)}; + --on-error-color: #fff; } diff --git a/app/presenters/domain_block_preview_presenter.rb b/app/presenters/domain_block_preview_presenter.rb new file mode 100644 index 000000000..601f76273 --- /dev/null +++ b/app/presenters/domain_block_preview_presenter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class DomainBlockPreviewPresenter < ActiveModelSerializers::Model + attributes :followers_count, :following_count +end diff --git a/app/serializers/rest/domain_block_preview_serializer.rb b/app/serializers/rest/domain_block_preview_serializer.rb new file mode 100644 index 000000000..fea8c2f1e --- /dev/null +++ b/app/serializers/rest/domain_block_preview_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class REST::DomainBlockPreviewSerializer < ActiveModel::Serializer + attributes :following_count, :followers_count +end diff --git a/config/routes/api.rb b/config/routes/api.rb index 93698381c..46907a1ce 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -125,6 +125,10 @@ namespace :api, format: false do get :search, to: 'search#index' end + namespace :domain_blocks do + resource :preview, only: [:show] + end + resource :domain_blocks, only: [:show, :create, :destroy] resource :directory, only: [:show]