You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

192 lines
5.9 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { connect } from 'react-redux';
  4. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  5. import { toServerSideType } from 'mastodon/utils/filters';
  6. import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
  7. import Icon from 'mastodon/components/icon';
  8. import fuzzysort from 'fuzzysort';
  9. const messages = defineMessages({
  10. search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
  11. clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
  12. });
  13. const mapStateToProps = (state, { contextType }) => ({
  14. filters: Array.from(state.get('filters').values()).map((filter) => [
  15. filter.get('id'),
  16. filter.get('title'),
  17. filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
  18. filter.get('expires_at') && filter.get('expires_at') < new Date(),
  19. contextType && !filter.get('context').includes(toServerSideType(contextType)),
  20. ]),
  21. });
  22. class SelectFilter extends React.PureComponent {
  23. static propTypes = {
  24. onSelectFilter: PropTypes.func.isRequired,
  25. onNewFilter: PropTypes.func.isRequired,
  26. filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
  27. intl: PropTypes.object.isRequired,
  28. };
  29. state = {
  30. searchValue: '',
  31. };
  32. search () {
  33. const { filters } = this.props;
  34. const { searchValue } = this.state;
  35. if (searchValue === '') {
  36. return filters;
  37. }
  38. return fuzzysort.go(searchValue, filters, {
  39. keys: ['1', '2'],
  40. limit: 5,
  41. threshold: -10000,
  42. }).map(result => result.obj);
  43. }
  44. renderItem = filter => {
  45. let warning = null;
  46. if (filter[3] || filter[4]) {
  47. warning = (
  48. <span className='language-dropdown__dropdown__results__item__common-name'>
  49. (
  50. {filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
  51. {filter[3] && filter[4] && ', '}
  52. {filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
  53. )
  54. </span>
  55. );
  56. }
  57. return (
  58. <div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
  59. <span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
  60. </div>
  61. );
  62. };
  63. renderCreateNew (name) {
  64. return (
  65. <div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
  66. <Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
  67. </div>
  68. );
  69. }
  70. handleSearchChange = ({ target }) => {
  71. this.setState({ searchValue: target.value });
  72. };
  73. setListRef = c => {
  74. this.listNode = c;
  75. };
  76. handleKeyDown = e => {
  77. const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
  78. let element = null;
  79. switch(e.key) {
  80. case ' ':
  81. case 'Enter':
  82. e.currentTarget.click();
  83. break;
  84. case 'ArrowDown':
  85. element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
  86. break;
  87. case 'ArrowUp':
  88. element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
  89. break;
  90. case 'Tab':
  91. if (e.shiftKey) {
  92. element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
  93. } else {
  94. element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
  95. }
  96. break;
  97. case 'Home':
  98. element = this.listNode.firstChild;
  99. break;
  100. case 'End':
  101. element = this.listNode.lastChild;
  102. break;
  103. }
  104. if (element) {
  105. element.focus();
  106. e.preventDefault();
  107. e.stopPropagation();
  108. }
  109. };
  110. handleSearchKeyDown = e => {
  111. let element = null;
  112. switch(e.key) {
  113. case 'Tab':
  114. case 'ArrowDown':
  115. element = this.listNode.firstChild;
  116. if (element) {
  117. element.focus();
  118. e.preventDefault();
  119. e.stopPropagation();
  120. }
  121. break;
  122. }
  123. };
  124. handleClear = () => {
  125. this.setState({ searchValue: '' });
  126. };
  127. handleItemClick = e => {
  128. const value = e.currentTarget.getAttribute('data-index');
  129. e.preventDefault();
  130. this.props.onSelectFilter(value);
  131. };
  132. handleNewFilterClick = e => {
  133. e.preventDefault();
  134. this.props.onNewFilter(this.state.searchValue);
  135. };
  136. render () {
  137. const { intl } = this.props;
  138. const { searchValue } = this.state;
  139. const isSearching = searchValue !== '';
  140. const results = this.search();
  141. return (
  142. <React.Fragment>
  143. <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
  144. <p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
  145. <div className='emoji-mart-search'>
  146. <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
  147. <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
  148. </div>
  149. <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
  150. {results.map(this.renderItem)}
  151. {isSearching && this.renderCreateNew(searchValue) }
  152. </div>
  153. </React.Fragment>
  154. );
  155. }
  156. }
  157. export default connect(mapStateToProps)(injectIntl(SelectFilter));