diff --git a/app/javascript/flavours/glitch/actions/filters.js b/app/javascript/flavours/glitch/actions/filters.js index 050b30322..9aa31028a 100644 --- a/app/javascript/flavours/glitch/actions/filters.js +++ b/app/javascript/flavours/glitch/actions/filters.js @@ -1,9 +1,24 @@ import api from 'flavours/glitch/util/api'; +import { openModal } from './modal'; export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; +export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST'; +export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS'; +export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL'; + +export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; +export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; +export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; + +export const initAddFilter = (status, { contextType }) => dispatch => + dispatch(openModal('FILTER', { + statusId: status?.get('id'), + contextType: contextType, + })); + export const fetchFilters = () => (dispatch, getState) => { dispatch({ type: FILTERS_FETCH_REQUEST, @@ -11,7 +26,7 @@ export const fetchFilters = () => (dispatch, getState) => { }); api(getState) - .get('/api/v1/filters') + .get('/api/v2/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, filters: data, @@ -24,3 +39,55 @@ export const fetchFilters = () => (dispatch, getState) => { skipAlert: true, })); }; + +export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterStatusRequest()); + + api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => { + dispatch(createFilterStatusSuccess(response.data)); + if (onSuccess) onSuccess(); + }).catch(error => { + dispatch(createFilterStatusFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterStatusRequest = () => ({ + type: FILTERS_STATUS_CREATE_REQUEST, +}); + +export const createFilterStatusSuccess = filter_status => ({ + type: FILTERS_STATUS_CREATE_SUCCESS, + filter_status, +}); + +export const createFilterStatusFail = error => ({ + type: FILTERS_STATUS_CREATE_FAIL, + error, +}); + +export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => { + dispatch(createFilterRequest()); + + api(getState).post('/api/v2/filters', params).then(response => { + dispatch(createFilterSuccess(response.data)); + if (onSuccess) onSuccess(response.data); + }).catch(error => { + dispatch(createFilterFail(error)); + if (onFail) onFail(); + }); +}; + +export const createFilterRequest = () => ({ + type: FILTERS_CREATE_REQUEST, +}); + +export const createFilterSuccess = filter => ({ + type: FILTERS_CREATE_SUCCESS, + filter, +}); + +export const createFilterFail = error => ({ + type: FILTERS_CREATE_FAIL, + error, +}); diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index ec41fea6e..94d133b5f 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; +export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -28,6 +29,10 @@ export function importStatuses(statuses) { return { type: STATUSES_IMPORT, statuses }; } +export function importFilters(filters) { + return { type: FILTERS_IMPORT, filters }; +} + export function importPolls(polls) { return { type: POLLS_IMPORT, polls }; } @@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) { const accounts = []; const normalStatuses = []; const polls = []; + const filters = []; function processStatus(status) { pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings'))); pushUnique(accounts, status.account); + if (status.filtered) { + status.filtered.forEach(result => pushUnique(filters, result.filter)); + } + if (status.reblog && status.reblog.id) { processStatus(status.reblog); } @@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) { dispatch(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); + dispatch(importFilters(filters)); }; } diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index c6acdbdbb..9950a720b 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -42,6 +42,14 @@ export function normalizeAccount(account) { return account; } +export function normalizeFilterResult(result) { + const normalResult = { ...result }; + + normalResult.filter = normalResult.filter.id; + + return normalResult; +} + export function normalizeStatus(status, normalOldStatus, settings) { const normalStatus = { ...status }; normalStatus.account = status.account.id; @@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus, settings) { normalStatus.poll = status.poll.id; } + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + // Only calculate these values when status first encountered and // when the underlying values change. Otherwise keep the ones // already in the reducer diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 63c1fe038..ae3ee1a4c 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -12,10 +12,8 @@ import { saveSettings } from './settings'; import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from 'flavours/glitch/util/html'; -import { getFiltersRegex } from 'flavours/glitch/selectors'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; import compareId from 'flavours/glitch/util/compare_id'; -import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -74,20 +72,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFiltersRegex(getState(), { contextType: 'notifications' }); let filtered = false; - if (['mention', 'status'].includes(notification.type)) { - const dropRegex = filters[0]; - const regex = filters[1]; - const searchIndex = searchTextFromRawStatus(notification.status); + if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { + const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); - if (dropRegex && dropRegex.test(searchIndex)) { + if (filters.some(result => result.filter.filter_action === 'hide')) { return; } - filtered = regex && regex.test(searchIndex); + filtered = filters.length > 0; } if (['follow_request'].includes(notification.type)) { diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 1f223f22e..58c1d44a6 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) { }; }; -export function fetchStatus(id) { +export function fetchStatus(id, forceFetch = false) { return (dispatch, getState) => { - const skipLoading = getState().getIn(['statuses', id], null) !== null; + const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null; dispatch(fetchContext(id)); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 90d6a0231..375728cb5 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -21,7 +21,6 @@ import { updateReaction as updateAnnouncementsReaction, deleteAnnouncement, } from './announcements'; -import { fetchFilters } from './filters'; import { getLocale } from 'mastodon/locales'; const { messages } = getLocale(); @@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'conversation': dispatch(updateConversations(JSON.parse(data.payload))); break; - case 'filters_changed': - dispatch(fetchFilters()); - break; case 'announcement': dispatch(updateAnnouncements(JSON.parse(data.payload))); break; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 0b36d8ac3..0d6f844b3 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -4,8 +4,7 @@ import api, { getLinks } from 'flavours/glitch/util/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'flavours/glitch/util/compare_id'; import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; -import { getFiltersRegex } from 'flavours/glitch/selectors'; -import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; +import { toServerSideType } from 'flavours/glitch/util/filters'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -40,14 +39,13 @@ export function updateTimeline(timeline, status, accept) { return; } - const filters = getFiltersRegex(getState(), { contextType: timeline }); - const dropRegex = filters[0]; - const regex = filters[1]; - const text = searchTextFromRawStatus(status); - let filtered = false; + let filtered = false; - if (status.account.id !== me) { - filtered = (dropRegex && dropRegex.test(text)) || (regex && regex.test(text)); + if (status.filtered) { + const contextType = toServerSideType(timeline); + const filters = status.filtered.filter(result => result.filter.context.includes(contextType)); + + filtered = filters.length > 0; } dispatch(importFetchedStatus(status)); diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index b2d9acebc..e238456c5 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -79,6 +79,7 @@ class Status extends ImmutablePureComponent { onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, + onAddFilter: PropTypes.func, onEmbed: PropTypes.func, onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, @@ -455,8 +456,8 @@ class Status extends ImmutablePureComponent { } handleUnfilterClick = e => { - const { onUnfilter, status } = this.props; - onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false })); + this.setState({ forceFilter: false }); + e.preventDefault(); } handleFilterClick = () => { @@ -557,8 +558,8 @@ class Status extends ImmutablePureComponent { ); } - const filtered = (status.get('filtered') || status.getIn(['reblog', 'filtered'])) && settings.get('filtering_behavior') !== 'content_warning'; - if (forceFilter === undefined ? filtered : forceFilter) { + const matchedFilters = status.get('matched_filters'); + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -567,13 +568,11 @@ class Status extends ImmutablePureComponent { return (
- - {settings.get('filtering_behavior') !== 'upstream' && ' '} - {settings.get('filtering_behavior') !== 'upstream' && ( - - )} + : {matchedFilters.join(', ')}. + {' '} +
); @@ -789,11 +788,11 @@ class Status extends ImmutablePureComponent { {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( ) : null} {notification ? ( diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 667afac5a..c0cd496ce 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -41,6 +41,7 @@ const messages = defineMessages({ copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, + filter: { id: 'status.filter', defaultMessage: 'Filter this post' }, }); export default @injectIntl @@ -67,6 +68,7 @@ class StatusActionBar extends ImmutablePureComponent { onPin: PropTypes.func, onBookmark: PropTypes.func, onFilter: PropTypes.func, + onAddFilter: PropTypes.func, withDismiss: PropTypes.bool, showReplyCount: PropTypes.bool, scrollKey: PropTypes.string, @@ -193,10 +195,14 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleFilterClick = () => { + handleHideClick = () => { this.props.onFilter(); } + handleFilterClick = () => { + this.props.onAddFilter(this.props.status); + } + render () { const { status, intl, withDismiss, showReplyCount, scrollKey } = this.props; @@ -238,6 +244,12 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); + + if (!this.props.onFilter) { + menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick }); + menu.push(null); + } + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); @@ -271,10 +283,6 @@ class StatusActionBar extends ImmutablePureComponent { ); - const filterButton = status.get('filtered') && ( - - ); - let replyButton = ( + ); + return (
{replyButton} @@ -316,6 +328,7 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} + {filterButton}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 6c8f261e4..0ba2e712c 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import Status from 'flavours/glitch/components/status'; import { List as ImmutableList } from 'immutable'; -import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors'; +import { makeGetStatus } from 'flavours/glitch/selectors'; import { replyCompose, mentionCompose, @@ -25,6 +25,9 @@ import { revealStatus, editStatus } from 'flavours/glitch/actions/statuses'; +import { + initAddFilter, +} from 'flavours/glitch/actions/filters'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; @@ -201,52 +204,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ dispatch(initBlockModal(account)); }, - onUnfilter (status, onConfirm) { - dispatch((_, getState) => { - let state = getState(); - const serverSideType = toServerSideType(contextType); - const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); - const searchIndex = status.get('search_index'); - const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex)); - dispatch(openModal('CONFIRM', { - message: [ - , -
- - - - -
    - {matchingFilters.map(filter => ( -
  • - {filter.get('phrase')} - {!!filterEditLink && ' '} - {!!filterEditLink && ( - - - - )} -
  • - ))} -
-
-
- ], - confirm: intl.formatMessage(messages.unfilterConfirm), - onConfirm: onConfirm, - })); - }); - }, - onReport (status) { dispatch(initReport(status.get('account'), status)); }, + onAddFilter (status) { + dispatch(initAddFilter(status, { contextType })); + }, + onMute (account) { dispatch(initMuteModal(account)); }, diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js index c8c503e58..035b0c0c3 100644 --- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js @@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring'; import { supportsPassiveEvents } from 'detect-passive-events'; import classNames from 'classnames'; import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state'; +import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons'; import fuzzysort from 'fuzzysort'; const messages = defineMessages({ @@ -16,22 +17,6 @@ const messages = defineMessages({ clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, }); -// Copied from emoji-mart for consistency with emoji picker and since -// they don't export the icons in the package -const icons = { - loupe: ( - - - - ), - - delete: ( - - - - ), -}; - const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class LanguageDropdownMenu extends React.PureComponent { @@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
- +
diff --git a/app/javascript/flavours/glitch/features/filters/added_to_filter.js b/app/javascript/flavours/glitch/features/filters/added_to_filter.js new file mode 100644 index 000000000..f777ca429 --- /dev/null +++ b/app/javascript/flavours/glitch/features/filters/added_to_filter.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import { toServerSideType } from 'flavours/glitch/util/filters'; +import Button from 'flavours/glitch/components/button'; +import { connect } from 'react-redux'; + +const mapStateToProps = (state, { filterId }) => ({ + filter: state.getIn(['filters', filterId]), +}); + +export default @connect(mapStateToProps) +class AddedToFilter extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + contextType: PropTypes.string, + filter: ImmutablePropTypes.map.isRequired, + dispatch: PropTypes.func.isRequired, + }; + + handleCloseClick = () => { + const { onClose } = this.props; + onClose(); + }; + + render () { + const { filter, contextType } = this.props; + + let expiredMessage = null; + if (filter.get('expires_at') && filter.get('expires_at') < new Date()) { + expiredMessage = ( + +

+

+ +

+
+ ); + } + + let contextMismatchMessage = null; + if (contextType && !filter.get('context').includes(toServerSideType(contextType))) { + contextMismatchMessage = ( + +

+

+ +

+
+ ); + } + + const settings_link = ( + + + + ); + + return ( + +

+

+ +

+ + {expiredMessage} + {contextMismatchMessage} + +

+

+ +

+ +
+ +
+ +
+ + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/filters/select_filter.js b/app/javascript/flavours/glitch/features/filters/select_filter.js new file mode 100644 index 000000000..5321dbb96 --- /dev/null +++ b/app/javascript/flavours/glitch/features/filters/select_filter.js @@ -0,0 +1,192 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { toServerSideType } from 'flavours/glitch/util/filters'; +import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons'; +import Icon from 'flavours/glitch/components/icon'; +import fuzzysort from 'fuzzysort'; + +const messages = defineMessages({ + search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' }, + clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, +}); + +const mapStateToProps = (state, { contextType }) => ({ + filters: Array.from(state.get('filters').values()).map((filter) => [ + filter.get('id'), + filter.get('title'), + filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'), + filter.get('expires_at') && filter.get('expires_at') < new Date(), + contextType && !filter.get('context').includes(toServerSideType(contextType)), + ]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class SelectFilter extends React.PureComponent { + + static propTypes = { + onSelectFilter: PropTypes.func.isRequired, + onNewFilter: PropTypes.func.isRequired, + filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)), + intl: PropTypes.object.isRequired, + }; + + state = { + searchValue: '', + }; + + search () { + const { filters } = this.props; + const { searchValue } = this.state; + + if (searchValue === '') { + return filters; + } + + return fuzzysort.go(searchValue, filters, { + keys: ['1', '2'], + limit: 5, + threshold: -10000, + }).map(result => result.obj); + } + + renderItem = filter => { + let warning = null; + if (filter[3] || filter[4]) { + warning = ( + + ( + {filter[3] && } + {filter[3] && filter[4] && ', '} + {filter[4] && } + ) + + ); + } + + return ( +
+ {filter[1]} {warning} +
+ ); + } + + renderCreateNew (name) { + return ( +
+ +
+ ); + } + + handleSearchChange = ({ target }) => { + this.setState({ searchValue: target.value }); + } + + setListRef = c => { + this.listNode = c; + } + + handleKeyDown = e => { + const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget); + + let element = null; + + switch(e.key) { + case ' ': + case 'Enter': + e.currentTarget.click(); + break; + case 'ArrowDown': + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + break; + case 'ArrowUp': + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; + } else { + element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; + } + break; + case 'Home': + element = this.listNode.firstChild; + break; + case 'End': + element = this.listNode.lastChild; + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleSearchKeyDown = e => { + let element = null; + + switch(e.key) { + case 'Tab': + case 'ArrowDown': + element = this.listNode.firstChild; + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + + break; + } + } + + handleClear = () => { + this.setState({ searchValue: '' }); + } + + handleItemClick = e => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + this.props.onSelectFilter(value); + } + + handleNewFilterClick = e => { + e.preventDefault(); + + this.props.onNewFilter(this.state.searchValue); + }; + + render () { + const { intl } = this.props; + + const { searchValue } = this.state; + const isSearching = searchValue !== ''; + const results = this.search(); + + return ( + +

+

+ +
+ + +
+ +
+ {results.map(this.renderItem)} + {isSearching && this.renderCreateNew(searchValue) } +
+ +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index ffa4e3409..333b73b45 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -24,10 +24,6 @@ const messages = defineMessages({ side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' }, side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' }, regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' }, - filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' }, - filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' }, - filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' }, - filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' }, rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' }, rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' }, rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' }, @@ -358,25 +354,6 @@ class LocalSettingsPage extends React.PureComponent {
), - ({ intl, onChange, settings }) => ( -
-

- - - -
- ), ({ onChange, settings }) => (

diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.js b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js new file mode 100644 index 000000000..d2482e733 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js @@ -0,0 +1,134 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { fetchStatus } from 'flavours/glitch/actions/statuses'; +import { fetchFilters, createFilter, createFilterStatus } from 'flavours/glitch/actions/filters'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import IconButton from 'flavours/glitch/components/icon_button'; +import SelectFilter from 'flavours/glitch/features/filters/select_filter'; +import AddedToFilter from 'flavours/glitch/features/filters/added_to_filter'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +export default @connect(undefined) +@injectIntl +class FilterModal extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + contextType: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + step: 'select', + filterId: null, + isSubmitting: false, + isSubmitted: false, + }; + + handleNewFilterSuccess = (result) => { + this.handleSelectFilter(result.id); + }; + + handleSuccess = () => { + const { dispatch, statusId } = this.props; + dispatch(fetchStatus(statusId, true)); + this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' }); + }; + + handleFail = () => { + this.setState({ isSubmitting: false }); + }; + + handleNextStep = step => { + this.setState({ step }); + }; + + handleSelectFilter = (filterId) => { + const { dispatch, statusId } = this.props; + + this.setState({ isSubmitting: true, filterId }); + + dispatch(createFilterStatus({ + filter_id: filterId, + status_id: statusId, + }, this.handleSuccess, this.handleFail)); + }; + + handleNewFilter = (title) => { + const { dispatch } = this.props; + + this.setState({ isSubmitting: true }); + + dispatch(createFilter({ + title, + context: ['home', 'notifications', 'public', 'thread', 'account'], + action: 'warn', + }, this.handleNewFilterSuccess, this.handleFail)); + }; + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(fetchFilters()); + } + + render () { + const { + intl, + statusId, + contextType, + onClose, + } = this.props; + + const { + step, + filterId, + } = this.state; + + let stepComponent; + + switch(step) { + case 'select': + stepComponent = ( + + ); + break; + case 'create': + stepComponent = null; + break; + case 'submitted': + stepComponent = ( + + ); + } + + return ( +
+
+ + +
+ +
+ {stepComponent} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 8f18d93b7..4df3a0dee 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -26,6 +26,7 @@ import { ListAdder, PinnedAccountsEditor, CompareHistoryModal, + FilterModal, } from 'flavours/glitch/util/async-components'; const MODAL_COMPONENTS = { @@ -49,6 +50,7 @@ const MODAL_COMPONENTS = { 'LIST_ADDER': ListAdder, 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, 'COMPARE_HISTORY': CompareHistoryModal, + 'FILTER': FilterModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 099ffc69c..2be6d9478 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -10,7 +10,6 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; -import { fetchFilters } from 'flavours/glitch/actions/filters'; import { fetchRules } from 'flavours/glitch/actions/rules'; import { clearHeight } from 'flavours/glitch/actions/height_cache'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; @@ -402,7 +401,7 @@ class UI extends React.Component { this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); - setTimeout(() => this.props.dispatch(fetchFilters()), 500); + setTimeout(() => this.props.dispatch(fetchRules()), 3000); } diff --git a/app/javascript/flavours/glitch/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js index 33f0c6732..cc1d3349c 100644 --- a/app/javascript/flavours/glitch/reducers/filters.js +++ b/app/javascript/flavours/glitch/reducers/filters.js @@ -1,10 +1,43 @@ -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; -import { List as ImmutableList, fromJS } from 'immutable'; +import { FILTERS_IMPORT } from '../actions/importer'; +import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters'; +import { Map as ImmutableMap, is, fromJS } from 'immutable'; -export default function filters(state = ImmutableList(), action) { +const normalizeFilter = (state, filter) => { + const normalizedFilter = fromJS({ + id: filter.id, + title: filter.title, + context: filter.context, + filter_action: filter.filter_action, + keywords: filter.keywords, + expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, + }); + + if (is(state.get(filter.id), normalizedFilter)) { + return state; + } else { + // Do not overwrite keywords when receiving a partial filter + return state.update(filter.id, ImmutableMap(), (old) => ( + old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter) + )); + } +}; + +const normalizeFilters = (state, filters) => { + filters.forEach(filter => { + state = normalizeFilter(state, filter); + }); + + return state; +}; + +export default function filters(state = ImmutableMap(), action) { switch(action.type) { + case FILTERS_CREATE_SUCCESS: + return normalizeFilter(state, action.filter); case FILTERS_FETCH_SUCCESS: - return fromJS(action.filters); + //TODO: handle deleting obsolete filters + case FILTERS_IMPORT: + return normalizeFilters(state, action.filters); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 62ce29f0c..81ab1cb0d 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -21,7 +21,6 @@ const initialState = ImmutableMap({ inline_preview_cards: true, hicolor_privacy_icons: false, show_content_type_choice: false, - filtering_behavior: 'hide', tag_misleading_links: true, rewrite_mentions: 'no', content_warnings : ImmutableMap({ diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index d9aa8f140..377805f16 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -1,6 +1,7 @@ import escapeTextContentForBrowser from 'escape-html'; import { createSelector } from 'reselect'; -import { List as ImmutableList, is } from 'immutable'; +import { List as ImmutableList } from 'immutable'; +import { toServerSideType } from 'flavours/glitch/util/filters'; import { me } from 'flavours/glitch/util/initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); @@ -21,69 +22,15 @@ export const makeGetAccount = () => { }); }; -export const toServerSideType = columnType => { - switch (columnType) { - case 'home': - case 'notifications': - case 'public': - case 'thread': - case 'account': - return columnType; - default: - if (columnType.indexOf('list:') > -1) { - return 'home'; - } else { - return 'public'; // community, account, hashtag - } - } +const getFilters = (state, { contextType }) => { + if (!contextType) return null; + + const serverSideType = toServerSideType(contextType); + const now = new Date(); + + return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now)); }; -const escapeRegExp = string => - string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string - -export const regexFromFilters = filters => { - if (filters.size === 0) { - return null; - } - - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); - - if (filter.get('whole_word')) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; - } - - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }).join('|'), 'i'); -}; - -// Memoize the filter regexps for each valid server contextType -const makeGetFiltersRegex = () => { - let memo = {}; - - return (state, { contextType }) => { - if (!contextType) return ImmutableList(); - - const serverSideType = toServerSideType(contextType); - const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - - if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) { - const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); - const regex = regexFromFilters(filters); - memo[serverSideType] = { filters: filters, results: [dropRegex, regex] }; - } - return memo[serverSideType].results; - }; -}; - -export const getFiltersRegex = makeGetFiltersRegex(); - export const makeGetStatus = () => { return createSelector( [ @@ -91,60 +38,37 @@ export const makeGetStatus = () => { (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - (state, _) => state.getIn(['local_settings', 'filtering_behavior']), - (state, _) => state.get('filters', ImmutableList()), - (_, { contextType }) => contextType, - getFiltersRegex, + getFilters, ], - (statusBase, statusReblog, accountBase, accountReblog, filteringBehavior, filters, contextType, filtersRegex) => { + (statusBase, statusReblog, accountBase, accountReblog, filters) => { if (!statusBase) { return null; } - const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0]; - - if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { - return null; - } - - const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1]; let filtered = false; + if ((accountReblog || accountBase).get('id') !== me && filters) { + let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + return null; + } + filterResults = filterResults.filter(result => filters.has(result.get('filter'))); + if (!filterResults.isEmpty()) { + filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + } + } if (statusReblog) { - filtered = regex && regex.test(statusReblog.get('search_index')); statusReblog = statusReblog.set('account', accountReblog); - statusReblog = statusReblog.set('filtered', filtered); + statusReblog = statusReblog.set('matched_filters', filtered); } else { statusReblog = null; } - filtered = filtered || regex && regex.test(statusBase.get('search_index')); - - if (filtered && filteringBehavior === 'drop') { - return null; - } else if (filtered && filteringBehavior === 'content_warning') { - let spoilerText = (statusReblog || statusBase).get('spoiler_text', ''); - const searchIndex = (statusReblog || statusBase).get('search_index'); - const serverSideType = toServerSideType(contextType); - const enabledFilters = filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray(); - const matchingFilters = enabledFilters.filter(filter => { - const regexp = regexFromFilters([filter]); - return regexp.test(searchIndex) && !regexp.test(spoilerText); - }); - if (statusReblog) { - statusReblog = statusReblog.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); - statusReblog = statusReblog.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); - } else { - statusBase = statusBase.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', ')); - statusBase = statusBase.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', ')); - } - } - return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); - map.set('filtered', filtered); + map.set('matched_filters', filtered); }); }, ); diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index 90e0da02a..e95bea0d7 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -583,6 +583,16 @@ line-height: 22px; color: lighten($inverted-text-color, 16%); margin-bottom: 30px; + + a { + text-decoration: none; + color: $inverted-text-color; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } } &__actions { @@ -730,6 +740,14 @@ background: transparent; margin: 15px 0; } + + .emoji-mart-search { + padding-right: 10px; + } + + .emoji-mart-search-icon { + right: 10px + 5px; + } } .report-modal__container { diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js index 8c9630eea..86bb7be36 100644 --- a/app/javascript/flavours/glitch/util/async-components.js +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -177,3 +177,7 @@ export function FollowRecommendations () { export function CompareHistoryModal () { return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal'); } + +export function FilterModal () { + return import(/*webpackChunkName: "flavours/glitch/async/filter_modal" */'flavours/glitch/features/ui/components/filter_modal'); +} diff --git a/app/javascript/flavours/glitch/util/filters.js b/app/javascript/flavours/glitch/util/filters.js new file mode 100644 index 000000000..97b433a99 --- /dev/null +++ b/app/javascript/flavours/glitch/util/filters.js @@ -0,0 +1,16 @@ +export const toServerSideType = columnType => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + case 'account': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; diff --git a/app/javascript/flavours/glitch/util/icons.js b/app/javascript/flavours/glitch/util/icons.js new file mode 100644 index 000000000..be566032e --- /dev/null +++ b/app/javascript/flavours/glitch/util/icons.js @@ -0,0 +1,13 @@ +// Copied from emoji-mart for consistency with emoji picker and since +// they don't export the icons in the package +export const loupeIcon = ( + + + +); + +export const deleteIcon = ( + + + +);