From a445e38694c698c698b8d4b315755af050f040da Mon Sep 17 00:00:00 2001 From: fef Date: Thu, 24 Nov 2022 17:30:52 +0000 Subject: [PATCH] add frontend for emoji reactions this is still pretty bare bones but hey, it works. --- .../flavours/glitch/actions/interactions.js | 87 ++++++++- .../flavours/glitch/components/status.jsx | 12 ++ .../glitch/components/status_reactions_bar.js | 177 ++++++++++++++++++ .../glitch/containers/status_container.js | 18 ++ .../flavours/glitch/reducers/statuses.js | 44 +++++ 5 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 app/javascript/flavours/glitch/components/status_reactions_bar.js diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index c7b552a656..08987ad664 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 STATUS_REACTION_UPDATE = 'STATUS_REACTION_UPDATE'; + +export const STATUS_REACTION_ADD_REQUEST = 'STATUS_REACTION_ADD_REQUEST'; +export const STATUS_REACTION_ADD_SUCCESS = 'STATUS_REACTION_ADD_SUCCESS'; +export const STATUS_REACTION_ADD_FAIL = 'STATUS_REACTION_ADD_FAIL'; + +export const STATUS_REACTION_REMOVE_REQUEST = 'STATUS_REACTION_REMOVE_REQUEST'; +export const STATUS_REACTION_REMOVE_SUCCESS = 'STATUS_REACTION_REMOVE_SUCCESS'; +export const STATUS_REACTION_REMOVE_FAIL = 'STATUS_REACTION_REMOVE_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -391,4 +401,79 @@ export function unpinFail(status, error) { status, error, }; -} +}; + +export const statusAddReaction = (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(statusAddReactionRequest(statusId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => { + dispatch(statusAddReactionSuccess(statusId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(statusAddReactionFail(statusId, name, err)); + } + }); +}; + +export const statusAddReactionRequest = (statusId, name) => ({ + type: STATUS_REACTION_ADD_REQUEST, + id: statusId, + name, + skipLoading: true, +}); + +export const statusAddReactionSuccess = (statusId, name) => ({ + type: STATUS_REACTION_ADD_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const statusAddReactionFail = (statusId, name, error) => ({ + type: STATUS_REACTION_ADD_FAIL, + id: statusId, + name, + error, + skipLoading: true, +}); + +export const statusRemoveReaction = (statusId, name) => (dispatch, getState) => { + dispatch(statusRemoveReactionRequest(statusId, name)); + + api(getState).delete(`/api/v1/statuses/${statusId}/reactions/${name}`).then(() => { + dispatch(statusRemoveReactionSuccess(statusId, name)); + }).catch(err => { + dispatch(statusRemoveReactionFail(statusId, name, err)); + }); +}; + +export const statusRemoveReactionRequest = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_REQUEST, + id: statusId, + name, + skipLoading: true, +}); + +export const statusRemoveReactionSuccess = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_SUCCESS, + id: statusId, + name, + skipLoading: true, +}); + +export const statusRemoveReactionFail = (statusId, name) => ({ + type: STATUS_REACTION_REMOVE_FAIL, + id: statusId, + name, + skipLoading: true, +}); diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 25eac0d452..ee3cbb9134 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -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 StatusReactionsBar from './status_reactions_bar'; import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; @@ -77,6 +78,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, @@ -104,6 +107,7 @@ class Status extends ImmutablePureComponent { scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, settings: ImmutablePropTypes.map.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, @@ -820,6 +824,14 @@ class Status extends ImmutablePureComponent { rewriteMentions={settings.get('rewrite_mentions')} /> + + {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { + const { addReaction, statusId } = this.props; + addReaction(statusId, data.native.replace(/:/g, '')); + } + + willEnter() { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave() { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render() { + const { reactions } = this.props; + const visibleReactions = reactions.filter(x => x.get('count') > 0); + + 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 }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); + } + +} + +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/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 8f3fce0705..0038b5ef83 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -15,6 +15,8 @@ import { unbookmark, pin, unpin, + statusAddReaction, + statusRemoveReaction, } from 'flavours/glitch/actions/interactions'; import { muteStatus, @@ -39,6 +41,13 @@ import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { defineMessages, injectIntl } from 'react-intl'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state'; import { showAlertForError } from '../actions/alerts'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import Spoilers from '../components/spoilers'; +import Icon from 'flavours/glitch/components/icon'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; + +const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -82,6 +91,7 @@ const makeMapStateToProps = () => { account: account || props.account, settings: state.get('local_settings'), prepend: prepend || props.prepend, + emojiMap: customEmojiMap(state), pictureInPicture: getPictureInPicture(state, props), }; }; @@ -161,6 +171,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onReactionAdd (statusId, name) { + dispatch(statusAddReaction(statusId, name)); + }, + + onReactionRemove (statusId, name) { + dispatch(statusRemoveReaction(statusId, name)); + }, + onEmbed (status) { dispatch(openModal('EMBED', { url: status.get('url'), diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index ca220c54d6..9e5533c91c 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -6,6 +6,11 @@ import { UNFAVOURITE_SUCCESS, BOOKMARK_REQUEST, BOOKMARK_FAIL, + STATUS_REACTION_UPDATE, + STATUS_REACTION_ADD_FAIL, + STATUS_REACTION_REMOVE_FAIL, + STATUS_REACTION_ADD_REQUEST, + STATUS_REACTION_REMOVE_REQUEST, } from 'flavours/glitch/actions/interactions'; import { STATUS_MUTE_SUCCESS, @@ -37,6 +42,37 @@ const deleteStatus = (state, id, references) => { return state.delete(id); }; +const updateReaction = (state, id, name, updater) => state.update( + id, + status => status.update( + 'reactions', + reactions => { + const index = reactions.findIndex(reaction => reaction.get('name') === name); + if (index > -1) { + return reactions.update(index, reaction => updater(reaction)); + } else { + return reactions.push(updater(fromJS({ name, count: 0 }))); + } + }, + ), +); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.status_id, reaction.name, x => x.set('count', reaction.count)); + +const addReaction = (state, id, name) => updateReaction( + state, + id, + name, + x => x.set('me', true).update('count', n => n + 1), +); + +const removeReaction = (state, id, name) => updateReaction( + state, + id, + name, + x => x.set('me', false).update('count', n => n - 1), +); + const initialState = ImmutableMap(); export default function statuses(state = initialState, action) { @@ -63,6 +99,14 @@ export default function statuses(state = initialState, action) { return state.setIn([action.status.get('id'), 'reblogged'], true); case REBLOG_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'reblogged'], false); + case STATUS_REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case STATUS_REACTION_ADD_REQUEST: + case STATUS_REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name); + case STATUS_REACTION_REMOVE_REQUEST: + case STATUS_REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); case STATUS_MUTE_SUCCESS: return state.setIn([action.id, 'muted'], true); case STATUS_UNMUTE_SUCCESS: