Add email subscriptions to profiles in web UI (#38487)

This commit is contained in:
Eugen Rochko 2026-04-01 16:24:28 +02:00 committed by GitHub
parent db704180b2
commit 93dcca7f12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 0 deletions

View File

@ -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

View File

@ -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 });

View File

@ -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

View File

@ -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<string, ValidationError[]>;
}

View File

@ -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,
)}
/>
<AccountHeaderFields accountId={accountId} />
</div>
{!me && account.email_subscriptions && (
<AccountSubscriptionForm accountId={accountId} />
)}
<AccountNumberFields accountId={accountId} />
</div>
)}

View File

@ -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<Record<string, ValidationError[]>>({});
const handleChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
(e) => {
setEmail(e.target.value);
setErrors({});
},
[],
);
const handleSubmit = useCallback<React.FormEventHandler>(
(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 (
<div className={classes.bannerBaseCentered}>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
/>
</h2>
<FormattedMessage
id='email_subscriptions.submitted.lead'
defaultMessage='Check your inbox for an email to finish signing up for email updates.'
/>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} className={classes.bannerBase} noValidate>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</h2>
<FormattedMessage
id='email_subscriptions.form.lead'
defaultMessage='Get posts in your inbox without creating a Mastodon account.'
/>
</div>
<div className={classes.bannerInputButton}>
<div className={formFieldClasses.wrapper}>
<TextInput
id={`${accessibilityId}-input`}
type='email'
value={email}
onChange={handleChange}
placeholder='name@email.com'
aria-label={intl.formatMessage(messages.email)}
aria-describedby={errors.email ? `${accessibilityId}-status` : ''}
/>
<A11yLiveRegion
className={formFieldClasses.status}
id={`${accessibilityId}-status`}
>
{errors.email && (
<CalloutInline {...fieldStatusFromErrors(intl, errors.email)} />
)}
</A11yLiveRegion>
</div>
<Button type='submit' loading={submitting}>
<FormattedMessage
id='email_subscriptions.form.action'
defaultMessage='Subscribe'
/>
</Button>
</div>
<div className={classes.bannerDisclaimer}>
<FormattedMessage
id='email_subscriptions.form.disclaimer'
defaultMessage='You can unsubscribe at any time. For more information, refer to the <a>Privacy Policy</a>.'
values={{ a: (str) => <Link to='/privacy-policy'>{str}</Link> }}
/>
</div>
</form>
);
};

View File

@ -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);
}
}

View File

@ -581,6 +581,15 @@
"domain_pill.your_server": "Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.",
"domain_pill.your_username": "Your unique identifier on this server. Its 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 <a>Privacy Policy</a>.",
"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",

View File

@ -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,

View File

@ -50,6 +50,13 @@ module.exports = {
true,
{ ignorePseudoClasses: ['global'] },
],
'property-no-unknown': [
true,
{
ignoreProperties: ['composes'],
},
],
},
},
],