diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index c94259e7b..c50d59818 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -12,10 +12,18 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; +const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST'; +const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS'; +const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL'; + const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; +const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST'; +const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS'; +const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL'; + const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; @@ -34,7 +42,7 @@ const fetchFiltersV1 = () => skipLoading: true, }); - api(getState) + return api(getState) .get('/api/v1/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, @@ -56,7 +64,7 @@ const fetchFiltersV2 = () => skipLoading: true, }); - api(getState) + return api(getState) .get('/api/v2/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, @@ -84,6 +92,61 @@ const fetchFilters = (fromFiltersPage = false) => if (features.filters) return dispatch(fetchFiltersV1()); }; +const fetchFilterV1 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v1/filters/${id}`) + .then(({ data }) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v2/filters/${id}`) + .then(({ data }) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(fetchFilterV2(id)); + + if (features.filters) return dispatch(fetchFilterV1(id)); + }; + const createFilterV1 = (title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_CREATE_REQUEST }); @@ -129,6 +192,51 @@ const createFilter = (title: string, expires_at: string, context: Array, return dispatch(createFilterV1(title, expires_at, context, hide, keywords)); }; +const updateFilterV1 = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v1/filters/${id}`, { + phrase: keywords[0].keyword, + context, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_at, + }).then(response => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilterV2 = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v2/filters/${id}`, { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_at, + keywords_attributes, + }).then(response => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilter = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_at, context, hide, keywords)); + + return dispatch(updateFilterV1(id, title, expires_at, context, hide, keywords)); + }; + const deleteFilterV1 = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_DELETE_REQUEST }); @@ -166,13 +274,21 @@ export { FILTERS_FETCH_REQUEST, FILTERS_FETCH_SUCCESS, FILTERS_FETCH_FAIL, + FILTER_FETCH_REQUEST, + FILTER_FETCH_SUCCESS, + FILTER_FETCH_FAIL, FILTERS_CREATE_REQUEST, FILTERS_CREATE_SUCCESS, FILTERS_CREATE_FAIL, + FILTERS_UPDATE_REQUEST, + FILTERS_UPDATE_SUCCESS, + FILTERS_UPDATE_FAIL, FILTERS_DELETE_REQUEST, FILTERS_DELETE_SUCCESS, FILTERS_DELETE_FAIL, fetchFilters, + fetchFilter, createFilter, + updateFilter, deleteFilter, }; diff --git a/app/soapbox/features/filters/edit-filter.tsx b/app/soapbox/features/filters/edit-filter.tsx new file mode 100644 index 000000000..24c3bdfe4 --- /dev/null +++ b/app/soapbox/features/filters/edit-filter.tsx @@ -0,0 +1,274 @@ +import React, { useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { createFilter, fetchFilter, updateFilter } from 'soapbox/actions/filters'; +import List, { ListItem } from 'soapbox/components/list'; +import MissingIndicator from 'soapbox/components/missing-indicator'; +import { Button, Column, Form, FormActions, FormGroup, HStack, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; +import { normalizeFilter } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; + +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +interface IFilterField { + keyword: string + whole_word: boolean +} + +interface IEditFilter { + params: { id?: string } +} + +const messages = defineMessages({ + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, + subheading_edit: { id: 'column.filters.subheading_edit', defaultMessage: 'Edit Filter' }, + title: { id: 'column.filters.title', defaultMessage: 'Title' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' }, + 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' }, + accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' }, + 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' }, + hide_header: { id: 'column.filters.hide_header', defaultMessage: 'Hide completely' }, + hide_hint: { id: 'column.filters.hide_hint', defaultMessage: 'Completely hide the filtered content, instead of showing a warning' }, + 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' }, + edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' }, + create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, +}); + +// const expirations = { +// null: 'Never', +// // 1800: '30 minutes', +// // 3600: '1 hour', +// // 21600: '6 hour', +// // 43200: '12 hours', +// // 86400 : '1 day', +// // 604800: '1 week', +// }; + +const FilterField: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: string): React.ChangeEventHandler => + e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); + + return ( + + + + + + + + + + + ); +}; + +const EditFilter: React.FC = ({ params }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const [loading, setLoading] = useState(false); + const [notFound, setNotFound] = useState(false); + + const [title, setTitle] = useState(''); + const [expiresAt] = useState(''); + const [homeTimeline, setHomeTimeline] = useState(true); + const [publicTimeline, setPublicTimeline] = useState(false); + const [notifications, setNotifications] = useState(false); + const [conversations, setConversations] = useState(false); + const [accounts, setAccounts] = useState(false); + const [hide, setHide] = useState(false); + const [keywords, setKeywords] = useState<{ id?: string, keyword: string, whole_word: boolean }[]>([{ keyword: '', whole_word: false }]); + + // const handleSelectChange = e => { + // this.setState({ [e.target.name]: e.target.value }); + // }; + + const handleAddNew: React.FormEventHandler = e => { + e.preventDefault(); + const context: Array = []; + + if (homeTimeline) { + context.push('home'); + } + if (publicTimeline) { + context.push('public'); + } + if (notifications) { + context.push('notifications'); + } + if (conversations) { + context.push('thread'); + } + if (accounts) { + context.push('account'); + } + + dispatch(params.id + ? updateFilter(params.id, title, expiresAt, context, hide, keywords) + : createFilter(title, expiresAt, context, hide, keywords)).then(() => { + history.push('/filters'); + }).catch(() => { + toast.error(intl.formatMessage(messages.create_error)); + }); + }; + + const handleChangeKeyword = (keywords: { keyword: string, whole_word: boolean }[]) => setKeywords(keywords); + + const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]); + + const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords.filter((_, index) => index !== i)); + + useEffect(() => { + if (params.id) { + setLoading(true); + dispatch(fetchFilter(params.id))?.then((res: any) => { + if (res.filter) { + const filter = normalizeFilter(res.filter); + + setTitle(filter.title); + setHomeTimeline(filter.context.includes('home')); + setPublicTimeline(filter.context.includes('public')); + setNotifications(filter.context.includes('notifications')); + setConversations(filter.context.includes('thread')); + setAccounts(filter.context.includes('account')); + setHide(filter.filter_action === 'hide'); + setKeywords(filter.keywords.toJS()); + } else { + setNotFound(true); + } + setLoading(false); + }); + } + }, [params.id]); + + if (notFound) return ; + + return ( + +
+ + setTitle(target.value)} + /> + + {/* + + */} + + + + + + + + + + + + + setHomeTimeline(target.checked)} + /> + + + setPublicTimeline(target.checked)} + /> + + + setNotifications(target.checked)} + /> + + + setConversations(target.checked)} + /> + + {features.filtersV2 && ( + + setAccounts(target.checked)} + /> + + )} + + + + + setHide(target.checked)} + /> + + + + + + + + + +
+ ); +}; + +export default EditFilter; diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index c9b503940..d1cbe1f3b 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -1,20 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; -import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; -import List, { ListItem } from 'soapbox/components/list'; +import { fetchFilters, deleteFilter } from 'soapbox/actions/filters'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { Button, CardTitle, Column, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import toast from 'soapbox/toast'; -import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; - -interface IFilterField { - keyword: string - whole_word: boolean -} - const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, @@ -59,83 +52,14 @@ const contexts = { // // 604800: '1 week', // }; -const FilterField: StreamfieldComponent = ({ value, onChange }) => { - const intl = useIntl(); - - const handleChange = (key: string): React.ChangeEventHandler => - e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); - - return ( - - - - - - - - - - - ); -}; - const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const features = useFeatures(); + const history = useHistory(); const filters = useAppSelector((state) => state.filters); - const [title, setTitle] = useState(''); - const [expiresAt] = useState(''); - const [homeTimeline, setHomeTimeline] = useState(true); - const [publicTimeline, setPublicTimeline] = useState(false); - const [notifications, setNotifications] = useState(false); - const [conversations, setConversations] = useState(false); - const [accounts, setAccounts] = useState(false); - const [hide, setHide] = useState(false); - const [keywords, setKeywords] = useState<{ keyword: string, whole_word: boolean }[]>([{ keyword: '', whole_word: false }]); - - // const handleSelectChange = e => { - // this.setState({ [e.target.name]: e.target.value }); - // }; - - const handleAddNew: React.FormEventHandler = e => { - e.preventDefault(); - const context: Array = []; - - if (homeTimeline) { - context.push('home'); - } - if (publicTimeline) { - context.push('public'); - } - if (notifications) { - context.push('notifications'); - } - if (conversations) { - context.push('thread'); - } - if (accounts) { - context.push('account'); - } - - dispatch(createFilter(title, expiresAt, context, hide, keywords)).then(() => { - return dispatch(fetchFilters(true)); - }).catch(error => { - toast.error(intl.formatMessage(messages.create_error)); - }); - }; + const handleFilterEdit = (id: string) => () => history.push(`/filters/${id}`); const handleFilterDelete = (id: string) => () => { dispatch(deleteFilter(id)).then(() => { @@ -145,12 +69,6 @@ const Filters = () => { }); }; - const handleChangeKeyword = (keywords: { keyword: string, whole_word: boolean }[]) => setKeywords(keywords); - - const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]); - - const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords.filter((_, index) => index !== i)); - useEffect(() => { dispatch(fetchFilters(true)); }, []); @@ -159,118 +77,16 @@ const Filters = () => { return ( - - - -
- - setTitle(target.value)} - /> - - {/* - - */} - - - - - - - - - - - - - setHomeTimeline(target.checked)} - /> - - - setPublicTimeline(target.checked)} - /> - - - setNotifications(target.checked)} - /> - - - setConversations(target.checked)} - /> - - {features.filtersV2 && ( - - setAccounts(target.checked)} - /> - - )} - - - - - setHide(target.checked)} - /> - - {/* - setWholeWord(target.checked)} - /> - */} - - - - - - - - - - + - + + { itemClassName='pb-4 last:pb-0' > {filters.map((filter, i) => ( - - + + {' '} @@ -303,6 +119,12 @@ const Filters = () => { )} */} + = ({ children }) => {features.federating && } + {(features.filters || features.filtersV2) && } + {(features.filters || features.filtersV2) && } {(features.filters || features.filtersV2) && } diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 6e18f5771..9a80d8ebb 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -102,6 +102,10 @@ export function Filters() { return import(/* webpackChunkName: "features/filters" */'../../filters'); } +export function EditFilter() { + return import(/* webpackChunkName: "features/filters" */'../../filters/edit-filter'); +} + export function ReportModal() { return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal'); } diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 46ac310ea..c818ccaab 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -452,7 +452,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can edit and manage timeline filters (aka "muted words"). - * @see {@link https://docs.joinmastodon.org/methods/accounts/filters/} + * @see {@link https://docs.joinmastodon.org/methods/filters/} */ filtersV2: v.software === MASTODON && gte(v.compatVersion, '3.6.0'), @@ -788,7 +788,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can display suggested accounts. - * @see {@link https://docs.joinmastodon.org/methods/accounts/suggestions/} + * @see {@link https://docs.joinmastodon.org/methods/suggestions/} */ suggestions: any([ v.software === MASTODON && gte(v.compatVersion, '2.4.3'),