inline_follow_suggestions.jsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import PropTypes from 'prop-types';
  2. import { useEffect, useCallback, useRef, useState } from 'react';
  3. import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
  4. import { Link } from 'react-router-dom';
  5. import ImmutablePropTypes from 'react-immutable-proptypes';
  6. import { useDispatch, useSelector } from 'react-redux';
  7. import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
  8. import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
  9. import CloseIcon from '@/material-icons/400-24px/close.svg?react';
  10. import InfoIcon from '@/material-icons/400-24px/info.svg?react';
  11. import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
  12. import { changeSetting } from 'mastodon/actions/settings';
  13. import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
  14. import { Avatar } from 'mastodon/components/avatar';
  15. import { Button } from 'mastodon/components/button';
  16. import { DisplayName } from 'mastodon/components/display_name';
  17. import { Icon } from 'mastodon/components/icon';
  18. import { IconButton } from 'mastodon/components/icon_button';
  19. import { VerifiedBadge } from 'mastodon/components/verified_badge';
  20. import { domain } from 'mastodon/initial_state';
  21. const messages = defineMessages({
  22. follow: { id: 'account.follow', defaultMessage: 'Follow' },
  23. unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
  24. previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
  25. next: { id: 'lightbox.next', defaultMessage: 'Next' },
  26. dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
  27. friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
  28. similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
  29. featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
  30. mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
  31. mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
  32. });
  33. const Source = ({ id }) => {
  34. const intl = useIntl();
  35. let label, hint;
  36. switch (id) {
  37. case 'friends_of_friends':
  38. hint = intl.formatMessage(messages.friendsOfFriendsHint);
  39. label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
  40. break;
  41. case 'similar_to_recently_followed':
  42. hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
  43. label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
  44. break;
  45. case 'featured':
  46. hint = intl.formatMessage(messages.featuredHint, { domain });
  47. label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
  48. break;
  49. case 'most_followed':
  50. hint = intl.formatMessage(messages.mostFollowedHint, { domain });
  51. label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
  52. break;
  53. case 'most_interactions':
  54. hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
  55. label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
  56. break;
  57. }
  58. return (
  59. <div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
  60. <Icon icon={InfoIcon} />
  61. {label}
  62. </div>
  63. );
  64. };
  65. Source.propTypes = {
  66. id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
  67. };
  68. const Card = ({ id, sources }) => {
  69. const intl = useIntl();
  70. const account = useSelector(state => state.getIn(['accounts', id]));
  71. const relationship = useSelector(state => state.getIn(['relationships', id]));
  72. const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
  73. const dispatch = useDispatch();
  74. const following = relationship?.get('following') ?? relationship?.get('requested');
  75. const handleFollow = useCallback(() => {
  76. if (following) {
  77. dispatch(unfollowAccount(id));
  78. } else {
  79. dispatch(followAccount(id));
  80. }
  81. }, [id, following, dispatch]);
  82. const handleDismiss = useCallback(() => {
  83. dispatch(dismissSuggestion(id));
  84. }, [id, dispatch]);
  85. return (
  86. <div className='inline-follow-suggestions__body__scrollable__card'>
  87. <IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
  88. <div className='inline-follow-suggestions__body__scrollable__card__avatar'>
  89. <Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
  90. </div>
  91. <div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
  92. <Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
  93. {firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
  94. </div>
  95. <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
  96. </div>
  97. );
  98. };
  99. Card.propTypes = {
  100. id: PropTypes.string.isRequired,
  101. sources: ImmutablePropTypes.list,
  102. };
  103. const DISMISSIBLE_ID = 'home/follow-suggestions';
  104. export const InlineFollowSuggestions = ({ hidden }) => {
  105. const intl = useIntl();
  106. const dispatch = useDispatch();
  107. const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
  108. const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
  109. const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
  110. const bodyRef = useRef();
  111. const [canScrollLeft, setCanScrollLeft] = useState(false);
  112. const [canScrollRight, setCanScrollRight] = useState(true);
  113. useEffect(() => {
  114. dispatch(fetchSuggestions());
  115. }, [dispatch]);
  116. useEffect(() => {
  117. if (!bodyRef.current) {
  118. return;
  119. }
  120. setCanScrollLeft(bodyRef.current.scrollLeft > 0);
  121. setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
  122. }, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
  123. const handleLeftNav = useCallback(() => {
  124. bodyRef.current.scrollLeft -= 200;
  125. }, [bodyRef]);
  126. const handleRightNav = useCallback(() => {
  127. bodyRef.current.scrollLeft += 200;
  128. }, [bodyRef]);
  129. const handleScroll = useCallback(() => {
  130. if (!bodyRef.current) {
  131. return;
  132. }
  133. setCanScrollLeft(bodyRef.current.scrollLeft > 0);
  134. setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
  135. }, [setCanScrollRight, setCanScrollLeft, bodyRef]);
  136. const handleDismiss = useCallback(() => {
  137. dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
  138. }, [dispatch]);
  139. if (dismissed || (!isLoading && suggestions.isEmpty())) {
  140. return null;
  141. }
  142. if (hidden) {
  143. return (
  144. <div className='inline-follow-suggestions' />
  145. );
  146. }
  147. return (
  148. <div className='inline-follow-suggestions'>
  149. <div className='inline-follow-suggestions__header'>
  150. <h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
  151. <div className='inline-follow-suggestions__header__actions'>
  152. <button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
  153. <Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
  154. </div>
  155. </div>
  156. <div className='inline-follow-suggestions__body'>
  157. <div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
  158. {suggestions.map(suggestion => (
  159. <Card
  160. key={suggestion.get('account')}
  161. id={suggestion.get('account')}
  162. sources={suggestion.get('sources')}
  163. />
  164. ))}
  165. </div>
  166. {canScrollLeft && (
  167. <button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
  168. <div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
  169. </button>
  170. )}
  171. {canScrollRight && (
  172. <button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
  173. <div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
  174. </button>
  175. )}
  176. </div>
  177. </div>
  178. );
  179. };
  180. InlineFollowSuggestions.propTypes = {
  181. hidden: PropTypes.bool,
  182. };