diff --git a/cmd/draft.go b/cmd/draft.go index 4da164dc..25207328 100644 --- a/cmd/draft.go +++ b/cmd/draft.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -10,7 +11,8 @@ import ( ) type draftReq struct { - Content string `json:"content"` + Content string `json:"content"` + Meta json.RawMessage `json:"meta"` } // handleUpsertConversationDraft saves or updates a draft for a conversation. @@ -43,7 +45,7 @@ func handleUpsertConversationDraft(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "content"), nil, envelope.InputError) } - draft, err := app.conversation.UpsertConversationDraft(conv.ID, user.ID, req.Content) + draft, err := app.conversation.UpsertConversationDraft(conv.ID, user.ID, req.Content, req.Meta) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/config.sample.toml b/config.sample.toml index 284736de..ae06134b 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -118,7 +118,7 @@ timeout = "15s" [conversation] # How often to check for conversations to unsnooze unsnooze_interval = "5m" -# How long to keep drafts before deleting them (e.g., "720h", "48h") +# How long to keep drafts before deleting them from the database. (e.g. "720h", "48h") draft_retention_period = "720h" [sla] diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index 9090fb59..f8e87f37 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -243,18 +243,17 @@ const editor = useEditor({ } }) -// Watch htmlContent for external changes (like draft loading) -watch(htmlContent, (newContent) => { - if (!isInternalUpdate.value && editor.value) { - const editorHTML = editor.value.getHTML() - - // Only update if content is actually different - if (newContent !== editorHTML) { +watch( + htmlContent, + (newContent) => { + if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) { editor.value.commands.setContent(newContent || '', false) textContent.value = editor.value.getText() + editor.value.commands.focus() } - } -}) + }, + { immediate: true } +) // Insert content at cursor position when insertContent prop changes. watch( diff --git a/frontend/src/composables/useDraftManager.js b/frontend/src/composables/useDraftManager.js index 81baee67..e11aafa6 100644 --- a/frontend/src/composables/useDraftManager.js +++ b/frontend/src/composables/useDraftManager.js @@ -1,51 +1,101 @@ import { ref, watch } from 'vue' import { watchDebounced } from '@vueuse/core' import { useDraftStore } from '@/stores/draftStore' +import { useConversationStore } from '@/stores/conversation' +import { MACRO_CONTEXT } from '@/constants/conversation' /** * Composable for managing draft state and persistence * @param key - Reactive reference to current draft key + * @param uploadedFiles - Optional reactive reference to uploaded files array */ -export function useDraftManager (key) { +export function useDraftManager (key, uploadedFiles = null) { const draftStore = useDraftStore() + const conversationStore = useConversationStore() const htmlContent = ref('') const textContent = ref('') - const isLoadingDraft = ref(false) + const meta = ref({}) + const isLoading = ref(false) const isDirty = ref(false) + const loadedAttachments = ref([]) + + /** + * Reset all draft state to initial values + */ + const resetState = () => { + htmlContent.value = '' + textContent.value = '' + meta.value = {} + isLoading.value = false + isDirty.value = false + loadedAttachments.value = [] + } /** * Load draft from backend for a given key */ const loadDraft = async (key) => { if (!key) return - isLoadingDraft.value = true - const draft = await draftStore.getDraft(key) - htmlContent.value = draft.htmlContent - textContent.value = draft.textContent + isLoading.value = true isDirty.value = false - isLoadingDraft.value = false + try { + const draft = await draftStore.getDraft(key) + console.log("Loaded draft:", draft) + htmlContent.value = draft.htmlContent + textContent.value = draft.textContent + meta.value = draft.meta || {} + loadedAttachments.value = draft.meta?.attachments || [] + } catch (error) { + resetState() + } finally { + isLoading.value = false + } } /** * Save draft to store */ const saveDraft = async (key) => { - if (!key || isLoadingDraft.value) return - await draftStore.setDraft(key, htmlContent.value, textContent.value) - isDirty.value = false + if (!key || isLoading.value) return + if (!isDirty.value) return + try { + const macroActions = getCurrentMacroActions() + let meta = {} + if (macroActions.length > 0) { + meta.macro_actions = macroActions + } + if (uploadedFiles && uploadedFiles.value && uploadedFiles.value.length > 0) { + meta.attachments = uploadedFiles.value + } + await draftStore.setDraft(key, htmlContent.value, textContent.value, meta) + isDirty.value = false + } catch (error) { + // pass + } } /** * Clear draft and local state */ - const clearDraft = (key) => { + const clearDraft = async (key) => { if (!key) return - isLoadingDraft.value = true - draftStore.clearDraft(key) - htmlContent.value = '' - textContent.value = '' - isDirty.value = false - isLoadingDraft.value = false + isLoading.value = true + try { + await draftStore.clearDraft(key) + resetState() + } catch (error) { + // pass + } finally { + isLoading.value = false + } + } + + /** + * Returns current set macro ID from draft meta + */ + const getCurrentMacroActions = () => { + const macro = conversationStore.getMacro(MACRO_CONTEXT.REPLY) + return macro ? macro.actions : [] } /** @@ -61,8 +111,12 @@ export function useDraftManager (key) { async (newKey, oldKey) => { // Save old draft first if content has changed. if (newKey != oldKey && isDirty.value && hasDraftContent()) { - draftStore.setDraft(oldKey, htmlContent.value, textContent.value) - isDirty.value = false + try { + await saveDraft(oldKey) + isDirty.value = false + } catch (error) { + // pass + } } // Load new draft. @@ -70,35 +124,44 @@ export function useDraftManager (key) { await loadDraft(newKey) } else if (!newKey && oldKey) { // Clear state. - isLoadingDraft.value = true - htmlContent.value = '' - textContent.value = '' - isDirty.value = false - isLoadingDraft.value = false + isLoading.value = true + resetState() + isLoading.value = false } }, { immediate: true } ) - // Auto-save draft when content changes (debounced to avoid excessive writes) + // Auto-save draft when content, macro, or uploaded files change (debounced) + const watchSources = [ + htmlContent, + textContent, + () => conversationStore.macros[MACRO_CONTEXT.REPLY] + ] + if (uploadedFiles) { + watchSources.push(uploadedFiles) + } + watchDebounced( - [htmlContent, textContent], + watchSources, async () => { - if (!isLoadingDraft.value && key.value) { + if (!isLoading.value && key.value) { isDirty.value = true await saveDraft(key.value) } }, - { debounce: 500 } + { debounce: 250, deep: true } ) return { + meta, htmlContent, textContent, - isLoadingDraft, + isLoading, loadDraft, saveDraft, clearDraft, - hasDraftContent + hasDraftContent, + loadedAttachments } } \ No newline at end of file diff --git a/frontend/src/composables/useFileUpload.js b/frontend/src/composables/useFileUpload.js index 5f43f3a4..2b58ffeb 100644 --- a/frontend/src/composables/useFileUpload.js +++ b/frontend/src/composables/useFileUpload.js @@ -127,6 +127,19 @@ export function useFileUpload (options = {}) { } } + /** + * Replace all media files with new files + * @param {Array} files - Array of file objects to set + */ + const setMediaFiles = (files) => { + if (Array.isArray(mediaFiles.value)) { + mediaFiles.value = files + } else { + mediaFiles.length = 0 + mediaFiles.push(...files) + } + } + return { // State uploadingFiles: readonly(uploadingFiles), @@ -137,6 +150,7 @@ export function useFileUpload (options = {}) { handleFileUpload, handleFileDelete, uploadFiles, - clearMediaFiles + clearMediaFiles, + setMediaFiles } } \ No newline at end of file diff --git a/frontend/src/constants/conversation.js b/frontend/src/constants/conversation.js index 7f294cff..8b8f2840 100644 --- a/frontend/src/constants/conversation.js +++ b/frontend/src/constants/conversation.js @@ -13,4 +13,9 @@ export const CONVERSATION_DEFAULT_STATUSES = { CLOSED: 'Closed', } -export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES); \ No newline at end of file +export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES); + +export const MACRO_CONTEXT = { + REPLY: 'reply', + NEW_CONVERSATION: 'new-conversation' +} \ No newline at end of file diff --git a/frontend/src/features/command/CommandBox.vue b/frontend/src/features/command/CommandBox.vue index fb5e0497..00f04cc5 100644 --- a/frontend/src/features/command/CommandBox.vue +++ b/frontend/src/features/command/CommandBox.vue @@ -211,7 +211,7 @@ import { useMagicKeys } from '@vueuse/core' import { CalendarIcon } from 'lucide-vue-next' import { useConversationStore } from '@/stores/conversation' import { useMacroStore } from '@/stores/macro' -import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' +import { CONVERSATION_DEFAULT_STATUSES, MACRO_CONTEXT } from '@/constants/conversation' import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next' import { CommandDialog, @@ -267,9 +267,9 @@ function handleApplyMacro(macro) { // Create a deep copy. const plainMacro = JSON.parse(JSON.stringify(macro)) if (nestedCommand.value === 'apply-macro-to-new-conversation') { - conversationStore.setMacro(plainMacro, 'new-conversation') + conversationStore.setMacro(plainMacro, MACRO_CONTEXT.NEW_CONVERSATION) } else { - conversationStore.setMacro(plainMacro, 'reply') + conversationStore.setMacro(plainMacro, MACRO_CONTEXT.REPLY) } toggleOpen() } diff --git a/frontend/src/features/conversation/CreateConversation.vue b/frontend/src/features/conversation/CreateConversation.vue index 4ce2c66e..e7b6901d 100644 --- a/frontend/src/features/conversation/CreateConversation.vue +++ b/frontend/src/features/conversation/CreateConversation.vue @@ -185,10 +185,10 @@ @@ -245,6 +245,7 @@ import { useConversationStore } from '@/stores/conversation' import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue' import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +import { MACRO_CONTEXT } from '@/constants/conversation' import { useEmitter } from '@/composables/useEmitter' import { handleHTTPError } from '@/utils/http' import { useInboxStore } from '@/stores/inbox' @@ -328,7 +329,7 @@ const formSchema = z.object({ onUnmounted(() => { clearTimeout(timeoutId) clearMediaFiles() - conversationStore.resetMacro('new-conversation') + conversationStore.resetMacro(MACRO_CONTEXT.NEW_CONVERSATION) emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, { command: null, open: false @@ -406,7 +407,7 @@ const createConversation = form.handleSubmit(async (values) => { const conversationUUID = conversation.data.data.uuid // Get macro from context, and set if any actions are available. - const macro = conversationStore.getMacro('new-conversation') + const macro = conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION) if (conversationUUID !== '' && macro?.id && macro?.actions?.length > 0) { try { await api.applyMacro(conversationUUID, macro.id, macro.actions) @@ -433,9 +434,9 @@ const createConversation = form.handleSubmit(async (values) => { * Watches for changes in the macro id and update message content. */ watch( - () => conversationStore.getMacro('new-conversation').id, + () => conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).id, () => { - form.setFieldValue('content', conversationStore.getMacro('new-conversation').message_content) + form.setFieldValue('content', conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).message_content) }, { deep: true } ) diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 783667b3..8cd8894f 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -51,6 +51,7 @@ :isFullscreen="true" :aiPrompts="aiPrompts" :isSending="isSending" + :isDraftLoading="isDraftLoading" :uploadingFiles="uploadingFiles" :uploadedFiles="mediaFiles" v-model:htmlContent="htmlContent" @@ -81,6 +82,7 @@ :isFullscreen="false" :aiPrompts="aiPrompts" :isSending="isSending" + :isDraftLoading="isDraftLoading" :uploadingFiles="uploadingFiles" :uploadedFiles="mediaFiles" v-model:htmlContent="htmlContent" @@ -102,9 +104,10 @@ \ No newline at end of file + +// Clear media files and reset macro when conversation changes. +watch( + () => conversationStore.current?.uuid, + () => { + clearMediaFiles() + conversationStore.resetMacro(MACRO_CONTEXT.REPLY) + } +) + diff --git a/frontend/src/features/conversation/ReplyBoxContent.vue b/frontend/src/features/conversation/ReplyBoxContent.vue index f475b6ad..d3b214b3 100644 --- a/frontend/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/src/features/conversation/ReplyBoxContent.vue @@ -97,9 +97,9 @@ @@ -128,6 +128,7 @@