[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:
parent
28f0dfc0b4
commit
0b733ca790
|
@ -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,
|
||||||
|
});
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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));
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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']));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
@ -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