remove onboarding wizard

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-10-13 12:49:34 +02:00
parent c695d47a4c
commit 0bf0ab114b
20 changed files with 4 additions and 1138 deletions

View File

@ -21,7 +21,6 @@ import { createAccount } from 'pl-fe/actions/accounts';
import { createApp } from 'pl-fe/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'pl-fe/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'pl-fe/actions/oauth';
import { startOnboarding } from 'pl-fe/actions/onboarding';
import * as BuildConfig from 'pl-fe/build-config';
import { queryClient } from 'pl-fe/queries/client';
import { selectAccount } from 'pl-fe/selectors';
@ -306,7 +305,6 @@ const register = (params: CreateAccountParams) =>
if ('identifier' in response) {
toast.info(response.message);
} else {
dispatch(startOnboarding());
return dispatch(authLoggedIn(response, app));
}
});

View File

@ -1,105 +0,0 @@
import { mockStore, mockWindowProperty, rootState } from 'pl-fe/jest/test-helpers';
import { checkOnboardingStatus, startOnboarding, endOnboarding } from './onboarding';
describe('checkOnboarding()', () => {
let mockGetItem: any;
mockWindowProperty('localStorage', {
getItem: (key: string) => mockGetItem(key),
});
beforeEach(() => {
mockGetItem = vi.fn().mockReturnValue(null);
});
it('does nothing if localStorage item is not set', async() => {
mockGetItem = vi.fn().mockReturnValue(null);
const state = { ...rootState };
state.onboarding.needsOnboarding = false;
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
it('does nothing if localStorage item is invalid', async() => {
mockGetItem = vi.fn().mockReturnValue('invalid');
const state = { ...rootState };
state.onboarding.needsOnboarding = false;
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
it('dispatches the correct action', async() => {
mockGetItem = vi.fn().mockReturnValue('1');
const state = { ...rootState };
state.onboarding.needsOnboarding = false;
const store = mockStore(state);
await store.dispatch(checkOnboardingStatus());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_START' }]);
expect(mockGetItem.mock.calls.length).toBe(1);
});
});
describe('startOnboarding()', () => {
let mockSetItem: any;
mockWindowProperty('localStorage', {
setItem: (key: string, value: string) => mockSetItem(key, value),
});
beforeEach(() => {
mockSetItem = vi.fn();
});
it('dispatches the correct action', async() => {
const state = { ...rootState };
state.onboarding.needsOnboarding = false;
const store = mockStore(state);
await store.dispatch(startOnboarding());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_START' }]);
expect(mockSetItem.mock.calls.length).toBe(1);
});
});
describe('endOnboarding()', () => {
let mockRemoveItem: any;
mockWindowProperty('localStorage', {
removeItem: (key: string) => mockRemoveItem(key),
});
beforeEach(() => {
mockRemoveItem = vi.fn();
});
it('dispatches the correct action', async() => {
const state = { ...rootState };
state.onboarding.needsOnboarding = false;
const store = mockStore(state);
await store.dispatch(endOnboarding());
const actions = store.getActions();
expect(actions).toEqual([{ type: 'ONBOARDING_END' }]);
expect(mockRemoveItem.mock.calls.length).toBe(1);
});
});

View File

@ -1,41 +0,0 @@
const ONBOARDING_START = 'ONBOARDING_START';
const ONBOARDING_END = 'ONBOARDING_END';
const ONBOARDING_LOCAL_STORAGE_KEY = 'plfe:onboarding';
type OnboardingStartAction = {
type: typeof ONBOARDING_START;
}
type OnboardingEndAction = {
type: typeof ONBOARDING_END;
}
type OnboardingActions = OnboardingStartAction | OnboardingEndAction
const checkOnboardingStatus = () => (dispatch: React.Dispatch<OnboardingActions>) => {
const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1';
if (needsOnboarding) {
dispatch({ type: ONBOARDING_START });
}
};
const startOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1');
dispatch({ type: ONBOARDING_START });
};
const endOnboarding = () => (dispatch: React.Dispatch<OnboardingActions>) => {
localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY);
dispatch({ type: ONBOARDING_END });
};
export {
type OnboardingActions,
ONBOARDING_END,
ONBOARDING_START,
checkOnboardingStatus,
endOnboarding,
startOnboarding,
};

View File

@ -1,122 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'pl-fe/actions/onboarding';
import LandingGradient from 'pl-fe/components/landing-gradient';
import HStack from 'pl-fe/components/ui/hstack';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useSettings } from 'pl-fe/hooks/use-settings';
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 FediverseStep from './steps/fediverse-step';
import SuggestedAccountsStep from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
const dispatch = useAppDispatch();
const features = useFeatures();
const { theme } = useSettings();
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} />,
];
if (features.federating){
steps.push(<FediverseStep onNext={handleNextStep} />);
}
steps.push(<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 data-testid='onboarding-wizard'>
{(theme?.backgroundGradient ?? true) && <LandingGradient />}
<main className='flex h-screen flex-col overflow-x-hidden'>
<div className='flex h-full flex-col items-center justify-center'>
<ReactSwipeableViews animateHeight index={currentStep} onChangeIndex={handleSwipe}>
{steps.map((step, i) => (
<div key={i} className='w-full max-w-[100vw] py-6 sm:mx-auto sm:max-w-lg md:max-w-2xl'>
<div
className={clsx({
'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={clsx({
'w-5 h-5 rounded-full focus:ring-primary-600 focus:ring-2 focus:ring-offset-2': true,
'bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-700/75 hover:bg-gray-400': i !== currentStep,
'bg-primary-600': i === currentStep,
})}
/>
))}
</HStack>
</div>
</main>
</div>
);
};
export { OnboardingWizard as default };

