diff options
Diffstat (limited to 'src/components')
6 files changed, 1139 insertions, 658 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; - } } } |