mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
feat: along with editor content also save attachments and macro actions in drafts
This commit is contained in:
+4
-2
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user