Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export default {

isDragAndDropBlocked() {
return this.chatExtrasStore.getMessageIdToEdit(this.token) !== undefined || !this.canUploadFiles
|| this.chatExtrasStore.scheduleMessageTime
},

dropHintText() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import type { BigIntChatMessage } from '../../../../../types/index.ts'
import type { RawTemporaryMessagePayload } from '../../../../../utils/prepareTemporaryMessage.ts'

import { t } from '@nextcloud/l10n'
import { computed, inject, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcActionText from '@nextcloud/vue/components/NcActionText'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconAlarm from 'vue-material-design-icons/Alarm.vue'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconCalendarClockOutline from 'vue-material-design-icons/CalendarClockOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import IconSendOutline from 'vue-material-design-icons/SendOutline.vue'
import IconSendVariantClockOutline from 'vue-material-design-icons/SendVariantClockOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { useTemporaryMessage } from '../../../../../composables/useTemporaryMessage.ts'
import { EventBus } from '../../../../../services/EventBus.ts'
import { useChatExtrasStore } from '../../../../../stores/chatExtras.ts'
import { convertToUnix, formatDateTime } from '../../../../../utils/formattedTime.ts'
import { getCustomDateOptions } from '../../../../../utils/getCustomDateOptions.ts'

const props = defineProps<{
message: BigIntChatMessage
isActionMenuOpen: boolean
}>()

const emit = defineEmits<{
(event: 'update:isActionMenuOpen', value: boolean): void
(event: 'edit'): void
}>()

const getMessagesListScroller = inject('getMessagesListScroller', () => undefined)

const router = useRouter()
const chatExtrasStore = useChatExtrasStore()
const vuexStore = useStore()

const { createTemporaryMessage } = useTemporaryMessage()

const submenu = ref<'schedule' | null>(null)
const customScheduleTimestamp = ref(new Date(props.message.timestamp * 1000))

const messageDateTime = computed(() => {
return formatDateTime(props.message.timestamp * 1000, 'shortDateWithTime')
})

/**
* Edit the scheduled message (trigger editing mode)
*/
async function handleEdit() {
emit('edit')
}

/**
* Edit the scheduled message (sendAt only)
*
* @param timestamp new scheduled timestamp (in ms)
*/
async function handleReschedule(timestamp: number) {
await chatExtrasStore.editScheduledMessage(props.message.token, props.message.id, {
message: props.message.message,
sendAt: convertToUnix(timestamp),
})
EventBus.emit('focus-message', { messageId: props.message.id })
}

/**
* Delete the scheduled message
*/
async function handleDelete() {
await chatExtrasStore.deleteScheduledMessage(props.message.token, props.message.id)
}

/**
* Send a scheduled message instantly
*/
async function handleSubmit() {
const temporaryMessagePayload: RawTemporaryMessagePayload = {
message: props.message.message,
token: props.message.token,
silent: props.message.silent,
}

if ((props.message.threadId ?? 0) > 0) {
temporaryMessagePayload.threadId = props.message.threadId
temporaryMessagePayload.isThread = true
}
if (props.message.parent?.id && !props.message.parent.deleted) {
temporaryMessagePayload.parent = props.message.parent
}
if (props.message.threadId === -1) {
// Substitute thread title with message text, if missing
temporaryMessagePayload.threadTitle = props.message.threadTitle
temporaryMessagePayload.threadReplies = 0
temporaryMessagePayload.isThread = true
}

const temporaryMessage = createTemporaryMessage(temporaryMessagePayload)

// Open normal chat/thread and scroll to bottom after sending message
await router.replace({ query: { threadId: temporaryMessagePayload.threadId }, hash: '' })
chatExtrasStore.setShowScheduledMessages(false)
EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true })

await vuexStore.dispatch('postNewMessage', { token: props.message.token, temporaryMessage })
await chatExtrasStore.deleteScheduledMessage(props.message.token, props.message.id)
}

