Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/spreed.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/Message.spec.js479
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/Message.vue387
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js437
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue453
-rw-r--r--src/components/Quote.vue3
-rw-r--r--src/components/UploadEditor.vue38
-rw-r--r--src/services/messagesService.js21
-rw-r--r--src/store/messagesStore.js101
-rw-r--r--src/store/messagesStore.spec.js7
-rw-r--r--src/store/reactionsStore.js103
-rw-r--r--src/store/storeConfig.js2
11 files changed, 1372 insertions, 659 deletions
diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js
index 02017389f..608fe10a8 100644
--- a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js
+++ b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js
@@ -1,21 +1,21 @@
-import Vuex from 'vuex'
-import { createLocalVue, shallowMount } from '@vue/test-utils'
+import Vuex, { Store } from 'vuex'
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils'
import { cloneDeep } from 'lodash'
import { EventBus } from '../../../../services/EventBus'
import storeConfig from '../../../../store/storeConfig'
-import { CONVERSATION, PARTICIPANT, ATTENDEE } from '../../../../constants'
+import { CONVERSATION, ATTENDEE } from '../../../../constants'
import Check from 'vue-material-design-icons/Check'
import CheckAll from 'vue-material-design-icons/CheckAll'
import Quote from '../../../Quote'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Mention from './MessagePart/Mention'
import FilePreview from './MessagePart/FilePreview'
import DeckCard from './MessagePart/DeckCard'
import Location from './MessagePart/Location'
import DefaultParameter from './MessagePart/DefaultParameter'
-import { findActionButton } from '../../../../test-helpers'
+import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue'
import Message from './Message'
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
// needed because of https://github.com/vuejs/vue-test-utils/issues/1507
const RichTextStub = {
@@ -75,6 +75,20 @@ describe('Message.vue', () => {
systemMessage: '',
messageType: 'comment',
}
+
+ // Dummy message getter so that the message component is always
+ // properly mounted.
+ testStoreConfig.modules.messagesStore.getters.message
+ = jest.fn().mockReturnValue(() => {
+ return {
+ reactions: '',
+ }
+ })
+
+ // Dummy hasReactions getter so that the message component is always
+ // properly mounted.
+ testStoreConfig.modules.messagesStore.getters.hasReactions
+ = jest.fn().mockReturnValue(() => false)
})
afterEach(() => {
@@ -83,7 +97,7 @@ describe('Message.vue', () => {
describe('message rendering', () => {
beforeEach(() => {
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
})
test('renders rich text message', async () => {
@@ -126,7 +140,7 @@ describe('Message.vue', () => {
message: 'message two',
}]
})
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
})
test('shows join call button on last message when a call is in progress', () => {
@@ -246,12 +260,13 @@ describe('Message.vue', () => {
messageParameters: {},
token: TOKEN,
parentId: -1,
+ reactions: '',
}
messageProps.parent = 120
const messageGetterMock = jest.fn().mockReturnValue(parentMessage)
testStoreConfig.modules.messagesStore.getters.message = jest.fn(() => messageGetterMock)
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
const wrapper = shallowMount(Message, {
localVue,
@@ -482,7 +497,7 @@ describe('Message.vue', () => {
describe('author rendering', () => {
const AUTHOR_SELECTOR = '.message-body__author'
beforeEach(() => {
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
})
test('renders author if first message', async () => {
@@ -511,10 +526,9 @@ describe('Message.vue', () => {
})
describe('actions', () => {
- const ACTIONS_SELECTOR = '.message__buttons-bar'
beforeEach(() => {
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
})
test('does not render actions for system messages are available', async () => {
@@ -528,8 +542,8 @@ describe('Message.vue', () => {
await wrapper.vm.$nextTick()
- const actionsEl = wrapper.find(ACTIONS_SELECTOR)
- expect(actionsEl.exists()).toBe(false)
+ const messageButtonsBar = wrapper.findComponent(MessageButtonsBar)
+ expect(messageButtonsBar.exists()).toBe(false)
})
test('does not render actions for temporary messages', async () => {
@@ -543,13 +557,13 @@ describe('Message.vue', () => {
await wrapper.vm.$nextTick()
- const actionsEl = wrapper.find(ACTIONS_SELECTOR)
- expect(actionsEl.exists()).toBe(false)
+ const messageButtonsBar = wrapper.findComponent(MessageButtonsBar)
+ expect(messageButtonsBar.exists()).toBe(false)
})
test('actions become visible on mouse over', async () => {
messageProps.sendingFailure = 'timeout'
- const wrapper = shallowMount(Message, {
+ const wrapper = mount(Message, {
localVue,
store,
propsData: messageProps,
@@ -557,437 +571,76 @@ describe('Message.vue', () => {
await wrapper.vm.$nextTick()
- const actionsEl = wrapper.find(ACTIONS_SELECTOR)
+ const messageButtonsBar = wrapper.findComponent(MessageButtonsBar)
- expect(wrapper.vm.showActions).toBe(false)
- expect(actionsEl.isVisible()).toBe(false)
+ expect(wrapper.vm.showMessageButtonsBar).toBe(false)
+ expect(messageButtonsBar.isVisible()).toBe(false)
await wrapper.find('.message-body').trigger('mouseover')
- expect(wrapper.vm.showActions).toBe(true)
- expect(actionsEl.isVisible()).toBe(true)
+ expect(wrapper.vm.showMessageButtonsBar).toBe(true)
+ expect(messageButtonsBar.isVisible()).toBe(true)
await wrapper.find('.message-body').trigger('mouseleave')
- expect(wrapper.vm.showActions).toBe(false)
- expect(actionsEl.isVisible()).toBe(false)
+ expect(wrapper.vm.showMessageButtonsBar).toBe(false)
+ expect(messageButtonsBar.isVisible()).toBe(false)
// actions are always present and rendered
const actions = wrapper.findAllComponents({ name: 'Actions' })
expect(actions.length).toBe(2)
})
+ })
- describe('reply action', () => {
- test('replies to message', async () => {
- const replyAction = jest.fn()
- testStoreConfig.modules.quoteReplyStore.actions.addMessageToBeReplied = replyAction
- store = new Vuex.Store(testStoreConfig)
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- propsData: messageProps,
- })
-
- await wrapper.find('.message-body').trigger('mouseover')
- const actionButton = findActionButton(wrapper, 'Reply')
- expect(actionButton.exists()).toBe(true)
- expect(actionButton.isVisible()).toBe(true)
- await actionButton.find('button').trigger('click')
-
- expect(replyAction).toHaveBeenCalledWith(expect.anything(), {
- id: 123,
- actorId: 'user-id-1',
- actorType: 'users',
- actorDisplayName: 'user-display-name-1',
- message: 'test message',
- messageParameters: {},
- messageType: 'comment',
- systemMessage: '',
- timestamp: new Date('2020-05-07 09:23:00').getTime() / 1000,
- token: TOKEN,
- })
- })
-
- test('hides reply button when not replyable', async () => {
- messageProps.isReplyable = false
- store = new Vuex.Store(testStoreConfig)
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Reply')
- expect(actionButton.isVisible()).toBe(false)
- })
- })
-
- describe('private reply action', () => {
- test('creates a new conversation when replying to message privately', async () => {
- const routerPushMock = jest.fn().mockResolvedValue()
- const createOneToOneConversation = jest.fn()
- testStoreConfig.modules.conversationsStore.actions.createOneToOneConversation = createOneToOneConversation
- store = new Vuex.Store(testStoreConfig)
-
- messageProps.actorId = 'another-user'
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- mocks: {
- $router: {
- push: routerPushMock,
- },
- },
- stubs: {
- ActionButton,
- },
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Reply privately')
- expect(actionButton.exists()).toBe(true)
-
- createOneToOneConversation.mockResolvedValueOnce({
- token: 'new-token',
- })
-
- await actionButton.find('button').trigger('click')
-
- expect(createOneToOneConversation).toHaveBeenCalledWith(expect.anything(), 'another-user')
-
- expect(routerPushMock).toHaveBeenCalledWith({
- name: 'conversation',
- params: {
- token: 'new-token',
- },
- })
- })
-
- /**
- * @param {boolean} visible Whether or not the reply-private action is visible
- */
- function testPrivateReplyActionVisible(visible) {
- store = new Vuex.Store(testStoreConfig)
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Reply privately')
- expect(actionButton.exists()).toBe(visible)
- }
-
- test('hides private reply action for own messages', async () => {
- // using default message props which have the
- // actor id set to the current user
- testPrivateReplyActionVisible(false)
- })
-
- test('hides private reply action for one to one conversation type', async () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE
- testPrivateReplyActionVisible(false)
- })
-
- test('hides private reply action for guest messages', async () => {
- messageProps.actorId = 'guest-user'
- messageProps.actorType = ATTENDEE.ACTOR_TYPE.GUESTS
- testPrivateReplyActionVisible(false)
- })
-
- test('hides private reply action when current user is a guest', async () => {
- messageProps.actorId = 'another-user'
- getActorTypeMock.mockClear().mockReturnValue(() => ATTENDEE.ACTOR_TYPE.GUESTS)
- testPrivateReplyActionVisible(false)
- })
- })
-
- describe('delete action', () => {
- test('deletes message', async () => {
- let resolveDeleteMessage
- const deleteMessage = jest.fn().mockReturnValue(new Promise((resolve, reject) => { resolveDeleteMessage = resolve }))
- testStoreConfig.modules.messagesStore.actions.deleteMessage = deleteMessage
- store = new Vuex.Store(testStoreConfig)
-
- // need to mock the date to be within 6h
- const mockDate = new Date('2020-05-07 10:00:00')
- jest.spyOn(global.Date, 'now')
- .mockImplementation(() => mockDate)
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Delete')
- expect(actionButton.exists()).toBe(true)
-
- await actionButton.find('button').trigger('click')
-
- expect(deleteMessage).toHaveBeenCalledWith(expect.anything(), {
- message: {
- token: TOKEN,
- id: 123,
- },
- placeholder: expect.anything(),
- })
-
- await wrapper.vm.$nextTick()
- expect(wrapper.vm.isDeleting).toBe(true)
- expect(wrapper.find('.icon-loading-small').exists()).toBe(true)
-
- resolveDeleteMessage(200)
- // needs two updates...
- await wrapper.vm.$nextTick()
- await wrapper.vm.$nextTick()
-
- expect(wrapper.vm.isDeleting).toBe(false)
- expect(wrapper.find('.icon-loading-small').exists()).toBe(false)
- })
-
- /**
- * @param {boolean} visible Whether or not the delete action is visible
- * @param {Date} mockDate The message date (deletion only works within 6h)
- * @param {number} participantType The participant type of the user
- */
- function testDeleteMessageVisible(visible, mockDate, participantType = PARTICIPANT.TYPE.USER) {
- store = new Vuex.Store(testStoreConfig)
-
- // need to mock the date to be within 6h
- if (!mockDate) {
- mockDate = new Date('2020-05-07 10:00:00')
- }
-
- jest.spyOn(global.Date, 'now')
- .mockImplementation(() => mockDate)
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- mixins: [{
- computed: {
- participant: () => {
- return {
- actorId: 'user-id-1',
- actorType: ATTENDEE.ACTOR_TYPE.USERS,
- participantType,
- }
- },
- },
- }],
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Delete')
- expect(actionButton.exists()).toBe(visible)
- }
-
- test('hides delete action when message is older than 6 hours', () => {
- testDeleteMessageVisible(false, new Date('2020-05-07 15:24:00'))
- })
-
- test('hides delete action when the conversation is read-only', () => {
- conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
- testDeleteMessageVisible(false)
- })
-
- test('hides delete action for file messages', () => {
- messageProps.message = '{file}'
- messageProps.messageParameters.file = {}
- testDeleteMessageVisible(false)
- })
-
- test('hides delete action on other people messages for non-moderators', () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.GROUP
- testDeleteMessageVisible(false)
- })
-
- test('shows delete action on other people messages for moderators', () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.GROUP
- testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.MODERATOR)
- })
-
- test('shows delete action on other people messages for owner', () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.PUBLIC
- testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.OWNER)
- })
-
- test('does not show delete action even for guest moderators', () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.PUBLIC
- testDeleteMessageVisible(false, null, PARTICIPANT.TYPE.GUEST_MODERATOR)
- })
-
- test('does not show delete action on other people messages in one to one conversations', () => {
- messageProps.actorId = 'another-user'
- conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE
- testDeleteMessageVisible(false)
- })
- })
-
- test('marks message as unread', async () => {
- const updateLastReadMessageAction = jest.fn().mockResolvedValueOnce()
- const fetchConversationAction = jest.fn().mockResolvedValueOnce()
- testStoreConfig.modules.conversationsStore.actions.updateLastReadMessage = updateLastReadMessageAction
- testStoreConfig.modules.conversationsStore.actions.fetchConversation = fetchConversationAction
- store = new Vuex.Store(testStoreConfig)
-
- messageProps.previousMessageId = 100
-
- // appears even with more restrictive conditions
- conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
- messageProps.actorId = 'another-user'
-
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
- },
- mixins: [{
- computed: {
- participant: () => {
- return {
- actorId: 'guest-id-1',
- actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
- participantType: PARTICIPANT.TYPE.GUEST,
- }
- },
- },
- }],
- propsData: messageProps,
- })
-
- const actionButton = findActionButton(wrapper, 'Mark as unread')
- expect(actionButton.exists()).toBe(true)
-
- await actionButton.find('button').trigger('click')
- // needs two updates...
- await wrapper.vm.$nextTick()
- await wrapper.vm.$nextTick()
-
- expect(updateLastReadMessageAction).toHaveBeenCalledWith(expect.anything(), {
- token: TOKEN,
- id: 100,
- updateVisually: true,
- })
-
- expect(fetchConversationAction).toHaveBeenCalledWith(expect.anything(), {
- token: TOKEN,
- })
- })
-
- test('copies message link', async () => {
- const copyTextMock = jest.fn()
+ describe('delete action', () => {
+ test('deletes message', async () => {
+ let resolveDeleteMessage
+ const deleteMessage = jest.fn().mockReturnValue(new Promise((resolve, reject) => { resolveDeleteMessage = resolve }))
+ testStoreConfig.modules.messagesStore.actions.deleteMessage = deleteMessage
+ store = new Store(testStoreConfig)
- // appears even with more restrictive conditions
- conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
- messageProps.actorId = 'another-user'
+ // need to mock the date to be within 6h
+ const mockDate = new Date('2020-05-07 10:00:00')
+ jest.spyOn(global.Date, 'now')
+ .mockImplementation(() => mockDate)
- const wrapper = shallowMount(Message, {
+ const wrapper = mount(Message, {
localVue,
store,
- mocks: {
- $copyText: copyTextMock,
- },
stubs: {
ActionButton,
+ MessageButtonsBar,
},
- mixins: [{
- computed: {
- participant: () => {
- return {
- actorId: 'guest-id-1',
- actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
- participantType: PARTICIPANT.TYPE.GUEST,
- }
- },
- },
- }],
propsData: messageProps,
})
- const actionButton = findActionButton(wrapper, 'Copy message link')
- expect(actionButton.exists()).toBe(true)
-
- await actionButton.find('button').trigger('click')
+ wrapper.find(MessageButtonsBar).vm.$emit('delete')
- expect(copyTextMock).toHaveBeenCalledWith('http://localhost/nc-webroot/call/XXTOKENXX#message_123')
- })
-
- test('renders clickable custom actions', async () => {
- const handler = jest.fn()
- const handler2 = jest.fn()
- const actionsGetterMock = jest.fn().mockReturnValue([{
- label: 'first action',
- icon: 'some-icon',
- callback: handler,
- }, {
- label: 'second action',
- icon: 'some-icon2',
- callback: handler2,
- }])
- testStoreConfig.modules.messageActionsStore.getters.messageActions = actionsGetterMock
- testStoreConfig.modules.messagesStore.getters.message = jest.fn(() => () => messageProps)
- store = new Vuex.Store(testStoreConfig)
- const wrapper = shallowMount(Message, {
- localVue,
- store,
- stubs: {
- ActionButton,
+ expect(deleteMessage).toHaveBeenCalledWith(expect.anything(), {
+ message: {
+ token: TOKEN,
+ id: 123,
},
- propsData: messageProps,
+ placeholder: expect.anything(),
})
- const actionButton = findActionButton(wrapper, 'first action')
- expect(actionButton.exists()).toBe(true)
- await actionButton.find('button').trigger('click')
-
- expect(handler).toHaveBeenCalledWith({
- apiVersion: 'v3',
- message: messageProps,
- metadata: conversationProps,
- })
+ await wrapper.vm.$nextTick()
+ expect(wrapper.vm.isDeleting).toBe(true)
+ expect(wrapper.find('.icon-loading-small').exists()).toBe(true)
- const actionButton2 = findActionButton(wrapper, 'second action')
- expect(actionButton2.exists()).toBe(true)
- await actionButton2.find('button').trigger('click')
+ resolveDeleteMessage(200)
+ // needs two updates...
+ await wrapper.vm.$nextTick()
+ await wrapper.vm.$nextTick()
- expect(handler2).toHaveBeenCalledWith({
- apiVersion: 'v3',
- message: messageProps,
- metadata: conversationProps,
- })
+ expect(wrapper.vm.isDeleting).toBe(false)
+ expect(wrapper.find('.icon-loading-small').exists()).toBe(false)
})
})
describe('status', () => {
beforeEach(() => {
- store = new Vuex.Store(testStoreConfig)
+ store = new Store(testStoreConfig)
})
test('lets user retry sending a timed out message', async () => {
diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue
index 280b231e3..8cbe9650f 100644
--- a/src/components/MessagesList/MessagesGroup/Message/Message.vue
+++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue
@@ -67,6 +67,7 @@ the main body of the message as well as a quote.
class="date"
:style="{'visibility': hasDate ? 'visible' : 'hidden'}"
:class="{'date--self': showSentIcon}">{{ messageTime }}</span>
+
<!-- Message delivery status indicators -->
<div v-if="sendingFailure"
v-tooltip.auto="sendingErrorIconTooltip"
@@ -78,13 +79,13 @@ the main body of the message as well as a quote.
@focus="showReloadButton = true"
@mouseleave="showReloadButton = true"
@blur="showReloadButton = true">
- <button v-if="sendingErrorCanRetry && showReloadButton"
+ <Button v-if="sendingErrorCanRetry && showReloadButton"
class="nc-button nc-button__main--dark"
@click="handleRetry">
<Reload decorative
title=""
:size="16" />
- </button>
+ </Button>
<AlertCircle v-else
decorative
title=""
@@ -112,90 +113,58 @@ the main body of the message as well as a quote.
</div>
</div>
</div>
- <!-- Message Actions -->
- <div v-if="hasActions"
- v-show="showActions"
- class="message__buttons-bar">
- <Actions v-show="isReplyable">
- <ActionButton icon="icon-reply"
- @click.stop="handleReply">
- {{ t('spreed', 'Reply') }}
- </ActionButton>
- </Actions>
- <Actions :force-menu="true"
- :container="container"
- :boundaries-element="containerElement">
- <ActionButton v-if="isPrivateReplyable"
- icon="icon-user"
- :close-after-click="true"
- @click.stop="handlePrivateReply">
- {{ t('spreed', 'Reply privately') }}
- </ActionButton>
- <ActionButton icon="icon-external"
- :close-after-click="true"
- @click.stop.prevent="handleCopyMessageLink">
- {{ t('spreed', 'Copy message link') }}
- </ActionButton>
- <ActionButton :close-after-click="true"
- @click.stop="handleMarkAsUnread">
- <template #icon>
- <EyeOffOutline decorative
- title=""
- :size="16" />
- </template>
- {{ t('spreed', 'Mark as unread') }}
- </ActionButton>
- <ActionLink v-if="linkToFile"
- icon="icon-text"
- :href="linkToFile">
- {{ t('spreed', 'Go to file') }}
- </ActionLink>
- <ActionButton v-if="!isCurrentGuest && !isFileShare"
- :close-after-click="true"
- @click.stop="showForwarder = true">
- <Share slot="icon"
- :size="16"
- decorative
- title="" />
- {{ t('spreed', 'Forward message') }}
- </ActionButton>
- <ActionSeparator v-if="messageActions.length > 0" />
- <template v-for="action in messageActions">
- <ActionButton :key="action.label"
- :icon="action.icon"
- :close-after-click="true"
- @click="action.callback(messageAPIData)">
- {{ action.label }}
- </ActionButton>
- </template>
- <template v-if="isDeleteable">
- <ActionSeparator />
- <ActionButton icon="icon-delete"
- :close-after-click="true"
- @click.stop="handleDelete">
- {{ t('spreed', 'Delete') }}
- </ActionButton>
- </template>
- </Actions>
+
+ <!-- reactions buttons and popover with details -->
+ <div v-if="hasReactions"
+ class="message-body__reactions"
+ @mouseover="handleReactionsMouseOver">
+ <Popover v-for="reaction in Object.keys(simpleReactions)"
+ :key="reaction"
+ :delay="200"
+ trigger="hover">
+ <button v-if="simpleReactions[reaction]!== 0"
+ slot="trigger"
+ class="reaction-button"
+ @click="handleReactionClick(reaction)">
+ <span class="reaction-button__emoji"> {{ reaction }} </span>
+ <span> {{ simpleReactions[reaction] }} </span>
+ </button>
+ <div v-if="detailedReactions" class="reaction-details">
+ <p v-for="detailedReaction in detailedReactions[reaction]" :key="detailedReaction.actorDisplayName">
+ {{ detailedReaction.actorDisplayName }}
+ </p>
+ </div>
+ </Popover>
+
+ <!-- More reactions picker -->
+ <EmojiPicker :per-line="5" @select="addReactionToMessage">
+ <button class="reaction-button">
+ <EmoticonOutline :size="15" />
+ </button>
+ </EmojiPicker>
</div>
</div>
+
+ <!-- Message actions -->
+ <MessageButtonsBar v-if="hasMessageButtonsBar"
+ v-show="showMessageButtonsBar"
+ ref="messageButtonsBar"
+ :message-api-data="messageApiData"
+ :message-object="messageObject"
+ v-bind="$props"
+ :previous-message-id="previousMessageId"
+ :participant="participant"
+ @delete="handleDelete" />
<div v-if="isLastReadMessage"
v-observe-visibility="lastReadMessageVisibilityChanged">
<div class="new-message-marker">
<span>{{ t('spreed', 'Unread messages') }}</span>
</div>
</div>
- <Forwarder v-if="showForwarder"
- :message-object="messageObject"
- @close="showForwarder = false" />
</li>
</template>
<script>
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
-import Actions from '@nextcloud/vue/dist/Components/Actions'
-import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
import CallButton from '../../../TopBar/CallButton'
import DeckCard from './MessagePart/DeckCard'
@@ -206,26 +175,20 @@ import RichText from '@juliushaertl/vue-richtext'
import AlertCircle from 'vue-material-design-icons/AlertCircle'
import Check from 'vue-material-design-icons/Check'
import CheckAll from 'vue-material-design-icons/CheckAll'
-import EyeOffOutline from 'vue-material-design-icons/EyeOffOutline'
import Reload from 'vue-material-design-icons/Reload'
-import Share from 'vue-material-design-icons/Share'
import Quote from '../../../Quote'
import isInCall from '../../../../mixins/isInCall'
import participant from '../../../../mixins/participant'
import { EventBus } from '../../../../services/EventBus'
import emojiRegex from 'emoji-regex'
-import { PARTICIPANT, CONVERSATION, ATTENDEE } from '../../../../constants'
import moment from '@nextcloud/moment'
-import {
- showError,
- showSuccess,
- showWarning,
- TOAST_DEFAULT_TIMEOUT,
-} from '@nextcloud/dialogs'
-import { generateUrl } from '@nextcloud/router'
import Location from './MessagePart/Location'
import Contact from './MessagePart/Contact.vue'
-import Forwarder from './MessagePart/Forwarder'
+import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue'
+import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
+import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
+import Popover from '@nextcloud/vue/dist/Components/Popover'
+import { showError, showSuccess, showWarning, TOAST_DEFAULT_TIMEOUT } from '@nextcloud/dialogs'
export default {
name: 'Message',
@@ -235,20 +198,17 @@ export default {
},
components: {
- Actions,
- ActionButton,
- ActionLink,
CallButton,
Quote,
RichText,
AlertCircle,
Check,
CheckAll,
- EyeOffOutline,
Reload,
- Share,
- ActionSeparator,
- Forwarder,
+ MessageButtonsBar,
+ EmojiPicker,
+ EmoticonOutline,
+ Popover,
},
mixins: [
@@ -388,16 +348,15 @@ export default {
data() {
return {
- showActions: false,
+ showMessageButtonsBar: false,
// Is tall enough for both actions and date upon hovering
isTallEnough: false,
showReloadButton: false,
isDeleting: false,
// whether the message was seen, only used if this was marked as last read message
seen: false,
- // Shows/hides the message forwarder component
- showForwarder: false,
isActionMenuOpen: false,
+ detailedReactionsRequested: false,
}
},
@@ -418,10 +377,6 @@ export default {
return this.$store.getters.message(this.token, this.id)
},
- isConversationReadOnly() {
- return this.conversation.readOnly === CONVERSATION.STATE.READ_ONLY
- },
-
isSystemMessage() {
return this.systemMessage !== ''
},
@@ -547,21 +502,13 @@ export default {
return false
}
- return this.isSystemMessage || !this.showActions || this.isTallEnough
+ return this.isSystemMessage || !this.showMessageButtonsBar || this.isTallEnough
},
- hasActions() {
+ hasMessageButtonsBar() {
return !this.isSystemMessage && !this.isTemporary
},
- container() {
- return this.$store.getters.getMainContainerSelector()
- },
-
- containerElement() {
- return document.querySelector(this.container)
- },
-
isTemporaryUpload() {
return this.isTemporary && this.messageParameters.file
},
@@ -595,55 +542,11 @@ export default {
return t('spreed', 'You cannot send messages to this conversation at the moment')
},
- isMyMsg() {
- return this.actorId === this.$store.getters.getActorId()
- && this.actorType === this.$store.getters.getActorType()
- },
-
- isFileShare() {
- return this.message === '{file}' && this.messageParameters?.file
- },
-
- linkToFile() {
- if (this.isFileShare) {
- return this.messageParameters?.file?.link
- }
- return ''
- },
-
- isDeleteable() {
- if (this.isConversationReadOnly) {
- return false
- }
-
- const isObjectShare = this.message === '{object}'
- && this.messageParameters?.object
-
- return (moment(this.timestamp * 1000).add(6, 'h')) > moment()
- && this.messageType === 'comment'
- && !this.isDeleting
- && !this.isFileShare
- && !isObjectShare
- && (this.isMyMsg
- || (this.conversation.type !== CONVERSATION.TYPE.ONE_TO_ONE
- && (this.participant.participantType === PARTICIPANT.TYPE.OWNER
- || this.participant.participantType === PARTICIPANT.TYPE.MODERATOR)))
- },
-
- isPrivateReplyable() {
- return this.isReplyable
- && (this.conversation.type === CONVERSATION.TYPE.PUBLIC
- || this.conversation.type === CONVERSATION.TYPE.GROUP)
- && !this.isMyMsg
- && this.actorType === ATTENDEE.ACTOR_TYPE.USERS
- && this.$store.getters.getActorType() === ATTENDEE.ACTOR_TYPE.USERS
- },
-
messageActions() {
return this.$store.getters.messageActions
},
- messageAPIData() {
+ messageApiData() {
return {
message: this.messageObject,
metadata: this.conversation,
@@ -651,8 +554,16 @@ export default {
}
},
- isCurrentGuest() {
- return this.$store.getters.getActorType() === 'guests'
+ hasReactions() {
+ return this.$store.getters.hasReactions(this.token, this.id)
+ },
+
+ simpleReactions() {
+ return this.messageObject.reactions
+ },
+
+ detailedReactions() {
+ return this.$store.getters.reactions(this.token, this.id)
},
},
@@ -696,27 +607,74 @@ export default {
// again another time
this.$refs.message.classList.remove('highlight-animation')
},
+
handleRetry() {
if (this.sendingErrorCanRetry) {
EventBus.$emit('retry-message', this.id)
EventBus.$emit('focus-chat-input')
}
},
- handleReply() {
- this.$store.dispatch('addMessageToBeReplied', {
- id: this.id,
- actorId: this.actorId,
- actorType: this.actorType,
- actorDisplayName: this.actorDisplayName,
- timestamp: this.timestamp,
- systemMessage: this.systemMessage,
- messageType: this.messageType,
- message: this.message,
- messageParameters: this.messageParameters,
- token: this.token,
- })
- EventBus.$emit('focus-chat-input')
+
+ handleMouseover() {
+ this.showMessageButtonsBar = true
+
+ },
+
+ handleReactionsMouseOver() {
+ if (this.hasReactions && !this.detailedReactionsRequested) {
+ this.getReactions()
+ }
+ },
+
+ handleMouseleave() {
+ if (!this.isActionMenuOpen) {
+ this.showMessageButtonsBar = false
+ }
+ },
+
+ async getReactions() {
+ try {
+ /**
+ * Get reaction details when the message is hovered for the first
+ * time. After that we rely on system messages to update the
+ * reactions.
+ */
+ this.detailedReactionsRequested = true
+ await this.$store.dispatch('getReactions', {
+ token: this.token,
+ messageId: this.id,
+ })
+ } catch {
+ this.detailedReactionsRequested = false
+ }
+ },
+
+ async handleReactionClick(clickedEmoji) {
+ if (!this.detailedReactionsRequested) {
+ await this.getReactions()
+ }
+ // Check if current user has already added this reaction to the message
+ const currentUserHasReacted = this.$store.getters.userHasReacted(this.actorId, this.token, this.id, clickedEmoji)
+
+ if (!currentUserHasReacted) {
+ console.debug('adding reaction')
+ this.$store.dispatch('addReactionToMessage', {
+ token: this.token,
+ messageId: this.id,
+ selectedEmoji: clickedEmoji,
+ actorId: this.actorId,
+ })
+ } else {
+ console.debug('user has already reacted, removing reaction')
+ this.$store.dispatch('removeReactionFromMessage', {
+ token: this.token,
+ messageId: this.id,
+ selectedEmoji: clickedEmoji,
+ actorId: this.actorId,
+ })
+ }
},
+
async handleDelete() {
this.isDeleting = true
try {
@@ -751,50 +709,19 @@ export default {
this.isDeleting = false
},
- handleMouseover() {
- this.showActions = true
- },
-
- handleMouseleave() {
- if (!this.isActionMenuOpen) {
- this.showActions = false
- }
- },
- handleActionMenuUpdate(type) {
- if (type === 'open') {
- this.isActionMenuOpen = true
- } else if (type === 'close') {
- this.isActionMenuOpen = false
- this.showActions = false
- }
- },
- async handlePrivateReply() {
- // open the 1:1 conversation
- const conversation = await this.$store.dispatch('createOneToOneConversation', this.actorId)
- this.$router.push({ name: 'conversation', params: { token: conversation.token } }).catch(err => console.debug(`Error while pushing the new conversation's route: ${err}`))
- },
-
- async handleCopyMessageLink() {
- try {
- const link = window.location.protocol + '//' + window.location.host + generateUrl('/call/' + this.token) + '#message_' + this.id
- await this.$copyText(link)
- showSuccess(t('spreed', 'Message link copied to clipboard.'))
- } catch (error) {
- console.error('Error copying link: ', error)
- showError(t('spreed', 'The link could not be copied.'))
+ addReactionToMessage(selectedEmoji) {
+ // Add reaction only if user hasn't reacted yet
+ if (!this.$store.getters.userHasReacted(this.actorId, this.token, this.messageObject.id, selectedEmoji)) {
+ this.$store.dispatch('addReactionToMessage', {
+ token: this.token,
+ messageId: this.messageObject.id,
+ selectedEmoji,
+ actorId: this.actorId,
+ })
+ } else {
+ console.debug('Current user has already reacted')
}
- },
-
- async handleMarkAsUnread() {
- // update in backend + visually
- await this.$store.dispatch('updateLastReadMessage', {
- token: this.token,
- id: this.previousMessageId,
- updateVisually: true,
- })
- // reload conversation to update additional attributes that have computed values
- await this.$store.dispatch('fetchConversation', { token: this.token })
},
},
}
@@ -811,6 +738,10 @@ export default {
}
}
+.message {
+ position: relative;
+}
+
.message-body {
padding: 4px;
font-size: $chat-font-size;
@@ -874,6 +805,10 @@ export default {
padding: 0 8px 0 8px;
}
}
+ &__reactions {
+ display: flex;
+ margin: 4px 0 4px -2px;
+ }
}
.date {
@@ -939,18 +874,26 @@ export default {
}
}
-.message__buttons-bar {
- display: flex;
- right: 14px;
- bottom: -4px;
- position: absolute;
- z-index: 100000;
+.reaction-button {
+ // Clear server rules
+ min-height: 0 !important;
+ padding: 0 8px !important;
+ font-weight: normal !important;
+
+ margin: 0 2px;
+ height: 26px;
background-color: var(--color-main-background);
- border-radius: calc($clickable-area / 2);
- box-shadow: 0 0 4px 0px var(--color-box-shadow);
- & h6 {
- margin-left: auto;
+ &__emoji {
+ margin: 0 4px 0 0;
}
+
+ &:hover {
+ background-color: var(--color-primary-element-lighter);
+ }
+}
+
+.reaction-details {
+ padding: 8px;
}
</style>
diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js
new file mode 100644
index 000000000..78d91b6ec
--- /dev/null
+++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js
@@ -0,0 +1,437 @@
+import Vuex, { Store } from 'vuex'
+import { createLocalVue, shallowMount } from '@vue/test-utils'
+import { cloneDeep } from 'lodash'
+import storeConfig from '../../../../../store/storeConfig'
+import { CONVERSATION, PARTICIPANT, ATTENDEE } from '../../../../../constants'
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import { findActionButton } from '../../../../../test-helpers'
+import MessageButtonsBar from './../MessageButtonsBar/MessageButtonsBar.vue'
+
+describe('MessageButtonsBar.vue', () => {
+ const TOKEN = 'XXTOKENXX'
+ let localVue
+ let testStoreConfig
+ let store
+ let messageProps
+ let conversationProps
+ let getActorTypeMock
+
+ beforeEach(() => {
+ localVue = createLocalVue()
+ localVue.use(Vuex)
+
+ conversationProps = {
+ token: TOKEN,
+ lastCommonReadMessage: 0,
+ type: CONVERSATION.TYPE.GROUP,
+ readOnly: CONVERSATION.STATE.READ_WRITE,
+ }
+
+ testStoreConfig = cloneDeep(storeConfig)
+ testStoreConfig.modules.tokenStore.getters.getToken
+ = jest.fn().mockReturnValue(() => TOKEN)
+ testStoreConfig.modules.conversationsStore.getters.conversation
+ = jest.fn().mockReturnValue((token) => conversationProps)
+ testStoreConfig.modules.actorStore.getters.getActorId
+ = jest.fn().mockReturnValue(() => 'user-id-1')
+ getActorTypeMock = jest.fn().mockReturnValue(() => ATTENDEE.ACTOR_TYPE.USERS)
+ testStoreConfig.modules.actorStore.getters.getActorType = getActorTypeMock
+
+ messageProps = {
+ message: 'test message',
+ actorType: ATTENDEE.ACTOR_TYPE.USERS,
+ actorId: 'user-id-1',
+ actorDisplayName: 'user-display-name-1',
+ messageParameters: {},
+ id: 123,
+ isTemporary: false,
+ isFirstMessage: true,
+ isReplyable: true,
+ timestamp: new Date('2020-05-07 09:23:00').getTime() / 1000,
+ token: TOKEN,
+ systemMessage: '',
+ messageType: 'comment',
+ previousMessageId: 100,
+ messageObject: {},
+ messageApiData: {
+ apiDummyData: 1,
+ },
+ participant: {
+ actorId: 'user-id-1',
+ actorType: ATTENDEE.ACTOR_TYPE.USERS,
+ participantType: PARTICIPANT.TYPE.USER,
+ },
+ }
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('actions', () => {
+
+ beforeEach(() => {
+ store = new Store(testStoreConfig)
+ })
+
+ describe('reply action', () => {
+ test('replies to message', async () => {
+ const replyAction = jest.fn()
+ testStoreConfig.modules.quoteReplyStore.actions.addMessageToBeReplied = replyAction
+ store = new Store(testStoreConfig)
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Reply')
+ expect(actionButton.exists()).toBe(true)
+ expect(actionButton.isVisible()).toBe(true)
+ await actionButton.find('button').trigger('click')
+
+ expect(replyAction).toHaveBeenCalledWith(expect.anything(), {
+ id: 123,
+ actorId: 'user-id-1',
+ actorType: 'users',
+ actorDisplayName: 'user-display-name-1',
+ message: 'test message',
+ messageParameters: {},
+ messageType: 'comment',
+ systemMessage: '',
+ timestamp: new Date('2020-05-07 09:23:00').getTime() / 1000,
+ token: TOKEN,
+ previousMessageId: 100,
+ })
+ })
+
+ test('hides reply button when not replyable', async () => {
+ messageProps.isReplyable = false
+ store = new Store(testStoreConfig)
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Reply')
+ expect(actionButton.isVisible()).toBe(false)
+ })
+ })
+
+ describe('private reply action', () => {
+ test('creates a new conversation when replying to message privately', async () => {
+ const routerPushMock = jest.fn().mockResolvedValue()
+ const createOneToOneConversation = jest.fn()
+ testStoreConfig.modules.conversationsStore.actions.createOneToOneConversation = createOneToOneConversation
+ store = new Store(testStoreConfig)
+
+ messageProps.actorId = 'another-user'
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ mocks: {
+ $router: {
+ push: routerPushMock,
+ },
+ },
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Reply privately')
+ expect(actionButton.exists()).toBe(true)
+
+ createOneToOneConversation.mockResolvedValueOnce({
+ token: 'new-token',
+ })
+
+ await actionButton.find('button').trigger('click')
+
+ expect(createOneToOneConversation).toHaveBeenCalledWith(expect.anything(), 'another-user')
+
+ expect(routerPushMock).toHaveBeenCalledWith({
+ name: 'conversation',
+ params: {
+ token: 'new-token',
+ },
+ })
+ })
+
+ /**
+ * @param {boolean} visible Whether or not the reply-private action is visible
+ */
+ function testPrivateReplyActionVisible(visible) {
+ store = new Store(testStoreConfig)
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Reply privately')
+ expect(actionButton.exists()).toBe(visible)
+ }
+
+ test('hides private reply action for own messages', async () => {
+ // using default message props which have the
+ // actor id set to the current user
+ testPrivateReplyActionVisible(false)
+ })
+
+ test('hides private reply action for one to one conversation type', async () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE
+ testPrivateReplyActionVisible(false)
+ })
+
+ test('hides private reply action for guest messages', async () => {
+ messageProps.actorId = 'guest-user'
+ messageProps.actorType = ATTENDEE.ACTOR_TYPE.GUESTS
+ testPrivateReplyActionVisible(false)
+ })
+
+ test('hides private reply action when current user is a guest', async () => {
+ messageProps.actorId = 'another-user'
+ getActorTypeMock.mockClear().mockReturnValue(() => ATTENDEE.ACTOR_TYPE.GUESTS)
+ testPrivateReplyActionVisible(false)
+ })
+ })
+
+ describe('delete action', () => {
+ test('emits delete event', async () => {
+ // need to mock the date to be within 6h
+ const mockDate = new Date('2020-05-07 10:00:00')
+ jest.spyOn(global.Date, 'now')
+ .mockImplementation(() => mockDate)
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Delete')
+ expect(actionButton.exists()).toBe(true)
+
+ await actionButton.find('button').trigger('click')
+
+ expect(wrapper.emitted().delete).toBeTruthy()
+ })
+
+ /**
+ * @param {boolean} visible Whether or not the delete action is visible
+ * @param {Date} mockDate The message date (deletion only works within 6h)
+ * @param {number} participantType The participant type of the user
+ */
+ function testDeleteMessageVisible(visible, mockDate, participantType = PARTICIPANT.TYPE.USER) {
+ store = new Store(testStoreConfig)
+
+ // need to mock the date to be within 6h
+ if (!mockDate) {
+ mockDate = new Date('2020-05-07 10:00:00')
+ }
+
+ jest.spyOn(global.Date, 'now')
+ .mockImplementation(() => mockDate)
+
+ messageProps.participant.participantType = participantType
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Delete')
+ expect(actionButton.exists()).toBe(visible)
+ }
+
+ test('hides delete action when message is older than 6 hours', () => {
+ testDeleteMessageVisible(false, new Date('2020-05-07 15:24:00'))
+ })
+
+ test('hides delete action when the conversation is read-only', () => {
+ conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
+ testDeleteMessageVisible(false)
+ })
+
+ test('hides delete action for file messages', () => {
+ messageProps.message = '{file}'
+ messageProps.messageParameters.file = {}
+ testDeleteMessageVisible(false)
+ })
+
+ test('hides delete action on other people messages for non-moderators', () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.GROUP
+ testDeleteMessageVisible(false)
+ })
+
+ test('shows delete action on other people messages for moderators', () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.GROUP
+ testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.MODERATOR)
+ })
+
+ test('shows delete action on other people messages for owner', () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.PUBLIC
+ testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.OWNER)
+ })
+
+ test('does not show delete action even for guest moderators', () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.PUBLIC
+ testDeleteMessageVisible(false, null, PARTICIPANT.TYPE.GUEST_MODERATOR)
+ })
+
+ test('does not show delete action on other people messages in one to one conversations', () => {
+ messageProps.actorId = 'another-user'
+ conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE
+ testDeleteMessageVisible(false)
+ })
+ })
+
+ test('marks message as unread', async () => {
+ const updateLastReadMessageAction = jest.fn().mockResolvedValueOnce()
+ const fetchConversationAction = jest.fn().mockResolvedValueOnce()
+ testStoreConfig.modules.conversationsStore.actions.updateLastReadMessage = updateLastReadMessageAction
+ testStoreConfig.modules.conversationsStore.actions.fetchConversation = fetchConversationAction
+ store = new Store(testStoreConfig)
+
+ messageProps.previousMessageId = 100
+
+ // appears even with more restrictive conditions
+ conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
+ messageProps.actorId = 'another-user'
+ messageProps.participant = {
+ actorId: 'guest-id-1',
+ actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
+ participantType: PARTICIPANT.TYPE.GUEST,
+ }
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Mark as unread')
+ expect(actionButton.exists()).toBe(true)
+
+ await actionButton.find('button').trigger('click')
+ // needs two updates...
+ await wrapper.vm.$nextTick()
+ await wrapper.vm.$nextTick()
+
+ expect(updateLastReadMessageAction).toHaveBeenCalledWith(expect.anything(), {
+ token: TOKEN,
+ id: 100,
+ updateVisually: true,
+ })
+
+ expect(fetchConversationAction).toHaveBeenCalledWith(expect.anything(), {
+ token: TOKEN,
+ })
+ })
+
+ test('copies message link', async () => {
+ const copyTextMock = jest.fn()
+
+ // appears even with more restrictive conditions
+ conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY
+ messageProps.actorId = 'another-user'
+ messageProps.participant = {
+ actorId: 'guest-id-1',
+ actorType: ATTENDEE.ACTOR_TYPE.GUESTS,
+ participantType: PARTICIPANT.TYPE.GUEST,
+ }
+
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ mocks: {
+ $copyText: copyTextMock,
+ },
+ stubs: {
+ ActionButton,
+ },
+
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'Copy message link')
+ expect(actionButton.exists()).toBe(true)
+
+ await actionButton.find('button').trigger('click')
+
+ expect(copyTextMock).toHaveBeenCalledWith('http://localhost/nc-webroot/call/XXTOKENXX#message_123')
+ })
+
+ test('renders clickable custom actions', async () => {
+ const handler = jest.fn()
+ const handler2 = jest.fn()
+ const actionsGetterMock = jest.fn().mockReturnValue([{
+ label: 'first action',
+ icon: 'some-icon',
+ callback: handler,
+ }, {
+ label: 'second action',
+ icon: 'some-icon2',
+ callback: handler2,
+ }])
+ testStoreConfig.modules.messageActionsStore.getters.messageActions = actionsGetterMock
+ testStoreConfig.modules.messagesStore.getters.message = jest.fn(() => () => messageProps)
+ store = new Store(testStoreConfig)
+ const wrapper = shallowMount(MessageButtonsBar, {
+ localVue,
+ store,
+ stubs: {
+ ActionButton,
+ },
+ propsData: messageProps,
+ })
+
+ const actionButton = findActionButton(wrapper, 'first action')
+ expect(actionButton.exists()).toBe(true)
+ await actionButton.find('button').trigger('click')
+
+ expect(handler).toHaveBeenCalledWith({
+ apiDummyData: 1,
+ },)
+
+ const actionButton2 = findActionButton(wrapper, 'second action')
+ expect(actionButton2.exists()).toBe(true)
+ await actionButton2.find('button').trigger('click')
+
+ expect(handler2).toHaveBeenCalledWith({
+ apiDummyData: 1,
+ })
+ })
+ })
+})
diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue
new file mode 100644
index 000000000..d1dc56aeb
--- /dev/null
+++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue
@@ -0,0 +1,453 @@
+<!--
+ - @copyright Copyright (c) 2021 Marco Ambrosini <marcoambrosini@pm.me>
+ -
+ - @author Marco Ambrosini <marcoambrosini@pm.me>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+-->
+
+<template>
+ <!-- Message Actions -->
+ <div class="message-buttons-bar">
+ <template v-if="page === 0">
+ <Button type="tertiary"
+ @click="page = 1">
+ <template #icon>
+ <EmoticonOutline :size="20" />
+ </template>
+ </Button>
+ <Actions v-show="isReplyable">
+ <ActionButton icon="icon-reply"
+ @click.stop="handleReply">
+ {{ t('spreed', 'Reply') }}
+ </ActionButton>
+ </Actions>
+ <Actions :force-menu="true"
+ :container="container"
+ :boundaries-element="containerElement">
+ <ActionButton v-if="isPrivateReplyable"
+ icon="icon-user"
+ :close-after-click="true"
+ @click.stop="handlePrivateReply">
+ {{ t('spreed', 'Reply privately') }}
+ </ActionButton>
+ <ActionButton icon="icon-external"
+ :close-after-click="true"
+ @click.stop.prevent="handleCopyMessageLink">
+ {{ t('spreed', 'Copy message link') }}
+ </ActionButton>
+ <ActionButton :close-after-click="true"
+ @click.stop="handleMarkAsUnread">
+ <template #icon>
+ <EyeOffOutline decorative
+ title=""
+ :size="16" />
+ </template>
+ {{ t('spreed', 'Mark as unread') }}
+ </ActionButton>
+ <ActionLink v-if="linkToFile"
+ icon="icon-text"
+ :href="linkToFile">
+ {{ t('spreed', 'Go to file') }}
+ </ActionLink>
+ <ActionButton v-if="!isCurrentGuest && !isFileShare"
+ :close-after-click="true"
+ @click.stop="showForwarder = true">
+ <Share slot="icon"
+ :size="16"
+ decorative
+ title="" />
+ {{ t('spreed', 'Forward message') }}
+ </ActionButton>
+ <ActionSeparator v-if="messageActions.length > 0" />
+ <template v-for="action in messageActions">
+ <ActionButton :key="action.label"
+ :icon="action.icon"
+ :close-after-click="true"
+ @click="action.callback(messageApiData)">
+ {{ action.label }}
+ </ActionButton>
+ </template>
+ <template v-if="isDeleteable">
+ <ActionSeparator />
+ <ActionButton icon="icon-delete"
+ :close-after-click="true"
+ @click.stop="handleDelete">
+ {{ t('spreed', 'Delete') }}
+ </ActionButton>
+ </template>
+ </Actions>
+ </template>
+
+ <template v-if="page === 1">
+ <Button type="tertiary"
+ @click="page = 0">
+ <template #icon>
+ <ArrowLeft :size="20" />
+ </template>
+ </Button>
+ <Button type="tertiary"
+ @click="addReactionToMessage('👍')">
+ <template #icon>
+ <span>👍</span>
+ </template>
+ </Button>
+ <Button type="tertiary"
+ @click="addReactionToMessage('❤️')">
+ <template #icon>
+ <span>❤️</span>
+ </template>
+ </Button>
+ <EmojiPicker @select="addReactionToMessage">
+ <Button type="tertiary">
+ <template #icon>
+ <Plus :size="20" />
+ </template>
+ </Button>
+ </EmojiPicker>
+ </template>
+ <Forwarder v-if="showForwarder"
+ :message-object="messageObject"
+ @close="showForwarder = false" />
+ </div>
+</template>
+
+<script>
+import { PARTICIPANT, CONVERSATION, ATTENDEE } from '../../../../../constants'
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import ActionLink from '@nextcloud/vue/dist/Components/ActionLink'
+import Actions from '@nextcloud/vue/dist/Components/Actions'
+import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
+import EyeOffOutline from 'vue-material-design-icons/EyeOffOutline'
+import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
+import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
+import Plus from 'vue-material-design-icons/Plus.vue'
+import Share from 'vue-material-design-icons/Share'
+import moment from '@nextcloud/moment'
+import { EventBus } from '../../../../../services/EventBus'
+import { generateUrl } from '@nextcloud/router'
+import {
+ showError,
+ showSuccess,
+} from '@nextcloud/dialogs'
+import Forwarder from '../MessagePart/Forwarder'
+import Button from '@nextcloud/vue/dist/Components/Button'
+import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
+
+export default {
+ name: 'MessageButtonsBar',
+
+ components: {
+ Actions,
+ ActionButton,
+ ActionLink,
+ EyeOffOutline,
+ Share,
+ ActionSeparator,
+ Forwarder,
+ Button,
+ EmoticonOutline,
+ ArrowLeft,
+ Plus,
+ EmojiPicker,
+ },
+
+ props: {
+ token: {
+ type: String,
+ required: true,
+ },
+
+ previousMessageId: {
+ type: [String, Number],
+ required: true,
+ },
+
+ isReplyable: {
+ type: Boolean,
+ required: true,
+ },
+
+ messageObject: {
+ type: Object,
+ required: true,
+ },
+
+ actorId: {
+ type: String,
+ required: true,
+ },
+
+ actorType: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * The display name of the sender of the message.
+ */
+ actorDisplayName: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * The parameters of the rich object message
+ */
+ messageParameters: {
+ type: [Array, Object],
+ required: true,
+ },
+
+ /**
+ * The message timestamp.
+ */
+ timestamp: {
+ type: Number,
+ default: 0,
+ },
+
+ /**
+ * The message id.
+ */
+ id: {
+ type: [String, Number],
+ required: true,
+ },
+
+ /**
+ * The parent message's id.
+ */
+ parent: {
+ type: Number,
+ default: 0,
+ },
+
+ /**
+ * The message or quote text.
+ */
+ message: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * The type of system message
+ */
+ systemMessage: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * The type of the message.
+ */
+ messageType: {
+ type: String,
+ required: true,
+ },
+
+ /**
+ * The participant object.
+ */
+ participant: {
+ type: Object,
+ required: true,
+ },
+
+ messageApiData: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ // Shows/hides the message forwarder component
+ showForwarder: false,
+
+ // The pagination of the buttons menu
+ page: 0,
+ }
+ },
+
+ computed: {
+ conversation() {
+ return this.$store.getters.conversation(this.token)
+ },
+
+ container() {
+ return this.$store.getters.getMainContainerSelector()
+ },
+
+ containerElement() {
+ return document.querySelector(this.container)
+ },
+
+ isDeleteable() {
+ if (this.isConversationReadOnly) {
+ return false
+ }
+
+ const isObjectShare = this.message === '{object}'
+ && this.messageParameters?.object
+
+ return (moment(this.timestamp * 1000).add(6, 'h')) > moment()
+ && this.messageType === 'comment'
+ && !this.isDeleting
+ && !this.isFileShare
+ && !isObjectShare
+ && (this.isMyMsg
+ || (this.conversation.type !== CONVERSATION.TYPE.ONE_TO_ONE
+ && (this.participant.participantType === PARTICIPANT.TYPE.OWNER
+ || this.participant.participantType === PARTICIPANT.TYPE.MODERATOR)))
+ },
+
+ isPrivateReplyable() {
+ return this.isReplyable
+ && (this.conversation.type === CONVERSATION.TYPE.PUBLIC
+ || this.conversation.type === CONVERSATION.TYPE.GROUP)
+ && !this.isMyMsg
+ && this.actorType === ATTENDEE.ACTOR_TYPE.USERS
+ && this.$store.getters.getActorType() === ATTENDEE.ACTOR_TYPE.USERS
+ },
+
+ messageActions() {
+ return this.$store.getters.messageActions
+ },
+
+ linkToFile() {
+ if (this.isFileShare) {
+ return this.messageParameters?.file?.link
+ }
+ return ''
+ },
+
+ isFileShare() {
+ return this.message === '{file}' && this.messageParameters?.file
+ },
+
+ isCurrentGuest() {
+ return this.$store.getters.getActorType() === 'guests'
+ },
+
+ isMyMsg() {
+ return this.actorId === this.$store.getters.getActorId()
+ && this.actorType === this.$store.getters.getActorType()
+ },
+
+ isConversationReadOnly() {
+ return this.conversation.readOnly === CONVERSATION.STATE.READ_ONLY
+ },
+ },
+
+ methods: {
+ handleReply() {
+ this.$store.dispatch('addMessageToBeReplied', {
+ id: this.id,
+ actorId: this.actorId,
+ actorType: this.actorType,
+ actorDisplayName: this.actorDisplayName,
+ timestamp: this.timestamp,
+ systemMessage: this.systemMessage,
+ messageType: this.messageType,
+ message: this.message,
+ messageParameters: this.messageParameters,
+ token: this.token,
+ previousMessageId: this.previousMessageId,
+ })
+ EventBus.$emit('focus-chat-input')
+ },
+
+ handleActionMenuUpdate(type) {
+ if (type === 'open') {
+ this.isActionMenuOpen = true
+ } else if (type === 'close') {
+ this.isActionMenuOpen = false
+ this.showActions = false
+ }
+ },
+
+ async handlePrivateReply() {
+ // open the 1:1 conversation
+ const conversation = await this.$store.dispatch('createOneToOneConversation', this.actorId)
+ this.$router.push({ name: 'conversation', params: { token: conversation.token } }).catch(err => console.debug(`Error while pushing the new conversation's route: ${err}`))
+ },
+
+ async handleCopyMessageLink() {
+ try {
+ const link = window.location.protocol + '//' + window.location.host + generateUrl('/call/' + this.token) + '#message_' + this.id
+ await this.$copyText(link)
+ showSuccess(t('spreed', 'Message link copied to clipboard.'))
+ } catch (error) {
+ console.error('Error copying link: ', error)
+ showError(t('spreed', 'The link could not be copied.'))
+ }
+ },
+
+ async handleMarkAsUnread() {
+ // update in backend + visually
+ await this.$store.dispatch('updateLastReadMessage', {
+ token: this.token,
+ id: this.previousMessageId,
+ updateVisually: true,
+ })
+
+ // reload conversation to update additional attributes that have computed values
+ await this.$store.dispatch('fetchConversation', { token: this.token })
+ },
+
+ addReactionToMessage(selectedEmoji) {
+ // Add reaction only if user hasn't reacted yet
+ if (!this.$store.getters.userHasReacted(this.actorId, this.token, this.messageObject.id, selectedEmoji)) {
+ this.$store.dispatch('addReactionToMessage', {
+ token: this.token,
+ messageId: this.messageObject.id,
+ selectedEmoji,
+ actorId: this.actorId,
+ })
+ } else {
+ console.debug('Current user has already reacted')
+ }
+
+ },
+
+ handleDelete() {
+ this.$emit('delete')
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@import '../../../../../assets/variables';
+
+.message-buttons-bar {
+ display: flex;
+ right: 14px;
+ bottom: -4px;
+ position: absolute;
+ z-index: 100000;
+ background-color: var(--color-main-background);
+ border-radius: calc($clickable-area / 2);
+ box-shadow: 0 0 4px 0px var(--color-box-shadow);
+
+ & h6 {
+ margin-left: auto;
+ }
+}
+
+</style>
diff --git a/src/components/Quote.vue b/src/components/Quote.vue
index d4a0e573d..6a19bc504 100644
--- a/src/components/Quote.vue
+++ b/src/components/Quote.vue
@@ -81,6 +81,9 @@ export default {
type: String,
required: true,
},
+ /**
+ * The display name of the sender of the message.
+ */
actorDisplayName: {
type: String,
required: true,
diff --git a/src/components/UploadEditor.vue b/src/components/UploadEditor.vue
index dfe91ff91..091532d5d 100644
--- a/src/components/UploadEditor.vue
+++ b/src/components/UploadEditor.vue
@@ -45,14 +45,16 @@
</template>
<div :key="'addMore'"
class="add-more">
- <button :aria-label="addMoreAriaLabel"
- class="add-more__button primary"
+ <Button :aria-label="addMoreAriaLabel"
+ type="primary"
+ class="add-more__button"
@click="clickImportInput">
- <Plus decorative
- title=""
- :size="48"
- class="upload-editor__plus-icon" />
- </button>
+ <template #icon>
+ <Plus decorative
+ title=""
+ :size="48" />
+ </template>
+ </Button>
</div>
</transition-group>
</template>
@@ -61,12 +63,12 @@
:local-url="voiceMessageLocalURL" />
</template>
<div class="upload-editor__actions">
- <button @click="handleDismiss">
+ <Button type="tertiary" @click="handleDismiss">
{{ t('spreed', 'Dismiss') }}
- </button>
- <button ref="submitButton" class="primary" @click="handleUpload">
+ </Button>
+ <Button ref="submitButton" type="primary" @click="handleUpload">
{{ t('spreed', 'Send') }}
- </button>
+ </Button>
</div>
</div>
</Modal>
@@ -78,6 +80,7 @@ import Modal from '@nextcloud/vue/dist/Components/Modal'
import FilePreview from './MessagesList/MessagesGroup/Message/MessagePart/FilePreview.vue'
import Plus from 'vue-material-design-icons/Plus'
import AudioPlayer from './MessagesList/MessagesGroup/Message/MessagePart/AudioPlayer.vue'
+import Button from '@nextcloud/vue/dist/Components/Button'
export default {
name: 'UploadEditor',
@@ -87,6 +90,7 @@ export default {
FilePreview,
Plus,
AudioPlayer,
+ Button,
},
computed: {
@@ -222,19 +226,7 @@ export default {
display: flex;
margin: 10px;
&__button {
- width: 80px;
- height: 80px;
- border: none;
- border-radius: var(--border-radius-pill);
- position: relative;
- z-index: 2;
- box-shadow: 0 0 4px var(--color-box-shadow);
- padding: 0;
margin: auto;
- &__plus {
- color: var(--color-primary-text);
- z-index: 3;
- }
}
}
diff --git a/src/services/messagesService.js b/src/services/messagesService.js
index 938a35130..3a211a7e5 100644
--- a/src/services/messagesService.js
+++ b/src/services/messagesService.js
@@ -132,6 +132,24 @@ const updateLastReadMessage = async function(token, lastReadMessage) {
})
}
+const addReactionToMessage = async function(token, messageId, selectedEmoji) {
+ return axios.post(generateOcsUrl('apps/spreed/api/v1/reaction/{token}/{messageId}', { token, messageId }), {
+ reaction: selectedEmoji,
+ })
+}
+
+const removeReactionFromMessage = async function(token, messageId, selectedEmoji) {
+ return axios.delete(generateOcsUrl('apps/spreed/api/v1/reaction/{token}/{messageId}', { token, messageId }), {
+ params: {
+ reaction: selectedEmoji,
+ },
+ })
+}
+
+const getReactionsDetails = async function(token, messageId) {
+ return axios.get(generateOcsUrl('apps/spreed/api/v1/reaction/{token}/{messageId}', { token, messageId }))
+}
+
export {
fetchMessages,
lookForNewMessages,
@@ -139,4 +157,7 @@ export {
deleteMessage,
postRichObjectToConversation,
updateLastReadMessage,
+ addReactionToMessage,
+ removeReactionFromMessage,
+ getReactionsDetails,
}
diff --git a/src/store/messagesStore.js b/src/store/messagesStore.js
index 3c08544e3..3dd4833d3 100644
--- a/src/store/messagesStore.js
+++ b/src/store/messagesStore.js
@@ -27,6 +27,8 @@ import {
lookForNewMessages,
postNewMessage,
postRichObjectToConversation,
+ addReactionToMessage,
+ removeReactionFromMessage,
} from '../services/messagesService'
import SHA256 from 'crypto-js/sha256'
@@ -138,7 +140,14 @@ const getters = {
*/
messagesList: (state) => (token) => {
if (state.messages[token]) {
- return Object.values(state.messages[token])
+ return Object.values(state.messages[token]).filter(message => {
+ // Filter out reaction messages
+ if (message.systemMessage === 'reaction' || message.systemMessage === 'reaction_deleted' || message.systemMessage === 'reaction_revoked') {
+ return false
+ } else {
+ return true
+ }
+ })
}
return []
},
@@ -191,6 +200,11 @@ const getters = {
// the cancel handler only exists when a message is being sent
return Object.keys(state.cancelPostNewMessage).length !== 0
},
+
+ // Returns true if the message has reactions
+ hasReactions: (state) => (token, messageId) => {
+ return Object.keys(state.messages[token][messageId].reactions).length !== 0
+ },
}
const mutations = {
@@ -333,6 +347,24 @@ const mutations = {
Vue.delete(state.messages, token)
}
},
+
+ // Increases reaction count for a particular reaction on a message
+ addReactionToMessage(state, { token, messageId, reaction }) {
+ if (!state.messages[token][messageId].reactions[reaction]) {
+ Vue.set(state.messages[token][messageId].reactions, reaction, 0)
+ }
+ const reactionCount = state.messages[token][messageId].reactions[reaction] + 1
+ Vue.set(state.messages[token][messageId].reactions, reaction, reactionCount)
+ },
+
+ // Decreases reaction count for a particular reaction on a message
+ removeReactionFromMessage(state, { token, messageId, reaction }) {
+ const reactionCount = state.messages[token][messageId].reactions[reaction] - 1
+ Vue.set(state.messages[token][messageId].reactions, reaction, reactionCount)
+ if (state.messages[token][messageId].reactions[reaction] <= 0) {
+ Vue.delete(state.messages[token][messageId].reactions, reaction)
+ }
+ },
}
const actions = {
@@ -442,6 +474,7 @@ const actions = {
token,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: Hex.stringify(SHA256(tempId)),
})
@@ -931,6 +964,72 @@ const actions = {
const response = await postRichObjectToConversation(token, richObject)
return response
},
+
+ /**
+ * Adds a single reaction to a message for the current user.
+ *
+ * @param {*} context the context object
+ * @param {*} param1 conversation token, message id and selected emoji (string)
+ */
+ async addReactionToMessage(context, { token, messageId, selectedEmoji }) {
+ try {
+ context.commit('addReactionToMessage', {
+ token,
+ messageId,
+ reaction: selectedEmoji,
+ })
+ // The response return an array with the reaction details for this message
+ const response = await addReactionToMessage(token, messageId, selectedEmoji)
+ // We replace the reaction details in the reactions store and wipe the old
+ // values
+ context.dispatch('updateReactions', {
+ token,
+ messageId,
+ reactionsDetails: response.data.ocs.data,
+ })
+ } catch (error) {
+ // Restore the previous state if the request fails
+ context.commit('removeReactionFromMessage', {
+ token,
+ messageId,
+ reaction: selectedEmoji,
+ })
+ console.debug(error)
+ }
+ },
+
+ /**
+ * Removes a single reaction from a message for the current user.
+ *
+ * @param {*} context the context object
+ * @param {*} param1 conversation token, message id and selected emoji (string)
+ */
+ async removeReactionFromMessage(context, { token, messageId, selectedEmoji }) {
+ try {
+ context.commit('removeReactionFromMessage', {
+ token,
+ messageId,
+ reaction: selectedEmoji,
+ })
+ // The response return an array with the reaction details for this message
+ const response = await removeReactionFromMessage(token, messageId, selectedEmoji)
+ // We replace the reaction details in the reactions store and wipe the old
+ // values
+ context.dispatch('updateReactions', {
+ token,
+ messageId,
+ reactionsDetails: response.data.ocs.data,
+ })
+ } catch (error) {
+ // Restore the previous state if the request fails
+ context.commit('addReactionToMessage', {
+ token,
+ messageId,
+ reaction: selectedEmoji,
+ })
+ console.debug(error)
+ }
+ },
}
export default { state, mutations, getters, actions }
diff --git a/src/store/messagesStore.spec.js b/src/store/messagesStore.spec.js
index 354d5e579..f7d715aee 100644
--- a/src/store/messagesStore.spec.js
+++ b/src/store/messagesStore.spec.js
@@ -313,6 +313,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
})
})
@@ -345,6 +346,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
parent: 123,
})
@@ -389,6 +391,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
})
})
@@ -418,6 +421,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
}])
@@ -440,6 +444,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
}])
})
@@ -473,6 +478,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: 'failure-reason',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
}])
})
@@ -518,6 +524,7 @@ describe('messagesStore', () => {
token: TOKEN,
isReplyable: false,
sendingFailure: '',
+ reactions: {},
referenceId: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
}])
})
diff --git a/src/store/reactionsStore.js b/src/store/reactionsStore.js
new file mode 100644
index 000000000..821e6c60a
--- /dev/null
+++ b/src/store/reactionsStore.js
@@ -0,0 +1,103 @@
+/**
+ * @copyright Copyright (c) 2022 Marco Ambrosini <marcoambrosini@pm.me>
+ *
+ * @author Marco Ambrosini <marcoambrosini@pm.me>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import Vue from 'vue'
+import {
+ getReactionsDetails,
+} from '../services/messagesService'
+
+const state = {
+ /**
+ * Structure:
+ * reactions.token.messageId
+ */
+ reactions: {},
+}
+
+const getters = {
+ reactions: (state) => (token, messageId) => {
+ if (state.reactions?.[token]?.[messageId]) {
+ return state.reactions[token][messageId]
+ } else {
+ return undefined
+ }
+ },
+
+ // Checks if a user has already reacted to a message with a particular reaction
+ userHasReacted: (state) => (actorId, token, messageId, reaction) => {
+ if (!state?.reactions?.[token]?.[messageId]?.[reaction]) {
+ return false
+ }
+ return state?.reactions?.[token]?.[messageId]?.[reaction].filter(item => {
+ return item.actorId === actorId
+ }).length !== 0
+ },
+}
+
+const mutations = {
+ addReactions(state, { token, messageId, reactions }) {
+ if (!state.reactions[token]) {
+ Vue.set(state.reactions, token, {})
+
+ }
+ Vue.set(state.reactions[token], messageId, reactions)
+ },
+}
+
+const actions = {
+ /**
+ * Updates reactions for a given message.
+ *
+ * @param {*} context The context object
+ * @param {*} param1 conversation token, message id
+ */
+ async updateReactions(context, { token, messageId, reactionsDetails }) {
+ context.commit('addReactions', {
+ token,
+ messageId,
+ reactions: reactionsDetails,
+ })
+ },
+
+ /**
+ * Gets the full reactions array for a given message.
+ *
+ * @param {*} context the context object
+ * @param {*} param1 conversation token, message id
+ */
+ async getReactions(context, { token, messageId }) {
+ console.debug('getting reactions details')
+ try {
+ const response = await getReactionsDetails(token, messageId)
+ context.commit('addReactions', {
+ token,
+ messageId,
+ reactions: response.data.ocs.data,
+ })
+
+ return response
+ } catch (error) {
+ console.debug(error)
+ }
+ },
+}
+
+export default { state, mutations, getters, actions }
diff --git a/src/store/storeConfig.js b/src/store/storeConfig.js
index 16b8b6db4..8d772fa26 100644
--- a/src/store/storeConfig.js
+++ b/src/store/storeConfig.js
@@ -38,6 +38,7 @@ import tokenStore from './tokenStore'
import uiModeStore from './uiModeStore'
import windowVisibilityStore from './windowVisibilityStore'
import messageActionsStore from './messageActionsStore'
+import reactionsStore from './reactionsStore'
export default {
modules: {
@@ -59,6 +60,7 @@ export default {
uiModeStore,
windowVisibilityStore,
messageActionsStore,
+ reactionsStore,
},
mutations: {},