|
@@ -0,0 +1,201 @@
|
|
|
+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 { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
|
|
|
+import { changeSetting } from 'mastodon/actions/settings';
|
|
|
+import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
|
|
|
+import { Avatar } from 'mastodon/components/avatar';
|
|
|
+import { Button } from 'mastodon/components/button';
|
|
|
+import { DisplayName } from 'mastodon/components/display_name';
|
|
|
+import { Icon } from 'mastodon/components/icon';
|
|
|
+import { IconButton } from 'mastodon/components/icon_button';
|
|
|
+import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
|
|
+
|
|
|
+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" },
|
|
|
+});
|
|
|
+
|
|
|
+const Source = ({ id }) => {
|
|
|
+ let label;
|
|
|
+
|
|
|
+ switch (id) {
|
|
|
+ case 'friends_of_friends':
|
|
|
+ case 'similar_to_recently_followed':
|
|
|
+ label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
|
|
|
+ break;
|
|
|
+ case 'featured':
|
|
|
+ label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage="Editors' Choice" />;
|
|
|
+ break;
|
|
|
+ case 'most_followed':
|
|
|
+ case 'most_interactions':
|
|
|
+ label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className='inline-follow-suggestions__body__scrollable__card__text-stack__source'>
|
|
|
+ <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, source }) => {
|
|
|
+ const intl = useIntl();
|
|
|
+ const account = useSelector(state => state.getIn(['accounts', id]));
|
|
|
+ const relationship = useSelector(state => state.getIn(['relationships', id]));
|
|
|
+ const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
|
|
+ const dispatch = useDispatch();
|
|
|
+ const following = relationship?.get('following') ?? relationship?.get('requested');
|
|
|
+
|
|
|
+ const handleFollow = useCallback(() => {
|
|
|
+ if (following) {
|
|
|
+ dispatch(unfollowAccount(id));
|
|
|
+ } else {
|
|
|
+ dispatch(followAccount(id));
|
|
|
+ }
|
|
|
+ }, [id, following, dispatch]);
|
|
|
+
|
|
|
+ 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={source.get(0)} />}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+Card.propTypes = {
|
|
|
+ id: PropTypes.string.isRequired,
|
|
|
+ source: 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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')}
|
|
|
+ source={suggestion.get('source')}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </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,
|
|
|
+};
|