Change onboarding flow in web UI (#32998)

This commit is contained in:
Eugen Rochko 2024-11-26 17:10:12 +01:00 committed by GitHub
parent 429e08e3d2
commit 7a3dea385e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1142 additions and 1183 deletions

View File

@ -1,58 +0,0 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions(withRelationships = false) {
return (dispatch) => {
dispatch(fetchSuggestionsRequest());
api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
if (withRelationships) {
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
}
}).catch(error => dispatch(fetchSuggestionsFail(error)));
};
}
export function fetchSuggestionsRequest() {
return {
type: SUGGESTIONS_FETCH_REQUEST,
skipLoading: true,
};
}
export function fetchSuggestionsSuccess(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions,
skipLoading: true,
};
}
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
}
export const dismissSuggestion = accountId => (dispatch) => {
dispatch({
type: SUGGESTIONS_DISMISS,
id: accountId,
});
api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
};

View File

@ -0,0 +1,24 @@
import {
apiGetSuggestions,
apiDeleteSuggestion,
} from 'mastodon/api/suggestions';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const fetchSuggestions = createDataLoadingThunk(
'suggestions/fetch',
() => apiGetSuggestions(20),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data.map((x) => x.account)));
dispatch(fetchRelationships(data.map((x) => x.account.id)));
return data;
},
);
export const dismissSuggestion = createDataLoadingThunk(
'suggestions/dismiss',
({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
);

View File

@ -0,0 +1,8 @@
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
export const apiGetSuggestions = (limit: number) =>
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
export const apiDeleteSuggestion = (accountId: string) =>
apiRequestDelete(`v1/suggestions/${accountId}`);

View File

@ -0,0 +1,13 @@
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type ApiSuggestionSourceJSON =
| 'featured'
| 'most_followed'
| 'most_interactions'
| 'similar_to_recently_followed'
| 'friends_of_friends';
export interface ApiSuggestionJSON {
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
account: ApiAccountJSON;
}

View File

@ -10,6 +10,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { FollowButton } from 'mastodon/components/follow_button';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
@ -23,9 +24,6 @@ import { DisplayName } from './display_name';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
@ -35,13 +33,9 @@ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
});
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
const Account = ({ size = 46, account, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
const intl = useIntl();
const handleFollow = useCallback(() => {
onFollow(account);
}, [onFollow, account]);
const handleBlock = useCallback(() => {
onBlock(account);
}, [onBlock, account]);
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
buttons = <FollowButton accountId={account.get('id')} />;
} else if (blocking) {
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
} else if (muting) {
@ -109,9 +102,11 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
} else if (defaultAction === 'block') {
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
} else if (!account.get('suspended') && !account.get('moved') || following) {
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
} else {
buttons = <FollowButton accountId={account.get('id')} />;
}
} else {
buttons = <FollowButton accountId={account.get('id')} />;
}
let muteTimeRemaining;
@ -168,7 +163,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
Account.propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func,
onBlock: PropTypes.func,
onMute: PropTypes.func,
onMuteNotifications: PropTypes.func,

View File

@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
}, [history, onClick]);
}
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
onClick,
}) => {
const handleClick = useHandleClick(onClick);

View File

@ -0,0 +1,67 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
export const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
onActivate: () => void;
placeholder: string;
active: boolean;
}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
useEffect(() => {
if (!active) {
setValue('');
}
}, [active]);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
onBack();
inputRef.current?.blur();
}
},
[onBack],
);
const handleFocus = useCallback(() => {
onActivate();
}, [onActivate]);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<form className='column-search-header' onSubmit={handleSubmit}>
<input
ref={inputRef}
type='search'
value={value}
onChange={handleChange}
onKeyUp={handleKeyUp}
placeholder={placeholder}
onFocus={handleFocus}
/>
{active && (
<button type='button' className='link-button' onClick={onBack}>
<FormattedMessage id='column_search.cancel' defaultMessage='Cancel' />
</button>
)}
</form>
);
};

View File

@ -99,7 +99,12 @@ export const FollowButton: React.FC<{
return (
<Button
onClick={handleClick}
disabled={relationship?.blocked_by || relationship?.blocking}
disabled={
relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following}
className={following ? 'button--destructive' : undefined}
>

View File

@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Card } from './components/card';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
suggestions: state.suggestions.items,
isLoading: state.suggestions.isLoading,
});
class Suggestions extends PureComponent {
static propTypes = {
isLoading: PropTypes.bool,
suggestions: ImmutablePropTypes.list,
suggestions: PropTypes.array,
dispatch: PropTypes.func.isRequired,
...WithRouterPropTypes,
};
@ -32,17 +31,17 @@ class Suggestions extends PureComponent {
const { dispatch, suggestions, history } = this.props;
// If we're navigating back to the screen, do not trigger a reload
if (history.action === 'POP' && suggestions.size > 0) {
if (history.action === 'POP' && suggestions.length > 0) {
return;
}
dispatch(fetchSuggestions(true));
dispatch(fetchSuggestions());
}
render () {
const { isLoading, suggestions } = this.props;
if (!isLoading && suggestions.isEmpty()) {
if (!isLoading && suggestions.length === 0) {
return (
<div className='explore__suggestions scrollable scrollable--flex'>
<div className='empty-column-indicator'>
@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
<div className='explore__suggestions scrollable' data-nosnippet>
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
source={suggestion.getIn(['sources', 0])}
key={suggestion.account_id}
id={suggestion.account_id}
source={suggestion.sources[0]}
/>
))}
</div>

View File

@ -1,217 +0,0 @@
import PropTypes from 'prop-types';
import { useEffect, useCallback, useRef, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
});
const Source = ({ id }) => {
const intl = useIntl();
let label, hint;
switch (id) {
case 'friends_of_friends':
hint = intl.formatMessage(messages.friendsOfFriendsHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'similar_to_recently_followed':
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'featured':
hint = intl.formatMessage(messages.featuredHint, { domain });
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
break;
case 'most_followed':
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
case 'most_interactions':
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
}
return (
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
<Icon icon={InfoIcon} />
{label}
</div>
);
};
Source.propTypes = {
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
};
const Card = ({ id, sources }) => {
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', id]));
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
const dispatch = useDispatch();
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id));
}, [id, dispatch]);
return (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
</div>
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
</div>
<FollowButton accountId={id} />
</div>
);
};
Card.propTypes = {
id: PropTypes.string.isRequired,
sources: ImmutablePropTypes.list,
};
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions = ({ hidden }) => {
const intl = useIntl();
const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
const bodyRef = useRef();
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
const handleLeftNav = useCallback(() => {
bodyRef.current.scrollLeft -= 200;
}, [bodyRef]);
const handleRightNav = useCallback(() => {
bodyRef.current.scrollLeft += 200;
}, [bodyRef]);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
if (dismissed || (!isLoading && suggestions.isEmpty())) {
return null;
}
if (hidden) {
return (
<div className='inline-follow-suggestions' />
);
}
return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
</div>
</div>
<div className='inline-follow-suggestions__body'>
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
{suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
sources={suggestion.get('sources')}
/>
))}
</div>
{canScrollLeft && (
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
</button>
)}
{canScrollRight && (
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
</button>
)}
</div>
</div>
);
};
InlineFollowSuggestions.propTypes = {
hidden: PropTypes.bool,
};

