mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 22:09:36 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
+66
-16
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user