/**
* Toggle action menu open state
*/
function onMenuOpen() {
emit('update:isActionMenuOpen', true)
}

/**
* Toggle action menu open state
*/
function onMenuClose() {
emit('update:isActionMenuOpen', false)
}
</script>

<template>
<div>
<NcButton
v-if="!isActionMenuOpen"
variant="tertiary"
:aria-label="t('spreed', 'More actions')"
:title="t('spreed', 'More actions')"
@click="onMenuOpen">
<template #icon>
<IconDotsHorizontal :size="20" />
</template>
</NcButton>
<NcActions
v-else
force-menu
open
placement="bottom-end"
:boundaries-element="getMessagesListScroller()"
@close="onMenuClose">
<template v-if="submenu === null">
<!-- Message timestamp -->
<NcActionText>
<template #icon>
<IconSendVariantClockOutline :size="20" />
</template>
{{ messageDateTime }}
</NcActionText>

<NcActionButton
key="set-schedule-menu"
is-menu
@click.stop="submenu = 'schedule'">
<template #icon>
<IconAlarm :size="20" />
</template>
{{ t('spreed', 'Reschedule') }}
</NcActionButton>
<NcActionButton
key="send-message"
close-after-click
@click.stop="handleSubmit">
<template #icon>
<IconSendOutline :size="20" />
</template>
{{ t('spreed', 'Send now') }}
</NcActionButton>

<NcActionSeparator />

<NcActionButton
key="edit-message"
close-after-click
@click.stop="handleEdit">
<template #icon>
<IconPencilOutline :size="20" />
</template>
{{ t('spreed', 'Edit') }}
</NcActionButton>
<NcActionButton
key="delete-message"
close-after-click
@click.stop="handleDelete">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
{{ t('spreed', 'Delete') }}
</NcActionButton>
</template>

<template v-else-if="submenu === 'schedule'">
<NcActionButton
key="action-back"
:aria-label="t('spreed', 'Back')"
@click.stop="submenu = null">
<template #icon>
<IconArrowLeft class="bidirectional-icon" />
</template>
{{ t('spreed', 'Back') }}
</NcActionButton>

<NcActionSeparator />

<NcActionButton
v-for="option in getCustomDateOptions()"
:key="option.key"
:aria-label="option.ariaLabel"
close-after-click
@click.stop="handleReschedule(option.timestamp)">
{{ option.label }}
</NcActionButton>

<NcActionInput
v-model="customScheduleTimestamp"
type="datetime-local"
:min="new Date()"
:label="t('spreed', 'Choose a time')"
:step="300"
is-native-picker>
<template #icon>
<IconCalendarClockOutline :size="20" />
</template>
</NcActionInput>

