logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe
commit: 73253b87bfce0466848231da47e1864c6ff639b0
parent: 4a11ee9768f2abbaa9da0e2454e2793734cce040
Author: lain <lain@soykaf.club>
Date:   Tue, 28 Jan 2020 15:24:56 +0000

Merge branch 'feat/emoji-reactions' into 'develop'

Emoji reactions

See merge request pleroma/pleroma-fe!1049

Diffstat:

Msrc/components/conversation/conversation.js1+
Asrc/components/emoji_reactions/emoji_reactions.js32++++++++++++++++++++++++++++++++
Asrc/components/emoji_reactions/emoji_reactions.vue51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/react_button/react_button.js43+++++++++++++++++++++++++++++++++++++++++++
Asrc/components/react_button/react_button.vue109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/status/status.js6+++++-
Msrc/components/status/status.vue8++++++++
Msrc/i18n/en.json1+
Msrc/modules/statuses.js84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/services/api/api.service.js28++++++++++++++++++++++++++++
Msrc/services/entity_normalizer/entity_normalizer.service.js1+
Mtest/unit/specs/modules/statuses.spec.js45+++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 407 insertions(+), 2 deletions(-)

diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js @@ -150,6 +150,7 @@ const conversation = { if (!id) return this.highlight = id this.$store.dispatch('fetchFavsAndRepeats', id) + this.$store.dispatch('fetchEmojiReactionsBy', id) }, getHighlight () { return this.isExpanded ? this.highlight : null diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js @@ -0,0 +1,32 @@ + +const EmojiReactions = { + name: 'EmojiReactions', + props: ['status'], + computed: { + emojiReactions () { + return this.status.emoji_reactions + } + }, + methods: { + reactedWith (emoji) { + const user = this.$store.state.users.currentUser + const reaction = this.status.emoji_reactions.find(r => r.emoji === emoji) + return reaction.accounts && reaction.accounts.find(u => u.id === user.id) + }, + reactWith (emoji) { + this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) + }, + unreact (emoji) { + this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) + }, + emojiOnClick (emoji, event) { + if (this.reactedWith(emoji)) { + this.unreact(emoji) + } else { + this.reactWith(emoji) + } + } + } +} + +export default EmojiReactions diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue @@ -0,0 +1,51 @@ +<template> + <div class="emoji-reactions"> + <button + v-for="(reaction) in emojiReactions" + :key="reaction.emoji" + class="emoji-reaction btn btn-default" + :class="{ 'picked-reaction': reactedWith(reaction.emoji) }" + @click="emojiOnClick(reaction.emoji, $event)" + > + <span>{{ reaction.emoji }}</span> + <span>{{ reaction.count }}</span> + </button> + </div> +</template> + +<script src="./emoji_reactions.js" ></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.emoji-reactions { + display: flex; + margin-top: 0.25em; + flex-wrap: wrap; +} + +.emoji-reaction { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + &:first-child { + margin-right: 0.25em; + } + &:last-child { + width: 1.5em; + } + &:focus { + outline: none; + } +} + +.picked-reaction { + border: 1px solid var(--link, $fallback--link); + margin-left: -1px; // offset the border, can't use inset shadows either + margin-right: calc(0.5em - 1px); +} + +</style> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js @@ -0,0 +1,43 @@ +import { mapGetters } from 'vuex' + +const ReactButton = { + props: ['status', 'loggedIn'], + data () { + return { + showTooltip: false, + filterWord: '', + popperOptions: { + modifiers: { + preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' } + } + } + } + }, + methods: { + openReactionSelect () { + this.showTooltip = true + this.filterWord = '' + }, + closeReactionSelect () { + this.showTooltip = false + }, + addReaction (event, emoji) { + this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) + this.closeReactionSelect() + } + }, + computed: { + commonEmojis () { + return ['❤️', '😠', '👀', '😂', '🔥'] + }, + emojis () { + if (this.filterWord !== '') { + return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord)) + } + return this.$store.state.instance.emoji || [] + }, + ...mapGetters(['mergedConfig']) + } +} + +export default ReactButton diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue @@ -0,0 +1,109 @@ +<template> + <v-popover + :popper-options="popperOptions" + :open="showTooltip" + trigger="manual" + placement="top" + class="react-button-popover" + @hide="closeReactionSelect" + > + <div slot="popover"> + <div class="reaction-picker-filter"> + <input + v-model="filterWord" + :placeholder="$t('emoji.search_emoji')" + > + </div> + <div class="reaction-picker"> + <span + v-for="emoji in commonEmojis" + :key="emoji" + class="emoji-button" + @click="addReaction($event, emoji)" + > + {{ emoji }} + </span> + <div class="reaction-picker-divider" /> + <span + v-for="(emoji, key) in emojis" + :key="key" + class="emoji-button" + @click="addReaction($event, emoji.replacement)" + > + {{ emoji.replacement }} + </span> + <div class="reaction-bottom-fader" /> + </div> + </div> + <div + v-if="loggedIn" + @click.prevent="openReactionSelect" + > + <i + class="icon-smile button-icon add-reaction-button" + :title="$t('tool_tip.add_reaction')" + /> + </div> + </v-popover> +</template> + +<script src="./react_button.js" ></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.reaction-picker-filter { + padding: 0.5em; +} + +.reaction-picker-divider { + height: 1px; + width: 100%; + margin: 0.5em; + background-color: var(--border, $fallback--border); +} + +.reaction-picker { + width: 10em; + height: 9em; + font-size: 1.5em; + overflow-y: scroll; + display: flex; + flex-wrap: wrap; + padding: 0.5em; + text-align: center; + align-content: flex-start; + user-select: none; + + mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); + transition: mask-size 150ms; + mask-size: 100% 20px, 100% 20px, auto; + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + + .emoji-button { + cursor: pointer; + + flex-basis: 20%; + line-height: 1.5em; + align-content: center; + + &:hover { + transform: scale(1.25); + } + } +} + +.add-reaction-button { + cursor: pointer; + + &:hover { + color: $fallback--text; + color: var(--text, $fallback--text); + } +} + +</style> diff --git a/src/components/status/status.js b/src/components/status/status.js @@ -1,5 +1,6 @@ import Attachment from '../attachment/attachment.vue' import FavoriteButton from '../favorite_button/favorite_button.vue' +import ReactButton from '../react_button/react_button.vue' import RetweetButton from '../retweet_button/retweet_button.vue' import Poll from '../poll/poll.vue' import ExtraButtons from '../extra_buttons/extra_buttons.vue' @@ -11,6 +12,7 @@ import LinkPreview from '../link-preview/link-preview.vue' import AvatarList from '../avatar_list/avatar_list.vue' import Timeago from '../timeago/timeago.vue' import StatusPopover from '../status_popover/status_popover.vue' +import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js' @@ -319,6 +321,7 @@ const Status = { components: { Attachment, FavoriteButton, + ReactButton, RetweetButton, ExtraButtons, PostStatusForm, @@ -329,7 +332,8 @@ const Status = { LinkPreview, AvatarList, Timeago, - StatusPopover + StatusPopover, + EmojiReactions }, methods: { visibilityIcon (visibility) { diff --git a/src/components/status/status.vue b/src/components/status/status.vue @@ -354,6 +354,10 @@ </div> </transition> + <EmojiReactions + :status="status" + /> + <div v-if="!noHeading && !isPreview" class="status-actions media-body" @@ -382,6 +386,10 @@ :logged-in="loggedIn" :status="status" /> + <ReactButton + :logged-in="loggedIn" + :status="status" + /> <extra-buttons :status="status" @onError="showError" diff --git a/src/i18n/en.json b/src/i18n/en.json @@ -648,6 +648,7 @@ "repeat": "Repeat", "reply": "Reply", "favorite": "Favorite", + "add_reaction": "Add Reaction", "user_settings": "User Settings" }, "upload":{ diff --git a/src/modules/statuses.js b/src/modules/statuses.js @@ -1,4 +1,17 @@ -import { remove, slice, each, findIndex, find, maxBy, minBy, merge, first, last, isArray, omitBy } from 'lodash' +import { + remove, + slice, + each, + findIndex, + find, + maxBy, + minBy, + merge, + first, + last, + isArray, + omitBy +} from 'lodash' import { set } from 'vue' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' @@ -518,6 +531,50 @@ export const mutations = { newStatus.fave_num = newStatus.favoritedBy.length newStatus.favorited = !!newStatus.favoritedBy.find(({ id }) => currentUser.id === id) }, + addEmojiReactionsBy (state, { id, emojiReactions, currentUser }) { + const status = state.allStatusesObject[id] + set(status, 'emoji_reactions', emojiReactions) + }, + addOwnReaction (state, { id, emoji, currentUser }) { + const status = state.allStatusesObject[id] + const reactionIndex = findIndex(status.emoji_reactions, { emoji }) + const reaction = status.emoji_reactions[reactionIndex] || { emoji, count: 0, accounts: [] } + + const newReaction = { + ...reaction, + count: reaction.count + 1, + accounts: [ + ...reaction.accounts, + currentUser + ] + } + + // Update count of existing reaction if it exists, otherwise append at the end + if (reactionIndex >= 0) { + set(status.emoji_reactions, reactionIndex, newReaction) + } else { + set(status, 'emoji_reactions', [...status.emoji_reactions, newReaction]) + } + }, + removeOwnReaction (state, { id, emoji, currentUser }) { + const status = state.allStatusesObject[id] + const reactionIndex = findIndex(status.emoji_reactions, { emoji }) + if (reactionIndex < 0) return + + const reaction = status.emoji_reactions[reactionIndex] + + const newReaction = { + ...reaction, + count: reaction.count - 1, + accounts: reaction.accounts.filter(acc => acc.id === currentUser.id) + } + + if (newReaction.count > 0) { + set(status.emoji_reactions, reactionIndex, newReaction) + } else { + set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.emoji !== emoji)) + } + }, updateStatusWithPoll (state, { id, poll }) { const status = state.allStatusesObject[id] status.poll = poll @@ -622,6 +679,31 @@ const statuses = { commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser }) }) }, + reactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) { + const currentUser = rootState.users.currentUser + commit('addOwnReaction', { id, emoji, currentUser }) + rootState.api.backendInteractor.reactWithEmoji({ id, emoji }).then( + status => { + dispatch('fetchEmojiReactionsBy', id) + } + ) + }, + unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) { + const currentUser = rootState.users.currentUser + commit('removeOwnReaction', { id, emoji, currentUser }) + rootState.api.backendInteractor.unreactWithEmoji({ id, emoji }).then( + status => { + dispatch('fetchEmojiReactionsBy', id) + } + ) + }, + fetchEmojiReactionsBy ({ rootState, commit }, id) { + rootState.api.backendInteractor.fetchEmojiReactions({ id }).then( + emojiReactions => { + commit('addEmojiReactionsBy', { id, emojiReactions, currentUser: rootState.users.currentUser }) + } + ) + }, fetchFavs ({ rootState, commit }, id) { rootState.api.backendInteractor.fetchFavoritedByUsers({ id }) .then(favoritedByUsers => commit('addFavs', { id, favoritedByUsers, currentUser: rootState.users.currentUser })) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js @@ -74,6 +74,9 @@ const MASTODON_SEARCH_2 = `/api/v2/search` const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search' const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks' const MASTODON_STREAMING = '/api/v1/streaming' +const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/emoji_reactions_by` +const PLEROMA_EMOJI_REACT_URL = id => `/api/v1/pleroma/statuses/${id}/react_with_emoji` +const PLEROMA_EMOJI_UNREACT_URL = id => `/api/v1/pleroma/statuses/${id}/unreact_with_emoji` const oldfetch = window.fetch @@ -881,6 +884,28 @@ const fetchRebloggedByUsers = ({ id }) => { return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser)) } +const fetchEmojiReactions = ({ id }) => { + return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id) }) +} + +const reactWithEmoji = ({ id, emoji, credentials }) => { + return promisedRequest({ + url: PLEROMA_EMOJI_REACT_URL(id), + method: 'POST', + credentials, + payload: { emoji } + }).then(parseStatus) +} + +const unreactWithEmoji = ({ id, emoji, credentials }) => { + return promisedRequest({ + url: PLEROMA_EMOJI_UNREACT_URL(id), + method: 'POST', + credentials, + payload: { emoji } + }).then(parseStatus) +} + const reportUser = ({ credentials, userId, statusIds, comment, forward }) => { return promisedRequest({ url: MASTODON_REPORT_USER_URL, @@ -1130,6 +1155,9 @@ const apiService = { fetchPoll, fetchFavoritedByUsers, fetchRebloggedByUsers, + fetchEmojiReactions, + reactWithEmoji, + unreactWithEmoji, reportUser, updateNotificationSettings, search2, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js @@ -242,6 +242,7 @@ export const parseStatus = (data) => { output.is_local = pleroma.local output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct output.thread_muted = pleroma.thread_muted + output.emoji_reactions = pleroma.emoji_reactions } else { output.text = data.content output.summary = data.spoiler_text diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js @@ -241,6 +241,51 @@ describe('Statuses module', () => { }) }) + describe('emojiReactions', () => { + it('increments count in existing reaction', () => { + const state = defaultState() + const status = makeMockStatus({ id: '1' }) + status.emoji_reactions = [ { emoji: '😂', count: 1, accounts: [] } ] + + mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) + mutations.addOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } }) + expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2) + expect(state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id).to.eql('me') + }) + + it('adds a new reaction', () => { + const state = defaultState() + const status = makeMockStatus({ id: '1' }) + status.emoji_reactions = [] + + mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) + mutations.addOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } }) + expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) + expect(state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id).to.eql('me') + }) + + it('decreases count in existing reaction', () => { + const state = defaultState() + const status = makeMockStatus({ id: '1' }) + status.emoji_reactions = [ { emoji: '😂', count: 2, accounts: [{ id: 'me' }] } ] + + mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) + mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: {} }) + expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1) + expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql([]) + }) + + it('removes a reaction', () => { + const state = defaultState() + const status = makeMockStatus({ id: '1' }) + status.emoji_reactions = [{ emoji: '😂', count: 1, accounts: [{ id: 'me' }] }] + + mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' }) + mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: {} }) + expect(state.allStatusesObject['1'].emoji_reactions.length).to.eql(0) + }) + }) + describe('showNewStatuses', () => { it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => { const state = defaultState()