diff options
83 files changed, 1045 insertions, 89 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 59b018e0f..8c0e05756 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -38,7 +38,7 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.7) + active_model_serializers (0.10.8) actionpack (>= 4.1, < 6) activemodel (>= 4.1, < 6) case_transform (>= 0.2) @@ -422,8 +422,8 @@ GEM pundit (2.0.0) activesupport (>= 3.0.0) raabro (1.1.6) - rack (2.0.5) - rack-attack (5.4.1) + rack (2.0.6) + rack-attack (5.4.2) rack (>= 1.0, < 3) rack-cors (1.0.2) rack-protection (2.0.4) @@ -475,7 +475,7 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.3) rdf (>= 2.2, < 4.0) - redis (4.0.2) + redis (4.0.3) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) 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 a48fdb9f8..d06f4e070 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -17,14 +17,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, @@ -47,7 +48,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/lists.js b/app/javascript/mastodon/actions/lists.js index 12cb17159..d736bacef 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -42,6 +42,13 @@ export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; +export const LIST_ADDER_RESET = 'LIST_ADDER_RESET'; +export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP'; + +export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST'; +export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS'; +export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL'; + export const fetchList = id => (dispatch, getState) => { if (getState().getIn(['lists', id])) { return; @@ -316,3 +323,50 @@ export const removeFromListFail = (listId, accountId, error) => ({ accountId, error, }); + +export const resetListAdder = () => ({ + type: LIST_ADDER_RESET, +}); + +export const setupListAdder = accountId => (dispatch, getState) => { + dispatch({ + type: LIST_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchLists()); + dispatch(fetchAccountLists(accountId)); +}; + +export const fetchAccountLists = accountId => (dispatch, getState) => { + dispatch(fetchAccountListsRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/lists`) + .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountListsFail(accountId, err))); +}; + +export const fetchAccountListsRequest = id => ({ + type:LIST_ADDER_LISTS_FETCH_REQUEST, + id, +}); + +export const fetchAccountListsSuccess = (id, lists) => ({ + type: LIST_ADDER_LISTS_FETCH_SUCCESS, + id, + lists, +}); + +export const fetchAccountListsFail = (id, err) => ({ + type: LIST_ADDER_LISTS_FETCH_FAIL, + id, + err, +}); + +export const addToListAdder = listId => (dispatch, getState) => { + dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + +export const removeFromListAdder = listId => (dispatch, getState) => { + dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId']))); +}; + 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/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index 5c888650c..e51c83c2b 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -9,6 +9,8 @@ import { List as ImmutableList } from 'immutable'; import classNames from 'classnames'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; +const MOUSE_IDLE_DELAY = 300; + export default class ScrollableList extends PureComponent { static contextTypes = { @@ -37,6 +39,8 @@ export default class ScrollableList extends PureComponent { state = { fullscreen: null, + mouseMovedRecently: false, + scrollToTopOnMouseIdle: false, }; intersectionObserverWrapper = new IntersectionObserverWrapper(); @@ -60,6 +64,47 @@ export default class ScrollableList extends PureComponent { trailing: true, }); + mouseIdleTimer = null; + + clearMouseIdleTimer = () => { + if (this.mouseIdleTimer === null) { + return; + } + clearTimeout(this.mouseIdleTimer); + this.mouseIdleTimer = null; + }; + + handleMouseMove = throttle(() => { + // As long as the mouse keeps moving, clear and restart the idle timer. + this.clearMouseIdleTimer(); + this.mouseIdleTimer = + setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + + this.setState(({ + mouseMovedRecently, + scrollToTopOnMouseIdle, + }) => ({ + mouseMovedRecently: true, + // Only set scrollToTopOnMouseIdle if we just started moving and were + // scrolled to the top. Otherwise, just retain the previous state. + scrollToTopOnMouseIdle: + mouseMovedRecently + ? scrollToTopOnMouseIdle + : (this.node.scrollTop === 0), + })); + }, MOUSE_IDLE_DELAY / 2); + + handleMouseIdle = () => { + if (this.state.scrollToTopOnMouseIdle) { + this.node.scrollTop = 0; + this.props.onScrollToTop(); + } + this.setState({ + mouseMovedRecently: false, + scrollToTopOnMouseIdle: false, + }); + } + componentDidMount () { this.attachScrollListener(); this.attachIntersectionObserver(); @@ -73,7 +118,7 @@ export default class ScrollableList extends PureComponent { const someItemInserted = React.Children.count(prevProps.children) > 0 && React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); - if (someItemInserted && this.node.scrollTop > 0) { + if ((someItemInserted && this.node.scrollTop > 0) || this.state.mouseMovedRecently) { return this.node.scrollHeight - this.node.scrollTop; } else { return null; @@ -93,6 +138,7 @@ export default class ScrollableList extends PureComponent { } componentWillUnmount () { + this.clearMouseIdleTimer(); this.detachScrollListener(); this.detachIntersectionObserver(); detachFullscreenListener(this.onFullScreenChange); @@ -151,7 +197,7 @@ export default class ScrollableList extends PureComponent { if (isLoading || childrenCount > 0 || !emptyMessage) { scrollableArea = ( - <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}> + <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> <div role='feed' className='item-list'> {prepend} diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index c9ae2df98..e6ae1a2fd 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -34,6 +34,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, }); export default @injectIntl @@ -51,6 +52,7 @@ class ActionBar extends React.PureComponent { onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, + onAddToList: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; @@ -105,6 +107,7 @@ class ActionBar extends React.PureComponent { } menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); + menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList }); menu.push(null); } diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index ab29e4bdf..779e116e0 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent { onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, + onAddToList: PropTypes.func.isRequired, hideTabs: PropTypes.bool, }; @@ -78,6 +79,10 @@ export default class Header extends ImmutablePureComponent { this.props.onEndorseToggle(this.props.account); } + handleAddToList = () => { + this.props.onAddToList(this.props.account); + } + render () { const { account, hideTabs } = this.props; @@ -106,6 +111,7 @@ export default class Header extends ImmutablePureComponent { onBlockDomain={this.handleBlockDomain} onUnblockDomain={this.handleUnblockDomain} onEndorseToggle={this.handleEndorseToggle} + onAddToList={this.handleAddToList} /> {!hideTabs && ( diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 02803893d..0fd79d036 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -116,6 +116,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(unblockDomain(domain)); }, + onAddToList(account){ + dispatch(openModal('LIST_ADDER', { + accountId: account.get('id'), + })); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); 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/list_adder/components/account.js b/app/javascript/mastodon/features/list_adder/components/account.js new file mode 100644 index 000000000..1369aac07 --- /dev/null +++ b/app/javascript/mastodon/features/list_adder/components/account.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import { injectIntl } from 'react-intl'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + + +export default @connect(makeMapStateToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/list_adder/components/list.js b/app/javascript/mastodon/features/list_adder/components/list.js new file mode 100644 index 000000000..cb8eb7d7a --- /dev/null +++ b/app/javascript/mastodon/features/list_adder/components/list.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { removeFromListAdder, addToListAdder } from '../../../actions/lists'; + +const messages = defineMessages({ + remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, +}); + +const MapStateToProps = (state, { listId, added }) => ({ + list: state.get('lists').get(listId), + added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added, +}); + +const mapDispatchToProps = (dispatch, { listId }) => ({ + onRemove: () => dispatch(removeFromListAdder(listId)), + onAdd: () => dispatch(addToListAdder(listId)), +}); + +export default @connect(MapStateToProps, mapDispatchToProps) +@injectIntl +class List extends ImmutablePureComponent { + + static propTypes = { + list: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { list, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='list'> + <div className='list__wrapper'> + <div className='list__display-name'> + <i className='fa fa-fw fa-list-ul column-link__icon' /> + {list.get('title')} + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/list_adder/index.js b/app/javascript/mastodon/features/list_adder/index.js new file mode 100644 index 000000000..cb8a15e8c --- /dev/null +++ b/app/javascript/mastodon/features/list_adder/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl } from 'react-intl'; +import { setupListAdder, resetListAdder } from '../../actions/lists'; +import { createSelector } from 'reselect'; +import List from './components/list'; +import Account from './components/account'; +import NewListForm from '../lists/components/new_list_form'; +// hack + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + listIds: getOrderedLists(state).map(list=>list.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: accountId => dispatch(setupListAdder(accountId)), + onReset: () => dispatch(resetListAdder()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class ListAdder extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + listIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, accountId } = this.props; + onInitialize(accountId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountId, listIds } = this.props; + + return ( + <div className='modal-root__modal list-adder'> + <div className='list-adder__account'> + <Account accountId={accountId} /> + </div> + + <NewListForm /> + + + <div className='list-adder__lists'> + {listIds.map(ListId => <List key={ListId} listId={ListId} />)} + </div> + </div> + ); + } + +} 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/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index d8e034554..b3b1ea862 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -16,6 +16,7 @@ import { ReportModal, EmbedModal, ListEditor, + ListAdder, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -30,6 +31,7 @@ const MODAL_COMPONENTS = { 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), + 'LIST_ADDER':ListAdder, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 8cf2a6e7d..2a15c052f 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -129,3 +129,7 @@ export function EmbedModal () { export function ListEditor () { return import(/* webpackChunkName: "features/list_editor" */'../../list_editor'); } + +export function ListAdder () { + return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); +} diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index db2593afc..8cd9ba773 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "روبوت", "account.block": "حظر @{name}", "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}", diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json index d84774f34..ab0f5b892 100644 --- a/app/javascript/mastodon/locales/ast.json +++ b/app/javascript/mastodon/locales/ast.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robó", "account.block": "Bloquiar a @{name}", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index a4366126f..853361b80 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Блокирай", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 2e766da6a..f4c5f97be 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bloca @{name}", "account.block_domain": "Amaga-ho tot de {domain}", diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json index 357ff0ac8..f322ce53d 100644 --- a/app/javascript/mastodon/locales/co.json +++ b/app/javascript/mastodon/locales/co.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bluccà @{name}", "account.block_domain": "Piattà tuttu da {domain}", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index 5f82dd8e0..e809eb136 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robot", "account.block": "Zablokovat uživatele @{name}", "account.block_domain": "Skrýt vše z {domain}", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 78c8d02f0..71a34272e 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blocio @{name}", "account.block_domain": "Cuddio popeth rhag {domain}", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index d76f4ac1f..54cae027f 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robot", "account.block": "Bloker @{name}", "account.block_domain": "Skjul alt fra {domain}", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 81b8ceedd..a81a52d51 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "@{name} blockieren", "account.block_domain": "Alles von {domain} verstecken", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 737c55d37..ec2d38a47 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -578,6 +578,9 @@ "id": "account.unendorse" }, { + "id": "account.add_or_remove_from_list" + }, + { "defaultMessage": "Information below may reflect the user's profile incompletely.", "id": "account.disclaimer_full" }, @@ -1455,6 +1458,19 @@ "id": "lists.account.add" } ], + "path": "app/javascript/mastodon/features/list_adder/components/list.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Remove from list", + "id": "lists.account.remove" + }, + { + "defaultMessage": "Add to list", + "id": "lists.account.add" + } + ], "path": "app/javascript/mastodon/features/list_editor/components/account.json" }, { diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 8e67e7f90..06356f8e6 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Μποτ", "account.block": "Απόκλεισε τον/την @{name}", "account.block_domain": "Απόκρυψε τα πάντα από το {domain}", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a45d762c1..bd41b9714 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", @@ -140,6 +141,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/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 0522ce95b..ef9f99abb 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Roboto", "account.block": "Bloki @{name}", "account.block_domain": "Kaŝi ĉion de {domain}", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index f06ac11a4..511209809 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bloquear", "account.block_domain": "Ocultar todo de {domain}", diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json index 300e7eae4..fb129967e 100644 --- a/app/javascript/mastodon/locales/eu.json +++ b/app/javascript/mastodon/locales/eu.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokeatu @{name}", "account.block_domain": "Ezkutatu {domain} domeinuko guztia", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 7198931c4..e1d7d9628 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "ربات", "account.block": "مسدودسازی @{name}", "account.block_domain": "پنهانسازی همه چیز از سرور {domain}", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index fc623dab8..abbcded90 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Botti", "account.block": "Estä @{name}", "account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index d8b16672b..c6cb3cba8 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bloquer @{name}", "account.block_domain": "Tout masquer venant de {domain}", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 2f8b76d3a..98011dac7 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bloquear @{name}", "account.block_domain": "Ocultar calquer contido de {domain}", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 1ef20f231..d0c96917e 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "חסימת @{name}", "account.block_domain": "להסתיר הכל מהקהילה {domain}", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index c9b8e7f75..c50138e23 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokiraj @{name}", "account.block_domain": "Sakrij sve sa {domain}", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 66a1d4c09..90d186d57 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "@{name} letiltása", "account.block_domain": "Minden elrejtése innen: {domain}", diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json index e7d251a35..388cc4381 100644 --- a/app/javascript/mastodon/locales/hy.json +++ b/app/javascript/mastodon/locales/hy.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Արգելափակել @{name}֊ին", "account.block_domain": "Թաքցնել ամենը հետեւյալ տիրույթից՝ {domain}", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 1c84ed061..4f3b654ab 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokir @{name}", "account.block_domain": "Sembunyikan segalanya dari {domain}", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index 9963a52a5..55a5ba748 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokusar @{name}", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 711a360a9..d6f6ff3e2 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blocca @{name}", "account.block_domain": "Nascondi tutto da {domain}", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 4dcc18518..a964d6133 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "@{name}さんをブロック", "account.block_domain": "{domain}全体を非表示", diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json index 9d6d0d66d..5d6537b90 100644 --- a/app/javascript/mastodon/locales/ka.json +++ b/app/javascript/mastodon/locales/ka.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "ბოტი", "account.block": "დაბლოკე @{name}", "account.block_domain": "დაიმალოს ყველაფერი დომენიდან {domain}", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index c779017f3..a7228d4d5 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "봇", "account.block": "@{name}을 차단", "account.block_domain": "{domain} 전체를 숨김", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 7a8ff6868..ec53a66b4 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokkeer @{name}", "account.block_domain": "Verberg alles van {domain}", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 7c7e7600e..d827a9816 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokkér @{name}", "account.block_domain": "Skjul alt fra {domain}", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 5df64e192..49da27568 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robòt", "account.block": "Blocar @{name}", "account.block_domain": "Tot amagar del domeni {domain}", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 3f596bc37..ed9956d2d 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokuj @{name}", "account.block_domain": "Blokuj wszystko z {domain}", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 061d10f4d..440a39c00 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robô", "account.block": "Bloquear @{name}", "account.block_domain": "Esconder tudo de {domain}", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index adb10dd07..94d01f2a4 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Bloquear @{name}", "account.block_domain": "Esconder tudo do domínio {domain}", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index 2a6479b91..ed6f2c7b1 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blochează @{name}", "account.block_domain": "Ascunde tot de la {domain}", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index cd65adcb5..d41cbd09d 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Бот", "account.block": "Блокировать", "account.block_domain": "Блокировать все с {domain}", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index cca2e3c62..8d1547d66 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokuj @{name}", "account.block_domain": "Ukry všetko z {domain}", diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json index 175a34efd..01f2ccbf9 100644 --- a/app/javascript/mastodon/locales/sl.json +++ b/app/javascript/mastodon/locales/sl.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robot", "account.block": "Blokiraj @{name}", "account.block_domain": "Skrij vse iz {domain}", diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json index 6020512c4..b3075d2f1 100644 --- a/app/javascript/mastodon/locales/sr-Latn.json +++ b/app/javascript/mastodon/locales/sr-Latn.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Blokiraj korisnika @{name}", "account.block_domain": "Sakrij sve sa domena {domain}", diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json index 41d9e12b0..4ed720c9f 100644 --- a/app/javascript/mastodon/locales/sr.json +++ b/app/javascript/mastodon/locales/sr.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Бот", "account.block": "Блокирај @{name}", "account.block_domain": "Сакриј све са домена {domain}", diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json index dbe9f709a..7beee3cdc 100644 --- a/app/javascript/mastodon/locales/sv.json +++ b/app/javascript/mastodon/locales/sv.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Robot", "account.block": "Blockera @{name}", "account.block_domain": "Dölj allt från {domain}", diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json index 803e004cc..0c712e84c 100644 --- a/app/javascript/mastodon/locales/ta.json +++ b/app/javascript/mastodon/locales/ta.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json index af036e300..c602362bf 100644 --- a/app/javascript/mastodon/locales/te.json +++ b/app/javascript/mastodon/locales/te.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "బాట్", "account.block": "@{name} ను బ్లాక్ చేయి", "account.block_domain": "{domain} నుంచి అన్నీ దాచిపెట్టు", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index fe36a966c..86d8c9b2b 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Block @{name}", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 323617f1d..98ae1185d 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.block": "Engelle @{name}", "account.block_domain": "Hide everything from {domain}", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index cdc13c574..95a947f78 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Бот", "account.block": "Заблокувати @{name}", "account.block_domain": "Заглушити {domain}", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 9fee25e15..3d837001a 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "机器人", "account.block": "屏蔽 @{name}", "account.block_domain": "隐藏来自 {domain} 的内容", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 26eba48f8..035a645b8 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "機械人", "account.block": "封鎖 @{name}", "account.block_domain": "隱藏來自 {domain} 的一切文章", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 6d4a9a0bb..acacc571d 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -1,4 +1,5 @@ { + "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "機器人", "account.block": "封鎖 @{name}", "account.block_domain": "隱藏來自 {domain} 的一切嘟文", diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js index b13a9fdf4..955a07754 100644 --- a/app/javascript/mastodon/reducers/conversations.js +++ b/app/javascript/mastodon/reducers/conversations.js @@ -56,7 +56,13 @@ const expandNormalizedConversations = (state, conversations, next) => { list = list.concat(items); - return list.sortBy(x => x.get('last_status'), (a, b) => compareId(a, b) * -1); + return list.sortBy(x => x.get('last_status'), (a, b) => { + if(a === null || b === null) { + return -1; + } + + return compareId(a, b) * -1; + }); }); } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 2c98af1db..0f0de849f 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -25,6 +25,7 @@ import height_cache from './height_cache'; import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; +import listAdder from './list_adder'; import filters from './filters'; import conversations from './conversations'; import suggestions from './suggestions'; @@ -56,6 +57,7 @@ const reducers = { custom_emojis, lists, listEditor, + listAdder, filters, conversations, suggestions, diff --git a/app/javascript/mastodon/reducers/list_adder.js b/app/javascript/mastodon/reducers/list_adder.js new file mode 100644 index 000000000..b8c1b0e26 --- /dev/null +++ b/app/javascript/mastodon/reducers/list_adder.js @@ -0,0 +1,47 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + LIST_ADDER_RESET, + LIST_ADDER_SETUP, + LIST_ADDER_LISTS_FETCH_REQUEST, + LIST_ADDER_LISTS_FETCH_SUCCESS, + LIST_ADDER_LISTS_FETCH_FAIL, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + accountId: null, + + lists: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function listAdderReducer(state = initialState, action) { + switch(action.type) { + case LIST_ADDER_RESET: + return initialState; + case LIST_ADDER_SETUP: + return state.withMutations(map => { + map.set('accountId', action.account.get('id')); + }); + case LIST_ADDER_LISTS_FETCH_REQUEST: + return state.setIn(['lists', 'isLoading'], true); + case LIST_ADDER_LISTS_FETCH_FAIL: + return state.setIn(['lists', 'isLoading'], false); + case LIST_ADDER_LISTS_FETCH_SUCCESS: + return state.update('lists', lists => lists.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.lists.map(item => item.id))); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); + default: + return state; + } +}; 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 f778ba06b..e669bf2e2 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 { @@ -5344,6 +5338,47 @@ noscript { } } +.list-adder { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (max-width: 420px) { + width: 90%; + } + + &__account { + background: lighten($ui-base-color, 13%); + } + + &__lists { + background: lighten($ui-base-color, 13%); + height: 50vh; + border-radius: 0 0 8px 8px; + overflow-y: auto; + } + + .list { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .list__wrapper { + display: flex; + } + + .list__display-name { + flex: 1 1 auto; + overflow: hidden; + text-decoration: none; + font-size: 16px; + padding: 10px; + } +} + .focal-point-modal { max-width: 80vw; max-height: 80vh; diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss index a140e8bc7..e24ba8c1c 100644 --- a/app/javascript/styles/mastodon/reset.scss +++ b/app/javascript/styles/mastodon/reset.scss @@ -54,8 +54,7 @@ table { } html { - scrollbar-face-color: lighten($ui-base-color, 4%); - scrollbar-track-color: rgba($base-overlay-background, 0.1); + scrollbar-color: lighten($ui-base-color, 4%) transparent; } ::-webkit-scrollbar { diff --git a/app/models/status.rb b/app/models/status.rb index f67a05b3c..e73f11503 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -85,6 +85,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 + } scope :not_local_only, -> { where(local_only: [false, nil]) } 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 diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 1db1917e2..ed0c56923 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -20,7 +20,7 @@ class ResolveURLService < BaseService def process_url if equals_or_includes_any?(type, %w(Application Group Organization Person Service)) FetchRemoteAccountService.new.call(atom_url, body, protocol) - elsif equals_or_includes_any?(type, %w(Note Article Image Video)) + elsif equals_or_includes_any?(type, %w(Note Article Image Video Page)) FetchRemoteStatusService.new.call(atom_url, body, protocol) end end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index e5a5c16b4..58fb243da 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,7 +7,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/report.rb", - "line": 86, + "line": 90, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")", "render_path": null, @@ -40,6 +40,26 @@ "note": "" }, { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "19df3740b8d02a9fe0eb52c939b4b87d3a2a591162a6adfa8d64e9c26aeebe6d", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/status.rb", + "line": 84, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")", + "render_path": null, + "location": { + "type": "method", + "class": "Status", + "method": null + }, + "user_input": "id", + "confidence": "Weak", + "note": "" + }, + { "warning_type": "Cross-Site Scripting", "warning_code": 4, "fingerprint": "1fc29c578d0c89bf13bd5476829d272d54cd06b92ccf6df18568fa1f2674926e", @@ -175,6 +195,26 @@ "note": "" }, { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "6f075c1484908e3ec9bed21ab7cf3c7866be8da3881485d1c82e13093aefcbd7", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/status.rb", + "line": 89, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "result.joins(\"LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")", + "render_path": null, + "location": { + "type": "method", + "class": "Status", + "method": null + }, + "user_input": "id", + "confidence": "Weak", + "note": "" + }, + { "warning_type": "Cross-Site Scripting", "warning_code": 4, "fingerprint": "82f7b0d09beb3ab68e0fa16be63cedf4e820f2490326e9a1cec05761d92446cd", @@ -311,25 +351,6 @@ "note": "" }, { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "c5d6945d63264af106d49367228d206aa2f176699ecdce2b98fac101bc6a96cf", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/admin/reports/index.html.haml", - "line": 22, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => filtered_reports.page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":10,"file":"app/controllers/admin/reports_controller.rb"}], - "location": { - "type": "template", - "template": "admin/reports/index" - }, - "user_input": "params[:page]", - "confidence": "Weak", - "note": "" - }, - { "warning_type": "Cross-Site Scripting", "warning_code": 4, "fingerprint": "e04aafe1e06cf8317fb6ac0a7f35783e45aa1274272ee6eaf28d39adfdad489b", @@ -355,7 +376,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/reports_controller.rb", - "line": 42, + "line": 37, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit(:account_id, :comment, :forward, :status_ids => ([]))", "render_path": null, @@ -388,6 +409,6 @@ "note": "" } ], - "updated": "2018-08-30 21:55:10 +0200", + "updated": "2018-10-20 23:24:45 +1300", "brakeman_version": "4.2.1" } diff --git a/package.json b/package.json index 417e07cce..68ff199f2 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "react-redux-loading-bar": "^2.9.3", "react-router-dom": "^4.1.1", "react-router-scroll-4": "^1.0.0-beta.1", + "react-select": "^2.0.0", "react-sparklines": "^1.7.0", "react-swipeable-views": "^0.12.17", "react-textarea-autosize": "^5.2.1", diff --git a/spec/services/hashtag_query_service_spec.rb b/spec/services/hashtag_query_service_spec.rb new file mode 100644 index 000000000..24282d2f0 --- /dev/null +++ b/spec/services/hashtag_query_service_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe HashtagQueryService, type: :service do + describe '.call' do + let(:account) { Fabricate(:account) } + let(:tag1) { Fabricate(:tag) } + let(:tag2) { Fabricate(:tag) } + let!(:status1) { Fabricate(:status, tags: [tag1]) } + let!(:status2) { Fabricate(:status, tags: [tag2]) } + let!(:both) { Fabricate(:status, tags: [tag1, tag2]) } + + it 'can add tags in "any" mode' do + results = subject.call(tag1, { any: [tag2.name] }) + expect(results).to include status1 + expect(results).to include status2 + expect(results).to include both + end + + it 'can remove tags in "all" mode' do + results = subject.call(tag1, { all: [tag2.name] }) + expect(results).to_not include status1 + expect(results).to_not include status2 + expect(results).to include both + end + + it 'can remove tags in "none" mode' do + results = subject.call(tag1, { none: [tag2.name] }) + expect(results).to include status1 + expect(results).to_not include status2 + expect(results).to_not include both + end + + it 'ignores an invalid mode' do + results = subject.call(tag1, { wark: [tag2.name] }) + expect(results).to include status1 + expect(results).to_not include status2 + expect(results).to include both + end + + it 'handles being passed non existant tag names' do + results = subject.call(tag1, { any: ['wark'] }) + expect(results).to include status1 + expect(results).to_not include status2 + expect(results).to include both + end + + it 'can restrict to an account' do + BlockService.new.call(account, status1.account) + results = subject.call(tag1, { none: [tag2.name] }, account) + expect(results).to_not include status1 + end + + it 'can restrict to local' do + status1.account.update(domain: 'example.com') + status1.update(local: false, uri: 'example.com/toot') + results = subject.call(tag1, { any: [tag2.name] }, nil, true) + expect(results).to_not include status1 + end + end +end diff --git a/yarn.lock b/yarn.lock index eebf5adff..39052a49b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -731,6 +731,50 @@ resolved "https://registry.yarnpkg.com/@csstools/sass-import-resolve/-/sass-import-resolve-1.0.0.tgz#32c3cdb2f7af3cd8f0dca357b592e7271f3831b5" integrity sha512-pH4KCsbtBLLe7eqUrw8brcuFO8IZlN36JjdKlOublibVdAIPHCzEnpBWOVUXK5sCf+DpBi8ZtuWtjF0srybdeA== +"@emotion/babel-utils@^0.6.4": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.9.tgz#bb074fadad65c443a575d3379488415fd194fc75" + dependencies: + "@emotion/hash" "^0.6.5" + "@emotion/memoize" "^0.6.5" + "@emotion/serialize" "^0.9.0" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.5.tgz#097729b84a5164f71f9acd2570ecfd1354d7b360" + +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.5.tgz#f868c314b889e7c3d84868a1d1cc323fbb40ca86" + +"@emotion/serialize@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.0.tgz#ac5577cb98c7557c1a24a94cc101c5da6dc18322" + dependencies: + "@emotion/hash" "^0.6.5" + "@emotion/memoize" "^0.6.5" + "@emotion/unitless" "^0.6.6" + "@emotion/utils" "^0.8.1" + +"@emotion/stylis@^0.6.10": + version "0.6.12" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.6.12.tgz#3fb58220e0fc9e380bcabbb3edde396ddc1dfe6e" + +"@emotion/stylis@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.0.tgz#4c30e6fccc9555e42fa6fef98b3bd0788b954684" + +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.6.tgz#988957ecd0a9be00ee9de27172f8c56d41595a93" + +"@emotion/utils@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.1.tgz#f3a81587ad8d0ef33cdad6f3b4310774fcc1053e" + "@types/node@*": version "10.9.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.9.4.tgz#0f4cb2dc7c1de6096055357f70179043c33e9897" @@ -1328,7 +1372,7 @@ babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: esutils "^2.0.2" js-tokens "^3.0.2" -babel-core@^6.0.0, babel-core@^6.26.0: +babel-core@^6.0.0, babel-core@^6.26.0, babel-core@^6.26.3: version "6.26.3" resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA== @@ -1417,6 +1461,24 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-emotion@^9.2.9: + version "9.2.9" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.9.tgz#7b3c72fd6a333127abafe7fb693bcb421e7f5b9f" + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-core "^6.26.3" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^1.0.0" + babel-plugin-istanbul@^4.1.6: version "4.1.6" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" @@ -1443,7 +1505,7 @@ babel-plugin-lodash@^3.3.4: lodash "^4.17.10" require-package-name "^2.0.1" -babel-plugin-macros@^2.2.2: +babel-plugin-macros@^2.0.0, babel-plugin-macros@^2.2.2: version "2.4.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.0.tgz#6c5f9836e1f6c0a9743b3bab4af29f73e437e544" integrity sha512-flIBfrqAdHWn+4l2cS/4jZEyl+m5EaBHVzTb0aOF+eu/zR7E41/MoCFHPhDNL8Wzq1nyelnXeT+vcL2byFLSZw== @@ -1467,6 +1529,10 @@ babel-plugin-react-intl@^3.0.0: intl-messageformat-parser "^1.2.0" mkdirp "^0.5.1" +babel-plugin-syntax-jsx@^6.18.0: + version "6.18.0" + resolved "http://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + babel-plugin-syntax-object-rest-spread@^6.13.0: version "6.13.0" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" @@ -2282,7 +2348,7 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: +convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1: version "1.6.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== @@ -2358,6 +2424,18 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^9.2.6: + version "9.2.6" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.6.tgz#f64cf1c64cf82fe7d22725d1d77498ddd2d39edb" + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.6.10" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2556,6 +2634,10 @@ csstype@^2.2.0: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.6.tgz#2ae1db2319642d8b80a668d2d025c6196071e788" integrity sha512-tKPyhy0FmfYD2KQYXD5GzkvAYLYj96cMLXr648CKGd3wBe0QqoPipImjGiLze9c8leJK8J3n7ap90tpk3E6HGQ== +csstype@^2.5.2: + version "2.5.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -2989,6 +3071,13 @@ emojis-list@^2.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= +emotion@^9.1.2: + version "9.2.9" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.9.tgz#c2028705acc60a138ecb69d3fc1d2056764f61a1" + dependencies: + babel-plugin-emotion "^9.2.9" + create-emotion "^9.2.6" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -3720,6 +3809,10 @@ find-cache-dir@^2.0.0: make-dir "^1.0.0" pkg-dir "^3.0.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -5909,6 +6002,10 @@ mem@^4.0.0: mimic-fn "^1.0.0" p-is-promise "^1.1.0" +memoize-one@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.2.tgz#3fb8db695aa14ab9c0f1644e1585a8806adc1aee" + memory-fs@^0.4.0, memory-fs@~0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" @@ -6439,6 +6536,12 @@ nopt@^4.0.1: abbrev "1" osenv "^0.1.4" +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + dependencies: + abbrev "1" + normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -7893,6 +7996,12 @@ react-immutable-pure-component@^1.1.1: optionalDependencies: "@types/react" "16.4.6" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-intl-translations-manager@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/react-intl-translations-manager/-/react-intl-translations-manager-5.0.3.tgz#aee010ecf35975673e033ca5d7d3f4147894324d" @@ -8003,6 +8112,18 @@ react-router@^4.3.1: prop-types "^15.6.1" warning "^4.0.1" +react-select@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.0.0.tgz#7e7ba31eff360b37ffc52b343a720f4248bd9b3b" + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^4.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-sparklines@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/react-sparklines/-/react-sparklines-1.7.0.tgz#9b1d97e8c8610095eeb2ad658d2e1fcf91f91a60" @@ -8066,7 +8187,7 @@ react-toggle@^4.0.1: dependencies: classnames "^2.2.5" -react-transition-group@^2.2.0: +react-transition-group@^2.2.0, react-transition-group@^2.2.1: version "2.4.0" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.4.0.tgz#1d9391fabfd82e016f26fabd1eec329dbd922b5a" integrity sha512-Xv5d55NkJUxUzLCImGSanK8Cl/30sgpOEMGc5m86t8+kZwrPxPCPcFqyx83kkr+5Lz5gs6djuvE5By+gce+VjA== @@ -8993,6 +9114,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + spdx-correct@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" @@ -9279,6 +9404,14 @@ style-loader@^0.23.0: loader-utils "^1.1.0" schema-utils "^0.4.5" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + +stylis@^3.5.0: + version "3.5.3" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.3.tgz#99fdc46afba6af4deff570825994181a5e6ce546" + substring-trie@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/substring-trie/-/substring-trie-1.0.2.tgz#7b42592391628b4f2cb17365c6cce4257c7b7af5" @@ -9493,6 +9626,12 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" +touch@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" + dependencies: + nopt "~1.0.10" + tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" |