Merge branch 'next-onboarding' into 'next'

Next: onboarding flow

See merge request soapbox-pub/soapbox-fe!1217
This commit is contained in:
Alex Gleason 2022-04-20 17:53:02 +00:00
commit 074a1a6fce
23 changed files with 864 additions and 27 deletions

View File

@ -0,0 +1,24 @@
import { getSettings } from 'soapbox/actions/settings';
import { createTestStore, rootState } from 'soapbox/jest/test-helpers';
import { ONBOARDING_VERSION, endOnboarding } from '../onboarding';
describe('endOnboarding()', () => {
it('updates the onboardingVersion setting', async() => {
const store = createTestStore(rootState);
// Sanity check:
// `onboardingVersion` should be `0` by default
const initialVersion = getSettings(store.getState()).get('onboardingVersion');
expect(initialVersion).toBe(0);
await store.dispatch(endOnboarding());
// After dispatching, `onboardingVersion` is updated
const updatedVersion = getSettings(store.getState()).get('onboardingVersion');
expect(updatedVersion).toBe(ONBOARDING_VERSION);
// Sanity check: `updatedVersion` is greater than `initialVersion`
expect(updatedVersion > initialVersion).toBe(true);
});
});

View File

@ -1,8 +0,0 @@
import { changeSetting, saveSettings } from './settings';
export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings());
};

View File

@ -0,0 +1,13 @@
import { changeSettingImmediate } from 'soapbox/actions/settings';
/** Repeat the onboading process when we bump the version */
export const ONBOARDING_VERSION = 1;
/** Finish onboarding and store the setting */
const endOnboarding = () => (dispatch: React.Dispatch<any>) => {
dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION));
};
export {
endOnboarding,
};

View File

