Add explore page to web UI (#17123)
* Add explore page to web UI * Fix not removing loaded statuses from trends on mute/block actionlocal
parent
27965ce5ed
commit
d4592bbfcd
22 changed files with 727 additions and 63 deletions
@ -0,0 +1,51 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Blurhash from 'mastodon/components/blurhash'; |
||||
import { accountsCountRenderer } from 'mastodon/components/hashtag'; |
||||
import ShortNumber from 'mastodon/components/short_number'; |
||||
import Skeleton from 'mastodon/components/skeleton'; |
||||
import classNames from 'classnames'; |
||||
|
||||
export default class Story extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
url: PropTypes.string, |
||||
title: PropTypes.string, |
||||
publisher: PropTypes.string, |
||||
sharedTimes: PropTypes.number, |
||||
thumbnail: PropTypes.string, |
||||
blurhash: PropTypes.string, |
||||
}; |
||||
|
||||
state = { |
||||
thumbnailLoaded: false, |
||||
}; |
||||
|
||||
handleImageLoad = () => this.setState({ thumbnailLoaded: true }); |
||||
|
||||
render () { |
||||
const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props; |
||||
|
||||
const { thumbnailLoaded } = this.state; |
||||
|
||||
return ( |
||||
<a className='story' href={url} target='blank' rel='noopener'> |
||||
<div className='story__details'> |
||||
<div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div> |
||||
<div className='story__details__title'>{title ? title : <Skeleton />}</div> |
||||
<div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div> |
||||
</div> |
||||
|
||||
<div className='story__thumbnail'> |
||||
{thumbnail ? ( |
||||
<React.Fragment> |
||||
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div> |
||||
<img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' /> |
||||
</React.Fragment> |
||||
) : <Skeleton />} |
||||
</div> |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,91 @@ |
||||
import React from 'react'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { connect } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import Column from 'mastodon/components/column'; |
||||
import ColumnHeader from 'mastodon/components/column_header'; |
||||
import { NavLink, Switch, Route } from 'react-router-dom'; |
||||
import Links from './links'; |
||||
import Tags from './tags'; |
||||
import Statuses from './statuses'; |
||||
import Suggestions from './suggestions'; |
||||
import Search from 'mastodon/features/compose/containers/search_container'; |
||||
import SearchResults from './results'; |
||||
|
||||
const messages = defineMessages({ |
||||
title: { id: 'explore.title', defaultMessage: 'Explore' }, |
||||
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
layout: state.getIn(['meta', 'layout']), |
||||
isSearching: state.getIn(['search', 'submitted']), |
||||
}); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
@injectIntl |
||||
class Explore extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
intl: PropTypes.object.isRequired, |
||||
multiColumn: PropTypes.bool, |
||||
isSearching: PropTypes.bool, |
||||
layout: PropTypes.string, |
||||
}; |
||||
|
||||
handleHeaderClick = () => { |
||||
this.column.scrollTop(); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.column = c; |
||||
} |
||||
|
||||
render () { |
||||
const { intl, multiColumn, isSearching, layout } = this.props; |
||||
|
||||
return ( |
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> |
||||
{layout === 'mobile' ? ( |
||||
<div className='explore__search-header'> |
||||
<Search /> |
||||
</div> |
||||
) : ( |
||||
<ColumnHeader |
||||
icon={isSearching ? 'search' : 'globe'} |
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)} |
||||
onClick={this.handleHeaderClick} |
||||
multiColumn={multiColumn} |
||||
/> |
||||
)} |
||||
|
||||
<div className='scrollable scrollable--flex'> |
||||
{isSearching ? ( |
||||
<SearchResults /> |
||||
) : ( |
||||
<React.Fragment> |
||||
<div className='account__section-headline'> |
||||
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink> |
||||
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink> |
||||
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink> |
||||
<NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink> |
||||
</div> |
||||
|
||||
<Switch> |
||||
<Route path='/explore/tags' component={Tags} /> |
||||
<Route path='/explore/links' component={Links} /> |
||||
<Route path='/explore/suggestions' component={Suggestions} /> |
||||
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} /> |
||||
</Switch> |
||||
</React.Fragment> |
||||
)} |
||||
</div> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,48 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import Story from './components/story'; |
||||
import LoadingIndicator from 'mastodon/components/loading_indicator'; |
||||
import { connect } from 'react-redux'; |
||||
import { fetchTrendingLinks } from 'mastodon/actions/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
links: state.getIn(['trends', 'links', 'items']), |
||||
isLoading: state.getIn(['trends', 'links', 'isLoading']), |
||||
}); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
class Links extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
links: ImmutablePropTypes.list, |
||||
isLoading: PropTypes.bool, |
||||
dispatch: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
const { dispatch } = this.props; |
||||
dispatch(fetchTrendingLinks()); |
||||
} |
||||
|
||||
render () { |
||||
const { isLoading, links } = this.props; |
||||
|
||||
return ( |
||||
<div className='explore__links'> |
||||
{isLoading ? (<LoadingIndicator />) : links.map(link => ( |
||||
<Story |
||||
key={link.get('id')} |
||||
url={link.get('url')} |
||||
title={link.get('title')} |
||||
publisher={link.get('provider_name')} |
||||
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1} |
||||
thumbnail={link.get('image')} |
||||
blurhash={link.get('blurhash')} |
||||
/> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,113 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import { connect } from 'react-redux'; |
||||
import { expandSearch } from 'mastodon/actions/search'; |
||||
import Account from 'mastodon/containers/account_container'; |
||||
import Status from 'mastodon/containers/status_container'; |
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; |
||||
import { List as ImmutableList } from 'immutable'; |
||||
import LoadMore from 'mastodon/components/load_more'; |
||||
import LoadingIndicator from 'mastodon/components/loading_indicator'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
isLoading: state.getIn(['search', 'isLoading']), |
||||
results: state.getIn(['search', 'results']), |
||||
}); |
||||
|
||||
const appendLoadMore = (id, list, onLoadMore) => { |
||||
if (list.size >= 5) { |
||||
return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />); |
||||
} else { |
||||
return list; |
||||
} |
||||
}; |
||||
|
||||
const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts').map(item => ( |
||||
<Account key={`account-${item}`} id={item} /> |
||||
)), onLoadMore); |
||||
|
||||
const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags').map(item => ( |
||||
<Hashtag key={`tag-${item.get('name')}`} hashtag={item} /> |
||||
)), onLoadMore); |
||||
|
||||
const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses').map(item => ( |
||||
<Status key={`status-${item}`} id={item} /> |
||||
)), onLoadMore); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
class Results extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
results: ImmutablePropTypes.map, |
||||
isLoading: PropTypes.bool, |
||||
multiColumn: PropTypes.bool, |
||||
dispatch: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
type: 'all', |
||||
}; |
||||
|
||||
handleSelectAll = () => this.setState({ type: 'all' }); |
||||
handleSelectAccounts = () => this.setState({ type: 'accounts' }); |
||||
handleSelectHashtags = () => this.setState({ type: 'hashtags' }); |
||||
handleSelectStatuses = () => this.setState({ type: 'statuses' }); |
||||
handleLoadMoreAccounts = () => this.loadMore('accounts'); |
||||
handleLoadMoreStatuses = () => this.loadMore('statuses'); |
||||
handleLoadMoreHashtags = () => this.loadMore('hashtags'); |
||||
|
||||
loadMore (type) { |
||||
const { dispatch } = this.props; |
||||
dispatch(expandSearch(type)); |
||||
} |
||||
|
||||
render () { |
||||
const { isLoading, results } = this.props; |
||||
const { type } = this.state; |
||||
|
||||
let filteredResults = ImmutableList(); |
||||
|
||||
if (!isLoading) { |
||||
switch(type) { |
||||
case 'all': |
||||
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses)); |
||||
break; |
||||
case 'accounts': |
||||
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts)); |
||||
break; |
||||
case 'hashtags': |
||||
filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags)); |
||||
break; |
||||
case 'statuses': |
||||
filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses)); |
||||
break; |
||||
} |
||||
|
||||
if (filteredResults.size === 0) { |
||||
filteredResults = ( |
||||
<div className='empty-column-indicator'> |
||||
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' /> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<React.Fragment> |
||||
<div className='account__section-headline'> |
||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button> |
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button> |
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button> |
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button> |
||||
</div> |
||||
|
||||
<div className='explore__search-results'> |
||||
{isLoading ? (<LoadingIndicator />) : filteredResults} |
||||
</div> |
||||
</React.Fragment> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,48 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import StatusList from 'mastodon/components/status_list'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import { connect } from 'react-redux'; |
||||
import { fetchTrendingStatuses } from 'mastodon/actions/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
statusIds: state.getIn(['status_lists', 'trending', 'items']), |
||||
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true), |
||||
}); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
class Statuses extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
statusIds: ImmutablePropTypes.list, |
||||
isLoading: PropTypes.bool, |
||||
multiColumn: PropTypes.bool, |
||||
dispatch: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
const { dispatch } = this.props; |
||||
dispatch(fetchTrendingStatuses()); |
||||
} |
||||
|
||||
render () { |
||||
const { isLoading, statusIds, multiColumn } = this.props; |
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />; |
||||
|
||||
return ( |
||||
<StatusList |
||||
trackScroll |
||||
statusIds={statusIds} |
||||
scrollKey='explore-statuses' |
||||
hasMore={false} |
||||
isLoading={isLoading} |
||||
emptyMessage={emptyMessage} |
||||
bindToDocument={!multiColumn} |
||||
withCounters |
||||
/> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,40 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import Account from 'mastodon/containers/account_container'; |
||||
import LoadingIndicator from 'mastodon/components/loading_indicator'; |
||||
import { connect } from 'react-redux'; |
||||
import { fetchSuggestions } from 'mastodon/actions/suggestions'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
suggestions: state.getIn(['suggestions', 'items']), |
||||
isLoading: state.getIn(['suggestions', 'isLoading']), |
||||
}); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
class Suggestions extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
isLoading: PropTypes.bool, |
||||
suggestions: ImmutablePropTypes.list, |
||||
dispatch: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
const { dispatch } = this.props; |
||||
dispatch(fetchSuggestions(true)); |
||||
} |
||||
|
||||
render () { |
||||
const { isLoading, suggestions } = this.props; |
||||
|
||||
return ( |
||||
<div className='explore__links'> |
||||
{isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => ( |
||||
<Account key={suggestion.get('account')} id={suggestion.get('account')} /> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,40 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; |
||||
import LoadingIndicator from 'mastodon/components/loading_indicator'; |
||||
import { connect } from 'react-redux'; |
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
hashtags: state.getIn(['trends', 'tags', 'items']), |
||||
isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']), |
||||
}); |
||||
|
||||
export default @connect(mapStateToProps) |
||||
class Tags extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
hashtags: ImmutablePropTypes.list, |
||||
isLoading: PropTypes.bool, |
||||
dispatch: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
const { dispatch } = this.props; |
||||
dispatch(fetchTrendingHashtags()); |
||||
} |
||||
|
||||
render () { |
||||
const { isLoading, hashtags } = this.props; |
||||
|
||||
return ( |
||||
<div className='explore__links'> |
||||
{isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => ( |
||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} /> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -1,13 +1,13 @@ |
||||
import { connect } from 'react-redux'; |
||||
import { fetchTrends } from 'mastodon/actions/trends'; |
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends'; |
||||
import Trends from '../components/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
trends: state.getIn(['trends', 'items']), |
||||
trends: state.getIn(['trends', 'tags', 'items']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
fetchTrends: () => dispatch(fetchTrends()), |
||||
fetchTrends: () => dispatch(fetchTrendingHashtags()), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Trends); |
||||
|
@ -1,17 +0,0 @@ |
||||
import React from 'react'; |
||||
import SearchContainer from 'mastodon/features/compose/containers/search_container'; |
||||
import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container'; |
||||
|
||||
const Search = () => ( |
||||
<div className='column search-page'> |
||||
<SearchContainer /> |
||||
|
||||
<div className='drawer__pager'> |
||||
<div className='drawer__inner darker'> |
||||
<SearchResultsContainer /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
|
||||
export default Search; |
Loading…
Reference in new issue