Merge branch 'filters' into 'develop'

Fix filters, restyle filters page

See merge request soapbox-pub/soapbox!2262
This commit is contained in:
marcin mikołajczak 2023-02-12 21:08:15 +00:00
commit 8f79ef2bcd
15 changed files with 102 additions and 196 deletions

View file

@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Groups: Initial support for groups. - Groups: Initial support for groups.
- Profile: Add RSS link to user profiles. - Profile: Add RSS link to user profiles.
- Reactions: adds support for reacting to chat messages. - Reactions: adds support for reacting to chat messages.
- Groups: initial support for groups.
- Profile: add RSS link to user profiles.
- Posts: fix posts filtering.
### Changed ### Changed
- Chats: improved display of media attachments. - Chats: improved display of media attachments.

View file

@ -289,8 +289,10 @@ const Status: React.FC<IStatus> = (props) => {
return ( return (
<HotKeys handlers={minHandlers}> <HotKeys handlers={minHandlers}>
<div className={clsx('status__wrapper', 'status__wrapper--filtered', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}> <div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' /> <Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
</Text>
</div> </div>
</HotKeys> </HotKeys>
); );

View file

@ -6,8 +6,7 @@ import { makeGetStatus } from 'soapbox/selectors';
interface IStatusContainer extends Omit<IStatus, 'status'> { interface IStatusContainer extends Omit<IStatus, 'status'> {
id: string, id: string,
/** @deprecated Unused. */ contextType?: string,
contextType?: any,
/** @deprecated Unused. */ /** @deprecated Unused. */
otherAccounts?: any, otherAccounts?: any,
/** @deprecated Unused. */ /** @deprecated Unused. */
@ -21,10 +20,10 @@ interface IStatusContainer extends Omit<IStatus, 'status'> {
* @deprecated Use the Status component directly. * @deprecated Use the Status component directly.
*/ */
const StatusContainer: React.FC<IStatusContainer> = (props) => { const StatusContainer: React.FC<IStatusContainer> = (props) => {
const { id, ...rest } = props; const { id, contextType, ...rest } = props;
const getStatus = useCallback(makeGetStatus(), []); const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id })); const status = useAppSelector(state => getStatus(state, { id, contextType }));
if (status) { if (status) {
return <Status status={status} {...rest} />; return <Status status={status} {...rest} />;

View file

@ -2,13 +2,9 @@ import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters'; import { fetchFilters, createFilter, deleteFilter } from 'soapbox/actions/filters';
import Icon from 'soapbox/components/icon'; import List, { ListItem } from 'soapbox/components/list';
import ScrollableList from 'soapbox/components/scrollable-list'; 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, HStack, IconButton, Input, Stack, Text, Toggle } from 'soapbox/components/ui';
import {
FieldsGroup,
Checkbox,
} from 'soapbox/features/forms';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
@ -33,6 +29,13 @@ const messages = defineMessages({
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
}); });
const contexts = {
home: messages.home_timeline,
public: messages.public_timeline,
notifications: messages.notifications,
thread: messages.conversations,
};
// const expirations = { // const expirations = {
// null: 'Never', // null: 'Never',
// // 3600: '30 minutes', // // 3600: '30 minutes',
@ -85,8 +88,8 @@ const Filters = () => {
}); });
}; };
const handleFilterDelete: React.MouseEventHandler<HTMLDivElement> = e => { const handleFilterDelete = (id: string) => () => {
dispatch(deleteFilter(e.currentTarget.dataset.value!)).then(() => { dispatch(deleteFilter(id)).then(() => {
return dispatch(fetchFilters()); return dispatch(fetchFilters());
}).catch(() => { }).catch(() => {
toast.error(intl.formatMessage(messages.delete_error)); toast.error(intl.formatMessage(messages.delete_error));
@ -121,58 +124,68 @@ const Filters = () => {
/> />
</FormGroup> */} </FormGroup> */}
<FieldsGroup> <Stack>
<Text tag='label'> <Text size='sm' weight='medium'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' /> <FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</Text> </Text>
<Text theme='muted' size='xs'> <Text size='xs' theme='muted'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' /> <FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</Text> </Text>
<div className='two-col'> </Stack>
<Checkbox
label={intl.formatMessage(messages.home_timeline)} <List>
<ListItem label={intl.formatMessage(messages.home_timeline)}>
<Toggle
name='home_timeline' name='home_timeline'
checked={homeTimeline} checked={homeTimeline}
onChange={({ target }) => setHomeTimeline(target.checked)} onChange={({ target }) => setHomeTimeline(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.public_timeline)} <ListItem label={intl.formatMessage(messages.public_timeline)}>
<Toggle
name='public_timeline' name='public_timeline'
checked={publicTimeline} checked={publicTimeline}
onChange={({ target }) => setPublicTimeline(target.checked)} onChange={({ target }) => setPublicTimeline(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.notifications)} <ListItem label={intl.formatMessage(messages.notifications)}>
<Toggle
name='notifications' name='notifications'
checked={notifications} checked={notifications}
onChange={({ target }) => setNotifications(target.checked)} onChange={({ target }) => setNotifications(target.checked)}
/> />
<Checkbox </ListItem>
label={intl.formatMessage(messages.conversations)} <ListItem label={intl.formatMessage(messages.conversations)}>
<Toggle
name='conversations' name='conversations'
checked={conversations} checked={conversations}
onChange={({ target }) => setConversations(target.checked)} onChange={({ target }) => setConversations(target.checked)}
/> />
</div> </ListItem>
</List>
</FieldsGroup> <List>
<ListItem
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)} label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)} hint={intl.formatMessage(messages.drop_hint)}
name='irreversible' >
checked={irreversible} <Toggle
onChange={({ target }) => setIrreversible(target.checked)} name='irreversible'
/> checked={irreversible}
<Checkbox onChange={({ target }) => setIrreversible(target.checked)}
/>
</ListItem>
<ListItem
label={intl.formatMessage(messages.whole_word_header)} label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)} hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word' >
checked={wholeWord} <Toggle
onChange={({ target }) => setWholeWord(target.checked)} name='whole_word'
/> checked={wholeWord}
</FieldsGroup> onChange={({ target }) => setWholeWord(target.checked)}
/>
</ListItem>
</List>
<FormActions> <FormActions>
<Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button> <Button type='submit' theme='primary'>{intl.formatMessage(messages.add_new)}</Button>
@ -186,40 +199,41 @@ const Filters = () => {
<ScrollableList <ScrollableList
scrollKey='filters' scrollKey='filters'
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
itemClassName='pb-4 last:pb-0'
> >
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<div key={i} className='filter__container'> <HStack space={1} justifyContent='between'>
<div className='filter__details'> <Stack space={1}>
<div className='filter__phrase'> <Text weight='medium'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span> <FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' />
<span className='filter__list-value'>{filter.phrase}</span> {' '}
</div> <Text theme='muted' tag='span'>{filter.phrase}</Text>
<div className='filter__contexts'> </Text>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span> <Text weight='medium'>
<span className='filter__list-value'> <FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' />
{filter.context.map((context, i) => ( {' '}
<span key={i} className='context'>{context}</span> <Text theme='muted' tag='span'>{filter.context.map(context => contexts[context] ? intl.formatMessage(contexts[context]) : context).join(', ')}</Text>
))} </Text>
</span> <HStack space={4}>
</div> <Text weight='medium'>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.irreversible ? {filter.irreversible ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> : <FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /> :
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span> <FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' />}
} </Text>
{filter.whole_word && {filter.whole_word && (
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span> <Text weight='medium'>
} <FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' />
</span> </Text>
</div> )}
</div> </HStack>
<div className='filter__delete' role='button' tabIndex={0} onClick={handleFilterDelete} data-value={filter.id} aria-label={intl.formatMessage(messages.delete)}> </Stack>
<Icon className='filter__delete-icon' src={require('@tabler/icons/x.svg')} /> <IconButton
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span> iconClassName='h-5 w-5 text-gray-700 dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500'
</div> src={require('@tabler/icons/trash.svg')}
</div> onClick={handleFilterDelete(filter.id)}
title={intl.formatMessage(messages.delete)}
/>
</HStack>
))} ))}
</ScrollableList> </ScrollableList>
</Column> </Column>

