diff options
author | marco <marcoambrosini@pm.me> | 2022-02-03 15:43:13 +0300 |
---|---|---|
committer | Joas Schilling <coding@schilljs.com> | 2022-03-21 14:08:25 +0300 |
commit | b3ea65f6d10cfc0dfc726f30782121689cdb20f1 (patch) | |
tree | 8cbb45b1bee251fada83b746691897e62884fe09 /src/components/MessagesList/MessagesGroup/Message | |
parent | c983d907cc03378b18f7dc3079e51561ba3f35b8 (diff) |
create MessageButtonsBar component
Signed-off-by: marco <marcoambrosini@pm.me>
Diffstat (limited to 'src/components/MessagesList/MessagesGroup/Message')
4 files changed, 882 insertions, 665 deletions
diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js index 02017389f..0314fc86f 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js @@ -1,19 +1,18 @@ import Vuex from 'vuex' -import { createLocalVue, shallowMount } from '@vue/test-utils' +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' @@ -511,7 +510,6 @@ describe('Message.vue', () => { }) describe('actions', () => { - const ACTIONS_SELECTOR = '.message__buttons-bar' beforeEach(() => { store = new Vuex.Store(testStoreConfig) @@ -528,8 +526,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 +541,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,432 +555,25 @@ 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() - - // appears even with more restrictive conditions - conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY - messageProps.actorId = 'another-user' - - const wrapper = shallowMount(Message, { - localVue, - store, - mocks: { - $copyText: copyTextMock, - }, - 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, '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 Vuex.Store(testStoreConfig) - const wrapper = shallowMount(Message, { - 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({ - apiVersion: 'v3', - message: messageProps, - metadata: conversationProps, - }) - - const actionButton2 = findActionButton(wrapper, 'second action') - expect(actionButton2.exists()).toBe(true) - await actionButton2.find('button').trigger('click') - - expect(handler2).toHaveBeenCalledWith({ - apiVersion: 'v3', - message: messageProps, - metadata: conversationProps, - }) - }) }) describe('status', () => { diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index 280b231e3..4e7397ba5 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -112,73 +112,12 @@ 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> - </div> </div> + <MessageButtonsBar v-if="hasMessageButtonsBar" + v-show="showMessageButtonsBar" + v-bind="$props" + :previous-message-id="previousMessageId" + :participant="participant" /> <div v-if="isLastReadMessage" v-observe-visibility="lastReadMessageVisibilityChanged"> <div class="new-message-marker"> @@ -192,10 +131,6 @@ the main body of the message as well as a quote. </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 +141,18 @@ 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 { CONVERSATION } 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' export default { name: 'Message', @@ -235,20 +162,15 @@ export default { }, components: { - Actions, - ActionButton, - ActionLink, CallButton, Quote, RichText, AlertCircle, Check, CheckAll, - EyeOffOutline, Reload, - Share, - ActionSeparator, Forwarder, + MessageButtonsBar, }, mixins: [ @@ -388,7 +310,7 @@ export default { data() { return { - showActions: false, + showMessageButtonsBar: false, // Is tall enough for both actions and date upon hovering isTallEnough: false, showReloadButton: false, @@ -547,21 +469,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 }, @@ -600,45 +514,6 @@ export default { && 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 }, @@ -650,10 +525,6 @@ export default { apiVersion: 'v3', } }, - - isCurrentGuest() { - return this.$store.getters.getActorType() === 'guests' - }, }, watch: { @@ -696,106 +567,23 @@ 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') - }, - async handleDelete() { - this.isDeleting = true - try { - const statusCode = await this.$store.dispatch('deleteMessage', { - message: { - token: this.token, - id: this.id, - }, - placeholder: t('spreed', 'Deleting message'), - }) - - if (statusCode === 202) { - showWarning(t('spreed', 'Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services'), { - timeout: TOAST_DEFAULT_TIMEOUT * 2, - }) - } else if (statusCode === 200) { - showSuccess(t('spreed', 'Message deleted successfully')) - } - } catch (e) { - if (e?.response?.status === 400) { - showError(t('spreed', 'Message could not be deleted because it is too old')) - } else if (e?.response?.status === 405) { - showError(t('spreed', 'Only normal chat messages can be deleted')) - } else { - showError(t('spreed', 'An error occurred while deleting the message')) - console.error(e) - } - this.isDeleting = false - return - } - - this.isDeleting = false - }, handleMouseover() { - this.showActions = true + this.showMessageButtonsBar = 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 + this.showMessageButtonsBar = 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 }) - }, }, } </script> @@ -811,6 +599,10 @@ export default { } } +.message { + position: relative; +} + .message-body { padding: 4px; font-size: $chat-font-size; @@ -938,19 +730,4 @@ export default { cursor: pointer; } } - -.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/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js new file mode 100644 index 000000000..eeff13add --- /dev/null +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js @@ -0,0 +1,468 @@ +import Vuex 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', + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('actions', () => { + + beforeEach(() => { + store = new Vuex.Store(testStoreConfig) + }) + + 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(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, + }) + }) + + test('hides reply button when not replyable', async () => { + messageProps.isReplyable = false + store = new Vuex.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 Vuex.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 Vuex.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('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(MessageButtonsBar, { + 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) + + messageProps.participant = { + actorId: 'user-id-1', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + 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 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(MessageButtonsBar, { + 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() + + // appears even with more restrictive conditions + conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY + messageProps.actorId = 'another-user' + + const wrapper = shallowMount(MessageButtonsBar, { + localVue, + store, + mocks: { + $copyText: copyTextMock, + }, + 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, '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 Vuex.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({ + apiVersion: 'v3', + message: messageProps, + metadata: conversationProps, + }) + + const actionButton2 = findActionButton(wrapper, 'second action') + expect(actionButton2.exists()).toBe(true) + await actionButton2.find('button').trigger('click') + + expect(handler2).toHaveBeenCalledWith({ + apiVersion: 'v3', + message: messageProps, + metadata: conversationProps, + }) + }) + }) +}) 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..bd7da0df5 --- /dev/null +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue @@ -0,0 +1,381 @@ +<!-- + - @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"> + <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> + </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 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, + showWarning, + TOAST_DEFAULT_TIMEOUT, +} from '@nextcloud/dialogs' + +export default { + name: 'MessageButtonsBar', + + components: { + Actions, + ActionButton, + ActionLink, + EyeOffOutline, + Share, + ActionSeparator, + }, + + props: { + token: { + type: String, + required: true, + }, + + previousMessageId: { + type: [String, Number], + required: true, + }, + + isReplyable: { + type: Boolean, + 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 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, + }, + }, + + 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' + }, + }, + + 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, + }) + EventBus.$emit('focus-chat-input') + }, + + async handleDelete() { + this.isDeleting = true + try { + const statusCode = await this.$store.dispatch('deleteMessage', { + message: { + token: this.token, + id: this.id, + }, + placeholder: t('spreed', 'Deleting message'), + }) + + if (statusCode === 202) { + showWarning(t('spreed', 'Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services'), { + timeout: TOAST_DEFAULT_TIMEOUT * 2, + }) + } else if (statusCode === 200) { + showSuccess(t('spreed', 'Message deleted successfully')) + } + } catch (e) { + if (e?.response?.status === 400) { + showError(t('spreed', 'Message could not be deleted because it is too old')) + } else if (e?.response?.status === 405) { + showError(t('spreed', 'Only normal chat messages can be deleted')) + } else { + showError(t('spreed', 'An error occurred while deleting the message')) + console.error(e) + } + this.isDeleting = false + return + } + + this.isDeleting = 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.')) + } + }, + + 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 }) + }, + }, +} +</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> |