-
+
-
{{ prompt.title }}
@@ -331,6 +337,7 @@
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", "