[Glitch] Add ability to filter individual posts

Port 50487db122 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Claire 2022-08-25 04:27:47 +02:00
parent 28f0dfc0b4
commit 0b733ca790
16 changed files with 612 additions and 39 deletions

View file

@ -0,0 +1,93 @@
import api from 'flavours/glitch/util/api';
import { openModal } from './modal';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
export const FILTERS_STATUS_CREATE_REQUEST = 'FILTERS_STATUS_CREATE_REQUEST';
export const FILTERS_STATUS_CREATE_SUCCESS = 'FILTERS_STATUS_CREATE_SUCCESS';
export const FILTERS_STATUS_CREATE_FAIL = 'FILTERS_STATUS_CREATE_FAIL';
export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const initAddFilter = (status, { contextType }) => dispatch =>
dispatch(openModal('FILTER', {
statusId: status?.get('id'),
contextType: contextType,
}));
export const fetchFilters = () => (dispatch, getState) => {
dispatch({
type: FILTERS_FETCH_REQUEST,
skipLoading: true,
});
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,
}));
};
export const createFilterStatus = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterStatusRequest());
api(getState).post(`/api/v1/filters/${params.filter_id}/statuses`, params).then(response => {
dispatch(createFilterStatusSuccess(response.data));
if (onSuccess) onSuccess();
}).catch(error => {
dispatch(createFilterStatusFail(error));
if (onFail) onFail();
});
};
export const createFilterStatusRequest = () => ({
type: FILTERS_STATUS_CREATE_REQUEST,
});
export const createFilterStatusSuccess = filter_status => ({
type: FILTERS_STATUS_CREATE_SUCCESS,
filter_status,
});
export const createFilterStatusFail = error => ({
type: FILTERS_STATUS_CREATE_FAIL,
error,
});
export const createFilter = (params, onSuccess, onFail) => (dispatch, getState) => {
dispatch(createFilterRequest());
api(getState).post('/api/v2/filters', params).then(response => {
dispatch(createFilterSuccess(response.data));
if (onSuccess) onSuccess(response.data);
}).catch(error => {
dispatch(createFilterFail(error));
if (onFail) onFail();
});
};
export const createFilterRequest = () => ({
type: FILTERS_CREATE_REQUEST,
});
export const createFilterSuccess = filter => ({
type: FILTERS_CREATE_SUCCESS,
filter,
});
export const createFilterFail = error => ({
type: FILTERS_CREATE_FAIL,
error,
});

View file

@ -42,9 +42,9 @@ export function fetchStatusRequest(id, skipLoading) {
}; };
}; };
export function fetchStatus(id) { export function fetchStatus(id, forceFetch = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
const skipLoading = getState().getIn(['statuses', id], null) !== null; const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
dispatch(fetchContext(id)); dispatch(fetchContext(id));

View file

@ -79,6 +79,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia: PropTypes.func, onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func, onOpenVideo: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
onAddFilter: PropTypes.func,
onEmbed: PropTypes.func, onEmbed: PropTypes.func,
onHeightChange: PropTypes.func, onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,

View file

@ -41,6 +41,7 @@ const messages = defineMessages({
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
}); });
export default @injectIntl export default @injectIntl
@ -67,6 +68,7 @@ class StatusActionBar extends ImmutablePureComponent {
onPin: PropTypes.func, onPin: PropTypes.func,
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onFilter: PropTypes.func, onFilter: PropTypes.func,
onAddFilter: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
showReplyCount: PropTypes.bool, showReplyCount: PropTypes.bool,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
@ -193,10 +195,14 @@ class StatusActionBar extends ImmutablePureComponent {
} }
} }
handleFilterClick = () => { handleHideClick = () => {
this.props.onFilter(); this.props.onFilter();
} }
handleFilterClick = () => {
this.props.onAddFilter(this.props.status);
}
render () { render () {
const { status, intl, withDismiss, showReplyCount, scrollKey } = this.props; const { status, intl, withDismiss, showReplyCount, scrollKey } = this.props;
@ -238,6 +244,12 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null); menu.push(null);
if (!this.props.onFilter) {
menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
@ -306,7 +318,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
const filterButton = this.props.onFilter && ( const filterButton = this.props.onFilter && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
); );
return ( return (

View file

@ -25,6 +25,9 @@ import {
revealStatus, revealStatus,
editStatus editStatus
} from 'flavours/glitch/actions/statuses'; } from 'flavours/glitch/actions/statuses';
import {
initAddFilter,
} from 'flavours/glitch/actions/filters';
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports'; import { initReport } from 'flavours/glitch/actions/reports';
@ -205,6 +208,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(initReport(status.get('account'), status)); dispatch(initReport(status.get('account'), status));
}, },
onAddFilter (status) {
dispatch(initAddFilter(status, { contextType }));
},
onMute (account) { onMute (account) {
dispatch(initMuteModal(account)); dispatch(initMuteModal(account));
}, },

