From 2ce98055d8c43c50f1a622e968827bded4db60a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 1 Mar 2023 21:02:04 +0100 Subject: [PATCH 01/14] Fix load more button height on account gallery page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/load-more.tsx | 7 ++++--- app/soapbox/features/account-gallery/index.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/load-more.tsx b/app/soapbox/components/load-more.tsx index e937965fdd..878adda7cb 100644 --- a/app/soapbox/components/load-more.tsx +++ b/app/soapbox/components/load-more.tsx @@ -6,16 +6,17 @@ import { Button } from 'soapbox/components/ui'; interface ILoadMore { onClick: React.MouseEventHandler disabled?: boolean - visible?: Boolean + visible?: boolean + className?: string } -const LoadMore: React.FC = ({ onClick, disabled, visible = true }) => { +const LoadMore: React.FC = ({ onClick, disabled, visible = true, className }) => { if (!visible) { return null; } return ( - ); diff --git a/app/soapbox/features/account-gallery/index.tsx b/app/soapbox/features/account-gallery/index.tsx index 8aec040e9c..7082124c85 100644 --- a/app/soapbox/features/account-gallery/index.tsx +++ b/app/soapbox/features/account-gallery/index.tsx @@ -121,7 +121,7 @@ const AccountGallery = () => { let loadOlder = null; if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; + loadOlder = ; } if (unavailable) { From 4200fa2df4ed4607abb332599222d0c04ae85db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 3 Mar 2023 22:40:39 +0100 Subject: [PATCH 02/14] filters v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- README.md | 8 +-- app/assets/icons/COPYING.md | 2 +- app/soapbox/actions/filters.ts | 86 ++++++++++++++++------- app/soapbox/actions/statuses.ts | 9 +++ app/soapbox/components/status.tsx | 14 +++- app/soapbox/locales/pl.json | 2 +- app/soapbox/normalizers/filter-keyword.ts | 18 +++++ app/soapbox/normalizers/filter-result.ts | 22 ++++++ app/soapbox/normalizers/filter-status.ts | 17 +++++ app/soapbox/normalizers/filter-v1.ts | 24 +++++++ app/soapbox/normalizers/filter.ts | 33 +++++++-- app/soapbox/normalizers/index.ts | 3 + app/soapbox/normalizers/status.ts | 17 +++-- app/soapbox/reducers/filters.ts | 18 ++--- app/soapbox/reducers/statuses.ts | 3 + app/soapbox/selectors/index.ts | 42 ++++++++--- app/soapbox/types/entities.ts | 9 +++ app/soapbox/utils/features.ts | 8 ++- 18 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 app/soapbox/normalizers/filter-keyword.ts create mode 100644 app/soapbox/normalizers/filter-result.ts create mode 100644 app/soapbox/normalizers/filter-status.ts create mode 100644 app/soapbox/normalizers/filter-v1.ts diff --git a/README.md b/README.md index 2504de2788..2c419698ad 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ One disadvantage of this approach is that it does not help the software spread. # License & Credits -© Alex Gleason & other Soapbox contributors -© Eugen Rochko & other Mastodon contributors -© Trump Media & Technology Group -© Gab AI, Inc. +© Alex Gleason & other Soapbox contributors +© Eugen Rochko & other Mastodon contributors +© Trump Media & Technology Group +© 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 1dcc928d92..a5dbe7d981 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 7e663f88d2..6d6f82736f 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -8,9 +8,13 @@ import api from '../api'; import type { AppDispatch, RootState } from 'soapbox/store'; -const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; -const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; -const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; +const FILTERS_V1_FETCH_REQUEST = 'FILTERS_V1_FETCH_REQUEST'; +const FILTERS_V1_FETCH_SUCCESS = 'FILTERS_V1_FETCH_SUCCESS'; +const FILTERS_V1_FETCH_FAIL = 'FILTERS_V1_FETCH_FAIL'; + +const FILTERS_V2_FETCH_REQUEST = 'FILTERS_V2_FETCH_REQUEST'; +const FILTERS_V2_FETCH_SUCCESS = 'FILTERS_V2_FETCH_SUCCESS'; +const FILTERS_V2_FETCH_FAIL = 'FILTERS_V2_FETCH_FAIL'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; @@ -25,6 +29,50 @@ const messages = defineMessages({ removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, }); +const fetchFiltersV1 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_V1_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v1/filters') + .then(({ data }) => dispatch({ + type: FILTERS_V1_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_V1_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFiltersV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_V2_FETCH_REQUEST, + skipLoading: true, + }); + + api(getState) + .get('/api/v2/filters') + .then(({ data }) => dispatch({ + type: FILTERS_V2_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_V2_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + const fetchFilters = () => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -33,26 +81,9 @@ const fetchFilters = () => const instance = state.instance; const features = getFeatures(instance); - if (!features.filters) return; + if (features.filtersV2) return dispatch(fetchFiltersV2()); - dispatch({ - type: FILTERS_FETCH_REQUEST, - skipLoading: true, - }); - - api(getState) - .get('/api/v1/filters') - .then(({ data }) => dispatch({ - type: FILTERS_FETCH_SUCCESS, - filters: data, - skipLoading: true, - })) - .catch(err => dispatch({ - type: FILTERS_FETCH_FAIL, - err, - skipLoading: true, - skipAlert: true, - })); + if (features.filters) return dispatch(fetchFiltersV1()); }; const createFilter = (phrase: string, expires_at: string, context: Array, whole_word: boolean, irreversible: boolean) => @@ -84,9 +115,12 @@ const deleteFilter = (id: string) => }; export { - FILTERS_FETCH_REQUEST, - FILTERS_FETCH_SUCCESS, - FILTERS_FETCH_FAIL, + FILTERS_V1_FETCH_REQUEST, + FILTERS_V1_FETCH_SUCCESS, + FILTERS_V1_FETCH_FAIL, + FILTERS_V2_FETCH_REQUEST, + FILTERS_V2_FETCH_SUCCESS, + FILTERS_V2_FETCH_FAIL, FILTERS_CREATE_REQUEST, FILTERS_CREATE_SUCCESS, FILTERS_CREATE_FAIL, @@ -96,4 +130,4 @@ export { fetchFilters, createFilter, deleteFilter, -}; \ No newline at end of file +}; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 047d61d711..b14108de24 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/status.tsx b/app/soapbox/components/status.tsx index 16358f9dd7..3ec072394c 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; -import { toggleStatusHidden } from 'soapbox/actions/statuses'; +import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; import TranslateButton from 'soapbox/components/translate-button'; import AccountContainer from 'soapbox/containers/account-container'; @@ -93,6 +93,8 @@ const Status: React.FC = (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/locales/pl.json b/app/soapbox/locales/pl.json index 2b32f88dc1..a3fc6cc0b4 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -1131,7 +1131,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 0000000000..b81fd4b63f --- /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 0000000000..08ed3499fd --- /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 0000000000..d827d76afe --- /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-v1.ts b/app/soapbox/normalizers/filter-v1.ts new file mode 100644 index 0000000000..ef454db1ef --- /dev/null +++ b/app/soapbox/normalizers/filter-v1.ts @@ -0,0 +1,24 @@ +/** + * Filter normalizer: + * Converts API filters into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/V1_Filter/} + */ +import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +import type { ContextType } from './filter'; + +// https://docs.joinmastodon.org/entities/V1_Filter/ +export const FilterV1Record = ImmutableRecord({ + id: '', + phrase: '', + context: ImmutableList(), + whole_word: false, + expires_at: '', + irreversible: false, +}); + +export const normalizeFilterV1 = (filter: Record) => { + return FilterV1Record( + ImmutableMap(fromJS(filter)), + ); +}; diff --git a/app/soapbox/normalizers/filter.ts b/app/soapbox/normalizers/filter.ts index 5537acac9d..24329945b5 100644 --- a/app/soapbox/normalizers/filter.ts +++ b/app/soapbox/normalizers/filter.ts @@ -5,20 +5,39 @@ */ import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; +import { FilterKeyword, FilterStatus } from 'soapbox/types/entities'; + +import { normalizeFilterKeyword } from './filter-keyword'; +import { normalizeFilterStatus } from './filter-status'; + export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; +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 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 => { + normalizeKeywords(filter); + normalizeStatuses(filter); + }), ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 5b05a0e214..b0dd890884 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -10,6 +10,9 @@ 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 { FilterV1Record, normalizeFilterV1 } from './filter-v1'; 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 57e27806fb..3de7f6f4fb 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 a31cb3295e..9aaff191ca 100644 --- a/app/soapbox/reducers/filters.ts +++ b/app/soapbox/reducers/filters.ts @@ -1,22 +1,22 @@ import { List as ImmutableList } from 'immutable'; -import { normalizeFilter } from 'soapbox/normalizers'; +import { normalizeFilterV1 } from 'soapbox/normalizers'; -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; +import { FILTERS_V1_FETCH_SUCCESS } from '../actions/filters'; import type { AnyAction } from 'redux'; -import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; +import type { APIEntity, FilterV1 as FilterV1Entity } from 'soapbox/types/entities'; -type State = ImmutableList; +type State = ImmutableList; -const importFilters = (_state: State, filters: APIEntity[]): State => { - return ImmutableList(filters.map((filter) => normalizeFilter(filter))); +const importFiltersV1 = (_state: State, filters: APIEntity[]): State => { + return ImmutableList(filters.map((filter) => normalizeFilterV1(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); + case FILTERS_V1_FETCH_SUCCESS: + return importFiltersV1(state, action.filters); default: return state; } diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index ed7ee1c2b0..17662da868 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 5e518f082b..ec5da2d822 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -10,12 +10,13 @@ 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'; import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; -import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; +import type { FilterV1 as FilterV1Entity, Notification } from 'soapbox/types/entities'; const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; @@ -114,13 +115,13 @@ export const getFilters = (state: RootState, query: FilterContext) => { const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -export const regexFromFilters = (filters: ImmutableList) => { +export const regexFromFilters = (filters: ImmutableList) => { if (filters.size === 0) return null; return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); + let expr = escapeRegExp(filter.phrase); - if (filter.get('whole_word')) { + if (filter.whole_word) { if (/^[\w]/.test(expr)) { expr = `\\b${expr}`; } @@ -134,6 +135,26 @@ export const regexFromFilters = (filters: ImmutableList) => { }).join('|'), 'i'); }; +const checkFiltered = (index: string, filters: ImmutableList) => + filters.reduce((result, filter) => { + 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.push(filter.phrase); + return result; + }, ImmutableList()); + type APIStatus = { id: string, username?: string }; export const makeGetStatus = () => { @@ -147,9 +168,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 +187,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 && (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 ed43df4079..30c94684e5 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -12,6 +12,9 @@ import { EmojiReactionRecord, FieldRecord, FilterRecord, + FilterKeywordRecord, + FilterStatusRecord, + FilterV1Record, GroupRecord, GroupRelationshipRecord, HistoryRecord, @@ -44,6 +47,9 @@ type Emoji = ReturnType; type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; +type FilterKeyword = ReturnType; +type FilterStatus = ReturnType; +type FilterV1 = ReturnType; type Group = ReturnType; type GroupRelationship = ReturnType; type History = ReturnType; @@ -89,6 +95,9 @@ export { EmojiReaction, Field, Filter, + FilterKeyword, + FilterStatus, + FilterV1, Group, GroupRelationship, History, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 3b1c14b77f..46ac310ea3 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -443,13 +443,19 @@ 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, ]), + /** + * Can edit and manage timeline filters (aka "muted words"). + * @see {@link https://docs.joinmastodon.org/methods/accounts/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/} From 4c92f581c484771b3b01050e4583f535176e3c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Mar 2023 12:43:27 +0100 Subject: [PATCH 03/14] Allow creating v2 filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/filters.ts | 97 +++++++++---- app/soapbox/components/sidebar-menu.tsx | 2 +- .../components/ui/streamfield/streamfield.tsx | 5 +- app/soapbox/features/filters/index.tsx | 127 ++++++++++++++---- .../features/ui/components/link-footer.tsx | 2 +- .../modals/edit-announcement-modal.tsx | 2 +- app/soapbox/features/ui/index.tsx | 2 +- app/soapbox/locales/en.json | 10 +- app/soapbox/normalizers/filter-v1.ts | 24 ---- app/soapbox/normalizers/filter.ts | 12 +- app/soapbox/normalizers/index.ts | 1 - app/soapbox/reducers/filters.ts | 17 ++- app/soapbox/selectors/index.ts | 78 +++++++---- app/soapbox/types/entities.ts | 3 - 14 files changed, 257 insertions(+), 125 deletions(-) delete mode 100644 app/soapbox/normalizers/filter-v1.ts diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index 6d6f82736f..c94259e7ba 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -8,13 +8,9 @@ import api from '../api'; import type { AppDispatch, RootState } from 'soapbox/store'; -const FILTERS_V1_FETCH_REQUEST = 'FILTERS_V1_FETCH_REQUEST'; -const FILTERS_V1_FETCH_SUCCESS = 'FILTERS_V1_FETCH_SUCCESS'; -const FILTERS_V1_FETCH_FAIL = 'FILTERS_V1_FETCH_FAIL'; - -const FILTERS_V2_FETCH_REQUEST = 'FILTERS_V2_FETCH_REQUEST'; -const FILTERS_V2_FETCH_SUCCESS = 'FILTERS_V2_FETCH_SUCCESS'; -const FILTERS_V2_FETCH_FAIL = 'FILTERS_V2_FETCH_FAIL'; +const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; +const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; +const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; @@ -29,22 +25,24 @@ const messages = defineMessages({ removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, }); +type FilterKeywords = { keyword: string, whole_word: boolean }[]; + const fetchFiltersV1 = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ - type: FILTERS_V1_FETCH_REQUEST, + type: FILTERS_FETCH_REQUEST, skipLoading: true, }); api(getState) .get('/api/v1/filters') .then(({ data }) => dispatch({ - type: FILTERS_V1_FETCH_SUCCESS, + type: FILTERS_FETCH_SUCCESS, filters: data, skipLoading: true, })) .catch(err => dispatch({ - type: FILTERS_V1_FETCH_FAIL, + type: FILTERS_FETCH_FAIL, err, skipLoading: true, skipAlert: true, @@ -54,26 +52,26 @@ const fetchFiltersV1 = () => const fetchFiltersV2 = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ - type: FILTERS_V2_FETCH_REQUEST, + type: FILTERS_FETCH_REQUEST, skipLoading: true, }); api(getState) .get('/api/v2/filters') .then(({ data }) => dispatch({ - type: FILTERS_V2_FETCH_SUCCESS, + type: FILTERS_FETCH_SUCCESS, filters: data, skipLoading: true, })) .catch(err => dispatch({ - type: FILTERS_V2_FETCH_FAIL, + type: FILTERS_FETCH_FAIL, err, skipLoading: true, skipAlert: true, })); }; -const fetchFilters = () => +const fetchFilters = (fromFiltersPage = false) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -81,19 +79,19 @@ const fetchFilters = () => const instance = state.instance; const features = getFeatures(instance); - if (features.filtersV2) return dispatch(fetchFiltersV2()); + if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2()); if (features.filters) return dispatch(fetchFiltersV1()); }; -const createFilter = (phrase: string, expires_at: string, context: Array, whole_word: boolean, irreversible: boolean) => +const createFilterV1 = (title: string, expires_at: string, 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, + irreversible: hide, + whole_word: keywords[0].whole_word, expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); @@ -103,7 +101,35 @@ const createFilter = (phrase: string, expires_at: string, context: Array }); }; -const deleteFilter = (id: string) => +const createFilterV2 = (title: string, expires_at: string, 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_at, + 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_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(createFilterV2(title, expires_at, context, hide, keywords)); + + return dispatch(createFilterV1(title, expires_at, 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 => { @@ -114,13 +140,32 @@ 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_V1_FETCH_REQUEST, - FILTERS_V1_FETCH_SUCCESS, - FILTERS_V1_FETCH_FAIL, - FILTERS_V2_FETCH_REQUEST, - FILTERS_V2_FETCH_SUCCESS, - FILTERS_V2_FETCH_FAIL, + FILTERS_FETCH_REQUEST, + FILTERS_FETCH_SUCCESS, + FILTERS_FETCH_FAIL, FILTERS_CREATE_REQUEST, FILTERS_CREATE_SUCCESS, FILTERS_CREATE_FAIL, diff --git a/app/soapbox/components/sidebar-menu.tsx b/app/soapbox/components/sidebar-menu.tsx index ffe3a0e983..ffd737dbbe 100644 --- a/app/soapbox/components/sidebar-menu.tsx +++ b/app/soapbox/components/sidebar-menu.tsx @@ -296,7 +296,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( 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(); @@ -70,7 +73,7 @@ const Streamfield: React.FC = ({ {values.map((value, i) => ( - {onRemoveItem && ( + {values.length > minItems && onRemoveItem && ( = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: string): React.ChangeEventHandler => + e => { + // console.log({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); + onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); + }; + + return ( + + + + + + + + + + + ); +}; + const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const features = useFeatures(); const filters = useAppSelector((state) => state.filters); - const [phrase, setPhrase] = useState(''); + 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 [irreversible, setIrreversible] = useState(false); - const [wholeWord, setWholeWord] = useState(true); + 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 }); @@ -80,9 +129,12 @@ const Filters = () => { if (conversations) { context.push('thread'); } + if (accounts) { + context.push('account'); + } - dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => { - return dispatch(fetchFilters()); + dispatch(createFilter(title, expiresAt, context, hide, keywords)).then(() => { + return dispatch(fetchFilters(true)); }).catch(error => { toast.error(intl.formatMessage(messages.create_error)); }); @@ -90,14 +142,20 @@ const Filters = () => { const handleFilterDelete = (id: string) => () => { dispatch(deleteFilter(id)).then(() => { - return dispatch(fetchFilters()); + return dispatch(fetchFilters(true)); }).catch(() => { toast.error(intl.formatMessage(messages.delete_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(() => { - dispatch(fetchFilters()); + dispatch(fetchFilters(true)); }, []); const emptyMessage = ; @@ -108,12 +166,13 @@ const Filters = () => {
- + setPhrase(target.value)} + name='title' + value={title} + onChange={({ target }) => setTitle(target.value)} /> {/* @@ -162,20 +221,29 @@ const Filters = () => { onChange={({ target }) => setConversations(target.checked)} /> + {features.filtersV2 && ( + + setAccounts(target.checked)} + /> + + )} setIrreversible(target.checked)} + name='hide' + checked={hide} + onChange={({ target }) => setHide(target.checked)} /> - @@ -184,9 +252,20 @@ const Filters = () => { checked={wholeWord} onChange={({ target }) => setWholeWord(target.checked)} /> - + */} + + @@ -207,7 +286,7 @@ const Filters = () => { {' '} - {filter.phrase} + {filter.keywords.map(keyword => keyword.keyword).join(', ')} @@ -215,7 +294,7 @@ const Filters = () => { {filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')} - + {/* {filter.irreversible ? : } @@ -224,7 +303,7 @@ const Filters = () => { - )} + )} */} { )} - {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 2a7fdf65c5..1835198179 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) && } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index dc55b1f835..944bca2677 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -321,6 +321,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", @@ -330,12 +331,17 @@ "column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed", "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.title": "Title", + "column.filters.whole_word": "Whole word", "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.follow_requests": "Follow requests", @@ -731,10 +737,7 @@ "filters.context_header": "Filter contexts", "filters.context_hint": "One or multiple contexts where the filter should apply", "filters.filters_list_context_label": "Filter contexts:", - "filters.filters_list_drop": "Drop", - "filters.filters_list_hide": "Hide", "filters.filters_list_phrase_label": "Keyword or phrase:", - "filters.filters_list_whole-word": "Whole word", "filters.removed": "Filter deleted.", "followRecommendations.heading": "Suggested Profiles", "follow_request.authorize": "Authorize", @@ -1384,6 +1387,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/normalizers/filter-v1.ts b/app/soapbox/normalizers/filter-v1.ts deleted file mode 100644 index ef454db1ef..0000000000 --- a/app/soapbox/normalizers/filter-v1.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Filter normalizer: - * Converts API filters into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/V1_Filter/} - */ -import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; - -import type { ContextType } from './filter'; - -// https://docs.joinmastodon.org/entities/V1_Filter/ -export const FilterV1Record = ImmutableRecord({ - id: '', - phrase: '', - context: ImmutableList(), - whole_word: false, - expires_at: '', - irreversible: false, -}); - -export const normalizeFilterV1 = (filter: Record) => { - return FilterV1Record( - ImmutableMap(fromJS(filter)), - ); -}; diff --git a/app/soapbox/normalizers/filter.ts b/app/soapbox/normalizers/filter.ts index 24329945b5..d3eaab237e 100644 --- a/app/soapbox/normalizers/filter.ts +++ b/app/soapbox/normalizers/filter.ts @@ -10,7 +10,7 @@ import { FilterKeyword, FilterStatus } from 'soapbox/types/entities'; import { normalizeFilterKeyword } from './filter-keyword'; import { normalizeFilterStatus } from './filter-status'; -export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; +export type ContextType = 'home' | 'public' | 'notifications' | 'thread' | 'account'; export type FilterActionType = 'warn' | 'hide'; // https://docs.joinmastodon.org/entities/filter/ @@ -24,6 +24,15 @@ export const FilterRecord = ImmutableRecord({ statuses: ImmutableList(), }); +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), @@ -37,6 +46,7 @@ const normalizeStatuses = (filter: ImmutableMap) => 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 b0dd890884..66daaae27c 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -12,7 +12,6 @@ export { EmojiReactionRecord } from './emoji-reaction'; export { FilterRecord, normalizeFilter } from './filter'; export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword'; export { FilterStatusRecord, normalizeFilterStatus } from './filter-status'; -export { FilterV1Record, normalizeFilterV1 } from './filter-v1'; export { GroupRecord, normalizeGroup } from './group'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { HistoryRecord, normalizeHistory } from './history'; diff --git a/app/soapbox/reducers/filters.ts b/app/soapbox/reducers/filters.ts index 9aaff191ca..8520b57a41 100644 --- a/app/soapbox/reducers/filters.ts +++ b/app/soapbox/reducers/filters.ts @@ -1,22 +1,21 @@ import { List as ImmutableList } from 'immutable'; -import { normalizeFilterV1 } from 'soapbox/normalizers'; +import { normalizeFilter } from 'soapbox/normalizers'; -import { FILTERS_V1_FETCH_SUCCESS } from '../actions/filters'; +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; import type { AnyAction } from 'redux'; -import type { APIEntity, FilterV1 as FilterV1Entity } from 'soapbox/types/entities'; +import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; -type State = ImmutableList; +type State = ImmutableList; -const importFiltersV1 = (_state: State, filters: APIEntity[]): State => { - return ImmutableList(filters.map((filter) => normalizeFilterV1(filter))); -}; +const importFilters = (_state: State, filters: APIEntity[]): State => + ImmutableList(filters.map((filter) => normalizeFilter(filter))); export default function filters(state: State = ImmutableList(), action: AnyAction): State { switch (action.type) { - case FILTERS_V1_FETCH_SUCCESS: - return importFiltersV1(state, action.filters); + case FILTERS_FETCH_SUCCESS: + return importFilters(state, action.filters); default: return state; } diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index ec5da2d822..12cb4df085 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -16,7 +16,7 @@ import { shouldFilter } from 'soapbox/utils/timelines'; import type { ContextType } from 'soapbox/normalizers/filter'; import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; -import type { FilterV1 as FilterV1Entity, Notification } from 'soapbox/types/entities'; +import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; @@ -115,45 +115,65 @@ export const getFilters = (state: RootState, query: FilterContext) => { const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -export const regexFromFilters = (filters: ImmutableList) => { +export const regexFromFilters = (filters: ImmutableList) => { if (filters.size === 0) return null; - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.phrase); + return new RegExp(filters.map(filter => + filter.keywords.map(keyword => { + let expr = escapeRegExp(keyword.keyword); - if (filter.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) => { - let expr = escapeRegExp(filter.phrase); +const checkFiltered = (index: string, filters: ImmutableList) => + filters.reduce((result, filter) => + result.concat(filter.keywords.reduce((result, keyword) => { + let expr = escapeRegExp(keyword.keyword); - if (filter.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`; - } - } + const regex = new RegExp(expr); - 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 (regex.test(index)) return result.push(filter.phrase); - return result; - }, ImmutableList()); +// 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 }; @@ -194,7 +214,7 @@ export const makeGetStatus = () => { // @ts-ignore map.set('group', group || null); - if (features.filters && (accountReblog || accountBase).id !== me) { + 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 30c94684e5..bf717e8056 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -14,7 +14,6 @@ import { FilterRecord, FilterKeywordRecord, FilterStatusRecord, - FilterV1Record, GroupRecord, GroupRelationshipRecord, HistoryRecord, @@ -49,7 +48,6 @@ type Field = ReturnType; type Filter = ReturnType; type FilterKeyword = ReturnType; type FilterStatus = ReturnType; -type FilterV1 = ReturnType; type Group = ReturnType; type GroupRelationship = ReturnType; type History = ReturnType; @@ -97,7 +95,6 @@ export { Filter, FilterKeyword, FilterStatus, - FilterV1, Group, GroupRelationship, History, From ebe4f9373bfef40a76229ea6d39aa6ff161bae33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Mar 2023 12:44:04 +0100 Subject: [PATCH 04/14] Remove console.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/filters/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index bccde00f21..c9b5039400 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -63,10 +63,7 @@ const FilterField: StreamfieldComponent = ({ value, onChange }) => const intl = useIntl(); const handleChange = (key: string): React.ChangeEventHandler => - e => { - // console.log({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); - onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); - }; + e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); return ( From af314ee55db66f55b73f6e57c5c1a2882e903e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Mar 2023 21:22:59 +0100 Subject: [PATCH 05/14] Allow editing filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/filters.ts | 120 +++++++- app/soapbox/features/filters/edit-filter.tsx | 274 ++++++++++++++++++ app/soapbox/features/filters/index.tsx | 226 ++------------- app/soapbox/features/ui/index.tsx | 3 + .../features/ui/util/async-components.ts | 4 + app/soapbox/utils/features.ts | 4 +- 6 files changed, 425 insertions(+), 206 deletions(-) create mode 100644 app/soapbox/features/filters/edit-filter.tsx diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index c94259e7ba..c50d598180 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 0000000000..24c3bdfe4b --- /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 c9b5039400..d1cbe1f3be 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 6e18f5771e..9a80d8ebbe 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 46ac310ea3..c818ccaabf 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'), From 1d4d9c2732cbaab677eacc6035589a7cf406d859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 5 Mar 2023 19:49:40 +0100 Subject: [PATCH 06/14] Filters expiration, restyle filters list, fix keywords deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/filters.ts | 28 +++---- .../components/ui/streamfield/streamfield.tsx | 2 +- .../features/auth-token-list/index.tsx | 7 +- app/soapbox/features/filters/edit-filter.tsx | 69 +++++++++------- app/soapbox/features/filters/index.tsx | 81 ++++++++----------- app/soapbox/utils/features.ts | 16 ++-- 6 files changed, 106 insertions(+), 97 deletions(-) diff --git a/app/soapbox/actions/filters.ts b/app/soapbox/actions/filters.ts index c50d598180..ee3508682d 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -147,7 +147,7 @@ const fetchFilter = (id: string) => if (features.filters) return dispatch(fetchFilterV1(id)); }; -const createFilterV1 = (title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => +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', { @@ -155,7 +155,7 @@ const createFilterV1 = (title: string, expires_at: string, context: Array { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); toast.success(messages.added); @@ -164,14 +164,14 @@ const createFilterV1 = (title: string, expires_at: string, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => +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_at, + expires_in, keywords_attributes, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); @@ -181,18 +181,18 @@ const createFilterV2 = (title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => +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_at, context, hide, keywords)); + if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords)); - return dispatch(createFilterV1(title, expires_at, context, hide, keywords)); + return dispatch(createFilterV1(title, expires_in, context, hide, keywords)); }; -const updateFilterV1 = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => +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}`, { @@ -200,7 +200,7 @@ const updateFilterV1 = (id: string, title: string, expires_at: string, context: context, irreversible: hide, whole_word: keywords[0].whole_word, - expires_at, + expires_in, }).then(response => { dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); toast.success(messages.added); @@ -209,14 +209,14 @@ const updateFilterV1 = (id: string, title: string, expires_at: string, context: }); }; -const updateFilterV2 = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => +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_at, + expires_in, keywords_attributes, }).then(response => { dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); @@ -226,15 +226,15 @@ const updateFilterV2 = (id: string, title: string, expires_at: string, context: }); }; -const updateFilter = (id: string, title: string, expires_at: string, context: Array, hide: boolean, keywords: FilterKeywords) => +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_at, context, hide, keywords)); + if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords)); - return dispatch(updateFilterV1(id, title, expires_at, context, hide, keywords)); + return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords)); }; const deleteFilterV1 = (id: string) => diff --git a/app/soapbox/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 3599eaae1e..5c436e70b2 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -70,7 +70,7 @@ const Streamfield: React.FC = ({ {(values.length > 0) && ( - {values.map((value, i) => ( + {values.map((value, i) => value?._destroy ? null : ( {values.length > minItems && onRemoveItem && ( diff --git a/app/soapbox/features/auth-token-list/index.tsx b/app/soapbox/features/auth-token-list/index.tsx index 9904f3f64d..a97d7ef75f 100644 --- a/app/soapbox/features/auth-token-list/index.tsx +++ b/app/soapbox/features/auth-token-list/index.tsx @@ -3,7 +3,7 @@ import { defineMessages, FormattedDate, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; -import { Button, Card, CardBody, CardHeader, CardTitle, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; +import { Button, Card, CardBody, CardHeader, CardTitle, Column, HStack, Spinner, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { Token } from 'soapbox/reducers/security'; @@ -59,12 +59,11 @@ const AuthToken: React.FC = ({ token, isCurrent }) => { )} - -
+ -
+
); diff --git a/app/soapbox/features/filters/edit-filter.tsx b/app/soapbox/features/filters/edit-filter.tsx index 24c3bdfe4b..2071953871 100644 --- a/app/soapbox/features/filters/edit-filter.tsx +++ b/app/soapbox/features/filters/edit-filter.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; @@ -10,11 +10,15 @@ 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 { @@ -28,7 +32,6 @@ const messages = defineMessages({ 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' }, @@ -43,18 +46,15 @@ const messages = defineMessages({ 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 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(); @@ -95,18 +95,28 @@ const EditFilter: React.FC = ({ params }) => { const [notFound, setNotFound] = useState(false); const [title, setTitle] = useState(''); - const [expiresAt] = 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<{ id?: string, keyword: string, whole_word: boolean }[]>([{ keyword: '', whole_word: false }]); + const [keywords, setKeywords] = useState([{ keyword: '', whole_word: false }]); - // const handleSelectChange = e => { - // this.setState({ [e.target.name]: e.target.value }); - // }; + 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(); @@ -129,8 +139,8 @@ const EditFilter: React.FC = ({ params }) => { } dispatch(params.id - ? updateFilter(params.id, title, expiresAt, context, hide, keywords) - : createFilter(title, expiresAt, context, hide, keywords)).then(() => { + ? 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)); @@ -141,7 +151,9 @@ const EditFilter: React.FC = ({ params }) => { const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]); - const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords.filter((_, index) => index !== i)); + 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) { @@ -180,13 +192,16 @@ const EditFilter: React.FC = ({ params }) => { onChange={({ target }) => setTitle(target.value)} />
- {/* - - */} + + {features.filtersExpiration && ( + + + + )} diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index d1cbe1f3be..6b09eb515f 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -4,7 +4,7 @@ import { useHistory } from 'react-router-dom'; import { fetchFilters, deleteFilter } from 'soapbox/actions/filters'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, CardTitle, Column, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; +import { Button, CardTitle, Column, HStack, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import toast from 'soapbox/toast'; @@ -31,6 +31,7 @@ const messages = defineMessages({ 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' }, + edit: { id: 'column.filters.edit', defaultMessage: 'Edit' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); @@ -42,16 +43,6 @@ const contexts = { account: messages.accounts, }; -// const expirations = { -// null: 'Never', -// // 1800: '30 minutes', -// // 3600: '1 hour', -// // 21600: '6 hour', -// // 43200: '12 hours', -// // 86400 : '1 day', -// // 604800: '1 week', -// }; - const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -94,44 +85,42 @@ const Filters = () => { itemClassName='pb-4 last:pb-0' > {filters.map((filter, i) => ( - - - - - {' '} - {filter.keywords.map(keyword => keyword.keyword).join(', ')} - - - - {' '} - {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(', ')} + + + {/* + {filter.irreversible ? + : + } + + {filter.whole_word && ( + + + + )} */} + + + + + - - - +
))} diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index c818ccaabf..c0ae18d276 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, @@ -450,6 +450,12 @@ const getInstanceFeatures = (instance: Instance) => { 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/} @@ -458,7 +464,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * 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'), @@ -529,7 +535,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([ @@ -644,7 +650,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 +742,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'), From 266dd3d1109176e42df6dd8d8ccb035f824a0cab Mon Sep 17 00:00:00 2001 From: Soapbox Bot Date: Mon, 6 Mar 2023 05:10:38 +0000 Subject: [PATCH 07/14] Update dependency eslint-plugin-jsdoc to v40 --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 721aa6dc3a..ea8b21b3d5 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "eslint": "^8.0.0", "eslint-plugin-compat": "^4.0.2", "eslint-plugin-import": "^2.25.4", - "eslint-plugin-jsdoc": "^39.6.2", + "eslint-plugin-jsdoc": "^40.0.0", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-promise": "^6.0.0", "eslint-plugin-react": "^7.25.1", diff --git a/yarn.lock b/yarn.lock index c596e6f57f..59ee35bc5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1703,10 +1703,10 @@ resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.1.2.tgz#777c976f8f143df47cbb23a7077c9ca9fe5fc513" integrity sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg== -"@es-joy/jsdoccomment@~0.36.0": - version "0.36.0" - resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.36.0.tgz#e3898aad334281a10ceb3c0ec406297a79f2b043" - integrity sha512-u0XZyvUF6Urb2cSivSXA8qXIpT/CxkHcdtZKoWusAzgzmsTWpg0F2FpWXsolHmMUyVY3dLWaoy+0ccJ5uf2QjA== +"@es-joy/jsdoccomment@~0.36.1": + version "0.36.1" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz#c37db40da36e4b848da5fd427a74bae3b004a30f" + integrity sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg== dependencies: comment-parser "1.3.1" esquery "^1.4.0" @@ -8350,12 +8350,12 @@ eslint-plugin-import@^2.25.4: resolve "^1.20.0" tsconfig-paths "^3.12.0" -eslint-plugin-jsdoc@^39.6.2: - version "39.6.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.2.tgz#dcc86cec7cce47aa1a646e38debd5bdf76f63742" - integrity sha512-dvgY/W7eUFoAIIiaWHERIMI61ZWqcz9YFjEeyTzdPlrZc3TY/3aZm5aB91NUoTLWYZmO/vFlYSuQi15tF7uE5A== +eslint-plugin-jsdoc@^40.0.0: + version "40.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-40.0.1.tgz#5f028b4928d5c77f54bfd3c42c00acb61d27bb9f" + integrity sha512-KkiRInury7YrjjV5aCHDxwsPy6XFt5p2b2CnpDMITnWs8patNPf5kj24+VXIWw45kP6z/B0GOKfrYczB56OjQQ== dependencies: - "@es-joy/jsdoccomment" "~0.36.0" + "@es-joy/jsdoccomment" "~0.36.1" comment-parser "1.3.1" debug "^4.3.4" escape-string-regexp "^4.0.0" From d969c91c76db18698f0575b9fc0b1f176a98518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 7 Mar 2023 23:59:37 +0100 Subject: [PATCH 08/14] Update filter preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 1 + app/soapbox/features/filters/edit-filter.tsx | 3 -- app/soapbox/features/filters/index.tsx | 52 +++++++++----------- app/soapbox/locales/en.json | 20 ++++++-- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ac41b44a..5220eab0c9 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 diff --git a/app/soapbox/features/filters/edit-filter.tsx b/app/soapbox/features/filters/edit-filter.tsx index 2071953871..4d035f8d32 100644 --- a/app/soapbox/features/filters/edit-filter.tsx +++ b/app/soapbox/features/filters/edit-filter.tsx @@ -27,7 +27,6 @@ interface IEditFilter { 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' }, @@ -41,8 +40,6 @@ const messages = defineMessages({ 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' }, diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index 6b09eb515f..46eba410bc 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -3,34 +3,20 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { fetchFilters, deleteFilter } from 'soapbox/actions/filters'; +import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, CardTitle, Column, HStack, Stack, Text } 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' }, - 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' }, - 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' }, edit: { id: 'column.filters.edit', defaultMessage: 'Edit' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); @@ -47,6 +33,7 @@ const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); + const { filtersV2 } = useFeatures(); const filters = useAppSelector((state) => state.filters); @@ -68,8 +55,7 @@ const Filters = () => { return ( - - +