Merge remote-tracking branch 'soapbox/develop' into lexical

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-10 16:04:39 +01:00
commit 84fa5bf333
37 changed files with 940 additions and 343 deletions

View file

@ -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.

View file

@ -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<string>, 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<string>, 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<string>
});
};
const deleteFilter = (id: string) =>
const createFilterV2 = (title: string, expires_in: string | null, context: Array<string>, 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<string>, 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<string>, 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<string>, 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<string>, 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,
};

View file

@ -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,
};

View file

@ -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<ILoadMore> = ({ onClick, disabled, visible = true }) => {
const LoadMore: React.FC<ILoadMore> = ({ onClick, disabled, visible = true, className }) => {
if (!visible) {
return null;
}
return (
<Button theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<Button className={className} theme='primary' block disabled={disabled || !visible} onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</Button>
);

View file

@ -297,7 +297,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.filters && (
{(features.filters || features.filtersV2) && (
<SidebarLink
to='/filters'
icon={require('@tabler/icons/filter.svg')}

View file

@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper';
import { useAppDispatch } from 'soapbox/hooks';
import { isPubkey } from 'soapbox/utils/nostr';
import type { Account, Status } from 'soapbox/types/entities';
@ -56,7 +57,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
className='reply-mentions__account'
onClick={(e) => e.stopPropagation()}
>
@{account.username}
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}
</Link>
);

View file

@ -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<IStatus> = (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<IStatus> = (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<IStatus> = (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<IStatus> = (props) => {
<HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</Text>
</div>
</HotKeys>

View file

@ -33,6 +33,8 @@ interface IStreamfield {
onChange: (values: any[]) => void
/** Input to render for each value. */
component: StreamfieldComponent<any>
/** Minimum number of allowed inputs. */
minItems?: number
/** Maximum number of allowed inputs. */
maxItems?: number
}
@ -47,6 +49,7 @@ const Streamfield: React.FC<IStreamfield> = ({
onChange,
component: Component,
maxItems = Infinity,
minItems = 0,
}) => {
const intl = useIntl();
@ -67,10 +70,10 @@ const Streamfield: React.FC<IStreamfield> = ({
{(values.length > 0) && (
<Stack>
{values.map((value, i) => (
{values.map((value, i) => value?._destroy ? null : (
<HStack space={2} alignItems='center'>
<Component key={i} onChange={handleChange(i)} value={value} />
{onRemoveItem && (
{values.length > minItems && onRemoveItem && (
<IconButton
iconClassName='h-4 w-4'
className='bg-transparent text-gray-400 hover:text-gray-600'

View file

@ -121,7 +121,7 @@ const AccountGallery = () => {
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={handleLoadOlder} />;
loadOlder = <LoadMore className='my-auto' visible={!isLoading} onClick={handleLoadOlder} />;
}
if (unavailable) {

View file

@ -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<IAuthToken> = ({ token, isCurrent }) => {
</Text>
)}
</Stack>
<div className='flex justify-end'>
<HStack justifyContent='end'>
<Button theme={isCurrent ? 'danger' : 'primary'} onClick={handleRevoke}>
{intl.formatMessage(messages.revoke)}
</Button>
</div>
</HStack>
</Stack>
</div>
);

View file

@ -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<IReplyMentions> = ({ composeId }) => {
);
}
const accounts = to.slice(0, 2).map((acct: string) => (
<span className='reply-mentions__account'>@{acct.split('@')[0]}</span>
)).toArray();
const accounts = to.slice(0, 2).map((acct: string) => {
const username = acct.split('@')[0];
return (
<span className='reply-mentions__account'>
@{isPubkey(username) ? username.slice(0, 8) : username}
</span>
);
}).toArray();
if (to.size > 2) {
accounts.push(

View file

@ -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<IFilterField> = ({ value, onChange }) => {
const intl = useIntl();
const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> =>
e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] });
return (
<HStack space={2} grow>
<Input
type='text'
outerClassName='w-2/5 grow'
value={value.keyword}
onChange={handleChange('keyword')}
placeholder={intl.formatMessage(messages.keyword)}
/>
<HStack alignItems='center' space={2}>
<Toggle
checked={value.whole_word}
onChange={handleChange('whole_word')}
icons={false}
/>
<Text tag='span' theme='muted'>
<FormattedMessage id='column.filters.whole_word' defaultMessage='Whole word' />
</Text>
</HStack>
</HStack>
);
};
const EditFilter: React.FC<IEditFilter> = ({ 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<string | null>(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<IFilterField[]>([{ 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<HTMLSelectElement> = e => {
setExpiresIn(e.target.value);
};
const handleAddNew: React.FormEventHandler = e => {
e.preventDefault();
const context: Array<string> = [];
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 <MissingIndicator />;
return (
<Column className='filter-settings-panel' label={intl.formatMessage(messages.subheading_add_new)}>
<Form onSubmit={handleAddNew}>
<FormGroup labelText={intl.formatMessage(messages.title)}>
<Input
required
type='text'
name='title'
value={title}
onChange={({ target }) => setTitle(target.value)}
/>
</FormGroup>
{features.filtersExpiration && (
<FormGroup labelText={intl.formatMessage(messages.expires)}>
<SelectDropdown
items={expirations}
defaultValue={''}
onChange={handleSelectChange}
/>
</FormGroup>
)}
<Stack>
<Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
</Stack>
<List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
/>
</ListItem>
{features.filtersV2 && (
<ListItem label={intl.formatMessage(messages.accounts)}>
<Toggle
name='accounts'
checked={accounts}
onChange={({ target }) => setAccounts(target.checked)}
/>
</ListItem>
)}
</List>
<List>
<ListItem
label={intl.formatMessage(features.filtersV2 ? messages.hide_header : messages.drop_header)}
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
>
<Toggle
name='hide'
checked={hide}
onChange={({ target }) => setHide(target.checked)}
/>
</ListItem>
</List>
<Streamfield
label={intl.formatMessage(messages.keywords)}
component={FilterField}
values={keywords}
onChange={handleChangeKeyword}
onAddItem={handleAddKeyword}
onRemoveItem={handleRemoveKeyword}
minItems={1}
maxItems={features.filtersV2 ? Infinity : 1}
/>
<FormActions>
<Button type='submit' theme='primary' disabled={loading}>
{intl.formatMessage(params.id ? messages.edit : messages.add_new)}
</Button>
</FormActions>
</Form>
</Column>
);
};
export default EditFilter;

View file

@ -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<string> = [];
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 = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
return (
<Column className='filter-settings-panel' label={intl.formatMessage(messages.heading)}>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_add_new)} />
</CardHeader>
<Form onSubmit={handleAddNew}>
<FormGroup labelText={intl.formatMessage(messages.keyword)}>
<Input
required
type='text'
name='phrase'
onChange={({ target }) => setPhrase(target.value)}
/>
</FormGroup>
{/* <FormGroup labelText={intl.formatMessage(messages.expires)} hintText={intl.formatMessage(messages.expires_hint)}>
<SelectDropdown
items={expirations}
defaultValue={expirations.never}
onChange={this.handleSelectChange}
/>
</FormGroup> */}
<Stack>
<Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
</Stack>
<List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
</ListItem>
<ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
/>
</ListItem>
</List>
<List>
<ListItem
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
<HStack className='mb-4' space={2} justifyContent='end'>
<Button
to='/filters/new'
theme='primary'
size='sm'
>
<Toggle
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
>
<Toggle
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</ListItem>
</List>
<FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
</FormActions>
</Form>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<FormattedMessage id='filters.create_filter' defaultMessage='Create filter' />
</Button>
</HStack>
<ScrollableList
scrollKey='filters'
@ -202,38 +71,48 @@ const Filters = () => {
itemClassName='pb-4 last:pb-0'
>
{filters.map((filter, i) => (
<HStack space={1} justifyContent='between'>
<Stack space={1}>
<div className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={2}>
<Stack className='grow' space={1}>
<Text weight='medium'>
<FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
<FormattedMessage id='filters.filters_list_phrases_label' defaultMessage='Keywords or phrases:' />
{' '}
<Text theme='muted' tag='span'>{filter.phrase}</Text>
<Text theme='muted' tag='span'>{filter.keywords.map(keyword => keyword.keyword).join(', ')}</Text>
</Text>
<Text weight='medium'>
<FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
{' '}
<Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
</Text>
<HStack space={4}>
<HStack space={4} wrap>
<Text weight='medium'>
{filter.irreversible ?
{filtersV2 ? (
filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_hide_completely' defaultMessage='Hide content' /> :
<FormattedMessage id='filters.filters_list_warn' defaultMessage='Display warning' />
) : (filter.filter_action === 'hide' ?
<FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />}
<FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />)}
</Text>
{filter.whole_word && (
{filter.expires_at && (
<Text weight='medium'>
<FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' />
{new Date(filter.expires_at).getTime() <= Date.now()
? <FormattedMessage id='filters.filters_list_expired' defaultMessage='Expired' />
: <RelativeTimestamp timestamp={filter.expires_at} className='whitespace-nowrap' futureDate />}
</Text>
)}
</HStack>
</Stack>
<IconButton
iconClassName='h-5 w-5 text-gray-700 hover:text-gray-800 dark:text-gray-600 dark:hover:text-gray-500'
src={require('@tabler/icons/trash.svg')}
onClick={handleFilterDelete(filter.id)}
title={intl.formatMessage(messages.delete)}
/>
<HStack space={2} justifyContent='end'>
<Button theme='primary' onClick={handleFilterEdit(filter.id)}>
{intl.formatMessage(messages.edit)}
</Button>
<Button theme='danger' onClick={handleFilterDelete(filter.id)}>
{intl.formatMessage(messages.delete)}
</Button>
</HStack>
</Stack>
</div>
))}
</ScrollableList>
</Column>

View file

@ -45,7 +45,7 @@ const LinkFooter: React.FC = (): JSX.Element => {
)}
<FooterLink to='/blocks'><FormattedMessage id='navigation_bar.blocks' defaultMessage='Blocks' /></FooterLink>
<FooterLink to='/mutes'><FormattedMessage id='navigation_bar.mutes' defaultMessage='Mutes' /></FooterLink>
{features.filters && (
{(features.filters || features.filtersV2) && (
<FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
)}
{features.federating && (

View file

@ -66,6 +66,7 @@ import {
DomainBlocks,
Mutes,
Filters,
EditFilter,
PinnedStatuses,
Search,
ListTimeline,
@ -267,7 +268,9 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
{features.federating && <WrappedRoute path='/domain_blocks' page={DefaultPage} component={DomainBlocks} content={children} />}
<WrappedRoute path='/mutes' page={DefaultPage} component={Mutes} content={children} />
{features.filters && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/new' page={DefaultPage} component={EditFilter} content={children} />}
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/:id' page={DefaultPage} component={EditFilter} content={children} />}
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
<WrappedRoute path='/@:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
<WrappedRoute path='/@:username/with_replies' publicRoute={!authenticatedProfile} component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />

View file

@ -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');
}

View file

@ -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",

View file

@ -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"
}

View file

@ -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",

View file

@ -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<string, any>) =>
FilterKeywordRecord(
ImmutableMap(fromJS(filterKeyword)),
);

View file

@ -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<string>(),
status_matches: ImmutableList<string>(),
});
export const normalizeFilterResult = (filterResult: Record<string, any>) =>
FilterResultRecord(
ImmutableMap(fromJS(filterResult)).update('filter', (filter: any) => normalizeFilter(filter) as any),
);

View file

@ -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<string, any>) =>
FilterStatusRecord(
ImmutableMap(fromJS(filterStatus)),
);

View file

@ -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<ContextType>(),
whole_word: false,
expires_at: '',
irreversible: false,
filter_action: 'warn' as FilterActionType,
keywords: ImmutableList<FilterKeyword>(),
statuses: ImmutableList<FilterStatus>(),
});
export const normalizeFilter = (filter: Record<string, any>) => {
return FilterRecord(
ImmutableMap(fromJS(filter)),
const normalizeFilterV1 = (filter: ImmutableMap<string, any>) =>
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<string, any>) =>
filter.update('keywords', ImmutableList(), keywords =>
keywords.map(normalizeFilterKeyword),
);
const normalizeStatuses = (filter: ImmutableMap<string, any>) =>
filter.update('statuses', ImmutableList(), statuses =>
statuses.map(normalizeFilterStatus),
);
export const normalizeFilter = (filter: Record<string, any>) =>
FilterRecord(
ImmutableMap(fromJS(filter)).withMutations(filter => {
if (filter.has('phrase')) normalizeFilterV1(filter);
normalizeKeywords(filter);
normalizeStatuses(filter);
}),
);
};

View file

@ -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';

View file

@ -50,6 +50,7 @@ export const StatusRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(),
favourited: false,
favourites_count: 0,
filtered: ImmutableList<string>(),
group: null as EmbeddedEntity<Group>,
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<string, string> | null,
});
@ -166,11 +167,6 @@ const fixQuote = (status: ImmutableMap<string, any>) => {
});
};
// Workaround for not yet implemented filtering from Mastodon 3.6
const fixFiltered = (status: ImmutableMap<string, any>) => {
status.delete('filtered');
};
/** If the status contains spoiler text, treat it as sensitive. */
const fixSensitivity = (status: ImmutableMap<string, any>) => {
if (status.get('spoiler_text')) {
@ -214,6 +210,13 @@ const fixContent = (status: ImmutableMap<string, any>) => {
}
};
const normalizeFilterResults = (status: ImmutableMap<string, any>) =>
status.update('filtered', ImmutableList(), filterResults =>
filterResults.map((filterResult: ImmutableMap<string, any>) =>
filterResult.getIn(['filter', 'title']),
),
);
export const normalizeStatus = (status: Record<string, any>) => {
return StatusRecord(
ImmutableMap(fromJS(status)).withMutations(status => {
@ -225,10 +228,10 @@ export const normalizeStatus = (status: Record<string, any>) => {
fixMentionsOrder(status);
addSelfMention(status);
fixQuote(status);
fixFiltered(status);
fixSensitivity(status);
normalizeEvent(status);
fixContent(status);
normalizeFilterResults(status);
}),
);
};

View file

@ -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<string>(),
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);
});
});
}

View file

@ -9,11 +9,10 @@ import type { APIEntity, Filter as FilterEntity } from 'soapbox/types/entities';
type State = ImmutableList<FilterEntity>;
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<FilterEntity>(), 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);

View file

@ -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:

View file

@ -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,10 +118,11 @@ const escapeRegExp = (string: string) =>
export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
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 (keyword.whole_word) {
if (/^[\w]/.test(expr)) {
expr = `\\b${expr}`;
}
@ -131,9 +133,48 @@ export const regexFromFilters = (filters: ImmutableList<FilterEntity>) => {
}
return expr;
}).join('|'), 'i');
}).join('|'),
).join('|'), 'i');
};
const checkFiltered = (index: string, filters: ImmutableList<FilterEntity>) =>
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<string>())), ImmutableList<string>());
// 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);
}
});
},
);

View file

@ -12,6 +12,8 @@ import {
EmojiReactionRecord,
FieldRecord,
FilterRecord,
FilterKeywordRecord,
FilterStatusRecord,
GroupRecord,
GroupRelationshipRecord,
HistoryRecord,
@ -44,6 +46,8 @@ type Emoji = ReturnType<typeof EmojiRecord>;
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
type Field = ReturnType<typeof FieldRecord>;
type Filter = ReturnType<typeof FilterRecord>;
type FilterKeyword = ReturnType<typeof FilterKeywordRecord>;
type FilterStatus = ReturnType<typeof FilterStatusRecord>;
type Group = ReturnType<typeof GroupRecord>;
type GroupRelationship = ReturnType<typeof GroupRelationshipRecord>;
type History = ReturnType<typeof HistoryRecord>;
@ -89,6 +93,8 @@ export {
EmojiReaction,
Field,
Filter,
FilterKeyword,
FilterStatus,
Group,
GroupRelationship,
History,

View file

@ -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'),

View file

@ -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,
};

View file

@ -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",

View file

@ -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"