View file

@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state'; import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state';
import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons';
import fuzzysort from 'fuzzysort'; import fuzzysort from 'fuzzysort';
const messages = defineMessages({ const messages = defineMessages({
@ -16,22 +17,6 @@ const messages = defineMessages({
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' }, clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
}); });
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
const icons = {
loupe: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
),
delete: (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
),
};
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class LanguageDropdownMenu extends React.PureComponent { class LanguageDropdownMenu extends React.PureComponent {
@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
<div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className='emoji-mart-search'> <div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus /> <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button> <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div> </div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>

View file

@ -0,0 +1,102 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import { toServerSideType } from 'flavours/glitch/util/filters';
import Button from 'flavours/glitch/components/button';
import { connect } from 'react-redux';
const mapStateToProps = (state, { filterId }) => ({
filter: state.getIn(['filters', filterId]),
});
export default @connect(mapStateToProps)
class AddedToFilter extends React.PureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
contextType: PropTypes.string,
filter: ImmutablePropTypes.map.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleCloseClick = () => {
const { onClose } = this.props;
onClose();
};
render () {
const { filter, contextType } = this.props;
let expiredMessage = null;
if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
expiredMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.expired_explanation'
defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
/>
</p>
</React.Fragment>
);
}
let contextMismatchMessage = null;
if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
contextMismatchMessage = (
<React.Fragment>
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.context_mismatch_explanation'
defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
/>
</p>
</React.Fragment>
);
}
const settings_link = (
<a href={`/filters/${filter.get('id')}/edit`}>
<FormattedMessage
id='filter_modal.added.settings_link'
defaultMessage='settings page'
/>
</a>
);
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.short_explanation'
defaultMessage='This post has been added to the following filter category: {title}.'
values={{ title: filter.get('title') }}
/>
</p>
{expiredMessage}
{contextMismatchMessage}
<h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
<p className='report-dialog-modal__lead'>
<FormattedMessage
id='filter_modal.added.review_and_configure'
defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
values={{ settings_link }}
/>
</p>
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
</div>
</React.Fragment>
);
}
}

View file

