[Glitch] Add new onboarding flow to web UI

Port 0461f83320 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2023-04-23 22:24:53 +02:00 committed by Claire
parent 335cfab32f
commit 4537b4b961
20 changed files with 789 additions and 305 deletions

View file

@ -84,6 +84,7 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
@ -144,6 +145,14 @@ export function resetCompose() {
};
}
export const focusCompose = routerHistory => dispatch => {
dispatch({
type: COMPOSE_FOCUS,
});
ensureComposeIsVisible(routerHistory);
};
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({

View file

@ -1,16 +1,8 @@
import { openModal } from './modal';
import { changeSetting, saveSettings } from './settings';
export function showOnboardingOnce() {
return (dispatch, getState) => {
const alreadySeen = getState().getIn(['settings', 'onboarded']);
export const INTRODUCTION_VERSION = 20181216044202;
if (!alreadySeen) {
dispatch(openModal({
modalType: 'ONBOARDING',
}));
dispatch(changeSetting(['onboarded'], true));
dispatch(saveSettings());
}
};
}
export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings());
};

View file

@ -1,6 +1,6 @@
const Check = () => (
<svg width='14' height='11' viewBox='0 0 14 11'>
<path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' />
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z' clipRule='evenodd' />
</svg>
);

View file

@ -13,13 +13,16 @@ export class ColumnBackButton extends PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
onClick: PropTypes.func,
...WithRouterPropTypes,
};
handleClick = () => {
const { history } = this.props;
const { onClick, history } = this.props;
if (history.location?.state?.fromMastodon) {
if (onClick) {
onClick();
} else if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');

View file

@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import classNames from 'classnames';
import { maxChars } from 'flavours/glitch/initial_state';
import { isMobile } from 'flavours/glitch/is_mobile';
@ -84,6 +85,10 @@ class ComposeForm extends ImmutablePureComponent {
showSearch: false,
};
state = {
highlighted: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
};
@ -209,6 +214,10 @@ class ComposeForm extends ImmutablePureComponent {
this._updateFocusAndSelection({ });
}
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
componentDidUpdate (prevProps) {
this._updateFocusAndSelection(prevProps);
}
@ -257,6 +266,8 @@ class ComposeForm extends ImmutablePureComponent {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
if (!singleColumn) textarea.scrollIntoView();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
}
@ -302,6 +313,7 @@ class ComposeForm extends ImmutablePureComponent {
spoilersAlwaysOn,
isEditing,
} = this.props;
const { highlighted } = this.state;
const countText = this.getFulltextForCharacterCounting();
@ -332,29 +344,31 @@ class ComposeForm extends ImmutablePureComponent {
/>
</div>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
suggestions={suggestions}
onFocus={this.handleFocus}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.handleSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
lang={this.props.lang}
>
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
suggestions={suggestions}
onFocus={this.handleFocus}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.handleSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
lang={this.props.lang}
>
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
</div>
<div className='compose-form__buttons-wrapper'>
<OptionsContainer

View file

@ -1,87 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { makeGetAccount } from 'flavours/glitch/selectors';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const getFirstSentence = str => {
const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
return arr[0];
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleFollow = () => {
const { account, dispatch } = this.props;
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
};
render () {
const { account, intl } = this.props;
let button;
if (account.getIn(['relationship', 'following'])) {
button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
}
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -1,119 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
import { markAsPartial } from 'flavours/glitch/actions/timelines';
import { Button } from 'flavours/glitch/components/button';
import Column from 'flavours/glitch/features/ui/components/column';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Account from './components/account';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
class FollowRecommendations extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
componentDidMount () {
const { dispatch, suggestions } = this.props;
// Don't re-fetch if we're e.g. navigating backwards to this page,
// since we don't want followed accounts to disappear from the list
if (suggestions.size === 0) {
dispatch(fetchSuggestions(true));
}
}
componentWillUnmount () {
const { dispatch } = this.props;
// Force the home timeline to be reloaded when the user navigates
// to it; if the user is new, it would've been empty before
dispatch(markAsPartial('home'));
}
handleDone = () => {
const { history, dispatch } = this.props;
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
history.push('/home');
};
render () {
const { suggestions, isLoading } = this.props;
return (
<Column>
<div className='scrollable follow-recommendations-container'>
<div className='column-title'>
<svg viewBox='0 0 79 79' className='logo'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
</div>
{!isLoading && (
<>
<div className='column-list'>
{suggestions.size > 0 ? suggestions.map(suggestion => (
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
)) : (
<div className='column-list__empty-message'>
<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.' />
</div>
)}
</div>
<div className='column-actions'>
<img src={imageGreeting} alt='' className='column-actions__background' />
<Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
</div>
</>
)}
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(FollowRecommendations));

View file

@ -0,0 +1,7 @@
const ArrowSmallRight = () => (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z' clipRule='evenodd' />
</svg>
);
export default ArrowSmallRight;

View file

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import Check from 'flavours/glitch/components/check';
import classNames from 'classnames';
const ProgressIndicator = ({ steps, completed }) => (
<div className='onboarding__progress-indicator'>
{(new Array(steps)).fill().map((_, i) => (
<React.Fragment key={i}>
{i > 0 && <div className={classNames('onboarding__progress-indicator__line', { active: completed > i })} />}
<div className={classNames('onboarding__progress-indicator__step', { active: completed > i })}>
{completed > i && <Check />}
</div>
</React.Fragment>
))}
</div>
);
ProgressIndicator.propTypes = {
steps: PropTypes.number.isRequired,
completed: PropTypes.number,
};
export default ProgressIndicator;

View file

@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import Icon from 'flavours/glitch/components/icon';
import Check from 'flavours/glitch/components/check';
const Step = ({ label, description, icon, completed, onClick, href }) => {
const content = (
<>
<div className='onboarding__steps__item__icon'>
<Icon id={icon} />
</div>
<div className='onboarding__steps__item__description'>
<h6>{label}</h6>
<p>{description}</p>
</div>
{completed && (
<div className='onboarding__steps__item__progress'>
<Check />
</div>
)}
</>
);
if (href) {
return (
<a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'>
{content}
</a>
);
}
return (
<button onClick={onClick} className='onboarding__steps__item'>
{content}
</button>
);
};
Step.propTypes = {
label: PropTypes.node,
description: PropTypes.node,
icon: PropTypes.string,
completed: PropTypes.bool,
href: PropTypes.string,
onClick: PropTypes.func,
};
export default Step;

View file

@ -0,0 +1,79 @@
import React from 'react';
import Column from 'flavours/glitch/components/column';
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
import { markAsPartial } from 'flavours/glitch/actions/timelines';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Account from 'flavours/glitch/containers/account_container';
import EmptyAccount from 'flavours/glitch/components/account';
import { FormattedMessage, FormattedHTMLMessage } from 'react-intl';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { me } from 'flavours/glitch/initial_state';
import ProgressIndicator from './components/progress_indicator';
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return state => ({
account: getAccount(state, me),
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
};
class Follows extends React.PureComponent {
static propTypes = {
onBack: PropTypes.func,
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
account: ImmutablePropTypes.map,
isLoading: PropTypes.bool,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchSuggestions(true));
}
componentWillUnmount () {
const { dispatch } = this.props;
dispatch(markAsPartial('home'));
}
render () {
const { onBack, isLoading, suggestions, account } = this.props;
return (
<Column>
<ColumnBackButton onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='onboarding.follows.title' defaultMessage='Popular on Mastodon' /></h3>
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
</div>
<ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
<div className='follow-recommendations'>
{isLoading ? (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />) : suggestions.map(suggestion => (
<Account id={suggestion.get('account')} key={suggestion.get('account')} />
))}
</div>
<p className='onboarding__lead'><FormattedHTMLMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: text => <strong>{text}</strong> }} /></p>
<div className='onboarding__footer'>
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button>
</div>
</div>
</Column>
);
}
}
export default connect(mapStateToProps)(Follows);

View file

@ -0,0 +1,149 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { fetchAccount } from 'flavours/glitch/actions/accounts';
import { focusCompose } from 'flavours/glitch/actions/compose';
import { closeOnboarding } from 'flavours/glitch/actions/onboarding';
import Column from 'flavours/glitch/features/ui/components/column';
import { me } from 'flavours/glitch/initial_state';
import { makeGetAccount } from 'flavours/glitch/selectors';
import illustration from 'mastodon/../images/elephant_ui_conversation.svg';
import ArrowSmallRight from './components/arrow_small_right';
import Step from './components/step';
import Follows from './follows';
import Share from './share';
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return state => ({
account: getAccount(state, me),
});
};
class Onboarding extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object.isRequired,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
};
state = {
step: null,
profileClicked: false,
shareClicked: false,
};
handleClose = () => {
const { dispatch } = this.props;
const { router } = this.context;
dispatch(closeOnboarding());
router.history.push('/home');
};
handleProfileClick = () => {
this.setState({ profileClicked: true });
};
handleFollowClick = () => {
this.setState({ step: 'follows' });
};
handleComposeClick = () => {
const { dispatch } = this.props;
const { router } = this.context;
dispatch(focusCompose(router.history));
};
handleShareClick = () => {
this.setState({ step: 'share', shareClicked: true });
};
handleBackClick = () => {
this.setState({ step: null });
};
handleWindowFocus = debounce(() => {
const { dispatch, account } = this.props;
dispatch(fetchAccount(account.get('id')));
}, 1000, { trailing: true });
componentDidMount () {
window.addEventListener('focus', this.handleWindowFocus, false);
}
componentWillUnmount () {
window.removeEventListener('focus', this.handleWindowFocus);
}
render () {
const { account } = this.props;
const { step, shareClicked } = this.state;
switch(step) {
case 'follows':
return <Follows onBack={this.handleBackClick} />;
case 'share':
return <Share onBack={this.handleBackClick} />;
}
return (
<Column>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<img src={illustration} alt='' className='onboarding__illustration' />
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
</div>
<div className='onboarding__steps'>
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Follow {count, plural, one {one person} other {# people}}' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage='You curate your own feed. Lets fill it with interesting people.' />} />
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
</div>
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage='Want to skip right ahead?' /></p>
<div className='onboarding__links'>
<Link to='/explore' className='onboarding__link'>
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
</div>
<div className='onboarding__footer'>
<button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button>
</div>
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(Onboarding);

View file

@ -0,0 +1,132 @@
import React from 'react';
import Column from 'flavours/glitch/components/column';
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import PropTypes from 'prop-types';
import { me, domain } from 'flavours/glitch/initial_state';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
import ArrowSmallRight from './components/arrow_small_right';
import { Link } from 'react-router-dom';
const messages = defineMessages({
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on Mastodon! Come follow me at {url}' },
});
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
});
class CopyPasteText extends React.PureComponent {
static propTypes = {
value: PropTypes.string,
};
state = {
copied: false,
focused: false,
};
setRef = c => {
this.input = c;
};
handleInputClick = () => {
this.setState({ copied: false });
this.input.focus();
this.input.select();
this.input.setSelectionRange(0, this.props.value.length);
};
handleButtonClick = e => {
e.stopPropagation();
const { value } = this.props;
navigator.clipboard.writeText(value);
this.input.blur();
this.setState({ copied: true });
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
render () {
const { value } = this.props;
const { copied, focused } = this.state;
return (
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
<button className='button' onClick={this.handleButtonClick}>
<Icon id='copy' /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
</button>
</div>
);
}
}
class Share extends React.PureComponent {
static propTypes = {
onBack: PropTypes.func,
account: ImmutablePropTypes.map,
intl: PropTypes.object,
};
render () {
const { onBack, account, intl } = this.props;
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
return (
<Column>
<ColumnBackButton onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='onboarding.share.title' defaultMessage='Share your profile' /></h3>
<p><FormattedMessage id='onboarding.share.lead' defaultMessage='Let people know how they can find you on Mastodon!' /></p>
</div>
<CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} />
<p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p>
<div className='onboarding__links'>
<Link to='/home' className='onboarding__link'>
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
</Link>
<Link to='/explore' className='onboarding__link'>
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
</div>
<div className='onboarding__footer'>
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></button>
</div>
</div>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Share));

View file

@ -18,6 +18,7 @@ import PermaLink from 'flavours/glitch/components/permalink';
import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache';
@ -62,7 +63,7 @@ import {
GettingStartedMisc,
Directory,
Explore,
FollowRecommendations,
Onboarding,
About,
PrivacyPolicy,
} from './util/async-components';
@ -86,7 +87,7 @@ const mapStateToProps = state => ({
showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
firstLaunch: false, // TODO: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
username: state.getIn(['accounts', me, 'username']),
});
@ -216,7 +217,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
@ -417,7 +418,6 @@ class UI extends Component {
// On first launch, redirect to the follow recommendations page
if (signedIn && this.props.firstLaunch) {
this.props.history.replace('/start');
// TODO: this.props.dispatch(closeOnboarding());
}
if (signedIn) {

View file

@ -170,8 +170,8 @@ export function Directory () {
return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
}
export function FollowRecommendations () {
return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
export function Onboarding () {
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'flavours/glitch/features/onboarding');
}
export function CompareHistoryModal () {

View file

@ -1,5 +1,7 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { me } from 'flavours/glitch/initial_state';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
@ -20,6 +22,14 @@ const normalizeAccounts = (state, accounts) => {
return state;
};
const incrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => num + 1)
.updateIn([me, 'following_count'], num => num + 1);
const decrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => Math.max(0, num - 1))
.updateIn([me, 'following_count'], num => Math.max(0, num - 1));
const initialState = ImmutableMap();
export default function accountsCounters(state = initialState, action) {
@ -30,9 +40,9 @@ export default function accountsCounters(state = initialState, action) {
return normalizeAccounts(state, action.accounts);
case ACCOUNT_FOLLOW_SUCCESS:
return action.alreadyFollowing ? state :
state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
incrementFollowers(state, action.relationship.id);
case ACCOUNT_UNFOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
return decrementFollowers(state, action.relationship.id);
default:
return state;
}

View file

@ -51,6 +51,7 @@ import {
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_SET_STATUS,
COMPOSE_FOCUS,
} from '../actions/compose';
import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store';
@ -651,6 +652,8 @@ export default function compose(state = initialState, action) {
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_LANGUAGE_CHANGE:
return state.set('language', action.language);
case COMPOSE_FOCUS:
return state.set('focusDate', new Date());
default:
return state;
}

View file

@ -45,16 +45,6 @@
}
}
.follow-recommendations-account {
.icon-button {
color: $ui-primary-color;
&.active {
color: $valid-value-color;
}
}
}
.account__wrapper {
display: flex;
gap: 10px;

View file

@ -914,13 +914,7 @@ $ui-header-height: 55px;
.column-title {
text-align: center;
padding: 40px;
.logo {
width: 50px;
margin: 0 auto;
margin-bottom: 40px;
}
padding-bottom: 40px;
h3 {
font-size: 24px;
@ -935,45 +929,274 @@ $ui-header-height: 55px;
font-weight: 400;
color: $darker-text-color;
}
}
.follow-recommendations-container {
display: flex;
flex-direction: column;
}
.column-actions {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px;
padding-top: 40px;
padding-bottom: 200px;
flex-grow: 1;
position: relative;
&__background {
position: absolute;
inset-inline-start: 0;
bottom: 0;
height: 220px;
width: auto;
@media screen and (min-width: 600px) {
padding: 40px;
}
}
.column-list {
margin: 0 20px;
border: 1px solid lighten($ui-base-color, 8%);
background: darken($ui-base-color, 2%);
border-radius: 4px;
.onboarding__footer {
margin-top: 30px;
color: $dark-text-color;
text-align: center;
font-size: 14px;
&__empty-message {
padding: 40px;
text-align: center;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: $darker-text-color;
.link-button {
display: inline-block;
color: inherit;
font-size: inherit;
}
}
.onboarding__link {
display: flex;
align-items: center;
gap: 10px;
color: $highlight-text-color;
background: lighten($ui-base-color, 4%);
border-radius: 8px;
padding: 10px;
box-sizing: border-box;
font-size: 17px;
height: 56px;
text-decoration: none;
svg {
height: 1.5em;
}
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 8%);
}
}
.onboarding__illustration {
display: block;
margin: 0 auto;
margin-bottom: 10px;
max-height: 200px;
width: auto;
}
.onboarding__lead {
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: $darker-text-color;
text-align: center;
margin-bottom: 30px;
strong {
font-weight: 700;
color: $secondary-text-color;
}
}
.onboarding__links {
margin-bottom: 30px;
& > * {
margin-bottom: 2px;
&:last-child {
margin-bottom: 0;
}
}
}
.onboarding__steps {
margin-bottom: 30px;
&__item {
background: lighten($ui-base-color, 4%);
border: 0;
border-radius: 8px;
display: flex;
width: 100%;
box-sizing: border-box;
align-items: center;
gap: 10px;
padding: 10px;
margin-bottom: 2px;
text-decoration: none;
text-align: start;
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 8%);
}
&__icon {
flex: 0 0 auto;
background: $ui-base-color;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: $dark-text-color;
@media screen and (min-width: 600px) {
display: flex;
}
}
&__progress {
flex: 0 0 auto;
background: $valid-value-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
color: $primary-text-color;
svg {
height: 14px;
width: auto;
}
}
&__description {
flex: 1 1 auto;
line-height: 18px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
h6 {
color: $primary-text-color;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
p {
color: $darker-text-color;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.onboarding__progress-indicator {
display: flex;
align-items: center;
margin-bottom: 30px;
position: sticky;
background: $ui-base-color;
@media screen and (min-width: 600) {
padding: 0 40px;
}
&__line {
height: 4px;
flex: 1 1 auto;
background: lighten($ui-base-color, 4%);
}
&__step {
flex: 0 0 auto;
width: 30px;
height: 30px;
background: lighten($ui-base-color, 4%);
border-radius: 50%;
color: $primary-text-color;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 15px;
height: auto;
}
&.active {
background: $valid-value-color;
}
}
&__step.active,
&__line.active {
background: $valid-value-color;
background-image: linear-gradient(
90deg,
$valid-value-color,
lighten($valid-value-color, 8%),
$valid-value-color
);
background-size: 200px 100%;
animation: skeleton 1.2s ease-in-out infinite;
}
}
.follow-recommendations {
background: darken($ui-base-color, 4%);
border-radius: 8px;
margin-bottom: 30px;
.account:last-child {
border-bottom: 0;
}
}
.copy-paste-text {
background: lighten($ui-base-color, 4%);
border-radius: 8px;
border: 1px solid lighten($ui-base-color, 8%);
padding: 16px;
color: $primary-text-color;
font-size: 15px;
line-height: 22px;
display: flex;
flex-direction: column;
align-items: flex-end;
transition: border-color 300ms linear;
margin-bottom: 30px;
&:focus,
&.focused {
transition: none;
outline: 0;
border-color: $highlight-text-color;
}
&.copied {
border-color: $valid-value-color;
transition: none;
}
textarea {
width: 100%;
height: auto;
background: transparent;
color: inherit;
font: inherit;
border: 0;
padding: 0;
margin-bottom: 30px;
resize: none;
&:focus {
outline: 0;
}
}
}
.compose-form__highlightable {
border-radius: 4px;
transition: box-shadow 300ms linear;
&.active {
transition: none;
box-shadow: 0 0 0 2px rgba(lighten($highlight-text-color, 8%), 0.7);
}
}

View file

@ -406,6 +406,11 @@ body > [data-popper-placement] {
text-overflow: ellipsis;
white-space: nowrap;
&__account {
text-overflow: ellipsis;
overflow: hidden;
}
a {
color: inherit;
text-decoration: inherit;