Add author links on the explore page in web UI (#30521)
This commit is contained in:
		
					parent
					
						
							
								37f53542fe
							
						
					
				
			
			
				commit
				
					
						ed6d24330b
					
				
			
		
					 11 changed files with 185 additions and 80 deletions
				
			
		|  | @ -68,13 +68,17 @@ export function importFetchedStatuses(statuses) { | |||
|         status.filtered.forEach(result => pushUnique(filters, result.filter)); | ||||
|       } | ||||
| 
 | ||||
|       if (status.reblog && status.reblog.id) { | ||||
|       if (status.reblog?.id) { | ||||
|         processStatus(status.reblog); | ||||
|       } | ||||
| 
 | ||||
|       if (status.poll && status.poll.id) { | ||||
|       if (status.poll?.id) { | ||||
|         pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); | ||||
|       } | ||||
| 
 | ||||
|       if (status.card?.author_account) { | ||||
|         pushUnique(accounts, status.card.author_account); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     statuses.forEach(processStatus); | ||||
|  |  | |||
|  | @ -36,6 +36,10 @@ export function normalizeStatus(status, normalOldStatus) { | |||
|     normalStatus.poll = status.poll.id; | ||||
|   } | ||||
| 
 | ||||
|   if (status.card?.author_account) { | ||||
|     normalStatus.card = { ...status.card, author_account: status.card.author_account.id }; | ||||
|   } | ||||
| 
 | ||||
|   if (status.filtered) { | ||||
|     normalStatus.filtered = status.filtered.map(normalizeFilterResult); | ||||
|   } | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import api, { getLinks } from '../api'; | ||||
| 
 | ||||
| import { importFetchedStatuses } from './importer'; | ||||
| import { importFetchedStatuses, importFetchedAccounts } from './importer'; | ||||
| 
 | ||||
| export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST'; | ||||
| export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS'; | ||||
|  | @ -49,8 +49,11 @@ export const fetchTrendingLinks = () => (dispatch) => { | |||
|   dispatch(fetchTrendingLinksRequest()); | ||||
| 
 | ||||
|   api() | ||||
|     .get('/api/v1/trends/links') | ||||
|     .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data))) | ||||
|     .get('/api/v1/trends/links', { params: { limit: 20 } }) | ||||
|     .then(({ data }) => { | ||||
|       dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account))); | ||||
|       dispatch(fetchTrendingLinksSuccess(data)); | ||||
|     }) | ||||
|     .catch(err => dispatch(fetchTrendingLinksFail(err))); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										19
									
								
								app/javascript/mastodon/components/more_from_author.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/javascript/mastodon/components/more_from_author.jsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import { AuthorLink } from 'mastodon/features/explore/components/author_link'; | ||||
| 
 | ||||
