From 93dcca7f12456f446f1e946602ec931fc0c3e48f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 1 Apr 2026 16:24:28 +0200 Subject: [PATCH] Add email subscriptions to profiles in web UI (#38487) --- .../email_subscriptions_controller.rb | 2 + app/javascript/mastodon/api/accounts.ts | 3 + app/javascript/mastodon/api_types/accounts.ts | 1 + app/javascript/mastodon/api_types/errors.ts | 22 ++ .../components/account_header.tsx | 6 + .../components/account_subscription_form.tsx | 207 ++++++++++++++++++ .../components/redesign.module.scss | 62 ++++++ app/javascript/mastodon/locales/en.json | 9 + app/javascript/mastodon/models/account.ts | 1 + stylelint.config.js | 7 + 10 files changed, 320 insertions(+) create mode 100644 app/javascript/mastodon/api_types/errors.ts create mode 100644 app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx diff --git a/app/controllers/api/v1/accounts/email_subscriptions_controller.rb b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb index dcdd41f6db..4e773f902b 100644 --- a/app/controllers/api/v1/accounts/email_subscriptions_controller.rb +++ b/app/controllers/api/v1/accounts/email_subscriptions_controller.rb @@ -8,6 +8,8 @@ class Api::V1::Accounts::EmailSubscriptionsController < Api::BaseController def create @account.email_subscriptions.create!(email: params[:email], locale: I18n.locale) render_empty + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e).as_json, status: 422 end private diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index fc6e38fbc8..2229d17c56 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -75,3 +75,6 @@ export const apiDeleteProfileAvatar = () => export const apiDeleteProfileHeader = () => apiRequestDelete('v1/profile/header'); + +export const apiSubscribeByEmail = (id: string, email: string) => + apiRequestPost(`v1/accounts/${id}/email_subscriptions`, { email }); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 351f3245cc..0a5e847e8e 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -68,6 +68,7 @@ export interface BaseApiAccountJSON { limited?: boolean; memorial?: boolean; hide_collections: boolean; + email_subscriptions?: boolean; } // See app/serializers/rest/muted_account_serializer.rb diff --git a/app/javascript/mastodon/api_types/errors.ts b/app/javascript/mastodon/api_types/errors.ts new file mode 100644 index 0000000000..46f8e0b8cd --- /dev/null +++ b/app/javascript/mastodon/api_types/errors.ts @@ -0,0 +1,22 @@ +export type ErrorToken = + | 'ERR_TAKEN' + | 'ERR_INVALID' + | 'ERR_BLOCKED' + | 'ERR_RESERVED' + | 'ERR_TOO_MANY' + | 'ERR_MALFORMED' + | 'ERR_UNUSABLE' + | 'ERR_TOO_SOON' + | 'ERR_BELOW_LIMIT' + | 'ERR_UNREACHABLE' + | 'ERR_ELEVATED'; + +export interface ValidationError { + error: ErrorToken; + description: string; +} + +export interface ValidationErrorResponse { + error: string; + details: Record; +} diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 6a9d51f737..4f4c1663e8 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -23,6 +23,7 @@ import { useAppSelector, useAppDispatch } from '@/mastodon/store'; import { isRedesignEnabled } from '../common'; import { AccountName } from './account_name'; +import { AccountSubscriptionForm } from './account_subscription_form'; import { AccountBadges } from './badges'; import { AccountButtons } from './buttons'; import { FamiliarFollowers } from './familiar_followers'; @@ -218,9 +219,14 @@ export const AccountHeader: React.FC<{ isRedesign && redesignClasses.bio, )} /> + + {!me && account.email_subscriptions && ( + + )} + )} diff --git a/app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx b/app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx new file mode 100644 index 0000000000..d183aa1cb3 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/account_subscription_form.tsx @@ -0,0 +1,207 @@ +import { useState, useCallback, useId } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { AxiosError } from 'axios'; + +import { apiSubscribeByEmail } from 'mastodon/api/accounts'; +import type { + ValidationErrorResponse, + ValidationError, +} from 'mastodon/api_types/errors'; +import { A11yLiveRegion } from 'mastodon/components/a11y_live_region'; +import { Button } from 'mastodon/components/button'; +import { CalloutInline } from 'mastodon/components/callout_inline'; +import { DisplayName } from 'mastodon/components/display_name'; +import type { FieldStatus } from 'mastodon/components/form_fields'; +import formFieldClasses from 'mastodon/components/form_fields/form_field_wrapper.module.scss'; +import { TextInput } from 'mastodon/components/form_fields/text_input_field'; +import { useAppSelector } from 'mastodon/store'; + +import classes from './redesign.module.scss'; + +const messages = defineMessages({ + emailInvalid: { + id: 'email_subscriptions.validation.email.invalid', + defaultMessage: 'Invalid email address', + }, + emailBlocked: { + id: 'email_subscriptions.validation.email.blocked', + defaultMessage: 'Blocked email provider', + }, + email: { + id: 'email_subscriptions.email', + defaultMessage: 'Email address', + }, +}); + +const isValidationErrorResponse = ( + data: unknown, +): data is ValidationErrorResponse => + typeof data === 'object' && + data !== null && + 'error' in data && + 'details' in data; + +const fieldStatusFromErrors = ( + intl: IntlShape, + errors: ValidationError[], +): FieldStatus | undefined => { + const error = errors[0]; + + if (!error) { + return undefined; + } + + let message: string; + + switch (error.error) { + case 'ERR_BLOCKED': + message = intl.formatMessage(messages.emailBlocked); + break; + case 'ERR_INVALID': + default: + message = intl.formatMessage(messages.emailInvalid); + break; + } + + return { variant: 'error', message }; +}; + +export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({ + accountId, +}) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + const intl = useIntl(); + const accessibilityId = useId(); + + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [errors, setErrors] = useState>({}); + + const handleChange = useCallback>( + (e) => { + setEmail(e.target.value); + setErrors({}); + }, + [], + ); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + + if (email.length === 0) { + return; + } + + setSubmitting(true); + + apiSubscribeByEmail(accountId, email) + .then(() => { + setSubmitting(false); + setSubmitted(true); + + return ''; + }) + .catch((err: unknown) => { + setSubmitting(false); + + if (err instanceof AxiosError && err.response) { + const data: unknown = err.response.data; + + if (isValidationErrorResponse(data)) { + if (data.details.email?.some((k) => k.error === 'ERR_TAKEN')) { + setSubmitted(true); + return; + } + + setErrors(data.details); + } + } + }); + }, + [accountId, email], + ); + + if (submitted) { + return ( +
+
+

+ +

+ +
+
+ ); + } + + return ( +
+
+

+ , + }} + /> +

