Permalink
Browse files

Redesign public hashtag page to use a masonry layout (#9822)

  • Loading branch information...
Gargron committed Jan 16, 2019
1 parent 4ab4228 commit bc642ac24b49c14dca382e7aabbc16130293d2f4
@@ -3,6 +3,8 @@
class TagsController < ApplicationController
PAGE_SIZE = 20

layout 'public'

before_action :set_body_classes
before_action :set_instance_presenter

@@ -1,23 +1,31 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

export default class DisplayName extends React.PureComponent {

static propTypes = {
account: ImmutablePropTypes.map.isRequired,
others: ImmutablePropTypes.list,
localDomain: PropTypes.string,
};

render () {
const { account, others } = this.props;
const { account, others, localDomain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };

let suffix;

if (others && others.size > 1) {
suffix = `+${others.size}`;
} else {
suffix = <span className='display-name__account'>@{account.get('acct')}</span>;
let acct = account.get('acct');

if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`;
}

suffix = <span className='display-name__account'>@{acct}</span>;
}

return (
@@ -77,7 +77,7 @@ class Status extends ImmutablePureComponent {
'account',
'muted',
'hidden',
]
];

handleClick = () => {
if (this.props.onClick) {
@@ -1,28 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../../ui/containers/status_list_container';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { expandHashtagTimeline } from '../../../actions/timelines';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import { connectHashtagStream } from '../../../actions/streaming';
import Masonry from 'react-masonry-infinite';
import { List as ImmutableList } from 'immutable';
import DetailedStatusContainer from '../../status/containers/detailed_status_container';
import { debounce } from 'lodash';
import LoadingIndicator from '../../../components/loading_indicator';

export default @connect()
const mapStateToProps = (state, { hashtag }) => ({
statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
});

export default @connect(mapStateToProps)
class HashtagTimeline extends React.PureComponent {

static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool.isRequired,
hasMore: PropTypes.bool.isRequired,
hashtag: PropTypes.string.isRequired,
};

handleHeaderClick = () => {
this.column.scrollTop();
}

setRef = c => {
this.column = c;
}

componentDidMount () {
const { dispatch, hashtag } = this.props;

@@ -37,28 +41,52 @@ class HashtagTimeline extends React.PureComponent {
}
}

handleLoadMore = maxId => {
this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
handleLoadMore = () => {
const maxId = this.props.statusIds.last();

if (maxId) {
this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
}
}

setRef = c => {
this.masonry = c;
}

handleHeightChange = debounce(() => {
if (!this.masonry) {
return;
}

this.masonry.forcePack();
}, 50)

render () {
const { hashtag } = this.props;
const { statusIds, hasMore, isLoading } = this.props;

const sizes = [
{ columns: 1, gutter: 0 },
{ mq: '415px', columns: 1, gutter: 10 },
{ mq: '640px', columns: 2, gutter: 10 },
{ mq: '960px', columns: 3, gutter: 10 },
{ mq: '1255px', columns: 3, gutter: 10 },
];

const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;

return (
<Column ref={this.setRef}>
<ColumnHeader
icon='hashtag'
title={hashtag}
onClick={this.handleHeaderClick}
/>

<StatusListContainer
trackScroll={false}
scrollKey='standalone_hashtag_timeline'
timelineId={`hashtag:${hashtag}`}
onLoadMore={this.handleLoadMore}
/>
</Column>
<Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
{statusIds.map(statusId => (
<div className='statuses-grid__item' key={statusId}>
<DetailedStatusContainer
id={statusId}
showThread
measureHeight
onHeightChange={this.handleHeightChange}
/>
</div>
)).toArray()}
</Masonry>
);
}

@@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';

export default class DetailedStatus extends ImmutablePureComponent {

@@ -23,10 +24,17 @@ export default class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
};

state = {
height: null,
};

handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
@@ -42,13 +50,56 @@ export default class DetailedStatus extends ImmutablePureComponent {
this.props.onToggleHidden(this.props.status);
}

_measureHeight (heightJustChanged) {
if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: this.node.offsetHeight }));

if (this.props.onHeightChange && heightJustChanged) {
this.props.onHeightChange();
}
}
}

setRef = c => {
this.node = c;
this._measureHeight();
}

componentDidUpdate (prevProps, prevState) {
this._measureHeight(prevState.height !== this.state.height);
}

handleModalLink = e => {
e.preventDefault();

let href;

if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}

window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}

render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };

if (!status) {
return null;
}

let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';

if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
}

if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -95,20 +146,51 @@ export default class DetailedStatus extends ImmutablePureComponent {

if (status.get('visibility') === 'private') {
reblogLink = <i className={`fa fa-${reblogIcon}`} />;
} else if (this.context.router) {
reblogLink = (
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<i className={`fa fa-${reblogIcon}`} />
<span className='detailed-status__reblogs'>
<FormattedNumber value={status.get('reblogs_count')} />
</span>
</Link>
);
} else {
reblogLink = (
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<i className={`fa fa-${reblogIcon}`} />
<span className='detailed-status__reblogs'>
<FormattedNumber value={status.get('reblogs_count')} />
</span>
</a>
);
}

if (this.context.router) {
favouriteLink = (
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<i className='fa fa-star' />
<span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} />
</span>
</Link>
);
} else {
reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<i className={`fa fa-${reblogIcon}`} />
<span className='detailed-status__reblogs'>
<FormattedNumber value={status.get('reblogs_count')} />
</span>
</Link>);
favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<i className='fa fa-star' />
<span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} />
</span>
</a>
);
}

return (
<div className='detailed-status'>
<div ref={this.setRef} className='detailed-status' style={outerStyle}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} />
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>

<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
@@ -118,12 +200,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<i className='fa fa-star' />
<span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} />
</span>
</Link>
</a>{applicationLink} · {reblogLink} · {favouriteLink}
</div>
</div>
);
Oops, something went wrong.

0 comments on commit bc642ac

Please sign in to comment.