View File

@ -1,122 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { patchMe } from 'pl-fe/actions/me';
import { BigCard } from 'pl-fe/components/big-card';
import Avatar from 'pl-fe/components/ui/avatar';
import Button from 'pl-fe/components/ui/button';
import Icon from 'pl-fe/components/ui/icon';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import toast from 'pl-fe/toast';
import { isDefaultAvatar } from 'pl-fe/utils/accounts';
import resizeImage from 'pl-fe/utils/resize-image';
import type { PlfeResponse } from 'pl-fe/api';
const messages = defineMessages({
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const dispatch = useAppDispatch();
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?.item(0);
if (!rawFile) return;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.avatar as string;
setSelectedFile(url);
setSubmitting(true);
const credentials = dispatch(patchMe({ avatar: rawFile }));
return Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: { response: PlfeResponse }) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
toast.error(error.response.json.error.replace('Validation failed: ', ''));
} else {
toast.error(messages.error);
}
});
}).catch(console.error);
};
return (
<BigCard
title={<FormattedMessage id='onboarding.avatar.title' defaultMessage='Choose a profile picture' />}
subtitle={<FormattedMessage id='onboarding.avatar.subtitle' defaultMessage='Just have fun with it.' />}
>
<Stack space={10}>
<div className='relative mx-auto rounded-lg bg-gray-200'>
{account && (
<Avatar src={selectedFile || account.avatar} alt={account.avatar_description} size={175} isCat={account.is_cat} />
)}
{isSubmitting && (
<div className='absolute inset-0 flex items-center justify-center rounded-lg bg-white/80 dark:bg-primary-900/80'>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={clsx({
'absolute bottom-3 right-2 p-1 bg-primary-600 rounded-lg ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@phosphor-icons/core/regular/plus.svg')} className='size-5 text-white' />
</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='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</BigCard>
);
};
export { AvatarSelectionStep as default };

View File

@ -1,97 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'pl-fe/actions/me';
import { BigCard } from 'pl-fe/components/big-card';
import Button from 'pl-fe/components/ui/button';
import FormGroup from 'pl-fe/components/ui/form-group';
import Stack from 'pl-fe/components/ui/stack';
import Textarea from 'pl-fe/components/ui/textarea';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import toast from 'pl-fe/toast';
import type { PlfeResponse } from 'pl-fe/api';
const messages = defineMessages({
bioPlaceholder: { id: 'onboarding.bio.placeholder', defaultMessage: 'Tell the world a little about yourself…' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const BioStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.__meta.source?.note ?? '');
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const handleSubmit = () => {
setSubmitting(true);
const credentials = dispatch(patchMe({ note: value }));
Promise.all([credentials])
.then(() => {
setSubmitting(false);
onNext();
}).catch((error: { response: PlfeResponse }) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.json.error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<BigCard
title={<FormattedMessage id='onboarding.note.title' defaultMessage='Write a short bio' />}
subtitle={<FormattedMessage id='onboarding.note.subtitle' defaultMessage='You can always edit this later.' />}
>
<Stack space={5}>
<div>
<FormGroup
hintText={<FormattedMessage id='onboarding.bio.hint' defaultMessage='Max 500 characters' />}
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
errors={errors}
>
<Textarea
onChange={(event) => setValue(event.target.value)}
placeholder={intl.formatMessage(messages.bioPlaceholder)}
value={value}
maxLength={500}
/>
</FormGroup>
</div>
<div>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
type='submit'
disabled={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</div>
</Stack>
</BigCard>
);
};
export { BioStep as default };

View File

@ -1,43 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Button from 'pl-fe/components/ui/button';
import Card, { CardBody } from 'pl-fe/components/ui/card';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
const CompletedStep = ({ onComplete }: { onComplete: () => void }) => (
<Card variant='rounded' size='xl'>
<CardBody>
<Stack space={2}>
<Icon strokeWidth={1} src={require('@tabler/icons/outline/confetti.svg')} className='mx-auto size-16 text-primary-600 dark:text-primary-400' />
<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='mx-auto pt-10 sm:w-2/3 md:w-1/2'>
<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 { CompletedStep as default };

View File

@ -1,152 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'pl-fe/actions/me';
import { BigCard } from 'pl-fe/components/big-card';
import StillImage from 'pl-fe/components/still-image';
import Avatar from 'pl-fe/components/ui/avatar';
import Button from 'pl-fe/components/ui/button';
import Icon from 'pl-fe/components/ui/icon';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import toast from 'pl-fe/toast';
import { isDefaultHeader } from 'pl-fe/utils/accounts';
import resizeImage from 'pl-fe/utils/resize-image';
import type { PlfeResponse } from 'pl-fe/api';
const messages = defineMessages({
header: { id: 'account.header.alt', defaultMessage: 'Profile header' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
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?.item(0);
if (!rawFile) return;
resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.header as string;
setSelectedFile(url);
setSubmitting(true);
const credentials = dispatch(patchMe({ header: file }));
Promise.all([credentials]).then(() => {
setDisabled(false);
setSubmitting(false);
onNext();
}).catch((error: { response: PlfeResponse }) => {
setSubmitting(false);
setDisabled(false);
setSelectedFile(null);
if (error.response?.status === 422) {
toast.error(error.response.json.error.replace('Validation failed: ', ''));
} else {
toast.error(messages.error);
}
});
}).catch(console.error);
};
return (
<BigCard
title={<FormattedMessage id='onboarding.header.title' defaultMessage='Pick a cover image' />}
subtitle={<FormattedMessage id='onboarding.header.subtitle' defaultMessage='This will be shown at the top of your profile.' />}
>
<Stack space={10}>
<div className='rounded-lg border border-solid border-gray-200 dark:border-gray-800'>
<div
role='button'
className='relative flex h-24 items-center justify-center rounded-t-md bg-gray-200 dark:bg-gray-800'
>
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt={account.header_description || intl.formatMessage(messages.header)}
className='absolute inset-0 rounded-t-md object-cover'
/>
)}
{isSubmitting && (
<div
className='absolute inset-0 flex items-center justify-center rounded-t-md bg-white/80 dark:bg-primary-900/80'
>
<Spinner withText={false} />
</div>
)}
<button
onClick={openFilePicker}
type='button'
className={clsx({
'absolute -top-3 -right-3 p-1 bg-primary-600 rounded-full ring-2 ring-white dark:ring-primary-900 hover:bg-primary-700': true,
'opacity-50 pointer-events-none': isSubmitting,
})}
disabled={isSubmitting}
>
<Icon src={require('@phosphor-icons/core/regular/plus.svg')} className='size-5 text-white' />
</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}
alt={account.avatar_description}
isCat={account.is_cat}
size={64}
className='-mt-8 mb-2 ring-2 ring-white dark:ring-primary-800'
/>
)}
<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 ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
) : (
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
)}
</Button>
{isDisabled && (
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</BigCard>
);
};
export { CoverPhotoSelectionStep as default };

