From 43c007ef9e0a90a2f75ccc18db74608dfd5ea17d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 01:54:35 +0530 Subject: [PATCH] improve notification previews and editor AI Show the latest reply in alerts without quoted email history. Share AI prompts across conversation editors and clarify browser notification settings. --- frontend/apps/main/src/App.vue | 3 + .../components/editor/ConversationEditor.vue | 9 +- .../src/components/editor/EditorToolbar.vue | 37 +++++++-- .../src/features/admin/templates/defaults.js | 12 ++- .../src/features/conversation/ReplyBox.vue | 14 +--- .../features/conversation/ReplyBoxContent.vue | 22 ++--- frontend/apps/main/src/stores/aiPrompt.js | 81 ++++++++++++------ .../NotificationPreferences.test.js | 82 +++++++++++++++---- .../notifications/NotificationPreferences.vue | 25 +++++- .../src/views/admin/ai/AIEditorPrompts.vue | 34 ++++---- i18n/en-US.json | 3 +- internal/conversation/conversation.go | 18 +++- .../conversation/conversation_reference.go | 10 --- .../conversation_reference_test.go | 21 +---- internal/conversation/notification_test.go | 39 +++++++++ internal/migrations/v2.9.0.go | 12 ++- schema.sql | 12 ++- 17 files changed, 284 insertions(+), 150 deletions(-) diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index 5012311b..b9dfe330 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag' import { useCustomAttributeStore } from './stores/customAttributes' import { useIdleDetection } from './composables/useIdleDetection' import { useNotificationStore } from './stores/notification' +import { useAiPromptStore } from '@main/stores/aiPrompt' import { useViewStore } from './stores/view' import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog' import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue' @@ -166,6 +167,7 @@ const openCreateConversationDialog = ref(false) const createConversationContact = ref(null) const { t } = useI18n() const notificationStore = useNotificationStore() +const aiPromptStore = useAiPromptStore() const pushNotifications = usePushNotifications() // Update browser tab title with unread notification count. @@ -217,6 +219,7 @@ const initStores = async () => { slaStore.fetchSlas(), tagStore.fetchTags(), customAttributeStore.fetchCustomAttributes(), + aiPromptStore.fetchPrompts(), refreshPushSubscription() ]) } diff --git a/frontend/apps/main/src/components/editor/ConversationEditor.vue b/frontend/apps/main/src/components/editor/ConversationEditor.vue index 9b7c7070..a0ae2cf1 100644 --- a/frontend/apps/main/src/components/editor/ConversationEditor.vue +++ b/frontend/apps/main/src/components/editor/ConversationEditor.vue @@ -12,9 +12,9 @@ > @@ -41,7 +41,6 @@ const props = defineProps({ insertContent: String, messageType: String, autoFocus: { type: Boolean, default: true }, - aiPrompts: { type: Array, default: () => [] }, disabled: { type: Boolean, default: false }, enableMentions: { type: Boolean, default: false }, getSuggestions: { type: Function, default: null }, @@ -53,7 +52,7 @@ const props = defineProps({ const emit = defineEmits([ 'send', - 'aiPromptSelected', + 'aiGenerationChange', 'mentionsChanged', 'filesDropped', 'toggleMessageType' @@ -61,8 +60,6 @@ const emit = defineEmits([ const linkDialog = ref(null) -const emitPrompt = (key) => emit('aiPromptSelected', key) - // Suppress the formatting bubble when an image node is selected so it // doesn't fight with the image's own size/remove toolbar. const shouldShowBubble = ({ editor: e, state }) => { diff --git a/frontend/apps/main/src/components/editor/EditorToolbar.vue b/frontend/apps/main/src/components/editor/EditorToolbar.vue index 79fab042..08085f95 100644 --- a/frontend/apps/main/src/components/editor/EditorToolbar.vue +++ b/frontend/apps/main/src/components/editor/EditorToolbar.vue @@ -1,8 +1,14 @@ diff --git a/frontend/apps/main/src/features/admin/templates/defaults.js b/frontend/apps/main/src/features/admin/templates/defaults.js index 362331bb..4df4b92d 100644 --- a/frontend/apps/main/src/features/admin/templates/defaults.js +++ b/frontend/apps/main/src/features/admin/templates/defaults.js @@ -21,7 +21,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }} -
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -38,7 +40,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }} -

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

