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

github.com/nextcloud/spreed.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormarco <marcoambrosini@pm.me>2022-02-03 15:43:13 +0300
committerJoas Schilling <coding@schilljs.com>2022-03-21 14:08:25 +0300
commitb3ea65f6d10cfc0dfc726f30782121689cdb20f1 (patch)
tree8cbb45b1bee251fada83b746691897e62884fe09 /src/components/MessagesList/MessagesGroup/Message
parentc983d907cc03378b18f7dc3079e51561ba3f35b8 (diff)
create MessageButtonsBar component
Signed-off-by: marco <marcoambrosini@pm.me>
Diffstat (limited to 'src/components/MessagesList/MessagesGroup/Message')
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/Message.spec.js439
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/Message.vue259
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js468
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue381
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>