View File

@ -0,0 +1,326 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import {
fetchSuggestions,
dismissSuggestion,
} from 'mastodon/actions/suggestions';
import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
dismiss: {
id: 'follow_suggestions.dismiss',
defaultMessage: "Don't show again",
},
friendsOfFriendsHint: {
id: 'follow_suggestions.hints.friends_of_friends',
defaultMessage: 'This profile is popular among the people you follow.',
},
similarToRecentlyFollowedHint: {
id: 'follow_suggestions.hints.similar_to_recently_followed',
defaultMessage:
'This profile is similar to the profiles you have most recently followed.',
},
featuredHint: {
id: 'follow_suggestions.hints.featured',
defaultMessage: 'This profile has been hand-picked by the {domain} team.',
},
mostFollowedHint: {
id: 'follow_suggestions.hints.most_followed',
defaultMessage: 'This profile is one of the most followed on {domain}.',
},
mostInteractionsHint: {
id: 'follow_suggestions.hints.most_interactions',
defaultMessage:
'This profile has been recently getting a lot of attention on {domain}.',
},
});
const Source: React.FC<{
id: ApiSuggestionSourceJSON;
}> = ({ id }) => {
const intl = useIntl();
let label, hint;
switch (id) {
case 'friends_of_friends':
hint = intl.formatMessage(messages.friendsOfFriendsHint);
label = (
<FormattedMessage
id='follow_suggestions.personalized_suggestion'
defaultMessage='Personalized suggestion'
/>
);
break;
case 'similar_to_recently_followed':
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
label = (
<FormattedMessage
id='follow_suggestions.personalized_suggestion'
defaultMessage='Personalized suggestion'
/>
);
break;
case 'featured':
hint = intl.formatMessage(messages.featuredHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.curated_suggestion'
defaultMessage='Staff pick'
/>
);
break;
case 'most_followed':
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.popular_suggestion'
defaultMessage='Popular suggestion'
/>
);
break;
case 'most_interactions':
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.popular_suggestion'
defaultMessage='Popular suggestion'
/>
);
break;
}
return (
<div
className='inline-follow-suggestions__body__scrollable__card__text-stack__source'
title={hint}
>
<Icon id='' icon={InfoIcon} />
{label}
</div>
);
};
const Card: React.FC<{
id: string;
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
}> = ({ id, sources }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(id));
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
const dispatch = useAppDispatch();
const handleDismiss = useCallback(() => {
void dispatch(dismissSuggestion({ accountId: id }));
}, [id, dispatch]);
return (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton
icon=''
iconComponent={CloseIcon}
onClick={handleDismiss}
title={intl.formatMessage(messages.dismiss)}
/>
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account?.acct}`}>
<Avatar account={account} size={72} />
</Link>
</div>
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account?.acct}`}>
<DisplayName account={account} />
</Link>
{firstVerifiedField ? (
<VerifiedBadge link={firstVerifiedField.value} />
) : (
<Source id={sources[0]} />
)}
</div>
<FollowButton accountId={id} />
</div>
);
};
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions: React.FC<{
hidden?: boolean;
}> = ({ hidden }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const dismissed = useAppSelector(
(state) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
);
const bodyRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
void dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
const handleLeftNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft -= 200;
}, []);
const handleRightNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft += 200;
}, []);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
if (dismissed || (!isLoading && suggestions.length === 0)) {
return null;
}
if (hidden) {
return <div className='inline-follow-suggestions' />;
}
return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3>
<FormattedMessage
id='follow_suggestions.who_to_follow'
defaultMessage='Who to follow'
/>
</h3>
<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}>
<FormattedMessage
id='follow_suggestions.dismiss'
defaultMessage="Don't show again"
/>
</button>
<Link to='/explore/suggestions' className='link-button'>
<FormattedMessage
id='follow_suggestions.view_all'
defaultMessage='View all'
/>
</Link>
</div>
</div>
<div className='inline-follow-suggestions__body'>
<div
className='inline-follow-suggestions__body__scrollable'
ref={bodyRef}
onScroll={handleScroll}
>
{suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))}
</div>
{canScrollLeft && (
<button
className='inline-follow-suggestions__body__scroll-button left'
onClick={handleLeftNav}
aria-label={intl.formatMessage(messages.previous)}
>
<div className='inline-follow-suggestions__body__scroll-button__icon'>
<Icon id='' icon={ChevronLeftIcon} />
</div>
</button>
)}
{canScrollRight && (
<button
className='inline-follow-suggestions__body__scroll-button right'
onClick={handleRightNav}
aria-label={intl.formatMessage(messages.next)}
>
<div className='inline-follow-suggestions__body__scroll-button__icon'>
<Icon id='' icon={ChevronRightIcon} />
</div>
</button>
)}
</div>
</div>
);
};