@ -0,0 +1,192 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { toServerSideType } from 'flavours/glitch/util/filters';
import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons';
import Icon from 'flavours/glitch/components/icon';
import fuzzysort from 'fuzzysort';
const messages = defineMessages({
search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
});
const mapStateToProps = (state, { contextType }) => ({
filters: Array.from(state.get('filters').values()).map((filter) => [
filter.get('id'),
filter.get('title'),
filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
filter.get('expires_at') && filter.get('expires_at') < new Date(),
contextType && !filter.get('context').includes(toServerSideType(contextType)),
]),
});
export default @connect(mapStateToProps)
@injectIntl
class SelectFilter extends React.PureComponent {
static propTypes = {
onSelectFilter: PropTypes.func.isRequired,
onNewFilter: PropTypes.func.isRequired,
filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
intl: PropTypes.object.isRequired,
};
state = {
searchValue: '',
};
search () {
const { filters } = this.props;
const { searchValue } = this.state;
if (searchValue === '') {
return filters;
}
return fuzzysort.go(searchValue, filters, {
keys: ['1', '2'],
limit: 5,
threshold: -10000,
}).map(result => result.obj);
}
renderItem = filter => {
let warning = null;
if (filter[3] || filter[4]) {
warning = (
<span className='language-dropdown__dropdown__results__item__common-name'>
(
{filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
{filter[3] && filter[4] && ', '}
{filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
)
</span>
);
}
return (
<div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
<span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
</div>
);
}
renderCreateNew (name) {
return (
<div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
<Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
</div>
);
}
handleSearchChange = ({ target }) => {
this.setState({ searchValue: target.value });
}
setListRef = c => {
this.listNode = c;
}
handleKeyDown = e => {
const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
let element = null;
switch(e.key) {
case ' ':
case 'Enter':
e.currentTarget.click();
break;
case 'ArrowDown':
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
break;
case 'ArrowUp':
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
} else {
element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
}
break;
case 'Home':
element = this.listNode.firstChild;
break;
case 'End':
element = this.listNode.lastChild;
break;
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
}
handleSearchKeyDown = e => {
let element = null;
switch(e.key) {
case 'Tab':
case 'ArrowDown':
element = this.listNode.firstChild;
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
break;
}
}
handleClear = () => {
this.setState({ searchValue: '' });
}
handleItemClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onSelectFilter(value);
}
handleNewFilterClick = e => {
e.preventDefault();
this.props.onNewFilter(this.state.searchValue);
};
render () {
const { intl } = this.props;
const { searchValue } = this.state;
const isSearching = searchValue !== '';
const results = this.search();
return (
<React.Fragment>
<h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
<p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
<div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
<button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
</div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
{results.map(this.renderItem)}
{isSearching && this.renderCreateNew(searchValue) }
</div>
</React.Fragment>
);
}
}

View file

@ -0,0 +1,134 @@
import React from 'react';
import { connect } from 'react-redux';
import { fetchStatus } from 'flavours/glitch/actions/statuses';
import { fetchFilters, createFilter, createFilterStatus } from 'flavours/glitch/actions/filters';
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'flavours/glitch/components/icon_button';
import SelectFilter from 'flavours/glitch/features/filters/select_filter';
import AddedToFilter from 'flavours/glitch/features/filters/added_to_filter';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @connect(undefined)
@injectIntl
class FilterModal extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
contextType: PropTypes.string,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
step: 'select',
filterId: null,
isSubmitting: false,
isSubmitted: false,
};
handleNewFilterSuccess = (result) => {
this.handleSelectFilter(result.id);
};
handleSuccess = () => {
const { dispatch, statusId } = this.props;
dispatch(fetchStatus(statusId, true));
this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
};
handleFail = () => {
this.setState({ isSubmitting: false });
};
handleNextStep = step => {
this.setState({ step });
};
handleSelectFilter = (filterId) => {
const { dispatch, statusId } = this.props;
this.setState({ isSubmitting: true, filterId });
dispatch(createFilterStatus({
filter_id: filterId,
status_id: statusId,
}, this.handleSuccess, this.handleFail));
};
handleNewFilter = (title) => {
const { dispatch } = this.props;
this.setState({ isSubmitting: true });
dispatch(createFilter({
title,
context: ['home', 'notifications', 'public', 'thread', 'account'],
action: 'warn',
}, this.handleNewFilterSuccess, this.handleFail));
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchFilters());
}
render () {
const {
intl,
statusId,
contextType,
onClose,
} = this.props;
const {
step,
filterId,
} = this.state;
let stepComponent;
switch(step) {
case 'select':
stepComponent = (
<SelectFilter
contextType={contextType}
onSelectFilter={this.handleSelectFilter}
onNewFilter={this.handleNewFilter}
/>
);
break;
case 'create':
stepComponent = null;
break;
case 'submitted':
stepComponent = (
<AddedToFilter
contextType={contextType}
filterId={filterId}
statusId={statusId}
onClose={onClose}
/>
);
}
return (
<div className='modal-root__modal report-dialog-modal'>
<div className='report-modal__target'>
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
<FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
</div>
<div className='report-dialog-modal__container'>
{stepComponent}
</div>
</div>
);
}
}

View file

