feat: along with editor content also save attachments and macro actions in drafts

This commit is contained in:
Abhinav Raut
2025-12-21 23:37:33 +05:30
parent cf67b9679d
commit 6bcc16e541
17 changed files with 247 additions and 94 deletions
+4 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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]
@@ -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(
+93 -30
View File
@@ -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
}
}
+15 -1
View File
@@ -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
}
}
+6 -1
View File
@@ -13,4 +13,9 @@ export const CONVERSATION_DEFAULT_STATUSES = {
CLOSED: 'Closed',
}
export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES);
export const CONVERSATION_DEFAULT_STATUSES_LIST = Object.values(CONVERSATION_DEFAULT_STATUSES);
export const MACRO_CONTEXT = {
REPLY: 'reply',
NEW_CONVERSATION: 'new-conversation'
}
+3 -3
View File
@@ -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()
}
@@ -185,10 +185,10 @@
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.getMacro('new-conversation').actions?.length > 0"
:actions="conversationStore.getMacro('new-conversation')?.actions || []"
v-if="conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION).actions?.length > 0"
:actions="conversationStore.getMacro(MACRO_CONTEXT.NEW_CONVERSATION)?.actions || []"
:onRemove="
(action) => conversationStore.removeMacroAction(action, 'new-conversation')
(action) => conversationStore.removeMacroAction(action, MACRO_CONTEXT.NEW_CONVERSATION)
"
class="mt-2 flex-shrink-0"
/>
@@ -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 }
)
+70 -17
View File
@@ -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 @@
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, toRaw } from 'vue'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { useUserStore } from '@/stores/user'
import { useDraftManager } from '@/composables/useDraftManager'
import api from '@/api'
@@ -147,19 +150,27 @@ const emitter = useEmitter()
const userStore = useUserStore()
// Setup file upload composable
const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMediaFiles } =
useFileUpload({
linkedModel: 'messages'
})
const {
uploadingFiles,
handleFileUpload,
handleFileDelete,
mediaFiles,
clearMediaFiles,
setMediaFiles
} = useFileUpload({
linkedModel: 'messages'
})
// Setup draft management composable
const currentDraftKey = computed(() => conversationStore.current?.uuid || null)
const {
htmlContent,
textContent,
isLoadingDraft,
clearDraft
} = useDraftManager(currentDraftKey)
isLoading: isDraftLoading,
meta: draftMeta,
clearDraft,
loadedAttachments
} = useDraftManager(currentDraftKey, mediaFiles)
// Rest of existing state
const openAIKeyPrompt = ref(false)
@@ -285,8 +296,8 @@ const processSend = async () => {
}
// Apply macro actions if any, for macro errors just show toast and clear the editor.
const macroID = conversationStore.getMacro('reply')?.id
const macroActions = conversationStore.getMacro('reply')?.actions || []
const macroID = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.id
const macroActions = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions || []
if (macroID > 0 && macroActions.length > 0) {
try {
await api.applyMacro(conversationStore.current.uuid, macroID, macroActions)
@@ -306,11 +317,11 @@ const processSend = async () => {
} finally {
// If API has NOT errored clear state.
if (hasMessageSendingErrored === false) {
// Clear draft using composable
// Clear draft from backend.
clearDraft(currentDraftKey.value)
// Clear macro.
conversationStore.resetMacro('reply')
// Clear macro for this conversation reply.
conversationStore.resetMacro(MACRO_CONTEXT.REPLY)
// Clear media files.
clearMediaFiles()
@@ -321,20 +332,53 @@ const processSend = async () => {
isSending.value = false
}
}
/**
* Watches for changes in the conversation's macro id and update message content.
*/
watch(
() => conversationStore.getMacro('reply').id,
(newId, oldId) => {
// Only update if macro ID actually changed and is not undefined/0
if (newId && newId !== oldId && conversationStore.getMacro('reply').message_content) {
// No macro set.
if (!newId) return
// If macro ID has changed and there is message content, update editor content.
if (newId !== oldId && conversationStore.getMacro('reply').message_content) {
htmlContent.value = conversationStore.getMacro('reply').message_content
}
},
{ deep: true }
)
/**
* Watch changes in draft meta which has macro actions and update conversation store.
*/
watch(
() => draftMeta,
(newMeta) => {
if (newMeta.value?.macro_actions) {
conversationStore.setMacroActions(
[...toRaw(newMeta.value.macro_actions)],
MACRO_CONTEXT.REPLY
)
}
},
{ deep: true }
)
/**
* Watch for loaded attachments from draft and restore them to mediaFiles.
*/
watch(
loadedAttachments,
(newAttachments) => {
if (newAttachments && newAttachments.length > 0) {
setMediaFiles([...newAttachments])
}
},
{ deep: true }
)
// Initialize to, cc, and bcc fields with the current conversation's values.
watch(
() => conversationStore.currentCC,
@@ -364,4 +408,13 @@ watch(
},
{ deep: true, immediate: true }
)
</script>
// Clear media files and reset macro when conversation changes.
watch(
() => conversationStore.current?.uuid,
() => {
clearMediaFiles()
conversationStore.resetMacro(MACRO_CONTEXT.REPLY)
}
)
</script>
@@ -97,9 +97,9 @@
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.getMacro('reply')?.actions?.length > 0"
:actions="conversationStore.getMacro('reply').actions"
:onRemove="(action) => conversationStore.removeMacroAction(action, 'reply')"
v-if="conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions?.length > 0"
:actions="conversationStore.getMacro(MACRO_CONTEXT.REPLY).actions"
:onRemove="(action) => conversationStore.removeMacroAction(action, MACRO_CONTEXT.REPLY)"
class="mt-2"
/>
@@ -128,6 +128,7 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@/constants/conversation'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from '@/components/editor/TextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
@@ -173,6 +174,11 @@ const props = defineProps({
type: Array,
required: false,
default: () => []
},
isDraftLoading: {
type: Boolean,
required: false,
default: false
}
})
@@ -211,7 +217,7 @@ const enableSend = computed(() => {
conversationStore.getMacro('reply')?.actions?.length > 0 ||
props.uploadedFiles.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
!props.uploadingFiles.length && !props.isDraftLoading
)
})
+10 -1
View File
@@ -662,10 +662,17 @@ export const useConversationStore = defineStore('conversation', () => {
/** Macros set for new conversation or an open conversation **/
async function setMacro (macro, context) {
function setMacro (macro, context) {
macros.value[context] = macro
}
function setMacroActions (actions, context) {
if (!macros.value[context]) {
macros.value[context] = {}
}
macros.value[context].actions = actions
}
function getMacro (context) {
return macros.value[context] || {}
}
@@ -680,6 +687,7 @@ export const useConversationStore = defineStore('conversation', () => {
}
return {
macros,
conversations,
conversation,
messages,
@@ -721,6 +729,7 @@ export const useConversationStore = defineStore('conversation', () => {
getMacro,
setMacro,
resetMacro,
setMacroActions,
removeAssignee,
getListSortField,
getListStatus,
+4 -6
View File
@@ -14,7 +14,8 @@ export const useDraftStore = defineStore('drafts', () => {
return {
htmlContent: draft.content || '',
// We only store HTML in backend.
textContent: ''
textContent: '',
meta: draft.meta || {}
}
} catch (error) {
return { htmlContent: '', textContent: '' }
@@ -24,14 +25,11 @@ export const useDraftStore = defineStore('drafts', () => {
/**
* Save draft to backend
*/
const setDraft = async (uuid, htmlContent, textContent) => {
const setDraft = async (uuid, htmlContent, textContent, meta = {}) => {
if (!uuid) return
if (!textContent || textContent.trim() === '') return
if (!htmlContent || htmlContent.trim() === '') return
try {
await api.saveDraft(uuid, { content: htmlContent })
await api.saveDraft(uuid, { content: htmlContent, meta })
} catch (error) {
// pass
}
+3 -2
View File
@@ -3,6 +3,7 @@ package conversation
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -11,10 +12,10 @@ import (
)
// UpsertConversationDraft saves or updates a draft for a conversation.
func (m *Manager) UpsertConversationDraft(conversationID, userID int, content string) (models.ConversationDraft, error) {
func (m *Manager) UpsertConversationDraft(conversationID, userID int, content string, meta json.RawMessage) (models.ConversationDraft, error) {
var draft models.ConversationDraft
if err := m.q.UpsertConversationDraft.Get(&draft, conversationID, userID, content); err != nil {
if err := m.q.UpsertConversationDraft.Get(&draft, conversationID, userID, content, meta); err != nil {
m.lo.Error("error upserting conversation draft", "conversation_id", conversationID, "user_id", userID, "error", err)
return draft, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "draft"), nil)
}
+7 -6
View File
@@ -272,10 +272,11 @@ type Priority struct {
// ConversationDraft represents a draft reply for a conversation.
type ConversationDraft struct {
ID int64 `db:"id" json:"id"`
ConversationID int64 `db:"conversation_id" json:"conversation_id"`
UserID int64 `db:"user_id" json:"user_id"`
Content string `db:"content" json:"content"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID int64 `db:"id" json:"id"`
ConversationID int64 `db:"conversation_id" json:"conversation_id"`
UserID int64 `db:"user_id" json:"user_id"`
Content string `db:"content" json:"content"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Meta json.RawMessage `db:"meta" json:"meta"`
}
+3 -3
View File
@@ -559,10 +559,10 @@ ORDER BY m.created_at DESC
LIMIT 1;
-- name: upsert-conversation-draft
INSERT INTO conversation_drafts (conversation_id, user_id, content, updated_at)
VALUES ($1, $2, $3, NOW())
INSERT INTO conversation_drafts (conversation_id, user_id, content, meta, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
DO UPDATE SET content = EXCLUDED.content, meta = EXCLUDED.meta, updated_at = NOW()
RETURNING *;
-- name: get-conversation-draft
+1 -1
View File
@@ -17,7 +17,7 @@ func V0_9_1(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
content TEXT NOT NULL,
CONSTRAINT constraint_conversation_drafts_on_content CHECK (length(content) <= 10000)
meta JSONB DEFAULT '{}'::jsonb NOT NULL
);
`)
if err != nil {
+1
View File
@@ -306,6 +306,7 @@ CREATE TABLE conversation_drafts (
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
content TEXT NOT NULL,
meta JSONB DEFAULT '{}'::jsonb NOT NULL
);
CREATE UNIQUE INDEX index_uniq_conversation_drafts_on_conversation_id_and_user_id ON conversation_drafts (conversation_id, user_id);