diff --git a/app/soapbox/actions/filters.js b/app/soapbox/actions/filters.js index cff647de3..3448e391c 100644 --- a/app/soapbox/actions/filters.js +++ b/app/soapbox/actions/filters.js @@ -1,4 +1,5 @@ import api from '../api'; +import { showAlert } from 'soapbox/actions/alerts'; export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; @@ -8,6 +9,10 @@ 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 FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; +export const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; +export const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; + export const fetchFilters = () => (dispatch, getState) => { if (!getState().get('me')) return; @@ -31,13 +36,33 @@ export const fetchFilters = () => (dispatch, getState) => { })); }; -export function createFilter(params) { +export function createFilter(phrase, expires_at, context, whole_word, irreversible) { return (dispatch, getState) => { dispatch({ type: FILTERS_CREATE_REQUEST }); - return api(getState).post('/api/v1/filters', params).then(response => { + return api(getState).post('/api/v1/filters', { + phrase, + context, + irreversible, + whole_word, + expires_at, + }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); + dispatch(showAlert('', 'Filter added')); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); }; } + + +export function deleteFilter(id) { + return (dispatch, getState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete('/api/v1/filters/'+id).then(response => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); + dispatch(showAlert('', 'Filter deleted')); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; +} diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index e1007f0d9..a82dfe519 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -168,10 +168,10 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.mutes)} - {/* + {intl.formatMessage(messages.filters)} - */} + { isStaff && {intl.formatMessage(messages.admin_settings)} diff --git a/app/soapbox/features/compose/components/action_bar.js b/app/soapbox/features/compose/components/action_bar.js index 814123da6..d24cf8032 100644 --- a/app/soapbox/features/compose/components/action_bar.js +++ b/app/soapbox/features/compose/components/action_bar.js @@ -76,7 +76,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); - // menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' }); + menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick }); if (isStaff) { diff --git a/app/soapbox/features/filters/index.js b/app/soapbox/features/filters/index.js index 7ae71daac..60b406a82 100644 --- a/app/soapbox/features/filters/index.js +++ b/app/soapbox/features/filters/index.js @@ -4,16 +4,55 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import Column from '../ui/components/column'; -import { fetchFilters } from '../../actions/filters'; +import { fetchFilters, createFilter, deleteFilter } from '../../actions/filters'; +import ScrollableList from '../../components/scrollable_list'; +import Button from 'soapbox/components/button'; +import { + SimpleForm, + SimpleInput, + FieldsGroup, + SelectDropdown, + Checkbox, +} from 'soapbox/features/forms'; +import { showAlert } from 'soapbox/actions/alerts'; +import Icon from 'soapbox/components/icon'; +import ColumnSubheading from '../ui/components/column_subheading'; const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, + expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' }, + home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, + public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, + notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, + conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, + drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, + drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, + whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' }, + whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' }, + add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, + create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, + subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, + delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); +const expirations = { + null: 'Never', + // 3600: '30 minutes', + // 21600: '1 hour', + // 43200: '12 hours', + // 86400 : '1 day', + // 604800: '1 week', +}; + const mapStateToProps = state => ({ filters: state.get('filters'), }); + export default @connect(mapStateToProps) @injectIntl class Filters extends ImmutablePureComponent { @@ -24,17 +63,206 @@ class Filters extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; + state = { + phrase: '', + expires_at: '', + home_timeline: true, + public_timeline: false, + notifications: false, + conversations: false, + irreversible: false, + whole_word: true, + } + + componentDidMount() { this.props.dispatch(fetchFilters()); } + handleInputChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + handleSelectChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + handleCheckboxChange = e => { + this.setState({ [e.target.name]: e.target.checked }); + } + + handleAddNew = e => { + e.preventDefault(); + const { intl, dispatch } = this.props; + const { phrase, whole_word, expires_at, irreversible } = this.state; + const { home_timeline, public_timeline, notifications, conversations } = this.state; + let context = []; + + if (home_timeline) { + context.push('home'); + }; + if (public_timeline) { + context.push('public'); + }; + if (notifications) { + context.push('notifications'); + }; + if (conversations) { + context.push('thread'); + }; + + dispatch(createFilter(phrase, expires_at, context, whole_word, irreversible)).then(response => { + return dispatch(fetchFilters()); + }).catch(error => { + dispatch(showAlert('', intl.formatMessage(messages.create_error))); + }); + } + + handleFilterDelete = e => { + const { intl, dispatch } = this.props; + dispatch(deleteFilter(e.currentTarget.dataset.value)).then(response => { + return dispatch(fetchFilters()); + }).catch(error => { + dispatch(showAlert('', intl.formatMessage(messages.delete_error))); + }); + } + + render() { - const { intl } = this.props; + const { intl, filters } = this.props; const emptyMessage = ; return ( - - {emptyMessage} + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + +
+ + + + +
+ +
+ + + + + +
+ +
+
+ + + +
); } diff --git a/app/soapbox/features/forms/index.js b/app/soapbox/features/forms/index.js index 471a173f8..e4f7a955a 100644 --- a/app/soapbox/features/forms/index.js +++ b/app/soapbox/features/forms/index.js @@ -36,7 +36,7 @@ InputContainer.propTypes = { extraClass: PropTypes.string, }; -export const LabelInputContainer = ({ label, children, ...props }) => { +export const LabelInputContainer = ({ label, hint, children, ...props }) => { const [id] = useState(uuidv4()); const childrenWithProps = React.Children.map(children, child => ( React.cloneElement(child, { id: id, key: id }) @@ -48,12 +48,14 @@ export const LabelInputContainer = ({ label, children, ...props }) => {
{childrenWithProps}
+ {hint && {hint}} ); }; LabelInputContainer.propTypes = { label: FormPropTypes.label.isRequired, + hint: PropTypes.node, children: PropTypes.node, }; @@ -223,11 +225,12 @@ export class SelectDropdown extends ImmutablePureComponent { static propTypes = { label: FormPropTypes.label, + hint: PropTypes.node, items: PropTypes.object.isRequired, } render() { - const { label, items, ...props } = this.props; + const { label, hint, items, ...props } = this.props; const optionElems = Object.keys(items).map(item => ( @@ -236,7 +239,7 @@ export class SelectDropdown extends ImmutablePureComponent { const selectElem = ; return label ? ( - {selectElem} + {selectElem} ) : selectElem; } diff --git a/app/styles/application.scss b/app/styles/application.scss index dee4df0ba..c42cff3e4 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -72,4 +72,5 @@ @import 'components/video-player'; @import 'components/audio-player'; @import 'components/profile_hover_card'; +@import 'components/filters'; @import 'components/mfa_form'; diff --git a/app/styles/components/filters.scss b/app/styles/components/filters.scss new file mode 100644 index 000000000..de1e6ee96 --- /dev/null +++ b/app/styles/components/filters.scss @@ -0,0 +1,99 @@ +.filter-settings-panel { + h1 { + font-size: 18px; + line-height: 1.25; + color: var(--primary-text-color); + font-weight: 400; + margin: 20px auto; + } + + .item-list article { + border-bottom: 1px solid var(--primary-text-color--faint); + + &:last-child { + border-bottom: 0; + } + } + + .fields-group .two-col { + display: flex; + align-items: flex-start; + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + + div.input { + width: 45%; + margin-right: 20px; + + .label_input { + width: 100%; + } + } + + @media(max-width: 485px){ + div.input { + width: 100%; + margin-right: 5px; + + .label_input { + width: auto; + } + } + } + } + + .filter__container { + padding: 20px; + display: flex; + justify-content: space-between; + font-size: 14px; + + .filter__phrase, .filter__contexts, .filter__details { + padding: 5px 0; + } + + span.filter__list-label { + padding-right: 5px; + color: var(--primary-text-color--faint); + } + + span.filter__list-value span { + padding-right: 5px; + text-transform: capitalize; + + &::after { + content: ','; + } + + &:last-of-type { + &::after { + content: ''; + } + } + } + + .filter__delete { + display: flex; + margin: 10px; + align-items: baseline; + cursor: pointer; + height: 20px; + + span.filter__delete-label { + color: var(--primary-text-color--faint); + font-size: 14px; + font-weight: 800; + } + + .filter__delete-icon { + background: none; + color: var(--primary-text-color--faint); + padding: 0 5px; + margin: 0 auto; + font-size: 16px; + } + } + + } +}