View Conversation @@ -55,7 +59,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }} -

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

View Conversation diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index edee9956..5c7acda6 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -55,7 +55,6 @@ v-if="isEditorFullscreen" ref="fullscreenContentRef" :isFullscreen="true" - :aiPrompts="aiPrompts" :isSending="isSending" :isDraftLoading="isDraftLoading" :uploadingFiles="uploadingFiles" @@ -75,7 +74,7 @@ @fileUpload="handleFileUpload" @fileDelete="handleFileDelete" @filesDropped="uploadFiles" - @aiPromptSelected="handleAiPromptSelected" + @aiGenerationChange="isGenerating = $event" :isGenerating="isGenerating" :canSendReply="canSendReply" :canSendPrivateNote="canSendPrivateNote" @@ -117,7 +116,6 @@ aiPromptStore.prompts) const replyBoxContentRef = ref(null) const fullscreenContentRef = ref(null) const activeContentRef = () => @@ -265,8 +260,6 @@ const showMissingTagsWarning = ref(false) const deferredStatus = ref(null) const mentions = ref([]) -aiPromptStore.fetchPrompts() - const runAiGeneration = async (requestFn) => { if (isGenerating.value) return const uuid = currentConversationUUID.value @@ -286,9 +279,6 @@ const runAiGeneration = async (requestFn) => { } } -const handleAiPromptSelected = (key) => - runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: htmlContent.value })) - const handleGenerateReply = () => runAiGeneration((uuid) => api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value }) diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index b97e132c..f207d2f2 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -7,18 +7,18 @@ :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }" > - + {{ $t('globals.terms.reply') }} {{ $t('globals.terms.privateNote') }} @@ -91,7 +91,6 @@ v-model:textContent="textContent" :message-type="messageType" :placeholder="isCramped ? t('globals.terms.typeMessage') : t('editor.hint.full')" - :aiPrompts="aiPrompts" :insertContent="insertContent" :autoFocus="true" :disabled="isDraftLoading" @@ -100,7 +99,7 @@ :enableInlineImages="conversationStore.current.inbox_channel === 'email'" :getSuggestions="getSuggestions" :getConversationSuggestions="getConversationSuggestions" - @aiPromptSelected="handleAiPromptSelected" + @aiGenerationChange="emit('aiGenerationChange', $event)" @send="handleSend" @mentionsChanged="handleMentionsChanged" @filesDropped="handleFilesDropped" @@ -146,9 +145,6 @@ const RECIPIENT_INPUT_CLASS = 'flex-grow border-input bg-card px-3 py-2 text-sm shadow-none focus-visible:ring-1 focus-visible:ring-ring' -const TAB_TRIGGER_CLASS = - 'rounded-md px-3 py-1 text-sm transition-colors duration-150 max-md:py-2.5 data-[state=active]:bg-card data-[state=active]:shadow-sm' - import { ref, computed, nextTick, watch } from 'vue' import { useDebounceFn } from '@vueuse/core' import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' @@ -248,10 +244,6 @@ const props = defineProps({ type: Boolean, default: false }, - aiPrompts: { - type: Array, - required: true - }, isSending: { type: Boolean, required: true @@ -292,7 +284,7 @@ const emit = defineEmits([ 'inlineImageUpload', 'fileDelete', 'filesDropped', - 'aiPromptSelected', + 'aiGenerationChange', 'generateReply' ]) @@ -400,10 +392,6 @@ const handleEmojiSelect = (emoji) => { nextTick(() => (insertContent.value = emoji)) } -const handleAiPromptSelected = (key) => { - emit('aiPromptSelected', key) -} - // Watch and update macro view based on message type this filters our macros. watch( messageType, diff --git a/frontend/apps/main/src/stores/aiPrompt.js b/frontend/apps/main/src/stores/aiPrompt.js index b706deee..e6050035 100644 --- a/frontend/apps/main/src/stores/aiPrompt.js +++ b/frontend/apps/main/src/stores/aiPrompt.js @@ -1,34 +1,61 @@ import { ref } from 'vue' import { defineStore } from 'pinia' import { handleHTTPError } from '@shared-ui/utils/http.js' -import { useEmitter } from '../composables/useEmitter' -import { EMITTER_EVENTS } from '../constants/emitterEvents' -import api from '../api' +import { useEmitter } from '@main/composables/useEmitter' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents' +import api from '@main/api' export const useAiPromptStore = defineStore('aiPrompt', () => { - const prompts = ref([]) - const emitter = useEmitter() - let inflight = null - let hasFetched = false - const fetchPrompts = () => { - if (hasFetched) return Promise.resolve() - if (inflight) return inflight - inflight = api.getAiPrompts() - .then(response => { - prompts.value = response?.data?.data || [] - hasFetched = true - }) - .catch(error => { - emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - variant: 'destructive', - description: handleHTTPError(error).message - }) - }) - .finally(() => { inflight = null }) - return inflight - } - return { - prompts, - fetchPrompts, + const prompts = ref([]) + const emitter = useEmitter() + let inflight = null + let hasFetched = false + + const showError = (error) => { + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { + variant: 'destructive', + description: handleHTTPError(error).message + }) + } + + const fetchPrompts = () => { + if (hasFetched) return Promise.resolve() + if (inflight) return inflight + + inflight = api + .getAiPrompts() + .then((response) => { + prompts.value = response?.data?.data || [] + hasFetched = true + }) + .catch(showError) + .finally(() => { + inflight = null + }) + + return inflight + } + + const refreshPrompts = async () => { + if (inflight) await inflight + hasFetched = false + return fetchPrompts() + } + + const complete = async (promptKey, content) => { + try { + const response = await api.aiCompletion({ prompt_key: promptKey, content }) + return response?.data?.data ?? '' + } catch (error) { + showError(error) + return null } + } + + return { + prompts, + fetchPrompts, + refreshPrompts, + complete + } }) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js index 092fd6de..80a42143 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -2,43 +2,73 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick, ref } from 'vue' -const { getPreferences, savePreferences, refresh, emit } = vi.hoisted(() => ({ +const { getPreferences, savePreferences, refresh, enable, disable, emit } = vi.hoisted(() => ({ getPreferences: vi.fn(), savePreferences: vi.fn().mockResolvedValue({}), refresh: vi.fn(), + enable: vi.fn().mockResolvedValue(true), + disable: vi.fn().mockResolvedValue(), emit: vi.fn() })) -vi.mock('@/api', () => ({ default: { getNotificationPreferences: getPreferences, updateNotificationPreferences: savePreferences } })) +vi.mock('@/api', () => ({ + default: { + getNotificationPreferences: getPreferences, + updateNotificationPreferences: savePreferences + } +})) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key }) })) vi.mock('@/composables/useEmitter', () => ({ useEmitter: () => ({ emit }) })) vi.mock('@shared-ui/utils/http.js', () => ({ handleHTTPError: (error) => error })) vi.mock('@/composables/usePushNotifications', () => ({ - usePushNotifications: () => ({ supported: ref(true), enabled: ref(false), permission: ref('granted'), refresh }) + usePushNotifications: () => ({ + supported: ref(true), + enabled: ref(false), + permission: ref('granted'), + refresh, + enable, + disable + }) })) vi.mock('@shared-ui/components/ui/switch', () => ({ Switch: { props: ['checked', 'disabled'], emits: ['update:checked'], - setup: (props, { emit }) => () => h('button', { - type: 'button', disabled: props.disabled, - onClick: () => emit('update:checked', !props.checked) - }) + setup: + (props, { emit }) => + () => + h('button', { + type: 'button', + disabled: props.disabled, + onClick: () => emit('update:checked', !props.checked) + }) } })) import NotificationPreferences from './NotificationPreferences.vue' let app let root -const settle = async () => { for (let i = 0; i < 10; i++) await nextTick() } -afterEach(() => { app?.unmount(); root?.remove(); vi.clearAllMocks() }) +const settle = async () => { + for (let i = 0; i < 10; i++) await nextTick() +} +afterEach(() => { + app?.unmount() + root?.remove() + vi.clearAllMocks() +}) const mount = async (vapidPublicKey = 'test-key') => { - getPreferences.mockResolvedValue({ data: { data: { - email_enabled: true, vapid_public_key: vapidPublicKey, preferences: [ - { notification_type: 'new_reply', channel: 'email', enabled: true }, - { notification_type: 'new_reply', channel: 'in_app', enabled: false } - ] - } } }) + getPreferences.mockResolvedValue({ + data: { + data: { + email_enabled: true, + vapid_public_key: vapidPublicKey, + preferences: [ + { notification_type: 'new_reply', channel: 'email', enabled: true }, + { notification_type: 'new_reply', channel: 'in_app', enabled: false } + ] + } + } + }) root = document.createElement('div') document.body.append(root) app = createApp(NotificationPreferences) @@ -54,7 +84,9 @@ describe('notification preferences', () => { expect(email).toBeDefined() email.click() await settle() - expect(savePreferences).toHaveBeenCalledWith([{ notification_type: 'new_reply', channel: 'email', enabled: false }]) + expect(savePreferences).toHaveBeenCalledWith([ + { notification_type: 'new_reply', channel: 'email', enabled: false } + ]) }) it('prevents overlapping saves for the same preference', async () => { @@ -99,4 +131,22 @@ describe('notification preferences', () => { expect(browserPush.disabled).toBe(true) expect(root.textContent).toContain('notification.browserPush.unavailable') }) + + it('shows a loader while browser notifications are being enabled', async () => { + let finishEnable + enable.mockReturnValueOnce( + new Promise((resolve) => { + finishEnable = resolve + }) + ) + await mount() + + root.querySelector('button').click() + await nextTick() + + expect(root.querySelector('.animate-dot-flashing')).not.toBeNull() + finishEnable(true) + await settle() + expect(root.querySelector('.animate-dot-flashing')).toBeNull() + }) }) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue index e6287a3d..057a2f5c 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -9,14 +9,24 @@ {{ $t('notification.emailChannelDisabled') }}

