From bdd9204b3c6ba6f67a6196b4d606c99191d572aa Mon Sep 17 00:00:00 2001 From: Mary Kate Date: Tue, 4 Aug 2020 15:21:42 -0500 Subject: [PATCH 01/34] basic filter form component set up --- app/soapbox/components/sidebar_menu.js | 4 +- .../features/compose/components/action_bar.js | 2 +- app/soapbox/features/filters/index.js | 174 +++++++++++++++++- 3 files changed, 174 insertions(+), 6 deletions(-) diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index f33802371..12ae23f57 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 411a30f17..fa763d806 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..0d603347a 100644 --- a/app/soapbox/features/filters/index.js +++ b/app/soapbox/features/filters/index.js @@ -4,16 +4,49 @@ 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 } from '../../actions/filters'; +import ScrollableList from '../../components/scrollable_list'; +import Button from 'soapbox/components/button'; +import { + SimpleForm, + SimpleInput, + FieldsGroup, + TextInput, + SelectDropdown, + Checkbox, +} from 'soapbox/features/forms'; +import { showAlert } from 'soapbox/actions/alerts'; const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, + 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 Muted Word' }, + error: { id: 'column.filters.error', defaultMessage: 'Error adding filter' }, }); +const expirations = { + 1800: '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 +57,152 @@ class Filters extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; + state = { + phrase: '', + expires_at: '', + context: { + home_timeline: false, + 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.state; + const { phrase, context, whole_word, expires_at } = this.state; + dispatch(createFilter(phrase, context, whole_word, expires_at)).then(response => { + dispatch(fetchFilters()); + }).catch(error => { + dispatch(showAlert('', intl.formatMessage(messages.error))); + }); + } + + render() { - const { intl } = this.props; + const { intl, filters } = this.props; const emptyMessage = ; return ( - {emptyMessage} + + +
+

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + +
); } From 3d24cfaf53a64dcd3affb9af5badaff8bb39226b Mon Sep 17 00:00:00 2001 From: crockwave Date: Tue, 4 Aug 2020 18:05:16 -0500 Subject: [PATCH 02/34] Store fieldsLimits in instance/pleroma/metadata of the Redux store. Render fields to maxFields value in features/edit_profile, using maxFields value --- app/soapbox/features/edit_profile/index.js | 17 ++++++++--------- app/soapbox/reducers/instance.js | 1 + 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/soapbox/features/edit_profile/index.js b/app/soapbox/features/edit_profile/index.js index 02972cb6d..fce8e33df 100644 --- a/app/soapbox/features/edit_profile/index.js +++ b/app/soapbox/features/edit_profile/index.js @@ -20,8 +20,6 @@ import { import { patchMe } from 'soapbox/actions/me'; import { unescape } from 'lodash'; -const MAX_FIELDS = 4; // TODO: Make this dynamic by the instance - const messages = defineMessages({ heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' }, metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' }, @@ -32,12 +30,13 @@ const mapStateToProps = state => { const me = state.get('me'); return { account: state.getIn(['accounts', me]), + maxFields: state.getIn(['instance', 'pleroma', 'metadata', 'fieldsLimits', 'maxFields']), }; }; -// Forces fields to be MAX_SIZE, filling empty values -const normalizeFields = fields => ( - ImmutableList(fields).setSize(MAX_FIELDS).map(field => +// Forces fields to be maxFields size, filling empty values +const normalizeFields = (fields, maxFields) => ( + ImmutableList(fields).setSize(maxFields).map(field => field ? field : ImmutableMap({ name: '', value: '' }) ) ); @@ -57,11 +56,11 @@ class EditProfile extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, account: ImmutablePropTypes.map, + maxFields: PropTypes.number, }; state = { isLoading: false, - fields: normalizeFields(Array.from({ length: MAX_FIELDS })), } constructor(props) { @@ -69,7 +68,7 @@ class EditProfile extends ImmutablePureComponent { const initialState = props.account.withMutations(map => { map.merge(map.get('source')); map.delete('source'); - map.set('fields', normalizeFields(map.get('fields'))); + map.set('fields', normalizeFields(map.get('fields'), props.maxFields)); unescapeParams(map, ['display_name', 'note']); }); this.state = initialState.toObject(); @@ -156,7 +155,7 @@ class EditProfile extends ImmutablePureComponent { } render() { - const { intl } = this.props; + const { intl, maxFields } = this.props; return ( @@ -214,7 +213,7 @@ class EditProfile extends ImmutablePureComponent {
- + { this.state.fields.map((field, i) => ( diff --git a/app/soapbox/reducers/instance.js b/app/soapbox/reducers/instance.js index 980951031..549f95207 100644 --- a/app/soapbox/reducers/instance.js +++ b/app/soapbox/reducers/instance.js @@ -12,6 +12,7 @@ const nodeinfoToInstance = nodeinfo => { account_activation_required: nodeinfo.getIn(['metadata', 'accountActivationRequired']), features: nodeinfo.getIn(['metadata', 'features']), federation: nodeinfo.getIn(['metadata', 'federation']), + fieldsLimits: nodeinfo.getIn(['metadata', 'fieldsLimits']), }), }), }); From 69184ef97c9664fb979eba759ad786706d57f42b Mon Sep 17 00:00:00 2001 From: Mary Kate Date: Tue, 4 Aug 2020 21:15:16 -0500 Subject: [PATCH 03/34] add and remove muted words, filters --- app/soapbox/actions/filters.js | 29 +++++- app/soapbox/features/filters/index.js | 130 ++++++++++++++++++-------- app/soapbox/features/forms/index.js | 9 +- app/styles/application.scss | 1 + app/styles/components/filters.scss | 72 ++++++++++++++ 5 files changed, 199 insertions(+), 42 deletions(-) create mode 100644 app/styles/components/filters.scss 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/features/filters/index.js b/app/soapbox/features/filters/index.js index 0d603347a..c1a2a66f9 100644 --- a/app/soapbox/features/filters/index.js +++ b/app/soapbox/features/filters/index.js @@ -4,23 +4,26 @@ 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, createFilter } 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, - TextInput, 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' }, @@ -29,17 +32,20 @@ const messages = defineMessages({ 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 Muted Word' }, - error: { id: 'column.filters.error', defaultMessage: 'Error adding filter' }, + 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 = { - 1800: 'Never', - 3600: '30 minutes', - 21600: '1 hour', - 43200: '12 hours', - 86400 : '1 day', - 604800: '1 week', + null: 'Never', + // 3600: '30 minutes', + // 21600: '1 hour', + // 43200: '12 hours', + // 86400 : '1 day', + // 604800: '1 week', }; const mapStateToProps = state => ({ @@ -60,12 +66,10 @@ class Filters extends ImmutablePureComponent { state = { phrase: '', expires_at: '', - context: { - home_timeline: false, - public_timeline: false, - notifications: false, - conversations: false, - }, + home_timeline: true, + public_timeline: false, + notifications: false, + conversations: false, irreversible: false, whole_word: true, } @@ -89,12 +93,37 @@ class Filters extends ImmutablePureComponent { handleAddNew = e => { e.preventDefault(); - const { intl, dispatch } = this.state; - const { phrase, context, whole_word, expires_at } = this.state; - dispatch(createFilter(phrase, context, whole_word, expires_at)).then(response => { - dispatch(fetchFilters()); + 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.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))); }); } @@ -104,14 +133,10 @@ class Filters extends ImmutablePureComponent { const emptyMessage = ; return ( - - + +
-

- -

-
@@ -119,12 +144,13 @@ class Filters extends ImmutablePureComponent { label={intl.formatMessage(messages.keyword)} required type='text' - name='custom_filter_phrase' + name='phrase' onChange={this.handleInputChange} /> @@ -183,16 +209,46 @@ class Filters extends ImmutablePureComponent {
-
+
+ + ); + } + +} diff --git a/app/soapbox/features/public_layout/components/header.js b/app/soapbox/features/public_layout/components/header.js index 9f018cc3c..70c20dcaa 100644 --- a/app/soapbox/features/public_layout/components/header.js +++ b/app/soapbox/features/public_layout/components/header.js @@ -6,25 +6,85 @@ import { Link } from 'react-router-dom'; import LoginForm from 'soapbox/features/auth_login/components/login_form'; import SiteLogo from './site_logo'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; +import { logIn } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; +import PropTypes from 'prop-types'; +import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form'; +import IconButton from 'soapbox/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); const mapStateToProps = state => ({ me: state.get('me'), instance: state.get('instance'), + isLoading: false, }); export default @connect(mapStateToProps) +@injectIntl class Header extends ImmutablePureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + static contextTypes = { + router: PropTypes.object, + }; + + handleSubmit = (event) => { + const { dispatch } = this.props; + const { username, password } = this.getFormData(event.target); + dispatch(logIn(username, password)).then(() => { + return dispatch(fetchMe()); + }).catch(error => { + if (error.response.data.error === 'mfa_required') { + this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); + } + this.setState({ isLoading: false }); + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + + onClickClose = (event) => { + this.setState({ mfa_auth_needed: false, mfa_token: '' }); + } + static propTypes = { me: SoapboxPropTypes.me, instance: ImmutablePropTypes.map, } + state = { + mfa_auth_needed: false, + mfa_token: '', + } + render() { - const { me, instance } = this.props; + const { me, instance, isLoading, intl } = this.props; + const { mfa_auth_needed, mfa_token } = this.state; return (