From 0b733ca7909bb96269613ca05df7a91eb083d042 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 25 Aug 2022 04:27:47 +0200 Subject: [Glitch] Add ability to filter individual posts Port 50487db1224851a49ee523bbc013d5f8686a7a55 to glitch-soc Signed-off-by: Claire --- .../compose/components/language_dropdown.js | 19 +- .../glitch/features/filters/added_to_filter.js | 102 +++++++++++ .../glitch/features/filters/select_filter.js | 192 +++++++++++++++++++++ .../glitch/features/ui/components/filter_modal.js | 134 ++++++++++++++ .../glitch/features/ui/components/modal_root.js | 2 + 5 files changed, 432 insertions(+), 17 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/filters/added_to_filter.js create mode 100644 app/javascript/flavours/glitch/features/filters/select_filter.js create mode 100644 app/javascript/flavours/glitch/features/ui/components/filter_modal.js (limited to 'app/javascript/flavours/glitch/features') diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js index c8c503e58..035b0c0c3 100644 --- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js @@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring'; import { supportsPassiveEvents } from 'detect-passive-events'; import classNames from 'classnames'; import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state'; +import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons'; import fuzzysort from 'fuzzysort'; const messages = defineMessages({ @@ -16,22 +17,6 @@ const messages = defineMessages({ 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: ( - - - - ), - - delete: ( - - - - ), -}; - const listenerOptions = supportsPassiveEvents ? { passive: true } : false; class LanguageDropdownMenu extends React.PureComponent { @@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
- +
diff --git a/app/javascript/flavours/glitch/features/filters/added_to_filter.js b/app/javascript/flavours/glitch/features/filters/added_to_filter.js new file mode 100644 index 000000000..f777ca429 --- /dev/null +++ b/app/javascript/flavours/glitch/features/filters/added_to_filter.js @@ -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 = ( + +

+

+ +

+
+ ); + } + + let contextMismatchMessage = null; + if (contextType && !filter.get('context').includes(toServerSideType(contextType))) { + contextMismatchMessage = ( + +

+

+ +

+
+ ); + } + + const settings_link = ( + + + + ); + + return ( + +

+

+ +

+ + {expiredMessage} + {contextMismatchMessage} + +

+

+ +

+ +
+ +
+ +
+ + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/filters/select_filter.js b/app/javascript/flavours/glitch/features/filters/select_filter.js new file mode 100644 index 000000000..5321dbb96 --- /dev/null +++ b/app/javascript/flavours/glitch/features/filters/select_filter.js @@ -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 = ( + + ( + {filter[3] && } + {filter[3] && filter[4] && ', '} + {filter[4] && } + ) + + ); + } + + return ( +
+ {filter[1]} {warning} +
+ ); + } + + renderCreateNew (name) { + return ( +
+ +
+ ); + } + + 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 ( + +

+

+ +
+ + +
+ +
+ {results.map(this.renderItem)} + {isSearching && this.renderCreateNew(searchValue) } +
+ +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.js b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js new file mode 100644 index 000000000..d2482e733 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js @@ -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 = ( + + ); + break; + case 'create': + stepComponent = null; + break; + case 'submitted': + stepComponent = ( + + ); + } + + return ( +
+
+ + +
+ +
+ {stepComponent} +
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 8f18d93b7..4df3a0dee 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -26,6 +26,7 @@ import { ListAdder, PinnedAccountsEditor, CompareHistoryModal, + FilterModal, } from 'flavours/glitch/util/async-components'; const MODAL_COMPONENTS = { @@ -49,6 +50,7 @@ const MODAL_COMPONENTS = { 'LIST_ADDER': ListAdder, 'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor, 'COMPARE_HISTORY': CompareHistoryModal, + 'FILTER': FilterModal, }; export default class ModalRoot extends React.PureComponent { -- cgit