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 0000000000..a917bddd98 --- /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 25bb25547c..51cbe0b695 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 c76aaea42f..3e720f7cee 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 d6fc6c4154..21a984f97f 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 78d5cbb130..0000000000 --- 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 0000000000..7e6715990e --- /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 d0563bb1b2..0dc4ccee80 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 847c594ae5..1d710546ca 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 c477e7a750..2601113d32 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 0000000000..601f76273d --- /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 0000000000..fea8c2f1ee --- /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 93698381cb..46907a1ce2 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]