Merge branch 'onboarding-flow-modal' into 'main'

Onboarding flow modal

Closes #1692

See merge request soapbox-pub/soapbox!3091
environments/review-main-yi2y9f/deployments/4765
Alex Gleason 1 month ago
commit b61f2e40ed

@ -1,19 +1,24 @@
import React from 'react';
import { Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
const closeIcon = require('@tabler/icons/outline/x.svg');
interface IBigCard {
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onClose?(): void;
}
const BigCard: React.FC<IBigCard> = ({ title, subtitle, children }) => {
const BigCard: React.FC<IBigCard> = ({ title, subtitle, children, onClose }) => {
return (
<Card variant='rounded' size='xl'>
<CardBody>
<CardBody className='relative'>
<div className='-mx-4 mb-4 border-b border-solid border-gray-200 pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<Stack space={2}>
{onClose && (<IconButton src={closeIcon} className='absolute right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' onClick={onClose} />)}
<Text size='2xl' align='center' weight='bold'>{title}</Text>
{subtitle && <Text theme='muted' align='center'>{subtitle}</Text>}
</Stack>

@ -13,6 +13,11 @@ const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
const themes = {
normal: 'bg-white p-6 shadow-xl',
transparent: 'bg-transparent p-0 shadow-none',
};
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
@ -45,6 +50,8 @@ interface IModal {
confirmationFullWidth?: boolean;
/** Callback when the modal is closed. */
onClose?: () => void;
/** Theme for the modal. */
theme?: keyof typeof themes;
/** Callback when the secondary action is chosen. */
secondaryAction?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
/** Secondary button text. */
@ -73,6 +80,7 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
confirmationTheme,
confirmationFullWidth,
onClose,
theme = 'normal',
secondaryAction,
secondaryDisabled = false,
secondaryText,
@ -95,7 +103,7 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
<div
ref={ref}
data-testid='modal'
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl bg-white p-6 text-start align-middle text-gray-900 shadow-xl transition-all black:bg-black dark:bg-primary-900 dark:text-gray-100', widths[width])}
className={clsx(className, 'pointer-events-auto mx-auto block w-full rounded-2xl text-start align-middle text-gray-900 transition-all black:bg-black dark:bg-primary-900 dark:text-gray-100', widths[width], themes[theme])}
>
<div className='w-full justify-between sm:flex sm:items-start'>
<div className='w-full'>

@ -1,119 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { BigCard } from 'soapbox/components/big-card';
import { Avatar, Button, Icon, Spinner, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultAvatar } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
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 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) {
toast.error((error.response.data as any).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-full bg-gray-200'>
{account && (
<Avatar src={selectedFile || account.avatar} size={175} />
)}
{isSubmitting && (
<div className='absolute inset-0 flex items-center justify-center rounded-full 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-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('@tabler/icons/outline/plus.svg')} className='h-5 w-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 default AvatarSelectionStep;

@ -1,93 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { BigCard } from 'soapbox/components/big-card';
import { Button, FormGroup, Stack, Textarea } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
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?.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: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).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 default BioStep;

@ -1,39 +0,0 @@
import 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/outline/confetti.svg')} className='mx-auto h-16 w-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 default CompletedStep;

@ -1,101 +0,0 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { BigCard } from 'soapbox/components/big-card';
import { Button, FormGroup, Input, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
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: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).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 default DisplayNameStep;

@ -1,88 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Account from 'soapbox/components/account';
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';
import { useInstance, useOwnAccount } from 'soapbox/hooks';
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('@tabler/icons/outline/affiliate.svg')} className='mx-auto h-16 w-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 sm:pb-5 dark:border-gray-800'>
<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 default FediverseStep;

@ -34,6 +34,7 @@ import {
MuteModal,
NostrLoginModal,
NostrSignupModal,
OnboardingFlowModal,
ReactionsModal,
ReblogsModal,
ReplyMentionsModal,
@ -84,6 +85,7 @@ const MODAL_COMPONENTS: Record<string, React.LazyExoticComponent<any>> = {
'MUTE': MuteModal,
'NOSTR_LOGIN': NostrLoginModal,
'NOSTR_SIGNUP': NostrSignupModal,
'ONBOARDING_FLOW': OnboardingFlowModal,
'REACTIONS': ReactionsModal,
'REBLOGS': ReblogsModal,
'REPLY_MENTIONS': ReplyMentionsModal,

@ -3,21 +3,22 @@ import React from 'react';
import ReactSwipeableViews from 'react-swipeable-views';
import { endOnboarding } from 'soapbox/actions/onboarding';
import LandingGradient from 'soapbox/components/landing-gradient';
import { HStack } from 'soapbox/components/ui';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { Stack, Modal, HStack } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import AvatarSelectionStep from './steps/avatar-selection-step';
import AvatarSelectionModal from './steps/avatar-step';
import BioStep from './steps/bio-step';
import CompletedStep from './steps/completed-step';
import CoverPhotoSelectionStep from './steps/cover-photo-selection-step';
import CompletedModal from './steps/completed-step';
import CoverPhotoSelectionModal 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';
import SuggestedAccountsModal from './steps/suggested-accounts-step';
const OnboardingWizard = () => {
interface IOnboardingFlowModal {
onClose(): void;
}
const OnboardingFlowModal: React.FC<IOnboardingFlowModal> = ({ onClose }) => {
const dispatch = useAppDispatch();
const features = useFeatures();
const [currentStep, setCurrentStep] = React.useState<number>(0);
@ -29,27 +30,28 @@ const OnboardingWizard = () => {
setCurrentStep((prevStep) => Math.max(0, prevStep - 1));
};
const handleDotClick = (nextStep: number) => {
setCurrentStep(nextStep);
};
const handleNextStep = () => {
setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length - 1));
};
const handleComplete = () => {
dispatch(endOnboarding());
onClose();
};
const steps = [
<AvatarSelectionStep onNext={handleNextStep} />,
<DisplayNameStep onNext={handleNextStep} />,
<BioStep onNext={handleNextStep} />,
<CoverPhotoSelectionStep onNext={handleNextStep} />,
<SuggestedAccountsStep onNext={handleNextStep} />,
<AvatarSelectionModal onClose={handleComplete} onNext={handleNextStep} />,
<DisplayNameStep onClose={handleComplete} onNext={handleNextStep} />,
<BioStep onClose={handleComplete} onNext={handleNextStep} />,
<CoverPhotoSelectionModal onClose={handleComplete} onNext={handleNextStep} />,
<SuggestedAccountsModal onClose={handleComplete} onNext={handleNextStep} />,
];
if (features.federating && !features.nostr) {
steps.push(<FediverseStep onNext={handleNextStep} />);
}
steps.push(<CompletedStep onComplete={handleComplete} />);
steps.push(<CompletedModal onComplete={handleComplete} />);
const handleKeyUp = ({ key }: KeyboardEvent): void => {
switch (key) {
@ -62,10 +64,6 @@ const OnboardingWizard = () => {
}
};
const handleDotClick = (nextStep: number) => {
setCurrentStep(nextStep);
};
React.useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
@ -74,15 +72,14 @@ const OnboardingWizard = () => {
};
}, []);
return (
<div data-testid='onboarding-wizard'>
<LandingGradient />
<main className='flex h-screen flex-col overflow-x-hidden'>
<div className='flex h-full flex-col items-center justify-center'>
return (
<Stack space={4} className='w-full'>
<Modal width='2xl' onClose={handleComplete} theme='transparent'>
<Stack space={4}>
<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 key={i} className='w-full'>
<div
className={clsx({
'transition-opacity ease-linear': true,
@ -95,7 +92,6 @@ const OnboardingWizard = () => {
</div>
))}
</ReactSwipeableViews>
<HStack space={3} alignItems='center' justifyContent='center' className='relative'>
{steps.map((_, i) => (
<button
@ -110,10 +106,10 @@ const OnboardingWizard = () => {
/>
))}
</HStack>
</div>
</main>
</div>
</Stack>
</Modal>
</Stack>
);
};
export default OnboardingWizard;
export default OnboardingFlowModal;

@ -0,0 +1,134 @@
import clsx from 'clsx';
import React from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Button, Stack, Text, Avatar, Icon, Spinner } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultAvatar } from 'soapbox/utils/accounts';
import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const closeIcon = require('@tabler/icons/outline/x.svg');
const messages = defineMessages({
error: { id: 'onboarding.error', defaultMessage: 'An unexpected error occurred. Please try again or skip this step.' },
});
interface IAvatarSelectionModal {
onClose?(): void;
onNext: () => void;
}
const AvatarSelectionModal: React.FC<IAvatarSelectionModal> = ({ onClose, onNext }) => {
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 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) {
toast.error((error.response.data as any).error.replace('Validation failed: ', ''));
} else {
toast.error(messages.error);
}
});
}).catch(console.error);
};
return (
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
<Stack space={2} justifyContent='center' alignItems='center' className='border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<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='relative mx-auto rounded-full bg-gray-200'>
{account && (
<Avatar src={selectedFile || account.avatar} size={175} />
)}
{isSubmitting && (
<div className='absolute inset-0 flex items-center justify-center rounded-full 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-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('@tabler/icons/outline/plus.svg')} className='h-5 w-5 text-white' />
</button>
<input type='file' className='hidden' ref={fileInput} onChange={handleFileChange} />
</div>
<Stack justifyContent='center' space={2} className='w-2/3'>
<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>
);
};
export default AvatarSelectionModal;

@ -0,0 +1,101 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Button, Text, FormGroup, Stack, Textarea } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
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 closeIcon = require('@tabler/icons/outline/x.svg');
interface IBioStep {
onClose(): void;
onNext: () => void;
}
const BioStep: React.FC<IBioStep> = ({ onClose, onNext }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const [value, setValue] = React.useState<string>(account?.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: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<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>
<div className='mx-auto w-2/3'>
<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>
<Stack justifyContent='center' space={2} className='w-2/3'>
<Button block theme='primary' type='button' onClick={handleSubmit} disabled={isSubmitting}>
{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>
);
};
export default BioStep;

@ -0,0 +1,49 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button, Icon, Stack, Text } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
const closeIcon = require('@tabler/icons/outline/x.svg');
interface ICompletedModal {
onClose?(): void;
onComplete: () => void;
}
const CompletedModal: React.FC<ICompletedModal> = ({ onClose, onComplete }) => {
return (
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' onClick={onClose} />
<Stack space={2} justifyContent='center' alignItems='center' className=''>
<Icon strokeWidth={1} src={require('@tabler/icons/outline/confetti.svg')} className='mx-auto h-16 w-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>
<Stack justifyContent='center' alignItems='center' className='w-full'>
<div className='w-2/3' />
<Stack justifyContent='center' space={2} className='w-2/3'>
<Button block theme='primary' onClick={onComplete}>
<FormattedMessage id='onboarding.view_feed' defaultMessage='View Feed' />
</Button>
</Stack>
</Stack>
</Stack>
);
};
export default CompletedModal;

@ -1,11 +1,11 @@
import clsx from 'clsx';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { BigCard } from 'soapbox/components/big-card';
import StillImage from 'soapbox/components/still-image';
import { Avatar, Button, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import { Button, Stack, Text, Avatar, Icon, Spinner } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { isDefaultHeader } from 'soapbox/utils/accounts';
@ -13,12 +13,19 @@ import resizeImage from 'soapbox/utils/resize-image';
import type { AxiosError } from 'axios';
const closeIcon = require('@tabler/icons/outline/x.svg');
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 }) => {
interface ICoverPhotoSelectionModal {
onClose?(): void;
onNext: () => void;
}
const CoverPhotoSelectionModal: React.FC<ICoverPhotoSelectionModal> = ({ onClose, onNext }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
@ -27,7 +34,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
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 isDefault = account ? isDefaultHeader(account.avatar) : false;
const openFilePicker = () => {
fileInput.current?.click();
@ -68,23 +75,36 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
};
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'>
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<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>
<Stack space={10} justifyContent='center' alignItems='center' className='w-full'>
<div className='w-2/3 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'
className='relative flex h-24 w-full items-center justify-center rounded-t-md bg-gray-200 dark:bg-gray-800'
>
<div className='flex h-24 w-full overflow-hidden rounded-t-md'>
{selectedFile || account?.header && (
<StillImage
src={selectedFile || account.header}
alt={intl.formatMessage(messages.header)}
className='absolute inset-0 rounded-t-md object-cover'
className='absolute inset-0 w-full rounded-t-md object-cover'
/>
)}
</div>
{isSubmitting && (
<div
@ -119,7 +139,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
</div>
</div>
<Stack justifyContent='center' space={2}>
<Stack justifyContent='center' space={2} className='w-2/3'>
<Button block theme='primary' type='button' onClick={onNext} disabled={isDefault && isDisabled || isSubmitting}>
{isSubmitting ? (
<FormattedMessage id='onboarding.saving' defaultMessage='Saving…' />
@ -128,15 +148,14 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
)}
</Button>
{isDisabled && (
<Button block theme='tertiary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip for now' />
</Button>
)}
</Stack>
</Stack>
</BigCard>
</Stack>
);
};
export default CoverPhotoSelectionStep;
export default CoverPhotoSelectionModal;

@ -0,0 +1,116 @@
import React from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me';
import { Button, Stack, Text, FormGroup, Input } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import type { AxiosError } from 'axios';
const closeIcon = require('@tabler/icons/outline/x.svg');
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.' },
});
interface IDisplayNameStep {
onClose?(): void;
onNext: () => void;
}
const DisplayNameStep: React.FC<IDisplayNameStep> = ({ onClose, onNext }) => {
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: AxiosError) => {
setSubmitting(false);
if (error.response?.status === 422) {
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
} else {
toast.error(messages.error);
}
});
};
return (
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<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>
<Stack space={5} justifyContent='center' alignItems='center' className='w-full'>
<div className='w-2/3'>
<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>
</div>
<Stack justifyContent='center' space={2} className='w-2/3'>
<Button block theme='primary' type='button' onClick={handleSubmit} disabled={isDisabled || isSubmitting}>
{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>
</Stack>
);
};
export default DisplayNameStep;

