From 7fee393cce8a5924287d19db1521afcaeb6644e9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 20 Oct 2026 00:58:33 +0530 Subject: [PATCH] fix issues found during release testing - Save recipient edits and sender choices with reply drafts. - Keep CC and BCC open when an address is cleared. - Allow SMTP without credentials when authentication is off. - Use the workspace timezone for reports and campaign dates. - Show CSAT responses without reloading the conversation. - Block access to attachments from deleted messages. - Accept conditional email templates and catch missing or repeated replies. - Explain why choosing another default email template failed. - Reload stale routes and keep widget image fallbacks working. - Keep the launcher usable by keyboard and reset host button styles. --- cmd/csat.go | 5 +- cmd/report.go | 10 +- .../main/src/composables/useDraftManager.js | 24 +++- .../src/composables/useDraftManager.test.js | 117 +++++++++++++++++ .../features/admin/inbox/WidgetCampaigns.vue | 19 ++- .../src/features/admin/inbox/formSchema.js | 11 +- .../features/admin/inbox/formSchema.test.js | 22 ++++ .../features/admin/notification/formSchema.js | 15 ++- .../admin/notification/formSchema.test.js | 22 ++++ .../src/features/conversation/ReplyBox.vue | 62 ++++----- frontend/apps/main/src/router/index.js | 17 +++ .../src/components/MessageAttachment.vue | 15 +-- .../src/composables/useReplyPreviews.js | 2 + i18n/en-US.json | 3 +- internal/conversation/conversation.go | 19 +++ internal/conversation/conversation_test.go | 83 ++++++++++++ internal/conversation/queries.sql | 7 + internal/media/media_test.go | 62 +++++++++ internal/media/queries.sql | 9 +- internal/report/queries.sql | 20 +-- internal/report/report.go | 21 +-- internal/report/report_test.go | 118 +++++++++++++++++ internal/template/render.go | 54 +++++++- internal/template/template.go | 24 +++- internal/template/template_test.go | 122 ++++++++++++++++++ static/widget.js | 14 +- 26 files changed, 798 insertions(+), 99 deletions(-) create mode 100644 frontend/apps/main/src/composables/useDraftManager.test.js create mode 100644 internal/report/report_test.go diff --git a/cmd/csat.go b/cmd/csat.go index 0171a0cf..d2c76992 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -86,7 +86,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }) } - if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil { + if err := app.conversation.UpdateCSATResponse(uuid, rating, feedback, metaJSON); err != nil { // Surface only known, user-safe messages; never leak raw driver errors // (e.g. a malformed UUID triggers a Postgres syntax error string). msg := app.i18n.T("globals.messages.somethingWentWrong") @@ -161,8 +161,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error { req.Feedback = truncateFeedback(req.Feedback) - // Update CSAT response - if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil { + if err := app.conversation.UpdateCSATResponse(uuid, req.Rating, req.Feedback, nil /** meta **/); err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/report.go b/cmd/report.go index f0dec2ef..91fda470 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -24,7 +24,7 @@ func handleOverviewCharts(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - charts, err := app.report.GetOverviewChart(days) + charts, err := app.report.GetOverviewChart(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -37,7 +37,7 @@ func handleOverviewSLA(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - sla, err := app.report.GetOverviewSLA(days) + sla, err := app.report.GetOverviewSLA(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -50,7 +50,7 @@ func handleOverviewCSAT(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - csat, err := app.report.GetOverviewCSAT(days) + csat, err := app.report.GetOverviewCSAT(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -63,7 +63,7 @@ func handleOverviewMessageVolume(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - volume, err := app.report.GetOverviewMessageVolume(days) + volume, err := app.report.GetOverviewMessageVolume(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -76,7 +76,7 @@ func handleOverviewTagDistribution(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - tags, err := app.report.GetOverviewTagDistribution(days) + tags, err := app.report.GetOverviewTagDistribution(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/frontend/apps/main/src/composables/useDraftManager.js b/frontend/apps/main/src/composables/useDraftManager.js index 310baab1..6e505976 100644 --- a/frontend/apps/main/src/composables/useDraftManager.js +++ b/frontend/apps/main/src/composables/useDraftManager.js @@ -23,13 +23,21 @@ const validateAttachments = (attachments) => { ) } +const validateRecipients = (recipients) => + Object.fromEntries( + ['to', 'cc', 'bcc', 'send_from'] + .filter(key => typeof recipients?.[key] === 'string') + .map(key => [key, recipients[key]]) + ) + const isDraftEmpty = (draft) => { const content = draft?.content || '' const hasText = getTextFromHTML(content).length > 0 const hasInlineImage = / 0 const hasMacroActions = draft?.meta?.macro_actions?.length > 0 - return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions + const hasRecipients = Object.keys(validateRecipients(draft?.meta?.recipients)).length > 0 + return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions && !hasRecipients } const draftKey = (uuid, type) => `${uuid}::${type}` @@ -38,6 +46,7 @@ const metaSignature = (meta) => JSON.stringify({ macro_id: meta?.macro_id || 0, macro_actions: meta?.macro_actions || [], + recipients: validateRecipients(meta?.recipients), attachments: (meta?.attachments || []).map(a => a.uuid) }) @@ -54,13 +63,17 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = const loadedAttachments = ref([]) const loadedMacroActions = ref([]) const loadedMacroID = ref(0) + const recipients = ref({}) // Live-key guard: the editor is transiently empty during open/switch and must not clobber a stored draft. const loadedKey = ref(null) const currentKey = () => draftKey(conversationUUID.value, messageType.value) - const buildDraft = () => { + const buildDraft = (type) => { const meta = {} + if (type === 'reply' && Object.keys(recipients.value).length > 0) { + meta.recipients = { ...recipients.value } + } const macro = conversationStore.getMacro(MACRO_CONTEXT.REPLY) const macroActions = macro?.actions || [] if (macro?.id > 0) meta.macro_id = macro.id @@ -85,6 +98,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = loadedAttachments.value = validateAttachments(draft?.meta?.attachments) loadedMacroActions.value = validateMacroActions(draft?.meta?.macro_actions) loadedMacroID.value = Number(draft?.meta?.macro_id) > 0 ? Number(draft.meta.macro_id) : 0 + recipients.value = validateRecipients(draft?.meta?.recipients) } const load = async (uuid, type) => { @@ -109,7 +123,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = // Update the store synchronously; server sync runs in the background, never awaited. const save = (uuid, type) => { - const draft = buildDraft() + const draft = buildDraft(type) if (sameDraft(draft, conversationStore.getDraft(uuid, type))) return if (isDraftEmpty(draft)) { conversationStore.removeDraft(uuid, type) @@ -134,6 +148,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = const watchSources = [ htmlContent, textContent, + recipients, () => conversationStore.macros[MACRO_CONTEXT.REPLY] ] if (uploadedFiles) watchSources.push(uploadedFiles) @@ -174,6 +189,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = clearDraft, loadedAttachments, loadedMacroActions, - loadedMacroID + loadedMacroID, + recipients } } diff --git a/frontend/apps/main/src/composables/useDraftManager.test.js b/frontend/apps/main/src/composables/useDraftManager.test.js new file mode 100644 index 00000000..e8e4d0a8 --- /dev/null +++ b/frontend/apps/main/src/composables/useDraftManager.test.js @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { useDraftManager } from './useDraftManager' + +const { drafts, api, store } = vi.hoisted(() => { + const drafts = new Map() + return { + drafts, + api: { saveDraft: vi.fn().mockResolvedValue({}), deleteDraft: vi.fn().mockResolvedValue({}) }, + store: { + macros: {}, + draftsReady: Promise.resolve(), + getMacro: () => null, + getDraft: (uuid, type) => drafts.get(`${uuid}:${type}`), + setDraft: (uuid, type, draft) => drafts.set(`${uuid}:${type}`, draft), + removeDraft: (uuid, type) => drafts.delete(`${uuid}:${type}`) + } + } +}) +vi.mock('@main/stores/conversation', () => ({ useConversationStore: () => store })) +vi.mock('@main/api', () => ({ default: api })) + +let scopes +const settle = async () => { + await nextTick() + await vi.advanceTimersByTimeAsync(600) +} +const openDraft = () => { + const scope = effectScope() + scopes.push(scope) + const uuid = ref('conversation-a') + const type = ref('reply') + const draft = scope.run(() => useDraftManager(uuid, type)) + return { ...draft, uuid, type } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + drafts.clear() + scopes = [] +}) +afterEach(() => { + scopes.forEach(scope => scope.stop()) + vi.useRealTimers() +}) + +describe('reply draft recipients', () => { + it('restores recipient edits with the body after remount', async () => { + const draft = openDraft() + await settle() + draft.htmlContent.value = '

Keep this reply.

' + draft.recipients.value = { to: 'other@example.com', cc: '', bcc: 'audit@example.com', send_from: 'alias@example.com' } + await settle() + const restored = openDraft() + await settle() + expect(restored.htmlContent.value).toBe('

Keep this reply.

') + expect(restored.recipients.value).toEqual(draft.recipients.value) + expect(api.saveDraft).toHaveBeenCalledTimes(1) + }) + + it('preserves an explicitly removed recipient without message text', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { cc: '' } + await settle() + expect(drafts.get('conversation-a:reply').meta.recipients).toEqual({ cc: '' }) + expect(api.deleteDraft).not.toHaveBeenCalled() + }) + + it('isolates recipients across conversations and private notes', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { bcc: 'audit@example.com' } + draft.type.value = 'private_note' + await settle() + expect(draft.recipients.value).toEqual({}) + draft.htmlContent.value = '

Private note

' + await settle() + expect(drafts.get('conversation-a:private_note').meta).not.toHaveProperty('recipients') + draft.type.value = 'reply' + await settle() + expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' }) + draft.uuid.value = 'conversation-b' + await settle() + expect(draft.recipients.value).toEqual({}) + draft.recipients.value = { to: 'second@example.com' } + draft.uuid.value = 'conversation-a' + await settle() + expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' }) + expect(drafts.get('conversation-b:reply').meta.recipients).toEqual({ to: 'second@example.com' }) + }) + + it('clears recipients after a successful send without resurrecting the draft', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { to: '', cc: 'copy@example.com' } + await settle() + draft.clearDraft() + await settle() + expect(draft.recipients.value).toEqual({}) + expect(drafts.size).toBe(0) + expect(api.deleteDraft).toHaveBeenCalledTimes(1) + }) + + it('loads older drafts and ignores malformed recipient metadata', async () => { + drafts.set('conversation-a:reply', { content: '

