account,
className,
inline,
+ localDomain,
}) {
const computedClass = classNames('display-name', { inline }, className);
+ if (!account) return null;
+
+ let acct = account.get('acct');
+ if (acct.indexOf('@') === -1 && localDomain) {
+ acct = `${acct}@${localDomain}`;
+ }
+
// The result.
return account ? (
<span className={computedClass}>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{inline ? ' ' : null}
- <span className='display-name__account'>@{account.get('acct')}</span>
+ <span className='display-name__account'>@{acct}</span>
</span>
) : null;
}
account: ImmutablePropTypes.map,
className: PropTypes.string,
inline: PropTypes.bool,
+ localDomain: PropTypes.string,
};
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
-import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
-@connect()
-export default class HashtagTimeline extends React.PureComponent {
+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;
}
}
- 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>
);
}
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from 'flavours/glitch/features/video';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
export default class DetailedStatus extends ImmutablePureComponent {
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
expanded: PropTypes.bool,
+ measureHeight: PropTypes.bool,
+ onHeightChange: PropTypes.func,
+ domain: PropTypes.string.isRequired,
+ };
+
+ state = {
+ height: null,
};
handleAccountClick = (e) => {
- if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+ if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
}
parseClick = (e, destination) => {
- if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+ if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
e.preventDefault();
this.context.router.history.push(destination);
}
this.props.onOpenVideo(media, startTime);
}
+ _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 { expanded, onToggleHidden, settings } = this.props;
+ const outerStyle = { boxSizing: 'border-box' };
+
+ if (!status) {
+ return null;
+ }
let media = '';
let mediaIcon = null;
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')) {
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' data-status-by={status.getIn(['account', 'acct'])}>
+ <div ref={this.setRef} className='detailed-status' data-status-by={status.getIn(['account', 'acct'])} 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
<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> · <VisibilityIcon visibility={status.get('visibility')} />
+ </a>{applicationLink} · {reblogLink} · {favouriteLink} · <VisibilityIcon visibility={status.get('visibility')} />
</div>
</div>
);
--- /dev/null
+import React from 'react';
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import {
+ replyCompose,
+ mentionCompose,
+ directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+ pin,
+ unpin,
+} from 'flavours/glitch/actions/interactions';
+import { blockAccount } from 'flavours/glitch/actions/accounts';
+import {
+ muteStatus,
+ unmuteStatus,
+ deleteStatus,
+ hideStatus,
+ revealStatus,
+} from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { showAlertForError } from 'flavours/glitch/actions/alerts';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props),
+ domain: state.getIn(['meta', 'domain']),
+ settings: state.get('local_settings'),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, router)),
+ }));
+ } else {
+ dispatch(replyCompose(status, router));
+ }
+ });
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+ }
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onPin (status) {
+ if (status.get('pinned')) {
+ dispatch(unpin(status));
+ } else {
+ dispatch(pin(status));
+ }
+ },
+
+ onEmbed (status) {
+ dispatch(openModal('EMBED', {
+ url: status.get('url'),
+ onError: error => dispatch(showAlertForError(error)),
+ }));
+ },
+
+ onDelete (status, history, withRedraft = false) {
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id'), history, withRedraft));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+ confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+ }));
+ }
+ },
+
+ onDirect (account, router) {
+ dispatch(directCompose(account, router));
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideo (media, time) {
+ dispatch(openModal('VIDEO', { media, time }));
+ },
+
+ onBlock (account) {
+ dispatch(openModal('CONFIRM', {
+ message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onMute (account) {
+ dispatch(initMuteModal(account));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+ onToggleHidden (status) {
+ if (status.get('hidden')) {
+ dispatch(revealStatus(status.get('id')));
+ } else {
+ dispatch(hideStatus(status.get('id')));
+ }
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
border-radius: 0;
}
}
+
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.statuses-grid {
+ min-height: 600px;
+
+ &__item {
+ width: (960px - 20px) / 3;
+
+ @media screen and (max-width: $fluid-breakpoint) {
+ width: (940px - 20px) / 3;
+ }
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ width: 100vw;
+ }
+ }
+
+ .detailed-status {
+ border-radius: 4px;
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ border-bottom: 1px solid lighten($ui-base-color, 12%);
+ }
+ }
+}