From 88f2423ca47a92c9a003b3d53aa822a9d13b141b Mon Sep 17 00:00:00 2001 From: fef Date: Wed, 30 Nov 2022 17:25:36 +0100 Subject: [PATCH] rebase with upstream --- .../glitch/components/status_prepend.jsx | 2 +- .../mastodon/actions/interactions.js | 85 +++++++++ .../mastodon/actions/notifications.js | 1 + app/javascript/mastodon/components/status.jsx | 14 ++ .../mastodon/components/status_action_bar.jsx | 28 +++ .../mastodon/components/status_reactions.js | 179 ++++++++++++++++++ .../mastodon/containers/status_container.jsx | 13 +- .../components/emoji_picker_dropdown.jsx | 3 +- .../components/column_settings.jsx | 12 ++ .../notifications/components/filter_bar.jsx | 8 + .../notifications/components/notification.jsx | 35 ++++ .../features/status/components/action_bar.jsx | 27 ++- .../status/components/detailed_status.jsx | 12 ++ .../mastodon/features/status/index.jsx | 27 +++ app/javascript/mastodon/initial_state.js | 2 + app/javascript/mastodon/locales/de.json | 4 + app/javascript/mastodon/locales/en.json | 4 + app/javascript/mastodon/locales/fr.json | 8 +- app/javascript/mastodon/reducers/settings.js | 3 + app/javascript/mastodon/reducers/statuses.js | 44 +++++ app/javascript/mastodon/selectors/index.js | 8 + .../styles/mastodon/components.scss | 19 ++ 22 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 app/javascript/mastodon/components/status_reactions.js diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index 555115a0c4..3941dc98b9 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -60,7 +60,7 @@ export default class StatusPrepend extends React.PureComponent { return ( ); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index bc35736ffa..86607368ee 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/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)); @@ -412,3 +422,78 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +export const addReaction = (statusId, name) => (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, alreadyAdded)); + } + + api(getState).put(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(statusId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(statusId, name, err)); + } + }); +}; + +export const addReactionRequest = (statusId, name) => ({ + type: REACTION_ADD_REQUEST, + id: statusId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (statusId, name) => ({ + type: REACTION_ADD_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const addReactionFail = (statusId, name, error) => ({ + type: REACTION_ADD_FAIL, + id: statusId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (statusId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(statusId, name)); + + api(getState).delete(`/api/v1/statuses/${statusId}/reactions/${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, + skipLoading: true, +}); + +export const removeReactionSuccess = (statusId, name) => ({ + type: REACTION_REMOVE_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (statusId, name) => ({ + type: REACTION_REMOVE_FAIL, + id: statusId, + name, + skipLoading: true, +}); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 93588d3c0c..6834f01898 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -127,6 +127,7 @@ const excludeTypesFromFilter = filter => { 'follow', 'follow_request', 'favourite', + 'reaction', 'reblog', 'mention', 'poll', diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index cd8423b2f4..6281efc145 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -7,6 +7,7 @@ import RelativeTimestamp from './relative_timestamp'; import DisplayName from './display_name'; 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, defineMessages, FormattedMessage } from 'react-intl'; @@ -21,6 +22,7 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_ // We use the component (and not the container) since we do not want // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; +import { visibleReactions } from '../../flavours/glitch/initial_state'; export const textForScreenReader = (intl, status, rebloggedByText = false) => { const displayName = status.getIn(['account', 'display_name']); @@ -78,6 +80,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, @@ -101,6 +105,7 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, + emojiMap: ImmutablePropTypes.map.isRequired, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, @@ -556,6 +561,15 @@ class Status extends ImmutablePureComponent { {media} + + diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 7b4031b68e..6384eda56f 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -9,6 +9,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; import classNames from 'classnames'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; +import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container'; +import { maxReactions } from '../../flavours/glitch/initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -27,6 +29,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' }, @@ -65,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, @@ -126,6 +130,16 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleEmojiPick = data => { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onReactionAdd(this.props.status.get('id'), data.native.replace(/:/g, '')); + } else { + this.props.onInteractionModal('favourite', this.props.status); + } + } + handleReblogClick = e => { const { signedIn } = this.context.identity; @@ -229,6 +243,8 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); }; + nop = () => {} + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.context.identity; @@ -355,11 +371,23 @@ class StatusActionBar extends ImmutablePureComponent { ); + const canReact = status.get('reactions').filter(r => r.get('count') > 0 && r.get('me')).size < maxReactions; + const reactButton = ( + + ); + return (
+ {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..39956270a4 --- /dev/null +++ b/app/javascript/mastodon/components/status_reactions.js @@ -0,0 +1,179 @@ +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { 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'; +import { autoPlayGif } from '../initial_state'; + +export default class StatusReactions extends ImmutablePureComponent { + + static propTypes = { + statusId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + numVisible: PropTypes.number, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.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')); + + // numVisible might be NaN because it's pulled from local settings + // which doesn't do a whole lot of input validation, but that's okay + // because NaN >= 0 evaluates false. + // Still, this should be improved at some point. + 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, + emojiMap: ImmutablePropTypes.map.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; + + let shortCode = reaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + + ); + } + +} + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + hovered: PropTypes.bool.isRequired, + }; + + render() { + const { emoji, emojiMap, hovered } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) + ? emojiMap.getIn([emoji, 'url']) + : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } else { + return null; + } + } + +} diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index f483166540..198ba9ad55 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -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, @@ -69,6 +71,7 @@ const makeMapStateToProps = () => { status: getStatus(state, props), nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null, pictureInPicture: getPictureInPicture(state, props), + emojiMap: makeCustomEmojiMap(state), }); return mapStateToProps; @@ -132,6 +135,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name) { + dispatch(addReaction(statusId, name)); + }, + + 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.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index 4fb131b476..867b761eab 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -316,6 +316,7 @@ class EmojiPickerDropdown extends React.PureComponent { onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, button: PropTypes.node, + disabled: PropTypes.bool, }; state = { @@ -349,7 +350,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.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx index 9251847bad..c93b47d263 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx +++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx @@ -6,6 +6,7 @@ import ClearColumnButton from './clear_column_button'; import GrantPermissionButton from './grant_permission_button'; import SettingToggle from './setting_toggle'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions'; +import PillBarButton from '../../../../flavours/glitch/features/notifications/components/pill_bar_button'; export default class ColumnSettings extends React.PureComponent { @@ -115,6 +116,17 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + +
+ + {showPushSettings && } + + +
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx index 88e95cf48f..edd4d6bc58 100644 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx +++ b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx @@ -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' }, @@ -73,6 +74,13 @@ class FilterBar extends React.PureComponent { > +