Older draft

', meta: {} }) + const draft = openDraft() + await settle() + expect(draft.recipients.value).toEqual({}) + drafts.set('conversation-b:reply', { content: '

Reply

', meta: { recipients: { to: [], cc: '', bcc: null, extra: 'ignored' } } }) + draft.uuid.value = 'conversation-b' + await settle() + expect(draft.recipients.value).toEqual({ cc: '' }) + }) +}) diff --git a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue index 85768e5b..ffb0e9f7 100644 --- a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue +++ b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue @@ -102,8 +102,8 @@ const businessHours = ref([]) const error = ref('') const pendingDelete = ref(null) const stats = ref([]) -const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)) -const to = ref(new Date().toISOString().slice(0, 10)) +const from = ref('') +const to = ref('') const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot')) const campaignEventExample = computed( () => `window.Libredesk.trackEvent(${JSON.stringify(campaign.value?.event || 'pricing_viewed')})` @@ -286,13 +286,24 @@ const refreshStats = async () => { onMounted(async () => { try { - const response = await api.getAllBusinessHours() + const [response, settingsResponse] = await Promise.all([ + api.getAllBusinessHours(), + api.getSettings('general') + ]) const data = response.data.data businessHours.value = data.results || data + const dateFormat = new Intl.DateTimeFormat('en-CA', { + timeZone: settingsResponse.data.data['app.timezone'] || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + from.value = dateFormat.format(new Date(Date.now() - 30 * 86400000)) + to.value = dateFormat.format(new Date()) + await refreshStats() } catch (err) { error.value = handleHTTPError(err).message } - await refreshStats() }) diff --git a/frontend/apps/main/src/features/admin/inbox/formSchema.js b/frontend/apps/main/src/features/admin/inbox/formSchema.js index 99f5fe1e..7e49c666 100644 --- a/frontend/apps/main/src/features/admin/inbox/formSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.js @@ -56,8 +56,8 @@ export const createFormSchema = (t) => z.object({ smtp: z.object({ host: z.string().min(1, t('globals.messages.required')), port: z.number().min(1).max(65535), - username: z.string().min(1, t('globals.messages.required')), - password: z.string().min(1, t('globals.messages.required')), + username: z.string().default(''), + password: z.string().default(''), max_conns: z.number().min(1), max_msg_retries: z.number().min(0).max(100), idle_timeout: z.string().min(1, t('globals.messages.required')).refine(isGoDuration, { @@ -70,6 +70,13 @@ export const createFormSchema = (t) => z.object({ tls_skip_verify: z.boolean().optional(), hello_hostname: z.string().optional(), auth_protocol: z.enum(['login', 'cram', 'plain', 'none']) + }).superRefine((smtp, ctx) => { + if (smtp.auth_protocol === 'none') return + for (const field of ['username', 'password']) { + if (!smtp[field]) { + ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') }) + } + } }) }).superRefine((values, ctx) => { const primary = extractEmailAddress(values.from).toLowerCase() diff --git a/frontend/apps/main/src/features/admin/inbox/formSchema.test.js b/frontend/apps/main/src/features/admin/inbox/formSchema.test.js index fa540b9e..d6c8bda7 100644 --- a/frontend/apps/main/src/features/admin/inbox/formSchema.test.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.test.js @@ -248,6 +248,28 @@ describe('Email Inbox Form Schema', () => { } }) + test('unauthenticated SMTP accepts empty and omitted credentials', () => { + for (const credential of ['', undefined]) { + expect(() => schema.parse(withSmtp({ + auth_protocol: 'none', username: credential, password: credential + }))).not.toThrow() + } + }) + + test('authenticated SMTP requires both credentials', () => { + for (const auth_protocol of ['login', 'cram', 'plain']) { + for (const field of ['username', 'password']) { + for (const credential of ['', undefined]) { + const result = schema.safeParse(withSmtp({ auth_protocol, [field]: credential })) + expect(result.success).toBe(false) + expect(result.error.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: ['smtp', field] }) + ])) + } + } + } + }) + test('smtp hello_hostname optional', () => { expect(() => schema.parse(withSmtp({ hello_hostname: 'desk.example.com' }))).not.toThrow() }) diff --git a/frontend/apps/main/src/features/admin/notification/formSchema.js b/frontend/apps/main/src/features/admin/notification/formSchema.js index bcdc3829..55291a1f 100644 --- a/frontend/apps/main/src/features/admin/notification/formSchema.js +++ b/frontend/apps/main/src/features/admin/notification/formSchema.js @@ -3,9 +3,7 @@ import { isGoDuration } from '@shared-ui/utils/string'; export const createFormSchema = (t) => z.object({ enabled: z.boolean().default(false), - username: z.string().nonempty({ - message: t('globals.messages.required') - }), + username: z.string().default(''), host: z.string().nonempty({ message: t('globals.messages.required') }), @@ -17,9 +15,7 @@ export const createFormSchema = (t) => z.object({ .min(1, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) }) .max(65535, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) }) .default(587), - password: z.string().nonempty({ - message: t('globals.messages.required') - }), + password: z.string().default(''), max_conns: z .number({ invalid_type_error: t('globals.messages.mustBeNumber'), @@ -54,4 +50,11 @@ export const createFormSchema = (t) => z.object({ hello_hostname: z.string().optional(), tls_type: z.enum(['none', 'starttls', 'tls']), tls_skip_verify: z.boolean().optional(), +}).superRefine((smtp, ctx) => { + if (smtp.auth_protocol === 'none') return + for (const field of ['username', 'password']) { + if (!smtp[field]) { + ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') }) + } + } }); diff --git a/frontend/apps/main/src/features/admin/notification/formSchema.test.js b/frontend/apps/main/src/features/admin/notification/formSchema.test.js index 105ee55c..2ef0fe01 100644 --- a/frontend/apps/main/src/features/admin/notification/formSchema.test.js +++ b/frontend/apps/main/src/features/admin/notification/formSchema.test.js @@ -161,6 +161,28 @@ describe('Notification Form Schema', () => { } }) + test('unauthenticated SMTP accepts empty and omitted credentials', () => { + for (const credential of ['', undefined]) { + expect(() => schema.parse({ + ...validForm, auth_protocol: 'none', username: credential, password: credential + })).not.toThrow() + } + }) + + test('authenticated SMTP requires both credentials', () => { + for (const auth_protocol of ['login', 'cram', 'plain']) { + for (const field of ['username', 'password']) { + for (const credential of ['', undefined]) { + const result = schema.safeParse({ ...validForm, auth_protocol, [field]: credential }) + expect(result.success).toBe(false) + expect(result.error.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: [field] }) + ])) + } + } + } + }) + test('tls_type missing', () => { const { tls_type, ...form } = validForm expect(() => schema.parse(form)).toThrow() diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index a4624a4d..53fe0269 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -332,7 +332,8 @@ const { clearDraft, loadedAttachments, loadedMacroActions, - loadedMacroID + loadedMacroID, + recipients } = useDraftManager(currentConversationUUID, messageType, mediaFiles) // Rest of existing state @@ -340,11 +341,19 @@ const isEditorFullscreen = ref(false) const isMinimized = ref(false) const isSending = ref(false) const isGenerating = ref(false) -const to = ref('') -const cc = ref('') -const bcc = ref('') -const sendFrom = ref('') -const senderPickedFor = ref(null) +const recipientField = (field, defaultValue) => computed({ + get: () => recipients.value[field] ?? defaultValue(), + set: (value) => { recipients.value = { ...recipients.value, [field]: value } } +}) +const to = recipientField('to', () => conversationStore.currentTo?.join(', ') || '') +const cc = recipientField('cc', () => conversationStore.currentCC?.join(', ') || '') +const bcc = recipientField('bcc', () => conversationStore.currentBCC?.join(', ') || '') +const sendFrom = computed({ + get: () => conversationStore.currentFromOptions.includes(recipients.value.send_from) + ? recipients.value.send_from + : conversationStore.currentFrom || '', + set: (value) => { recipients.value = { ...recipients.value, send_from: value } } +}) const showCc = ref(false) const showBcc = ref(false) const emailErrors = ref([]) @@ -720,22 +729,8 @@ watch( const pickSender = (value) => { sendFrom.value = value - senderPickedFor.value = currentConversationUUID.value } -// Keep the agent's own sender pick while it is still allowed, since currentFrom changes whenever a new message arrives. -watch( - [currentConversationUUID, () => conversationStore.currentFrom], - ([uuid, value], [previousUUID] = []) => { - if (uuid !== previousUUID) senderPickedFor.value = null - const picked = - senderPickedFor.value === uuid && - conversationStore.currentFromOptions.includes(sendFrom.value) - if (!picked) sendFrom.value = value || '' - }, - { immediate: true } -) - // Reset first so a loaded draft never inherits the previous conversation's macro (drafts store no message_content). watch( [loadedMacroID, loadedMacroActions], @@ -760,31 +755,22 @@ watch( { deep: true } ) -// Initialize to, cc, and bcc fields with the current conversation's values. watch( - () => conversationStore.currentCC, - (newVal) => { - cc.value = newVal?.join(', ') || '' - showCc.value = cc.value.length > 0 - }, - { deep: true, immediate: true } -) - -watch( - () => conversationStore.currentTo, - (newVal) => { - to.value = newVal?.join(', ') || '' + [isDraftLoading, cc], + ([loading, value], [wasLoading] = []) => { + if (loading) return + if (wasLoading || value.length > 0) showCc.value = value.length > 0 }, { immediate: true } ) watch( - () => conversationStore.currentBCC, - (newVal) => { - bcc.value = newVal?.join(', ') || '' - showBcc.value = bcc.value.length > 0 + [isDraftLoading, bcc], + ([loading, value], [wasLoading] = []) => { + if (loading) return + if (wasLoading || value.length > 0) showBcc.value = value.length > 0 }, - { deep: true, immediate: true } + { immediate: true } ) // Media files and macro state are restored per draft by the draft manager; resetting here would race ahead of the save and drop them. diff --git a/frontend/apps/main/src/router/index.js b/frontend/apps/main/src/router/index.js index 4b28f011..e7d4d2f2 100644 --- a/frontend/apps/main/src/router/index.js +++ b/frontend/apps/main/src/router/index.js @@ -696,6 +696,23 @@ const router = createRouter({ routes: routes }) +const preloadErrors = new WeakSet() +const reloadKey = 'libredesk:preload-reload' + +window.addEventListener('vite:preloadError', (event) => { + preloadErrors.add(event.payload) +}) + +router.onError((error, to) => { + if (!preloadErrors.has(error) || sessionStorage.getItem(reloadKey) === to.fullPath) return + sessionStorage.setItem(reloadKey, to.fullPath) + window.location.assign(to.fullPath) +}) + +router.afterEach((to, from, failure) => { + if (!failure) sessionStorage.removeItem(reloadKey) +}) + router.beforeEach((to, from, next) => { // Cancel in-flight requests. if (to.fullPath !== from.fullPath) { diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue index f65de1fe..9f1fd0b0 100644 --- a/frontend/apps/widget/src/components/MessageAttachment.vue +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -5,18 +5,16 @@ :key="attachment.uuid" class="flex items-center cursor-pointer" > -
-