@ -20,7 +20,7 @@ const messages = defineMessages({
}); });
export const defaultSettings = ImmutableMap({ export const defaultSettings = ImmutableMap({
onboarded: false, onboardingVersion: 0,
skinTone: 1, skinTone: 1,
reduceMotion: false, reduceMotion: false,
underlineLinks: false, underlineLinks: false,

View File

@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' | 'link'
type ButtonSizes = 'sm' | 'md' | 'lg' type ButtonSizes = 'sm' | 'md' | 'lg'
type IButtonStyles = { type IButtonStyles = {
@ -25,6 +25,7 @@ const useButtonStyles = ({
accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2', accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2',
danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2', danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700',
}; };
const sizes = { const sizes = {

View File

@ -2,7 +2,7 @@ import React from 'react';
import SvgIcon from './svg-icon'; import SvgIcon from './svg-icon';
interface IIcon { interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
className?: string, className?: string,
count?: number, count?: number,
alt?: string, alt?: string,

View File

@ -11,7 +11,7 @@ const messages = defineMessages({
hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' },
}); });
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> { interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> {
autoFocus?: boolean, autoFocus?: boolean,
defaultValue?: string, defaultValue?: string,
className?: string, className?: string,

View File

@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10
const spaces = { const spaces = {
'0.5': 'space-y-0.5', '0.5': 'space-y-0.5',
@ -11,6 +11,7 @@ const spaces = {
3: 'space-y-3', 3: 'space-y-3',
4: 'space-y-4', 4: 'space-y-4',
5: 'space-y-5', 5: 'space-y-5',
10: 'space-y-10',
}; };
const justifyContentOptions = { const justifyContentOptions = {

View File

@ -6,6 +6,7 @@ type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
type Alignments = 'left' | 'center' | 'right' type Alignments = 'left' | 'center' | 'right'
type TrackingSizes = 'normal' | 'wide' type TrackingSizes = 'normal' | 'wide'
type TransformProperties = 'uppercase' | 'normal'
type Families = 'sans' | 'mono' type Families = 'sans' | 'mono'
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
@ -48,6 +49,11 @@ const trackingSizes = {
wide: 'tracking-wide', wide: 'tracking-wide',
}; };
const transformProperties = {
normal: 'normal-case',
uppercase: 'uppercase',
};
const families = { const families = {
sans: 'font-sans', sans: 'font-sans',
mono: 'font-mono', mono: 'font-mono',
@ -62,6 +68,7 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
tag?: Tags, tag?: Tags,
theme?: Themes, theme?: Themes,
tracking?: TrackingSizes, tracking?: TrackingSizes,
transform?: TransformProperties,
truncate?: boolean, truncate?: boolean,
weight?: Weights weight?: Weights
} }
@ -76,6 +83,7 @@ const Text: React.FC<IText> = React.forwardRef(
tag = 'p', tag = 'p',
theme = 'default', theme = 'default',
tracking = 'normal', tracking = 'normal',
transform = 'normal',
truncate = false, truncate = false,
weight = 'normal', weight = 'normal',
...filteredProps ...filteredProps
@ -99,6 +107,7 @@ const Text: React.FC<IText> = React.forwardRef(
[trackingSizes[tracking]]: true, [trackingSizes[tracking]]: true,
[families[family]]: true, [families[family]]: true,
[alignmentClass]: typeof align !== 'undefined', [alignmentClass]: typeof align !== 'undefined',
[transformProperties[transform]]: typeof transform !== 'undefined',
}, className)} }, className)}
/> />
); );

View File

@ -1,14 +1,13 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
interface ITextarea { interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'required'> {
autoFocus?: boolean, autoFocus?: boolean,
defaultValue?: string, defaultValue?: string,
name?: string, name?: string,
isCodeEditor?: boolean, isCodeEditor?: boolean,
placeholder?: string, placeholder?: string,
value?: string, value?: string,
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void
} }
const Textarea = React.forwardRef( const Textarea = React.forwardRef(

View File

@ -19,7 +19,9 @@ import { FE_SUBDIRECTORY } from 'soapbox/build_config';
import { NODE_ENV } from 'soapbox/build_config'; import { NODE_ENV } from 'soapbox/build_config';
import Helmet from 'soapbox/components/helmet'; import Helmet from 'soapbox/components/helmet';
import AuthLayout from 'soapbox/features/auth_layout'; import AuthLayout from 'soapbox/features/auth_layout';
import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard';
import PublicLayout from 'soapbox/features/public_layout'; import PublicLayout from 'soapbox/features/public_layout';
import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container';
import WaitlistPage from 'soapbox/features/verification/waitlist_page'; import WaitlistPage from 'soapbox/features/verification/waitlist_page';
import { createGlobals } from 'soapbox/globals'; import { createGlobals } from 'soapbox/globals';
import messages from 'soapbox/locales/messages'; import messages from 'soapbox/locales/messages';
@ -28,10 +30,9 @@ import { getFeatures } from 'soapbox/utils/features';
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
import { generateThemeCss } from 'soapbox/utils/theme'; import { generateThemeCss } from 'soapbox/utils/theme';
import { INTRODUCTION_VERSION } from '../actions/onboarding'; import { ONBOARDING_VERSION } from '../actions/onboarding';
import { preload } from '../actions/preload'; import { preload } from '../actions/preload';
import ErrorBoundary from '../components/error_boundary'; import ErrorBoundary from '../components/error_boundary';
// import Introduction from '../features/introduction';
import UI from '../features/ui'; import UI from '../features/ui';
import { store } from '../store'; import { store } from '../store';
@ -71,15 +72,14 @@ const makeAccount = makeGetAccount();
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const me = state.get('me'); const me = state.get('me');
const account = makeAccount(state, me); const account = makeAccount(state, me);
const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false;
const settings = getSettings(state); const settings = getSettings(state);
const needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION;
const soapboxConfig = getSoapboxConfig(state); const soapboxConfig = getSoapboxConfig(state);
const locale = settings.get('locale'); const locale = settings.get('locale');
const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile'); const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile');
return { return {
showIntroduction,
me, me,
account, account,
reduceMotion: settings.get('reduceMotion'), reduceMotion: settings.get('reduceMotion'),
@ -93,6 +93,7 @@ const mapStateToProps = (state) => {
appleAppId: soapboxConfig.get('appleAppId'), appleAppId: soapboxConfig.get('appleAppId'),
themeMode: settings.get('themeMode'), themeMode: settings.get('themeMode'),
singleUserMode, singleUserMode,
needsOnboarding,
}; };
}; };
@ -100,12 +101,12 @@ const mapStateToProps = (state) => {
class SoapboxMount extends React.PureComponent { class SoapboxMount extends React.PureComponent {
static propTypes = { static propTypes = {
showIntroduction: PropTypes.bool,
me: SoapboxPropTypes.me, me: SoapboxPropTypes.me,
account: ImmutablePropTypes.record, account: ImmutablePropTypes.record,
reduceMotion: PropTypes.bool, reduceMotion: PropTypes.bool,
underlineLinks: PropTypes.bool, underlineLinks: PropTypes.bool,
systemFont: PropTypes.bool, systemFont: PropTypes.bool,
needsOnboarding: PropTypes.bool,
dyslexicFont: PropTypes.bool, dyslexicFont: PropTypes.bool,
demetricator: PropTypes.bool, demetricator: PropTypes.bool,
locale: PropTypes.string.isRequired, locale: PropTypes.string.isRequired,
@ -154,7 +155,7 @@ class SoapboxMount extends React.PureComponent {
} }
render() { render() {
const { me, account, themeCss, locale, singleUserMode } = this.props; const { me, account, themeCss, locale, needsOnboarding, singleUserMode } = this.props;
if (me === null) return null; if (me === null) return null;
if (me && !account) return null; if (me && !account) return null;
if (!this.state.isLoaded) return null; if (!this.state.isLoaded) return null;
@ -162,6 +163,26 @@ class SoapboxMount extends React.PureComponent {
const waitlisted = account && !account.getIn(['source', 'approved'], true); const waitlisted = account && !account.getIn(['source', 'approved'], true);
if (account && !waitlisted && needsOnboarding) {
return (
<IntlProvider locale={locale} messages={this.state.messages}>
<Helmet>
<html lang='en' className={classNames({ dark: this.props.themeMode === 'dark' })} />
<body className={bodyClass} />
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
<meta name='theme-color' content={this.props.brandColor} />
</Helmet>
<ErrorBoundary>
<BrowserRouter basename={FE_SUBDIRECTORY}>
<OnboardingWizard />
<NotificationsContainer />
</BrowserRouter>
</ErrorBoundary>
</IntlProvider>
);
}
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', { const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', {
'no-reduce-motion': !this.props.reduceMotion, 'no-reduce-motion': !this.props.reduceMotion,
'underline-links': this.props.underlineLinks, 'underline-links': this.props.underlineLinks,

View File

@ -0,0 +1,111 @@
import classNames from 'classnames';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'soapbox/actions/onboarding';
import { HStack } from 'soapbox/components/ui';
import AvatarSelectionStep from './steps/avatar-selection-step';
import BioStep from './steps/bio-step';
import CompletedStep from './steps/completed-step';
import CoverPhotoSelectionStep from './steps/cover-photo-selection-step';
import DisplayNameStep from './steps/display-name-step';
import SuggestedAccountsStep from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
const dispatch = useDispatch();
const [currentStep, setCurrentStep] = React.useState<number>(0);
const handleSwipe = (nextStep: number) => {
setCurrentStep(nextStep);
};
const handlePreviousStep = () => {
setCurrentStep((prevStep) => Math.max(0, prevStep - 1));
};
const handleNextStep = () => {
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length - 1));
};
const handleComplete = () => {
dispatch(endOnboarding());
};
const steps = [
<AvatarSelectionStep onNext={handleNextStep} />,
<DisplayNameStep onNext={handleNextStep} />,
<BioStep onNext={handleNextStep} />,
<CoverPhotoSelectionStep onNext={handleNextStep} />,
<SuggestedAccountsStep onNext={handleNextStep} />,
<CompletedStep onComplete={handleComplete} />,
];
const handleKeyUp = ({ key }: KeyboardEvent): void => {
switch (key) {
case 'ArrowLeft':
handlePreviousStep();
break;
case 'ArrowRight':
handleNextStep();
break;
}
};
const handleDotClick = (nextStep: number) => {
setCurrentStep(nextStep);
};
React.useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, []);
return (
<div>
<div className='fixed h-screen w-full bg-gradient-to-tr from-primary-50 via-white to-cyan-50' />
<main className='h-screen flex flex-col'>
<div className='flex flex-col justify-center items-center h-full'>
<ReactSwipeableViews animateHeight index={currentStep} onChangeIndex={handleSwipe}>
{steps.map((step, i) => (
<div key={i} className='py-6 sm:mx-auto w-full sm:max-w-lg md:max-w-2xl'>
<div
className={classNames({
'transition-opacity ease-linear': true,
'opacity-0 duration-500': currentStep !== i,
'opacity-100 duration-75': currentStep === i,
})}
>
{step}
</div>
</div>
))}
</ReactSwipeableViews>
<HStack space={3} alignItems='center' justifyContent='center' className='relative'>
{steps.map((_, i) => (
<button
key={i}
tabIndex={0}
onClick={() => handleDotClick(i)}
className={classNames({
'w-5 h-5 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
'bg-gray-200 hover:bg-gray-300': i !== currentStep,
'bg-primary-600': i === currentStep,
})}
/>
))}
</HStack>
</div>
</main>
</div>
);
};
export default OnboardingWizard;

View File

@ -0,0 +1,137 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize_image';
/** Default avatar filenames from various backends */
const DEFAULT_AVATARS = [
'/avatars/original/missing.png', // Mastodon
'/images/avi.png', // Pleroma
];
/** Check if the avatar is a default avatar */
const isDefaultAvatar = (url: string) => {
return DEFAULT_AVATARS.every(avatar => url.endsWith(avatar));
};
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const isDefault = account ? isDefaultAvatar(account.avatar) : false;
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 400 * 400;
const [rawFile] = event.target.files || [] as any;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.avatar as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('avatar', rawFile);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.avatar.title' defaultMessage='Choose a profile picture' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.avatar.subtitle' defaultMessage='Just have fun with it.' />
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={10}>
<div className='bg-gray-200 rounded-full relative mx-auto'>
{account && (
<Avatar src={selectedFile || account.avatar} size={175} />
)}
{isSubmitting && (
<div className='absolute inset-0 rounded-full flex justify-center items-center bg-white/80'>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={classNames({
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-full ring-2 ring-white hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/icons/plus.svg')} className='text-white w-5 h-5' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving...' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
{isDisabled && (
<Button block theme='link' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default AvatarSelectionStep;

View File

@ -0,0 +1,103 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
const BioStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.source.get('note') || '');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid;
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ note: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.note.title' defaultMessage='Write a short bio' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.note.subtitle' defaultMessage='You can always edit this later.' />
</Text>
</Stack>
</div>
<Stack space={5}>
<div className='sm:pt-10 sm:w-2/3 mx-auto'>
<FormGroup
hintText='Max 500 characters'
labelText='Bio'
errors={errors}
>
<Textarea
onChange={(event) => setValue(event.target.value)}
placeholder='Tell the world a little about yourself...'
value={value}
maxLength={500}
/>
</FormGroup>
</div>
<div className='sm:w-2/3 md:w-1/2 mx-auto'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving...' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
<Button block theme='link' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</div>
</Stack>
</div>
</CardBody>
</Card>
);
};
export default BioStep;

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import { FormattedMessage } from'react-intl';
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';
const CompletedStep = ({ onComplete }: { onComplete: () => void }) => (
<Card variant='rounded' size='xl'>
<CardBody>
<Stack space={2}>
<Icon strokeWidth={1} src={require('@tabler/icons/icons/confetti.svg')} className='w-16 h-16 mx-auto text-primary-600' />
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.finished.title' defaultMessage='Onboarding complete' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage
id='onboarding.finished.message'
defaultMessage='We are very excited to welcome you to our community! Tap the button below to get started.'
/>
</Text>
</Stack>
<div className='pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onComplete}
>
<FormattedMessage id='onboarding.view_feed' defaultMessage='View Feed' />
</Button>
</Stack>
</div>
</CardBody>
</Card>
);
export default CompletedStep;

View File

@ -0,0 +1,155 @@
import { AxiosError } from 'axios';
import classNames from 'classnames';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import StillImage from 'soapbox/components/still_image';
import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import resizeImage from 'soapbox/utils/resize_image';
/** Default header filenames from various backends */
const DEFAULT_HEADERS = [
'/headers/original/missing.png', // Mastodon
'/images/banner.png', // Pleroma
];
/** Check if the avatar is a default avatar */
const isDefaultHeader = (url: string) => {
return DEFAULT_HEADERS.every(header => url.endsWith(header));
};
const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const fileInput = React.useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = React.useState<string | null>();
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [isDisabled, setDisabled] = React.useState<boolean>(true);
const isDefault = account ? isDefaultHeader(account.header) : false;
const openFilePicker = () => {
fileInput.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 1920 * 1080;
const [rawFile] = event.target.files || [] as any;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.header as string;
setSelectedFile(url);
setSubmitting(true);
const formData = new FormData();
formData.append('header', file);
const credentials = dispatch(patchMe(formData));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
}).catch(console.error);
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.header.title' defaultMessage='Pick a cover image' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.header.subtitle' defaultMessage='This will be shown at the top of your profile.' />
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={10}>
<div className='border border-solid border-gray-200 rounded-lg'>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div
role='button'
className='relative h-24 bg-primary-100 rounded-t-md flex items-center justify-center'
>
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt='Profile Header'
className='absolute inset-0 object-cover rounded-t-md'
/>
)}
{isSubmitting && (
<div
className='absolute inset-0 rounded-t-md flex justify-center items-center bg-white/80'
>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={classNames({
'absolute -top-3 -right-3 p-1 bg-primary-600 rounded-full ring-2 ring-white hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@tabler/icons/icons/plus.svg')} className='text-white w-5 h-5' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<div className='flex flex-col px-4 pb-4'>
{account && (
<Avatar src={account.avatar} size={64} className='ring-2 ring-white -mt-8 mb-2' />
)}
<Text weight='bold' size='sm'>{account?.display_name}</Text>
<Text theme='muted' size='sm'>@{account?.username}</Text>
</div>
</div>
<Stack justifyContent='center' space={2}>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
{isDisabled && (
<Button block theme='link' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default CoverPhotoSelectionStep;

View File

@ -0,0 +1,105 @@
import { AxiosError } from 'axios';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { patchMe } from 'soapbox/actions/me';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Card, CardBody, FormGroup, Input, Stack, Text } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const account = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.display_name || '');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid || value.length > 30;
const hintText = React.useMemo(() => {
const charsLeft = 30 - value.length;
const suffix = charsLeft === 1 ? 'character remaining' : 'characters remaining';
return `${charsLeft} ${suffix}`;
}, [value]);
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ display_name: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
} else {
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
}
});
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.display_name.title' defaultMessage='Choose a display name' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.display_name.subtitle' defaultMessage='You can always edit this later.' />
</Text>
</Stack>
</div>
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
<Stack space={5}>
<FormGroup
hintText={hintText}
labelText='Display name'
errors={errors}
>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder='Eg. John Smith'
type='text'
value={value}
maxLength={30}
/>
</FormGroup>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? 'Saving...' : 'Next'}
</Button>
<Button block theme='link' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default DisplayNameStep;

View File

@ -0,0 +1,99 @@
import { Map as ImmutableMap } from 'immutable';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
import { fetchSuggestions } from '../../../actions/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useDispatch();
const suggestions = useAppSelector((state) => state.suggestions.get('items'));
const suggestionsToRender = suggestions.slice(0, 5);
React.useEffect(() => {
dispatch(fetchSuggestions());
}, []);
const renderSuggestions = () => {
return (
<div className='sm:pt-4 sm:pb-10 flex flex-col divide-y divide-solid divide-gray-200'>
{suggestionsToRender.map((suggestion: ImmutableMap<string, any>) => (
<div key={suggestion.get('account')} className='py-2'>
<AccountContainer
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.get('account')}
showProfileHoverCard={false}
/>
</div>
))}
</div>
);
};
const renderEmpty = () => {
return (
<div className='bg-primary-50 dark:bg-slate-700 my-2 rounded-lg text-center p-8'>
<Text>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</Text>
</div>
);
};
const renderBody = () => {
if (suggestionsToRender.isEmpty()) {
return renderEmpty();
} else {
return renderSuggestions();
}
};
return (
<Card variant='rounded' size='xl'>
<CardBody>
<div>
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
<Stack space={2}>
<Text size='2xl' align='center' weight='bold'>
<FormattedMessage id='onboarding.suggestions.title' defaultMessage='Suggested accounts' />
</Text>
<Text theme='muted' align='center'>
<FormattedMessage id='onboarding.suggestions.subtitle' defaultMessage='Here are a few of the most popular accounts you might like.' />
</Text>
</Stack>
</div>
{renderBody()}
<div className='sm:w-2/3 md:w-1/2 mx-auto'>
<Stack>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onNext}
>
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
</Button>
<Button block theme='link' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</Stack>
</div>
</div>
</CardBody>
</Card>
);
};
export default SuggestedAccountsStep;

View File

@ -1,6 +1,7 @@
import { AxiosError } from 'axios';
import * as React from 'react'; import * as React from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth'; import { logIn, verifyCredentials } from 'soapbox/actions/auth';
@ -8,6 +9,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { createAccount } from 'soapbox/actions/verification'; import { createAccount } from 'soapbox/actions/verification';
import { removeStoredVerification } from 'soapbox/actions/verification'; import { removeStoredVerification } from 'soapbox/actions/verification';
import { useAppSelector } from 'soapbox/hooks';
import { Button, Form, FormGroup, Input } from '../../components/ui'; import { Button, Form, FormGroup, Input } from '../../components/ui';
@ -20,11 +22,11 @@ const Registration = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl(); const intl = useIntl();
const isLoading = useSelector((state) => state.verification.get('isLoading')); const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean);
const siteTitle = useSelector((state) => state.instance.title); const siteTitle = useAppSelector((state) => state.instance.title);
const [state, setState] = React.useState(initialState); const [state, setState] = React.useState(initialState);
const [shouldRedirect, setShouldRedirect] = React.useState(false); const [shouldRedirect, setShouldRedirect] = React.useState<boolean>(false);
const { username, password } = state; const { username, password } = state;
const handleSubmit = React.useCallback((event) => { const handleSubmit = React.useCallback((event) => {
@ -33,7 +35,7 @@ const Registration = () => {
// TODO: handle validation errors from Pepe // TODO: handle validation errors from Pepe
dispatch(createAccount(username, password)) dispatch(createAccount(username, password))
.then(() => dispatch(logIn(intl, username, password))) .then(() => dispatch(logIn(intl, username, password)))
.then(({ access_token }) => dispatch(verifyCredentials(access_token))) .then(({ access_token }: any) => dispatch(verifyCredentials(access_token)))
.then(() => dispatch(fetchInstance())) .then(() => dispatch(fetchInstance()))
.then(() => { .then(() => {
setShouldRedirect(true); setShouldRedirect(true);
@ -47,7 +49,7 @@ const Registration = () => {
), ),
); );
}) })
.catch((error) => { .catch((error: AxiosError) => {
if (error?.response?.status === 422) { if (error?.response?.status === 422) {
dispatch( dispatch(
snackbar.error( snackbar.error(

View File

@ -63,6 +63,23 @@ const customRender = (
...options, ...options,
}); });
const mockWindowProperty = (property: any, value: any) => {
const { [property]: originalProperty } = window;
delete window[property];
beforeAll(() => {
Object.defineProperty(window, property, {
configurable: true,
writable: true,
value,
});
});
afterAll(() => {
window[property] = originalProperty;
});
};
export * from '@testing-library/react'; export * from '@testing-library/react';
export { export {
customRender as render, customRender as render,
@ -70,5 +87,6 @@ export {
applyActions, applyActions,
rootState, rootState,
rootReducer, rootReducer,
mockWindowProperty,
createTestStore, createTestStore,
}; };

View File

@ -79,6 +79,7 @@
"@types/react-helmet": "^6.1.5", "@types/react-helmet": "^6.1.5",
"@types/react-motion": "^0.0.32", "@types/react-motion": "^0.0.32",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-swipeable-views": "^0.13.1",
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/redux-mock-store": "^1.0.3", "@types/redux-mock-store": "^1.0.3",
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",

View File

@ -2229,6 +2229,13 @@
"@types/history" "^4.7.11" "@types/history" "^4.7.11"
"@types/react" "*" "@types/react" "*"
"@types/react-swipeable-views@^0.13.1":
version "0.13.1"
resolved "https://registry.yarnpkg.com/@types/react-swipeable-views/-/react-swipeable-views-0.13.1.tgz#381c8513deef5426623aa851033ff4f4831ae15c"
integrity sha512-Nuvywkv9CkwcUgItOCBszkc/pc8YSdiKV5E1AzOJ/p32Db50LgwhJFi5b1ANPgyWxB0Q5yn69aMURHyGi3MLyg==
dependencies:
"@types/react" "*"
"@types/react-toggle@^4.0.3": "@types/react-toggle@^4.0.3":
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/react-toggle/-/react-toggle-4.0.3.tgz#8db98ac8d2c5e8c03c2d3a42027555c1cd2289da" resolved "https://registry.yarnpkg.com/@types/react-toggle/-/react-toggle-4.0.3.tgz#8db98ac8d2c5e8c03c2d3a42027555c1cd2289da"