View File

@ -7,11 +7,8 @@ import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowing } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { apiRequest } from 'mastodon/api';
@ -25,14 +22,12 @@ import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
@ -49,54 +44,6 @@ const messages = defineMessages({
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
listId: string;
@ -156,6 +103,7 @@ const AccountItem: React.FC<{
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
secondary={partOfList}
onClick={handleClick}
/>
</div>
@ -171,9 +119,6 @@ const ListMembers: React.FC<{
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
);
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
@ -195,8 +140,6 @@ const ListMembers: React.FC<{
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
@ -265,8 +208,8 @@ const ListMembers: React.FC<{
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
if (mode === 'add' && searching) {
displayedAccountIds = searchAccountIds;
} else {
displayedAccountIds = accountIds;
}
@ -276,31 +219,21 @@ const ListMembers: React.FC<{
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
extraButton={
<button
onClick={handleSearchClick}
type='button'
className='column-header__button'
title={intl.formatMessage(messages.enterSearch)}
aria-label={intl.formatMessage(messages.enterSearch)}
>
<Icon id='plus' icon={AddIcon} />
</button>
}
/>
) : (
<ColumnSearchHeader
onBack={handleDismissSearchClick}
onSubmit={handleSearch}
/>
)}
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<ColumnSearchHeader
placeholder={intl.formatMessage(messages.placeholder)}
onBack={handleDismissSearchClick}
onSubmit={handleSearch}
onActivate={handleSearchClick}
active={mode === 'add'}
/>
<ScrollableList
scrollKey='list_members'
@ -310,17 +243,15 @@ const ListMembers: React.FC<{
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>
</>
)
<div className='column-footer'>
<Link to={`/lists/${id}`} className='button button--block'>
<FormattedMessage id='lists.done' defaultMessage='Done' />
</Link>
</div>
</>
}
emptyMessage={
mode === 'remove' ? (

View File

@ -1,57 +0,0 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
import CheckIcon from '@/material-icons/400-24px/done.svg?react';
import { Icon } from 'mastodon/components/icon';
export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => {
const content = (
<>
<div className='onboarding__steps__item__icon'>
<Icon id={icon} icon={iconComponent} />
</div>
<div className='onboarding__steps__item__description'>
<h6>{label}</h6>
<p>{description}</p>
</div>
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
{completed ? <Icon icon={CheckIcon} /> : <Icon icon={ArrowRightAltIcon} />}
</div>
</>
);
if (href) {
return (
<a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'>
{content}
</a>
);
} else if (to) {
return (
<Link to={to} className='onboarding__steps__item'>
{content}
</Link>
);
}
return (
<button onClick={onClick} className='onboarding__steps__item'>
{content}
</button>
);
};
Step.propTypes = {
label: PropTypes.node,
description: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
completed: PropTypes.bool,
href: PropTypes.string,
to: PropTypes.string,
onClick: PropTypes.func,
};

View File

@ -1,62 +0,0 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { markAsPartial } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { EmptyAccount } from 'mastodon/components/empty_account';
import Account from 'mastodon/containers/account_container';
import { useAppSelector } from 'mastodon/store';
export const Follows = () => {
const dispatch = useDispatch();
const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading']));
const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items']));
useEffect(() => {
dispatch(fetchSuggestions(true));
return () => {
dispatch(markAsPartial('home'));
};
}, [dispatch]);
let loadedContent;
if (isLoading) {
loadedContent = (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />);
} else if (suggestions.isEmpty()) {
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
} else {
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
}
return (
<>
<ColumnBackButton />
<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>
<div className='follow-recommendations'>
{loadedContent}
</div>
<p className='onboarding__lead'><FormattedMessage 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: chunks => <strong>{chunks}</strong> }} /></p>
<div className='onboarding__footer'>
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></Link>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,191 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import { fetchRelationships } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { markAsPartial } from 'mastodon/actions/timelines';
import { apiRequest } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { ColumnSearchHeader } from 'mastodon/components/column_search_header';
import ScrollableList from 'mastodon/components/scrollable_list';
import Account from 'mastodon/containers/account_container';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
title: {
id: 'onboarding.follows.title',
defaultMessage: 'Follow people to get started',
},
search: { id: 'onboarding.follows.search', defaultMessage: 'Search' },
back: { id: 'onboarding.follows.back', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
export const Follows: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const suggestions = useAppSelector((state) => state.suggestions.items);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [mode, setMode] = useState<Mode>('remove');
const [isLoadingSearch, setIsLoadingSearch] = useState(false);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
void dispatch(fetchSuggestions());
return () => {
dispatch(markAsPartial('home'));
};
}, [dispatch]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setIsSearching(false);
}, [setMode, setIsSearching]);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setIsSearching(false);
setSearchAccountIds([]);
return;
}
setIsSearching(true);
setIsLoadingSearch(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(data.map((a) => a.id)));
setSearchAccountIds(data.map((a) => a.id));
setIsLoadingSearch(false);
return '';
})
.catch(() => {
setIsLoadingSearch(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add' && isSearching) {
displayedAccountIds = searchAccountIds;
} else {
displayedAccountIds = suggestions.map(
(suggestion) => suggestion.account_id,
);
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
title={intl.formatMessage(messages.title)}
icon='person'
iconComponent={PersonIcon}
multiColumn={multiColumn}
showBackButton
/>
<ColumnSearchHeader
placeholder={intl.formatMessage(messages.search)}
onBack={handleDismissSearchClick}
onActivate={handleSearchClick}
active={mode === 'add'}
onSubmit={handleSearch}
/>
<ScrollableList
scrollKey='follow_recommendations'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
showLoading={
(isLoading || isLoadingSearch) && displayedAccountIds.length === 0
}
hasMore={false}
isLoading={isLoading || isLoadingSearch}
footer={
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link className='button button--block' to='/home'>
<FormattedMessage
id='onboarding.follows.done'
defaultMessage='Done'
/>
</Link>
</div>
</>
}
emptyMessage={
mode === 'remove' ? (
<FormattedMessage
id='onboarding.follows.empty'
defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.'
/>
) : (
<FormattedMessage
id='lists.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<Account
/* @ts-expect-error inferred props are wrong */
id={accountId}
key={accountId}
withBio={false}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Follows;

View File

@ -1,91 +0,0 @@
import { useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link, Switch, Route } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import illustration from '@/images/elephant_ui_conversation.svg';
import AccountCircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import { focusCompose } from 'mastodon/actions/compose';
import { Icon } from 'mastodon/components/icon';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import Column from 'mastodon/features/ui/components/column';
import { me } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store';
import { assetHost } from 'mastodon/utils/config';
import { Step } from './components/step';
import { Follows } from './follows';
import { Profile } from './profile';
import { Share } from './share';
const messages = defineMessages({
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
});
const Onboarding = () => {
const account = useAppSelector(state => state.getIn(['accounts', me]));
const dispatch = useDispatch();
const intl = useIntl();
const handleComposeClick = useCallback(() => {
dispatch(focusCompose(intl.formatMessage(messages.template)));
}, [dispatch, intl]);
return (
<Column>
{account ? (
<Switch>
<Route path='/start' exact>
<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 to='/start/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} 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 to='/start/follows' completed={(account.get('following_count') * 1) >= 1} icon='user-plus' iconComponent={PersonAddIcon} label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
<Step onClick={handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' iconComponent={EditNoteIcon} 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.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
<Step to='/start/share' icon='copy' iconComponent={ContentCopyIcon} 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="Don't need help getting started?" /></p>
<div className='onboarding__links'>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<Icon icon={ArrowRightAltIcon} />
</Link>
<Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<Icon icon={ArrowRightAltIcon} />
</Link>
</div>
</div>
</Route>
<Route path='/start/profile' component={Profile} />
<Route path='/start/follows' component={Follows} />
<Route path='/start/share' component={Share} />
</Switch>
) : <NotSignedInIndicator />}
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
export default Onboarding;

View File

@ -1,162 +0,0 @@
import { useState, useMemo, useCallback, createRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import Toggle from 'react-toggle';
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { updateAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { me } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store';
import { unescapeHTML } from 'mastodon/utils/html';
const messages = defineMessages({
uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' },
uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' },
});
const nullIfMissing = path => path.endsWith('missing.png') ? null : path;
export const Profile = () => {
const account = useAppSelector(state => state.getIn(['accounts', me]));
const [displayName, setDisplayName] = useState(account.get('display_name'));
const [note, setNote] = useState(unescapeHTML(account.get('note')));
const [avatar, setAvatar] = useState(null);
const [header, setHeader] = useState(null);
const [discoverable, setDiscoverable] = useState(account.get('discoverable'));
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState();
const avatarFileRef = createRef();
const headerFileRef = createRef();
const dispatch = useDispatch();
const intl = useIntl();
const history = useHistory();
const handleDisplayNameChange = useCallback(e => {
setDisplayName(e.target.value);
}, [setDisplayName]);
const handleNoteChange = useCallback(e => {
setNote(e.target.value);
}, [setNote]);
const handleDiscoverableChange = useCallback(e => {
setDiscoverable(e.target.checked);
}, [setDiscoverable]);
const handleAvatarChange = useCallback(e => {
setAvatar(e.target?.files?.[0]);
}, [setAvatar]);
const handleHeaderChange = useCallback(e => {
setHeader(e.target?.files?.[0]);
}, [setHeader]);
const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : nullIfMissing(account.get('avatar')), [avatar, account]);
const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : nullIfMissing(account.get('header')), [header, account]);
const handleSubmit = useCallback(() => {
setIsSaving(true);
dispatch(updateAccount({
displayName,
note,
avatar,
header,
discoverable,
indexable: discoverable,
})).then(() => history.push('/start/follows')).catch(err => {
setIsSaving(false);
setErrors(err.response.data.details);
});
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
return (
<>
<ColumnBackButton />
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='onboarding.profile.title' defaultMessage='Profile setup' /></h3>
<p><FormattedMessage id='onboarding.profile.lead' defaultMessage='You can always complete this later in the settings, where even more customization options are available.' /></p>
</div>
<div className='simple_form'>
<div className='onboarding__profile'>
<label className={classNames('app-form__header-input', { selected: !!headerPreview, invalid: !!errors?.header })} title={intl.formatMessage(messages.uploadHeader)}>
<input
type='file'
hidden
ref={headerFileRef}
accept='image/*'
onChange={handleHeaderChange}
/>
{headerPreview && <img src={headerPreview} alt='' />}
<Icon icon={headerPreview ? EditIcon : AddPhotoAlternateIcon} />
</label>
<label className={classNames('app-form__avatar-input', { selected: !!avatarPreview, invalid: !!errors?.avatar })} title={intl.formatMessage(messages.uploadAvatar)}>
<input
type='file'
hidden
ref={avatarFileRef}
accept='image/*'
onChange={handleAvatarChange}
/>
{avatarPreview && <img src={avatarPreview} alt='' />}
<Icon icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon} />
</label>
</div>
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.display_name })}>
<label htmlFor='display_name'><FormattedMessage id='onboarding.profile.display_name' defaultMessage='Display name' /></label>
<span className='hint'><FormattedMessage id='onboarding.profile.display_name_hint' defaultMessage='Your full name or your fun name…' /></span>
<div className='label_input'>
<input id='display_name' type='text' value={displayName} onChange={handleDisplayNameChange} maxLength={30} />
</div>
</div>
<div className={classNames('input with_block_label', { field_with_errors: !!errors?.note })}>
<label htmlFor='note'><FormattedMessage id='onboarding.profile.note' defaultMessage='Bio' /></label>
<span className='hint'><FormattedMessage id='onboarding.profile.note_hint' defaultMessage='You can @mention other people or #hashtags…' /></span>
<div className='label_input'>
<textarea id='note' value={note} onChange={handleNoteChange} maxLength={500} />
</div>
</div>
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong><FormattedMessage id='onboarding.profile.discoverable' defaultMessage='Make my profile discoverable' /></strong> <span className='recommended'><FormattedMessage id='recommended' defaultMessage='Recommended' /></span>
<span className='hint'><FormattedMessage id='onboarding.profile.discoverable_hint' defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.' /></span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={discoverable} onChange={handleDiscoverableChange} />
</div>
</div>
</label>
</div>
<div className='onboarding__footer'>
<Button block onClick={handleSubmit} disabled={isSaving}>{isSaving ? <LoadingIndicator /> : <FormattedMessage id='onboarding.profile.save_and_continue' defaultMessage='Save and continue' />}</Button>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,329 @@
import { useState, useMemo, useCallback, createRef } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { useHistory } from 'react-router-dom';
import Toggle from 'react-toggle';
import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import { updateAccount } from 'mastodon/actions/accounts';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { unescapeHTML } from 'mastodon/utils/html';
const messages = defineMessages({
title: {
id: 'onboarding.profile.title',
defaultMessage: 'Profile setup',
},
uploadHeader: {
id: 'onboarding.profile.upload_header',
defaultMessage: 'Upload profile header',
},
uploadAvatar: {
id: 'onboarding.profile.upload_avatar',
defaultMessage: 'Upload profile picture',
},
});
const nullIfMissing = (path: string) =>
path.endsWith('missing.png') ? null : path;
interface ApiAccountErrors {
display_name?: unknown;
note?: unknown;
avatar?: unknown;
header?: unknown;
}
export const Profile: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const [displayName, setDisplayName] = useState(account?.display_name ?? '');
const [note, setNote] = useState(
account ? (unescapeHTML(account.note) ?? '') : '',
);
const [avatar, setAvatar] = useState<File>();
const [header, setHeader] = useState<File>();
const [discoverable, setDiscoverable] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ApiAccountErrors>();
const avatarFileRef = createRef<HTMLInputElement>();
const headerFileRef = createRef<HTMLInputElement>();
const dispatch = useAppDispatch();
const intl = useIntl();
const history = useHistory();
const handleDisplayNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
},
[setDisplayName],
);
const handleNoteChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNote(e.target.value);
},
[setNote],
);
const handleDiscoverableChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setDiscoverable(e.target.checked);
},
[setDiscoverable],
);
const handleAvatarChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setAvatar(e.target.files?.[0]);
},
[setAvatar],
);
const handleHeaderChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setHeader(e.target.files?.[0]);
},
[setHeader],
);
const avatarPreview = useMemo(
() =>
avatar
? URL.createObjectURL(avatar)
: nullIfMissing(account?.avatar ?? 'missing.png'),
[avatar, account],
);
const headerPreview = useMemo(
() =>
header
? URL.createObjectURL(header)
: nullIfMissing(account?.header ?? 'missing.png'),
[header, account],
);
const handleSubmit = useCallback(() => {
setIsSaving(true);
dispatch(
updateAccount({
displayName,
note,
avatar,
header,
discoverable,
indexable: discoverable,
}),
)
.then(() => {
history.push('/start/follows');
return '';
})
// eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable
.catch((err) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (err.response) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const { details }: { details: ApiAccountErrors } = err.response.data;
setErrors(details);
}
setIsSaving(false);
});
}, [dispatch, displayName, note, avatar, header, discoverable, history]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
title={intl.formatMessage(messages.title)}
icon='person'
iconComponent={PersonIcon}
multiColumn={multiColumn}
/>
<div className='scrollable scrollable--flex'>
<div className='simple_form app-form'>
<div className='onboarding__profile'>
<label
className={classNames('app-form__header-input', {
selected: !!headerPreview,
invalid: !!errors?.header,
})}
title={intl.formatMessage(messages.uploadHeader)}
>
<input
type='file'
hidden
ref={headerFileRef}
accept='image/*'
onChange={handleHeaderChange}
/>
{headerPreview && <img src={headerPreview} alt='' />}
<Icon
id=''
icon={headerPreview ? EditIcon : AddPhotoAlternateIcon}
/>
</label>
<label
className={classNames('app-form__avatar-input', {
selected: !!avatarPreview,
invalid: !!errors?.avatar,
})}
title={intl.formatMessage(messages.uploadAvatar)}
>
<input
type='file'
hidden
ref={avatarFileRef}
accept='image/*'
onChange={handleAvatarChange}
/>
{avatarPreview && <img src={avatarPreview} alt='' />}
<Icon
id=''
icon={avatarPreview ? EditIcon : AddPhotoAlternateIcon}
/>
</label>
</div>
<div className='fields-group'>
<div
className={classNames('input with_block_label', {
field_with_errors: !!errors?.display_name,
})}
>
<label htmlFor='display_name'>
<FormattedMessage
id='onboarding.profile.display_name'
defaultMessage='Display name'
/>
</label>
<span className='hint'>
<FormattedMessage
id='onboarding.profile.display_name_hint'
defaultMessage='Your full name or your fun name…'
/>
</span>
<div className='label_input'>
<input
id='display_name'
type='text'
value={displayName}
onChange={handleDisplayNameChange}
maxLength={30}
/>
</div>
</div>
</div>
<div className='fields-group'>
<div
className={classNames('input with_block_label', {
field_with_errors: !!errors?.note,
})}
>
<label htmlFor='note'>
<FormattedMessage
id='onboarding.profile.note'
defaultMessage='Bio'
/>
</label>
<span className='hint'>
<FormattedMessage
id='onboarding.profile.note_hint'
defaultMessage='You can @mention other people or #hashtags…'
/>
</span>
<div className='label_input'>
<textarea
id='note'
value={note}
onChange={handleNoteChange}
maxLength={500}
/>
</div>
</div>
</div>
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='onboarding.profile.discoverable'
defaultMessage='Make my profile discoverable'
/>
</strong>{' '}
<span className='recommended'>
<FormattedMessage
id='recommended'
defaultMessage='Recommended'
/>
</span>
<span className='hint'>
<FormattedMessage
id='onboarding.profile.discoverable_hint'
defaultMessage='When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={discoverable}
onChange={handleDiscoverableChange}
/>
</div>
</div>
</label>
</div>
<div className='spacer' />
<div className='column-footer'>
<Button block onClick={handleSubmit} disabled={isSaving}>
{isSaving ? (
<LoadingIndicator />
) : (
<FormattedMessage
id='onboarding.profile.save_and_continue'
defaultMessage='Save and continue'
/>
)}
</Button>
</div>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Profile;

