Add email subscriptions to profiles in web UI (#38487)
This commit is contained in:
parent
db704180b2
commit
93dcca7f12
@ -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
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
|
||||
22
app/javascript/mastodon/api_types/errors.ts
Normal file
22
app/javascript/mastodon/api_types/errors.ts
Normal 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[]>;
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,6 +50,13 @@ module.exports = {
|
||||
true,
|
||||
{ ignorePseudoClasses: ['global'] },
|
||||
],
|
||||
|
||||
'property-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: ['composes'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user