diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ac41b44a..afca6bb714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Posts: Support posts filtering on recent Mastodon versions ### Changed +- Posts: truncate Nostr pubkeys in reply mentions. ### Fixed - Posts: fixed emojis being cut off in reactions modal. diff --git a/README.md b/README.md index 2504de2788..f0a74b88f0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ One disadvantage of this approach is that it does not help the software spread. © Alex Gleason & other Soapbox contributors © Eugen Rochko & other Mastodon contributors © Trump Media & Technology Group -© Gab AI, Inc. +© Gab AI, Inc. Soapbox is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by diff --git a/app/assets/icons/COPYING.md b/app/assets/icons/COPYING.md index 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..ee3508682d 100644 --- a/app/soapbox/actions/filters.ts +++ b/app/soapbox/actions/filters.ts @@ -12,10 +12,18 @@ const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; +const FILTER_FETCH_REQUEST = 'FILTER_FETCH_REQUEST'; +const FILTER_FETCH_SUCCESS = 'FILTER_FETCH_SUCCESS'; +const FILTER_FETCH_FAIL = 'FILTER_FETCH_FAIL'; + const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; +const FILTERS_UPDATE_REQUEST = 'FILTERS_UPDATE_REQUEST'; +const FILTERS_UPDATE_SUCCESS = 'FILTERS_UPDATE_SUCCESS'; +const FILTERS_UPDATE_FAIL = 'FILTERS_UPDATE_FAIL'; + const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; @@ -25,22 +33,16 @@ const messages = defineMessages({ removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, }); -const fetchFilters = () => +type FilterKeywords = { keyword: string, whole_word: boolean }[]; + +const fetchFiltersV1 = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const state = getState(); - const instance = state.instance; - const features = getFeatures(instance); - - if (!features.filters) return; - dispatch({ type: FILTERS_FETCH_REQUEST, skipLoading: true, }); - api(getState) + return api(getState) .get('/api/v1/filters') .then(({ data }) => dispatch({ type: FILTERS_FETCH_SUCCESS, @@ -55,15 +57,105 @@ const fetchFilters = () => })); }; -const createFilter = (phrase: string, expires_at: string, context: Array, whole_word: boolean, irreversible: boolean) => +const fetchFiltersV2 = () => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTERS_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get('/api/v2/filters') + .then(({ data }) => dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTERS_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilters = (fromFiltersPage = false) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2 && fromFiltersPage) return dispatch(fetchFiltersV2()); + + if (features.filters) return dispatch(fetchFiltersV1()); + }; + +const fetchFilterV1 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v1/filters/${id}`) + .then(({ data }) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ + type: FILTER_FETCH_REQUEST, + skipLoading: true, + }); + + return api(getState) + .get(`/api/v2/filters/${id}`) + .then(({ data }) => dispatch({ + type: FILTER_FETCH_SUCCESS, + filter: data, + skipLoading: true, + })) + .catch(err => dispatch({ + type: FILTER_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + })); + }; + +const fetchFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(fetchFilterV2(id)); + + if (features.filters) return dispatch(fetchFilterV1(id)); + }; + +const createFilterV1 = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_CREATE_REQUEST }); return api(getState).post('/api/v1/filters', { - phrase, + phrase: keywords[0].keyword, context, - irreversible, - whole_word, - expires_at, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_in, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); toast.success(messages.added); @@ -72,7 +164,80 @@ const createFilter = (phrase: string, expires_at: string, context: Array }); }; -const deleteFilter = (id: string) => +const createFilterV2 = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_CREATE_REQUEST }); + return api(getState).post('/api/v2/filters', { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_in, + keywords_attributes, + }).then(response => { + dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_CREATE_FAIL, error }); + }); + }; + +const createFilter = (title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(createFilterV2(title, expires_in, context, hide, keywords)); + + return dispatch(createFilterV1(title, expires_in, context, hide, keywords)); + }; + +const updateFilterV1 = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v1/filters/${id}`, { + phrase: keywords[0].keyword, + context, + irreversible: hide, + whole_word: keywords[0].whole_word, + expires_in, + }).then(response => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilterV2 = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords_attributes: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_UPDATE_REQUEST }); + return api(getState).patch(`/api/v2/filters/${id}`, { + title, + context, + filter_action: hide ? 'hide' : 'warn', + expires_in, + keywords_attributes, + }).then(response => { + dispatch({ type: FILTERS_UPDATE_SUCCESS, filter: response.data }); + toast.success(messages.added); + }).catch(error => { + dispatch({ type: FILTERS_UPDATE_FAIL, error }); + }); + }; + +const updateFilter = (id: string, title: string, expires_in: string | null, context: Array, hide: boolean, keywords: FilterKeywords) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(updateFilterV2(id, title, expires_in, context, hide, keywords)); + + return dispatch(updateFilterV1(id, title, expires_in, context, hide, keywords)); + }; + +const deleteFilterV1 = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete(`/api/v1/filters/${id}`).then(response => { @@ -83,17 +248,47 @@ const deleteFilter = (id: string) => }); }; +const deleteFilterV2 = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete(`/api/v2/filters/${id}`).then(response => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); + toast.success(messages.removed); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; + +const deleteFilter = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (features.filtersV2) return dispatch(deleteFilterV2(id)); + + return dispatch(deleteFilterV1(id)); + }; + export { FILTERS_FETCH_REQUEST, FILTERS_FETCH_SUCCESS, FILTERS_FETCH_FAIL, + FILTER_FETCH_REQUEST, + FILTER_FETCH_SUCCESS, + FILTER_FETCH_FAIL, FILTERS_CREATE_REQUEST, FILTERS_CREATE_SUCCESS, FILTERS_CREATE_FAIL, + FILTERS_UPDATE_REQUEST, + FILTERS_UPDATE_SUCCESS, + FILTERS_UPDATE_FAIL, FILTERS_DELETE_REQUEST, FILTERS_DELETE_SUCCESS, FILTERS_DELETE_FAIL, fetchFilters, + fetchFilter, createFilter, + updateFilter, deleteFilter, -}; \ No newline at end of file +}; diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 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/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/components/sidebar-menu.tsx b/app/soapbox/components/sidebar-menu.tsx index 0c68b2aff9..aad2d7f6be 100644 --- a/app/soapbox/components/sidebar-menu.tsx +++ b/app/soapbox/components/sidebar-menu.tsx @@ -297,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( = ({ status, hoverable className='reply-mentions__account' onClick={(e) => e.stopPropagation()} > - @{account.username} + @{isPubkey(account.username) ? account.username.slice(0, 8) : account.username} ); 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/components/ui/streamfield/streamfield.tsx b/app/soapbox/components/ui/streamfield/streamfield.tsx index 49658099a2..5c436e70b2 100644 --- a/app/soapbox/components/ui/streamfield/streamfield.tsx +++ b/app/soapbox/components/ui/streamfield/streamfield.tsx @@ -33,6 +33,8 @@ interface IStreamfield { onChange: (values: any[]) => void /** Input to render for each value. */ component: StreamfieldComponent + /** Minimum number of allowed inputs. */ + minItems?: number /** Maximum number of allowed inputs. */ maxItems?: number } @@ -47,6 +49,7 @@ const Streamfield: React.FC = ({ onChange, component: Component, maxItems = Infinity, + minItems = 0, }) => { const intl = useIntl(); @@ -67,10 +70,10 @@ const Streamfield: React.FC = ({ {(values.length > 0) && ( - {values.map((value, i) => ( + {values.map((value, i) => value?._destroy ? null : ( - {onRemoveItem && ( + {values.length > minItems && onRemoveItem && ( { let loadOlder = null; if (hasMore && !(isLoading && attachments.size === 0)) { - loadOlder = ; + loadOlder = ; } if (unavailable) { 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/compose/components/reply-mentions.tsx b/app/soapbox/features/compose/components/reply-mentions.tsx index baa8f8d049..511950c8e9 100644 --- a/app/soapbox/features/compose/components/reply-mentions.tsx +++ b/app/soapbox/features/compose/components/reply-mentions.tsx @@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals'; import { useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose'; import { makeGetStatus } from 'soapbox/selectors'; +import { isPubkey } from 'soapbox/utils/nostr'; import type { Status as StatusEntity } from 'soapbox/types/entities'; @@ -52,9 +53,14 @@ const ReplyMentions: React.FC = ({ composeId }) => { ); } - const accounts = to.slice(0, 2).map((acct: string) => ( - @{acct.split('@')[0]} - )).toArray(); + const accounts = to.slice(0, 2).map((acct: string) => { + const username = acct.split('@')[0]; + return ( + + @{isPubkey(username) ? username.slice(0, 8) : username} + + ); + }).toArray(); if (to.size > 2) { accounts.push( diff --git a/app/soapbox/features/filters/edit-filter.tsx b/app/soapbox/features/filters/edit-filter.tsx new file mode 100644 index 0000000000..4d035f8d32 --- /dev/null +++ b/app/soapbox/features/filters/edit-filter.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { createFilter, fetchFilter, updateFilter } from 'soapbox/actions/filters'; +import List, { ListItem } from 'soapbox/components/list'; +import MissingIndicator from 'soapbox/components/missing-indicator'; +import { Button, Column, Form, FormActions, FormGroup, HStack, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; +import { normalizeFilter } from 'soapbox/normalizers'; +import toast from 'soapbox/toast'; + +import { SelectDropdown } from '../forms'; + +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield'; + +interface IFilterField { + id?: string + keyword: string + whole_word: boolean + _destroy?: boolean +} + +interface IEditFilter { + params: { id?: string } +} + +const messages = defineMessages({ + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, + title: { id: 'column.filters.title', defaultMessage: 'Title' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' }, + expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, + home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, + public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, + notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, + conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, + accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' }, + drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, + drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, + hide_header: { id: 'column.filters.hide_header', defaultMessage: 'Hide completely' }, + hide_hint: { id: 'column.filters.hide_hint', defaultMessage: 'Completely hide the filtered content, instead of showing a warning' }, + add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, + edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' }, + create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + expiration_never: { id: 'colum.filters.expiration.never', defaultMessage: 'Never' }, + expiration_1800: { id: 'colum.filters.expiration.1800', defaultMessage: '30 minutes' }, + expiration_3600: { id: 'colum.filters.expiration.3600', defaultMessage: '1 hour' }, + expiration_21600: { id: 'colum.filters.expiration.21600', defaultMessage: '6 hours' }, + expiration_43200: { id: 'colum.filters.expiration.43200', defaultMessage: '12 hours' }, + expiration_86400: { id: 'colum.filters.expiration.86400', defaultMessage: '1 day' }, + expiration_604800: { id: 'colum.filters.expiration.604800', defaultMessage: '1 week' }, +}); + +const FilterField: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + + const handleChange = (key: string): React.ChangeEventHandler => + e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] }); + + return ( + + + + + + + + + + + ); +}; + +const EditFilter: React.FC = ({ params }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const [loading, setLoading] = useState(false); + const [notFound, setNotFound] = useState(false); + + const [title, setTitle] = useState(''); + const [expiresIn, setExpiresIn] = useState(null); + const [homeTimeline, setHomeTimeline] = useState(true); + const [publicTimeline, setPublicTimeline] = useState(false); + const [notifications, setNotifications] = useState(false); + const [conversations, setConversations] = useState(false); + const [accounts, setAccounts] = useState(false); + const [hide, setHide] = useState(false); + const [keywords, setKeywords] = useState([{ keyword: '', whole_word: false }]); + + const expirations = useMemo(() => ({ + '': intl.formatMessage(messages.expiration_never), + 1800: intl.formatMessage(messages.expiration_1800), + 3600: intl.formatMessage(messages.expiration_3600), + 21600: intl.formatMessage(messages.expiration_21600), + 43200: intl.formatMessage(messages.expiration_43200), + 86400: intl.formatMessage(messages.expiration_86400), + 604800: intl.formatMessage(messages.expiration_604800), + }), []); + + const handleSelectChange: React.ChangeEventHandler = e => { + setExpiresIn(e.target.value); + }; + + const handleAddNew: React.FormEventHandler = e => { + e.preventDefault(); + const context: Array = []; + + if (homeTimeline) { + context.push('home'); + } + if (publicTimeline) { + context.push('public'); + } + if (notifications) { + context.push('notifications'); + } + if (conversations) { + context.push('thread'); + } + if (accounts) { + context.push('account'); + } + + dispatch(params.id + ? updateFilter(params.id, title, expiresIn, context, hide, keywords) + : createFilter(title, expiresIn, context, hide, keywords)).then(() => { + history.push('/filters'); + }).catch(() => { + toast.error(intl.formatMessage(messages.create_error)); + }); + }; + + const handleChangeKeyword = (keywords: { keyword: string, whole_word: boolean }[]) => setKeywords(keywords); + + const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]); + + const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords[i].id + ? keywords.map((keyword, index) => index === i ? { ...keyword, _destroy: true } : keyword) + : keywords.filter((_, index) => index !== i)); + + useEffect(() => { + if (params.id) { + setLoading(true); + dispatch(fetchFilter(params.id))?.then((res: any) => { + if (res.filter) { + const filter = normalizeFilter(res.filter); + + setTitle(filter.title); + setHomeTimeline(filter.context.includes('home')); + setPublicTimeline(filter.context.includes('public')); + setNotifications(filter.context.includes('notifications')); + setConversations(filter.context.includes('thread')); + setAccounts(filter.context.includes('account')); + setHide(filter.filter_action === 'hide'); + setKeywords(filter.keywords.toJS()); + } else { + setNotFound(true); + } + setLoading(false); + }); + } + }, [params.id]); + + if (notFound) return ; + + return ( + +
+ + setTitle(target.value)} + /> + + + {features.filtersExpiration && ( + + + + )} + + + + + + + + + + + + + setHomeTimeline(target.checked)} + /> + + + setPublicTimeline(target.checked)} + /> + + + setNotifications(target.checked)} + /> + + + setConversations(target.checked)} + /> + + {features.filtersV2 && ( + + setAccounts(target.checked)} + /> + + )} + + + + + setHide(target.checked)} + /> + + + + + + + + + +
+ ); +}; + +export default EditFilter; diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index 20c59ff369..46eba410bc 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -1,31 +1,23 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; -import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; -import List, { ListItem } from 'soapbox/components/list'; +import { fetchFilters, deleteFilter } from 'soapbox/actions/filters'; +import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import toast from 'soapbox/toast'; const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, - subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, - keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, - expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, - expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' }, home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, - drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, - drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, - whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' }, - whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' }, - add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, - create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' }, delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, - subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, + edit: { id: 'column.filters.edit', defaultMessage: 'Edit' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); @@ -34,167 +26,44 @@ const contexts = { public: messages.public_timeline, notifications: messages.notifications, thread: messages.conversations, + account: messages.accounts, }; -// const expirations = { -// null: 'Never', -// // 3600: '30 minutes', -// // 21600: '1 hour', -// // 43200: '12 hours', -// // 86400 : '1 day', -// // 604800: '1 week', -// }; - const Filters = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const history = useHistory(); + const { filtersV2 } = useFeatures(); const filters = useAppSelector((state) => state.filters); - const [phrase, setPhrase] = useState(''); - const [expiresAt] = useState(''); - const [homeTimeline, setHomeTimeline] = useState(true); - const [publicTimeline, setPublicTimeline] = useState(false); - const [notifications, setNotifications] = useState(false); - const [conversations, setConversations] = useState(false); - const [irreversible, setIrreversible] = useState(false); - const [wholeWord, setWholeWord] = useState(true); - - // const handleSelectChange = e => { - // this.setState({ [e.target.name]: e.target.value }); - // }; - - const handleAddNew: React.FormEventHandler = e => { - e.preventDefault(); - const context: Array = []; - - if (homeTimeline) { - context.push('home'); - } - if (publicTimeline) { - context.push('public'); - } - if (notifications) { - context.push('notifications'); - } - if (conversations) { - context.push('thread'); - } - - dispatch(createFilter(phrase, expiresAt, context, wholeWord, irreversible)).then(() => { - return dispatch(fetchFilters()); - }).catch(error => { - toast.error(intl.formatMessage(messages.create_error)); - }); - }; + const handleFilterEdit = (id: string) => () => history.push(`/filters/${id}`); const handleFilterDelete = (id: string) => () => { dispatch(deleteFilter(id)).then(() => { - return dispatch(fetchFilters()); + return dispatch(fetchFilters(true)); }).catch(() => { toast.error(intl.formatMessage(messages.delete_error)); }); }; useEffect(() => { - dispatch(fetchFilters()); + dispatch(fetchFilters(true)); }, []); const emptyMessage = ; return ( - - - -
- - setPhrase(target.value)} - /> - - {/* - - */} - - - - - - - - - - - - - setHomeTimeline(target.checked)} - /> - - - setPublicTimeline(target.checked)} - /> - - - setNotifications(target.checked)} - /> - - - setConversations(target.checked)} - /> - - - - - - setIrreversible(target.checked)} - /> - - - setWholeWord(target.checked)} - /> - - - - - - -
- - - - + + + { itemClassName='pb-4 last:pb-0' > {filters.map((filter, i) => ( - - - - - {' '} - {filter.phrase} - - - - {' '} - {filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')} - - +
+ + - {filter.irreversible ? - : - } + + {' '} + {filter.keywords.map(keyword => keyword.keyword).join(', ')} - {filter.whole_word && ( + + + {' '} + {filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')} + + - + {filtersV2 ? ( + filter.filter_action === 'hide' ? + : + + ) : (filter.filter_action === 'hide' ? + : + )} - )} + {filter.expires_at && ( + + {new Date(filter.expires_at).getTime() <= Date.now() + ? + : } + + )} + + + + + - - +
))}
diff --git a/app/soapbox/features/ui/components/link-footer.tsx b/app/soapbox/features/ui/components/link-footer.tsx index c8dd935884..3772340937 100644 --- a/app/soapbox/features/ui/components/link-footer.tsx +++ b/app/soapbox/features/ui/components/link-footer.tsx @@ -45,7 +45,7 @@ const LinkFooter: React.FC = (): JSX.Element => { )} - {features.filters && ( + {(features.filters || features.filtersV2) && ( )} {features.federating && ( diff --git a/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx b/app/soapbox/features/ui/components/modals/edit-announcement-modal.tsx index 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) && } + {(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 8f55da6bc1..88e55b2beb 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -102,6 +102,10 @@ export function Filters() { return import(/* webpackChunkName: "features/filters" */'../../filters'); } +export function EditFilter() { + return import(/* webpackChunkName: "features/filters" */'../../filters/edit-filter'); +} + export function ReportModal() { return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal'); } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 657ab8d19d..782234a371 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -283,6 +283,13 @@ "chats.main.blankslate_with_chats.subtitle": "Select from one of your open chats or create a new message.", "chats.main.blankslate_with_chats.title": "Select a chat", "chats.search_placeholder": "Start a chat with…", + "colum.filters.expiration.1800": "30 minutes", + "colum.filters.expiration.21600": "6 hours", + "colum.filters.expiration.3600": "1 hour", + "colum.filters.expiration.43200": "12 hours", + "colum.filters.expiration.604800": "1 week", + "colum.filters.expiration.86400": "1 day", + "colum.filters.expiration.never": "Never", "column.admin.announcements": "Announcements", "column.admin.awaiting_approval": "Awaiting Approval", "column.admin.create_announcement": "Create announcement", @@ -321,6 +328,7 @@ "column.favourites": "Likes", "column.federation_restrictions": "Federation Restrictions", "column.filters": "Muted words", + "column.filters.accounts": "Accounts", "column.filters.add_new": "Add New Filter", "column.filters.conversations": "Conversations", "column.filters.create_error": "Error adding filter", @@ -328,16 +336,18 @@ "column.filters.delete_error": "Error deleting filter", "column.filters.drop_header": "Drop instead of hide", "column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed", + "column.filters.edit": "Edit", "column.filters.expires": "Expire after", - "column.filters.expires_hint": "Expiration dates are not currently supported", + "column.filters.hide_header": "Hide completely", + "column.filters.hide_hint": "Completely hide the filtered content, instead of showing a warning", "column.filters.home_timeline": "Home timeline", "column.filters.keyword": "Keyword or phrase", + "column.filters.keywords": "Keywords or phrases", "column.filters.notifications": "Notifications", "column.filters.public_timeline": "Public timeline", "column.filters.subheading_add_new": "Add New Filter", - "column.filters.subheading_filters": "Current Filters", - "column.filters.whole_word_header": "Whole word", - "column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word", + "column.filters.title": "Title", + "column.filters.whole_word": "Whole word", "column.follow_requests": "Follow requests", "column.followers": "Followers", "column.following": "Following", @@ -740,11 +750,14 @@ "filters.added": "Filter added.", "filters.context_header": "Filter contexts", "filters.context_hint": "One or multiple contexts where the filter should apply", + "filters.create_filter": "Create filter", "filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_drop": "Drop", + "filters.filters_list_expired": "Expired", "filters.filters_list_hide": "Hide", - "filters.filters_list_phrase_label": "Keyword or phrase:", - "filters.filters_list_whole-word": "Whole word", + "filters.filters_list_hide_completely": "Hide content", + "filters.filters_list_phrases_label": "Keywords or phrases:", + "filters.filters_list_warn": "Display warning", "filters.removed": "Filter deleted.", "followRecommendations.heading": "Suggested Profiles", "follow_request.authorize": "Authorize", @@ -1402,6 +1415,7 @@ "status.sensitive_warning": "Sensitive content", "status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.", "status.share": "Share", + "status.show_filter_reason": "Show anyway", "status.show_less_all": "Show less for all", "status.show_more_all": "Show more for all", "status.show_original": "Show original", diff --git a/app/soapbox/locales/it.json b/app/soapbox/locales/it.json index 2a851507a2..a47db17e82 100644 --- a/app/soapbox/locales/it.json +++ b/app/soapbox/locales/it.json @@ -620,17 +620,27 @@ "email_verifilcation.exists": "Questo indirizzo email non è disponibile.", "embed.instructions": "Inserisci questo status nel tuo sito copiando il codice qui sotto.", "emoji_button.activity": "Attività", + "emoji_button.add_custom": "Aggiungere emoji personalizzate", "emoji_button.custom": "Personalizzato", "emoji_button.flags": "Bandiere", "emoji_button.food": "Cibo e bevande", "emoji_button.label": "Inserisci emoji", "emoji_button.nature": "Natura", - "emoji_button.not_found": "Niente emoji?! (╯°□°)╯︵ ┻━┻", + "emoji_button.not_found": "Niente emoji", "emoji_button.objects": "Oggetti", + "emoji_button.oh_no": "Oh no!", "emoji_button.people": "Persone", + "emoji_button.pick": "Scegli un'emoji…", "emoji_button.recent": "Usati di frequente", "emoji_button.search": "Cerca…", "emoji_button.search_results": "Risultati della ricerca", + "emoji_button.skins_1": "Predefinito", + "emoji_button.skins_2": "Chiaro", + "emoji_button.skins_3": "Medio-Chiaro", + "emoji_button.skins_4": "Medio", + "emoji_button.skins_5": "Medio-Scuro", + "emoji_button.skins_6": "Scuro", + "emoji_button.skins_choose": "Scegliere il tono della pelle predefinito", "emoji_button.symbols": "Simboli", "emoji_button.travel": "Viaggi e luoghi", "empty_column.account_blocked": "Profilo bloccato da @{accountUsername}.", @@ -668,7 +678,7 @@ "empty_column.public": "Qui non c'è nulla! Per riempire questo spazio puoi scrivere qualcosa pubblicamente o seguire qualche persona da altre istanze", "empty_column.quotes": "Questa pubblicazione non è ancora stata citata.", "empty_column.remote": "Qui non c'è niente! Segui qualche profilo di {instance} per riempire quest'area.", - "empty_column.scheduled_statuses": "Non hai ancora pianificato alcuna pubblicazione, quando succederà, saranno elencate qui", + "empty_column.scheduled_statuses": "Non hai ancora pianificato alcuna pubblicazione, quando succederà, saranno elencate qui.", "empty_column.search.accounts": "Non risulta alcun profilo per \"{term}\"", "empty_column.search.groups": "Nessun risultato di gruppi con \"{term}\"", "empty_column.search.hashtags": "Non risulta alcun hashtag per \"{term}\"", @@ -718,7 +728,7 @@ "federation_restriction.full_media_removal": "Rimozione totale degli allegati", "federation_restriction.media_nsfw": "Nascondi gli allegati come sensibili", "federation_restriction.partial_media_removal": "Rimozione parziale degli allegati", - "federation_restrictions.empty_message": "{siteTitle} non ha limitato alcuna istanza", + "federation_restrictions.empty_message": "{siteTitle} non ha limitato alcuna istanza.", "federation_restrictions.explanation_box.message": "Le istanze del Fediverso normalmente comunicano liberamente. {siteTitle} ha impostato delle restrizioni sulle seguenti istanze:", "federation_restrictions.explanation_box.title": "Policy relative a istanze specifiche", "federation_restrictions.not_disclosed_message": "{siteTitle} non divulga le proprie restrizioni tramite le API.", @@ -727,7 +737,7 @@ "fediverse_tab.explanation_box.title": "Che cos'è il Fediverso?", "feed_suggestions.heading": "Profili in primo piano", "feed_suggestions.view_all": "Di più", - "filters.added": "Aggiunto un nuovo filtro", + "filters.added": "Hai aggiunto il filtro.", "filters.context_header": "Contesto del filtro", "filters.context_hint": "Seleziona uno o più contesti a cui applicare il filtro", "filters.filters_list_context_label": "Contesto del filtro:", @@ -763,7 +773,8 @@ "group.group_mod_unblock": "Sblocca", "group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo", "group.header.alt": "Testata del gruppo", - "group.join.public": "Entra nel gruppo", + "group.join.private": "Richiesta di accesso", + "group.join.public": "Unisciti al gruppo", "group.join.request_success": "Richiesta di partecipazione", "group.join.success": "Partecipazione nel gruppo", "group.leave": "Abbandona il gruppo", @@ -772,12 +783,20 @@ "group.moderator_subheading": "Moderazione del gruppo", "group.privacy.locked": "Privato", "group.privacy.public": "Pubblico", - "group.join.private": "Richiesta di partecipazione", "group.role.admin": "Amministrazione", "group.role.moderator": "Moderazione", "group.tabs.all": "Tutto", "group.tabs.members": "Partecipanti", "group.user_subheading": "Persone", + "groups.discover.search.no_results.subtitle": "Prova a cercare un altro gruppo.", + "groups.discover.search.no_results.title": "Nessun risultato", + "groups.discover.search.placeholder": "Cerca", + "groups.discover.search.recent_searches.blankslate.subtitle": "Cerca nomi di gruppi, argomenti o parole chiave", + "groups.discover.search.recent_searches.blankslate.title": "Nessuna ricerca recente", + "groups.discover.search.recent_searches.clear_all": "Cancella tutto", + "groups.discover.search.recent_searches.title": "Ricerche recenti", + "groups.discover.search.results.groups": "Gruppi", + "groups.discover.search.results.member_count": "{members, plural, one {partecipante} other {partecipanti}}", "groups.empty.subtitle": "Inizia scoprendo a che gruppi partecipare, o creandone uno tuo.", "groups.empty.title": "Ancora nessun gruppo", "hashtag.column_header.tag_mode.all": "e {additional}", @@ -863,7 +882,7 @@ "lists.new.create": "Aggiungi lista", "lists.new.create_title": "Crea nuova lista", "lists.new.save_title": "Salva il titolo", - "lists.new.title_placeholder": "Digita il titolo della nuova lista ...", + "lists.new.title_placeholder": "Titolo della nuova lista", "lists.search": "Digita @nome ... Premi il bottone", "lists.subheading": "Le tue liste", "loading_indicator.label": "Caricamento…", @@ -876,7 +895,7 @@ "login.fields.username_label": "Email o username", "login.log_in": "Accedi", "login.otp_log_in": "Accesso con OTP", - "login.otp_log_in.fail": "Codice sbagliato, per favore riprova", + "login.otp_log_in.fail": "Codice sbagliato, per favore riprova.", "login.reset_password_hint": "Password dimenticata?", "login.sign_in": "Accedi", "login_external.errors.instance_fail": "L'istanza ha restituito un errore.", @@ -903,10 +922,10 @@ "manage_group.submit_success": "Hai creato il gruppo", "manage_group.tagline": "I gruppi ti collegano ad altre persone con interessi in comune.", "manage_group.update": "Aggiorna", - "media_panel.empty_message": "Non ha caricato niente", + "media_panel.empty_message": "Nessun media.", "media_panel.title": "Media", - "mfa.confirm.success_message": "Autenticazione a due fattori, attivata!", - "mfa.disable.success_message": "Autenticazione a due fattori, disattivata!", + "mfa.confirm.success_message": "Hai attivato l'autenticazione a due fattori", + "mfa.disable.success_message": "Hai disattivato l'autenticazione a due fattori", "mfa.disabled": "Disattivo", "mfa.enabled": "Attivo", "mfa.mfa_disable_enter_password": "Digita la password per interrompere il sistema di autenticazione a due fattori.", @@ -944,7 +963,7 @@ "moderation_overlay.show": "Mostrami il contenuto", "moderation_overlay.subtitle": "Questa pubblicazione è stata segnalata per un controllo di moderazione ed è solamente visibile a te. Se credi si tratti di un errore, per favore, contatta gli amministratori.", "moderation_overlay.title": "Pubblicazione sotto controllo", - "mute_modal.auto_expire": "Scadenza automatica", + "mute_modal.auto_expire": "Scadenza automatica?", "mute_modal.duration": "Durata", "mute_modal.hide_notifications": "Nascondere le notifiche da questa persona?", "navbar.login.action": "Accedi", @@ -952,7 +971,7 @@ "navbar.login.password.label": "Password", "navbar.login.username.placeholder": "Email o nome utente", "navigation.chats": "Chat", - "navigation.compose": "Pubblica qualcosa!", + "navigation.compose": "Pubblica qualcosa", "navigation.dashboard": "Cruscotto", "navigation.developers": "Sviluppatori", "navigation.direct_messages": "Messaggi diretti", @@ -1019,43 +1038,43 @@ "notifications.queue_label": "Hai {count, plural, one {una notifica} other {# notifiche}} da leggere", "oauth_consumer.tooltip": "Sign in with {provider}", "oauth_consumers.title": "Altri modi di accedere", - "onboarding.avatar.subtitle": "Scegline una accattivante, o divertente!", + "onboarding.avatar.subtitle": "Scegline una accattivante, o divertente.", "onboarding.avatar.title": "Scegli una immagine emblematica", "onboarding.bio.hint": "Al massimo 500 caratteri", "onboarding.bio.placeholder": "Descrivi al mondo qualcosa su di te…", "onboarding.display_name.label": "Nome visualizzato", "onboarding.display_name.placeholder": "es: Winston Smith", - "onboarding.display_name.subtitle": "Facoltativo, puoi decidere anche successivamente", + "onboarding.display_name.subtitle": "Facoltativo, puoi decidere anche successivamente.", "onboarding.display_name.title": "Scegli un nome da visualizzare", "onboarding.done": "Finito", - "onboarding.error": "Ooops! Un errore inaspettato!. Per favore, riprova, oppure salta il passaggio", + "onboarding.error": "Ooops! Un errore inaspettato! Per favore, riprova, oppure salta il passaggio.", "onboarding.fediverse.its_you": "Ecco il tuo profilo! Le altre persone possono seguirti da altri siti tramite la username completa di dominio che vedi.", "onboarding.fediverse.message": "Il «Fediverso» è un social network mondiale composto da migliaia di piccoli siti indipendenti come questo. Puoi seguire la maggior parte delle persone, aggiungere nuove pubblicazioni, rispondere a quelle altrui, ripubblicarle, reagire con i Like, esse comunicano tramite «{siteTitle}».", "onboarding.fediverse.next": "Avanti", "onboarding.fediverse.other_instances": "Quando esplori le tue «Timeline», fai sempre attenzione alla username altrui. La parte dietro la seconda @ indica il dominio (sito) di provenienza.", - "onboarding.fediverse.title": "{siteTitle} è appena una istanza di tutto il fediverso!", + "onboarding.fediverse.title": "{siteTitle} è solo una delle istanze di tutto il fediverso", "onboarding.fediverse.trailer": "Il «Fediverso» è distribuito, chiunque può avviare il proprio sito, per questo è Resiliente e Open. Se sceglierai cambiare sito, o di avviarne uno nuovo, potrai interagire con le stesse persone continuando il grafo sociale precedente.", - "onboarding.finished.message": "Inizia subito leggendo cosa scrivono le altre persone o pubblicando qualcosa!", - "onboarding.finished.title": "Eccoci finalmente insieme!", + "onboarding.finished.message": "Siamo entusiasti di accoglierti nella nostra community! Inizia premendo il bottone sottostante.", + "onboarding.finished.title": "Eccoci finalmente insieme", "onboarding.header.subtitle": "Verrà mostrata in cima al tuo profilo.", "onboarding.header.title": "Segli l'immagine di testata", "onboarding.next": "Avanti", - "onboarding.note.subtitle": "Facoltativo, puoi modificare in seguito", + "onboarding.note.subtitle": "Facoltativo, potrai modificare in seguito.", "onboarding.note.title": "Scrivi una breve autobiografia", "onboarding.saving": "Salvataggio…", "onboarding.skip": "Salta per ora", - "onboarding.suggestions.subtitle": "Alcuni profili tra i più popolari, che potresti seguire", + "onboarding.suggestions.subtitle": "Alcuni profili tra i più popolari, che potresti seguire.", "onboarding.suggestions.title": "Profili suggeriti", "onboarding.view_feed": "Apri la «Timeline personale»", - "password_reset.confirmation": "Ti abbiamo spedito una email di conferma, verifica per favore", + "password_reset.confirmation": "Ti abbiamo spedito una email di conferma, verifica per favore.", "password_reset.fields.username_placeholder": "Email o username", "password_reset.header": "Reset Password", "password_reset.reset": "Ripristina password", "patron.donate": "Dona", "patron.title": "Obiettivo della raccolta", - "pinned_accounts.title": "{name} consiglia...", - "pinned_statuses.none": "Nulla in cima al profilo", - "poll.choose_multiple": "Seleziona quante opzioni desideri", + "pinned_accounts.title": "{name} consiglia", + "pinned_statuses.none": "Nulla in cima al profilo.", + "poll.choose_multiple": "Seleziona quante opzioni desideri.", "poll.closed": "Chiuso", "poll.non_anonymous": "Sondaggio pubblico", "poll.non_anonymous.label": "Le opzioni per cui hai votato potrebbero essere mostrate su altre istanze", @@ -1090,8 +1109,8 @@ "preferences.fields.theme": "Tema grafico", "preferences.fields.underline_links_label": "Link sempre sottolineati", "preferences.fields.unfollow_modal_label": "Messaggio di conferma prima di smettere di seguire un profilo", - "preferences.hints.demetricator": "Diminuisci l'ansia, nascondendo tutti i conteggi", - "preferences.notifications.advanced": "Show all notification categories", + "preferences.hints.demetricator": "Diminuisci l'ansia, nascondendo tutti i contatori.", + "preferences.notifications.advanced": "Mostra tutti i tipi di notifiche", "preferences.options.content_type_markdown": "Markdown", "preferences.options.content_type_plaintext": "Testo semplice", "preferences.options.privacy_followers_only": "Solo Followers", @@ -1114,7 +1133,7 @@ "reactions.all": "Tutte", "regeneration_indicator.label": "Attendere prego…", "regeneration_indicator.sublabel": "Stiamo preparando il tuo home feed!", - "register_invite.lead": "Completa questo modulo per creare il tuo profilo ed essere dei nostri!", + "register_invite.lead": "Completa questo modulo per creare il tuo profilo.", "register_invite.title": "Hai ricevuto un invito su {siteTitle}, iscriviti!", "registration.acceptance": "Registrandoti, accetti {terms} e {privacy}.", "registration.agreement": "Accetto e autorizzo: {tos} ai sensi del Regolamento Europeo 679/2016 GDPR.", @@ -1126,27 +1145,27 @@ "registration.fields.confirm_placeholder": "Password (ripetila per verifica)", "registration.fields.email_placeholder": "Indirizzo email", "registration.fields.password_placeholder": "Password", - "registration.fields.username_hint": "Puoi usare solo lettere, cifre e _ (trattino basso)", + "registration.fields.username_hint": "Puoi usare solo lettere, cifre e _ (trattino basso).", "registration.fields.username_placeholder": "Nome utente", "registration.header": "Crea un nuovo profilo", - "registration.newsletter": "Autorizzo il trattamento dei dati per ricevere comunicazioni dagli amministratori via email", - "registration.password_mismatch": "Le password non coincidono", + "registration.newsletter": "Autorizzo il trattamento dei dati per ricevere comunicazioni dagli amministratori via email.", + "registration.password_mismatch": "Le password non coincidono.", "registration.privacy": "Privacy Policy", "registration.reason": "Perché vuoi iscriverti?", "registration.reason_hint": "Motivazione per l'iscrizione", "registration.sign_up": "Iscriviti", "registration.tos": "Termini di servizio e informativa sul trattamento dei dati personali", - "registration.username_unavailable": "Questo nome utente non è disponibile", + "registration.username_unavailable": "Questo nome utente non è disponibile.", "registration.validation.capital_letter": "Almeno una lettera maiuscola", "registration.validation.lowercase_letter": "Almeno una lettera minuscola", "registration.validation.minimum_characters": "8 caratteri", "registrations.create_account": "Crea un nuovo profilo", - "registrations.error": "Impossibile registrare il profilo", + "registrations.error": "Impossibile registrare il profilo.", "registrations.get_started": "Iniziamo!", "registrations.password.label": "Password", "registrations.success": "Eccoci su {siteTitle}!", "registrations.tagline": "Social Media Senza Discriminazioni", - "registrations.unprocessable_entity": "Questo nome utente è già stato scelto", + "registrations.unprocessable_entity": "Questo nome utente è già stato scelto.", "registrations.username.hint": "Solamente caratteri alfanumerici e _ (trattino basso)", "registrations.username.label": "Nome utente", "relative_time.days": "{number, plural, one {# giorno} other {# gg}}", @@ -1194,14 +1213,14 @@ "report.forward": "Inoltra a {target}", "report.forward_hint": "Questo account appartiene a un altro server. Mandare anche là una copia anonima del rapporto?", "report.next": "Avanti", - "report.otherActions.addAdditional": "Seleziona altre pubblicazioni da includere a questa segnalazione", + "report.otherActions.addAdditional": "Vuoi includere altre pubblicazioni in questa segnalazione?", "report.otherActions.addMore": "Aggiungi", "report.otherActions.furtherActions": "Azioni ulteriori:", "report.otherActions.hideAdditional": "Annulla selezione", - "report.otherActions.otherStatuses": "Includi altre pubblicazioni", + "report.otherActions.otherStatuses": "Includere altre pubblicazioni?", "report.placeholder": "Descrivi dettagliatamente le tue motivazioni", "report.previous": "Indietro", - "report.reason.blankslate": "Hai deselezionato tutte le pubblicazioni", + "report.reason.blankslate": "Hai deselezionato tutte le pubblicazioni.", "report.reason.title": "Motivo della segnalazione", "report.submit": "Invia", "report.target": "Segnalazione per {target}", @@ -1222,10 +1241,10 @@ "search_results.groups": "Gruppi", "search_results.hashtags": "Hashtag", "search_results.statuses": "Pubblicazioni", - "security.codes.fail": "Impossibile ottenere i codici di backup.", + "security.codes.fail": "Impossibile ottenere i codici di backup", "security.confirm.fail": "Codice o password sbagliati. Riprova.", - "security.delete_account.fail": "Eliminazione fallita", - "security.delete_account.success": "Eliminazione avvenuta", + "security.delete_account.fail": "Eliminazione profilo, fallita.", + "security.delete_account.success": "Eliminazione profilo, avvenuta.", "security.disable.fail": "Password sbagliata. Riprova.", "security.fields.email.label": "Indirizzo email", "security.fields.new_password.label": "Nuova password", @@ -1240,10 +1259,10 @@ "security.text.delete": "Attenzione: si tratta di una eliminazione permanente e irreversibile. Per eliminare e distruggere il proprio profilo su questo sito, occorre digitare la password e cliccare il bottone «Elimina il mio profilo». Successivamente, verrà inoltrata la richiesta di eliminazione alle altre istanze federate, non possiamo garantirti che tutte le altre istanze siano in grado di procedere alla eliminazione completa.", "security.text.delete.local": "Per eliminare il profilo, digita la password e clicca «Elimina profilo». Si tratta di una eliminazione permanente, irreversibile.", "security.tokens.revoke": "Revoca", - "security.update_email.fail": "Aggiornamento email, fallito", - "security.update_email.success": "Aggiornamento email, confermato", - "security.update_password.fail": "Aggiornamento password, fallito", - "security.update_password.success": "Aggiornamento password, confermato", + "security.update_email.fail": "Aggiornamento email, fallito.", + "security.update_email.success": "Aggiornamento email, avvenuto.", + "security.update_password.fail": "Aggiornamento password, fallito.", + "security.update_password.success": "Aggiornamento password, avvenuto.", "settings.account_migration": "Migrazione profilo", "settings.change_email": "Cambia email", "settings.change_password": "Cambia Password", @@ -1259,7 +1278,7 @@ "settings.sessions": "Sessioni attive", "settings.settings": "Impostazioni", "shared.tos": "Regolamento", - "signup_panel.subtitle": "Iscriviti subito per conversare su quel che succede!", + "signup_panel.subtitle": "Iscriviti subito per conversare su quel che sta succedendo.", "signup_panel.title": "Eccoci su {site_title}", "site_preview.preview": "Anteprima", "sms_verification.expired": "Il token SMS è scaduto.", @@ -1285,9 +1304,9 @@ "soapbox_config.crypto_address.meta_fields.note_placeholder": "Nota (facoltativo)", "soapbox_config.crypto_address.meta_fields.ticker_placeholder": "Valuta", "soapbox_config.crypto_donate_panel_limit.meta_fields.limit_placeholder": "Numero di elementi da mostrare in homepage, nel riquadro", - "soapbox_config.cta_label": "Mostra un riquadro promozionale ai visitatori non autenticati.", + "soapbox_config.cta_label": "Mostra un riquadro promozionale ai visitatori non autenticati", "soapbox_config.custom_css.meta_fields.url_placeholder": "URL", - "soapbox_config.display_fqn_label": "Mostra il dominio (es: @profilo@dominio) dei profili locali", + "soapbox_config.display_fqn_label": "Mostra il dominio (es: @profilo@dominio) dei profili locali.", "soapbox_config.feed_injection_hint": "Inserisci nelle Timeline informazioni aggiuntive, ad esempio, i profili suggeriti.", "soapbox_config.feed_injection_label": "Informazioni aggiuntive", "soapbox_config.fields.crypto_addresses_label": "Indirizzi di wallet", @@ -1296,16 +1315,16 @@ "soapbox_config.fields.logo_label": "Logo", "soapbox_config.fields.promo_panel_fields_label": "Elementi nel pannello promozionale", "soapbox_config.fields.theme_label": "Tema predefinito", - "soapbox_config.greentext_label": "Enable greentext support", + "soapbox_config.greentext_label": "Abilita il sistema greentext", "soapbox_config.headings.advanced": "Avanzata", "soapbox_config.headings.cryptocurrency": "Cripto valuta", "soapbox_config.headings.events": "Eventi", "soapbox_config.headings.navigation": "Navigazione", "soapbox_config.headings.options": "Opzioni", "soapbox_config.headings.theme": "Tema grafico", - "soapbox_config.hints.crypto_addresses": "Aggiungi indirizzi di wallet per criptovalute, così le persone iscritte potranno donare. Inseriscili in minuscolo e nell'ordine preferito. (es: btc)", + "soapbox_config.hints.crypto_addresses": "Aggiungi indirizzi di wallet per criptovalute, così le persone iscritte potranno donare. Inseriscili in minuscolo e nell'ordine preferito (es: btc).", "soapbox_config.hints.home_footer_fields": "Puoi personalizzare i link mostrati nel fondo delle pagine statiche", - "soapbox_config.hints.logo": "Formato SVG al massimo di 2 MB. Verrà mostrato alto 50 pixel, mantenendo le proporzioni.", + "soapbox_config.hints.logo": "Formato SVG al massimo di 2 MB. Verrà mostrato alto 50 pixel, mantenendo le proporzioni", "soapbox_config.hints.promo_panel_fields": "Puoi mettere dei link personalizzati, verranno mostrati nella colonna destra delle pagine con Timeline.", "soapbox_config.hints.promo_panel_icons.link": "Soapbox Icons List", "soapbox_config.home_footer.meta_fields.label_placeholder": "Etichetta", @@ -1333,7 +1352,7 @@ "status.approval.pending": "Richieste in attesa", "status.approval.rejected": "Rifiutata", "status.bookmark": "Aggiungi segnalibro", - "status.bookmarked": "Segnalibro aggiunto!", + "status.bookmarked": "Segnalibro aggiunto.", "status.cancel_reblog_private": "Annulla condivisione", "status.cannot_reblog": "Questa pubblicazione non può essere condivisa", "status.chat": "Chatta con @{name}", @@ -1351,7 +1370,7 @@ "status.group_mod_block": "Blocca @{name} dal gruppo", "status.group_mod_delete": "Elimina pubblicazione dal gruppo", "status.group_mod_kick": "Espelli @{name} dal gruppo", - "status.interactions.favourites": "{count, plural, one {Like} other {Likes}}", + "status.interactions.favourites": "{count} Like", "status.interactions.quotes": "{count, plural, one {Citazione} other {Citazioni}}", "status.interactions.reblogs": "{count, plural, one {Condivisione} other {Condivisioni}}", "status.load_more": "Mostra di più", @@ -1430,7 +1449,7 @@ "theme_toggle.light": "Giorno", "theme_toggle.system": "Dal sistema", "thread_login.login": "Accedi", - "thread_login.message": "Accedi a {siteTitle} per ottenere più informazioni", + "thread_login.message": "Accedi a {siteTitle} per ottenere più informazioni.", "thread_login.signup": "Iscriviti", "thread_login.title": "Continua la conversazione", "time_remaining.days": "ancora {number, plural, one {# giorno} other {# giorni}}", @@ -1442,13 +1461,13 @@ "trends.count_by_accounts": "{count} {rawCount, plural, one {persona ne sta} other {persone ne stanno}} parlando", "trends.title": "Tendenze", "trendsPanel.viewAll": "Di più", - "unauthorized_modal.text": "Per fare questo, devi prima autenticarti", + "unauthorized_modal.text": "Per fare questo, devi prima autenticarti.", "unauthorized_modal.title": "Iscriviti su {site_title}", "upload_area.title": "Trascina per caricare", "upload_button.label": "Aggiungi allegati", "upload_error.image_size_limit": "L'immagine eccede il limite di dimensioni ({limit})", - "upload_error.limit": "Hai superato il limite di quanti file puoi caricare", - "upload_error.poll": "Caricamento file non consentito nei sondaggi", + "upload_error.limit": "Hai superato il limite di quanti file puoi caricare.", + "upload_error.poll": "Caricamento file non consentito nei sondaggi.", "upload_error.video_duration_limit": "Il video eccede la durata limite (di {limit, plural, one {# secondo} other {# secondi}})", "upload_error.video_size_limit": "Il video eccede il limite di dimensioni ({limit})", "upload_form.description": "Descrizione a persone potratrici di disabilità visive", @@ -1466,6 +1485,6 @@ "video.play": "Avvia", "video.unmute": "Riattiva sonoro", "waitlist.actions.verify_number": "Verifica numero telefonico", - "waitlist.body": "Eccoti di nuovo su {title}! In precedenza hai ricevuto un inserimento in lista d'attesa. Verifica il tuo numero telefonico per ottenere nuovamente accesso al tuo account.", + "waitlist.body": "Eccoti di nuovo su {title}! In precedenza hai ricevuto un inserimento in lista d'attesa. Verifica il tuo numero telefonico per ottenere nuovamente accesso al tuo profilo!", "who_to_follow.title": "Chi potresti seguire" } diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 43c3c94004..32ae6d0498 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -1271,7 +1271,7 @@ "status.embed": "Osadź", "status.external": "View post on {domain}", "status.favourite": "Zareaguj", - "status.filtered": "Filtrowany(-a)", + "status.filtered": "Filtrowany", "status.interactions.favourites": "{count, plural, one {Polubienie} few {Polubienia} other {Polubień}}", "status.interactions.reblogs": "{count, plural, one {Podanie dalej} few {Podania dalej} other {Podań dalej}}", "status.load_more": "Załaduj więcej", diff --git a/app/soapbox/normalizers/filter-keyword.ts b/app/soapbox/normalizers/filter-keyword.ts new file mode 100644 index 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.ts b/app/soapbox/normalizers/filter.ts index 5537acac9d..d3eaab237e 100644 --- a/app/soapbox/normalizers/filter.ts +++ b/app/soapbox/normalizers/filter.ts @@ -5,20 +5,49 @@ */ import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; -export type ContextType = 'home' | 'public' | 'notifications' | 'thread'; +import { FilterKeyword, FilterStatus } from 'soapbox/types/entities'; + +import { normalizeFilterKeyword } from './filter-keyword'; +import { normalizeFilterStatus } from './filter-status'; + +export type ContextType = 'home' | 'public' | 'notifications' | 'thread' | 'account'; +export type FilterActionType = 'warn' | 'hide'; // https://docs.joinmastodon.org/entities/filter/ export const FilterRecord = ImmutableRecord({ id: '', - phrase: '', + title: '', context: ImmutableList(), - whole_word: false, expires_at: '', - irreversible: false, + filter_action: 'warn' as FilterActionType, + keywords: ImmutableList(), + statuses: ImmutableList(), }); -export const normalizeFilter = (filter: Record) => { - return FilterRecord( - ImmutableMap(fromJS(filter)), +const normalizeFilterV1 = (filter: ImmutableMap) => + filter + .set('title', filter.get('phrase')) + .set('keywords', ImmutableList([ImmutableMap({ + keyword: filter.get('phrase'), + whole_word: filter.get('whole_word'), + })])) + .set('filter_action', filter.get('irreversible') ? 'hide' : 'warn'); + +const normalizeKeywords = (filter: ImmutableMap) => + filter.update('keywords', ImmutableList(), keywords => + keywords.map(normalizeFilterKeyword), + ); + +const normalizeStatuses = (filter: ImmutableMap) => + filter.update('statuses', ImmutableList(), statuses => + statuses.map(normalizeFilterStatus), + ); + +export const normalizeFilter = (filter: Record) => + FilterRecord( + ImmutableMap(fromJS(filter)).withMutations(filter => { + if (filter.has('phrase')) normalizeFilterV1(filter); + normalizeKeywords(filter); + normalizeStatuses(filter); + }), ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 5b05a0e214..66daaae27c 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -10,6 +10,8 @@ export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiReactionRecord } from './emoji-reaction'; export { FilterRecord, normalizeFilter } from './filter'; +export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword'; +export { FilterStatusRecord, normalizeFilterStatus } from './filter-status'; export { GroupRecord, normalizeGroup } from './group'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; export { HistoryRecord, normalizeHistory } from './history'; diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 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/conversations.ts b/app/soapbox/reducers/conversations.ts index 57c9074484..9a1ba07aa7 100644 --- a/app/soapbox/reducers/conversations.ts +++ b/app/soapbox/reducers/conversations.ts @@ -9,7 +9,7 @@ import { CONVERSATIONS_UPDATE, CONVERSATIONS_READ, } from '../actions/conversations'; -import { compareId } from '../utils/comparators'; +import { compareDate } from '../utils/comparators'; import type { AnyAction } from 'redux'; import type { APIEntity } from 'soapbox/types/entities'; @@ -19,7 +19,7 @@ const ConversationRecord = ImmutableRecord({ unread: false, accounts: ImmutableList(), last_status: null as string | null, - + last_status_created_at: null as string | null, }); const ReducerRecord = ImmutableRecord({ @@ -37,6 +37,7 @@ const conversationToMap = (item: APIEntity) => ConversationRecord({ unread: item.unread, accounts: ImmutableList(item.accounts.map((a: APIEntity) => a.id)), last_status: item.last_status ? item.last_status.id : null, + last_status_created_at: item.last_status ? item.last_status.created_at : null, }); const updateConversation = (state: State, item: APIEntity) => state.update('items', list => { @@ -71,12 +72,12 @@ const expandNormalizedConversations = (state: State, conversations: APIEntity[], list = list.concat(items); - return list.sortBy(x => x.get('last_status'), (a, b) => { + return list.sortBy(x => x.get('last_status_created_at'), (a, b) => { if (a === null || b === null) { return -1; } - return compareId(a, b) * -1; + return compareDate(a, b); }); }); } diff --git a/app/soapbox/reducers/filters.ts b/app/soapbox/reducers/filters.ts index a31cb3295e..8520b57a41 100644 --- a/app/soapbox/reducers/filters.ts +++ b/app/soapbox/reducers/filters.ts @@ -9,11 +9,10 @@ import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities'; type State = ImmutableList; -const importFilters = (_state: State, filters: APIEntity[]): State => { - return ImmutableList(filters.map((filter) => normalizeFilter(filter))); -}; +const importFilters = (_state: State, filters: APIEntity[]): State => + ImmutableList(filters.map((filter) => normalizeFilter(filter))); -export default function filters(state: State = ImmutableList(), action: AnyAction): State { +export default function filters(state: State = ImmutableList(), action: AnyAction): State { switch (action.type) { case FILTERS_FETCH_SUCCESS: return importFilters(state, action.filters); diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 58d2a8b57c..66b34b7d70 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..12cb4df085 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -10,6 +10,7 @@ import { getSettings } from 'soapbox/actions/settings'; import { getDomain } from 'soapbox/utils/accounts'; import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config-db'; +import { getFeatures } from 'soapbox/utils/features'; import { shouldFilter } from 'soapbox/utils/timelines'; import type { ContextType } from 'soapbox/normalizers/filter'; @@ -117,23 +118,63 @@ const escapeRegExp = (string: string) => export const regexFromFilters = (filters: ImmutableList) => { if (filters.size === 0) return null; - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); + return new RegExp(filters.map(filter => + filter.keywords.map(keyword => { + let expr = escapeRegExp(keyword.keyword); - if (filter.get('whole_word')) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } } - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }).join('|'), 'i'); + return expr; + }).join('|'), + ).join('|'), 'i'); }; +const checkFiltered = (index: string, filters: ImmutableList) => + filters.reduce((result, filter) => + result.concat(filter.keywords.reduce((result, keyword) => { + let expr = escapeRegExp(keyword.keyword); + + if (keyword.whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + const regex = new RegExp(expr); + + if (regex.test(index)) return result.concat(filter.title); + return result; + }, ImmutableList())), ImmutableList()); +// const results = +// let expr = escapeRegExp(filter.phrase); + +// if (filter.whole_word) { +// if (/^[\w]/.test(expr)) { +// expr = `\\b${expr}`; +// } + +// if (/[\w]$/.test(expr)) { +// expr = `${expr}\\b`; +// } +// } + +// const regex = new RegExp(expr); + +// if (regex.test(index)) return result.join(filter.phrase); +// return result; + type APIStatus = { id: string, username?: string }; export const makeGetStatus = () => { @@ -147,9 +188,10 @@ export const makeGetStatus = () => { (_state: RootState, { username }: APIStatus) => username, getFilters, (state: RootState) => state.me, + (state: RootState) => getFeatures(state.instance), ], - (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me) => { + (statusBase, statusReblog, accountBase, accountReblog, group, username, filters, me, features) => { if (!statusBase || !accountBase) return null; const accountUsername = accountBase.acct; @@ -165,16 +207,18 @@ export const makeGetStatus = () => { statusReblog = undefined; } - const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters); - const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index); - return statusBase.withMutations(map => { map.set('reblog', statusReblog || null); // @ts-ignore :( map.set('account', accountBase || null); // @ts-ignore map.set('group', group || null); - map.set('filtered', Boolean(filtered)); + + if ((features.filters || features.filtersV2) && (accountReblog || accountBase).id !== me) { + const filtered = checkFiltered(statusReblog?.search_index || statusBase.search_index, filters); + + map.set('filtered', filtered); + } }); }, ); diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index ed43df4079..bf717e8056 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -12,6 +12,8 @@ import { EmojiReactionRecord, FieldRecord, FilterRecord, + FilterKeywordRecord, + FilterStatusRecord, GroupRecord, GroupRelationshipRecord, HistoryRecord, @@ -44,6 +46,8 @@ type Emoji = ReturnType; type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; +type FilterKeyword = ReturnType; +type FilterStatus = ReturnType; type Group = ReturnType; type GroupRelationship = ReturnType; type History = ReturnType; @@ -89,6 +93,8 @@ export { EmojiReaction, Field, Filter, + FilterKeyword, + FilterStatus, Group, GroupRelationship, History, diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index f4c029c9ae..fb5654bd43 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -262,7 +262,7 @@ const getInstanceFeatures = (instance: Instance) => { */ chats: any([ v.software === TRUTHSOCIAL, - v.software === PLEROMA && gte(v.version, '2.1.0') && v.build !== AKKOMA, + features.includes('pleroma_chat_messages'), ]), /** @@ -314,7 +314,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Mastodon's newer solution for direct messaging. - * @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} + * @see {@link https://docs.joinmastodon.org/methods/conversations/} */ conversations: any([ v.software === FRIENDICA, @@ -443,16 +443,28 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can edit and manage timeline filters (aka "muted words"). - * @see {@link https://docs.joinmastodon.org/methods/accounts/filters/} + * @see {@link https://docs.joinmastodon.org/methods/filters/#v1} */ filters: any([ v.software === MASTODON && lt(v.compatVersion, '3.6.0'), v.software === PLEROMA, ]), + /** Whether filters can automatically expires. */ + filtersExpiration: any([ + v.software === MASTODON, + v.software === PLEROMA && gte(v.version, '2.3.0'), + ]), + + /** + * Can edit and manage timeline filters (aka "muted words"). + * @see {@link https://docs.joinmastodon.org/methods/filters/} + */ + filtersV2: v.software === MASTODON && gte(v.compatVersion, '3.6.0'), + /** * Allows setting the focal point of a media attachment. - * @see {@link https://docs.joinmastodon.org/methods/statuses/media/} + * @see {@link https://docs.joinmastodon.org/methods/media/} */ focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), @@ -528,7 +540,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can create, view, and manage lists. - * @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} + * @see {@link https://docs.joinmastodon.org/methods/lists/} * @see GET /api/v1/timelines/list/:list_id */ lists: any([ @@ -643,7 +655,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * A directory of discoverable profiles from the instance. - * @see {@link https://docs.joinmastodon.org/methods/instance/directory/} + * @see {@link https://docs.joinmastodon.org/methods/directory/} */ profileDirectory: any([ v.software === FRIENDICA, @@ -679,6 +691,7 @@ const getInstanceFeatures = (instance: Instance) => { */ quotePosts: any([ v.software === PLEROMA && [REBASED, AKKOMA].includes(v.build!) && gte(v.version, '2.4.50'), + features.includes('quote_posting'), instance.feature_quote === true, ]), @@ -735,7 +748,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can schedule statuses to be posted at a later time. * @see POST /api/v1/statuses - * @see {@link https://docs.joinmastodon.org/methods/statuses/scheduled_statuses/} + * @see {@link https://docs.joinmastodon.org/methods/scheduled_statuses/} */ scheduledStatuses: any([ v.software === MASTODON && gte(v.version, '2.7.0'), @@ -787,7 +800,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can display suggested accounts. - * @see {@link https://docs.joinmastodon.org/methods/accounts/suggestions/} + * @see {@link https://docs.joinmastodon.org/methods/suggestions/} */ suggestions: any([ v.software === MASTODON && gte(v.compatVersion, '2.4.3'), diff --git a/app/soapbox/utils/nostr.ts b/app/soapbox/utils/nostr.ts new file mode 100644 index 0000000000..4616776369 --- /dev/null +++ b/app/soapbox/utils/nostr.ts @@ -0,0 +1,6 @@ +/** Check whether the given input is a valid Nostr hexidecimal pubkey. */ +const isPubkey = (value: string) => /^[0-9a-f]{64}$/i.test(value); + +export { + isPubkey, +}; \ No newline at end of file diff --git a/package.json b/package.json index 2bad2228d2..d765251c48 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/typography": "^0.5.7", "@tanstack/react-query": "^4.0.10", - "@testing-library/react": "^13.0.0", + "@testing-library/react": "^14.0.0", "@types/escape-html": "^1.0.1", "@types/flexsearch": "^0.7.3", "@types/http-link-header": "^1.0.3", diff --git a/yarn.lock b/yarn.lock index 560f04dfa7..2009e8096c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4036,10 +4036,10 @@ lz-string "^1.4.4" pretty-format "^27.0.2" -"@testing-library/dom@^8.5.0": - version "8.19.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.1.tgz#0e2dafd281dedb930bb235eac1045470b4129d0e" - integrity sha512-P6iIPyYQ+qH8CvGauAqanhVnjrnRe0IZFSYCeGkSRW9q3u8bdVn2NPI+lasFyVsEQn1J/IFmp5Aax41+dAP9wg== +"@testing-library/dom@^9.0.0": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.0.1.tgz#fb9e3837fe2a662965df1536988f0863f01dbf51" + integrity sha512-fTOVsMY9QLFCCXRHG3Ese6cMH5qIWwSbgxZsgeF5TNsy81HKaZ4kgehnSF8FsR3OF+numlIV2YcU79MzbnhSig== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" @@ -4047,7 +4047,7 @@ aria-query "^5.0.0" chalk "^4.1.0" dom-accessibility-api "^0.5.9" - lz-string "^1.4.4" + lz-string "^1.5.0" pretty-format "^27.0.2" "@testing-library/jest-dom@^5.16.4": @@ -4073,13 +4073,13 @@ "@babel/runtime" "^7.12.5" react-error-boundary "^3.1.0" -"@testing-library/react@^13.0.0": - version "13.4.0" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.4.0.tgz#6a31e3bf5951615593ad984e96b9e5e2d9380966" - integrity sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw== +"@testing-library/react@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.0.0.tgz#59030392a6792450b9ab8e67aea5f3cc18d6347c" + integrity sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.5.0" + "@testing-library/dom" "^9.0.0" "@types/react-dom" "^18.0.0" "@testing-library/user-event@^13.2.1": @@ -12298,6 +12298,11 @@ lz-string@^1.4.4: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"