feat(schedule): action to edit scheduled messages

Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
This commit is contained in:
Maksim Sukharev 2025-12-09 23:58:50 +01:00
commit 91f3fbb1d6
4 changed files with 228 additions and 13 deletions

View file

@ -0,0 +1,181 @@
<!--
- 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 { t } from '@nextcloud/l10n'
import { computed, inject, ref } from 'vue'
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 IconSendVariantClock from 'vue-material-design-icons/SendVariantClock.vue'
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 chatExtrasStore = useChatExtrasStore()
const submenu = ref<'schedule' | null>(null)
const customScheduleTimestamp = ref(new Date(new Date().setHours(new Date().getHours() + 1, 0, 0, 0)))
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),
})
}
/**
* 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>
<IconSendVariantClock :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>
<NcActionSeparator />
<NcActionButton
key="edit-message"
:aria-label="t('spreed', 'Edit message')"
close-after-click
@click.stop="handleEdit">
<template #icon>
<IconPencilOutline :size="20" />
</template>
{{ t('spreed', 'Edit message') }}
</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()"
: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 on custom time') }}
</NcActionButton>
</template>
</NcActions>
</div>
</template>

View file

@ -50,9 +50,12 @@
'bottom-side': isSplitViewEnabled && !isShortSimpleMessage && (isSmallMobile || isSidebar),
overlay: isSplitViewEnabled && !isShortSimpleMessage && isReactionsMenuOpen && !(isSmallMobile || isSidebar),
}">
<div
<ScheduledMessageActions
v-if="showScheduledMessages && showMessageButtonsBar"
class="message-buttons-bar" />
v-model:is-action-menu-open="isActionMenuOpen"
:message="message"
class="message-buttons-bar"
@edit="handleEdit" />
<MessageButtonsBar
v-else-if="showMessageButtonsBar"
v-model:is-action-menu-open="isActionMenuOpen"
@ -94,6 +97,7 @@ import { inject } from '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'
@ -120,6 +124,7 @@ export default {
MessageForwarder,
MessageTranslateDialog,
ReactionsWrapper,
ScheduledMessageActions,
},
props: {

View file

@ -547,7 +547,12 @@ export default {
messageToEdit() {
const messageToEditId = this.chatExtrasStore.getMessageIdToEdit(this.token)
return messageToEditId && this.$store.getters.message(this.token, messageToEditId)
if (!messageToEditId) {
return undefined
}
return (this.showScheduledMessages)
? this.chatExtrasStore.getScheduledMessage(this.token, messageToEditId)
: this.$store.getters.message(this.token, messageToEditId)
},
canShareFiles() {
@ -721,7 +726,12 @@ export default {
messageToEdit(newValue) {
if (newValue) {
this.text = this.chatExtrasStore.getChatEditInput(this.token)
this.chatExtrasStore.removeThreadTitle(this.token)
// Clear thread title when editing a message (unless it's a scheduled thread)
if (newValue.threadId !== -1) {
this.chatExtrasStore.removeThreadTitle(this.token)
}
if (this.parentMessage) {
this.chatExtrasStore.removeParentIdToReply(this.token)
}
@ -992,12 +1002,21 @@ export default {
async handleEdit() {
try {
await this.$store.dispatch('editMessage', {
token: this.token,
messageId: this.messageToEdit.id,
updatedMessage: parseSpecialSymbols(this.text.trim()),
})
if (this.showScheduledMessages) {
await this.chatExtrasStore.editScheduledMessage(this.token, this.messageToEdit.id, {
message: parseSpecialSymbols(this.text.trim()),
sendAt: this.messageToEdit.timestamp,
threadTitle: this.threadTitle,
})
} else {
await this.$store.dispatch('editMessage', {
token: this.token,
messageId: this.messageToEdit.id,
updatedMessage: parseSpecialSymbols(this.text.trim()),
})
}
this.chatExtrasStore.removeMessageIdToEdit(this.token)
this.chatExtrasStore.removeThreadTitle(this.token)
this.resetTypingIndicator()
// refocus input as the user might want to type further
this.focusInput()

View file

@ -38,6 +38,13 @@ import {
import { parseMentions, parseSpecialSymbols } from '../utils/textParse.ts'
import { useActorStore } from './actor.ts'
type InitiateEditingMessagePayload = {
token: string
id: number | string
message: string
messageParameters: ChatMessage['messageParameters']
}
const FOLLOWED_THREADS_FETCH_LIMIT = 100
const pendingFetchSingleThreadRequests = new Set<number>()
@ -52,7 +59,7 @@ export const useChatExtrasStore = defineStore('chatExtras', () => {
const threadTitle = ref<Record<string, string>>({})
const parentToReply = ref<Record<string, number>>({})
const chatInput = ref<Record<string, string>>({})
const messageIdToEdit = ref<Record<string, number>>({})
const messageIdToEdit = ref<Record<string, number | string>>({})
const chatEditInput = ref<Record<string, string>>({})
const tasksCount = ref(0)
const tasksDoneCount = ref(0)
@ -134,7 +141,7 @@ export const useChatExtrasStore = defineStore('chatExtras', () => {
*
* @param token - conversation token
*/
function getMessageIdToEdit(token: string) {
function getMessageIdToEdit(token: string): number | string | undefined {
return messageIdToEdit.value[token]
}
@ -521,7 +528,7 @@ export const useChatExtrasStore = defineStore('chatExtras', () => {
* @param token - conversation token
* @param id The id of message
*/
function setMessageIdToEdit(token: string, id: number) {
function setMessageIdToEdit(token: string, id: number | string) {
messageIdToEdit.value[token] = id
}
@ -554,7 +561,7 @@ export const useChatExtrasStore = defineStore('chatExtras', () => {
* @param payload.message - message text
* @param payload.messageParameters - message parameters
*/
function initiateEditingMessage({ token, id, message, messageParameters }: { token: string, id: number, message: string, messageParameters: ChatMessage['messageParameters'] }) {
function initiateEditingMessage({ token, id, message, messageParameters }: InitiateEditingMessagePayload) {
setMessageIdToEdit(token, id)
const isFileShareOnly = Object.keys(messageParameters ?? {}).some((key) => key.startsWith('file'))
&& message === '{file}'
@ -567,6 +574,9 @@ export const useChatExtrasStore = defineStore('chatExtras', () => {
parameters: messageParameters,
})
}
if (scheduledMessages.value[token]?.[id] && scheduledMessages.value[token][id].threadId === -1) {
setThreadTitle(token, scheduledMessages.value[token][id].threadTitle!)
}
EventBus.emit('editing-message')
EventBus.emit('focus-chat-input')
}