+ +
+ +
+
+ + + + {errors.email && ( + + )} + +
+ + +
+ +
+ {str} }} + /> +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 391a6ac7fe..11446104a3 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -391,3 +391,65 @@ svg.badgeIcon { padding-bottom: 14px; } } + +.bannerBase { + box-sizing: border-box; + padding: 16px; + border-radius: 12px; + background: var(--color-bg-secondary); + display: flex; + flex-direction: column; + gap: 12px; + justify-content: center; + align-items: flex-start; + margin: 16px 0; +} + +.bannerTextAndActions { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + font-weight: 400; + color: var(--color-text-primary); + + h2 { + font-size: 17px; + font-weight: 600; + } +} + +.bannerDisclaimer { + color: var(--color-text-secondary); + font-size: 11px; + + a { + color: inherit; + } +} + +.bannerBaseCentered { + composes: bannerBase; + min-height: 146px; + align-items: center; + + .bannerTextAndActions { + text-align: center; + } +} + +.bannerInputButton { + display: flex; + gap: 8px; + align-self: stretch; + align-items: flex-start; + + & > div { + flex-grow: 1; + } + + input[type='email'] { + padding: 7px 8px; // To align size with button + background: var(--color-bg-primary); + } +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a752034830..19fc3c3efe 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -581,6 +581,15 @@ "domain_pill.your_server": "Your digital home, where all of your posts live. Don’t like this one? Transfer servers at any time and bring your followers, too.", "domain_pill.your_username": "Your unique identifier on this server. It’s possible to find users with the same username on different servers.", "dropdown.empty": "Select an option", + "email_subscriptions.email": "Email address", + "email_subscriptions.form.action": "Subscribe", + "email_subscriptions.form.disclaimer": "You can unsubscribe at any time. For more information, refer to the Privacy Policy.", + "email_subscriptions.form.lead": "Get posts in your inbox without creating a Mastodon account.", + "email_subscriptions.form.title": "Sign up for email updates from {name}", + "email_subscriptions.submitted.lead": "Check your inbox for an email to finish signing up for email updates.", + "email_subscriptions.submitted.title": "One more step", + "email_subscriptions.validation.email.blocked": "Blocked email provider", + "email_subscriptions.validation.email.invalid": "Invalid email address", "embed.instructions": "Embed this post on your website by copying the code below.", "embed.preview": "Here is what it will look like:", "emoji_button.activity": "Activity", diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index 6248d8e97b..f13d1c6831 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -101,6 +101,7 @@ export const accountDefaultValues: AccountShape = { limited: false, moved: null, hide_collections: false, + email_subscriptions: false, // This comes from `ApiMutedAccountJSON`, but we should eventually // store that in a different object. mute_expires_at: null, diff --git a/stylelint.config.js b/stylelint.config.js index 94a1829f6f..10a2f1cd55 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -50,6 +50,13 @@ module.exports = { true, { ignorePseudoClasses: ['global'] }, ], + + 'property-no-unknown': [ + true, + { + ignoreProperties: ['composes'], + }, + ], }, }, ],