View File

@ -1,120 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import SwipeableViews from 'react-swipeable-views';
import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { CopyPasteText } from 'mastodon/components/copy_paste_text';
import { Icon } from 'mastodon/components/icon';
import { me, domain } from 'mastodon/initial_state';
import { useAppSelector } from 'mastodon/store';
const messages = defineMessages({
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
});
class TipCarousel extends PureComponent {
static propTypes = {
children: PropTypes.node,
};
state = {
index: 0,
};
handleSwipe = index => {
this.setState({ index });
};
handleChangeIndex = e => {
this.setState({ index: Number(e.currentTarget.getAttribute('data-index')) });
};
handleKeyDown = e => {
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
this.setState(({ index }, { children }) => ({ index: Math.abs(index - 1) % children.length }));
break;
case 'ArrowRight':
e.preventDefault();
this.setState(({ index }, { children }) => ({ index: (index + 1) % children.length }));
break;
}
};
render () {
const { children } = this.props;
const { index } = this.state;
return (
<div className='tip-carousel' tabIndex='0' onKeyDown={this.handleKeyDown}>
<SwipeableViews onChangeIndex={this.handleSwipe} index={index} enableMouseEvents tabIndex='-1'>
{children}
</SwipeableViews>
<div className='media-modal__pagination'>
{children.map((_, i) => (
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
{i + 1}
</button>
))}
</div>
</div>
);
}
}
export const Share = () => {
const account = useAppSelector(state => state.getIn(['accounts', me]));
const intl = useIntl();
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
return (
<>
<ColumnBackButton />
<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 })} />
<TipCarousel>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
</TipCarousel>
<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'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<Icon icon={ArrowRightAltIcon} />
</Link>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<Icon icon={ArrowRightAltIcon} />
</Link>
</div>
<div className='onboarding__footer'>
<Link className='link-button' to='/start'><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></Link>
</div>
</div>
</>
);
};