-
+

{{ $t('globals.terms.browserNotification', 2) }}

-

{{ browserPushDescription }}

+

+ {{ browserPushDescription }} +

+ ")), + "Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "
")), } content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data) return subject, content, err @@ -1364,7 +1366,7 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag Type: group.nType, Recipients: recipients, Title: group.title, - Body: conversation.Subject, + Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)), ConversationID: null.IntFrom(conversation.ID), MessageID: null.IntFrom(message.ID), ConversationUUID: conversation.UUID, @@ -1422,7 +1424,7 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message, if err != nil { m.lo.Error("error fetching root URL for mention notification", "error", err) } - messageContent := sanitizeNotificationHTML(message.Content, rootURL) + messageContent := htmltemplate.HTML(absolutizeConversationReferenceLinks(message.Content, rootURL)) recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) { data := notificationTemplateData(conversation, recipient, umodels.User{}) @@ -2394,3 +2396,11 @@ func listTypeWhereClause(conditions []string) string { } return "AND (" + strings.Join(conditions, " OR ") + ")" } + +func replyNotificationText(message models.Message) string { + full := strings.TrimSpace(message.TextContent) + if message.ContentType == models.ContentTypeHTML { + return cmp.Or(stringutil.HTML2TextNoQuotes(message.Content), full) + } + return cmp.Or(stringutil.TrimPlainTextQuotes(full), full) +} diff --git a/internal/conversation/conversation_reference.go b/internal/conversation/conversation_reference.go index b76f58fe..b6f540ff 100644 --- a/internal/conversation/conversation_reference.go +++ b/internal/conversation/conversation_reference.go @@ -1,14 +1,9 @@ package conversation import ( - htmltemplate "html/template" "strings" - - "github.com/microcosm-cc/bluemonday" ) -var notificationHTMLPolicy = bluemonday.UGCPolicy() - func absolutizeConversationReferenceLinks(content, rootURL string) string { if rootURL == "" { return content @@ -19,8 +14,3 @@ func absolutizeConversationReferenceLinks(content, rootURL string) string { `href="`+strings.TrimRight(rootURL, "/")+`/inboxes/all/conversation/`, ) } - -func sanitizeNotificationHTML(content, rootURL string) htmltemplate.HTML { - content = notificationHTMLPolicy.Sanitize(content) - return htmltemplate.HTML(absolutizeConversationReferenceLinks(content, rootURL)) -} diff --git a/internal/conversation/conversation_reference_test.go b/internal/conversation/conversation_reference_test.go index 24a390f3..954b6c91 100644 --- a/internal/conversation/conversation_reference_test.go +++ b/internal/conversation/conversation_reference_test.go @@ -1,9 +1,6 @@ package conversation -import ( - "strings" - "testing" -) +import "testing" func TestAbsolutizeConversationReferenceLinks(t *testing.T) { content := `

