diff options
author | James Kiesel <james.kiesel@gmail.com> | 2018-11-06 06:53:25 +1300 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2018-11-05 18:53:25 +0100 |
commit | 4c03e05a4e1a237f8a414a0861c03abe3269dbc8 (patch) | |
tree | 6caa724b3ce982af4e40237a43ecf38d7595030a /app/javascript | |
parent | bb5558de627ca9bc26949570025f6193cd7cbd98 (diff) |
Allow joining several hashtags in a single column (#8904)
* Nascent tag menu on frontend * Hook up frontend to search * Tag intersection backend first pass * Update yarnlock * WIP * Fix for tags not searching correctly * Make radio buttons function * Simplify radio buttons with modeOption * Better naming * Rearrange options * Add all/any/none functionality on backend * Small PR cleanup * Move to service from scope * Small cleanup, add proper service tests * Don't use send with user input :D * Set appropriate column header * Handle auto updating timeline * Fix up toggle function * Use tag value correctly * A bit more correct to use 'self' rather than 'all' in status scope * Fix some style issues * Fix more code style issues * Style select dropdown more better * Only use to_id'ed value to ensure no SQL injection * Revamp frontend to allow for multiple selects * Update backend / col header to account for more flexible tagging * Update brakeman ignore * Codeclimate suggestions * Fix presenter tag_url * Implement initial PR feedback * Handle additional tag streaming * CodeClimate tweak
Diffstat (limited to 'app/javascript')
10 files changed, 286 insertions, 49 deletions
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 8cf055540..cd319709d 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -12,7 +12,7 @@ import { getLocale } from '../locales'; const { messages } = getLocale(); -export function connectTimelineStream (timelineId, path, pollingRefresh = null) { +export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) { return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); @@ -24,7 +24,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) onReceive (data) { switch(data.event) { case 'update': - dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), accept)); break; case 'delete': dispatch(deleteFromTimelines(data.payload)); @@ -51,6 +51,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => { export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); -export const connectHashtagStream = tag => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index c4fc6448c..2b7962a6e 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -4,6 +4,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; +export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; @@ -13,10 +14,14 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; -export function updateTimeline(timeline, status) { +export function updateTimeline(timeline, status, accept) { return (dispatch, getState) => { const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; + if (typeof accept === 'function' && !accept(status)) { + return; + } + dispatch(importFetchedStatus(status)); dispatch({ @@ -44,8 +49,20 @@ export function deleteFromTimelines(id) { }; }; +export function clearTimeline(timeline) { + return (dispatch) => { + dispatch({ type: TIMELINE_CLEAR, timeline }); + }; +}; + const noOp = () => {}; +const parseTags = (tags = {}, mode) => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + export function expandTimeline(timelineId, path, params = {}, done = noOp) { return (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); @@ -79,9 +96,17 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); -export const expandHashtagTimeline = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + }, done); +}; + export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js new file mode 100644 index 000000000..82936c838 --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import AsyncSelect from 'react-select/lib/Async'; + +@injectIntl +export default 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.toJSON) { + return tags.toJSON(); + } else { + return tags; + } + }; + + onSelect = (mode) => { + return (value) => { + this.props.onChange(['tags', mode], value); + }; + }; + + onToggle = () => { + if (this.state.open && this.hasTags()) { + this.props.onChange('tags', {}); + } + this.setState({ open: !this.state.open }); + }; + + modeSelect (mode) { + return ( + <div className='column-settings__section'> + {this.modeLabel(mode)} + <AsyncSelect + isMulti + autoFocus + value={this.tags(mode)} + settings={this.props.settings} + settingPath={['tags', mode]} + onChange={this.onSelect(mode)} + loadOptions={this.props.onLoad} + classNamePrefix='column-settings__hashtag-select' + name='tags' + /> + </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' />; + } + return ''; + }; + + render () { + 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> + ); + } + +} diff --git a/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..c5098052c --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeColumnParams } from '../../../actions/columns'; +import api from '../../../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') }; +}; + +const mapDispatchToProps = (dispatch, { columnId }) => ({ + onChange (key, value) { + dispatch(changeColumnParams(columnId, key, value)); + }, + + onLoad (value) { + return api().get('/api/v2/search', { params: { q: value } }).then(response => { + return (response.data.hashtags || []).map((tag) => { + return { value: tag.name, label: `#${tag.name}` }; + }); + }); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 63efdf1bd..86658cb66 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -4,7 +4,8 @@ import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandHashtagTimeline } from '../../actions/timelines'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; import { connectHashtagStream } from '../../actions/streaming'; @@ -16,6 +17,8 @@ const mapStateToProps = (state, props) => ({ export default @connect(mapStateToProps) class HashtagTimeline extends React.PureComponent { + disconnects = []; + static propTypes = { params: PropTypes.object.isRequired, columnId: PropTypes.string, @@ -35,6 +38,30 @@ class HashtagTimeline extends React.PureComponent { } } + title = () => { + let title = [this.props.params.id]; + if (this.additionalFor('any')) { + title.push(<FormattedMessage id='hashtag.column_header.tag_mode.any' values={{ additional: this.additionalFor('any') }} defaultMessage=' or {additional}' />); + } + if (this.additionalFor('all')) { + title.push(<FormattedMessage id='hashtag.column_header.tag_mode.all' values={{ additional: this.additionalFor('all') }} defaultMessage=' and {additional}' />); + } + if (this.additionalFor('none')) { + title.push(<FormattedMessage 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)); @@ -44,30 +71,40 @@ class HashtagTimeline extends React.PureComponent { this.column.scrollTop(); } - _subscribe (dispatch, id) { - this.disconnect = dispatch(connectHashtagStream(id)); + _subscribe (dispatch, id, tags = {}) { + 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, (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 () { - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } + this.disconnects.map(disconnect => disconnect()); + this.disconnects = []; } componentDidMount () { const { dispatch } = this.props; - const { id } = this.props.params; + const { id, tags } = this.props.params; - dispatch(expandHashtagTimeline(id)); - this._subscribe(dispatch, id); + dispatch(expandHashtagTimeline(id, { tags })); } componentWillReceiveProps (nextProps) { - if (nextProps.params.id !== this.props.params.id) { - this.props.dispatch(expandHashtagTimeline(nextProps.params.id)); + const { dispatch, params } = this.props; + const { id, tags } = nextProps.params; + if (id !== params.id || tags !== params.tags) { this._unsubscribe(); - this._subscribe(this.props.dispatch, nextProps.params.id); + this._subscribe(dispatch, id, tags); + this.props.dispatch(clearTimeline(`hashtag:${id}`)); + this.props.dispatch(expandHashtagTimeline(id, { tags })); } } @@ -80,7 +117,8 @@ class HashtagTimeline extends React.PureComponent { } handleLoadMore = maxId => { - this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId })); + const { id, tags } = this.props.params; + this.props.dispatch(expandHashtagTimeline(id, { maxId, tags })); } render () { @@ -93,14 +131,16 @@ class HashtagTimeline extends React.PureComponent { <ColumnHeader icon='hashtag' active={hasUnread} - title={id} + title={this.title()} onPin={this.handlePin} onMove={this.handleMove} onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} showBackButton - /> + > + {columnId && <ColumnSettingsContainer columnId={columnId} />} + </ColumnHeader> <StatusListContainer trackScroll={!pinned} diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js index 45c263257..759922638 100644 --- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js @@ -27,7 +27,7 @@ class HashtagTimeline extends React.PureComponent { const { dispatch, hashtag } = this.props; dispatch(expandHashtagTimeline(hashtag)); - this.disconnect = dispatch(connectHashtagStream(hashtag)); + this.disconnect = dispatch(connectHashtagStream(hashtag, hashtag)); } componentWillUnmount () { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index bf593f21b..665fc3a3c 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -137,6 +137,13 @@ "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", "getting_started.security": "Security", "getting_started.terms": "Terms of service", + "hashtag.column_settings.tag_toggle": "Include additional tags for this column", + "hashtag.column_settings.tag_mode.any": "Any of these", + "hashtag.column_settings.tag_mode.all": "All of these", + "hashtag.column_settings.tag_mode.none": "None of these", + "hashtag.column_header.tag_mode.any": "{tag} or {additional}", + "hashtag.column_header.tag_mode.all": "{tag} and {additional}", + "hashtag.column_header.tag_mode.none": "{tag} without {additional}", "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 916a091eb..664d65151 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -1,6 +1,7 @@ import { TIMELINE_UPDATE, TIMELINE_DELETE, + TIMELINE_CLEAR, TIMELINE_EXPAND_SUCCESS, TIMELINE_EXPAND_REQUEST, TIMELINE_EXPAND_FAIL, @@ -86,6 +87,10 @@ const deleteStatus = (state, id, accountId, references) => { return state; }; +const clearTimeline = (state, timeline) => { + return state.updateIn([timeline, 'items'], list => list.clear()); +}; + const filterTimelines = (state, relationship, statuses) => { let references; @@ -126,6 +131,8 @@ export default function timelines(state = initialState, action) { return updateTimeline(state, action.timeline, fromJS(action.status)); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case TIMELINE_CLEAR: + return clearTimeline(state, action.timeline); case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_MUTE_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss index 67d768a6c..d5bafe6b6 100644 --- a/app/javascript/styles/mastodon/_mixins.scss +++ b/app/javascript/styles/mastodon/_mixins.scss @@ -10,3 +10,34 @@ height: $size; background-size: $size $size; } + +@mixin search-input() { + outline: 0; + box-sizing: border-box; + width: 100%; + border: none; + box-shadow: none; + font-family: inherit; + background: $ui-base-color; + color: $darker-text-color; + font-size: 14px; + margin: 0; + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 481648054..4f1537314 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3022,6 +3022,26 @@ a.status-card.compact:hover { display: block; font-weight: 500; margin-bottom: 10px; + + .column-settings__hashtag-select { + &__control { + @include search-input(); + } + + &__multi-value { + background: lighten($ui-base-color, 8%); + } + + &__multi-value__label, + &__input { + color: $darker-text-color; + } + + &__indicator-separator, + &__dropdown-indicator { + display: none; + } + } } .column-settings__row { @@ -3473,36 +3493,10 @@ a.status-card.compact:hover { } .search__input { - outline: 0; - box-sizing: border-box; display: block; - width: 100%; - border: none; padding: 10px; padding-right: 30px; - font-family: inherit; - background: $ui-base-color; - color: $darker-text-color; - font-size: 14px; - margin: 0; - - &::-moz-focus-inner { - border: 0; - } - - &::-moz-focus-inner, - &:focus, - &:active { - outline: 0 !important; - } - - &:focus { - background: lighten($ui-base-color, 4%); - } - - @media screen and (max-width: 600px) { - font-size: 16px; - } + @include search-input(); } .search__icon { |