You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

237 lines
7.1 KiB

7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
  1. import React from 'react';
  2. import { connect } from 'react-redux';
  3. import PropTypes from 'prop-types';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import StatusListContainer from '../ui/containers/status_list_container';
  6. import Column from 'mastodon/components/column';
  7. import ColumnHeader from 'mastodon/components/column_header';
  8. import ColumnSettingsContainer from './containers/column_settings_container';
  9. import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines';
  10. import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
  11. import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
  12. import { connectHashtagStream } from 'mastodon/actions/streaming';
  13. import { isEqual } from 'lodash';
  14. import { fetchHashtag, followHashtag, unfollowHashtag } from 'mastodon/actions/tags';
  15. import Icon from 'mastodon/components/icon';
  16. import classNames from 'classnames';
  17. import { Helmet } from 'react-helmet';
  18. const messages = defineMessages({
  19. followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' },
  20. unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' },
  21. });
  22. const mapStateToProps = (state, props) => ({
  23. hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
  24. tag: state.getIn(['tags', props.params.id]),
  25. });
  26. class HashtagTimeline extends React.PureComponent {
  27. disconnects = [];
  28. static contextTypes = {
  29. identity: PropTypes.object,
  30. };
  31. static propTypes = {
  32. params: PropTypes.object.isRequired,
  33. columnId: PropTypes.string,
  34. dispatch: PropTypes.func.isRequired,
  35. hasUnread: PropTypes.bool,
  36. tag: ImmutablePropTypes.map,
  37. multiColumn: PropTypes.bool,
  38. intl: PropTypes.object,
  39. };
  40. handlePin = () => {
  41. const { columnId, dispatch } = this.props;
  42. if (columnId) {
  43. dispatch(removeColumn(columnId));
  44. } else {
  45. dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
  46. }
  47. };
  48. title = () => {
  49. const { id } = this.props.params;
  50. const title = [id];
  51. if (this.additionalFor('any')) {
  52. title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
  53. }
  54. if (this.additionalFor('all')) {
  55. title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
  56. }
  57. if (this.additionalFor('none')) {
  58. title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
  59. }
  60. return title;
  61. };
  62. additionalFor = (mode) => {
  63. const { tags } = this.props.params;
  64. if (tags && (tags[mode] || []).length > 0) {
  65. return tags[mode].map(tag => tag.value).join('/');
  66. } else {
  67. return '';
  68. }
  69. };
  70. handleMove = (dir) => {
  71. const { columnId, dispatch } = this.props;
  72. dispatch(moveColumn(columnId, dir));
  73. };
  74. handleHeaderClick = () => {
  75. this.column.scrollTop();
  76. };
  77. _subscribe (dispatch, id, tags = {}, local) {
  78. const { signedIn } = this.context.identity;
  79. if (!signedIn) {
  80. return;
  81. }
  82. let any = (tags.any || []).map(tag => tag.value);
  83. let all = (tags.all || []).map(tag => tag.value);
  84. let none = (tags.none || []).map(tag => tag.value);
  85. [id, ...any].map(tag => {
  86. this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
  87. let tags = status.tags.map(tag => tag.name);
  88. return all.filter(tag => tags.includes(tag)).length === all.length &&
  89. none.filter(tag => tags.includes(tag)).length === 0;
  90. })));
  91. });
  92. }
  93. _unsubscribe () {
  94. this.disconnects.map(disconnect => disconnect());
  95. this.disconnects = [];
  96. }
  97. _unload () {
  98. const { dispatch } = this.props;
  99. const { id, local } = this.props.params;
  100. this._unsubscribe();
  101. dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
  102. }
  103. _load() {
  104. const { dispatch } = this.props;
  105. const { id, tags, local } = this.props.params;
  106. this._subscribe(dispatch, id, tags, local);
  107. dispatch(expandHashtagTimeline(id, { tags, local }));
  108. dispatch(fetchHashtag(id));
  109. }
  110. componentDidMount () {
  111. this._load();
  112. }
  113. componentDidUpdate (prevProps) {
  114. const { params } = this.props;
  115. const { id, tags, local } = prevProps.params;
  116. if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
  117. this._unload();
  118. this._load();
  119. }
  120. }
  121. componentWillUnmount () {
  122. this._unsubscribe();
  123. }
  124. setRef = c => {
  125. this.column = c;
  126. };
  127. handleLoadMore = maxId => {
  128. const { dispatch, params } = this.props;
  129. const { id, tags, local } = params;
  130. dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
  131. };
  132. handleFollow = () => {
  133. const { dispatch, params, tag } = this.props;
  134. const { id } = params;
  135. const { signedIn } = this.context.identity;
  136. if (!signedIn) {
  137. return;
  138. }
  139. if (tag.get('following')) {
  140. dispatch(unfollowHashtag(id));
  141. } else {
  142. dispatch(followHashtag(id));
  143. }
  144. };
  145. render () {
  146. const { hasUnread, columnId, multiColumn, tag, intl } = this.props;
  147. const { id, local } = this.props.params;
  148. const pinned = !!columnId;
  149. const { signedIn } = this.context.identity;
  150. let followButton;
  151. if (tag) {
  152. const following = tag.get('following');
  153. followButton = (
  154. <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} active={following} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
  155. <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
  156. </button>
  157. );
  158. }
  159. return (
  160. <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
  161. <ColumnHeader
  162. icon='hashtag'
  163. active={hasUnread}
  164. title={this.title()}
  165. onPin={this.handlePin}
  166. onMove={this.handleMove}
  167. onClick={this.handleHeaderClick}
  168. pinned={pinned}
  169. multiColumn={multiColumn}
  170. extraButton={followButton}
  171. showBackButton
  172. >
  173. {columnId && <ColumnSettingsContainer columnId={columnId} />}
  174. </ColumnHeader>
  175. <StatusListContainer
  176. trackScroll={!pinned}
  177. scrollKey={`hashtag_timeline-${columnId}`}
  178. timelineId={`hashtag:${id}${local ? ':local' : ''}`}
  179. onLoadMore={this.handleLoadMore}
  180. emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
  181. bindToDocument={!multiColumn}
  182. />
  183. <Helmet>
  184. <title>#{id}</title>
  185. <meta name='robots' content='noindex' />
  186. </Helmet>
  187. </Column>
  188. );
  189. }
  190. }
  191. export default connect(mapStateToProps)(injectIntl(HashtagTimeline));