View File

@ -1,105 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'pl-fe/actions/me';
import { BigCard } from 'pl-fe/components/big-card';
import Button from 'pl-fe/components/ui/button';
import FormGroup from 'pl-fe/components/ui/form-group';
import Input from 'pl-fe/components/ui/input';
import Stack from 'pl-fe/components/ui/stack';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import toast from 'pl-fe/toast';
import type { PlfeResponse } from 'pl-fe/api';
const messages = defineMessages({
usernamePlaceholder: { id: 'onboarding.display_name.placeholder', defaultMessage: 'Eg. John Smith' },
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
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: { response: PlfeResponse }) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([error.response.json.error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<BigCard
title={<FormattedMessage id='onboarding.display_name.title' defaultMessage='Choose a display name' />}
subtitle={<FormattedMessage id='onboarding.display_name.subtitle' defaultMessage='You can always edit this later.' />}
>
<Stack space={5}>
<FormGroup
hintText={hintText}
labelText={<FormattedMessage id='onboarding.display_name.label' defaultMessage='Display name' />}
errors={errors}
>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder={intl.formatMessage(messages.usernamePlaceholder)}
type='text'
value={value}
maxLength={30}
/>
</FormGroup>
<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='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</Stack>
</BigCard>
);
};
export { DisplayNameStep as default };

View File

@ -1,93 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Account from 'pl-fe/components/account';
import Button from 'pl-fe/components/ui/button';
import Card, { CardBody } from 'pl-fe/components/ui/card';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useInstance } from 'pl-fe/hooks/use-instance';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
const FediverseStep = ({ onNext }: { onNext: () => void }) => {
const { account } = useOwnAccount();
const instance = useInstance();
return (
<Card variant='rounded' size='xl'>
<CardBody>
<Stack space={2}>
<Icon strokeWidth={1} src={require('@phosphor-icons/core/regular/planet.svg')} className='mx-auto size-16 text-primary-600 dark:text-primary-400' />
<Text size='2xl' weight='bold'>
<FormattedMessage
id='onboarding.fediverse.title'
defaultMessage='{siteTitle} is just one part of the Fediverse'
values={{
siteTitle: instance.title,
}}
/>
</Text>
<Stack space={4}>
<div className='border-b border-solid border-gray-200 pb-2 dark:border-gray-800 sm:pb-5'>
<Stack space={4}>
<Text theme='muted'>
<FormattedMessage
id='onboarding.fediverse.message'
defaultMessage='The Fediverse is a social network made up of thousands of diverse and independently-run social media sites (aka "servers"). You can follow users — and like, repost, and reply to posts — from most other Fediverse servers, because they can communicate with {siteTitle}.'
values={{
siteTitle: instance.title,
}}
/>
</Text>
<Text theme='muted'>
<FormattedMessage
id='onboarding.fediverse.trailer'
defaultMessage='Because it is distributed and anyone can run their own server, the Fediverse is resilient and open. If you choose to join another server or set up your own, you can interact with the same people and continue on the same social graph.'
/>
</Text>
</Stack>
</div>
{account && (
<div className='rounded-lg bg-primary-50 p-4 text-center dark:bg-gray-800'>
<Account account={account} />
</div>
)}
<Text theme='muted'>
<FormattedMessage
id='onboarding.fediverse.its_you'
defaultMessage='This is you! Other people can follow you from other servers by using your full @-handle.'
/>
</Text>
<Text theme='muted'>
<FormattedMessage
id='onboarding.fediverse.other_instances'
defaultMessage='When browsing your timeline, pay attention to the full username after the second @ symbol to know which server a post is from.'
/>
</Text>
</Stack>
</Stack>
<div className='mx-auto pt-10 sm:w-2/3 md:w-1/2'>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onNext}
>
<FormattedMessage id='onboarding.fediverse.next' defaultMessage='Next' />
</Button>
</Stack>
</div>
</CardBody>
</Card>
);
};
export { FediverseStep as default };