#108 Help #109

` @@ -13,19 +10,3 @@ func TestAbsolutizeConversationReferenceLinks(t *testing.T) { t.Fatalf("got %q, want %q", got, want) } } - -func TestSanitizeNotificationHTML(t *testing.T) { - content := `

Allowed#108

` - got := string(sanitizeNotificationHTML(content, "https://desk.example.com")) - - for _, unsafe := range []string{"onerror", "Allowed", `href="https://desk.example.com/inboxes/all/conversation/abc"`} { - if !strings.Contains(got, safe) { - t.Fatalf("safe content %q missing from %q", safe, got) - } - } -} diff --git a/internal/conversation/notification_test.go b/internal/conversation/notification_test.go index 76f0252e..f389b3a5 100644 --- a/internal/conversation/notification_test.go +++ b/internal/conversation/notification_test.go @@ -4,6 +4,7 @@ import ( "errors" htmltemplate "html/template" "slices" + "strings" "sync" "testing" "time" @@ -222,6 +223,44 @@ func TestReplyNotificationsAlertForEveryMessage(t *testing.T) { } }) + t.Run("reply notifications remove quoted text", func(t *testing.T) { + m := newManager(nmodels.NotificationChannelInApp, nmodels.NotificationChannelEmail) + replyConversation := conv + replyConversation.Subject = null.StringFrom("Conversation subject") + reply := message + reply.ContentType = models.ContentTypeHTML + reply.Content = `
Live chat reply
Earlier message
` + reply.TextContent = "Live chat reply\nEarlier message" + m.NotifyNewReply(replyConversation, reply, false) + + var body string + db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`) + if body != "Live chat reply" { + t.Fatalf("notification body = %q, want fresh reply", body) + } + + var emailContent string + db.Get(&emailContent, `SELECT content FROM notification_email_queue LIMIT 1`) + if !strings.Contains(emailContent, "Live chat reply") || strings.Contains(emailContent, "Earlier message") { + t.Fatalf("queued email contains quoted history: %q", emailContent) + } + }) + + t.Run("assignment body falls back to last message", func(t *testing.T) { + m := newManager(nmodels.NotificationChannelInApp) + assigned := conv + assigned.LastMessage = null.StringFrom("Live chat opening message") + if err := m.NotifyAssignment([]int{userID}, assigned); err != nil { + t.Fatal(err) + } + + var body string + db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`) + if body != assigned.LastMessage.String { + t.Fatalf("notification body = %q, want %q", body, assigned.LastMessage.String) + } + }) + t.Run("replies arriving together alert individually", func(t *testing.T) { m := newManager(nmodels.NotificationChannelInApp) var wg sync.WaitGroup diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go index 5dcabb68..406df15b 100644 --- a/internal/migrations/v2.9.0.go +++ b/internal/migrations/v2.9.0.go @@ -133,7 +133,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }}
-
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -160,7 +162,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }}

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

View Conversation @@ -187,7 +191,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }} -

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

View Conversation diff --git a/schema.sql b/schema.sql index 5e090136..fc510c63 100644 --- a/schema.sql +++ b/schema.sql @@ -1109,7 +1109,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

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

View Conversation @@ -1132,7 +1134,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

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

View Conversation @@ -1155,7 +1159,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

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

View Conversation