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)