diff --git a/app/soapbox/components/filter_bar.js b/app/soapbox/components/filter_bar.js new file mode 100644 index 000000000..a2ba02775 --- /dev/null +++ b/app/soapbox/components/filter_bar.js @@ -0,0 +1,156 @@ +import React from 'react'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; + +export default class FilterBar extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + items: PropTypes.array.isRequired, + active: PropTypes.string, + className: PropTypes.string, + }; + + state = { + mounted: false, + }; + + componentDidMount() { + document.addEventListener('keydown', this.handleKeyDown, false); + window.addEventListener('resize', this.handleResize, { passive: true }); + + const { left, width } = this.getActiveTabIndicationSize(); + this.setState({ mounted: true, left, width }); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown, false); + document.removeEventListener('resize', this.handleResize, false); + } + + handleResize = debounce(() => { + this.setState(this.getActiveTabIndicationSize()); + }, 300, { + trailing: true, + }); + + componentDidUpdate(prevProps) { + if (this.props.active !== prevProps.active) { + this.setState(this.getActiveTabIndicationSize()); + } + } + + setRef = c => { + this.node = c; + } + + setFocusRef = c => { + this.focusedItem = c; + } + + handleKeyDown = e => { + const items = Array.from(this.node.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement); + let element = null; + + switch(e.key) { + case 'ArrowRight': + element = items[index+1] || items[0]; + break; + case 'ArrowLeft': + element = items[index-1] || items[items.length-1]; + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleItemKeyPress = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleClick(e); + } + } + + handleClick = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const { action, to } = this.props.items[i]; + + if (typeof action === 'function') { + e.preventDefault(); + action(e); + } else if (to) { + e.preventDefault(); + this.context.router.history.push(to); + } + } + + getActiveTabIndicationSize() { + const { active, items } = this.props; + + if (!active || !this.node) return { width: null }; + + const index = items.findIndex(({ name }) => name === active); + const elements = Array.from(this.node.getElementsByTagName('a')); + const element = elements[index]; + + if (!element) return { width: null }; + + const left = element.offsetLeft; + const { width } = element.getBoundingClientRect(); + + return { left, width }; + } + + renderActiveTabIndicator() { + const { left, width } = this.state; + + return ( +
+ ); + } + + renderItem(option, i) { + if (option === null) { + return
  • ; + } + + const { name, text, href, to, title } = option; + + return ( + + {text} + + ); + } + + render() { + const { className, items } = this.props; + const { mounted } = this.state; + + return ( +
    + {mounted && this.renderActiveTabIndicator()} + {items.map((option, i) => this.renderItem(option, i))} +
    + ); + } + +} \ No newline at end of file diff --git a/app/soapbox/features/compose/components/search_results.js b/app/soapbox/features/compose/components/search_results.js index e316123b1..13487887b 100644 --- a/app/soapbox/features/compose/components/search_results.js +++ b/app/soapbox/features/compose/components/search_results.js @@ -2,18 +2,26 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Hashtag from '../../../components/hashtag'; -import FilterBar from '../../search/components/filter_bar'; import ScrollableList from 'soapbox/components/scrollable_list'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account'; import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import Pullable from 'soapbox/components/pullable'; +import FilterBar from 'soapbox/components/filter_bar'; -export default class SearchResults extends ImmutablePureComponent { +const messages = defineMessages({ + accounts: { id: 'search_results.accounts', defaultMessage: 'People' }, + statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' }, + hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' }, +}); + +export default @injectIntl +class SearchResults extends ImmutablePureComponent { static propTypes = { value: PropTypes.string, @@ -25,12 +33,37 @@ export default class SearchResults extends ImmutablePureComponent { features: PropTypes.object.isRequired, suggestions: ImmutablePropTypes.list, trends: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, }; handleLoadMore = () => this.props.expandSearch(this.props.selectedFilter); handleSelectFilter = newActiveFilter => this.props.selectFilter(newActiveFilter); + renderFilterBar() { + const { intl, selectedFilter } = this.props; + + const items = [ + { + text: intl.formatMessage(messages.accounts), + action: () => this.handleSelectFilter('accounts'), + name: 'accounts', + }, + { + text: intl.formatMessage(messages.statuses), + action: () => this.handleSelectFilter('statuses'), + name: 'statuses', + }, + { + text: intl.formatMessage(messages.hashtags), + action: () => this.handleSelectFilter('hashtags'), + name: 'hashtags', + }, + ]; + + return ; + } + render() { const { value, results, submitted, selectedFilter, suggestions, trends } = this.props; @@ -105,7 +138,7 @@ export default class SearchResults extends ImmutablePureComponent { return ( <> - + {this.renderFilterBar()} {noResultsMessage || ( diff --git a/app/soapbox/features/notifications/components/filter_bar.js b/app/soapbox/features/notifications/components/filter_bar.js index f0802f66b..33d4f41f7 100644 --- a/app/soapbox/features/notifications/components/filter_bar.js +++ b/app/soapbox/features/notifications/components/filter_bar.js @@ -1,9 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import Icon from 'soapbox/components/icon'; +import FilterBar from 'soapbox/components/filter_bar'; -const tooltips = defineMessages({ +const messages = defineMessages({ + all: { id: 'notifications.filter.all', defaultMessage: 'All' }, mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Likes' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Reposts' }, @@ -14,7 +16,7 @@ const tooltips = defineMessages({ }); export default @injectIntl -class FilterBar extends React.PureComponent { +class NotificationFilterBar extends React.PureComponent { static propTypes = { selectFilter: PropTypes.func.isRequired, @@ -30,90 +32,67 @@ class FilterBar extends React.PureComponent { render() { const { selectedFilter, advancedMode, supportsEmojiReacts, intl } = this.props; - const renderedElement = !advancedMode ? ( -
    - - -
    - ) : ( -
    - - - - {supportsEmojiReacts && } - - - - -
    - ); - return renderedElement; + + const items = [ + { + text: intl.formatMessage(messages.all), + action: this.onClick('all'), + name: 'all', + }, + ]; + + if (!advancedMode) { + items.push({ + text: intl.formatMessage(messages.mentions), + action: this.onClick('mention'), + name: 'mention', + }); + } else { + items.push({ + text: , + title: intl.formatMessage(messages.mentions), + action: this.onClick('mention'), + name: 'mention', + }); + items.push({ + text: , + title: intl.formatMessage(messages.favourites), + action: this.onClick('favourite'), + name: 'favourite', + }); + if (supportsEmojiReacts) items.push({ + text: , + title: intl.formatMessage(messages.emoji_reacts), + action: this.onClick('pleroma:emoji_reaction'), + name: 'pleroma:emoji_reaction', + }); + items.push({ + text: , + title: intl.formatMessage(messages.boosts), + action: this.onClick('reblog'), + name: 'reblog', + }); + items.push({ + text: , + title: intl.formatMessage(messages.polls), + action: this.onClick('poll'), + name: 'poll', + }); + items.push({ + text: , + title: intl.formatMessage(messages.follows), + action: this.onClick('follow'), + name: 'follow', + }); + items.push({ + text: , + title: intl.formatMessage(messages.moves), + action: this.onClick('move'), + name: 'move', + }); + } + + return ; } } diff --git a/app/soapbox/features/search/components/filter_bar.js b/app/soapbox/features/search/components/filter_bar.js deleted file mode 100644 index 917ad99c7..000000000 --- a/app/soapbox/features/search/components/filter_bar.js +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, injectIntl } from 'react-intl'; - -export default @injectIntl -class FilterBar extends React.PureComponent { - - static propTypes = { - selectFilter: PropTypes.func.isRequired, - selectedFilter: PropTypes.string.isRequired, - }; - - onClick(searchType) { - return () => this.props.selectFilter(searchType); - } - - render() { - const { selectedFilter } = this.props; - - return ( -
    - - - -
    - ); - } - -} diff --git a/app/soapbox/features/ui/components/reactions_modal.js b/app/soapbox/features/ui/components/reactions_modal.js index 8b905ac25..3f7617b1f 100644 --- a/app/soapbox/features/ui/components/reactions_modal.js +++ b/app/soapbox/features/ui/components/reactions_modal.js @@ -9,9 +9,11 @@ import LoadingIndicator from 'soapbox/components/loading_indicator'; import AccountContainer from 'soapbox/containers/account_container'; import ScrollableList from 'soapbox/components/scrollable_list'; import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions'; +import FilterBar from 'soapbox/components/filter_bar'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, + all: { id: 'reactions.all', defaultMessage: 'All' }, }); const mapStateToProps = (state, props) => { @@ -62,6 +64,29 @@ class ReactionsModal extends React.PureComponent { this.setState({ reaction }); }; + renderFilterBar() { + const { intl, reactions } = this.props; + const { reaction } = this.state; + + const items = [ + { + text: intl.formatMessage(messages.all), + action: this.handleFilterChange(''), + name: 'all', + }, + ]; + + reactions.forEach(reaction => items.push( + { + text: `${reaction.name} ${reaction.count}`, + action: this.handleFilterChange(reaction.name), + name: reaction.name, + }, + )); + + return ; + } + render() { const { intl, reactions } = this.props; const { reaction } = this.state; @@ -78,14 +103,7 @@ class ReactionsModal extends React.PureComponent { const emptyMessage = ; body = (<> - { - reactions.size > 0 && ( -
    - - {reactions?.filter(reaction => reaction.count).map(reaction => )} -
    - ) - } + {reactions.size > 0 && this.renderFilterBar()} .domain { } } -.notification__filter-bar, -.search__filter-bar, -.account__section-headline, -.reaction__filter-bar { +.filter-bar, +.account__section-headline { border-bottom: 1px solid var(--brand-color--faint); cursor: default; display: flex; @@ -684,15 +682,30 @@ article:last-child > .domain { background-color: var(--accent-color); } } - } - button .svg-icon { - width: 18px; - height: 18px; - margin: 0 auto; + .svg-icon { + width: 18px; + height: 18px; + margin: 0 auto; + } } } +.filter-bar { + position: relative; + + &__active { + position: absolute; + height: 3px; + bottom: 0; + background-color: var(--accent-color); + } +} + +.no-reduce-motion .filter-bar__active { + transition: all 0.3s; +} + .reaction__filter-bar { overflow-x: auto; overflow-y: hidden;