| export const MoreFromAuthor = ({ accountId }) => ( | ||||
|   <div className='more-from-author'> | ||||
|     <svg viewBox='0 0 79 79' className='logo logo--icon' role='img'> | ||||
|       <use xlinkHref='#logo-symbol-icon' /> | ||||
|     </svg> | ||||
| 
 | ||||
|     <FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} /> | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| MoreFromAuthor.propTypes = { | ||||
|   accountId: PropTypes.string.isRequired, | ||||
| }; | ||||
|  | @ -0,0 +1,21 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import { Avatar } from 'mastodon/components/avatar'; | ||||
| import { useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| export const AuthorLink = ({ accountId }) => { | ||||
|   const account = useAppSelector(state => state.getIn(['accounts', accountId])); | ||||
| 
 | ||||
|   return ( | ||||
|     <Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'> | ||||
|       <Avatar account={account} size={16} /> | ||||
|       <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> | ||||
|     </Link> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| AuthorLink.propTypes = { | ||||
|   accountId: PropTypes.string.isRequired, | ||||
| }; | ||||
|  | @ -1,61 +1,89 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| import { useState, useCallback } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| 
 | ||||
| import { Blurhash } from 'mastodon/components/blurhash'; | ||||
| import { accountsCountRenderer } from 'mastodon/components/hashtag'; | ||||
| import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; | ||||
| import { ShortNumber } from 'mastodon/components/short_number'; | ||||
| import { Skeleton } from 'mastodon/components/skeleton'; | ||||
| 
 | ||||
| export default class Story extends PureComponent { | ||||
| import { AuthorLink } from './author_link'; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     url: PropTypes.string, | ||||
|     title: PropTypes.string, | ||||
|     lang: PropTypes.string, | ||||
|     publisher: PropTypes.string, | ||||
|     publishedAt: PropTypes.string, | ||||
|     author: PropTypes.string, | ||||
|     sharedTimes: PropTypes.number, | ||||
|     thumbnail: PropTypes.string, | ||||
|     thumbnailDescription: PropTypes.string, | ||||
|     blurhash: PropTypes.string, | ||||
|     expanded: PropTypes.bool, | ||||
|   }; | ||||
| const sharesCountRenderer = (displayNumber, pluralReady) => ( | ||||
|   <FormattedMessage | ||||
|     id='link_preview.shares' | ||||
|     defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}}' | ||||
|     values={{ | ||||
|       count: pluralReady, | ||||
|       counter: <strong>{displayNumber}</strong>, | ||||
|     }} | ||||
|   /> | ||||
| ); | ||||
| 
 | ||||
|   state = { | ||||
|     thumbnailLoaded: false, | ||||
|   }; | ||||
| export const Story = ({ | ||||
|   url, | ||||
|   title, | ||||
|   lang, | ||||
|   publisher, | ||||
|   publishedAt, | ||||
|   author, | ||||
|   authorAccount, | ||||
|   sharedTimes, | ||||
|   thumbnail, | ||||
|   thumbnailDescription, | ||||
|   blurhash, | ||||
|   expanded | ||||
| }) => { | ||||
|   const [thumbnailLoaded, setThumbnailLoaded] = useState(false); | ||||
| 
 | ||||
|   handleImageLoad = () => this.setState({ thumbnailLoaded: true }); | ||||
|   const handleImageLoad = useCallback(() => { | ||||
|     setThumbnailLoaded(true); | ||||
|   }, [setThumbnailLoaded]); | ||||
| 
 | ||||
|   render () { | ||||
|     const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, thumbnailDescription, blurhash } = this.props; | ||||
| 
 | ||||
|     const { thumbnailLoaded } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <a className={classNames('story', { expanded })} href={url} target='blank' rel='noopener'> | ||||
|         <div className='story__details'> | ||||
|           <div className='story__details__publisher'>{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}</div> | ||||
|           <div className='story__details__title' lang={lang}>{title ? title : <Skeleton />}</div> | ||||
|           <div className='story__details__shared'>{author && <><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{author}</strong> }} /> · </>}{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div> | ||||
|   return ( | ||||
|     <div className={classNames('story', { expanded })}> | ||||
|       <div className='story__details'> | ||||
|         <div className='story__details__publisher'> | ||||
|           {publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>} | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='story__thumbnail'> | ||||
|           {thumbnail ? ( | ||||
|             <> | ||||
|               <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div> | ||||
|               <img src={thumbnail} onLoad={this.handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} /> | ||||
|             </> | ||||
|           ) : <Skeleton />} | ||||
|         <a className='story__details__title' lang={lang} href={url} target='blank' rel='noopener'> | ||||
|           {title ? title : <Skeleton />} | ||||
|         </a> | ||||
| 
 | ||||
|         <div className='story__details__shared'> | ||||
|           {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />} | ||||
|           {typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />} | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <a className='story__thumbnail' href={url} target='blank' rel='noopener'> | ||||
|         {thumbnail ? ( | ||||
|           <> | ||||
|             <div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div> | ||||
|             <img src={thumbnail} onLoad={handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} /> | ||||
|           </> | ||||
|         ) : <Skeleton />} | ||||
|       </a> | ||||
|     ); | ||||
|   } | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| } | ||||
| Story.propTypes = { | ||||
|   url: PropTypes.string, | ||||
|   title: PropTypes.string, | ||||
|   lang: PropTypes.string, | ||||
|   publisher: PropTypes.string, | ||||
|   publishedAt: PropTypes.string, | ||||
|   author: PropTypes.string, | ||||
|   authorAccount: PropTypes.string, | ||||
|   sharedTimes: PropTypes.number, | ||||
|   thumbnail: PropTypes.string, | ||||
|   thumbnailDescription: PropTypes.string, | ||||
|   blurhash: PropTypes.string, | ||||
|   expanded: PropTypes.bool, | ||||
| }; | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ import { DismissableBanner } from 'mastodon/components/dismissable_banner'; | |||
| import { LoadingIndicator } from 'mastodon/components/loading_indicator'; | ||||
| import { WithRouterPropTypes } from 'mastodon/utils/react_router'; | ||||
| 
 | ||||
| import Story from './components/story'; | ||||
| import { Story } from './components/story'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   links: state.getIn(['trends', 'links', 'items']), | ||||
|  | @ -75,6 +75,7 @@ class Links extends PureComponent { | |||
|             publisher={link.get('provider_name')} | ||||
|             publishedAt={link.get('published_at')} | ||||
|             author={link.get('author_name')} | ||||
|             authorAccount={link.getIn(['author_account', 'id'])} | ||||
|             sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1} | ||||
|             thumbnail={link.get('image')} | ||||
|             thumbnailDescription={link.get('image_description')} | ||||
|  |  | |||
|  | @ -6,7 +6,6 @@ import { PureComponent } from 'react'; | |||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| 
 | ||||
| import Immutable from 'immutable'; | ||||
|  | @ -15,9 +14,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
| import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; | ||||
| import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; | ||||
| import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; | ||||
| import { Avatar } from 'mastodon/components/avatar'; | ||||
| import { Blurhash } from 'mastodon/components/blurhash'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { MoreFromAuthor } from 'mastodon/components/more_from_author'; | ||||
| import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; | ||||
| import { useBlurhash } from 'mastodon/initial_state'; | ||||
| 
 | ||||