@ -2,13 +2,20 @@ import debounce from 'lodash/debounce';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { BigCard } from 'soapbox/components/big-card';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Stack, Text } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import AccountContainer from 'soapbox/containers/account-container';
import { useOnboardingSuggestions } from 'soapbox/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const closeIcon = require('@tabler/icons/outline/x.svg');
interface ICoverPhotoSelectionModal {
onClose?(): void;
onNext: () => void;
}
const CoverPhotoSelectionModal: React.FC<ICoverPhotoSelectionModal> = ({ onClose, onNext }) => {
const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions();
const handleLoadMore = debounce(() => {
@ -23,7 +30,6 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
if (!data) {
return null;
}
return (
<div className='flex flex-col sm:pb-10 sm:pt-4'>
<ScrollableList
@ -67,19 +73,28 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
};
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.' />}
>
<Stack space={10} justifyContent='center' alignItems='center' className='w-full rounded-3xl bg-white px-4 py-8 text-gray-900 shadow-lg black:bg-black sm:p-10 dark:bg-primary-900 dark:text-gray-100 dark:shadow-none'>
<div className='relative w-full'>
<IconButton src={closeIcon} onClick={onClose} className='absolute -top-[6%] right-[2%] text-gray-500 hover:text-gray-700 rtl:rotate-180 dark:text-gray-300 dark:hover:text-gray-200' />
<Stack space={2} justifyContent='center' alignItems='center' className='bg-grey-500 border-grey-200 -mx-4 mb-4 border-b border-solid pb-4 sm:-mx-10 sm:pb-10 dark:border-gray-800'>
<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>
<Stack justifyContent='center' alignItems='center' className='w-full'>
<div className='w-2/3'>
{renderBody()}
</div>
<Stack>
<Stack justifyContent='center' space={2}>
<Button
block
theme='primary'
onClick={onNext}
>
<Stack justifyContent='center' space={2} className='w-2/3'>
<Button block theme='primary' type='button' onClick={onNext}>
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
</Button>
@ -88,8 +103,9 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
</Button>
</Stack>
</Stack>
</BigCard>
</Stack>
);
};
export default SuggestedAccountsStep;
export default CoverPhotoSelectionModal;

@ -121,7 +121,6 @@ export const SettingsStore = lazy(() => import('soapbox/features/developers/sett
export const TestTimeline = lazy(() => import('soapbox/features/test-timeline'));
export const ServiceWorkerInfo = lazy(() => import('soapbox/features/developers/service-worker-info'));
export const DatePicker = lazy(() => import('soapbox/features/birthdays/date-picker'));
export const OnboardingWizard = lazy(() => import('soapbox/features/onboarding/onboarding-wizard'));
export const CompareHistoryModal = lazy(() => import('soapbox/features/ui/components/modals/compare-history-modal'));
export const AuthTokenList = lazy(() => import('soapbox/features/auth-token-list'));
export const FamiliarFollowersModal = lazy(() => import('soapbox/features/ui/components/modals/familiar-followers-modal'));
@ -164,6 +163,7 @@ export const AccountNotePanel = lazy(() => import('soapbox/features/ui/component
export const ComposeEditor = lazy(() => import('soapbox/features/compose/editor'));
export const NostrSignupModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-signup-modal/nostr-signup-modal'));
export const NostrLoginModal = lazy(() => import('soapbox/features/ui/components/modals/nostr-login-modal/nostr-login-modal'));
export const OnboardingFlowModal = lazy(() => import ('soapbox/features/ui/components/modals/onboarding-flow-modal/onboarding-flow-modal'));
export const BookmarkFolders = lazy(() => import('soapbox/features/bookmark-folders'));
export const EditBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/edit-bookmark-folder-modal'));
export const SelectBookmarkFolderModal = lazy(() => import('soapbox/features/ui/components/modals/select-bookmark-folder-modal'));

@ -5,14 +5,15 @@ import { CompatRouter } from 'react-router-dom-v5-compat';
// @ts-ignore: it doesn't have types
import { ScrollContext } from 'react-router-scroll-4';
import { openModal } from 'soapbox/actions/modals';
import * as BuildConfig from 'soapbox/build-config';
import LoadingScreen from 'soapbox/components/loading-screen';
import SiteErrorBoundary from 'soapbox/components/site-error-boundary';
import {
ModalContainer,
OnboardingWizard,
} from 'soapbox/features/ui/util/async-components';
import {
useAppDispatch,
useAppSelector,
useLoggedIn,
useOwnAccount,
@ -30,10 +31,15 @@ const SoapboxMount = () => {
const { isLoggedIn } = useLoggedIn();
const { account } = useOwnAccount();
const dispatch = useAppDispatch();
const soapboxConfig = useSoapboxConfig();
const needsOnboarding = useAppSelector(state => state.onboarding.needsOnboarding);
const showOnboarding = account && needsOnboarding;
if (showOnboarding) {
dispatch(openModal('ONBOARDING_FLOW'));
}
const { redirectRootNoLogin, gdpr } = soapboxConfig;
// @ts-ignore: I don't actually know what these should be, lol
@ -64,10 +70,7 @@ const SoapboxMount = () => {
<Route>
<Suspense fallback={<LoadingScreen />}>
{showOnboarding
? <OnboardingWizard />
: <UI />
}
<UI />
</Suspense>
<Suspense>

@ -1211,12 +1211,6 @@
"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.",

@ -580,7 +580,7 @@
"confirmations.mute_group.message": "Você está prestes a silenciar o grupo. Você quer continuar?",
"confirmations.redraft.confirm": "Apagar e reescrever",
"confirmations.redraft.heading": "Apagar e reescrever",
"confirmations.redraft.message": "Você tem certeza que deseja apagar esse status e usá-lo como rascunho? Seus compartilhamentos e favoritos serão perdidos e as respostas ao toot original ficarão desconectadas.",
"confirmations.redraft.message": "Você tem certeza que deseja apagar esse status e usá-lo como rascunho? Seus compartilhamentos e favoritos serão perdidos e as respostas ao post original ficarão desconectadas.",
"confirmations.register.needs_approval": "Sua conta será aprovada manualmente por um administrador. Por favor, seja paciente enquanto revisamos seus detalhes.",
"confirmations.register.needs_approval.header": "Aprovação necessária",
"confirmations.register.needs_confirmation": "Por favor, verifique sua caixa de entrada em {email} para instruções de confirmação. Você precisará verificar seu endereço de email para continuar.",
@ -715,7 +715,7 @@
"emoji_button.travel": "Viagens & Lugares",
"empty_column.account_blocked": "Você está bloqueado por @{accountUsername}.",
"empty_column.account_favourited_statuses": "Este usuário ainda não tem postagens favoritas.",
"empty_column.account_timeline": "Não há toots aqui!",
"empty_column.account_timeline": "Não há posts aqui!",
"empty_column.account_unavailable": "Perfil indisponível",
"empty_column.admin.announcements": "Ainda não há anúncios.",
"empty_column.admin.domains": "Ainda não há domínios.",
@ -732,8 +732,8 @@
"empty_column.domain_blocks": "Ainda não há nenhum domínio escondido.",
"empty_column.event_participant_requests": "Não há solicitações de participação em eventos pendentes.",
"empty_column.event_participants": "Ninguém entrou neste evento ainda. Quando alguém entrar, aparecerá aqui.",
"empty_column.favourited_statuses": "Você ainda não tem nenhum toot favorito. Quando você favoritar um postagem, ela aparecerá aqui.",
"empty_column.favourites": "Ninguém favoritou esse toot até agora. Quando alguém favoritar, a pessoa aparecerá aqui.",
"empty_column.favourited_statuses": "Você ainda não tem nenhum post favorito. Quando você favoritar um postagem, ela aparecerá aqui.",
"empty_column.favourites": "Ninguém favoritou esse post até agora. Quando alguém favoritar, a pessoa aparecerá aqui.",
"empty_column.filters": "Você ainda não criou nenhuma palavra silenciada.",
"empty_column.follow_recommendations": "Parece que não foram geradas sugestões para você. Você pode tentar usar a busca para procurar pessoas que você pode conhecer ou explorar hashtags em alta.",
"empty_column.follow_requests": "Você não tem nenhum pedido de seguir por agora. Quando você receber um, ele aparecerá aqui.",
@ -1122,7 +1122,7 @@
"navigation_bar.account_aliases": "Apelidos de conta",
"navigation_bar.account_migration": "Mover conta",
"navigation_bar.blocks": "Usuários bloqueados",
"navigation_bar.compose": "Compor um novo toot",
"navigation_bar.compose": "Compor um novo post",
"navigation_bar.compose_direct": "Mensagem direta",
"navigation_bar.compose_edit": "Editar postagem",
"navigation_bar.compose_event": "Gerenciar evento",
@ -1532,7 +1532,7 @@
"status.pin": "Fixar no perfil",
"status.pin_to_group": "Fixar no Grupo",
"status.pin_to_group.success": "Fixado no Grupo!",
"status.pinned": "Toot fixado",
"status.pinned": "Post fixado",
"status.quote": "Citar postagem",
"status.quote_tombstone": "Postagem indisponível.",
"status.reactions.cry": "Triste",
@ -1547,7 +1547,7 @@
"status.reblog_private": "Compartilhar com a audiência original",
"status.reblogged_by": "{name} compartilhou",
"status.reblogged_by_with_group": "{name} republicou de {group}",
"status.reblogs.empty": "Ninguém compartilhou este toot ainda. Quando alguém o fizer, aparecerá aqui.",
"status.reblogs.empty": "Ninguém compartilhou este post ainda. Quando alguém o fizer, aparecerá aqui.",
"status.redraft": "Excluir & usar como rascunho",
"status.remove_account_from_group": "Remover conta do grupo",
"status.remove_post_from_group": "Remover postagem do grupo",

Loading…
Cancel
Save