From b8da96c1d186387dd8754ca72991c5955172d2d3 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 3 Nov 2025 13:11:27 +0530 Subject: [PATCH 001/200] fix: set Content-Disposition header for media served from filesystem set inline for videos, images and pdfs and attachment for rest --- cmd/media.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/media.go b/cmd/media.go index f953d5de..9bffec76 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -185,6 +185,18 @@ func handleServeMedia(r *fastglue.Request) error { consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": + disposition := "attachment" + + // Keep certain content types inline. + if strings.HasPrefix(media.ContentType, "image/") || + strings.HasPrefix(media.ContentType, "video/") || + media.ContentType == "application/pdf" { + disposition = "inline" + } + + r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType) + r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename)) + fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) case "s3": r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound) From 16fbfa7b7ccc6b0616fe09c4447f8a3df5077321 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 3 Nov 2025 13:11:53 +0530 Subject: [PATCH 002/200] fix: display message content conditionally based on content type Show `text` as is, render HTML with vue-letter --- .../conversation/message/ContactMessageBubble.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/features/conversation/message/ContactMessageBubble.vue b/frontend/src/features/conversation/message/ContactMessageBubble.vue index deb4e8bd..65eb2eaa 100644 --- a/frontend/src/features/conversation/message/ContactMessageBubble.vue +++ b/frontend/src/features/conversation/message/ContactMessageBubble.vue @@ -31,7 +31,15 @@
+
+ {{ sanitizedMessageContent }} +
Date: Mon, 3 Nov 2025 15:56:23 +0530 Subject: [PATCH 003/200] fix incorrect sender name when there are multiple participants involved --- .../conversation/message/ContactMessageBubble.vue | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/conversation/message/ContactMessageBubble.vue b/frontend/src/features/conversation/message/ContactMessageBubble.vue index 65eb2eaa..358e1eae 100644 --- a/frontend/src/features/conversation/message/ContactMessageBubble.vue +++ b/frontend/src/features/conversation/message/ContactMessageBubble.vue @@ -102,8 +102,12 @@ const settingsStore = useAppSettingsStore() const showQuotedText = ref(false) const { t } = useI18n() +const participant = computed(() => { + return convStore.conversation?.participants?.[props.message.sender_id] ?? {} +}) + const getAvatar = computed(() => { - return convStore.current?.contact?.avatar_url || '' + return participant.value?.avatar_url || '' }) const sanitizedMessageContent = computed(() => { let content = props.message.content || '' @@ -132,13 +136,14 @@ const nonInlineAttachments = computed(() => ) const getFullName = computed(() => { - const contact = convStore.current?.contact || {} - return `${contact.first_name || ''} ${contact.last_name || ''}`.trim() + const firstName = participant.value?.first_name ?? 'User' + const lastName = participant.value?.last_name ?? '' + return `${firstName} ${lastName}` }) const avatarFallback = computed(() => { - const contact = convStore.current?.contact || {} - return (contact.first_name || '').toUpperCase().substring(0, 2) + const firstName = participant.value?.first_name ?? 'U' + return firstName.toUpperCase().substring(0, 2) }) const showEnvelope = computed(() => { From dc14e805600071bfdab15b0c5aed2f0c88307ca0 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 9 Nov 2025 21:08:59 +0530 Subject: [PATCH 004/200] Fix media URLs not updating when root URL changes in settings --- cmd/init.go | 18 ++++++++++++------ cmd/main.go | 2 +- internal/media/stores/localfs/fs.go | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index c9351e83..0c194f60 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -429,12 +429,11 @@ func initTeam(db *sqlx.DB, i18n *i18n.I18n) *team.Manager { } // initMedia inits media manager. -func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { +func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.Manager { var ( - store media.Store - err error - appRootURL = ko.String("app.root_url") - lo = initLogger("media") + store media.Store + err error + lo = initLogger("media") ) switch s := ko.MustString("upload.provider"); s { case "s3": @@ -457,7 +456,14 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { store, err = fs.New(fs.Opts{ UploadURI: "/uploads", UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")), - RootURL: appRootURL, + RootURL: func() string { + rootURL, err := settings.GetAppRootURL() + if err != nil { + // Fallback to config if settings fetch fails + return ko.String("app.root_url") + } + return rootURL + }, }) if err != nil { log.Fatalf("error initializing fs media store: %v", err) diff --git a/cmd/main.go b/cmd/main.go index c7571a6d..2842e843 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -191,7 +191,7 @@ func main() { priority = initPriority(db, i18n) auth = initAuth(oidc, rdb, i18n) template = initTemplate(db, fs, constants, i18n) - media = initMedia(db, i18n) + media = initMedia(db, i18n, settings) inbox = initInbox(db, i18n) team = initTeam(db, i18n) businessHours = initBusinessHours(db, i18n) diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go index 372f386f..7b082b6c 100644 --- a/internal/media/stores/localfs/fs.go +++ b/internal/media/stores/localfs/fs.go @@ -13,7 +13,7 @@ import ( type Opts struct { UploadPath string UploadURI string - RootURL string + RootURL func() string } // Client implements `media.Store` @@ -49,7 +49,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 { - return fmt.Sprintf("%s%s/%s", c.opts.RootURL, c.opts.UploadURI, name) + return fmt.Sprintf("%s%s/%s", c.opts.RootURL(), c.opts.UploadURI, name) } // GetBlob accepts a URL, reads the file, and returns the blob. From 3d5db7b4988445df0cea7570868ac967ce539572 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 11 Nov 2025 15:37:07 +0530 Subject: [PATCH 005/200] fix message bubble overflows due to code and pre blocks --- frontend/src/assets/styles/main.scss | 11 +++++++++++ .../conversation/message/ContactMessageBubble.vue | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss index 8220a84d..1b3bbca8 100644 --- a/frontend/src/assets/styles/main.scss +++ b/frontend/src/assets/styles/main.scss @@ -166,6 +166,17 @@ .message-bubble { @apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm; + overflow-wrap: break-all; + + * { + max-width: 100%; + } + + // Force pre blocks to wrap + pre, code { + white-space: pre-wrap; + } + table { width: 100% !important; table-layout: fixed !important; diff --git a/frontend/src/features/conversation/message/ContactMessageBubble.vue b/frontend/src/features/conversation/message/ContactMessageBubble.vue index 358e1eae..62545d61 100644 --- a/frontend/src/features/conversation/message/ContactMessageBubble.vue +++ b/frontend/src/features/conversation/message/ContactMessageBubble.vue @@ -33,7 +33,7 @@
{{ sanitizedMessageContent }} @@ -42,7 +42,7 @@ v-else :html="sanitizedMessageContent" :allowedSchemas="['cid', 'https', 'http', 'mailto']" - class="mb-1 native-html break-all" + class="mb-1 native-html" :class="{ 'mb-3': message.attachments.length > 0 }" /> From 284d421ab4a6d90b5ccbe5db88ed2558ff135a81 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Wed, 19 Nov 2025 23:13:24 +0530 Subject: [PATCH 006/200] 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 007/200] 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 3845b03337bf719e0b04538bf78d62e61c199e31 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 00:03:19 +0530 Subject: [PATCH 008/200] fix: handle cases where last name is missing in full name computation. Particularly fixes an extra whitespace in rendered email template e.g. "Dear john ," Doesn't need to get fixed everywhere. So I've particularly fixed for rendered email template Fixes #167 --- frontend/src/stores/user.js | 1 + internal/conversation/models/models.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 8ea23ed8..7fe0eec3 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -34,6 +34,7 @@ export const useUserStore = defineStore('user', () => { const getFullName = computed(() => { const first = user.value.first_name ?? '' const last = user.value.last_name ?? '' + if (!last) return first return `${first} ${last}`.trim() }) diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index b3e80712..0489febb 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -153,6 +153,9 @@ type ConversationContact struct { } func (c *ConversationContact) FullName() string { + if c.LastName == "" { + return c.FirstName + } return c.FirstName + " " + c.LastName } From 3ba93d8dd6f284fb620d3667a266fd7c4e5de2aa Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 01:00:43 +0530 Subject: [PATCH 009/200] fix: redirect to next query param after login not working fixes #169 --- cmd/auth.go | 18 +++++++++++++++--- frontend/src/views/auth/UserLoginView.vue | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index e075202f..998275a1 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -13,6 +13,7 @@ import ( var ( oidcStateSessKey = "oidc_state" + oidcNextSessKey = "oidc_next" ) // handleOIDCLogin redirects to the OIDC provider for login. @@ -33,9 +34,13 @@ func handleOIDCLogin(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError) } - if err = app.auth.SetSessionValues(r, map[string]interface{}{ + sessionValues := map[string]any{ oidcStateSessKey: state, - }); err != nil { + // For redirecting after login + oidcNextSessKey: string(r.RequestCtx.QueryArgs().Peek("next")), + } + + if err = app.auth.SetSessionValues(r, sessionValues); err != nil { app.lo.Error("error saving state in session", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError) } @@ -104,5 +109,12 @@ func handleOIDCCallback(r *fastglue.Request) error { app.lo.Error("error creating login activity log", "error", err) } - return r.Redirect("/", fasthttp.StatusFound, nil, "") + // Read the 'next' parameter from session to redirect after login. + nextParam, _ := app.auth.GetSessionValue(r, oidcNextSessKey) + redirectURL := "/" + if nextStr, ok := nextParam.(string); ok && nextStr != "" { + redirectURL = nextStr + } + + return r.RedirectURI(redirectURL, fasthttp.StatusFound, nil, "") } diff --git a/frontend/src/views/auth/UserLoginView.vue b/frontend/src/views/auth/UserLoginView.vue index 52bc4bb6..d58c0ac5 100644 --- a/frontend/src/views/auth/UserLoginView.vue +++ b/frontend/src/views/auth/UserLoginView.vue @@ -185,7 +185,12 @@ const fetchOIDCProviders = async () => { } const redirectToOIDC = (provider) => { - window.location.href = `/api/v1/oidc/${provider.id}/login` + // Pass the 'next' parameter to OIDC login if it exists + const nextParam = router.currentRoute.value.query.next + const url = nextParam + ? `/api/v1/oidc/${provider.id}/login?next=${encodeURIComponent(nextParam)}` + : `/api/v1/oidc/${provider.id}/login` + window.location.href = url } const validateForm = () => { @@ -221,8 +226,14 @@ const loginAction = () => { } // Also fetch general setting as user's logged in. appSettingsStore.fetchSettings('general') - // Navigate to inboxes - router.push({ name: 'inboxes' }) + + // Redirect to the 'next' parameter if it exists + const nextParam = router.currentRoute.value.query.next + if (nextParam) { + router.push(nextParam) + } else { + router.push({ name: 'inboxes' }) + } }) .catch((error) => { errorMessage.value = handleHTTPError(error).message From 1de50d5c12bd681891882a0ff7405530e32fc327 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:35:49 +0000 Subject: [PATCH 010/200] chore(deps): bump golang.org/x/crypto from 0.38.0 to 0.45.0 Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.45.0. - [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.45.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- go.mod | 10 +++++----- go.sum | 22 ++++++++++------------ 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 9d9d36f7..65af307a 100644 --- a/go.mod +++ b/go.mod @@ -37,8 +37,8 @@ require ( github.com/zerodha/logf v0.5.5 github.com/zerodha/simplesessions/stores/redis/v3 v3.0.0 github.com/zerodha/simplesessions/v3 v3.0.0 - golang.org/x/crypto v0.38.0 - golang.org/x/mod v0.17.0 + golang.org/x/crypto v0.45.0 + golang.org/x/mod v0.29.0 golang.org/x/oauth2 v0.27.0 ) @@ -73,8 +73,8 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/image v0.18.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 56d292e7..a55a6089 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,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.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE= -github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= 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= @@ -192,15 +190,15 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -211,8 +209,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -229,8 +227,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -241,8 +239,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= From e55d9929815c296e71aa7e01c7f40fb0a95f9652 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 19:35:55 +0000 Subject: [PATCH 011/200] chore(deps-dev): bump vite from 5.4.20 to 5.4.21 in /frontend Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.20 to 5.4.21. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.4.21/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 5.4.21 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 211 +++++++++++++++++++++------------------- 2 files changed, 111 insertions(+), 102 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 4333abfd..d7cbd2a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,7 +78,7 @@ "start-server-and-test": "^2.0.3", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", - "vite": "^5.4.20", + "vite": "^5.4.21", "vitest": "^3.2.2" }, "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b27979e1..e6e59679 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -146,7 +146,7 @@ importers: version: 1.10.5 '@vitejs/plugin-vue': specifier: ^5.0.3 - version: 5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3)) + version: 5.2.1(vite@5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3)) '@vue/eslint-config-prettier': specifier: ^8.0.0 version: 8.0.0(eslint@8.57.1)(prettier@3.4.2) @@ -184,8 +184,8 @@ importers: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) vite: - specifier: ^5.4.20 - version: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) + specifier: ^5.4.21 + version: 5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) vitest: specifier: ^3.2.2 version: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) @@ -708,108 +708,113 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rollup/rollup-android-arm-eabi@4.50.2': - resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==} + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.50.2': - resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==} + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.50.2': - resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==} + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.50.2': - resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==} + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.50.2': - resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==} + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.50.2': - resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==} + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.50.2': - resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==} + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.50.2': - resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==} + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.50.2': - resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==} + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.50.2': - resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==} + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.50.2': - resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==} + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.50.2': - resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==} + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.50.2': - resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==} + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.50.2': - resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==} + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.50.2': - resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==} + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.50.2': - resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==} + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.50.2': - resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==} + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.50.2': - resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==} + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.50.2': - resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==} + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.50.2': - resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==} + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.50.2': - resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==} + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} cpu: [x64] os: [win32] @@ -3006,8 +3011,8 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.50.2: - resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==} + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3369,8 +3374,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@5.4.20: - resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -4105,67 +4110,70 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@rollup/rollup-android-arm-eabi@4.50.2': + '@rollup/rollup-android-arm-eabi@4.53.3': optional: true - '@rollup/rollup-android-arm64@4.50.2': + '@rollup/rollup-android-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-arm64@4.50.2': + '@rollup/rollup-darwin-arm64@4.53.3': optional: true - '@rollup/rollup-darwin-x64@4.50.2': + '@rollup/rollup-darwin-x64@4.53.3': optional: true - '@rollup/rollup-freebsd-arm64@4.50.2': + '@rollup/rollup-freebsd-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-x64@4.50.2': + '@rollup/rollup-freebsd-x64@4.53.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.50.2': + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.50.2': + '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.50.2': + '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.50.2': + '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.50.2': + '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.50.2': + '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.50.2': + '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.50.2': + '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.50.2': + '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.50.2': + '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-musl@4.50.2': + '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.50.2': + '@rollup/rollup-openharmony-arm64@4.53.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.50.2': + '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.50.2': + '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.50.2': + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true '@rushstack/eslint-patch@1.10.5': {} @@ -4676,9 +4684,9 @@ snapshots: transitivePeerDependencies: - vue - '@vitejs/plugin-vue@5.2.1(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))': + '@vitejs/plugin-vue@5.2.1(vite@5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))(vue@3.5.13(typescript@5.7.3))': dependencies: - vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) + vite: 5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) vue: 3.5.13(typescript@5.7.3) '@vitest/expect@3.2.2': @@ -4689,13 +4697,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))': + '@vitest/mocker@3.2.2(vite@5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0))': dependencies: '@vitest/spy': 3.2.2 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) + vite: 5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) '@vitest/pretty-format@3.2.2': dependencies: @@ -6604,31 +6612,32 @@ snapshots: robust-predicates@3.0.2: {} - rollup@4.50.2: + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.2 - '@rollup/rollup-android-arm64': 4.50.2 - '@rollup/rollup-darwin-arm64': 4.50.2 - '@rollup/rollup-darwin-x64': 4.50.2 - '@rollup/rollup-freebsd-arm64': 4.50.2 - '@rollup/rollup-freebsd-x64': 4.50.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.2 - '@rollup/rollup-linux-arm-musleabihf': 4.50.2 - '@rollup/rollup-linux-arm64-gnu': 4.50.2 - '@rollup/rollup-linux-arm64-musl': 4.50.2 - '@rollup/rollup-linux-loong64-gnu': 4.50.2 - '@rollup/rollup-linux-ppc64-gnu': 4.50.2 - '@rollup/rollup-linux-riscv64-gnu': 4.50.2 - '@rollup/rollup-linux-riscv64-musl': 4.50.2 - '@rollup/rollup-linux-s390x-gnu': 4.50.2 - '@rollup/rollup-linux-x64-gnu': 4.50.2 - '@rollup/rollup-linux-x64-musl': 4.50.2 - '@rollup/rollup-openharmony-arm64': 4.50.2 - '@rollup/rollup-win32-arm64-msvc': 4.50.2 - '@rollup/rollup-win32-ia32-msvc': 4.50.2 - '@rollup/rollup-win32-x64-msvc': 4.50.2 + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -7006,7 +7015,7 @@ snapshots: debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) + vite: 5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) transitivePeerDependencies: - '@types/node' - less @@ -7018,11 +7027,11 @@ snapshots: - supports-color - terser - vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0): + vite@5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 - rollup: 4.50.2 + rollup: 4.53.3 optionalDependencies: '@types/node': 22.10.5 fsevents: 2.3.3 @@ -7033,7 +7042,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.2 - '@vitest/mocker': 3.2.2(vite@5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)) + '@vitest/mocker': 3.2.2(vite@5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0)) '@vitest/pretty-format': 3.2.2 '@vitest/runner': 3.2.2 '@vitest/snapshot': 3.2.2 @@ -7051,7 +7060,7 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.0 tinyrainbow: 2.0.0 - vite: 5.4.20(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) + vite: 5.4.21(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) vite-node: 3.2.2(@types/node@22.10.5)(sass@1.83.1)(stylus@0.57.0) why-is-node-running: 2.3.0 optionalDependencies: From 4e7eeafdd7345bcf639e901b1f0bcadc1305d452 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 01:30:11 +0530 Subject: [PATCH 012/200] show total count of conversations on views --- .../src/features/conversation/list/ConversationList.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/conversation/list/ConversationList.vue b/frontend/src/features/conversation/list/ConversationList.vue index 8bbd6b9a..26b501a4 100644 --- a/frontend/src/features/conversation/list/ConversationList.vue +++ b/frontend/src/features/conversation/list/ConversationList.vue @@ -29,7 +29,11 @@ -
+
+ +
From 9283b0a9b1bb9a4bfb33d40e7184458a7d4f5e40 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 01:31:35 +0530 Subject: [PATCH 013/200] fix: incorrect count for team inbox, due to missing status filters in api request fixes #166 --- frontend/src/stores/conversation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/stores/conversation.js b/frontend/src/stores/conversation.js index e9f75ea1..9d30b76c 100644 --- a/frontend/src/stores/conversation.js +++ b/frontend/src/stores/conversation.js @@ -438,7 +438,8 @@ export const useConversationStore = defineStore('conversation', () => { page: page, page_size: CONV_LIST_PAGE_SIZE, order_by: sortFieldMap[conversations.sortField].model + "." + sortFieldMap[conversations.sortField].field, - order: sortFieldMap[conversations.sortField].order + order: sortFieldMap[conversations.sortField].order, + filters }) case CONVERSATION_LIST_TYPE.VIEW: return await api.getViewConversations(viewID, { From 232bad4c3ca408e508bded3b54e2cf8b3bc2e6e5 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 01:45:00 +0530 Subject: [PATCH 014/200] fix: sidebar collapsing when trying to make text bold in editor --- frontend/src/components/editor/TextEditor.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index 0fde4260..337ace53 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -149,7 +149,8 @@ const CustomTable = Table.extend({ ...this.parent?.(), style: { parseHTML: (element) => - (element.getAttribute('style') || '') + '; border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;' + (element.getAttribute('style') || '') + + '; border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;' } } } @@ -199,6 +200,10 @@ 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 49b2718495960ab92bbc9a4e7bb439ed2532371b Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 02:14:31 +0530 Subject: [PATCH 015/200] use dialog box for text editor link input instead of the bubble area instead --- frontend/src/components/editor/TextEditor.vue | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index 337ace53..f9ea4256 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -68,23 +68,41 @@ > -
- - - -
+ + + + + + {{ editor?.isActive('link') + ? $t('globals.messages.edit', { name: $t('globals.terms.link', 1).toLowerCase() + ' ' + $t('globals.terms.url', 1).toLowerCase() }) + : $t('globals.messages.add', { name: $t('globals.terms.link', 1).toLowerCase() + ' ' + $t('globals.terms.url', 1).toLowerCase() }) + }} + + + +
+
+ +
+ + + + +
+
+
@@ -99,8 +117,6 @@ import { List, ListOrdered, Link as LinkIcon, - Check, - X } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { @@ -110,6 +126,14 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' import Placeholder from '@tiptap/extension-placeholder' import Image from '@tiptap/extension-image' import StarterKit from '@tiptap/starter-kit' @@ -121,7 +145,7 @@ import TableHeader from '@tiptap/extension-table-header' const textContent = defineModel('textContent', { default: '' }) const htmlContent = defineModel('htmlContent', { default: '' }) -const showLinkInput = ref(false) +const showLinkDialog = ref(false) const linkUrl = ref('') const props = defineProps({ @@ -249,19 +273,19 @@ const openLinkModal = () => { } else { linkUrl.value = '' } - showLinkInput.value = true + showLinkDialog.value = true } const setLink = () => { if (linkUrl.value) { editor.value?.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.value }).run() } - showLinkInput.value = false + showLinkDialog.value = false } const unsetLink = () => { editor.value?.chain().focus().unsetLink().run() - showLinkInput.value = false + showLinkDialog.value = false } From 99f095288d8f8acb8662281a60253238d0f7b346 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 02:14:48 +0530 Subject: [PATCH 016/200] fix display of contact notes --- frontend/src/features/contact/ContactNotes.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/contact/ContactNotes.vue b/frontend/src/features/contact/ContactNotes.vue index ed998274..2a028bb4 100644 --- a/frontend/src/features/contact/ContactNotes.vue +++ b/frontend/src/features/contact/ContactNotes.vue @@ -99,7 +99,7 @@ -

+

From ca19fb23a55fcdf9c8a8a0b30fdc4c7ab06872b9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 02:18:02 +0530 Subject: [PATCH 017/200] reduce font size of powered by link --- 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 e16e6b92..05819c5f 100644 --- a/frontend/src/layouts/auth/AuthLayout.vue +++ b/frontend/src/layouts/auth/AuthLayout.vue @@ -8,7 +8,7 @@
From 87184e004c50358398c0f0b49e5c71a4351931b6 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 21 Nov 2025 02:38:08 +0530 Subject: [PATCH 018/200] fix table overflow in msg bubble --- frontend/src/assets/styles/main.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss index 1b3bbca8..37b2333a 100644 --- a/frontend/src/assets/styles/main.scss +++ b/frontend/src/assets/styles/main.scss @@ -179,7 +179,7 @@ table { width: 100% !important; - table-layout: fixed !important; + table-layout: auto !important; } } From 077c7ade780bb0f0863c7a258e0ee677dd30110a Mon Sep 17 00:00:00 2001 From: csr4422 Date: Fri, 21 Nov 2025 16:15:29 +0530 Subject: [PATCH 019/200] 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 020/200] 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 021/200] 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 022/200] 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 023/200] 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 024/200] 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 025/200] 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 026/200] 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 027/200] 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 028/200] 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 029/200] 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 030/200] 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 031/200] 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 032/200] 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 033/200] 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 034/200] 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 035/200] 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 036/200] 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 037/200] 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 038/200] 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 084/200] 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 085/200] 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 086/200] 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 087/200] 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 088/200] 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 089/200] 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 @@ diff --git a/frontend/src/views/admin/status/StatusView.vue b/frontend/src/views/admin/status/StatusView.vue index d6b13b30..6feb87c4 100644 --- a/frontend/src/views/admin/status/StatusView.vue +++ b/frontend/src/views/admin/status/StatusView.vue @@ -43,7 +43,7 @@
- +
diff --git a/frontend/src/views/admin/tags/TagsView.vue b/frontend/src/views/admin/tags/TagsView.vue index 7cee3c61..9733e129 100644 --- a/frontend/src/views/admin/tags/TagsView.vue +++ b/frontend/src/views/admin/tags/TagsView.vue @@ -39,7 +39,7 @@
- +
diff --git a/frontend/src/views/admin/teams/TeamList.vue b/frontend/src/views/admin/teams/TeamList.vue index 6d26d8b7..4c0bc774 100644 --- a/frontend/src/views/admin/teams/TeamList.vue +++ b/frontend/src/views/admin/teams/TeamList.vue @@ -7,7 +7,7 @@
- +
diff --git a/frontend/src/views/admin/templates/Templates.vue b/frontend/src/views/admin/templates/Templates.vue index 07aa7a8e..d96f238d 100644 --- a/frontend/src/views/admin/templates/Templates.vue +++ b/frontend/src/views/admin/templates/Templates.vue @@ -31,10 +31,10 @@ - + - + diff --git a/frontend/src/views/admin/webhooks/WebhookList.vue b/frontend/src/views/admin/webhooks/WebhookList.vue index f030d1b7..c784aa0b 100644 --- a/frontend/src/views/admin/webhooks/WebhookList.vue +++ b/frontend/src/views/admin/webhooks/WebhookList.vue @@ -14,7 +14,7 @@
- +
From 5b393c2f193314c1bfa1e43f4565db6ada4a0626 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 11 Jan 2026 18:27:55 +0530 Subject: [PATCH 199/200] add mention message content to mentioned msg notification --- internal/conversation/conversation.go | 3 ++- internal/migrations/v0.10.0.go | 4 ++++ schema.sql | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 94e90996..db413b2b 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -974,7 +974,8 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message, "Email": recipient.Email.String, }, "Message": map[string]any{ - "UUID": message.UUID, + "UUID": message.UUID, + "Content": message.Content, }, "MentionedBy": map[string]any{ "FirstName": author.FirstName, diff --git a/internal/migrations/v0.10.0.go b/internal/migrations/v0.10.0.go index 28aa62a8..1896774f 100644 --- a/internal/migrations/v0.10.0.go +++ b/internal/migrations/v0.10.0.go @@ -55,6 +55,10 @@ func V0_10_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 'email_notification'::template_type, '

{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.

+
+{{ .Message.Content }} +
+

View Conversation

diff --git a/schema.sql b/schema.sql index 2af70c88..ec756302 100644 --- a/schema.sql +++ b/schema.sql @@ -840,6 +840,10 @@ VALUES ( '

{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.

+
+{{ .Message.Content }} +
+

View Conversation

From 718a9e6b2eb026381c8fa5f0eb5d8587e4fb0859 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 11 Jan 2026 20:33:36 +0530 Subject: [PATCH 200/200] feat: Add entire conversation on webhook events, so clients don't have to fetch conversation again. Closes #208 --- internal/conversation/conversation.go | 37 +++++++++++++++++++-------- internal/conversation/message.go | 15 +++++------ 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index db413b2b..d308cfb2 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -541,18 +541,19 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) } - c.webhookStore.TriggerEvent(wmodels.EventConversationAssigned, map[string]any{ - "conversation_uuid": uuid, - "assigned_to": assigneeID, - "actor_id": actor.ID, - }) - // Refetch the conversation to get the updated details. conversation, err := c.GetConversation(0, uuid, "") if err != nil { return err } + c.webhookStore.TriggerEvent(wmodels.EventConversationAssigned, map[string]any{ + "conversation_uuid": uuid, + "assigned_to": assigneeID, + "actor_id": actor.ID, + "conversation": conversation, + }) + // Evaluate automation rules. c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationUserAssigned) @@ -715,6 +716,12 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) } + // Fetch conversation for webhook and automation rules. + conversation, err := c.GetConversation(0, uuid, "") + if err != nil { + c.lo.Error("error fetching conversation after status change", "uuid", uuid, "error", err) + } + // Trigger webhook for conversation status change var snoozeUntilStr string if !snoozeUntil.IsZero() { @@ -726,6 +733,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn "new_status": status, "snooze_until": snoozeUntilStr, "actor_id": actor.ID, + "conversation": conversation, }) // Record the status change as an activity. @@ -737,10 +745,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn c.BroadcastConversationUpdate(uuid, "status", status) // Evaluate automation rules. - conversation, err := c.GetConversation(0, uuid, "") - if err != nil { - c.lo.Error("error fetching conversation after status change", "uuid", uuid, "error", err) - } else { + if conversation.ID != 0 { c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationStatusChange) } @@ -797,6 +802,12 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.tag}"), nil) } + // Fetch conversation for webhook. + conversation, err := c.GetConversation(0, uuid, "") + if err != nil { + c.lo.Error("error fetching conversation after tags change", "uuid", uuid, "error", err) + } + // Trigger webhook for conversation tags changed. if newTags == nil { newTags = []string{} @@ -806,6 +817,7 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str "previous_tags": prevTags, "new_tags": newTags, "actor_id": actor.ID, + "conversation": conversation, }) // Find actually removed tags. @@ -1146,9 +1158,14 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use // Trigger webhook for conversation unassigned from user. if typ == models.AssigneeTypeUser { + conversation, err := m.GetConversation(0, uuid, "") + if err != nil { + m.lo.Error("error fetching conversation after unassignment", "uuid", uuid, "error", err) + } m.webhookStore.TriggerEvent(wmodels.EventConversationUnassigned, map[string]any{ "conversation_uuid": uuid, "actor_id": actor.ID, + "conversation": conversation, }) } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 128b3fa1..df1d7968 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -514,15 +514,12 @@ func (m *Manager) InsertMessage(message *models.Message) error { // Broadcast new message. m.BroadcastNewMessage(message) - // Refetch message if this message has media attachments, as media gets linked after inserting the message. - if len(message.Media) > 0 { - refetchedMessage, err := m.GetMessage(message.UUID) - if err != nil { - m.lo.Error("error fetching message after insert", "error", err) - } else { - // Replace the message in the struct with the refetched message. - *message = refetchedMessage - } + // Refetch the message to get all fields populated (e.g., author, media URLs). + refetchedMessage, err := m.GetMessage(message.UUID) + if err != nil { + m.lo.Error("error fetching message after insert", "error", err) + } else { + *message = refetchedMessage } // Trigger webhook for new message created.