View file

@ -160,14 +160,6 @@ export const SimpleForm: React.FC<ISimpleForm> = (props) => {
); );
}; };
interface IFieldsGroup {
children: React.ReactNode,
}
export const FieldsGroup: React.FC<IFieldsGroup> = ({ children }) => (
<div className='fields-group'>{children}</div>
);
interface ICheckbox { interface ICheckbox {
label?: React.ReactNode, label?: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,

View file

@ -329,6 +329,7 @@ const Notification: React.FC<INotificaton> = (props) => {
onMoveDown={handleMoveDown} onMoveDown={handleMoveDown}
onMoveUp={handleMoveUp} onMoveUp={handleMoveUp}
avatarSize={avatarSize} avatarSize={avatarSize}
contextType='notifications'
/> />
) : null; ) : null;
default: default:

View file

@ -8,6 +8,7 @@ import { useAppSelector } from 'soapbox/hooks';
interface IThreadStatus { interface IThreadStatus {
id: string, id: string,
contextType?: string,
focusedStatusId: string, focusedStatusId: string,
onMoveUp: (id: string) => void, onMoveUp: (id: string) => void,
onMoveDown: (id: string) => void, onMoveDown: (id: string) => void,

View file

@ -361,6 +361,7 @@ const Thread: React.FC<IThread> = (props) => {
focusedStatusId={status!.id} focusedStatusId={status!.id}
onMoveUp={handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown} onMoveDown={handleMoveDown}
contextType='thread'
/> />
); );
}; };

View file