View File

@ -1,84 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { BigCard } from 'pl-fe/components/big-card';
import ScrollableList from 'pl-fe/components/scrollable-list';
import Button from 'pl-fe/components/ui/button';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import AccountContainer from 'pl-fe/containers/account-container';
import { useOnboardingSuggestions } from 'pl-fe/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const { data, isFetching } = useOnboardingSuggestions();
const renderSuggestions = () => {
if (!data) {
return null;
}
return (
<div className='flex flex-col sm:pb-10 sm:pt-4'>
<ScrollableList
scrollKey='suggestedAccounts'
isLoading={isFetching}
style={{ height: 320 }}
useWindowScroll={false}
>
{data.map((suggestion) => (
<div key={suggestion.account.id} className='py-2'>
<AccountContainer
id={suggestion.account.id}
showAccountHoverCard={false}
withLinkToProfile={false}
/>
</div>
))}
</ScrollableList>
</div>
);
};
const renderEmpty = () => (
<div className='my-2 rounded-lg bg-primary-50 p-8 text-center dark:bg-gray-800'>
<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 (!data || data.length === 0) {
return renderEmpty();
} else {
return renderSuggestions();
}
};
return (
<BigCard
title={<FormattedMessage id='onboarding.suggestions.title' defaultMessage='Suggested accounts' />}
subtitle={<FormattedMessage id='onboarding.suggestions.subtitle' defaultMessage='Here are a few of the most popular accounts you might like.' />}
>
{renderBody()}
<Stack>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onNext}
>
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
</Button>
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
</Stack>
</Stack>
</BigCard>
);
};
export { SuggestedAccountsStep as default };

