Compare commits

...

10 Commits

Author SHA1 Message Date
unknown e4d2b29361 Fix error on filter delete 2023-08-08 13:53:55 +02:00
unknown f3263be6eb Apply filters when they are just retrieved 2023-08-08 13:44:33 +02:00
unknown 549b177b2f Add media attachment counter + hide media on detailed status too 2023-08-08 13:09:02 +02:00
unknown a17f572d05 Fix collapse 2023-08-08 12:55:48 +02:00
unknown e4c30e746a Refacto status 2023-08-08 12:19:33 +02:00
unknown 68a157540a New filter + CW ui 2023-08-08 00:18:18 +02:00
unknown a7caf62887 Fix translation 2023-08-07 20:30:00 +02:00
unknown 75fdfffe35 WIP filters 2023-07-27 17:05:58 +02:00
unknown ab2709afd0 List ok 2023-07-22 14:12:06 +02:00
clovis 0c06f9b786 Start to fix filters 2023-07-15 16:46:32 +02:00
15 changed files with 256 additions and 279 deletions

View File

@ -1,11 +1,14 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import { getFilters } from 'soapbox/selectors';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
import api from '../api';
import { STATUS_APPLY_FILTERS } from './statuses';
import type { AppDispatch, RootState } from 'soapbox/store';
const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
@ -26,7 +29,7 @@ const messages = defineMessages({
});
const fetchFilters = () =>
(dispatch: AppDispatch, getState: () => RootState) => {
async(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
const state = getState();
@ -40,19 +43,26 @@ const fetchFilters = () =>
skipLoading: true,
});
api(getState)
.get('/api/v1/filters')
.then(({ data }) => dispatch({
try {
const { data } = await api(getState).get('/api/v1/filters');
await dispatch({
type: FILTERS_FETCH_SUCCESS,
filters: data,
skipLoading: true,
}))
.catch(err => dispatch({
});
const filters = getFilters(getState(), null);
dispatch({
type: STATUS_APPLY_FILTERS,
filters,
});
} catch (err) {
dispatch({
type: FILTERS_FETCH_FAIL,
err,
skipLoading: true,
skipAlert: true,
}));
});
}
};
const createFilter = (phrase: string, expires_at: string, context: Array<string>, whole_word: boolean, irreversible: boolean) =>

View File

@ -1,3 +1,5 @@
import { getFilters } from 'soapbox/selectors';
import { getSettings } from '../settings';
import type { AppDispatch, RootState } from 'soapbox/store';
@ -21,14 +23,16 @@ export function importAccounts(accounts: APIEntity[]) {
export function importStatus(status: APIEntity, idempotencyKey?: string) {
return (dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers });
const filters = getFilters(getState(), null);
return dispatch({ type: STATUS_IMPORT, status, idempotencyKey, expandSpoilers, filters });
};
}
export function importStatuses(statuses: APIEntity[]) {
return (dispatch: AppDispatch, getState: () => RootState) => {
const expandSpoilers = getSettings(getState()).get('expandSpoilers');
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers });
const filters = getFilters(getState(), null);
return dispatch({ type: STATUSES_IMPORT, statuses, expandSpoilers, filters });
};
}

View File

