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.
This commit is contained in:
Abhinav Raut
2026-09-16 01:54:35 +05:30
parent ef53a48106
commit 43c007ef9e
17 changed files with 284 additions and 150 deletions
+3
View File
@@ -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()
])
}
@@ -12,9 +12,9 @@
>
<EditorToolbar
:editor="editor"
:ai-prompts="aiPrompts"
show-ai
@open-link="linkDialog?.open()"
@ai-prompt="emitPrompt"
@ai-generation-change="emit('aiGenerationChange', $event)"
/>
</BubbleMenu>
<EditorContent :editor="editor" class="native-html" />
@@ -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 }) => {
@@ -1,8 +1,14 @@
<template>
<div class="flex flex-wrap gap-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenu v-if="showAi && aiPromptStore.prompts.length > 0">
<DropdownMenuTrigger as-child>
<Button type="button" size="sm" variant="ghost" class="flex items-center justify-center">
<Button
type="button"
size="sm"
variant="ghost"
class="flex items-center justify-center"
:disabled="isGenerating"
>
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
@@ -12,9 +18,9 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="prompt in aiPrompts"
v-for="prompt in aiPromptStore.prompts"
:key="prompt.key"
@select="emit('aiPrompt', prompt.key)"
@select="complete(prompt.key)"
>
{{ prompt.title }}
</DropdownMenuItem>
@@ -331,6 +337,7 @@
</template>
<script setup>
import { ref } from 'vue'
import {
ChevronDown,
Bold,
@@ -363,16 +370,20 @@ import {
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { codeBlockLanguages } from './codeLanguages'
const props = defineProps({
editor: { type: Object, default: null },
showArticleTools: { type: Boolean, default: false },
aiPrompts: { type: Array, default: () => [] },
showAi: { type: Boolean, default: false },
enableInlineImages: { type: Boolean, default: false }
})
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiPrompt'])
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiGenerationChange'])
const aiPromptStore = useAiPromptStore()
const isGenerating = ref(false)
const alignments = [
{ dir: 'left', icon: AlignLeft, label: 'editor.tooltip.alignLeft' },
@@ -398,4 +409,18 @@ const getCurrentHeadingText = () => {
}
return 'P'
}
const complete = async (promptKey) => {
if (!props.editor || isGenerating.value) return
isGenerating.value = true
emit('aiGenerationChange', true)
try {
const content = await aiPromptStore.complete(promptKey, props.editor.getHTML())
if (content !== null) props.editor.commands.setContent(content)
} finally {
isGenerating.value = false
emit('aiGenerationChange', false)
}
}
</script>
@@ -21,7 +21,9 @@ export const BUILT_IN_TEMPLATE_BODIES = {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -38,7 +40,9 @@ export const BUILT_IN_TEMPLATE_BODIES = {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -55,7 +59,9 @@ export const BUILT_IN_TEMPLATE_BODIES = {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -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 @@
<ReplyBoxContent
ref="replyBoxContentRef"
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:isDraftLoading="isDraftLoading"
:uploadingFiles="uploadingFiles"
@@ -137,7 +135,7 @@
@fileUpload="handleFileUpload"
@fileDelete="handleFileDelete"
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
@aiGenerationChange="isGenerating = $event"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@@ -158,7 +156,6 @@ import api from '@main/api'
import { useI18n } from 'vue-i18n'
import { useConversationStore } from '@main/stores/conversation'
import { useInboxStore } from '@main/stores/inbox'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { useNotificationStore } from '@main/stores/notification'
import {
AlertDialog,
@@ -254,8 +251,6 @@ const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPromptStore = useAiPromptStore()
const aiPrompts = computed(() => 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 })
@@ -7,18 +7,18 @@
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="rounded-lg border bg-muted/50 p-0.5">
<TabsList>
<TabsTrigger
v-if="canSendReply"
value="reply"
:class="TAB_TRIGGER_CLASS"
class="max-md:py-2.5"
>
{{ $t('globals.terms.reply') }}
</TabsTrigger>
<TabsTrigger
v-if="canSendPrivateNote"
value="private_note"
:class="TAB_TRIGGER_CLASS"
class="max-md:py-2.5"
>
{{ $t('globals.terms.privateNote') }}
</TabsTrigger>
@@ -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,
+54 -27
View File
@@ -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
}
})
@@ -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()
})
})
@@ -9,14 +9,24 @@
{{ $t('notification.emailChannelDisabled') }}
</p>
<div class="flex items-center justify-between gap-4 border border-border rounded-md px-4 py-3">
<div
class="flex items-center justify-between gap-4 border border-border rounded-md px-4 py-3"
>
<div class="space-y-1">
<p class="text-sm text-foreground">
{{ $t('globals.terms.browserNotification', 2) }}
</p>
<p class="text-xs text-muted-foreground">{{ browserPushDescription }}</p>
<p v-if="browserPushDescription" class="text-xs text-muted-foreground">
{{ browserPushDescription }}
</p>
</div>
<DotLoader
v-if="isUpdatingBrowserPush"
role="status"
:aria-label="$t('globals.terms.loading')"
/>
<Switch
v-else
:checked="pushNotifications.enabled.value"
:disabled="
!pushNotifications.supported.value ||
@@ -66,6 +76,7 @@
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Switch } from '@shared-ui/components/ui/switch'
import { DotLoader } from '@shared-ui/components/ui/loader'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
@@ -78,12 +89,13 @@ const rows = ref([])
const emailEnabled = ref(true)
const vapidPublicKey = ref('')
const pendingUpdates = ref(new Set())
const isUpdatingBrowserPush = ref(false)
const pushNotifications = usePushNotifications()
const channels = [
{ key: 'in_app', labelKey: 'globals.terms.inApp' },
{ key: 'email', labelKey: 'globals.terms.email' },
{ key: 'push', labelKey: 'globals.terms.push' }
{ key: 'push', labelKey: 'globals.terms.browser' }
]
const typeLabels = {
@@ -126,11 +138,14 @@ const browserPushDescription = computed(() => {
if (!pushNotifications.supported.value) return t('notification.browserPush.unsupported')
if (pushNotifications.permission.value === 'denied') return t('notification.browserPush.blocked')
if (!vapidPublicKey.value) return t('notification.browserPush.unavailable')
return t('notification.browserPush.description')
return ''
})
const updateBrowserPush = async (value) => {
if (isUpdatingBrowserPush.value) return
try {
isUpdatingBrowserPush.value = true
if (value) {
const granted = await pushNotifications.enable(vapidPublicKey.value)
if (!granted) {
@@ -147,6 +162,8 @@ const updateBrowserPush = async (value) => {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isUpdatingBrowserPush.value = false
}
}
@@ -37,6 +37,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import AdminSplitLayout from '@/layouts/admin/AdminSplitLayout.vue'
import LoadingOverlay from '@main/components/layout/LoadingOverlay.vue'
import DataTable from '@main/components/datatable/DataTable.vue'
@@ -55,18 +56,20 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useAiPromptStore } from '@main/stores/aiPrompt'
const { t } = useI18n()
const emitter = useEmitter()
const aiPromptStore = useAiPromptStore()
const { prompts } = storeToRefs(aiPromptStore)
const isLoading = ref(false)
const prompts = ref([])
const dialogOpen = ref(false)
const isEditing = ref(false)
const initialValues = ref({})
const editingId = ref(null)
const refreshHandler = (data) => {
if (data?.model === 'ai_editor_prompts') getPrompts()
if (data?.model === 'ai_editor_prompts') refreshPrompts()
}
const editHandler = (data) => {
@@ -74,7 +77,7 @@ const editHandler = (data) => {
}
onMounted(() => {
getPrompts()
loadPrompts()
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshHandler)
emitter.on(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
@@ -84,19 +87,16 @@ onUnmounted(() => {
emitter.off(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
const getPrompts = async () => {
try {
isLoading.value = true
const resp = await api.getAiPrompts()
prompts.value = resp.data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
const loadPrompts = async () => {
isLoading.value = true
await aiPromptStore.fetchPrompts()
isLoading.value = false
}
const refreshPrompts = async () => {
isLoading.value = true
await aiPromptStore.refreshPrompts()
isLoading.value = false
}
const newPrompt = () => {
@@ -128,7 +128,7 @@ const submitPrompt = async (values) => {
await api.createAIPrompt(values)
}
dialogOpen.value = false
getPrompts()
await refreshPrompts()
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
+1 -2
View File
@@ -972,6 +972,7 @@
"globals.terms.body": "Body",
"globals.terms.brandName": "Brand name",
"globals.terms.breadcrumb": "Breadcrumb",
"globals.terms.browser": "Browser",
"globals.terms.browserNotification": "Browser notification | Browser notifications",
"globals.terms.businessHour": "Business hour | Business hours",
"globals.terms.callbackURL": "Callback URL",
@@ -1112,7 +1113,6 @@
"globals.terms.provider": "Provider | Providers",
"globals.terms.providerURL": "Provider URL",
"globals.terms.published": "Published",
"globals.terms.push": "Push",
"globals.terms.read": "Read",
"globals.terms.reconnect": "Reconnect",
"globals.terms.referenceNumber": "Reference number",
@@ -1379,7 +1379,6 @@
"navigation.logout": "Logout",
"navigation.reassignReplies": "Reassign replies",
"notification.browserPush.blocked": "Browser notifications are blocked. Allow them in your browser settings to receive notifications on this device.",
"notification.browserPush.description": "Receive notifications on this device when libredesk is closed.",
"notification.browserPush.unavailable": "Browser notifications are unavailable because push is not configured. Ask an administrator to configure push notifications.",
"notification.browserPush.unsupported": "This browser does not support push notifications. Use a supported browser to receive notifications when libredesk is closed.",
"notification.channelToggleLabel": "{channel} notifications for {type}",
+14 -4
View File
@@ -2,6 +2,7 @@
package conversation
import (
"cmp"
"context"
"database/sql"
"embed"
@@ -1249,7 +1250,7 @@ func (m *Manager) NotifyAssignment(userIDs []int, conversation models.Conversati
},
}},
Title: m.i18n.Ts("notification.conversationAssigned", "referenceNumber", conversation.ReferenceNumber),
Body: conversation.Subject,
Body: null.StringFrom(cmp.Or(conversation.Subject.String, conversation.LastMessage.String)),
ConversationID: null.IntFrom(conversation.ID),
ConversationUUID: conversation.UUID,
}); err != nil {
@@ -1340,6 +1341,7 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag
if len(group.recipients) == 0 {
return
}
messageText := replyNotificationText(message)
candidateIDs := make([]int, len(group.recipients))
for i, recipient := range group.recipients {
candidateIDs[i] = recipient.ID
@@ -1349,7 +1351,7 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag
data := notificationTemplateData(conversation, recipient, author)
data["Message"] = map[string]any{
"UUID": message.UUID,
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(message.TextContent), "\n", "<br>")),
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "<br>")),
}
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)
}
@@ -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))
}
@@ -1,9 +1,6 @@
package conversation
import (
"strings"
"testing"
)
import "testing"
func TestAbsolutizeConversationReferenceLinks(t *testing.T) {
content := `<p><a href="/inboxes/all/conversation/abc" class="ld-conversation-reference">#108</a> <a href="/help">Help</a> <a href="https://old.example.com/inboxes/all/conversation/def">#109</a></p>`
@@ -13,19 +10,3 @@ func TestAbsolutizeConversationReferenceLinks(t *testing.T) {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSanitizeNotificationHTML(t *testing.T) {
content := `<p><strong>Allowed</strong><img src="x" onerror="alert(1)"><script>alert(2)</script><a href="/inboxes/all/conversation/abc">#108</a></p>`
got := string(sanitizeNotificationHTML(content, "https://desk.example.com"))
for _, unsafe := range []string{"onerror", "<script", "alert(1)", "alert(2)"} {
if strings.Contains(got, unsafe) {
t.Fatalf("unsafe content %q remained in %q", unsafe, got)
}
}
for _, safe := range []string{"<strong>Allowed</strong>", `href="https://desk.example.com/inboxes/all/conversation/abc"`} {
if !strings.Contains(got, safe) {
t.Fatalf("safe content %q missing from %q", safe, got)
}
}
}
@@ -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 = `<div>Live chat reply</div><div class="gmail_quote gmail_quote_container"><blockquote>Earlier message</blockquote></div>`
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
+9 -3
View File
@@ -133,7 +133,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -160,7 +162,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -187,7 +191,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
+9 -3
View File
@@ -1109,7 +1109,9 @@ VALUES('email_notification'::template_type, '
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -1132,7 +1134,9 @@ VALUES('email_notification'::template_type, '
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
@@ -1155,7 +1159,9 @@ VALUES('email_notification'::template_type, '
Subject: {{ .Conversation.Subject }}
</div>
<blockquote>{{ .Message.Content }}</blockquote>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>