diff --git a/CHANGELOG.md b/CHANGELOG.md index b75cf6d1a..afca6bb71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Posts: Support posts filtering on recent Mastodon versions ### Changed - Posts: truncate Nostr pubkeys in reply mentions. diff --git a/README.md b/README.md index 2504de278..f0a74b88f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread. © Alex Gleason & other Soapbox contributors © Eugen Rochko & other Mastodon contributors © Trump Media & Technology Group -© Gab AI, Inc. +© Gab AI, Inc. Soapbox is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by diff --git a/app/assets/icons/COPYING.md b/app/assets/icons/COPYING.md index 1dcc928d9..a5dbe7d98 100644 --- a/app/assets/icons/COPYING.md +++ b/app/assets/icons/COPYING.md @@ -2,4 +2,4 @@ - verified.svg - Created by Alex Gleason. CC0 -Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg +Fediverse logo: https://en.wikipedia.org/wiki/Fediverse#/media/File:Fediverse_logo_proposal.svg diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 7e663f88d..ee3508682 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'; @@ -25,22 +33,16 @@ const messages = defineMessages({ removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, }); -const fetchFilters = () => +type FilterKeywords = { keyword: string, whole_word: boolean }[]; + +const fetchFiltersV1 = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const state = getState(); - const instance = state.instance; - const features = getFeatures(instance); - - if (!features.filters) return; - dispatch({ type: FILTERS_FETCH_REQUEST, skipLoading: true, }); - api(getState) + return api(getState) .get('/api/v1/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, @@ -55,15 +57,105 @@ const fetchFilters = () => })); }; -const createFilter = (phrase: string, expires_at: string, context: Array, whole_word: boolean, irreversible: boolean) => +const fetchFiltersV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get('/api/v2/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilters = (fromFiltersPage = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2()); + + 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_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_CREATE_REQUEST }); return api(getState).post('/api/v1/filters', { - phrase, + phrase: keywords[0].keyword, context, - irreversible, - whole_word, - expires_at, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_in, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); toast.success(messages.added); @@ -72,7 +164,80 @@ const createFilter = (phrase: string, expires_at: string, context: Array }); }; -const deleteFilter = (id: string) => +const createFilterV2 = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_CREATE_REQUEST }); + return api(getState).post('/api/v2/filters', { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_in, + keywords_attributes, + }).then(response => { + dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_CREATE_FAIL, error }); + }); + }; + +const createFilter = (title: string, expires_in: string | null, 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(createFilterV2(title, expires_in, context, hide, keywords)); + + return dispatch(createFilterV1(title, expires_in, context, hide, keywords)); + }; + +const updateFilterV1 = (id: string, title: string, expires_in: string | null, 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_in, + }).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_in: string | null, 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_in, + 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_in: string | null, 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_in, context, hide, keywords)); + + return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords)); + }; + +const deleteFilterV1 = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete(`/api/v1/filters/${id}`).then(response => { @@ -83,17 +248,47 @@ const deleteFilter = (id: string) => }); }; +const deleteFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete(`/api/v2/filters/${id}`).then(response => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); + toast.success(messages.removed); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; + +const deleteFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(deleteFilterV2(id)); + + return dispatch(deleteFilterV1(id)); + }; + 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, -}; \ No newline at end of file +}; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 047d61d71..b14108de2 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -48,6 +48,8 @@ const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; +const STATUS_UNFILTER = 'STATUS_UNFILTER'; + const statusExists = (getState: () => RootState, statusId: string) => { return (getState().statuses.get(statusId) || null) !== null; }; @@ -335,6 +337,11 @@ const undoStatusTranslation = (id: string) => ({ id, }); +const unfilterStatus = (id: string) => ({ + type: STATUS_UNFILTER, + id, +}); + export { STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, @@ -363,6 +370,7 @@ export { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_UNDO, + STATUS_UNFILTER, createStatus, editStatus, fetchStatus, @@ -381,4 +389,5 @@ export { toggleStatusHidden, translateStatus, undoStatusTranslation, + unfilterStatus, }; diff --git a/app/soapbox/components/sidebar-menu.tsx b/app/soapbox/components/sidebar-menu.tsx index 0c68b2aff..aad2d7f6b 100644 --- a/app/soapbox/components/sidebar-menu.tsx +++ b/app/soapbox/components/sidebar-menu.tsx @@ -297,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( = (props) => { const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; const group = actualStatus.group as GroupEntity | null; + const filtered = (status.filtered.size || actualStatus.filtered.size) > 0; + // Track height changes we know about to compensate scrolling. useEffect(() => { didShowCard.current = Boolean(!muted && !hidden && status?.card); @@ -202,6 +204,8 @@ const Status: React.FC = (props) => { _expandEmojiSelector(); }; + const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id)); + const _expandEmojiSelector = (): void => { const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); @@ -281,7 +285,7 @@ const Status: React.FC = (props) => { ); } - if (status.filtered || actualStatus.filtered) { + if (filtered && status.showFiltered) { const minHandlers = muted ? undefined : { moveUp: handleHotkeyMoveUp, moveDown: handleHotkeyMoveDown, @@ -291,7 +295,11 @@ const Status: React.FC = (props) => {
- + : {status.filtered.join(', ')}. + {' '} +
diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 49658099a..5c436e70b 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -33,6 +33,8 @@ interface IStreamfield { onChange: (values: any[]) => void /** Input to render for each value. */ component: StreamfieldComponent + /** Minimum number of allowed inputs. */ + minItems?: number /** Maximum number of allowed inputs. */ maxItems?: number } @@ -47,6 +49,7 @@ const Streamfield: React.FC = ({ onChange, component: Component, maxItems = Infinity, + minItems = 0, }) => { const intl = useIntl(); @@ -67,10 +70,10 @@ const Streamfield: React.FC = ({ {(values.length > 0) && ( - {values.map((value, i) => ( + {values.map((value, i) => value?._destroy ? null : ( - {onRemoveItem && ( + {values.length > minItems && onRemoveItem && ( = ({ token, isCurrent }) => { )} - -
+ -
+ ); diff --git a/app/soapbox/features/filters/edit-filter.tsx b/app/soapbox/features/filters/edit-filter.tsx new file mode 100644 index 000000000..4d035f8d3 --- /dev/null +++ b/app/soapbox/features/filters/edit-filter.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useMemo, 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 { SelectDropdown } from '../forms'; + +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +interface IFilterField { + id?: string + keyword: string + whole_word: boolean + _destroy?: boolean +} + +interface IEditFilter { + params: { id?: string } +} + +const messages = defineMessages({ + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New 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' }, + 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' }, + 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' }, + expiration_never: { id: 'colum.filters.expiration.never', defaultMessage: 'Never' }, + expiration_1800: { id: 'colum.filters.expiration.1800', defaultMessage: '30 minutes' }, + expiration_3600: { id: 'colum.filters.expiration.3600', defaultMessage: '1 hour' }, + expiration_21600: { id: 'colum.filters.expiration.21600', defaultMessage: '6 hours' }, + expiration_43200: { id: 'colum.filters.expiration.43200', defaultMessage: '12 hours' }, + expiration_86400: { id: 'colum.filters.expiration.86400', defaultMessage: '1 day' }, + expiration_604800: { id: 'colum.filters.expiration.604800', defaultMessage: '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 [expiresIn, setExpiresIn] = useState(null); + 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: '', whole_word: false }]); + + const expirations = useMemo(() => ({ + '': intl.formatMessage(messages.expiration_never), + 1800: intl.formatMessage(messages.expiration_1800), + 3600: intl.formatMessage(messages.expiration_3600), + 21600: intl.formatMessage(messages.expiration_21600), + 43200: intl.formatMessage(messages.expiration_43200), + 86400: intl.formatMessage(messages.expiration_86400), + 604800: intl.formatMessage(messages.expiration_604800), + }), []); + + const handleSelectChange: React.ChangeEventHandler = e => { + setExpiresIn(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, expiresIn, context, hide, keywords) + : createFilter(title, expiresIn, 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[i].id + ? keywords.map((keyword, index) => index === i ? { ...keyword, _destroy: true } : keyword) + : 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)} + /> + + + {features.filtersExpiration && ( + + + + )} + + + + + + + + + + + + + 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 20c59ff36..46eba410b 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -1,31 +1,23 @@ -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 RelativeTimestamp from 'soapbox/components/relative-timestamp'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import toast from 'soapbox/toast'; 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' }, + accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' }, delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, - subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, + edit: { id: 'column.filters.edit', defaultMessage: 'Edit' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); @@ -34,167 +26,44 @@ const contexts = { public: messages.public_timeline, notifications: messages.notifications, thread: messages.conversations, + account: messages.accounts, }; -// const expirations = { -// null: 'Never', -// // 3600: '30 minutes', -// // 21600: '1 hour', -// // 43200: '12 hours', -// // 86400 : '1 day', -// // 604800: '1 week', -// }; - const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const history = useHistory(); + const { filtersV2 } = useFeatures(); const filters = useAppSelector((state) => state.filters); - const [phrase, setPhrase] = 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 [irreversible, setIrreversible] = useState(false); - const [wholeWord, setWholeWord] = useState(true); - - // 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'); - } - - dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => { - return dispatch(fetchFilters()); - }).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(() => { - return dispatch(fetchFilters()); + return dispatch(fetchFilters(true)); }).catch(() => { toast.error(intl.formatMessage(messages.delete_error)); }); }; useEffect(() => { - dispatch(fetchFilters()); + dispatch(fetchFilters(true)); }, []); const emptyMessage = ; return ( - - - -
- - setPhrase(target.value)} - /> - - {/* - - */} - - - - - - - - - - - - - setHomeTimeline(target.checked)} - /> - - - setPublicTimeline(target.checked)} - /> - - - setNotifications(target.checked)} - /> - - - setConversations(target.checked)} - /> - - - - - - setIrreversible(target.checked)} - /> - - - setWholeWord(target.checked)} - /> - - - - - - -
- - - - + + + { itemClassName='pb-4 last:pb-0' > {filters.map((filter, i) => ( - - - - - {' '} - {filter.phrase} - - - - {' '} - {filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')} - - +
+ + - {filter.irreversible ? - : - } + + {' '} + {filter.keywords.map(keyword => keyword.keyword).join(', ')} - {filter.whole_word && ( + + + {' '} + {filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')} + + - + {filtersV2 ? ( + filter.filter_action === 'hide' ? + : + + ) : (filter.filter_action === 'hide' ? + : + )} - )} + {filter.expires_at && ( + + {new Date(filter.expires_at).getTime() <= Date.now() + ? + : } + + )} + + + + + - - +
))}
diff --git a/app/soapbox/features/ui/components/link-footer.tsx b/app/soapbox/features/ui/components/link-footer.tsx index c8dd93588..377234093 100644 --- a/app/soapbox/features/ui/components/link-footer.tsx +++ b/app/soapbox/features/ui/components/link-footer.tsx @@ -45,7 +45,7 @@ const LinkFooter: React.FC = (): JSX.Element => { )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( )} {features.federating && ( diff --git a/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx b/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx index 2a7fdf65c..183519817 100644 --- a/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx +++ b/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx @@ -96,7 +96,7 @@ const EditAnnouncementModal: React.FC = ({ onClose }) => />)} - + = ({ children }) => {features.federating && } - {features.filters && } + {(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 8f55da6bc..88e55b2be 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/locales/en.json b/app/soapbox/locales/en.json index 657ab8d19..782234a37 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -283,6 +283,13 @@ "chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.", "chats.main.blankslate_with_chats.title": "Select a chat", "chats.search_placeholder": "Start a chat with…", + "colum.filters.expiration.1800": "30 minutes", + "colum.filters.expiration.21600": "6 hours", + "colum.filters.expiration.3600": "1 hour", + "colum.filters.expiration.43200": "12 hours", + "colum.filters.expiration.604800": "1 week", + "colum.filters.expiration.86400": "1 day", + "colum.filters.expiration.never": "Never", "column.admin.announcements": "Announcements", "column.admin.awaiting_approval": "Awaiting Approval", "column.admin.create_announcement": "Create announcement", @@ -321,6 +328,7 @@ "column.favourites": "Likes", "column.federation_restrictions": "Federation Restrictions", "column.filters": "Muted words", + "column.filters.accounts": "Accounts", "column.filters.add_new": "Add New Filter", "column.filters.conversations": "Conversations", "column.filters.create_error": "Error adding filter", @@ -328,16 +336,18 @@ "column.filters.delete_error": "Error deleting filter", "column.filters.drop_header": "Drop instead of hide", "column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed", + "column.filters.edit": "Edit", "column.filters.expires": "Expire after", - "column.filters.expires_hint": "Expiration dates are not currently supported", + "column.filters.hide_header": "Hide completely", + "column.filters.hide_hint": "Completely hide the filtered content, instead of showing a warning", "column.filters.home_timeline": "Home timeline", "column.filters.keyword": "Keyword or phrase", + "column.filters.keywords": "Keywords or phrases", "column.filters.notifications": "Notifications", "column.filters.public_timeline": "Public timeline", "column.filters.subheading_add_new": "Add New Filter", - "column.filters.subheading_filters": "Current Filters", - "column.filters.whole_word_header": "Whole word", - "column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word", + "column.filters.title": "Title", + "column.filters.whole_word": "Whole word", "column.follow_requests": "Follow requests", "column.followers": "Followers", "column.following": "Following", @@ -740,11 +750,14 @@ "filters.added": "Filter added.", "filters.context_header": "Filter contexts", "filters.context_hint": "One or multiple contexts where the filter should apply", + "filters.create_filter": "Create filter", "filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_drop": "Drop", + "filters.filters_list_expired": "Expired", "filters.filters_list_hide": "Hide", - "filters.filters_list_phrase_label": "Keyword or phrase:", - "filters.filters_list_whole-word": "Whole word", + "filters.filters_list_hide_completely": "Hide content", + "filters.filters_list_phrases_label": "Keywords or phrases:", + "filters.filters_list_warn": "Display warning", "filters.removed": "Filter deleted.", "followRecommendations.heading": "Suggested Profiles", "follow_request.authorize": "Authorize", @@ -1402,6 +1415,7 @@ "status.sensitive_warning": "Sensitive content", "status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.", "status.share": "Share", + "status.show_filter_reason": "Show anyway", "status.show_less_all": "Show less for all", "status.show_more_all": "Show more for all", "status.show_original": "Show original", diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 43c3c9400..32ae6d049 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -1271,7 +1271,7 @@ "status.embed": "Osadź", "status.external": "View post on {domain}", "status.favourite": "Zareaguj", - "status.filtered": "Filtrowany(-a)", + "status.filtered": "Filtrowany", "status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}", "status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}", "status.load_more": "Załaduj więcej", diff --git a/app/soapbox/normalizers/filter-keyword.ts b/app/soapbox/normalizers/filter-keyword.ts new file mode 100644 index 000000000..b81fd4b63 --- /dev/null +++ b/app/soapbox/normalizers/filter-keyword.ts @@ -0,0 +1,18 @@ +/** + * Filter normalizer: + * Converts API filters into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/FilterKeyword/} + */ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/FilterKeyword/ +export const FilterKeywordRecord = ImmutableRecord({ + id: '', + keyword: '', + whole_word: false, +}); + +export const normalizeFilterKeyword = (filterKeyword: Record) => + FilterKeywordRecord( + ImmutableMap(fromJS(filterKeyword)), + ); diff --git a/app/soapbox/normalizers/filter-result.ts b/app/soapbox/normalizers/filter-result.ts new file mode 100644 index 000000000..08ed3499f --- /dev/null +++ b/app/soapbox/normalizers/filter-result.ts @@ -0,0 +1,22 @@ +/** + * Filter normalizer: + * Converts API filters into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/FilterResult/} + */ +import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +import { normalizeFilter } from './filter'; + +import type { Filter } from 'soapbox/types/entities'; + +// https://docs.joinmastodon.org/entities/FilterResult/ +export const FilterResultRecord = ImmutableRecord({ + filter: null as Filter | null, + keyword_matches: ImmutableList(), + status_matches: ImmutableList(), +}); + +export const normalizeFilterResult = (filterResult: Record) => + FilterResultRecord( + ImmutableMap(fromJS(filterResult)).update('filter', (filter: any) => normalizeFilter(filter) as any), + ); diff --git a/app/soapbox/normalizers/filter-status.ts b/app/soapbox/normalizers/filter-status.ts new file mode 100644 index 000000000..d827d76af --- /dev/null +++ b/app/soapbox/normalizers/filter-status.ts @@ -0,0 +1,17 @@ +/** + * Filter normalizer: + * Converts API filters into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/FilterStatus/} + */ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/FilterStatus/ +export const FilterStatusRecord = ImmutableRecord({ + id: '', + status_id: '', +}); + +export const normalizeFilterStatus = (filterStatus: Record) => + FilterStatusRecord( + ImmutableMap(fromJS(filterStatus)), + ); diff --git a/app/soapbox/normalizers/filter.ts b/app/soapbox/normalizers/filter.ts index 5537acac9..d3eaab237 100644 --- a/app/soapbox/normalizers/filter.ts +++ b/app/soapbox/normalizers/filter.ts @@ -5,20 +5,49 @@ */ import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; -export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; +import { FilterKeyword, FilterStatus } from 'soapbox/types/entities'; + +import { normalizeFilterKeyword } from './filter-keyword'; +import { normalizeFilterStatus } from './filter-status'; + +export type ContextType = 'home' | 'public' | 'notifications' | 'thread' | 'account'; +export type FilterActionType = 'warn' | 'hide'; // https://docs.joinmastodon.org/entities/filter/ export const FilterRecord = ImmutableRecord({ id: '', - phrase: '', + title: '', context: ImmutableList(), - whole_word: false, expires_at: '', - irreversible: false, + filter_action: 'warn' as FilterActionType, + keywords: ImmutableList(), + statuses: ImmutableList(), }); -export const normalizeFilter = (filter: Record) => { - return FilterRecord( - ImmutableMap(fromJS(filter)), +const normalizeFilterV1 = (filter: ImmutableMap) => + filter + .set('title', filter.get('phrase')) + .set('keywords', ImmutableList([ImmutableMap({ + keyword: filter.get('phrase'), + whole_word: filter.get('whole_word'), + })])) + .set('filter_action', filter.get('irreversible') ? 'hide' : 'warn'); + +const normalizeKeywords = (filter: ImmutableMap) => + filter.update('keywords', ImmutableList(), keywords => + keywords.map(normalizeFilterKeyword), + ); + +const normalizeStatuses = (filter: ImmutableMap) => + filter.update('statuses', ImmutableList(), statuses => + statuses.map(normalizeFilterStatus), + ); + +export const normalizeFilter = (filter: Record) => + FilterRecord( + ImmutableMap(fromJS(filter)).withMutations(filter => { + if (filter.has('phrase')) normalizeFilterV1(filter); + normalizeKeywords(filter); + normalizeStatuses(filter); + }), ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 5b05a0e21..66daaae27 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -10,6 +10,8 @@ export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiReactionRecord } from './emoji-reaction'; export { FilterRecord, normalizeFilter } from './filter'; +export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword'; +export { FilterStatusRecord, normalizeFilterStatus } from './filter-status'; export { GroupRecord, normalizeGroup } from './group'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { HistoryRecord, normalizeHistory } from './history'; diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 57e27806f..3de7f6f4f 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -50,6 +50,7 @@ export const StatusRecord = ImmutableRecord({ emojis: ImmutableList(), favourited: false, favourites_count: 0, + filtered: ImmutableList(), group: null as EmbeddedEntity, in_reply_to_account_id: null as string | null, in_reply_to_id: null as string | null, @@ -78,9 +79,9 @@ export const StatusRecord = ImmutableRecord({ // Internal fields contentHtml: '', expectsCard: false, - filtered: false, hidden: false, search_index: '', + showFiltered: true, spoilerHtml: '', translation: null as ImmutableMap | null, }); @@ -166,11 +167,6 @@ const fixQuote = (status: ImmutableMap) => { }); }; -// Workaround for not yet implemented filtering from Mastodon 3.6 -const fixFiltered = (status: ImmutableMap) => { - status.delete('filtered'); -}; - /** If the status contains spoiler text, treat it as sensitive. */ const fixSensitivity = (status: ImmutableMap) => { if (status.get('spoiler_text')) { @@ -214,6 +210,13 @@ const fixContent = (status: ImmutableMap) => { } }; +const normalizeFilterResults = (status: ImmutableMap) => + status.update('filtered', ImmutableList(), filterResults => + filterResults.map((filterResult: ImmutableMap) => + filterResult.getIn(['filter', 'title']), + ), + ); + export const normalizeStatus = (status: Record) => { return StatusRecord( ImmutableMap(fromJS(status)).withMutations(status => { @@ -225,10 +228,10 @@ export const normalizeStatus = (status: Record) => { fixMentionsOrder(status); addSelfMention(status); fixQuote(status); - fixFiltered(status); fixSensitivity(status); normalizeEvent(status); fixContent(status); + normalizeFilterResults(status); }), ); }; diff --git a/app/soapbox/reducers/filters.ts b/app/soapbox/reducers/filters.ts index a31cb3295..8520b57a4 100644 --- a/app/soapbox/reducers/filters.ts +++ b/app/soapbox/reducers/filters.ts @@ -9,11 +9,10 @@ import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; type State = ImmutableList; -const importFilters = (_state: State, filters: APIEntity[]): State => { - return ImmutableList(filters.map((filter) => normalizeFilter(filter))); -}; +const importFilters = (_state: State, filters: APIEntity[]): State => + ImmutableList(filters.map((filter) => normalizeFilter(filter))); -export default function filters(state: State = ImmutableList(), action: AnyAction): State { +export default function filters(state: State = ImmutableList(), action: AnyAction): State { switch (action.type) { case FILTERS_FETCH_SUCCESS: return importFilters(state, action.filters); diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 58d2a8b57..66b34b7d7 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -38,6 +38,7 @@ import { STATUS_DELETE_FAIL, STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO, + STATUS_UNFILTER, } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -287,6 +288,8 @@ export default function statuses(state = initialState, action: AnyAction): State return importTranslation(state, action.id, action.translation); case STATUS_TRANSLATE_UNDO: return deleteTranslation(state, action.id); + case STATUS_UNFILTER: + return state.setIn([action.id, 'showFiltered'], false); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); case EVENT_JOIN_REQUEST: diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index 5e518f082..12cb4df08 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -10,6 +10,7 @@ import { getSettings } from 'soapbox/actions/settings'; import { getDomain } from 'soapbox/utils/accounts'; import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config-db'; +import { getFeatures } from 'soapbox/utils/features'; import { shouldFilter } from 'soapbox/utils/timelines'; import type { ContextType } from 'soapbox/normalizers/filter'; @@ -117,23 +118,63 @@ const escapeRegExp = (string: string) => export const regexFromFilters = (filters: ImmutableList) => { if (filters.size === 0) return null; - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); + return new RegExp(filters.map(filter => + filter.keywords.map(keyword => { + let expr = escapeRegExp(keyword.keyword); - if (filter.get('whole_word')) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } } - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }).join('|'), 'i'); + return expr; + }).join('|'), + ).join('|'), 'i'); }; +const checkFiltered = (index: string, filters: ImmutableList) => + filters.reduce((result, filter) => + result.concat(filter.keywords.reduce((result, keyword) => { + let expr = escapeRegExp(keyword.keyword); + + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + const regex = new RegExp(expr); + + if (regex.test(index)) return result.concat(filter.title); + return result; + }, ImmutableList())), ImmutableList()); +// const results = +// let expr = escapeRegExp(filter.phrase); + +// if (filter.whole_word) { +// if (/^[\w]/.test(expr)) { +// expr = `\\b${expr}`; +// } + +// if (/[\w]$/.test(expr)) { +// expr = `${expr}\\b`; +// } +// } + +// const regex = new RegExp(expr); + +// if (regex.test(index)) return result.join(filter.phrase); +// return result; + type APIStatus = { id: string, username?: string }; export const makeGetStatus = () => { @@ -147,9 +188,10 @@ export const makeGetStatus = () => { (_state: RootState, { username }: APIStatus) => username, getFilters, (state: RootState) => state.me, + (state: RootState) => getFeatures(state.instance), ], - (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => { + (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me, features) => { if (!statusBase || !accountBase) return null; const accountUsername = accountBase.acct; @@ -165,16 +207,18 @@ export const makeGetStatus = () => { statusReblog = undefined; } - const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters); - const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index); - return statusBase.withMutations(map => { map.set('reblog', statusReblog || null); // @ts-ignore :( map.set('account', accountBase || null); // @ts-ignore map.set('group', group || null); - map.set('filtered', Boolean(filtered)); + + if ((features.filters || features.filtersV2) && (accountReblog || accountBase).id !== me) { + const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters); + + map.set('filtered', filtered); + } }); }, ); diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index ed43df407..bf717e805 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -12,6 +12,8 @@ import { EmojiReactionRecord, FieldRecord, FilterRecord, + FilterKeywordRecord, + FilterStatusRecord, GroupRecord, GroupRelationshipRecord, HistoryRecord, @@ -44,6 +46,8 @@ type Emoji = ReturnType; type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; +type FilterKeyword = ReturnType; +type FilterStatus = ReturnType; type Group = ReturnType; type GroupRelationship = ReturnType; type History = ReturnType; @@ -89,6 +93,8 @@ export { EmojiReaction, Field, Filter, + FilterKeyword, + FilterStatus, Group, GroupRelationship, History, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index e2a8eb6c8..fb5654bd4 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -314,7 +314,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Mastodon's newer solution for direct messaging. - * @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} + * @see {@link https://docs.joinmastodon.org/methods/conversations/} */ conversations: any([ v.software === FRIENDICA, @@ -443,16 +443,28 @@ 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/#v1} */ filters: any([ v.software === MASTODON && lt(v.compatVersion, '3.6.0'), v.software === PLEROMA, ]), + /** Whether filters can automatically expires. */ + filtersExpiration: any([ + v.software === MASTODON, + v.software === PLEROMA && gte(v.version, '2.3.0'), + ]), + + /** + * Can edit and manage timeline filters (aka "muted words"). + * @see {@link https://docs.joinmastodon.org/methods/filters/} + */ + filtersV2: v.software === MASTODON && gte(v.compatVersion, '3.6.0'), + /** * Allows setting the focal point of a media attachment. - * @see {@link https://docs.joinmastodon.org/methods/statuses/media/} + * @see {@link https://docs.joinmastodon.org/methods/media/} */ focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), @@ -528,7 +540,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can create, view, and manage lists. - * @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} + * @see {@link https://docs.joinmastodon.org/methods/lists/} * @see GET /api/v1/timelines/list/:list_id */ lists: any([ @@ -643,7 +655,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * A directory of discoverable profiles from the instance. - * @see {@link https://docs.joinmastodon.org/methods/instance/directory/} + * @see {@link https://docs.joinmastodon.org/methods/directory/} */ profileDirectory: any([ v.software === FRIENDICA, @@ -736,7 +748,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can schedule statuses to be posted at a later time. * @see POST /api/v1/statuses - * @see {@link https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/} + * @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/} */ scheduledStatuses: any([ v.software === MASTODON && gte(v.version, '2.7.0'), @@ -788,7 +800,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'),