View File

@ -134,7 +134,6 @@ export const EventHeader = lazy(() => import('pl-fe/features/event/components/ev
export const LightningAddress = lazy(() => import('pl-fe/features/crypto-donate/components/lightning-address'));
export const MfaForm = lazy(() => import('pl-fe/features/security/mfa-form'));
export const ModalRoot = lazy(() => import('pl-fe/features/ui/components/modal-root'));
export const OnboardingWizard = lazy(() => import('pl-fe/features/onboarding/onboarding-wizard'));
export const AccountHoverCard = lazy(() => import('pl-fe/components/account-hover-card'));
export const StatusHoverCard = lazy(() => import('pl-fe/components/status-hover-card'));
export const Video = lazy(() => import('pl-fe/features/video'));

View File

@ -8,10 +8,8 @@ import { ScrollContext } from 'react-router-scroll-4';
import * as BuildConfig from 'pl-fe/build-config';
import LoadingScreen from 'pl-fe/components/loading-screen';
import SiteErrorBoundary from 'pl-fe/components/site-error-boundary';
import { ModalRoot, OnboardingWizard } from 'pl-fe/features/ui/util/async-components';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { ModalRoot } from 'pl-fe/features/ui/util/async-components';
import { useLoggedIn } from 'pl-fe/hooks/use-logged-in';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { useCachedLocationHandler } from 'pl-fe/utils/redirect';
@ -24,11 +22,8 @@ const PlFeMount = () => {
useCachedLocationHandler();
const { isLoggedIn } = useLoggedIn();
const { account } = useOwnAccount();
const plFeConfig = usePlFeConfig();
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
const showOnboarding = account && needsOnboarding;
const { redirectRootNoLogin, gdpr } = plFeConfig;
// @ts-ignore: I don't actually know what these should be, lol
@ -59,10 +54,7 @@ const PlFeMount = () => {
<Route>
<Suspense fallback={<LoadingScreen />}>
{showOnboarding
? <OnboardingWizard />
: <UI />
}
<UI />
</Suspense>
<Suspense>

View File

@ -6,7 +6,6 @@ import { Provider } from 'react-redux';
import { StatProvider } from 'pl-fe/contexts/stat-context';
import { queryClient } from 'pl-fe/queries/client';
import { checkOnboardingStatus } from '../actions/onboarding';
import { preload } from '../actions/preload';
import { store } from '../store';
@ -17,9 +16,6 @@ import PlFeMount from './pl-fe-mount';
// Preload happens synchronously
store.dispatch(preload() as any);
// This happens synchronously
store.dispatch(checkOnboardingStatus() as any);
/** The root React node of the application. */
const PlFe: React.FC = () => (
<>

View File

@ -33,7 +33,7 @@
"account.follows": "Following",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.follows_you": "Follows you",
"account.header.alt": "Profile header",
"account.header.alt": "Header",
"account.header.description": "Header description",
"account.hide_reblogs": "Hide reposts from @{name}",
"account.instance_favicon": "Visit {domain} timeline",
@ -816,7 +816,6 @@
"empty_column.favourited_statuses": "You don't have any liked posts yet. When you like one, it will show up here.",
"empty_column.favourites": "No one has liked this post yet. When someone does, they will show up here.",
"empty_column.filters": "You haven't created any muted words yet.",
"empty_column.follow_recommendations": "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.",
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
"empty_column.followed_tags": "You haven't followed any hashtag yet.",
"empty_column.group": "There are no posts in this group yet.",
@ -1300,34 +1299,6 @@
"notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}",
"oauth_consumer.tooltip": "Sign in with {provider}",
"oauth_consumers.title": "Other ways to sign in",
"onboarding.avatar.subtitle": "Just have fun with it.",
"onboarding.avatar.title": "Choose a profile picture",
"onboarding.bio.hint": "Max 500 characters",
"onboarding.bio.placeholder": "Tell the world a little about yourself…",
"onboarding.display_name.label": "Display name",
"onboarding.display_name.placeholder": "Eg. John Smith",
"onboarding.display_name.subtitle": "You can always edit this later.",
"onboarding.display_name.title": "Choose a display name",
"onboarding.done": "Done",
"onboarding.error": "An unexpected error occurred. Please try again or skip this step.",
"onboarding.fediverse.its_you": "This is you! Other people can follow you from other servers by using your full @-handle.",
"onboarding.fediverse.message": "The Fediverse is a social network made up of thousands of diverse and independently-run social media sites (aka \"servers\"). You can follow users — and like, repost, and reply to posts — from most other Fediverse servers, because they can communicate with {siteTitle}.",
"onboarding.fediverse.next": "Next",
"onboarding.fediverse.other_instances": "When browsing your timeline, pay attention to the full username after the second @ symbol to know which server a post is from.",
"onboarding.fediverse.title": "{siteTitle} is just one part of the Fediverse",
"onboarding.fediverse.trailer": "Because it is distributed and anyone can run their own server, the Fediverse is resilient and open. If you choose to join another server or set up your own, you can interact with the same people and continue on the same social graph.",
"onboarding.finished.message": "We are very excited to welcome you to our community! Tap the button below to get started.",
"onboarding.finished.title": "Onboarding complete",
"onboarding.header.subtitle": "This will be shown at the top of your profile.",
"onboarding.header.title": "Pick a cover image",
"onboarding.next": "Next",
"onboarding.note.subtitle": "You can always edit this later.",
"onboarding.note.title": "Write a short bio",
"onboarding.saving": "Saving…",
"onboarding.skip": "Skip for now",
"onboarding.suggestions.subtitle": "Here are a few of the most popular accounts you might like.",
"onboarding.suggestions.title": "Suggested accounts",
"onboarding.view_feed": "View feed",
"password_reset.confirmation": "Check your email for confirmation.",
"password_reset.fields.email_placeholder": "E-mail address",
"password_reset.fields.username_placeholder": "Email or username",

View File

@ -1,44 +0,0 @@
import { __stub } from 'pl-fe/api';
import { renderHook, waitFor } from 'pl-fe/jest/test-helpers';
import { useOnboardingSuggestions } from './suggestions';
describe('useOnboardingSuggestions', () => {
describe('with a successful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [
{ source: 'staff', account: { id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' } },
{ source: 'staff', account: { id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' } },
], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data?.length).toBe(2);
});
});
describe('with an unsuccessful query', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions').networkError();
});
});
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.error).toBeDefined();
});
});
});