|  | @ -59,20 +58,6 @@ const addAutoPlay = html => { | |||
|   return html; | ||||
| }; | ||||
| 
 | ||||
| const MoreFromAuthor = ({ author }) => ( | ||||
|   <div className='more-from-author'> | ||||
|     <svg viewBox='0 0 79 79' className='logo logo--icon' role='img'> | ||||
|       <use xlinkHref='#logo-symbol-icon' /> | ||||
|     </svg> | ||||
| 
 | ||||
|     <FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <Link to={`/@${author.get('acct')}`}><Avatar account={author} size={16} /> {author.get('display_name')}</Link> }} /> | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| MoreFromAuthor.propTypes = { | ||||
|   author: ImmutablePropTypes.map, | ||||
| }; | ||||
| 
 | ||||
| export default class Card extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|  | @ -259,7 +244,7 @@ export default class Card extends PureComponent { | |||
|           {description} | ||||
|         </a> | ||||
| 
 | ||||
|         {showAuthor && <MoreFromAuthor author={card.get('author_account')} />} | ||||
|         {showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />} | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -415,6 +415,7 @@ | |||
|   "limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.", | ||||
|   "link_preview.author": "By {name}", | ||||
|   "link_preview.more_from_author": "More from {name}", | ||||
|   "link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}", | ||||
|   "lists.account.add": "Add to list", | ||||
|   "lists.account.remove": "Remove from list", | ||||
|   "lists.delete": "Delete list", | ||||
|  |  | |||
|  | @ -8796,43 +8796,80 @@ noscript { | |||
|   display: flex; | ||||
|   align-items: center; | ||||
|   color: $primary-text-color; | ||||
|   text-decoration: none; | ||||
|   padding: 15px; | ||||
|   padding: 16px; | ||||
|   border-bottom: 1px solid var(--background-border-color); | ||||
|   gap: 15px; | ||||
|   gap: 16px; | ||||
| 
 | ||||
|   &:last-child { | ||||
|     border-bottom: 0; | ||||
|   } | ||||
| 
 | ||||
|   &:hover, | ||||
|   &:active, | ||||
|   &:focus { | ||||
|     color: $highlight-text-color; | ||||
| 
 | ||||
|     .story__details__publisher, | ||||
|     .story__details__shared { | ||||
|       color: $highlight-text-color; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &__details { | ||||
|     flex: 1 1 auto; | ||||
| 
 | ||||
|     &__publisher { | ||||
|       color: $darker-text-color; | ||||
|       margin-bottom: 8px; | ||||
|       font-size: 14px; | ||||
|       line-height: 20px; | ||||
|     } | ||||
| 
 | ||||
|     &__title { | ||||
|       display: block; | ||||
|       font-size: 19px; | ||||
|       line-height: 24px; | ||||
|       font-weight: 500; | ||||
|       margin-bottom: 8px; | ||||
|       text-decoration: none; | ||||
|       color: $primary-text-color; | ||||
| 
 | ||||
|       &:hover, | ||||
|       &:active, | ||||
|       &:focus { | ||||
|         color: $highlight-text-color; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     &__shared { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       color: $darker-text-color; | ||||
|       gap: 8px; | ||||
|       justify-content: space-between; | ||||
|       font-size: 14px; | ||||
|       line-height: 20px; | ||||
| 
 | ||||
|       & > span { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|       } | ||||
| 
 | ||||
|       &__pill { | ||||
|         background: var(--surface-variant-background-color); | ||||
|         border-radius: 4px; | ||||
|         color: inherit; | ||||
|         text-decoration: none; | ||||
|         padding: 4px 12px; | ||||
|         font-size: 12px; | ||||
|         font-weight: 500; | ||||
|         line-height: 16px; | ||||
|       } | ||||
| 
 | ||||
|       &__author-link { | ||||
|         display: inline-flex; | ||||
|         align-items: center; | ||||
|         gap: 4px; | ||||
|         color: $primary-text-color; | ||||
|         font-weight: 500; | ||||
|         text-decoration: none; | ||||
| 
 | ||||
|         &:hover, | ||||
|         &:active, | ||||
|         &:focus { | ||||
|           color: $highlight-text-color; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     strong { | ||||
|  | @ -9903,14 +9940,14 @@ noscript { | |||
|     color: inherit; | ||||
|     text-decoration: none; | ||||
|     padding: 4px 12px; | ||||
|     background: $ui-base-color; | ||||
|     background: var(--surface-variant-background-color); | ||||
|     border-radius: 4px; | ||||
|     font-weight: 500; | ||||
| 
 | ||||
|     &:hover, | ||||
|     &:focus, | ||||
|     &:active { | ||||
|       background: lighten($ui-base-color, 4%); | ||||
|       background: var(--surface-variant-active-background-color); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -106,4 +106,6 @@ $font-monospace: 'mastodon-font-monospace' !default; | |||
|   --background-color: #{darken($ui-base-color, 8%)}; | ||||
|   --background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)}; | ||||
|   --surface-background-color: #{darken($ui-base-color, 4%)}; | ||||
|   --surface-variant-background-color: #{$ui-base-color}; | ||||
|   --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)}; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue