Merge pull request #1829 from ClearlyClaire/glitch-soc/merge-upstream
Port UI filter changes from upstreammain
commit
acc580fb7d
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
Loading…
Reference in new issue