From 284d421ab4a6d90b5ccbe5db88ed2558ff135a81 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Wed, 19 Nov 2025 23:13:24 +0530 Subject: [PATCH 001/160] fix: ReplyBox draft handling by integrating Pinia draft store --- .../src/features/conversation/ReplyBox.vue | 37 +++++++-- frontend/src/stores/draftStore.js | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 frontend/src/stores/draftStore.js diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 764e0412..06fe5e85 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -106,6 +106,7 @@ import { ref, onMounted, watch, computed } from 'vue' import { handleHTTPError } from '@/utils/http' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { useUserStore } from '@/stores/user' +import { useDraftStore } from '@/stores/draftStore' import api from '@/api' import { useI18n } from 'vue-i18n' import { useConversationStore } from '@/stores/conversation' @@ -142,6 +143,7 @@ const formSchema = toTypedSchema( const { t } = useI18n() const conversationStore = useConversationStore() +const draftStore = useDraftStore() const emitter = useEmitter() const userStore = useUserStore() @@ -163,11 +165,32 @@ const bcc = ref('') const showBcc = ref(false) const emailErrors = ref([]) const aiPrompts = ref([]) -const htmlContent = ref('') -const textContent = ref('') -onMounted(async () => { - await fetchAiPrompts() +// Draft store integration with computed properties +const htmlContent = computed({ + get: () => draftStore.getDraft(conversationStore.current?.uuid).htmlContent, + set: (value) => { + draftStore.setDraft( + conversationStore.current?.uuid, + value, + textContent.value + ) + } +}) + +const textContent = computed({ + get: () => draftStore.getDraft(conversationStore.current?.uuid).textContent, + set: (value) => { + draftStore.setDraft( + conversationStore.current?.uuid, + htmlContent.value, + value + ) + } +}) + +onMounted( () => { + draftStore.loadDrafts() }) /** @@ -299,6 +322,9 @@ const processSend = async () => { } finally { // If API has NOT errored clear state. if (hasMessageSendingErrored === false) { + // Clear draft from store + draftStore.clearDraft(conversationStore.current?.uuid) + // Clear macro. conversationStore.resetMacro('reply') @@ -311,7 +337,6 @@ const processSend = async () => { isSending.value = false } } - /** * Watches for changes in the conversation's macro id and update message content. */ @@ -352,4 +377,4 @@ watch( }, { deep: true, immediate: true } ) - + \ No newline at end of file diff --git a/frontend/src/stores/draftStore.js b/frontend/src/stores/draftStore.js new file mode 100644 index 00000000..05c227b7 --- /dev/null +++ b/frontend/src/stores/draftStore.js @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useDebounceFn } from '@vueuse/core' + +const STORAGE_KEY = 'libredesk-conversation-drafts' + +export const useDraftStore = defineStore('drafts', () => { + // State + const drafts = ref({}) + + // Load from localStorage + const loadDrafts = () => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) { + drafts.value = JSON.parse(saved) + } + } catch (error) { + console.error('Failed to load drafts:', error) + drafts.value = {} + } + } + + // Save to localStorage (immediate) + const saveDrafts = () => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts.value)) + } catch (error) { + console.error('Failed to save drafts:', error) + } + } + + // Debounced save (500ms delay) + const debouncedSave = useDebounceFn(saveDrafts, 500) + + // Get draft for a conversation + const getDraft = (uuid) => { + if (!uuid) return { htmlContent: '', textContent: '' } + return drafts.value[uuid] || { htmlContent: '', textContent: '' } + } + + // Set draft for a conversation + const setDraft = (uuid, htmlContent, textContent) => { + if (!uuid) return + + drafts.value[uuid] = { + htmlContent, + textContent, + timestamp: Date.now() + } + + debouncedSave() + } + + // Clear draft for a conversation + const clearDraft = (uuid) => { + if (!uuid) return + + delete drafts.value[uuid] + saveDrafts() // Immediate save for deletions + } + + // Clear all drafts + const clearAllDrafts = () => { + drafts.value = {} + saveDrafts() + } + + return { + drafts, + loadDrafts, + getDraft, + setDraft, + clearDraft, + clearAllDrafts + } +}) \ No newline at end of file From 563d3aba8572ab8c82309bd32682fd0df99c992d Mon Sep 17 00:00:00 2001 From: csr4422 Date: Wed, 19 Nov 2025 23:20:43 +0530 Subject: [PATCH 002/160] fix: block global Ctrl+B shortcut when typing in text editor --- frontend/src/components/editor/TextEditor.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index 0fde4260..b37e2825 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -199,6 +199,11 @@ const editor = useEditor({ editorProps: { attributes: { class: 'outline-none' }, handleKeyDown: (view, event) => { + + if (event.ctrlKey && event.key.toLowerCase() === 'b') { + event.stopPropagation(); + return false; + } if (event.ctrlKey && event.key === 'Enter') { emit('send') return true From 077c7ade780bb0f0863c7a258e0ee677dd30110a Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 21 Nov 2025 16:15:29 +0530 Subject: [PATCH 003/160] Use useStorage to sync draftStore state with localStorage --- .../src/features/conversation/ReplyBox.vue | 72 ++++++++++--------- frontend/src/stores/draftStore.js | 52 ++++---------- 2 files changed, 52 insertions(+), 72 deletions(-) diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 06fe5e85..2537d488 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -53,8 +53,8 @@ :isSending="isSending" :uploadingFiles="uploadingFiles" :uploadedFiles="mediaFiles" - v-model:htmlContent="htmlContent" - v-model:textContent="textContent" + v-model:htmlContent="localHtmlContent" + v-model:textContent="localTextContent" v-model:to="to" v-model:cc="cc" v-model:bcc="bcc" @@ -83,8 +83,8 @@ :isSending="isSending" :uploadingFiles="uploadingFiles" :uploadedFiles="mediaFiles" - v-model:htmlContent="htmlContent" - v-model:textContent="textContent" + v-model:htmlContent="localHtmlContent" + v-model:textContent="localTextContent" v-model:to="to" v-model:cc="cc" v-model:bcc="bcc" @@ -166,31 +166,31 @@ const showBcc = ref(false) const emailErrors = ref([]) const aiPrompts = ref([]) -// Draft store integration with computed properties -const htmlContent = computed({ - get: () => draftStore.getDraft(conversationStore.current?.uuid).htmlContent, - set: (value) => { - draftStore.setDraft( - conversationStore.current?.uuid, - value, - textContent.value - ) - } -}) +// Local state for draft content +const localHtmlContent = ref('') +const localTextContent = ref('') -const textContent = computed({ - get: () => draftStore.getDraft(conversationStore.current?.uuid).textContent, - set: (value) => { - draftStore.setDraft( - conversationStore.current?.uuid, - htmlContent.value, - value - ) - } -}) +// Watch for conversation changes - save old draft, load new draft +watch( + () => conversationStore.current?.uuid, + (newUuid, oldUuid) => { + if (oldUuid && (localHtmlContent.value || localTextContent.value)) { + draftStore.setDraft(oldUuid, localHtmlContent.value, localTextContent.value) + } + + const draft = draftStore.getDraft(newUuid) + localHtmlContent.value = draft.htmlContent + localTextContent.value = draft.textContent + }, + { immediate: true } +) -onMounted( () => { - draftStore.loadDrafts() +// Sync local content to store as user types +watch([localHtmlContent, localTextContent], () => { + const uuid = conversationStore.current?.uuid + if (uuid) { + draftStore.setDraft(uuid, localHtmlContent.value, localTextContent.value) + } }) /** @@ -218,9 +218,9 @@ const handleAiPromptSelected = async (key) => { try { const resp = await api.aiCompletion({ prompt_key: key, - content: textContent.value + content: localTextContent.value }) - htmlContent.value = resp.data.data.replace(/\n/g, '
') + localHtmlContent.value = resp.data.data.replace(/\n/g, '
') } catch (error) { // Check if user needs to enter OpenAI API key and has permission to do so. if (error.response?.status === 400 && userStore.can('ai:manage')) { @@ -261,7 +261,7 @@ const updateProvider = async (values) => { * Returns true if the editor has text content. */ const hasTextContent = computed(() => { - return textContent.value.trim().length > 0 + return localTextContent.value.trim().length > 0 }) /** @@ -274,7 +274,7 @@ const processSend = async () => { isSending.value = true // Send message if there is text content in the editor or media files are attached. if (hasTextContent.value > 0 || mediaFiles.value.length > 0) { - const message = htmlContent.value + const message = localHtmlContent.value await api.sendMessage(conversationStore.current.uuid, { sender_type: UserTypeAgent, private: messageType.value === 'private_note', @@ -322,8 +322,12 @@ const processSend = async () => { } finally { // If API has NOT errored clear state. if (hasMessageSendingErrored === false) { - // Clear draft from store - draftStore.clearDraft(conversationStore.current?.uuid) + const uuid = conversationStore.current?.uuid + if (uuid) { + draftStore.clearDraft(uuid) + } + localHtmlContent.value = '' + localTextContent.value = '' // Clear macro. conversationStore.resetMacro('reply') @@ -343,7 +347,7 @@ const processSend = async () => { watch( () => conversationStore.getMacro('reply').id, () => { - htmlContent.value = conversationStore.getMacro('reply').message_content + localHtmlContent.value = conversationStore.getMacro('reply').message_content }, { deep: true } ) diff --git a/frontend/src/stores/draftStore.js b/frontend/src/stores/draftStore.js index 05c227b7..4d263241 100644 --- a/frontend/src/stores/draftStore.js +++ b/frontend/src/stores/draftStore.js @@ -1,45 +1,29 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' -import { useDebounceFn } from '@vueuse/core' +import { useStorage } from '@vueuse/core' const STORAGE_KEY = 'libredesk-conversation-drafts' export const useDraftStore = defineStore('drafts', () => { - // State - const drafts = ref({}) - - // Load from localStorage - const loadDrafts = () => { - try { - const saved = localStorage.getItem(STORAGE_KEY) - if (saved) { - drafts.value = JSON.parse(saved) - } - } catch (error) { - console.error('Failed to load drafts:', error) - drafts.value = {} + // Reactive ref that auto-syncs with localStorage + const drafts = useStorage(STORAGE_KEY, {}, localStorage, { + serializer: { + read: (v) => { + try { + return v ? JSON.parse(v) : {} + } catch (error) { + console.error('Failed to parse drafts:', error) + return {} + } + }, + write: (v) => JSON.stringify(v) } - } + }) - // Save to localStorage (immediate) - const saveDrafts = () => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts.value)) - } catch (error) { - console.error('Failed to save drafts:', error) - } - } - - // Debounced save (500ms delay) - const debouncedSave = useDebounceFn(saveDrafts, 500) - - // Get draft for a conversation const getDraft = (uuid) => { if (!uuid) return { htmlContent: '', textContent: '' } return drafts.value[uuid] || { htmlContent: '', textContent: '' } } - // Set draft for a conversation const setDraft = (uuid, htmlContent, textContent) => { if (!uuid) return @@ -48,27 +32,19 @@ export const useDraftStore = defineStore('drafts', () => { textContent, timestamp: Date.now() } - - debouncedSave() } - // Clear draft for a conversation const clearDraft = (uuid) => { if (!uuid) return - delete drafts.value[uuid] - saveDrafts() // Immediate save for deletions } - // Clear all drafts const clearAllDrafts = () => { drafts.value = {} - saveDrafts() } return { drafts, - loadDrafts, getDraft, setDraft, clearDraft, From e9bd9e63444b11a6f957c438ab8d9adafda73ba0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 24 Nov 2025 23:06:04 +0530 Subject: [PATCH 004/160] make footer text smaller and lighten color --- frontend/src/layouts/auth/AuthLayout.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/layouts/auth/AuthLayout.vue b/frontend/src/layouts/auth/AuthLayout.vue index 05819c5f..e20d8877 100644 --- a/frontend/src/layouts/auth/AuthLayout.vue +++ b/frontend/src/layouts/auth/AuthLayout.vue @@ -8,7 +8,7 @@
From f516bbfa12c19f43c91cc77889a53458094612b9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 25 Nov 2025 16:06:36 +0530 Subject: [PATCH 005/160] fix: improve MIME type detection and content disposition handling for media uploads for s3 store. --- cmd/media.go | 17 +++--- cmd/messages.go | 6 +- go.mod | 4 ++ go.sum | 4 +- internal/conversation/conversation.go | 2 +- internal/conversation/message.go | 2 +- internal/image/image.go | 1 + internal/media/media.go | 84 +++++++++++++++++++++++---- internal/media/stores/localfs/fs.go | 2 +- internal/media/stores/s3/s3.go | 13 +++-- 10 files changed, 102 insertions(+), 33 deletions(-) diff --git a/cmd/media.go b/cmd/media.go index 9bffec76..ebc5e8ae 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -20,10 +20,6 @@ import ( "github.com/zerodha/fastglue" ) -const ( - thumbPrefix = "thumb_" -) - // handleMediaUpload handles media uploads. func handleMediaUpload(r *fastglue.Request) error { var ( @@ -88,7 +84,7 @@ func handleMediaUpload(r *fastglue.Request) error { // Delete files on any error. var uuid = uuid.New() - thumbName := thumbPrefix + uuid.String() + thumbName := image.ThumbPrefix + uuid.String() defer func() { if cleanUp { app.media.Delete(uuid.String()) @@ -105,7 +101,7 @@ func handleMediaUpload(r *fastglue.Request) error { app.lo.Error("error creating thumb image", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError) } - thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile) + thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile) if err != nil { return sendErrorEnvelope(r, err) } @@ -124,8 +120,11 @@ func handleMediaUpload(r *fastglue.Request) error { }) } + // Reset ptr. file.Seek(0, 0) - _, err = app.media.Upload(uuid.String(), srcContentType, file) + + // Override content type after upload (in case it was detected incorrectly). + _, srcContentType, err = app.media.Upload(uuid.String(), srcContentType, file) if err != nil { cleanUp = true app.lo.Error("error uploading file", "error", err) @@ -156,7 +155,7 @@ func handleServeMedia(r *fastglue.Request) error { } // Fetch media from DB. - media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) + media, err := app.media.Get(0, strings.TrimPrefix(uuid, image.ThumbPrefix)) if err != nil { return sendErrorEnvelope(r, err) } @@ -199,7 +198,7 @@ func handleServeMedia(r *fastglue.Request) error { fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) case "s3": - r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound) + r.RequestCtx.Redirect(app.media.GetURL(media.UUID, media.ContentType, media.Filename), http.StatusFound) } return nil } diff --git a/cmd/messages.go b/cmd/messages.go index f48f7fab..be9315f3 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -55,7 +55,8 @@ func handleGetMessages(r *fastglue.Request) error { total = messages[i].Total // Populate attachment URLs for j := range messages[i].Attachments { - messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID) + att := messages[i].Attachments[j] + messages[i].Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name) } // Redact CSAT survey link messages[i].CensorCSATContent() @@ -98,7 +99,8 @@ func handleGetMessage(r *fastglue.Request) error { message.CensorCSATContent() for j := range message.Attachments { - message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID) + att := message.Attachments[j] + message.Attachments[j].URL = app.media.GetURL(att.UUID, att.ContentType, att.Name) } return r.SendEnvelope(message) diff --git a/go.mod b/go.mod index 65af307a..23101eaf 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/emersion/go-message v0.18.1 github.com/fasthttp/websocket v1.5.9 github.com/ferluci/fast-realip v1.0.1 + github.com/gabriel-vasile/mimetype v1.4.11 github.com/google/uuid v1.6.0 github.com/jhillyerd/enmime v1.2.0 github.com/jmoiron/sqlx v1.4.0 @@ -78,3 +79,6 @@ require ( golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// TODO: Push simple s3 change to upstream +replace github.com/rhnvrm/simples3 => /home/abhinavr/projects/simples3 diff --git a/go.sum b/go.sum index a55a6089..884861a0 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/ferluci/fast-realip v1.0.1 h1:zPi0iv7zgOOlM/qJt9mozLz5IjVRAezaqHJoQ0J github.com/ferluci/fast-realip v1.0.1/go.mod h1:Ag7xdRQ9GOCL/pwbDe4zJv6SlfYdROArc8O+qIKhRc4= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -140,8 +142,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM= github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/rhnvrm/simples3 v0.9.2 h1:XrwsiMnwWf7t/kskvhMYXW6keqp5u3u6t5Va3ltzCQI= -github.com/rhnvrm/simples3 v0.9.2/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 823ee23b..78eac0bf 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -112,7 +112,7 @@ type mediaStore interface { Attach(id int, model string, modelID int) error GetByModel(id int, model string) ([]mmodels.Media, error) ContentIDExists(contentID string) (bool, string, error) - Upload(fileName, contentType string, content io.ReadSeeker) (string, error) + Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error) UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error) } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 8106bc64..855cd4cd 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -973,7 +973,7 @@ func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) e thumbName := fmt.Sprintf("thumb_%s", media.UUID) // Upload the thumbnail - if _, err := m.mediaStore.Upload(thumbName, media.ContentType, thumbFile); err != nil { + if _, _, err := m.mediaStore.Upload(thumbName, media.ContentType, thumbFile); err != nil { m.lo.Error("error uploading thumbnail", "error", err) return fmt.Errorf("error uploading thumbnail: %w", err) } diff --git a/internal/image/image.go b/internal/image/image.go index ddb9f878..96d0ca98 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -12,6 +12,7 @@ import ( var ( Exts = []string{"gif", "png", "jpg", "jpeg"} DefThumbSize = 150 + ThumbPrefix = "thumb_" ) // GetDimensions returns the width and height of the image in the provided file. diff --git a/internal/media/media.go b/internal/media/media.go index 3e42aeb4..210d152c 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -9,11 +9,14 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/media/models" + "github.com/gabriel-vasile/mimetype" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" @@ -30,7 +33,7 @@ var ( type Store interface { Put(name, contentType string, content io.ReadSeeker) (string, error) Delete(name string) error - GetURL(name string) string + GetURL(name, disposition, fileName string) string GetBlob(name string) ([]byte, error) Name() string } @@ -78,8 +81,13 @@ type queries struct { // UploadAndInsert uploads file on storage and inserts an entry in db. func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (models.Media, error) { - var uuid = uuid.New() - _, err := m.Upload(uuid.String(), contentType, content) + var ( + uuid = uuid.New() + err error + ) + + // Override content type after upload (in case it was detected incorrectly). + _, contentType, err = m.Upload(uuid.String(), contentType, content) if err != nil { return models.Media{}, err } @@ -92,14 +100,19 @@ func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID string, mo return media, nil } -// Upload saves the media file to the storage backend and returns the generated filename. -func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, error) { +// Upload saves the media file to the storage backend - returns the generated filename and content type (after detection). +func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error) { + contentType, err := m.detectContentType(fileName, contentType, content) + if err != nil { + return "", "", err + } + fName, err := m.store.Put(fileName, contentType, content) if err != nil { m.lo.Error("error uploading media", "error", err) - return "", envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil) + return "", "", envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil) } - return fName, nil + return fName, contentType, nil } // Insert inserts media details into the database and returns the inserted media record. @@ -122,7 +135,7 @@ func (m *Manager) Get(id int, uuid string) (models.Media, error) { m.lo.Error("error fetching media", "error", err) return media, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil) } - media.URL = m.store.GetURL(media.UUID) + media.URL = m.store.GetURL(media.UUID, media.ContentType, media.Filename) return media, nil } @@ -145,8 +158,15 @@ func (m *Manager) GetBlob(name string) ([]byte, error) { } // GetURL returns the URL for accessing a media file by its name. -func (m *Manager) GetURL(name string) string { - return m.store.GetURL(name) +func (m *Manager) GetURL(uuid, contentType, fileName string) string { + // Keep some content types inline. + disposition := "attachment" + if strings.HasPrefix(contentType, "image/") || + strings.HasPrefix(contentType, "video/") || + contentType == "application/pdf" { + disposition = "inline" + } + return m.store.GetURL(uuid, disposition, fileName) } // Attach associates a media file with a specific model by its ID and model name. @@ -177,6 +197,12 @@ func (m *Manager) Delete(name string) error { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.media}"), nil) } } + + // Return thumbs don't exist in DB, just on store. + if strings.HasPrefix(name, image.ThumbPrefix) { + return nil + } + // Delete the media record from the database. if _, err := m.queries.Delete.Exec(name); err != nil { m.lo.Error("error deleting media from db", "error", err) @@ -214,7 +240,43 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { m.lo.Error("error deleting unlinked media", "error", err) continue } - // TODO: If it's an image also delete the `thumb_uuid` image. + + // If it's an image, also delete the `thumb_uuid` image from store. + if strings.HasPrefix(mm.ContentType, "image/") { + thumbUUID := "thumb_" + mm.UUID + m.lo.Debug("deleting thumbnail for unlinked media", "thumb_uuid", thumbUUID) + if err := m.Delete(thumbUUID); err != nil { + m.lo.Error("error deleting thumbnail for unlinked media", "error", err) + } + } } return nil } + +// detectContentType detects the content type of a file from its filename or content. +// If contentType is unreliable, it attempts to detect the proper type using mimetype library. +// Returns the detected content type or an error if seeking fails after content detection. +func (m *Manager) detectContentType(fileName, contentType string, content io.ReadSeeker) (string, error) { + m.lo.Debug("detecting content type for file", "filename", fileName, "content_type", contentType) + + if contentType != "" && contentType != "application/octet-stream" { + return contentType, nil + } + + mtype, err := mimetype.DetectReader(content) + if err != nil { + m.lo.Error("error detecting content type", "filename", fileName, "error", err) + return "application/octet-stream", nil + } + + detectedType := mtype.String() + m.lo.Debug("detected content type for file", "filename", fileName, "detected_content_type", detectedType, "previous_content_type", contentType) + + // Reset ptr. + if _, err := content.Seek(0, io.SeekStart); err != nil { + m.lo.Error("error seeking to start after content type detection", "error", err) + return "", envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil) + } + + return detectedType, nil +} diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go index 7b082b6c..42e3f2c2 100644 --- a/internal/media/stores/localfs/fs.go +++ b/internal/media/stores/localfs/fs.go @@ -48,7 +48,7 @@ func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, } // GetURL accepts a filename and retrieves the full URL for file. -func (c *Client) GetURL(name string) string { +func (c *Client) GetURL(name, _, _ string) string { return fmt.Sprintf("%s%s/%s", c.opts.RootURL(), c.opts.UploadURI, name) } diff --git a/internal/media/stores/s3/s3.go b/internal/media/stores/s3/s3.go index 9c19b069..3ffa8d8b 100644 --- a/internal/media/stores/s3/s3.go +++ b/internal/media/stores/s3/s3.go @@ -88,14 +88,15 @@ func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, err // GetURL generates a URL to access the file stored in S3. // It returns a pre-signed URL for private buckets or a public URL for public buckets. -func (c *Client) GetURL(name string) string { +func (c *Client) GetURL(name string, disposition, fileName string) string { if c.opts.BucketType == "private" && c.opts.PublicURL == "" { u := c.s3.GeneratePresignedURL(simples3.PresignedInput{ - Bucket: c.opts.Bucket, - ObjectKey: c.makeBucketPath(name), - Method: "GET", - Timestamp: time.Now(), - ExpirySeconds: int(c.opts.Expiry.Seconds()), + Bucket: c.opts.Bucket, + ObjectKey: c.makeBucketPath(name), + Method: "GET", + Timestamp: time.Now(), + ExpirySeconds: int(c.opts.Expiry.Seconds()), + ResponseContentDisposition: fmt.Sprintf("%s; filename=\"%s\"", disposition, fileName), }) return u } From de3c0cf1915d6c857142872db4684fa2a53852cf Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 25 Nov 2025 17:17:36 +0530 Subject: [PATCH 006/160] Update internal/media/media.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/media/media.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/media/media.go b/internal/media/media.go index 210d152c..29312d95 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -243,7 +243,7 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { // If it's an image, also delete the `thumb_uuid` image from store. if strings.HasPrefix(mm.ContentType, "image/") { - thumbUUID := "thumb_" + mm.UUID + thumbUUID := image.ThumbPrefix + mm.UUID m.lo.Debug("deleting thumbnail for unlinked media", "thumb_uuid", thumbUUID) if err := m.Delete(thumbUUID); err != nil { m.lo.Error("error deleting thumbnail for unlinked media", "error", err) From 80b249eaeb3efb149dcb5aada7ce9553a48eee65 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 25 Nov 2025 17:17:47 +0530 Subject: [PATCH 007/160] Update internal/media/media.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/media/media.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/media/media.go b/internal/media/media.go index 29312d95..3615fc52 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -198,7 +198,7 @@ func (m *Manager) Delete(name string) error { } } - // Return thumbs don't exist in DB, just on store. + // Thumbnail files do not exist in the database, only in the storage backend, so return early. if strings.HasPrefix(name, image.ThumbPrefix) { return nil } From a7ec3368538ac0b49a7fe891c3d780b1819cc8d9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 25 Nov 2025 17:18:05 +0530 Subject: [PATCH 008/160] Update cmd/media.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/media.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/media.go b/cmd/media.go index ebc5e8ae..cfb031b8 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -198,7 +198,7 @@ func handleServeMedia(r *fastglue.Request) error { fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) case "s3": - r.RequestCtx.Redirect(app.media.GetURL(media.UUID, media.ContentType, media.Filename), http.StatusFound) + r.RequestCtx.Redirect(app.media.GetURL(uuid, media.ContentType, media.Filename), http.StatusFound) } return nil } From 3c1935f53b257eaa459ce3c106c5bec1f2811ba3 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 25 Nov 2025 17:24:25 +0530 Subject: [PATCH 009/160] Update internal/media/media.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/media/media.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/media/media.go b/internal/media/media.go index 3615fc52..df24dfc5 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -135,7 +135,7 @@ func (m *Manager) Get(id int, uuid string) (models.Media, error) { m.lo.Error("error fetching media", "error", err) return media, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil) } - media.URL = m.store.GetURL(media.UUID, media.ContentType, media.Filename) + media.URL = m.GetURL(media.UUID, media.ContentType, media.Filename) return media, nil } From ee2e929da5b8a555307e60381c52fc472eaa4d2d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 12:34:49 +0530 Subject: [PATCH 010/160] bump simple s3 --- go.mod | 7 ++----- go.sum | 2 ++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 23101eaf..125fe1a8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/abhinavxd/libredesk -go 1.24.3 +go 1.25.0 require ( github.com/casbin/casbin/v2 v2.99.0 @@ -29,7 +29,7 @@ require ( github.com/lib/pq v1.10.9 github.com/mr-karan/balance v0.0.0-20250317053523-d32c6ade6cf1 github.com/redis/go-redis/v9 v9.5.5 - github.com/rhnvrm/simples3 v0.9.2 + github.com/rhnvrm/simples3 v0.10.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.10.0 github.com/valyala/fasthttp v1.62.0 @@ -79,6 +79,3 @@ require ( golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -// TODO: Push simple s3 change to upstream -replace github.com/rhnvrm/simples3 => /home/abhinavr/projects/simples3 diff --git a/go.sum b/go.sum index 884861a0..21f5f1e7 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM= github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rhnvrm/simples3 v0.10.1 h1:V/EbG6tC3F5MS7Vw5D02qIbNJom2jU+FVtGdrmsD67o= +github.com/rhnvrm/simples3 v0.10.1/go.mod h1:c2xW30bukipkBlWNnXG1wDjq3gykQ6ww2AB/9NHMLMY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= From f3ce64033af2a6c3b8eed298df773aaf86d69ed0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 14:38:50 +0530 Subject: [PATCH 011/160] refactor use http lib to detect mimetype first and then fallback to github.com/gabriel-vasile/mimetype --- internal/media/media.go | 60 ++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/internal/media/media.go b/internal/media/media.go index df24dfc5..8a7d7018 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "strings" "time" @@ -102,7 +103,8 @@ func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID string, mo // Upload saves the media file to the storage backend - returns the generated filename and content type (after detection). func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error) { - contentType, err := m.detectContentType(fileName, contentType, content) + // Detect content type and override if needed. + contentType, err := m.detectContentType(contentType, content) if err != nil { return "", "", err } @@ -253,30 +255,52 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { return nil } -// detectContentType detects the content type of a file from its filename or content. -// If contentType is unreliable, it attempts to detect the proper type using mimetype library. -// Returns the detected content type or an error if seeking fails after content detection. -func (m *Manager) detectContentType(fileName, contentType string, content io.ReadSeeker) (string, error) { - m.lo.Debug("detecting content type for file", "filename", fileName, "content_type", contentType) - - if contentType != "" && contentType != "application/octet-stream" { - return contentType, nil +// detectContentType detects the content type of a file. +// It trusts the source content type unless it's a generic type like application/octet-stream. +// For generic types, it uses http.DetectContentType (stdlib) as a fast path, +// falling back to mimetype library for deeper inspection using magic numbers. +func (m *Manager) detectContentType(sourceContentType string, content io.ReadSeeker) (string, error) { + // Set default if empty + if sourceContentType == "" { + sourceContentType = "application/octet-stream" } + // Trust source unless it's a generic/useless type + if sourceContentType != "application/octet-stream" && + sourceContentType != "application/data" && + sourceContentType != "application/binary" { + m.lo.Debug("detected media content type from trusted source", "detected_type", sourceContentType) + return sourceContentType, nil + } + + // Ensure we're at the start + content.Seek(0, io.SeekStart) + + // Fast path: stdlib + buf := make([]byte, 512) + n, _ := content.Read(buf) + detected := http.DetectContentType(buf[:n]) + + // If stdlib gives a useful type, use it. + // stdlib defaults to application/octet-stream for unknown types. + if detected != "application/octet-stream" { + content.Seek(0, io.SeekStart) + m.lo.Debug("detected media content type using stdlib", "detected_type", detected, "source_type", sourceContentType) + return detected, nil + } + + // Slow path: mimetype library + content.Seek(0, io.SeekStart) mtype, err := mimetype.DetectReader(content) if err != nil { - m.lo.Error("error detecting content type", "filename", fileName, "error", err) - return "application/octet-stream", nil + m.lo.Error("error detecting content type", "error", err) + content.Seek(0, io.SeekStart) + return sourceContentType, nil } detectedType := mtype.String() - m.lo.Debug("detected content type for file", "filename", fileName, "detected_content_type", detectedType, "previous_content_type", contentType) - - // Reset ptr. - if _, err := content.Seek(0, io.SeekStart); err != nil { - m.lo.Error("error seeking to start after content type detection", "error", err) - return "", envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil) - } + m.lo.Debug("detected media content type using mimetype lib", "detected_type", detectedType, "source_type", sourceContentType) + content.Seek(0, io.SeekStart) return detectedType, nil } From 913d2fb24feb6d19aed9a8fd3740eb86876174f5 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 14:39:24 +0530 Subject: [PATCH 012/160] fix errors while inserting acitvity logs --- internal/activity_log/activity_log.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/activity_log/activity_log.go b/internal/activity_log/activity_log.go index f249aa92..749a9271 100644 --- a/internal/activity_log/activity_log.go +++ b/internal/activity_log/activity_log.go @@ -183,8 +183,7 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE // create creates a new activity log in DB. func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error { - var activityLog models.ActivityLog - if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil { + if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil { m.lo.Error("error inserting activity log", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil) } From fd349ab0c74926ca75ed248a38464fa7611d9580 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 15:10:37 +0530 Subject: [PATCH 013/160] add debug logs while detecting content type --- internal/media/media.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/media/media.go b/internal/media/media.go index 8a7d7018..83c5b152 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -103,6 +103,9 @@ func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID string, mo // Upload saves the media file to the storage backend - returns the generated filename and content type (after detection). func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error) { + // On store file is named by UUID to avoid collisions and the actual filename is stored in DB. + m.lo.Debug("detecting content type for file before upload", "uuid", fileName, "source_content_type", contentType) + // Detect content type and override if needed. contentType, err := m.detectContentType(contentType, content) if err != nil { From a279197331277690b1e37615defa336d357e8405 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 15:15:34 +0530 Subject: [PATCH 014/160] add error logs --- internal/media/media.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/media/media.go b/internal/media/media.go index 83c5b152..fe0b2964 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -109,6 +109,7 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s // Detect content type and override if needed. contentType, err := m.detectContentType(contentType, content) if err != nil { + m.lo.Error("error detecting content type", "error", err) return "", "", err } From 2331a2c9702f082bf8d34c8d76fc810e6a7efa20 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 26 Nov 2025 15:25:59 +0530 Subject: [PATCH 015/160] add validation for empty file uploads with appropriate error message --- cmd/media.go | 6 ++++++ i18n/en.json | 1 + 2 files changed, 7 insertions(+) diff --git a/cmd/media.go b/cmd/media.go index cfb031b8..426764dd 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -66,6 +66,12 @@ func handleMediaUpload(r *fastglue.Request) error { srcFileSize := fileHeader.Size srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") + // Check if file is empty + if srcFileSize == 0 { + app.lo.Error("error: uploaded file is empty (0 bytes)") + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileEmpty"), nil, envelope.InputError) + } + // Check file size consts := app.consts.Load().(*constants) if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) { diff --git a/i18n/en.json b/i18n/en.json index ca49201e..c631af1e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -332,6 +332,7 @@ "user.errorGeneratingPasswordToken": "Error generating password token", "media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ", "media.fileTypeNotAllowed": "File type not allowed", + "media.fileEmpty": "This file is 0 bytes, so it will not be attached.", "inbox.emptyIMAP": "Empty IMAP config", "inbox.emptySMTP": "Empty SMTP config", "template.defaultTemplateAlreadyExists": "Default template already exists", From f452e0279eeaf846963a4093202cb1996acad50b Mon Sep 17 00:00:00 2001 From: csr4422 Date: Thu, 27 Nov 2025 15:58:54 +0530 Subject: [PATCH 016/160] feat: add draft persistence with eviction policy --- frontend/src/stores/draftStore.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/stores/draftStore.js b/frontend/src/stores/draftStore.js index 4d263241..b22fba37 100644 --- a/frontend/src/stores/draftStore.js +++ b/frontend/src/stores/draftStore.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { useStorage } from '@vueuse/core' const STORAGE_KEY = 'libredesk-conversation-drafts' +const MAX_ENTRIES = 10 export const useDraftStore = defineStore('drafts', () => { // Reactive ref that auto-syncs with localStorage @@ -32,6 +33,17 @@ export const useDraftStore = defineStore('drafts', () => { textContent, timestamp: Date.now() } + + const keys = Object.keys(drafts.value) + if (keys.length > MAX_ENTRIES) { + const sorted = keys + .map(k => [k, drafts.value[k].timestamp]) + .sort((a, b) => a[1] - b[1]) + const removeCount = keys.length - MAX_ENTRIES + for (let i = 0; i < removeCount; i++) { + delete drafts.value[sorted[i][0]] + } + } } const clearDraft = (uuid) => { From bd092c5b1efc7b38ceeedb37b4d43f47f7708592 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 28 Nov 2025 13:19:06 +0530 Subject: [PATCH 017/160] fix: prevent saving empty drafts to localStorage --- frontend/src/stores/draftStore.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/stores/draftStore.js b/frontend/src/stores/draftStore.js index b22fba37..30ece9f9 100644 --- a/frontend/src/stores/draftStore.js +++ b/frontend/src/stores/draftStore.js @@ -19,7 +19,6 @@ export const useDraftStore = defineStore('drafts', () => { write: (v) => JSON.stringify(v) } }) - const getDraft = (uuid) => { if (!uuid) return { htmlContent: '', textContent: '' } return drafts.value[uuid] || { htmlContent: '', textContent: '' } @@ -28,12 +27,15 @@ export const useDraftStore = defineStore('drafts', () => { const setDraft = (uuid, htmlContent, textContent) => { if (!uuid) return + const isEmpty = (!htmlContent || htmlContent.trim() === '') && + (!textContent || textContent.trim() === '') + + if (isEmpty) return drafts.value[uuid] = { - htmlContent, - textContent, - timestamp: Date.now() - } - + htmlContent, + textContent, + timestamp: Date.now() + } const keys = Object.keys(drafts.value) if (keys.length > MAX_ENTRIES) { const sorted = keys From d2f02872d356743b1e6d6cc7deaf0f8e68e5d2c0 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 28 Nov 2025 13:21:35 +0530 Subject: [PATCH 018/160] refactor: optimize editor content sync to prevent unnecessary updates --- frontend/src/components/editor/TextEditor.vue | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index f9ea4256..9090fb59 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -243,17 +243,18 @@ const editor = useEditor({ } }) -watch( - htmlContent, - (newContent) => { - if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) { +// 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) { 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( @@ -335,4 +336,4 @@ const unsetLink = () => { } } } - + \ No newline at end of file From d8676115d31d0405ca91085dc7960262336c647f Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 28 Nov 2025 13:24:34 +0530 Subject: [PATCH 019/160] fix: improve draft persistence and prevent content loss on conversation switching --- frontend/src/composables/useDraftManager.js | 107 ++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 frontend/src/composables/useDraftManager.js diff --git a/frontend/src/composables/useDraftManager.js b/frontend/src/composables/useDraftManager.js new file mode 100644 index 00000000..2d2dcc91 --- /dev/null +++ b/frontend/src/composables/useDraftManager.js @@ -0,0 +1,107 @@ +import { ref, watch } from 'vue' +import { watchDebounced } from '@vueuse/core' +import { useDraftStore } from '@/stores/draftStore' + +/** + * Composable for managing draft state and persistence + * @param {Ref} conversationKey - Reactive reference to current conversation UUID + */ +export function useDraftManager(conversationKey) { + const draftStore = useDraftStore() + + const htmlContent = ref('') + const textContent = ref('') + const isLoadingDraft = ref(false) + + /** + * Load draft from store for a given key + */ + const loadDraft = (key) => { + if (!key) return + + isLoadingDraft.value = true + const draft = draftStore.getDraft(key) + htmlContent.value = draft.htmlContent || '' + textContent.value = draft.textContent || '' + + // Small delay to prevent race conditions with watchers + setTimeout(() => { + isLoadingDraft.value = false + }, 50) + } + + /** + * Save draft to store + */ + const saveDraft = (key) => { + if (!key || isLoadingDraft.value) return + draftStore.setDraft(key, htmlContent.value, textContent.value) + } + + /** + * Clear draft and local state + */ + const clearDraft = (key) => { + if (!key) return + + isLoadingDraft.value = true + draftStore.clearDraft(key) + htmlContent.value = '' + textContent.value = '' + + setTimeout(() => { + isLoadingDraft.value = false + }, 600) // Change this line from 50 to 600 +} + + /** + * Check if draft has content + */ + const hasDraftContent = () => { + return (htmlContent.value?.trim() || '') !== '' || (textContent.value?.trim() || '') !== '' + } + +// Watch for conversation key changes +watch( + conversationKey, + (newKey, oldKey) => { + // Save old draft BEFORE switching (whether going to new or existing conversation) + if (oldKey && hasDraftContent()) { + draftStore.setDraft(oldKey, htmlContent.value, textContent.value) + } + + // Only load draft if switching to an EXISTING conversation with a different key + if (newKey && newKey !== oldKey) { + loadDraft(newKey) + } else if (!newKey && oldKey) { + // Clear draft if switching to a NEW conversation + isLoadingDraft.value = true + setTimeout(() => { + isLoadingDraft.value = false + }, 50) + } + }, + { immediate: true } +) + + // Auto-save draft when content changes (debounced to avoid excessive writes) + watchDebounced( + [htmlContent, textContent], + () => { + if (!isLoadingDraft.value && conversationKey.value) { + saveDraft(conversationKey.value) + } + }, + { debounce: 500 } + ) + + return { + htmlContent, + textContent, + isLoadingDraft, + loadDraft, + saveDraft, + clearDraft, + hasDraftContent + } +} \ No newline at end of file From f98e59fd720a35793297043148825967f3926f46 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 28 Nov 2025 13:25:46 +0530 Subject: [PATCH 020/160] fix: prevent macro watcher from clearing draft content unintentionally --- .../src/features/conversation/ReplyBox.vue | 54 ++++++------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 2537d488..bf1efa22 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -106,7 +106,7 @@ import { ref, onMounted, watch, computed } from 'vue' import { handleHTTPError } from '@/utils/http' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { useUserStore } from '@/stores/user' -import { useDraftStore } from '@/stores/draftStore' +import { useDraftManager } from '@/composables/useDraftManager' import api from '@/api' import { useI18n } from 'vue-i18n' import { useConversationStore } from '@/stores/conversation' @@ -143,7 +143,6 @@ const formSchema = toTypedSchema( const { t } = useI18n() const conversationStore = useConversationStore() -const draftStore = useDraftStore() const emitter = useEmitter() const userStore = useUserStore() @@ -153,6 +152,15 @@ const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMed linkedModel: 'messages' }) +// Setup draft management composable +const currentDraftKey = computed(() => conversationStore.current?.uuid || null) +const { + htmlContent: localHtmlContent, + textContent: localTextContent, + isLoadingDraft, + clearDraft +} = useDraftManager(currentDraftKey) + // Rest of existing state const openAIKeyPrompt = ref(false) const isOpenAIKeyUpdating = ref(false) @@ -166,33 +174,6 @@ const showBcc = ref(false) const emailErrors = ref([]) const aiPrompts = ref([]) -// Local state for draft content -const localHtmlContent = ref('') -const localTextContent = ref('') - -// Watch for conversation changes - save old draft, load new draft -watch( - () => conversationStore.current?.uuid, - (newUuid, oldUuid) => { - if (oldUuid && (localHtmlContent.value || localTextContent.value)) { - draftStore.setDraft(oldUuid, localHtmlContent.value, localTextContent.value) - } - - const draft = draftStore.getDraft(newUuid) - localHtmlContent.value = draft.htmlContent - localTextContent.value = draft.textContent - }, - { immediate: true } -) - -// Sync local content to store as user types -watch([localHtmlContent, localTextContent], () => { - const uuid = conversationStore.current?.uuid - if (uuid) { - draftStore.setDraft(uuid, localHtmlContent.value, localTextContent.value) - } -}) - /** * Fetches AI prompts from the server. */ @@ -322,12 +303,8 @@ const processSend = async () => { } finally { // If API has NOT errored clear state. if (hasMessageSendingErrored === false) { - const uuid = conversationStore.current?.uuid - if (uuid) { - draftStore.clearDraft(uuid) - } - localHtmlContent.value = '' - localTextContent.value = '' + // Clear draft using composable + clearDraft(currentDraftKey.value) // Clear macro. conversationStore.resetMacro('reply') @@ -346,8 +323,11 @@ const processSend = async () => { */ watch( () => conversationStore.getMacro('reply').id, - () => { - localHtmlContent.value = conversationStore.getMacro('reply').message_content + (newId, oldId) => { + // Only update if macro ID actually changed and is not undefined/0 + if (newId && newId !== oldId && conversationStore.getMacro('reply').message_content) { + localHtmlContent.value = conversationStore.getMacro('reply').message_content + } }, { deep: true } ) From 59bb8d2c689f63294b630aa473e0cc5d94271cdb Mon Sep 17 00:00:00 2001 From: csr4422 Date: Sun, 30 Nov 2025 16:16:42 +0530 Subject: [PATCH 021/160] fix: implement reactive macro updates using loadMacros --- frontend/src/features/admin/macros/dataTableDropdown.vue | 7 ++++++- frontend/src/stores/macro.js | 4 ++-- frontend/src/views/admin/macros/CreateMacro.vue | 7 ++++++- frontend/src/views/admin/macros/EditMacro.vue | 8 +++++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/admin/macros/dataTableDropdown.vue b/frontend/src/features/admin/macros/dataTableDropdown.vue index 349f5157..e6070ed4 100644 --- a/frontend/src/features/admin/macros/dataTableDropdown.vue +++ b/frontend/src/features/admin/macros/dataTableDropdown.vue @@ -55,10 +55,12 @@ import { Button } from '@/components/ui/button' import { useEmitter } from '@/composables/useEmitter' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { useRouter } from 'vue-router' +import { useMacroStore } from '@/stores/macro' import api from '@/api/index.js' const router = useRouter() const emit = useEmitter() +const macroStore = useMacroStore() const isDeleteOpen = ref(false) const props = defineProps({ @@ -70,6 +72,9 @@ const props = defineProps({ const handleDelete = async () => { await api.deleteMacro(props.macro.id) + + await macroStore.loadMacros(true) + isDeleteOpen.value = false emit.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'macros' }) } @@ -77,4 +82,4 @@ const handleDelete = async () => { const editMacro = () => { router.push({ path: `/admin/conversations/macros/${props.macro.id}/edit` }) } - + \ No newline at end of file diff --git a/frontend/src/stores/macro.js b/frontend/src/stores/macro.js index a8962326..3ece30b3 100644 --- a/frontend/src/stores/macro.js +++ b/frontend/src/stores/macro.js @@ -61,8 +61,8 @@ export const useMacroStore = defineStore('macroStore', () => { })) }) - const loadMacros = async () => { - if (macroList.value.length) return + const loadMacros = async (force = false) => { + if (!force && macroList.value.length) return try { const response = await api.getAllMacros() macroList.value = response?.data?.data || [] diff --git a/frontend/src/views/admin/macros/CreateMacro.vue b/frontend/src/views/admin/macros/CreateMacro.vue index 9049c1b6..1f38d918 100644 --- a/frontend/src/views/admin/macros/CreateMacro.vue +++ b/frontend/src/views/admin/macros/CreateMacro.vue @@ -14,11 +14,13 @@ import { useRouter } from 'vue-router' import { useEmitter } from '@/composables/useEmitter' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { useI18n } from 'vue-i18n' +import { useMacroStore } from '@/stores/macro' import api from '@/api' const router = useRouter() const emit = useEmitter() const { t } = useI18n() +const macroStore = useMacroStore() const formLoading = ref(false) const breadcrumbLinks = [ { path: 'macro-list', label: t('globals.terms.macro', 2) }, @@ -38,6 +40,9 @@ const createMacro = async (values) => { try { formLoading.value = true await api.createMacro(values) + + await macroStore.loadMacros(true) + emit.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.createdSuccessfully', { name: t('globals.terms.macro') @@ -53,4 +58,4 @@ const createMacro = async (values) => { formLoading.value = false } } - + \ No newline at end of file diff --git a/frontend/src/views/admin/macros/EditMacro.vue b/frontend/src/views/admin/macros/EditMacro.vue index c909ec8c..08d15ff3 100644 --- a/frontend/src/views/admin/macros/EditMacro.vue +++ b/frontend/src/views/admin/macros/EditMacro.vue @@ -16,12 +16,14 @@ import MacroForm from '@/features/admin/macros/MacroForm.vue' import { CustomBreadcrumb } from '@/components/ui/breadcrumb' import { useI18n } from 'vue-i18n' import { Spinner } from '@/components/ui/spinner' +import { useMacroStore } from '@/stores/macro' const macro = ref({}) const { t } = useI18n() const isLoading = ref(false) const formLoading = ref(false) const emitter = useEmitter() +const macroStore = useMacroStore() const breadcrumbLinks = [ { path: 'macro-list', label: t('globals.terms.macro', 2) }, @@ -36,6 +38,10 @@ const updateMacro = async (payload) => { try { formLoading.value = true await api.updateMacro(macro.value.id, payload) + + // Reload macros from server + await macroStore.loadMacros(true) + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.updatedSuccessfully', { name: t('globals.terms.macro') @@ -72,4 +78,4 @@ const props = defineProps({ required: true } }) - + \ No newline at end of file From e22dbdfc7950d902292d34485efb492ef0e3c833 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Sun, 30 Nov 2025 19:18:35 +0530 Subject: [PATCH 022/160] fix: call fetchAiPrompts on component mount and fix formatting --- frontend/src/composables/useDraftManager.js | 26 +++++++++++-------- .../src/features/conversation/ReplyBox.vue | 5 +++- frontend/src/stores/draftStore.js | 10 +++---- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/frontend/src/composables/useDraftManager.js b/frontend/src/composables/useDraftManager.js index 2d2dcc91..c38e8f9b 100644 --- a/frontend/src/composables/useDraftManager.js +++ b/frontend/src/composables/useDraftManager.js @@ -20,14 +20,14 @@ export function useDraftManager(conversationKey) { if (!key) return isLoadingDraft.value = true - const draft = draftStore.getDraft(key) + const draft = draftStore.getDraft(key) htmlContent.value = draft.htmlContent || '' textContent.value = draft.textContent || '' // Small delay to prevent race conditions with watchers setTimeout(() => { isLoadingDraft.value = false - }, 50) + }, 600) } /** @@ -41,17 +41,17 @@ export function useDraftManager(conversationKey) { /** * Clear draft and local state */ - const clearDraft = (key) => { - if (!key) return + const clearDraft = (key) => { + if (!key) return - isLoadingDraft.value = true - draftStore.clearDraft(key) - htmlContent.value = '' - textContent.value = '' + isLoadingDraft.value = true + draftStore.clearDraft(key) + htmlContent.value = '' + textContent.value = '' setTimeout(() => { isLoadingDraft.value = false - }, 600) // Change this line from 50 to 600 + }, 600) } /** @@ -76,9 +76,13 @@ watch( } else if (!newKey && oldKey) { // Clear draft if switching to a NEW conversation isLoadingDraft.value = true + + htmlContent.value = '' + textContent.value = '' + setTimeout(() => { isLoadingDraft.value = false - }, 50) + }, 600) } }, { immediate: true } @@ -104,4 +108,4 @@ watch( clearDraft, hasDraftContent } -} \ No newline at end of file +} diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index bf1efa22..13348f7c 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -102,7 +102,7 @@ - - diff --git a/frontend/src/features/conversation/message/ContactMessageBubble.vue b/frontend/src/features/conversation/message/ContactMessageBubble.vue deleted file mode 100644 index cc432317..00000000 --- a/frontend/src/features/conversation/message/ContactMessageBubble.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - diff --git a/frontend/src/features/conversation/message/MessageBubble.vue b/frontend/src/features/conversation/message/MessageBubble.vue new file mode 100644 index 00000000..232cfaa4 --- /dev/null +++ b/frontend/src/features/conversation/message/MessageBubble.vue @@ -0,0 +1,220 @@ + + + \ No newline at end of file diff --git a/frontend/src/features/conversation/message/MessageList.vue b/frontend/src/features/conversation/message/MessageList.vue index 5d57ab83..24c036a0 100644 --- a/frontend/src/features/conversation/message/MessageList.vue +++ b/frontend/src/features/conversation/message/MessageList.vue @@ -31,12 +31,11 @@ 'pt-4': index === 0 }" > -
- - +
+
- +
@@ -75,9 +74,8 @@ \ 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 @@ diff --git a/frontend/src/stores/draftStore.js b/frontend/src/stores/draftStore.js deleted file mode 100644 index 9a995962..00000000 --- a/frontend/src/stores/draftStore.js +++ /dev/null @@ -1,56 +0,0 @@ -import { defineStore } from 'pinia' -import api from '@/api' - -export const useDraftStore = defineStore('drafts', () => { - /** - * Get draft from backend - */ - const getDraft = async (uuid) => { - if (!uuid) return { htmlContent: '', textContent: '' } - - try { - const response = await api.getDraft(uuid) - const draft = response.data.data - return { - htmlContent: draft.content || '', - // We only store HTML in backend. - textContent: '', - meta: draft.meta || {} - } - } catch (error) { - return { htmlContent: '', textContent: '' } - } - } - - /** - * Save draft to backend - */ - const setDraft = async (uuid, htmlContent, textContent, meta = {}) => { - if (!uuid) return - - try { - await api.saveDraft(uuid, { content: htmlContent, meta }) - } catch (error) { - // pass - } - } - - /** - * Delete draft from backend - */ - const clearDraft = async (uuid) => { - if (!uuid) return - - try { - await api.deleteDraft(uuid) - } catch (error) { - // pass - } - } - - return { - getDraft, - setDraft, - clearDraft, - } -}) \ No newline at end of file From 67ade61250eba92ede16cac8b890e01620c8da5b Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 23 Dec 2025 01:05:57 +0530 Subject: [PATCH 068/160] store content in local storage first and sync with backend on certain events --- frontend/src/composables/useDraftManager.js | 115 ++++++++++++++------ 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/frontend/src/composables/useDraftManager.js b/frontend/src/composables/useDraftManager.js index b8e9da8e..7a352a8d 100644 --- a/frontend/src/composables/useDraftManager.js +++ b/frontend/src/composables/useDraftManager.js @@ -1,5 +1,5 @@ import { ref, watch } from 'vue' -import { watchDebounced } from '@vueuse/core' +import { watchDebounced, useStorage, useEventListener } from '@vueuse/core' import { useConversationStore } from '@/stores/conversation' import { MACRO_CONTEXT } from '@/constants/conversation' import api from '@/api' @@ -35,6 +35,7 @@ const validateAttachments = (attachments) => { /** * Composable for managing draft state and persistence + * Saves to localStorage immediately, syncs to backend on conversation switch/send/unload * @param key - Reactive reference to current draft key * @param uploadedFiles - Optional reactive reference to uploaded files array */ @@ -44,9 +45,57 @@ export function useDraftManager (key, uploadedFiles = null) { const textContent = ref('') const isLoading = ref(false) const isDirty = ref(false) + const skipNextSave = ref(false) const loadedAttachments = ref([]) const loadedMacroActions = ref([]) + // Reactive localStorage for all drafts + const localDrafts = useStorage('libredesk_drafts', {}) + + /** + * Save draft to localStorage only + */ + const saveDraftLocal = (draftKey) => { + if (!draftKey) return + const macroActions = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions || [] + const draftMeta = {} + if (macroActions.length > 0) draftMeta.macro_actions = macroActions + if (uploadedFiles?.value?.length > 0) draftMeta.attachments = uploadedFiles.value + + localDrafts.value[draftKey] = { content: htmlContent.value, meta: draftMeta } + isDirty.value = true + } + + /** + * Get draft from localStorage + */ + const getLocalDraft = (draftKey) => localDrafts.value[draftKey] || null + + /** + * Remove draft from localStorage + */ + const removeLocalDraft = (draftKey) => { + if (localDrafts.value[draftKey]) { + delete localDrafts.value[draftKey] + } + } + + /** + * Sync localStorage draft to backend + */ + const syncDraftToBackend = async (draftKey) => { + if (!draftKey || !isDirty.value) return + const localDraft = getLocalDraft(draftKey) + if (!localDraft) return + + try { + await api.saveDraft(draftKey, localDraft) + isDirty.value = false + } catch (error) { + // Silent fail - will retry on next sync + } + } + /** * Reset all draft state to initial values */ @@ -60,13 +109,22 @@ export function useDraftManager (key, uploadedFiles = null) { } /** - * Load draft from backend for a given key + * Load draft from backend */ const loadDraft = async (draftKey) => { if (!draftKey) return isLoading.value = true isDirty.value = false + skipNextSave.value = true try { + // Check if there's an unsynced localStorage draft (e.g., from page refresh) + const localDraft = getLocalDraft(draftKey) + if (localDraft) { + await api.saveDraft(draftKey, localDraft) + removeLocalDraft(draftKey) + } + + // Load from backend (source of truth) const response = await api.getDraft(draftKey) const draft = response.data.data htmlContent.value = draft.content || '' @@ -81,37 +139,17 @@ export function useDraftManager (key, uploadedFiles = null) { } /** - * Save draft to backend - */ - const saveDraft = async (draftKey) => { - if (!draftKey || isLoading.value || !isDirty.value) return - try { - const macroActions = conversationStore.getMacro(MACRO_CONTEXT.REPLY)?.actions || [] - const draftMeta = {} - if (macroActions.length > 0) { - draftMeta.macro_actions = macroActions - } - if (uploadedFiles?.value?.length > 0) { - draftMeta.attachments = uploadedFiles.value - } - await api.saveDraft(draftKey, { content: htmlContent.value, meta: draftMeta }) - isDirty.value = false - } catch (error) { - // Silent fail for drafts - } - } - - /** - * Clear draft and local state + * Clear draft from both localStorage and backend */ const clearDraft = async (draftKey) => { if (!draftKey) return + removeLocalDraft(draftKey) isLoading.value = true try { await api.deleteDraft(draftKey) resetState() } catch (error) { - // Silent fail for drafts + // Silent fail } finally { isLoading.value = false } @@ -124,16 +162,17 @@ export function useDraftManager (key, uploadedFiles = null) { return textContent.value?.trim() !== '' } - // Watch for key changes to save / load draft + // Watch for key changes - sync to backend before switching watch( key, async (newKey, oldKey) => { - // Save old draft if dirty + // Sync old draft to backend before switching if (newKey !== oldKey && isDirty.value && hasDraftContent()) { - await saveDraft(oldKey) + await syncDraftToBackend(oldKey) + removeLocalDraft(oldKey) } - // Load new draft or clear state + // Load new draft from backend if (newKey && newKey !== oldKey) { await loadDraft(newKey) } else if (!newKey && oldKey) { @@ -143,7 +182,7 @@ export function useDraftManager (key, uploadedFiles = null) { { immediate: true } ) - // Auto-save draft when content, macro, or uploaded files change (debounced) + // Debounced watcher - save to localStorage only const watchSources = [ htmlContent, textContent, @@ -155,15 +194,25 @@ export function useDraftManager (key, uploadedFiles = null) { watchDebounced( watchSources, - async () => { + () => { + if (skipNextSave.value) { + skipNextSave.value = false + return + } if (!isLoading.value && key.value) { - isDirty.value = true - await saveDraft(key.value) + saveDraftLocal(key.value) } }, { debounce: 250, deep: true } ) + // Sync to backend when page is hidden (tab switch) + useEventListener(document, 'visibilitychange', async () => { + if (document.visibilityState === 'hidden' && isDirty.value && key.value) { + await syncDraftToBackend(key.value) + } + }) + return { htmlContent, textContent, From b1e0295c2bd7c4c9e7f5c74cfe2fbffa394e558e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 24 Dec 2025 00:13:53 +0530 Subject: [PATCH 069/160] allow setting same macro content again --- frontend/src/features/conversation/ReplyBox.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/conversation/ReplyBox.vue b/frontend/src/features/conversation/ReplyBox.vue index 7bdcd85b..81c64424 100644 --- a/frontend/src/features/conversation/ReplyBox.vue +++ b/frontend/src/features/conversation/ReplyBox.vue @@ -337,12 +337,12 @@ const processSend = async () => { */ watch( () => conversationStore.getMacro('reply').id, - (newId, oldId) => { + (newId) => { // 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) { + // If macro has message content, set it in the editor. + if (conversationStore.getMacro('reply').message_content) { htmlContent.value = conversationStore.getMacro('reply').message_content } }, From 07457acd79161f1834295b9b52b311b955acb32b Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 24 Dec 2025 00:20:09 +0530 Subject: [PATCH 070/160] fix: skip attachments already associated with another model in message sending --- cmd/messages.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/messages.go b/cmd/messages.go index f48f7fab..0d6d9356 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -187,6 +187,11 @@ func handleSendMessage(r *fastglue.Request) error { app.lo.Error("error fetching media", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError) } + if m.ModelID.Int > 0 { + // Attachment is already associated with another model. Skip it. + app.lo.Warn("attachment already associated with another model, skipping", "media_id", m.ID, "model", m.Model.String, "model_id", m.ModelID.Int) + continue + } media = append(media, m) } From aed1b904bb90abcd144c9b795342564138f5955b Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 24 Dec 2025 00:36:33 +0530 Subject: [PATCH 071/160] fix: reorder migration list to ensure correct upgrade path --- cmd/upgrade.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 36ace176..db288c50 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -36,8 +36,8 @@ var migList = []migFunc{ {"v0.6.0", migrations.V0_6_0}, {"v0.7.0", migrations.V0_7_0}, {"v0.7.4", migrations.V0_7_4}, - {"v0.9.1", migrations.V0_9_1}, {"v0.8.5", migrations.V0_8_5}, + {"v0.9.1", migrations.V0_9_1}, } // upgrade upgrades the database to the current version by running SQL migration files From 364b551106f416a01b209ad7eaff7ed92c7e8fe4 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 24 Dec 2025 00:42:27 +0530 Subject: [PATCH 072/160] fix: add meta size validation in draft handling --- cmd/draft.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/draft.go b/cmd/draft.go index 25207328..8e860cd2 100644 --- a/cmd/draft.go +++ b/cmd/draft.go @@ -10,6 +10,8 @@ import ( "github.com/zerodha/fastglue" ) +const maxMetaSize = 32 * 1024 // 32KB + type draftReq struct { Content string `json:"content"` Meta json.RawMessage `json:"meta"` @@ -40,6 +42,10 @@ func handleUpsertConversationDraft(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } + if len(req.Meta) > maxMetaSize { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "meta"), nil, envelope.InputError) + } + // Validate content is not empty if strings.TrimSpace(req.Content) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "content"), nil, envelope.InputError) From b0c5003103da286cda7ccaa4b01071b569744a4e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 24 Dec 2025 02:19:46 +0530 Subject: [PATCH 073/160] Delete drafts when there's no meaningful content in them. - show `pencil` icon in conversations if they have draft. - auto-delete drafts if draft has empty content --- cmd/draft.go | 2 +- frontend/src/components/editor/TextEditor.vue | 32 +++++-- frontend/src/composables/useDraftManager.js | 90 ++++++++++++++----- frontend/src/composables/useIdleDetection.js | 10 +-- .../src/features/contact/ContactsList.vue | 4 +- .../features/conversation/ReplyBoxContent.vue | 1 + .../list/ConversationListItem.vue | 11 ++- frontend/src/utils/debounce.js | 7 -- internal/conversation/models/models.go | 1 + internal/conversation/queries.sql | 6 +- internal/migrations/v0.9.1.go | 21 +---- 11 files changed, 117 insertions(+), 68 deletions(-) delete mode 100644 frontend/src/utils/debounce.js diff --git a/cmd/draft.go b/cmd/draft.go index 8e860cd2..bbb10570 100644 --- a/cmd/draft.go +++ b/cmd/draft.go @@ -47,7 +47,7 @@ func handleUpsertConversationDraft(r *fastglue.Request) error { } // Validate content is not empty - if strings.TrimSpace(req.Content) == "" { + if strings.TrimSpace(req.Content) == "" && (len(req.Meta) == 0 || string(req.Meta) == "{}") { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "content"), nil, envelope.InputError) } diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index f8e87f37..0466da34 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -1,5 +1,5 @@