diff --git a/app/controllers/api/v1/reaction_deck_controller.rb b/app/controllers/api/v1/reaction_deck_controller.rb index cac2a9fb00..a65cd33e7f 100644 --- a/app/controllers/api/v1/reaction_deck_controller.rb +++ b/app/controllers/api/v1/reaction_deck_controller.rb @@ -18,6 +18,35 @@ class Api::V1::ReactionDeckController < Api::BaseController end def create + deck = [] + + (deck_params['emojis'] || []).each do |shortcode| + shortcode = shortcode.delete(':') + custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: nil) + + emoji_data = {} + + if custom_emoji + emoji_data['name'] = custom_emoji.shortcode + emoji_data['url'] = full_asset_url(custom_emoji.image.url) + emoji_data['static_url'] = full_asset_url(custom_emoji.image.url(:static)) + emoji_data['width'] = custom_emoji.image_width + emoji_data['height'] = custom_emoji.image_height + emoji_data['custom_emoji_id'] = custom_emoji.id + else + emoji_data['name'] = shortcode + end + + deck << emoji_data + end + + current_user.settings['reaction_deck'] = deck.to_json + current_user.save! + + render json: remove_metas(deck) + end + + def legacy_create deck = @deck (deck_params['emojis'] || []).each do |data| @@ -86,6 +115,7 @@ class Api::V1::ReactionDeckController < Api::BaseController deck.tap do |d| d.each do |item| item.delete('custom_emoji_id') + # item.delete('id') if item.key?('id') end end end diff --git a/app/javascript/mastodon/actions/reaction_deck.js b/app/javascript/mastodon/actions/reaction_deck.js index 5b8be5d62f..c1a1b0146e 100644 --- a/app/javascript/mastodon/actions/reaction_deck.js +++ b/app/javascript/mastodon/actions/reaction_deck.js @@ -47,11 +47,11 @@ export function fetchReactionDeckFail(error) { }; } -export function updateReactionDeck(id, emoji) { +export function updateReactionDeck(emojis) { return (dispatch, getState) => { dispatch(updateReactionDeckRequest()); - api(getState).post('/api/v1/reaction_deck', { emojis: [{ id, emoji: emoji.native || emoji.id }] }).then(response => { + api(getState).post('/api/v1/reaction_deck', { emojis }).then(response => { dispatch(updateReactionDeckSuccess(response.data)); }).catch(error => { dispatch(updateReactionDeckFail(error)); diff --git a/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx b/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx index dadcb1982e..d221ef857c 100644 --- a/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx +++ b/app/javascript/mastodon/features/reaction_deck/components/reaction_emoji.jsx @@ -2,12 +2,10 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { updateReactionDeck, removeReactionDeck } from 'mastodon/actions/reaction_deck'; import Button from 'mastodon/components/button'; import EmojiPickerDropdownContainer from 'mastodon/features/compose/containers/emoji_picker_dropdown_container'; import emojify from 'mastodon/features/emoji/emoji'; @@ -17,19 +15,10 @@ const messages = defineMessages({ remove: { id: 'reaction_deck.remove', defaultMessage: 'Remove' }, }); -const MapStateToProps = (state, { emojiId, emojiMap }) => ({ - emoji: (state.get('reaction_deck', ImmutableList()).toArray().find(em => em.get('id') === emojiId) || ImmutableMap({ emoji: { shortcode: '' } })).get('name'), - emojiMap, -}); - -const mapDispatchToProps = (dispatch, { emojiId }) => ({ - onChange: (emoji) => dispatch(updateReactionDeck(emojiId, emoji)), - onRemove: () => dispatch(removeReactionDeck(emojiId)), -}); - class ReactionEmoji extends ImmutablePureComponent { static propTypes = { + index: PropTypes.number, emoji: PropTypes.string, emojiMap: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, @@ -40,8 +29,16 @@ class ReactionEmoji extends ImmutablePureComponent { emoji: '', }; + handleChange = (emoji) => { + this.props.onChange(this.props.index, emoji); + }; + + handleRemove = () => { + this.props.onRemove(this.props.index); + }; + render () { - const { intl, emojiMap, emoji, onChange, onRemove } = this.props; + const { intl, emojiMap, emoji } = this.props; let content = null; @@ -69,13 +66,13 @@ class ReactionEmoji extends ImmutablePureComponent {
- +
{content}
-
@@ -84,4 +81,4 @@ class ReactionEmoji extends ImmutablePureComponent { } -export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(ReactionEmoji)); +export default connect(injectIntl(ReactionEmoji)); diff --git a/app/javascript/mastodon/features/reaction_deck/index.jsx b/app/javascript/mastodon/features/reaction_deck/index.jsx index a457272da5..feb309b804 100644 --- a/app/javascript/mastodon/features/reaction_deck/index.jsx +++ b/app/javascript/mastodon/features/reaction_deck/index.jsx @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import { useEffect, useState } from "react"; import { defineMessages, injectIntl } from 'react-intl'; @@ -10,15 +11,36 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; + +import { updateReactionDeck } from 'mastodon/actions/reaction_deck'; +import Button from 'mastodon/components/button'; import ColumnHeader from 'mastodon/components/column_header'; import LoadingIndicator from 'mastodon/components/loading_indicator'; import ScrollableList from 'mastodon/components/scrollable_list'; import Column from 'mastodon/features/ui/components/column'; + import ReactionEmoji from './components/reaction_emoji'; -const DECK_SIZE = 16; +// https://medium.com/@wbern/getting-react-18s-strict-mode-to-work-with-react-beautiful-dnd-47bc909348e4 +/* eslint react/prop-types: 0 */ +const StrictModeDroppable = ({ children, ...props }) => { + const [enabled, setEnabled] = useState(false); + useEffect(() => { + const animation = requestAnimationFrame(() => setEnabled(true)); + return () => { + cancelAnimationFrame(animation); + setEnabled(false); + }; + }, []); + if (!enabled) { + return null; + } + return {children}; +}; +/* eslint react/prop-types: 0 */ const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); @@ -33,6 +55,10 @@ const mapStateToProps = (state, props) => ({ emojiMap: customEmojiMap(state), }); +const mapDispatchToProps = (dispatch) => ({ + onChange: (emojis) => dispatch(updateReactionDeck(emojis)), +}); + class ReactionDeck extends ImmutablePureComponent { static propTypes = { @@ -42,8 +68,40 @@ class ReactionDeck extends ImmutablePureComponent { emojiMap: ImmutablePropTypes.map, multiColumn: PropTypes.bool, intl: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, }; + deckToArray = () => { + const { deck } = this.props; + + return deck.map((item) => item.get('name')).toArray(); + }; + + handleReorder = (result) => { + const newDeck = this.deckToArray(); + const deleted = newDeck.splice(result.source.index, 1); + newDeck.splice(result.destination.index, 0, deleted[0]); + this.props.onChange(newDeck); + }; + + handleChange = (index, emoji) => { + const newDeck = this.deckToArray(); + newDeck[index] = emoji.native || emoji.id.replace(':', ''); + this.props.onChange(newDeck); + }; + + handleRemove = (index) => { + const newDeck = this.deckToArray(); + newDeck.splice(index, 1); + this.props.onChange(newDeck); + }; + + handleAdd = () => { + const newDeck = this.deckToArray(); + newDeck.push('👍'); + this.props.onChange(newDeck); + } + render () { const { intl, deck, emojiMap, multiColumn } = this.props; @@ -69,9 +127,32 @@ class ReactionDeck extends ImmutablePureComponent { scrollKey='reaction_deck' bindToDocument={!multiColumn} > - {[...Array(DECK_SIZE).keys()].map(emojiId => - - )} + + + {(provided) => ( +
+ {deck.map((emoji, index) => ( + + {(provided2) => ( +
+ +
+ )} +
+ ))} + {provided.placeholder} + +
+ )} +
+
@@ -83,4 +164,4 @@ class ReactionDeck extends ImmutablePureComponent { } -export default connect(mapStateToProps)(injectIntl(ReactionDeck)); +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ReactionDeck)); diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index eff67cb837..7b2154aed4 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -86,7 +86,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer }, reaction_deck: { - max_emojis: 16, + max_emojis: 32_767, }, reactions: { diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index 059ce3a4d5..9c4ae9e3be 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -92,7 +92,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer }, reaction_deck: { - max_emojis: 16, + max_emojis: 32_767, }, reactions: { diff --git a/package.json b/package.json index 13720c56b4..d96e9b189b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "prop-types": "^15.8.1", "punycode": "^2.3.0", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-hotkeys": "^1.1.4", diff --git a/yarn.lock b/yarn.lock index ae89f81d37..773070a0a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1062,7 +1062,7 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q== @@ -2006,7 +2006,7 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== -"@types/hoist-non-react-statics@^3.3.1": +"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== @@ -2222,6 +2222,16 @@ dependencies: react-overlays "*" +"@types/react-redux@^7.1.20": + version "7.1.25" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.25.tgz#de841631205b24f9dfb4967dd4a7901e048f9a88" + integrity sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-router-dom@^5.3.3": version "5.3.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" @@ -4222,6 +4232,13 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-declaration-sorter@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz#be5e1d71b7a992433fb1c542c7a1b835e45682ec" @@ -7940,6 +7957,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +memoize-one@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -9445,6 +9467,11 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +raf-schd@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + raf@^3.1.0: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -9482,6 +9509,19 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +react-beautiful-dnd@^13.1.1: + version "13.1.1" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" + integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== + dependencies: + "@babel/runtime" "^7.9.2" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.2.0" + redux "^4.0.4" + use-memo-one "^1.1.1" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -9568,7 +9608,7 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: +react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== @@ -9621,6 +9661,18 @@ react-redux-loading-bar@^5.0.4: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" +react-redux@^7.2.0: + version "7.2.9" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" + integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + react-redux@^8.0.4: version "8.0.5" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.5.tgz#e5fb8331993a019b8aaf2e167a93d10af469c7bd" @@ -9871,7 +9923,7 @@ redux-thunk@^2.4.2: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q== -redux@^4.0.0, redux@^4.2.1: +redux@^4.0.0, redux@^4.0.4, redux@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== @@ -11281,6 +11333,11 @@ tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== +tiny-invariant@^1.0.6: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" + integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== + tiny-queue@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tiny-queue/-/tiny-queue-0.2.1.tgz#25a67f2c6e253b2ca941977b5ef7442ef97a6046" @@ -11685,6 +11742,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-memo-one@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" + integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== + use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"