@ -107,9 +107,11 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
filtered = regex && regex.test(searchIndex);
}
if (filtered) return;
// Desktop notifications
try {
if (showAlert && !filtered) {
if (showAlert) {
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
@ -128,7 +130,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
console.warn(e);
}
if (playSound && !filtered) {
if (playSound) {
dispatch({
type: NOTIFICATIONS_UPDATE_NOOP,
meta: { sound: 'boop' },

View File

@ -47,6 +47,8 @@ const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL';
const STATUS_HIDE = 'STATUS_HIDE';
const STATUS_APPLY_FILTERS = 'STATUS_APPLY_FILTERS';
const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null;
};
@ -353,6 +355,7 @@ export {
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_APPLY_FILTERS,
createStatus,
editStatus,
fetchStatus,

View File

@ -51,35 +51,35 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
{poll.pleroma.get('non_anonymous') && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text theme='muted' weight='medium'>
<Text tag='span' theme='muted' weight='medium'>
<FormattedMessage id='poll.non_anonymous' defaultMessage='Public poll' />
</Text>
</Tooltip>
<Text theme='muted'>&middot;</Text>
<Text tag='span' theme='muted'>&middot;</Text>
</>
)}
{showResults && (
<>
<button className='text-gray-600 underline' onClick={handleRefresh} data-testid='poll-refresh'>
<Text theme='muted' weight='medium'>
<Text tag='span' theme='muted' weight='medium'>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</Text>
</button>
<Text theme='muted'>&middot;</Text>
<Text tag='span' theme='muted'>&middot;</Text>
</>
)}
<Text theme='muted' weight='medium'>
<Text tag='span' theme='muted' weight='medium'>
{votesCount}
</Text>
{poll.expires_at && (
<>
<Text theme='muted'>&middot;</Text>
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
<Text tag='span' theme='muted'>&middot;</Text>
<Text tag='span' weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
</>
)}
</HStack>

View File

@ -204,21 +204,6 @@ const Status: React.FC<IStatus> = (props) => {
);
}
if (status.filtered || actualStatus.filtered) {
const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
<div className={classNames('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</div>
</HotKeys>
);
}
let quote;
if (actualStatus.quote) {
@ -348,15 +333,21 @@ const Status: React.FC<IStatus> = (props) => {
collapsable
/>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{
!actualStatus.hidden && (
<>
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{ quote }
</>
{!actualStatus.hidden && quote}
)
}
{!hideActionBar && (
<div className='pt-4'>

View File

@ -11,6 +11,7 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import Poll from './polls/poll';
import { Button, Text } from './ui';
import type { Status, Mention } from 'soapbox/types/entities';
@ -28,10 +29,11 @@ interface IReadMoreButton {
/** Button to expand a truncated status (due to too much content) */
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<button className='status__content__read-more-button' onClick={onClick}>
<Button onClick={onClick} theme='link' size='sm' classNames='-mx-3'>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block h-5 w-5' src={require('@tabler/icons/chevron-right.svg')} fixedWidth />
</button>
</Button>
);
interface ISpoilerButton {
@ -42,18 +44,9 @@ interface ISpoilerButton {
/** Button to expand status text behind a content warning */
const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex }) => (
<button
tabIndex={tabIndex}
className={classNames(
'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]',
'text-black dark:text-white',
'font-bold text-[11px] uppercase',
'bg-primary-100 dark:bg-primary-900',
'hover:bg-primary-300 dark:hover:bg-primary-600',
'focus:bg-primary-200 dark:focus:bg-primary-600',
'hover:no-underline',
'duration-100',
)}
<Button
theme='ghost'
size='sm'
onClick={onClick}
>
{hidden ? (
@ -61,9 +54,68 @@ const SpoilerButton: React.FC<ISpoilerButton> = ({ onClick, hidden, tabIndex })
) : (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
)}
</button>
</Button>
);
interface ISpoiler {
hidden: boolean,
status: Status,
onClick: (event: React.MouseEvent<Element, MouseEvent>) => void,
}
const Spoiler: React.FC<ISpoiler> = ({ hidden, onClick, status }) => {
return (
<div className='flex items-center justify-between bg-gray-100 dark:bg-slate-700 p-2 rounded mt-1'>
{
status.spoiler_text.length > 0 ? (
<span>
<Text tag='span' weight='medium'>
<FormattedMessage
id='status.cw'
defaultMessage='Warning:'
/>
</Text>
&nbsp;
<span dangerouslySetInnerHTML={{ __html: status.spoilerHtml }} lang={status.language || undefined} />
</span>
) : (
<span>
<Text tag='span'>
<FormattedMessage
id='status.filtered'
defaultMessage='Filtered'
/>
</Text>
<br />
<Text size='xs' theme='muted' tag='span'>
<FormattedMessage
id='status.filtered-hint'
defaultMessage='Status was hidden by filter settings'
/>
</Text>
</span>
)
}
<div className='flex gap-3 items-center'>
{
status.media_attachments?.count() > 0 && (
<div aria-hidden className='flex gap-1 items-center'>
<Icon className='inline-block' src={require('@tabler/icons/paperclip.svg')} />
<Text tag='span' size='xs' theme='muted'>{ status.media_attachments.count() }</Text>
</div>
)
}
<SpoilerButton
tabIndex={0}
onClick={onClick}
hidden={hidden}
/>
</div>
</div>
);
};
interface IStatusContent {
status: Status,
expanded?: boolean,
@ -214,7 +266,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
const isHidden = onExpandedToggle ? !expanded : hidden;
const content = { __html: parsedHtml };
const spoilerContent = { __html: status.spoilerHtml };
const directionStyle: React.CSSProperties = { direction: 'ltr' };
const className = classNames('status__content', {
'status__content--with-action': onClick,
@ -227,80 +278,50 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
directionStyle.direction = 'rtl';
}
if (status.spoiler_text.length > 0) {
return (
<div className={className} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
<p style={{ marginBottom: isHidden && status.mentions.isEmpty() ? 0 : undefined }}>
<span dangerouslySetInnerHTML={spoilerContent} lang={status.language || undefined} />
<SpoilerButton
tabIndex={0}
onClick={handleSpoilerClick}
hidden={isHidden}
/>
</p>
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames('status__content__text', {
'status__content__text--visible': !isHidden,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
{!isHidden && status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} status={status.url} />
)}
return (
<>
<div className={`${className} flex flex-col gap-2`} ref={node} tabIndex={0} style={directionStyle} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
{
// post has a spoiler or was filtered
(status.spoiler_text.length > 0 || status.filtered) && (
<Spoiler
status={status}
hidden={isHidden}
onClick={handleSpoilerClick}
/>
)
}
{
// actual content
!isHidden && (
<div
tabIndex={!isHidden ? 0 : undefined}
className={classNames('min-h-0 overflow-hidden text-ellipsis status__content__text', {
'status__content__text--visible': !isHidden,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>
)
}
{
// post folded because too long
collapsed && onClick && (
<div>
<ReadMoreButton onClick={onClick} key='read-more' />
</div>
)
}
{
// post has a poll
status.poll && typeof status.poll === 'string' && (
<Poll id={status.poll} key='poll' status={status.url} />
)
}
</div>
);
} else if (onClick) {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={className}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>,
];
if (collapsed) {
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
}
const hasPoll = status.poll && typeof status.poll === 'string';
if (hasPoll) {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <div className={classNames({ 'rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<div
ref={node}
tabIndex={0}
key='content'
className={classNames('status__content', {
'status__content--big': onlyEmoji,
})}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.language || undefined}
/>,
];
if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
}
</>
);
};
export default React.memo(StatusContent);

View File

@ -12,7 +12,6 @@ interface IButton {
block?: boolean,
/** Elements inside the <button> */
children?: React.ReactNode,
/** @deprecated unused */
classNames?: string,
/** Prevent the button from being clicked. */
disabled?: boolean,
@ -22,7 +21,6 @@ interface IButton {
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void,
/** A predefined button size. */
size?: ButtonSizes,
/** @deprecated unused */
style?: React.CSSProperties,
/** Text inside the button. Takes precedence over `children`. */
text?: React.ReactNode,
@ -37,6 +35,7 @@ interface IButton {
/** Customizable button element with various themes. */
const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.Element => {
const {
classNames,
block = false,
children,
disabled = false,
@ -47,6 +46,7 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
theme = 'accent',
to,
type = 'button',
style,
} = props;
const themeClass = useButtonStyles({
@ -72,12 +72,13 @@ const Button = React.forwardRef<HTMLButtonElement, IButton>((props, ref): JSX.El
const renderButton = () => (
<button
className={themeClass}
className={`${themeClass} ${classNames}`}
disabled={disabled}
onClick={handleClick}
ref={ref}
type={type}
data-testid='button'
style={style}
>
{renderIcon()}
{text || children}

View File

@ -4,13 +4,13 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import snackbar from 'soapbox/actions/snackbar';
import Icon from 'soapbox/components/icon';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui';
import { Button, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Filter } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
@ -20,8 +20,9 @@ const messages = defineMessages({
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' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Also apply for notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_notifications: { id: 'column.filters.drop_notifications', defaultMessage: 'Will also hide status in notifications' },
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' },
@ -33,15 +34,6 @@ const messages = defineMessages({
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
});
// 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();
@ -50,43 +42,20 @@ const 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(() => {
dispatch(createFilter(phrase, expiresAt, [], wholeWord, irreversible)).then(() => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.create_error)));
});
};
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => {
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => {
const handleFilterDelete = (filter: Filter) => {
dispatch(deleteFilter(filter.id)).then(() => {
return dispatch(fetchFilters());
}).catch(() => {
dispatch(snackbar.error(intl.formatMessage(messages.delete_error)));
@ -113,65 +82,23 @@ const Filters = () => {
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> */}
<FieldsGroup>
<Text tag='label'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text>
<Text theme='muted' size='xs'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text>
<div className='two-col'>
<div className='flex flex-col gap-2'>
<Checkbox
label={intl.formatMessage(messages.home_timeline)}
name='home_timeline'
checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)}
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.public_timeline)}
name='public_timeline'
checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.notifications)}
name='notifications'
checked={notifications}
onChange={({ target }) => setNotifications(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.conversations)}
name='conversations'
checked={conversations}
onChange={({ target }) => setConversations(target.checked)}
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</div>
</FieldsGroup>
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={irreversible}
onChange={({ target }) => setIrreversible(target.checked)}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={wholeWord}
onChange={({ target }) => setWholeWord(target.checked)}
/>
</FieldsGroup>
<FormActions>
@ -183,27 +110,19 @@ const Filters = () => {
<CardTitle title={intl.formatMessage(messages.subheading_filters)} />
</CardHeader>
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
>
<div>
{
filters.size === 0 && <div>{ emptyMessage }</div>
}
{filters.map((filter, i) => (
<div key={i} className='filter__container'>
<div className='filter__details'>
<div className='filter__phrase'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<div key={i} className='filter__container rounded bg-gray-100 dark:bg-slate-900 p-2 my-3'>
<div className=''>
<div className='mb-1'>
<span className='pr-1 text-gray-600 dark:text-gray-400'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<span className='filter__list-value'>{filter.phrase}</span>
</div>
<div className='filter__contexts'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
<span className='filter__list-value'>
{filter.context.map((context, i) => (
<span key={i} className='context'>{context}</span>
))}
</span>
</div>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<div className=''>
<span className='pr-1 text-gray-600 dark:text-gray-400'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.irreversible ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
@ -215,13 +134,18 @@ const Filters = () => {
</span>
</div>
</div>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' src={require('@tabler/icons/x.svg')} />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
<div>
<Button theme='ghost' onClick={() => handleFilterDelete(filter)} aria-label={intl.formatMessage(messages.delete)}>
<div className='flex items-end gap-1'>
<FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' />
<Icon src={require('@tabler/icons/x.svg')} />
</div>
</Button>
</div>
</div>
))}
</ScrollableList>
</div>
</Column>
);
};

View File

@ -161,13 +161,16 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
)
}
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
{!actualStatus.hidden && quote}
{!actualStatus.hidden && (
<>
<StatusMedia
status={actualStatus}
showMedia={showMedia}
onToggleVisibility={onToggleMediaVisibility}
/>
{ quote }
</>
)}
<HStack justifyContent='between' alignItems='center' className='py-2'>
<StatusInteractionBar status={actualStatus} />

View File

@ -242,9 +242,8 @@
"column.filters.expires_hint": "Expiration dates are not currently supported",
"column.filters.home_timeline": "Accueil",
"column.filters.keyword": "Mot-clé ou phrase",
"column.filters.notifications": "Notifications",
"column.filters.public_timeline": "Découvrir",
"column.filters.subheading_add_new": "Ajouter le nouveau filtre",
"column.filters.subheading_add_new": "Ajouter un nouveau filtre",
"column.filters.subheading_filters": "Filtres actuels",
"column.filters.whole_word_header": "Mot entier",
"column.filters.whole_word_hint": "Le filtre ne sera appliqué que s'il correspond exactement au mot entier.",
@ -1063,7 +1062,9 @@
"status.edit": "Éditer",
"status.embed": "Intégrer",
"status.favourite": "Réagir",
"status.cw": "Avertissement:",
"status.filtered": "Filtré",
"status.filtered-hint": "Statut masqué par vos paramètres de filtrage",
"status.load_more": "Charger plus",
"status.media_hidden": "Média caché",
"status.mention": "Mentionner @{name}",

View File

@ -3,6 +3,8 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import emojify from 'soapbox/features/emoji/emoji';
import { normalizeStatus } from 'soapbox/normalizers';
import { regexFromFilters } from 'soapbox/selectors';
import { Filter } from 'soapbox/types/entities';
import { simulateEmojiReact, simulateUnEmojiReact } from 'soapbox/utils/emoji_reacts';
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';
import { makeEmojiMap, normalizeId } from 'soapbox/utils/normalizers';
@ -31,6 +33,7 @@ import {
STATUS_DELETE_REQUEST,
STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS,
STATUS_APPLY_FILTERS,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
@ -103,18 +106,28 @@ export const calculateStatus = (
});
} else {
const spoilerText = status.spoiler_text;
const searchContent = buildSearchContent(status);
const searchIndex = domParser.parseFromString(buildSearchContent(status), 'text/html').documentElement.textContent || '';
const emojiMap = makeEmojiMap(status.emojis);
return status.merge({
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
search_index: searchIndex,
contentHtml: stripCompatibilityFeatures(emojify(status.content, emojiMap)),
spoilerHtml: emojify(escapeTextContentForBrowser(spoilerText), emojiMap),
hidden: expandSpoilers ? false : spoilerText.length > 0 || status.sensitive,
hidden: (expandSpoilers ? false : spoilerText.length > 0 || status.sensitive),
});
}
};
// apply filters on a status
const fixFilters = (status: StatusRecord, filters: ImmutableList<Filter>): StatusRecord => {
const regex = regexFromFilters(filters);
const filtered = Boolean(regex && regex.test(status.search_index));
return status.merge({
hidden: status.hidden || filtered,
filtered,
});
};
// Check whether a status is a quote by secondary characteristics
const isQuote = (status: StatusRecord) => {
return Boolean(status.pleroma.get('quote_url'));
@ -131,21 +144,22 @@ const fixQuote = (status: StatusRecord, oldStatus?: StatusRecord): StatusRecord
}
};
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): ReducerStatus => {
const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean, filters: ImmutableList<Filter>): ReducerStatus => {
const oldStatus = state.get(status.id);
return normalizeStatus(status).withMutations(status => {
fixQuote(status, oldStatus);
calculateStatus(status, oldStatus, expandSpoilers);
fixFilters(status, filters);
minifyStatus(status);
}) as ReducerStatus;
};
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State =>
state.set(status.id, fixStatus(state, status, expandSpoilers));
const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean, filters: ImmutableList<Filter>): State =>
state.set(status.id, fixStatus(state, status, expandSpoilers, filters));
const importStatuses = (state: State, statuses: APIEntities, expandSpoilers: boolean): State =>
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status, expandSpoilers)));
const importStatuses = (state: State, statuses: APIEntities, expandSpoilers: boolean, filters: ImmutableList<Filter>): State =>
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status, expandSpoilers, filters)));
const translateStatus = (state: State, statusId: string, language: string, text: string) =>
state.updateIn([statusId, 'translations', language], () => text);
@ -197,14 +211,24 @@ const simulateFavourite = (
return state.set(statusId, updatedStatus);
};
// When filters are fetched we want to apply them to already retrieved status
const applyFilters = (
state: State,
filters: ImmutableList<Filter>,
): State => {
return state.map((status) => fixFilters(status, filters) as ReducerStatus);
};
const initialState: State = ImmutableMap();
export default function statuses(state = initialState, action: AnyAction): State {
switch (action.type) {
case STATUS_APPLY_FILTERS:
return applyFilters(state, action.filters);
case STATUS_IMPORT:
return importStatus(state, action.status, action.expandSpoilers);
return importStatus(state, action.status, action.expandSpoilers, action.filters);
case STATUSES_IMPORT:
return importStatuses(state, action.statuses, action.expandSpoilers);
return importStatuses(state, action.statuses, action.expandSpoilers, action.filters);
case STATUS_TRANSLATE_SUCCESS:
return translateStatus(state, action.statusId, action.language, action.text);
case STATUS_CREATE_REQUEST:

View File

@ -105,9 +105,10 @@ type FilterContext = { contextType?: string };
export const getFilters = (state: RootState, query: FilterContext) => {
return state.filters.filter((filter) => {
return query?.contextType
&& filter.context.includes(toServerSideType(query.contextType))
&& (filter.expires_at === null
// if contextType is provided we want to filter by context
if (query?.contextType && !filter.context.includes(toServerSideType(query.contextType))) return false;
return (filter.expires_at === null
|| Date.parse(filter.expires_at) > new Date().getTime());
});
};
@ -165,14 +166,10 @@ 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);
map.set('filtered', Boolean(filtered));
});
},
);

View File

@ -60,7 +60,7 @@
}
.filter__container {
@apply flex justify-between py-5 px-2 text-sm text-black dark:text-white;
@apply flex justify-between p-2 text-sm text-black dark:text-white;
.filter__phrase,
.filter__contexts,
@ -68,12 +68,8 @@
@apply py-1;
}
span.filter__list-label {
@apply pr-1 text-gray-500 dark:text-gray-400;
}
span.filter__list-value span {
@apply pr-1 capitalize;
@apply pr-1;
&::after {
content: ",";

View File

@ -224,7 +224,7 @@
}
.status__content {
@apply text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative;
@apply text-gray-900 dark:text-gray-100 break-words relative;
&:focus {
@apply outline-none;