View File

@ -66,8 +66,9 @@ import {
Mutes,
PinnedStatuses,
Directory,
OnboardingProfile,
OnboardingFollows,
Explore,
Onboarding,
About,
PrivacyPolicy,
} from './util/async-components';
@ -219,7 +220,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' component={Onboarding} content={children} />
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
<WrappedRoute path='/start/follows' component={OnboardingFollows} 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} />

View File

@ -158,8 +158,12 @@ export function Directory () {
return import(/* webpackChunkName: "features/directory" */'../../directory');
}
export function Onboarding () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding');
export function OnboardingProfile () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/profile');
}
export function OnboardingFollows () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/follows');
}
export function CompareHistoryModal () {

View File

@ -162,6 +162,7 @@
"column_header.pin": "Pin",
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_search.cancel": "Cancel",
"column_subheading.settings": "Settings",
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
@ -649,44 +650,21 @@
"notifications_permission_banner.enable": "Enable desktop notifications",
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
"notifications_permission_banner.title": "Never miss a thing",
"onboarding.action.back": "Take me back",
"onboarding.actions.back": "Take me back",
"onboarding.actions.go_to_explore": "Take me to trending",
"onboarding.actions.go_to_home": "Take me to my home feed",
"onboarding.compose.template": "Hello #Mastodon!",
"onboarding.follows.back": "Back",
"onboarding.follows.done": "Done",
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
"onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
"onboarding.follows.title": "Personalize your home feed",
"onboarding.follows.search": "Search",
"onboarding.follows.title": "Follow people to get started",
"onboarding.profile.discoverable": "Make my profile discoverable",
"onboarding.profile.discoverable_hint": "When you opt in to discoverability on Mastodon, your posts may appear in search results and trending, and your profile may be suggested to people with similar interests to you.",
"onboarding.profile.display_name": "Display name",
"onboarding.profile.display_name_hint": "Your full name or your fun name…",
"onboarding.profile.lead": "You can always complete this later in the settings, where even more customization options are available.",
"onboarding.profile.note": "Bio",
"onboarding.profile.note_hint": "You can @mention other people or #hashtags…",
"onboarding.profile.save_and_continue": "Save and continue",
"onboarding.profile.title": "Profile setup",
"onboarding.profile.upload_avatar": "Upload profile picture",
"onboarding.profile.upload_header": "Upload profile header",
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
"onboarding.share.next_steps": "Possible next steps:",
"onboarding.share.title": "Share your profile",
"onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:",
"onboarding.start.skip": "Don't need help getting started?",
"onboarding.start.title": "You've made it!",
"onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
"onboarding.steps.follow_people.title": "Personalize your home feed",
"onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}",
"onboarding.steps.publish_status.title": "Make your first post",
"onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.",
"onboarding.steps.setup_profile.title": "Personalize your profile",
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon",
"onboarding.steps.share_profile.title": "Share your Mastodon profile",
"onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
"onboarding.tips.accounts_from_other_servers": "<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!",
"onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",
"onboarding.tips.verification": "<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!",
"password_confirmation.exceeds_maxlength": "Password confirmation exceeds the maximum password length",
"password_confirmation.mismatching": "Password confirmation does not match",
"picture_in_picture.restore": "Put it back",

View File

@ -0,0 +1,12 @@
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
export interface Suggestion extends Omit<ApiSuggestionJSON, 'account'> {
account_id: string;
}
export const createSuggestion = (
serverJSON: ApiSuggestionJSON,
): Suggestion => ({
sources: serverJSON.sources,
account_id: serverJSON.account.id,
});

View File

@ -35,7 +35,7 @@ import server from './server';
import settings from './settings';
import status_lists from './status_lists';
import statuses from './statuses';
import suggestions from './suggestions';
import { suggestionsReducer } from './suggestions';
import tags from './tags';
import timelines from './timelines';
import trends from './trends';
@ -70,7 +70,7 @@ const reducers = {
lists: listsReducer,
filters,
conversations,
suggestions,
suggestions: suggestionsReducer,
polls,
trends,
markers: markersReducer,

View File

@ -1,40 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { blockAccountSuccess, muteAccountSuccess } from 'mastodon/actions/accounts';
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import {
SUGGESTIONS_FETCH_REQUEST,
SUGGESTIONS_FETCH_SUCCESS,
SUGGESTIONS_FETCH_FAIL,
SUGGESTIONS_DISMISS,
} from '../actions/suggestions';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
});
export default function suggestionsReducer(state = initialState, action) {
switch(action.type) {
case SUGGESTIONS_FETCH_REQUEST:
return state.set('isLoading', true);
case SUGGESTIONS_FETCH_SUCCESS:
return state.withMutations(map => {
map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
map.set('isLoading', false);
});
case SUGGESTIONS_FETCH_FAIL:
return state.set('isLoading', false);
case SUGGESTIONS_DISMISS:
return state.update('items', list => list.filterNot(x => x.get('account') === action.id));
case blockAccountSuccess.type:
case muteAccountSuccess.type:
return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id));
case blockDomainSuccess.type:
return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account'))));
default:
return state;
}
}