@ -706,8 +706,6 @@
"filters.context_header": "Filter contexts", "filters.context_header": "Filter contexts",
"filters.context_hint": "One or multiple contexts where the filter should apply", "filters.context_hint": "One or multiple contexts where the filter should apply",
"filters.filters_list_context_label": "Filter contexts:", "filters.filters_list_context_label": "Filter contexts:",
"filters.filters_list_delete": "Delete",
"filters.filters_list_details_label": "Filter settings:",
"filters.filters_list_drop": "Drop", "filters.filters_list_drop": "Drop",
"filters.filters_list_hide": "Hide", "filters.filters_list_hide": "Hide",
"filters.filters_list_phrase_label": "Keyword or phrase:", "filters.filters_list_phrase_label": "Keyword or phrase:",

View file

@ -5,11 +5,13 @@
*/ */
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
export type ContextType = 'home' | 'public' | 'notifications' | 'thread';
// https://docs.joinmastodon.org/entities/filter/ // https://docs.joinmastodon.org/entities/filter/
export const FilterRecord = ImmutableRecord({ export const FilterRecord = ImmutableRecord({
id: '', id: '',
phrase: '', phrase: '',
context: ImmutableList<string>(), context: ImmutableList<ContextType>(),
whole_word: false, whole_word: false,
expires_at: '', expires_at: '',
irreversible: false, irreversible: false,

View file

@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth';
import ConfigDB from 'soapbox/utils/config-db'; import ConfigDB from 'soapbox/utils/config-db';
import { shouldFilter } from 'soapbox/utils/timelines'; import { shouldFilter } from 'soapbox/utils/timelines';
import type { ContextType } from 'soapbox/normalizers/filter';
import type { ReducerChat } from 'soapbox/reducers/chats'; import type { ReducerChat } from 'soapbox/reducers/chats';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities'; import type { Filter as FilterEntity, Notification } from 'soapbox/types/entities';
@ -85,7 +86,7 @@ export const findAccountByUsername = (state: RootState, username: string) => {
} }
}; };
const toServerSideType = (columnType: string): string => { const toServerSideType = (columnType: string): ContextType => {
switch (columnType) { switch (columnType) {
case 'home': case 'home':
case 'notifications': case 'notifications':
@ -105,10 +106,8 @@ type FilterContext = { contextType?: string };
export const getFilters = (state: RootState, query: FilterContext) => { export const getFilters = (state: RootState, query: FilterContext) => {
return state.filters.filter((filter) => { return state.filters.filter((filter) => {
return query?.contextType return (!query?.contextType || filter.context.includes(toServerSideType(query.contextType)))
&& filter.context.includes(toServerSideType(query.contextType)) && (filter.expires_at === null || Date.parse(filter.expires_at) > new Date().getTime());
&& (filter.expires_at === null
|| Date.parse(filter.expires_at) > new Date().getTime());
}); });
}; };

View file

@ -29,7 +29,6 @@
@import 'components/react-toggle'; @import 'components/react-toggle';
@import 'components/video-player'; @import 'components/video-player';
@import 'components/audio-player'; @import 'components/audio-player';
@import 'components/filters';
@import 'components/crypto-donate'; @import 'components/crypto-donate';
@import 'components/aliases'; @import 'components/aliases';
@import 'components/icon'; @import 'components/icon';

View file

@ -1,93 +0,0 @@
.filter-settings-panel {
.fields-group .two-col {
display: flex;
align-items: flex-start;
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
div.input {
width: 45%;
margin-right: 20px;
.label_input {
width: 100%;
}
}
@media (max-width: 485px) {
div.input {
width: 100%;
margin-right: 5px;
.label_input {
width: auto;
}
}
}
}
.input.boolean {
.label_input {
@apply relative pl-7 text-black dark:text-white;
label {
@apply text-sm;
}
&__wrapper {
@apply static;
}
input[type='checkbox'] {
position: absolute;
top: 3px;
left: 0;
}
}
.hint {
@apply block pl-7 text-xs text-gray-500 dark:text-gray-400;
}
}
.filter__container {
@apply flex justify-between py-5 px-2 text-sm text-black dark:text-white;
.filter__phrase,
.filter__contexts,
.filter__details {
@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;
&::after {
content: ',';
}
&:last-of-type {
&::after {
content: '';
}
}
}
.filter__delete {
@apply flex items-center h-5 m-2.5 cursor-pointer;
span.filter__delete-label {
@apply text-gray-500 dark:text-gray-400 font-semibold;
}
.filter__delete-icon {
@apply mx-1 text-gray-500 dark:text-gray-400;
}
}
}
}

View file

@ -17,7 +17,7 @@
[column-type='filled'] .status__wrapper, [column-type='filled'] .status__wrapper,
[column-type='filled'] .status-placeholder { [column-type='filled'] .status-placeholder {
@apply rounded-none shadow-none p-4; @apply bg-transparent dark:bg-transparent rounded-none shadow-none p-4;
} }
.status-check-box { .status-check-box {

View file

@ -191,18 +191,6 @@ select {
color: lighten($error-value-color, 12%); color: lighten($error-value-color, 12%);
} }
.fields-group {
margin-bottom: 25px;
&:last-child {
margin-bottom: 0;
}
.input:last-child {
margin-bottom: 0;
}
}
.input.radio_buttons .radio label { .input.radio_buttons .radio label {
@apply text-gray-900; @apply text-gray-900;
margin-bottom: 5px; margin-bottom: 5px;