diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/hashtag_timeline')
3 files changed, 402 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js new file mode 100644 index 000000000..ede8907e5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import AsyncSelect from 'react-select/async'; +import { NonceProvider } from 'react-select'; +import SettingToggle from '../../notifications/components/setting_toggle'; + +const messages = defineMessages({ + placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' }, + noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' }, +}); + +export default @injectIntl +class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onLoad: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + open: this.hasTags(), + }; + + hasTags () { + return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true); + } + + tags (mode) { + let tags = this.props.settings.getIn(['tags', mode]) || []; + + if (tags.toJS) { + return tags.toJS(); + } else { + return tags; + } + }; + + onSelect = mode => value => { + const oldValue = this.tags(mode); + + // Prevent changes that add more than 4 tags, but allow removing + // tags that were already added before + if ((value.length > 4) && !(value < oldValue)) { + return; + } + + this.props.onChange(['tags', mode], value); + }; + + onToggle = () => { + if (this.state.open && this.hasTags()) { + this.props.onChange('tags', {}); + } + + this.setState({ open: !this.state.open }); + }; + + noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions); + + modeSelect (mode) { + return ( + <div className='column-settings__row'> + <span className='column-settings__section'> + {this.modeLabel(mode)} + </span> + + <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='tags'> + <AsyncSelect + isMulti + autoFocus + value={this.tags(mode)} + onChange={this.onSelect(mode)} + loadOptions={this.props.onLoad} + className='column-select__container' + classNamePrefix='column-select' + name='tags' + placeholder={this.props.intl.formatMessage(messages.placeholder)} + noOptionsMessage={this.noOptionsMessage} + /> + </NonceProvider> + </div> + ); + } + + modeLabel (mode) { + switch(mode) { + case 'any': + return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />; + case 'all': + return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />; + case 'none': + return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />; + default: + return ''; + } + }; + + render () { + const { settings, onChange } = this.props; + + return ( + <div> + <div className='column-settings__row'> + <div className='setting-toggle'> + <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} /> + + <span className='setting-toggle__label'> + <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' /> + </span> + </div> + </div> + + {this.state.open && ( + <div className='column-settings__hashtags'> + {this.modeSelect('any')} + {this.modeSelect('all')} + {this.modeSelect('none')} + </div> + )} + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..004856b04 --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeColumnParams } from 'flavours/glitch/actions/columns'; +import api from 'flavours/glitch/api'; + +const mapStateToProps = (state, { columnId }) => { + const columns = state.getIn(['settings', 'columns']); + const index = columns.findIndex(c => c.get('uuid') === columnId); + + if (!(columnId && index >= 0)) { + return {}; + } + + return { + settings: columns.get(index).get('params'), + onLoad (value) { + return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => { + return (response.data.hashtags || []).map((tag) => { + return { value: tag.name, label: `#${tag.name}` }; + }); + }); + }, + }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => ({ + onChange (key, value) { + dispatch(changeColumnParams(columnId, key, value)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js new file mode 100644 index 000000000..f1827789f --- /dev/null +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -0,0 +1,237 @@ +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 Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { connectHashtagStream } from 'flavours/glitch/actions/streaming'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { isEqual } from 'lodash'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'flavours/glitch/actions/tags'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { Helmet } from 'react-helmet'; + +const messages = defineMessages({ + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, +}); + +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0, + tag: state.getIn(['tags', props.params.id]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class HashtagTimeline extends React.PureComponent { + + disconnects = []; + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + columnId: PropTypes.string, + dispatch: PropTypes.func.isRequired, + hasUnread: PropTypes.bool, + tag: ImmutablePropTypes.map, + multiColumn: PropTypes.bool, + intl: PropTypes.object, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HASHTAG', { id: this.props.params.id })); + } + } + + title = () => { + const { id } = this.props.params; + const title = [id]; + + if (this.additionalFor('any')) { + title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />); + } + + if (this.additionalFor('all')) { + title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />); + } + + if (this.additionalFor('none')) { + title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />); + } + + return title; + } + + additionalFor = (mode) => { + const { tags } = this.props.params; + + if (tags && (tags[mode] || []).length > 0) { + return tags[mode].map(tag => tag.value).join('/'); + } else { + return ''; + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + _subscribe (dispatch, id, tags = {}, local) { + const { signedIn } = this.context.identity; + + if (!signedIn) { + return; + } + + let any = (tags.any || []).map(tag => tag.value); + let all = (tags.all || []).map(tag => tag.value); + let none = (tags.none || []).map(tag => tag.value); + + [id, ...any].map(tag => { + this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => { + let tags = status.tags.map(tag => tag.name); + + return all.filter(tag => tags.includes(tag)).length === all.length && + none.filter(tag => tags.includes(tag)).length === 0; + }))); + }); + } + + _unsubscribe () { + this.disconnects.map(disconnect => disconnect()); + this.disconnects = []; + } + + _unload () { + const { dispatch } = this.props; + const { id, local } = this.props.params; + + this._unsubscribe(); + dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`)); + } + + _load() { + const { dispatch } = this.props; + const { id, tags, local } = this.props.params; + + this._subscribe(dispatch, id, tags, local); + dispatch(expandHashtagTimeline(id, { tags, local })); + dispatch(fetchHashtag(id)); + } + + componentDidMount () { + this._load(); + } + + componentDidUpdate (prevProps) { + const { params } = this.props; + const { id, tags, local } = prevProps.params; + + if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) { + this._unload(); + this._load(); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = maxId => { + const { dispatch, params } = this.props; + const { id, tags, local } = params; + + dispatch(expandHashtagTimeline(id, { maxId, tags, local })); + } + + handleFollow = () => { + const { dispatch, params, tag } = this.props; + const { id } = params; + const { signedIn } = this.context.identity; + + if (!signedIn) { + return; + } + + if (tag.get('following')) { + dispatch(unfollowHashtag(id)); + } else { + dispatch(followHashtag(id)); + } + } + + render () { + const { hasUnread, columnId, multiColumn, tag, intl } = this.props; + const { id, local } = this.props.params; + const pinned = !!columnId; + const { signedIn } = this.context.identity; + + let followButton; + + if (tag) { + const following = tag.get('following'); + + followButton = ( + <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-pressed={following ? 'true' : 'false'}> + <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' /> + </button> + ); + } + + return ( + <Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}> + <ColumnHeader + icon='hashtag' + active={hasUnread} + title={this.title()} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + extraButton={followButton} + showBackButton + > + {columnId && <ColumnSettingsContainer columnId={columnId} />} + </ColumnHeader> + + <StatusListContainer + trackScroll={!pinned} + scrollKey={`hashtag_timeline-${columnId}`} + timelineId={`hashtag:${id}${local ? ':local' : ''}`} + onLoadMore={this.handleLoadMore} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + bindToDocument={!multiColumn} + /> + + <Helmet> + <title>#{id}</title> + <meta name='robots' content='noindex' /> + </Helmet> + </Column> + ); + } + +} |