View File

@ -51,33 +51,4 @@ const useDismissSuggestion = () => {
});
};
const useOnboardingSuggestions = () => {
const client = useClient();
const dispatch = useAppDispatch();
const getSuggestions = async () => {
const response = await client.myAccount.getSuggestions();
const accounts = response.map(({ account }) => account);
const accountIds = accounts.map((account) => account.id);
dispatch(importEntities({ accounts }));
dispatch(fetchRelationships(accountIds));
return response;
};
const result = useQuery({
queryKey: ['suggestions', 'v2'],
queryFn: () => getSuggestions(),
placeholderData: keepPreviousData,
});
const data = result.data;
return {
...result,
data,
};
};
export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion };
export { useSuggestions, useDismissSuggestion };

View File

@ -15,7 +15,6 @@ import instance from './instance';
import me from './me';
import meta from './meta';
import notifications from './notifications';
import onboarding from './onboarding';
import pending_statuses from './pending-statuses';
import plfe from './pl-fe';
import polls from './polls';
@ -37,7 +36,6 @@ const reducers = {
me,
meta,
notifications,
onboarding,
pending_statuses,
plfe,
polls,

View File

@ -1,27 +0,0 @@
import { ONBOARDING_START, ONBOARDING_END } from 'pl-fe/actions/onboarding';
import reducer from './onboarding';
describe('onboarding reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {} as any)).toEqual({
needsOnboarding: false,
});
});
describe('ONBOARDING_START', () => {
it('sets "needsOnboarding" to "true"', () => {
const initialState = { needsOnboarding: false };
const action = { type: ONBOARDING_START } as any;
expect(reducer(initialState, action).needsOnboarding).toEqual(true);
});
});
describe('ONBOARDING_END', () => {
it('sets "needsOnboarding" to "false"', () => {
const initialState = { needsOnboarding: true };
const action = { type: ONBOARDING_END } as any;
expect(reducer(initialState, action).needsOnboarding).toEqual(false);
});
});
});

View File

@ -1,24 +0,0 @@
import { ONBOARDING_START, ONBOARDING_END } from 'pl-fe/actions/onboarding';
import type { OnboardingActions } from 'pl-fe/actions/onboarding';
type OnboardingState = {
needsOnboarding: boolean;
}
const initialState: OnboardingState = {
needsOnboarding: false,
};
const onboarding = (state: OnboardingState = initialState, action: OnboardingActions): OnboardingState => {
switch (action.type) {
case ONBOARDING_START:
return { ...state, needsOnboarding: true };
case ONBOARDING_END:
return { ...state, needsOnboarding: false };
default:
return state;
}
};
export { onboarding as default };