View File

@ -0,0 +1,60 @@
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import {
blockAccountSuccess,
muteAccountSuccess,
} from 'mastodon/actions/accounts';
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import {
fetchSuggestions,
dismissSuggestion,
} from 'mastodon/actions/suggestions';
import { createSuggestion } from 'mastodon/models/suggestion';
import type { Suggestion } from 'mastodon/models/suggestion';
interface State {
items: Suggestion[];
isLoading: boolean;
}
const initialState: State = {
items: [],
isLoading: false,
};
export const suggestionsReducer = createReducer(initialState, (builder) => {
builder.addCase(fetchSuggestions.pending, (state) => {
state.isLoading = true;
});
builder.addCase(fetchSuggestions.fulfilled, (state, action) => {
state.items = action.payload.map(createSuggestion);
state.isLoading = false;
});
builder.addCase(fetchSuggestions.rejected, (state) => {
state.isLoading = false;
});
builder.addCase(dismissSuggestion.pending, (state, action) => {
state.items = state.items.filter(
(x) => x.account_id !== action.meta.arg.accountId,
);
});
builder.addCase(blockDomainSuccess, (state, action) => {
state.items = state.items.filter(
(x) =>
!action.payload.accounts.some((account) => account.id === x.account_id),
);
});
builder.addMatcher(
isAnyOf(blockAccountSuccess, muteAccountSuccess),
(state, action) => {
state.items = state.items.filter(
(x) => x.account_id !== action.payload.relationship.id,
);
},
);
});

