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 0000000000..f777ca429e
--- /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 0000000000..5321dbb960
--- /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/ui/components/filter_modal.js b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js
new file mode 100644
index 0000000000..d2482e7334
--- /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 8f18d93b73..4df3a0dee7 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/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js
index 14b7040273..cc1d3349c5 100644
--- a/app/javascript/flavours/glitch/reducers/filters.js
+++ b/app/javascript/flavours/glitch/reducers/filters.js
@@ -1,4 +1,5 @@
import { FILTERS_IMPORT } from '../actions/importer';
+import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
import { Map as ImmutableMap, is, fromJS } from 'immutable';
const normalizeFilter = (state, filter) => {
@@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => {
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 {
- return state.set(filter.id, normalizedFilter);
+ // 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)
+ ));
}
};
@@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => {
export default function filters(state = ImmutableMap(), action) {
switch(action.type) {
+ case FILTERS_CREATE_SUCCESS:
+ return normalizeFilter(state, action.filter);
+ case FILTERS_FETCH_SUCCESS:
+ //TODO: handle deleting obsolete filters
case FILTERS_IMPORT:
return normalizeFilters(state, action.filters);
default:
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 7caa5259fb..377805f16c 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 } 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,23 +22,6 @@ 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;
@@ -68,6 +52,7 @@ export const makeGetStatus = () => {
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']));
}
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index 90e0da02a9..e95bea0d7f 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 8c9630eea0..86bb7be362 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 0000000000..97b433a991
--- /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 0000000000..be566032e0
--- /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 = (
+
+);