From 4c03e05a4e1a237f8a414a0861c03abe3269dbc8 Mon Sep 17 00:00:00 2001 From: James Kiesel Date: Tue, 6 Nov 2018 06:53:25 +1300 Subject: 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 --- app/controllers/api/v1/timelines/tag_controller.rb | 2 +- app/controllers/tags_controller.rb | 7 +- app/javascript/mastodon/actions/streaming.js | 6 +- app/javascript/mastodon/actions/timelines.js | 29 +++++- .../hashtag_timeline/components/column_settings.js | 102 +++++++++++++++++++++ .../containers/column_settings_container.js | 31 +++++++ .../mastodon/features/hashtag_timeline/index.js | 72 +++++++++++---- .../features/standalone/hashtag_timeline/index.js | 2 +- app/javascript/mastodon/locales/en.json | 7 ++ app/javascript/mastodon/reducers/timelines.js | 7 ++ app/javascript/styles/mastodon/_mixins.scss | 31 +++++++ app/javascript/styles/mastodon/components.scss | 48 +++++----- app/models/status.rb | 11 +++ app/services/hashtag_query_service.rb | 21 +++++ 14 files changed, 323 insertions(+), 53 deletions(-) create mode 100644 app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js create mode 100644 app/javascript/mastodon/features/hashtag_timeline/containers/column_settings_container.js create mode 100644 app/services/hashtag_query_service.rb (limited to 'app') diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index cf58d5cf4..92c32c178 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -45,7 +45,7 @@ class Api::V1::Timelines::TagController < Api::BaseController end def tag_timeline_statuses - Status.as_tag_timeline(@tag, current_account, truthy_param?(:local)) + HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, truthy_param?(:local)) end def insert_pagination_headers diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 8772509d5..8e4051834 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -16,14 +16,15 @@ class TagsController < ApplicationController end format.rss do - @statuses = Status.as_tag_timeline(@tag).limit(PAGE_SIZE) + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none)).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) render xml: RSS::TagSerializer.render(@tag, @statuses) end format.json do - @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id]) + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]) + .paginate_by_max_id(PAGE_SIZE, params[:max_id]) @statuses = cache_collection(@statuses, Status) render json: collection_presenter, @@ -46,7 +47,7 @@ class TagsController < ApplicationController def collection_presenter ActivityPub::CollectionPresenter.new( - id: tag_url(@tag), + id: tag_url(@tag, params.slice(:any, :all, :none)), type: :ordered, size: @tag.statuses.count, items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } 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 ( +
+ {this.modeLabel(mode)} + +
+ ); + } + + modeLabel (mode) { + switch(mode) { + case 'any': return ; + case 'all': return ; + case 'none': return ; + } + return ''; + }; + + render () { + return ( +
+
+
+ + + + +
+
+ {this.state.open && +
+ {this.modeSelect('any')} + {this.modeSelect('all')} + {this.modeSelect('none')} +
+ } +
+ ); + } + +} 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(); + } + if (this.additionalFor('all')) { + title.push(); + } + if (this.additionalFor('none')) { + title.push(); + } + 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 { + > + {columnId && } + { 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 { diff --git a/app/models/status.rb b/app/models/status.rb index 32fedb924..11a75f876 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -82,6 +82,17 @@ class Status < ApplicationRecord scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) } + scope :tagged_with_all, ->(tags) { + Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id| + result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") + end + } + scope :tagged_with_none, ->(tags) { + Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id| + result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") + .where("t#{id}.tag_id IS NULL") + end + } cache_associated :account, :application, diff --git a/app/services/hashtag_query_service.rb b/app/services/hashtag_query_service.rb new file mode 100644 index 000000000..86558a446 --- /dev/null +++ b/app/services/hashtag_query_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class HashtagQueryService < BaseService + def call(tag, params, account = nil, local = false) + any = tags_for(params[:any]) + all = tags_for(params[:all]) + none = tags_for(params[:none]) + + @query = Status.as_tag_timeline(tag, account, local) + .tagged_with_all(all) + .tagged_with_none(none) + @query = @query.distinct.or(self.class.new.call(any, params.except(:any), account, local).distinct) if any + @query + end + + private + + def tags_for(tags) + Tag.where(name: tags.map(&:downcase)) if tags.presence + end +end -- cgit