<NcActionButton
key="custom-time-submit"
:disabled="!customScheduleTimestamp"
close-after-click
@click.stop="handleReschedule(customScheduleTimestamp.valueOf())">
<template #icon>
<IconCheck :size="20" />
</template>
{{ t('spreed', 'Send at custom time') }}
</NcActionButton>
</template>
</NcActions>
</div>
</template>
58 changes: 41 additions & 17 deletions src/components/MessagesList/MessagesGroup/Message/MessageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,32 @@
'bottom-side': isSplitViewEnabled && !isShortSimpleMessage && (isSmallMobile || isSidebar),
overlay: isSplitViewEnabled && !isShortSimpleMessage && isReactionsMenuOpen && !(isSmallMobile || isSidebar),
}">
<MessageButtonsBar
v-if="showMessageButtonsBar"
v-model:is-action-menu-open="isActionMenuOpen"
v-model:is-emoji-picker-open="isEmojiPickerOpen"
v-model:is-reactions-menu-open="isReactionsMenuOpen"
v-model:is-forwarder-open="isForwarderOpen"
class="message-buttons-bar"
:class="{ outlined: buttonsBarOutlined }"
:is-translation-available="isTranslationAvailable"
:can-react="canReact"
:message="message"
:previous-message-id="previousMessageId"
:read-info="readInfo"
@show-translate-dialog="isTranslateDialogOpen = true"
@reply="handleReply"
@edit="handleEdit"
@delete="handleDelete" />
<template v-if="showMessageButtonsBar">
<ScheduledMessageActions
v-if="showScheduledMessages"
v-model:is-action-menu-open="isActionMenuOpen"
:message="message"
class="message-buttons-bar"
:class="{ outlined: buttonsBarOutlined }"
@edit="handleEdit" />
<MessageButtonsBar
v-else
v-model:is-action-menu-open="isActionMenuOpen"
v-model:is-emoji-picker-open="isEmojiPickerOpen"
v-model:is-reactions-menu-open="isReactionsMenuOpen"
v-model:is-forwarder-open="isForwarderOpen"
class="message-buttons-bar"
:class="{ outlined: buttonsBarOutlined }"
:is-translation-available="isTranslationAvailable"
:can-react="canReact"
:message="message"
:previous-message-id="previousMessageId"
:read-info="readInfo"
@show-translate-dialog="isTranslateDialogOpen = true"
@reply="handleReply"
@edit="handleEdit"
@delete="handleDelete" />
</template>
<div
v-else-if="isSplitViewEnabled && isPinned"
class="icon-pin-highlighted"
Expand Down Expand Up @@ -100,6 +109,7 @@ import IconPin from 'vue-material-design-icons/PinOutline.vue'
import MessageButtonsBar from './MessageButtonsBar/MessageButtonsBar.vue'
import MessageForwarder from './MessageButtonsBar/MessageForwarder.vue'
import MessageTranslateDialog from './MessageButtonsBar/MessageTranslateDialog.vue'
import ScheduledMessageActions from './MessageButtonsBar/ScheduledMessageActions.vue'
import ContactCard from './MessagePart/ContactCard.vue'
import DeckCard from './MessagePart/DeckCard.vue'
import DefaultParameter from './MessagePart/DefaultParameter.vue'
Expand All @@ -126,6 +136,7 @@ export default {
MessageForwarder,
MessageTranslateDialog,
ReactionsWrapper,
ScheduledMessageActions,
IconPin,
},

Expand Down Expand Up @@ -194,6 +205,10 @@ export default {
return this.message.messageType === MESSAGE.TYPE.COMMENT_DELETED
},

showScheduledMessages() {
return this.chatExtrasStore.showScheduledMessages
},

conversation() {
return this.$store.getters.conversation(this.message.token)
},
Expand Down Expand Up @@ -279,6 +294,13 @@ export default {
},

readInfo() {
if (this.showScheduledMessages) {
return {
showSilentIcon: this.message.silent,
silentIconTitle: t('spreed', 'Will be sent without notification'),
}
}

return {
showCommonReadIcon: this.showCommonReadIcon,
commonReadIconTitle: t('spreed', 'Message read by everyone who shares their reading status'),
Expand Down Expand Up @@ -308,6 +330,8 @@ export default {

return this.message.id === this.message.threadId
|| (this.message.threadTitle && this.message.id.toString().startsWith('temp-'))
// FIXME properly render scheduled messages as threads
|| (this.message.threadTitle && this.message.threadId === -1)
},

isShortSimpleMessage() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
<!-- Actions and reactions slot -->
<div v-if="!isDeletedMessage" class="message-actions">
<NcButton
v-if="isThreadStarterMessage"
v-if="isThreadStarterMessage && message.threadId !== -1"
class="message-actions__thread"
:class="{ light: isSplitViewEnabled && isOwnMessage }"
size="small"
Expand Down Expand Up @@ -339,6 +339,8 @@ export default {

return this.message.id === this.message.threadId
|| (this.message.threadTitle && this.message.id.toString().startsWith('temp-'))
// FIXME properly render scheduled messages as threads
|| (this.message.threadTitle && this.message.threadId === -1)
},

threadInfo() {
Expand Down
Loading