[Glitch] Add email subscriptions to profiles in web UI
Port 93dcca7f12456f446f1e946602ec931fc0c3e48f to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
f403449c68
commit
c315d27f3c
@ -75,3 +75,6 @@ export const apiDeleteProfileAvatar = () =>
|
|||||||
|
|
||||||
export const apiDeleteProfileHeader = () =>
|
export const apiDeleteProfileHeader = () =>
|
||||||
apiRequestDelete('v1/profile/header');
|
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;
|
limited?: boolean;
|
||||||
memorial?: boolean;
|
memorial?: boolean;
|
||||||
hide_collections: boolean;
|
hide_collections: boolean;
|
||||||
|
email_subscriptions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// See app/serializers/rest/muted_account_serializer.rb
|
// See app/serializers/rest/muted_account_serializer.rb
|
||||||
|
|||||||
22
app/javascript/flavours/glitch/api_types/errors.ts
Normal file
22
app/javascript/flavours/glitch/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[]>;
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import { ActionBar } from '../../account/components/action_bar';
|
|||||||
import { isRedesignEnabled } from '../common';
|
import { isRedesignEnabled } from '../common';
|
||||||
|
|
||||||
import { AccountName } from './account_name';
|
import { AccountName } from './account_name';
|
||||||
|
import { AccountSubscriptionForm } from './account_subscription_form';
|
||||||
import { AccountBadges } from './badges';
|
import { AccountBadges } from './badges';
|
||||||
import { AccountButtons } from './buttons';
|
import { AccountButtons } from './buttons';
|
||||||
import { FamiliarFollowers } from './familiar_followers';
|
import { FamiliarFollowers } from './familiar_followers';
|
||||||
@ -220,6 +221,10 @@ export const AccountHeader: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<AccountHeaderFields accountId={accountId} />
|
<AccountHeaderFields accountId={accountId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!me && account.email_subscriptions && (
|
||||||
|
<AccountSubscriptionForm accountId={accountId} />
|
||||||
|
)}
|
||||||
</div>
|
</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 'flavours/glitch/api/accounts';
|
||||||
|
import type {
|
||||||
|
ValidationErrorResponse,
|
||||||
|
ValidationError,
|
||||||
|
} from 'flavours/glitch/api_types/errors';
|
||||||
|
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
|
||||||
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
|
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
|
||||||
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import type { FieldStatus } from 'flavours/glitch/components/form_fields';
|
||||||
|
import formFieldClasses from 'flavours/glitch/components/form_fields/form_field_wrapper.module.scss';
|
||||||
|
import { TextInput } from 'flavours/glitch/components/form_fields/text_input_field';
|
||||||
|
import { useAppSelector } from 'flavours/glitch/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;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -101,6 +101,7 @@ export const accountDefaultValues: AccountShape = {
|
|||||||
limited: false,
|
limited: false,
|
||||||
moved: null,
|
moved: null,
|
||||||
hide_collections: false,
|
hide_collections: false,
|
||||||
|
email_subscriptions: false,
|
||||||
// This comes from `ApiMutedAccountJSON`, but we should eventually
|
// This comes from `ApiMutedAccountJSON`, but we should eventually
|
||||||
// store that in a different object.
|
// store that in a different object.
|
||||||
mute_expires_at: null,
|
mute_expires_at: null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user