diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 1f5a8082982..1e4bb8a0ae9 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -71,6 +71,7 @@ export interface BaseApiAccountJSON { memorial?: boolean; hide_collections: boolean; email_subscriptions?: boolean; + invalid_handle?: boolean; } // See app/serializers/rest/muted_account_serializer.rb diff --git a/app/javascript/mastodon/components/account_header/menu.tsx b/app/javascript/mastodon/components/account_header/menu.tsx index 299ea26d370..8619a1c45fc 100644 --- a/app/javascript/mastodon/components/account_header/menu.tsx +++ b/app/javascript/mastodon/components/account_header/menu.tsx @@ -226,6 +226,10 @@ const redesignMessages = defineMessages({ id: 'account.menu.open_original_page', defaultMessage: 'View on {domain}', }, + openOriginalPageInvalid: { + id: 'account.menu.open_original_page_no_domain', + defaultMessage: 'View on original server', + }, removeFollower: { id: 'account.menu.remove_follower', defaultMessage: 'Remove follower', @@ -270,32 +274,37 @@ function getMenuItems({ // Open on remote page. if (isRemote) { items.push({ - text: intl.formatMessage(redesignMessages.openOriginalPage, { - domain: remoteDomain, - }), + text: account.invalid_handle + ? intl.formatMessage(redesignMessages.openOriginalPageInvalid) + : intl.formatMessage(redesignMessages.openOriginalPage, { + domain: remoteDomain, + }), href: account.url, }); } // Mention and direct message options if (signedIn && !account.suspended) { - items.push( - null, - { - text: intl.formatMessage(redesignMessages.mention), - action: () => { - dispatch(mentionCompose(account)); + if (account.invalid_handle) items.push(null); + else { + items.push( + null, + { + text: intl.formatMessage(redesignMessages.mention), + action: () => { + dispatch(mentionCompose(account)); + }, }, - }, - { - text: intl.formatMessage(redesignMessages.direct), - action: () => { - dispatch(directCompose(account)); + { + text: intl.formatMessage(redesignMessages.direct), + action: () => { + dispatch(directCompose(account)); + }, }, - }, - null, - ); + null, + ); + } } if (!signedIn) { @@ -484,7 +493,7 @@ function getMenuItems({ }); } - if (remoteDomain) { + if (remoteDomain && !account.invalid_handle) { items.push(null, { text: intl.formatMessage( relationship?.domain_blocking @@ -524,6 +533,7 @@ function getMenuItems({ } if ( remoteDomain && + !account.invalid_handle && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION ) { diff --git a/app/javascript/mastodon/components/account_header/name.tsx b/app/javascript/mastodon/components/account_header/name.tsx index 4b6f9d1b44e..77579b544f6 100644 --- a/app/javascript/mastodon/components/account_header/name.tsx +++ b/app/javascript/mastodon/components/account_header/name.tsx @@ -62,17 +62,84 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => { {relationship?.followed_by && } - + {account.invalid_handle ? ( + + ) : ( + + )} ); }; +const InvalidAccountHelp: FC = () => { + const accessibilityId = useId(); + const intl = useIntl(); + const [open, setOpen] = useState(false); + const [triggerElement, setTriggerElement] = + useState(null); + + const handleClick = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + return ( + <> + + + + {({ props }) => ( +
+ + +
+ )} +
+ + ); +}; + const AccountNameHelp: FC<{ username: string; domain: string; diff --git a/app/javascript/mastodon/components/account_header/styles.module.scss b/app/javascript/mastodon/components/account_header/styles.module.scss index 56d57bc81c6..71062bfa8e3 100644 --- a/app/javascript/mastodon/components/account_header/styles.module.scss +++ b/app/javascript/mastodon/components/account_header/styles.module.scss @@ -142,7 +142,8 @@ font-weight: 600; } - > ol { + > ol, + > p { margin: 12px 0; } diff --git a/app/javascript/mastodon/components/display_name/default.tsx b/app/javascript/mastodon/components/display_name/default.tsx index 0f93884d802..552b5303f00 100644 --- a/app/javascript/mastodon/components/display_name/default.tsx +++ b/app/javascript/mastodon/components/display_name/default.tsx @@ -1,26 +1,42 @@ import { useMemo } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + import { Skeleton } from '../skeleton'; import type { DisplayNameProps } from './index'; import { DisplayNameWithoutDomain } from './no-domain'; +const messages = defineMessages({ + invalidHandle: { + id: 'account.hame.invalid_handle', + defaultMessage: 'Handle unavailable', + }, +}); + export function useAccountHandle( account: DisplayNameProps['account'], localDomain: DisplayNameProps['localDomain'], ) { + const intl = useIntl(); + return useMemo(() => { if (!account) { return null; } + + if (account.invalid_handle) + return intl.formatMessage(messages.invalidHandle); + let acct = account.acct; if (!acct.includes('@') && localDomain) { acct = `${acct}@${localDomain}`; } + return `@${acct}`; - }, [account, localDomain]); + }, [account, localDomain, intl]); } export const DisplayNameDefault: FC< diff --git a/app/javascript/mastodon/components/status/action_bar.tsx b/app/javascript/mastodon/components/status/action_bar.tsx index 8c27e68e696..e0060628d16 100644 --- a/app/javascript/mastodon/components/status/action_bar.tsx +++ b/app/javascript/mastodon/components/status/action_bar.tsx @@ -465,23 +465,25 @@ function getMenuItems({ return menu; } - menu.push({ - text: intl.formatMessage(messages.mention, { - name: account.username, - }), - action: () => { - dispatch(mentionCompose(account)); - }, - }); - menu.push({ - text: intl.formatMessage(messages.direct, { - name: account.username, - }), - action: () => { - dispatch(directCompose(account)); - }, - }); - menu.push(null); + if (!account.invalid_handle) { + menu.push({ + text: intl.formatMessage(messages.mention, { + name: account.username, + }), + action: () => { + dispatch(mentionCompose(account)); + }, + }); + menu.push({ + text: intl.formatMessage(messages.direct, { + name: account.username, + }), + action: () => { + dispatch(directCompose(account)); + }, + }); + menu.push(null); + } if (interactions.revokeQuote) { menu.push({ diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 27204fb15e7..b386edaf3dc 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -314,9 +314,11 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); - menu.push(null); + if (!account.get('invalid_handle')) { + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); + menu.push(null); + } if (isQuotingMe) { menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); @@ -342,7 +344,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true }); - if (account.get('acct') !== account.get('username')) { + if (account.get('acct') !== account.get('username') && !account.get('invalid_handle')) { const domain = account.get('acct').split('@')[1]; menu.push(null); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index f4bcabc3727..ecbef2633b1 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -266,8 +266,10 @@ class ActionBar extends PureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); - menu.push(null); + if (!account.get('invalid_handle')) { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + } if (quotedAccountId === me) { menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); @@ -287,7 +289,7 @@ class ActionBar extends PureComponent { menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true }); - if (account.get('acct') !== account.get('username')) { + if (account.get('acct') !== account.get('username') && !account.get('invalid_handle')) { const domain = account.get('acct').split('@')[1]; menu.push(null); @@ -307,6 +309,7 @@ class ActionBar extends PureComponent { } if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { const domain = account.get('acct').split('@')[1]; + menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); } } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 5d244c98c6c..b7fb4c74fc3 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -67,6 +67,7 @@ "account.follows.empty": "This user doesn't follow anyone yet.", "account.follows_you": "Follows you", "account.go_to_profile": "Go to profile", + "account.hame.invalid_handle": "Handle unavailable", "account.hide_reblogs": "Hide boosts from @{name}", "account.in_memoriam": "In Memoriam.", "account.join_modal.day": "Day", @@ -98,6 +99,7 @@ "account.menu.mute": "Mute account", "account.menu.note.description": "Visible only to you", "account.menu.open_original_page": "View on {domain}", + "account.menu.open_original_page_no_domain": "View on original server", "account.menu.remove_follower": "Remove follower", "account.menu.report": "Report account", "account.menu.share": "Share…", @@ -116,6 +118,8 @@ "account.name.help.domain_self": "{domain} is your server that hosts your profile and posts.", "account.name.help.footer": "Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Fediverse apps.", "account.name.help.header": "A handle is like an email address", + "account.name.help.invalid_explanation": "This can happen when a user changes username, and is generally temporary. If this persists, it may be because of an unavailable server or some misconfiguration on their end.", + "account.name.help.invalid_header": "This user's handle is being updated", "account.name.help.username": "{username} is this account’s username on their server. Someone on another server might have the same username.", "account.name.help.username_self": "{username} is your username on this server. Someone on another server might have the same username.", "account.name_info": "What does this mean?", diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index 8a14674ce4a..9556c769177 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -112,6 +112,7 @@ export const accountDefaultValues: AccountShape = { moved: null, hide_collections: false, email_subscriptions: false, + invalid_handle: false, // This comes from `ApiMutedAccountJSON`, but we should eventually // store that in a different object. mute_expires_at: null, diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index da844ab149b..2f53436c805 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -25,7 +25,13 @@ class ResolveAccountService < BaseService # First of all we want to check if we've got the account # record with the URI already, and if so, we can exit early - return if domain_not_allowed?(@domain) || @domain == 'handle.invalid' + return if domain_not_allowed?(@domain) + + # Special-case resolving invalid handles + if @domain == 'handle.invalid' + account = Account.remote.find_by(id: @username) + return account if account.invalidated_username? && account.id.to_s == @username + end @account ||= Account.find_remote(@username, @domain)