@ -26,6 +26,7 @@ import {
ListAdder, ListAdder,
PinnedAccountsEditor, PinnedAccountsEditor,
CompareHistoryModal, CompareHistoryModal,
FilterModal,
} from 'flavours/glitch/util/async-components'; } from 'flavours/glitch/util/async-components';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
@ -49,6 +50,7 @@ const MODAL_COMPONENTS = {
'LIST_ADDER': ListAdder, 'LIST_ADDER': ListAdder,
'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
'COMPARE_HISTORY': CompareHistoryModal, 'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

View file

@ -1,4 +1,5 @@
import { FILTERS_IMPORT } from '../actions/importer'; import { FILTERS_IMPORT } from '../actions/importer';
import { FILTERS_FETCH_SUCCESS, FILTERS_CREATE_SUCCESS } from '../actions/filters';
import { Map as ImmutableMap, is, fromJS } from 'immutable'; import { Map as ImmutableMap, is, fromJS } from 'immutable';
const normalizeFilter = (state, filter) => { const normalizeFilter = (state, filter) => {
@ -7,13 +8,17 @@ const normalizeFilter = (state, filter) => {
title: filter.title, title: filter.title,
context: filter.context, context: filter.context,
filter_action: filter.filter_action, filter_action: filter.filter_action,
keywords: filter.keywords,
expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
}); });
if (is(state.get(filter.id), normalizedFilter)) { if (is(state.get(filter.id), normalizedFilter)) {
return state; return state;
} else { } else {
return state.set(filter.id, normalizedFilter); // Do not overwrite keywords when receiving a partial filter
return state.update(filter.id, ImmutableMap(), (old) => (
old.mergeWith(((old_value, new_value) => (new_value === undefined ? old_value : new_value)), normalizedFilter)
));
} }
}; };
@ -27,6 +32,10 @@ const normalizeFilters = (state, filters) => {
export default function filters(state = ImmutableMap(), action) { export default function filters(state = ImmutableMap(), action) {
switch(action.type) { switch(action.type) {
case FILTERS_CREATE_SUCCESS:
return normalizeFilter(state, action.filter);
case FILTERS_FETCH_SUCCESS:
//TODO: handle deleting obsolete filters
case FILTERS_IMPORT: case FILTERS_IMPORT:
return normalizeFilters(state, action.filters); return normalizeFilters(state, action.filters);
default: default:

View file

@ -1,6 +1,7 @@
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { toServerSideType } from 'flavours/glitch/util/filters';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
@ -21,23 +22,6 @@ export const makeGetAccount = () => {
}); });
}; };
export const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};
const getFilters = (state, { contextType }) => { const getFilters = (state, { contextType }) => {
if (!contextType) return null; if (!contextType) return null;
@ -68,6 +52,7 @@ export const makeGetStatus = () => {
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
return null; return null;
} }
filterResults = filterResults.filter(result => filters.has(result.get('filter')));
if (!filterResults.isEmpty()) { if (!filterResults.isEmpty()) {
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
} }

View file

@ -583,6 +583,16 @@
line-height: 22px; line-height: 22px;
color: lighten($inverted-text-color, 16%); color: lighten($inverted-text-color, 16%);
margin-bottom: 30px; margin-bottom: 30px;
a {
text-decoration: none;
color: $inverted-text-color;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
} }
&__actions { &__actions {
@ -730,6 +740,14 @@
background: transparent; background: transparent;
margin: 15px 0; margin: 15px 0;
} }
.emoji-mart-search {
padding-right: 10px;
}
.emoji-mart-search-icon {
right: 10px + 5px;
}
} }
.report-modal__container { .report-modal__container {

View file

@ -177,3 +177,7 @@ export function FollowRecommendations () {
export function CompareHistoryModal () { export function CompareHistoryModal () {
return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal'); return import(/*webpackChunkName: "flavours/glitch/async/compare_history_modal" */'flavours/glitch/features/ui/components/compare_history_modal');
} }
export function FilterModal () {
return import(/*webpackChunkName: "flavours/glitch/async/filter_modal" */'flavours/glitch/features/ui/components/filter_modal');
}

View file

@ -0,0 +1,16 @@
export const toServerSideType = columnType => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};

View file

@ -0,0 +1,13 @@
// Copied from emoji-mart for consistency with emoji picker and since
// they don't export the icons in the package
export const loupeIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
</svg>
);
export const deleteIcon = (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
<path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
</svg>
);