View File

@ -447,17 +447,26 @@
color: lighten($ui-highlight-color, 8%);
}
.compose-form .autosuggest-textarea__textarea,
.compose-form__highlightable,
.autosuggest-textarea__suggestions,
.search__input,
.search__popout,
.emoji-mart-search input,
.language-dropdown__dropdown .emoji-mart-search input,
.poll__option input[type='text'] {
background: darken($ui-base-color, 10%);
}
.search__popout__menu__item {
&:hover,
&:active,
&:focus,
&.active {
color: $white;
mark,
.icon-button {
color: $white;
}
}
}
.inline-follow-suggestions {
background-color: rgba($ui-highlight-color, 0.1);
border-bottom-color: rgba($ui-highlight-color, 0.3);

View File

@ -80,4 +80,5 @@ body {
--rich-text-text-color: rgba(114, 47, 83, 100%);
--rich-text-decorations-color: rgba(255, 175, 212, 100%);
--input-placeholder-color: #{transparentize($dark-text-color, 0.5)};
--input-background-color: #{darken($ui-base-color, 10%)};
}

View File

@ -4,7 +4,7 @@
width: 100%;
box-shadow: none;
font-family: inherit;
background: $ui-base-color;
background: var(--input-background-color);
color: $darker-text-color;
border-radius: 4px;
border: 1px solid var(--background-border-color);

View File

@ -44,6 +44,10 @@
color: $ui-primary-color;
cursor: default;
}
&:focus-visible {
outline: $ui-button-icon-focus-outline;
}
}
.button {
@ -419,10 +423,10 @@ body > [data-popper-placement] {
&__suggestions {
box-shadow: var(--dropdown-shadow);
background: $ui-base-color;
background: var(--input-background-color);
border: 1px solid var(--background-border-color);
border-radius: 0 0 4px 4px;
color: $secondary-text-color;
color: var(--on-input-color);
font-size: 14px;
padding: 0;
@ -435,7 +439,7 @@ body > [data-popper-placement] {
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
color: $secondary-text-color;
color: var(--on-input-color);
&:last-child {
border-radius: 0 0 4px 4px;
@ -549,7 +553,7 @@ body > [data-popper-placement] {
transition: border-color 300ms linear;
min-height: 0;
position: relative;
background: $ui-base-color;
background: var(--input-background-color);
overflow-y: auto;
&.active {
@ -622,7 +626,7 @@ body > [data-popper-placement] {
width: 100%;
margin: 0;
color: $secondary-text-color;
background: $ui-base-color;
background: var(--input-background-color);
font-family: inherit;
font-size: 14px;
padding: 12px;
@ -3247,203 +3251,6 @@ $ui-header-logo-wordmark-width: 99px;
}
}
.onboarding__footer {
margin-top: 30px;
color: $dark-text-color;
text-align: center;
font-size: 14px;
.link-button {
display: inline-block;
color: inherit;
font-size: inherit;
}
}
.onboarding__link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: $highlight-text-color;
background: lighten($ui-base-color, 4%);
border-radius: 8px;
padding: 10px 15px;
box-sizing: border-box;
font-size: 14px;
font-weight: 500;
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;
padding-inline-end: 15px;
margin-bottom: 2px;
text-decoration: none;
text-align: start;
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 8%);
}
&__icon {
flex: 0 0 auto;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: $highlight-text-color;
font-size: 1.2rem;
@media screen and (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;
}
}
&__go {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
color: $highlight-text-color;
font-size: 17px;
svg {
height: 1.5em;
width: auto;
}
}
&__description {
flex: 1 1 auto;
line-height: 20px;
h6 {
color: $highlight-text-color;
font-weight: 500;
font-size: 14px;
}
p {
color: $darker-text-color;
overflow: hidden;
}
}
}
}
.follow-recommendations {
background: darken($ui-base-color, 4%);
border-radius: 8px;
margin-bottom: 30px;
.account:last-child {
border-bottom: 0;
}
&__empty {
text-align: center;
color: $darker-text-color;
font-weight: 500;
padding: 40px;
}
}
.tip-carousel {
border: 1px solid transparent;
border-radius: 8px;
padding: 16px;
margin-bottom: 30px;
&:focus {
outline: 0;
border-color: $highlight-text-color;
}
.media-modal__pagination {
margin-bottom: 0;
}
}
.copy-paste-text {
background: lighten($ui-base-color, 4%);
border-radius: 8px;
@ -3490,9 +3297,10 @@ $ui-header-logo-wordmark-width: 99px;
.onboarding__profile {
position: relative;
margin-bottom: 40px + 20px;
margin-top: -20px;
.app-form__avatar-input {
border: 2px solid $ui-base-color;
border: 2px solid var(--background-color);
position: absolute;
inset-inline-start: -2px;
bottom: -40px;
@ -5569,7 +5377,7 @@ a.status-card {
inset-inline-start: 0;
margin-top: -2px;
width: 100%;
background: $ui-base-color;
background: var(--input-background-color);
border: 1px solid var(--background-border-color);
border-radius: 0 0 4px 4px;
box-shadow: var(--dropdown-shadow);
@ -11116,19 +10924,22 @@ noscript {
.column-search-header {
display: flex;
border-radius: 4px 4px 0 0;
gap: 12px;
align-items: center;
border: 1px solid var(--background-border-color);
.column-header__back-button.compact {
flex: 0 0 auto;
color: $primary-text-color;
}
border-top: 0;
border-bottom: 0;
padding: 16px;
padding-bottom: 8px;
input {
background: transparent;
border: 0;
color: $primary-text-color;
background: var(--input-background-color);
border: 1px solid var(--background-border-color);
color: var(--on-input-color);
padding: 12px;
font-size: 16px;
line-height: normal;
border-radius: 4px;
display: block;
flex: 1 1 auto;

View File

@ -1255,13 +1255,13 @@ code {
}
.app-form {
padding: 20px;
padding: 16px;
&__avatar-input,
&__header-input {
display: block;
border-radius: 8px;
background: var(--dropdown-background-color);
background: var(--surface-variant-background-color);
position: relative;
cursor: pointer;
@ -1309,7 +1309,7 @@ code {
}
&:hover {
background-color: var(--dropdown-border-color);
background-color: var(--surface-variant-active-background-color);
}
}

View File

@ -120,4 +120,6 @@ $font-monospace: 'mastodon-font-monospace' !default;
--rich-text-text-color: rgba(255, 175, 212, 100%);
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
--input-placeholder-color: #{$dark-text-color};
--input-background-color: var(--surface-variant-background-color);
--on-input-color: #{$secondary-text-color};
}