diff --git a/.env.production.sample b/.env.production.sample index 50e412d3ba..ec7c7c5af1 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -272,6 +272,9 @@ MAX_POLL_OPTIONS=5 # Maximum allowed poll option characters MAX_POLL_OPTION_CHARS=100 +# Maximum number of emoji reactions per toot and user (minimum 1) +MAX_REACTIONS=1 + # Maximum image and video/audio upload sizes # Units are in bytes # 1048576 bytes equals 1 megabyte diff --git a/app/controllers/api/v1/statuses/reactions_controller.rb b/app/controllers/api/v1/statuses/reactions_controller.rb new file mode 100644 index 0000000000..333054f2a0 --- /dev/null +++ b/app/controllers/api/v1/statuses/reactions_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReactionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:favourites' } + before_action :require_user! + before_action :set_status + + def create + ReactService.new.call(current_account, @status, params[:id]) + render_empty + end + + def destroy + UnreactService.new.call(current_account, @status, params[:id]) + render_empty + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 4c13364369..39715b724b 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -57,6 +57,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_use_pending_items, :setting_trends, :setting_crop_images, + :setting_visible_reactions, :setting_always_send_emails, notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal), interactions: %i(must_be_follower must_be_following must_be_following_dm) diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 225ee7eb2a..a7eb7a78dd 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -41,6 +41,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const REACTION_UPDATE = 'REACTION_UPDATE'; + +export const REACTION_ADD_REQUEST = 'REACTION_ADD_REQUEST'; +export const REACTION_ADD_SUCCESS = 'REACTION_ADD_SUCCESS'; +export const REACTION_ADD_FAIL = 'REACTION_ADD_FAIL'; + +export const REACTION_REMOVE_REQUEST = 'REACTION_REMOVE_REQUEST'; +export const REACTION_REMOVE_SUCCESS = 'REACTION_REMOVE_SUCCESS'; +export const REACTION_REMOVE_FAIL = 'REACTION_REMOVE_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -392,3 +402,75 @@ export function unpinFail(status, error) { error, }; }; + +export const addReaction = (statusId, name, url) => (dispatch, getState) => { + const status = getState().get('statuses').get(statusId); + let alreadyAdded = false; + if (status) { + const reaction = status.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + if (!alreadyAdded) { + dispatch(addReactionRequest(statusId, name, url)); + } + + // encodeURIComponent is required for the Keycap Number Sign emoji, see: + // + api(getState).post(`/api/v1/statuses/${statusId}/react/${encodeURIComponent(name)}`).then(() => { + dispatch(addReactionSuccess(statusId, name)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(statusId, name, err)); + } + }); +}; + +export const addReactionRequest = (statusId, name, url) => ({ + type: REACTION_ADD_REQUEST, + id: statusId, + name, + url, +}); + +export const addReactionSuccess = (statusId, name) => ({ + type: REACTION_ADD_SUCCESS, + id: statusId, + name, +}); + +export const addReactionFail = (statusId, name, error) => ({ + type: REACTION_ADD_FAIL, + id: statusId, + name, + error, +}); + +export const removeReaction = (statusId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(statusId, name)); + + api(getState).post(`/api/v1/statuses/${statusId}/unreact/${encodeURIComponent(name)}`).then(() => { + dispatch(removeReactionSuccess(statusId, name)); + }).catch(err => { + dispatch(removeReactionFail(statusId, name, err)); + }); +}; + +export const removeReactionRequest = (statusId, name) => ({ + type: REACTION_REMOVE_REQUEST, + id: statusId, + name, +}); + +export const removeReactionSuccess = (statusId, name) => ({ + type: REACTION_REMOVE_SUCCESS, + id: statusId, + name, +}); + +export const removeReactionFail = (statusId, name) => ({ + type: REACTION_REMOVE_FAIL, + id: statusId, + name, +}); diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 158a5b7e43..fa556269c8 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -139,6 +139,7 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 4041b48194..99ed644aa7 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -6,6 +6,7 @@ import StatusHeader from './status_header'; import StatusIcons from './status_icons'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; +import StatusReactions from './status_reactions'; import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; @@ -16,7 +17,7 @@ import NotificationOverlayContainer from 'flavours/glitch/features/notifications import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import PollContainer from 'flavours/glitch/containers/poll_container'; -import { displayMedia } from 'flavours/glitch/initial_state'; +import { displayMedia, visibleReactions } from 'flavours/glitch/initial_state'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; // We use the component (and not the container) since we do not want @@ -61,6 +62,7 @@ class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -75,6 +77,8 @@ class Status extends ImmutablePureComponent { onDelete: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, + onReactionAdd: PropTypes.func, + onReactionRemove: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, @@ -722,6 +726,7 @@ class Status extends ImmutablePureComponent { if (this.props.prepend && account) { const notifKind = { favourite: 'favourited', + reaction: 'reacted', reblog: 'boosted', reblogged_by: 'boosted', status: 'posted', @@ -800,6 +805,15 @@ class Status extends ImmutablePureComponent { rewriteMentions={settings.get('rewrite_mentions')} /> + + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + } + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -195,8 +202,11 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onAddFilter(this.props.status); } + handleNoOp = () => {} // hack for reaction add button + render () { const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; + const { signedIn } = this.context.identity; const anonymousAccess = !me; const mutingConversation = status.get('muted'); @@ -298,6 +308,17 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + + ); + return (
+ { + signedIn + ? + : reactButton + } {shareButton} diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index f825330621..946146b5ef 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -56,6 +56,14 @@ export default class StatusPrepend extends React.PureComponent { values={{ name : link }} /> ); + case 'reaction': + return ( + + ); case 'reblog': return ( x.get('count') > 0) + .sort((a, b) => b.get('count') - a.get('count')); + + if (numVisible >= 0) { + visibleReactions = visibleReactions.filter((_, i) => i < numVisible); + } + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render() { + const { reaction } = this.props; + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, + }; + + render() { + const { emoji, hovered, url, staticUrl } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } + } + +} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 947573fc7d..3edcf9c7a8 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -1,6 +1,5 @@ import { connect } from 'react-redux'; import Status from 'flavours/glitch/components/status'; -import { List as ImmutableList } from 'immutable'; import { makeGetStatus } from 'flavours/glitch/selectors'; import { replyCompose, @@ -16,6 +15,8 @@ import { unbookmark, pin, unpin, + addReaction, + removeReaction, } from 'flavours/glitch/actions/interactions'; import { muteStatus, @@ -166,6 +167,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal('EMBED', { url: status.get('url'), diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js index 00317bf8b7..41eb6e90f0 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js @@ -321,6 +321,7 @@ class EmojiPickerDropdown extends React.PureComponent { onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, button: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -358,7 +359,7 @@ class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index 64fd98bd92..5c3d595978 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -116,6 +116,17 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js index c1de0f90ea..6027a55d85 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -6,6 +6,7 @@ import Icon from 'flavours/glitch/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent { > +
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 40c86afdf0..45300ddea8 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -6,9 +6,10 @@ import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me } from '../initial_state'; +import { me, maxReactions } from '../initial_state'; import classNames from 'classnames'; import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; +import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -27,6 +28,7 @@ const messages = defineMessages({ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + react: { id: 'status.react', defaultMessage: 'React' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, @@ -66,6 +68,7 @@ class StatusActionBar extends ImmutablePureComponent { relationship: ImmutablePropTypes.map, onReply: PropTypes.func, onFavourite: PropTypes.func, + onReactionAdd: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, @@ -127,6 +130,10 @@ class StatusActionBar extends ImmutablePureComponent { } } + handleEmojiPick = data => { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, ''), data.imageUrl); + } + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -230,6 +237,8 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); } + handleNoOp = () => {} // hack for reaction add button + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn } = this.context.identity; @@ -350,11 +359,27 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = signedIn && status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + + ); + return (
+ { + signedIn + ? + : reactButton + } {shareButton} diff --git a/app/javascript/mastodon/components/status_reactions.js b/app/javascript/mastodon/components/status_reactions.js new file mode 100644 index 0000000000..ff025e8d28 --- /dev/null +++ b/app/javascript/mastodon/components/status_reactions.js @@ -0,0 +1,170 @@ +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif, reduceMotion } from '../initial_state'; +import spring from 'react-motion/lib/spring'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import classNames from 'classnames'; +import React from 'react'; +import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; +import AnimatedNumber from './animated_number'; +import { assetHost } from '../utils/config'; + +export default class StatusReactions extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + numVisible: PropTypes.number, + addReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + removeReaction: PropTypes.func.isRequired, + }; + + willEnter() { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave() { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render() { + const { reactions, numVisible } = this.props; + let visibleReactions = reactions + .filter(x => x.get('count') > 0) + .sort((a, b) => b.get('count') - a.get('count')); + + if (numVisible >= 0) { + visibleReactions = visibleReactions.filter((_, i) => i < numVisible); + } + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} +
+ )} +
+ ); + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + canReact: PropTypes.bool.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, statusId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(statusId, reaction.get('name')); + } else { + addReaction(statusId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render() { + const { reaction } = this.props; + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + hovered: PropTypes.bool.isRequired, + url: PropTypes.string, + staticUrl: PropTypes.string, + }; + + render() { + const { emoji, hovered, url, staticUrl } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else { + const filename = (autoPlayGif || hovered) ? url : staticUrl; + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } + } + +} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 294105f259..70adc04934 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; -import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; +import { makeGetStatus, makeGetPictureInPicture, makeCustomEmojiMap } from '../selectors'; import { replyCompose, mentionCompose, @@ -16,6 +16,8 @@ import { unbookmark, pin, unpin, + addReaction, + removeReaction, } from '../actions/interactions'; import { muteStatus, @@ -66,6 +68,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), pictureInPicture: getPictureInPicture(state, props), + emojiMap: makeCustomEmojiMap(state), }); return mapStateToProps; @@ -129,6 +132,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name, url) { + dispatch(addReaction(statusId, name, url)); + }, + + onReactionRemove (statusId, name) { + dispatch(removeReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal('EMBED', { url: status.get('url'), diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 12eff1bab1..3415fe2cbf 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -319,6 +319,7 @@ class EmojiPickerDropdown extends React.PureComponent { onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, button: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -356,7 +357,7 @@ class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (!this.state.disabled && !this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index a38f8d3c2d..3b36e634e5 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -115,6 +115,17 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js index 368eb0b7e6..2b5f48f0b4 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.js +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js @@ -6,6 +6,7 @@ import Icon from 'mastodon/components/icon'; const tooltips = defineMessages({ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + reactions: { id: 'notifications.filter.reactions', defaultMessage: 'Reactions' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, @@ -74,6 +75,13 @@ class FilterBar extends React.PureComponent { > +