diff options
Diffstat (limited to 'src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar')
2 files changed, 890 insertions, 0 deletions
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> |