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 @@