Просмотр исходного кода

Follow up 84c3d62f - Add browser notifications and sound to the online notifications

Benjamin Scharf 1 месяц назад
Родитель
Сommit
7f5fac4eb0

+ 71 - 1
app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification.vue

@@ -1,9 +1,17 @@
 <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
 <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
 
 
 <script setup lang="ts">
 <script setup lang="ts">
+import { useWebNotification, whenever } from '@vueuse/core'
+import { onMounted, watch, ref } from 'vue'
+
 import CommonPopover from '#shared/components/CommonPopover/CommonPopover.vue'
 import CommonPopover from '#shared/components/CommonPopover/CommonPopover.vue'
 import { usePopover } from '#shared/components/CommonPopover/usePopover.ts'
 import { usePopover } from '#shared/components/CommonPopover/usePopover.ts'
+import { useActivityMessage } from '#shared/composables/activity-message/useActivityMessage.ts'
+import { useBrowserNotifications } from '#shared/composables/useBrowserNotifications.ts'
+import { useOnlineNotificationSound } from '#shared/composables/useOnlineNotification/useOnlineNotificationSound.ts'
 import { useOnlineNotificationCount } from '#shared/entities/online-notification/composables/useOnlineNotificationCount.ts'
 import { useOnlineNotificationCount } from '#shared/entities/online-notification/composables/useOnlineNotificationCount.ts'
+import { useOnlineNotificationList } from '#shared/entities/online-notification/composables/useOnlineNotificationList.ts'
+import { cleanupMarkup } from '#shared/utils/markup.ts'
 
 
 import NotificationButton from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationButton.vue'
 import NotificationButton from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationButton.vue'
 import NotificationPopover from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover.vue'
 import NotificationPopover from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover.vue'
@@ -11,6 +19,61 @@ import NotificationPopover from '#desktop/components/layout/LayoutSidebar/LeftSi
 const { unseenCount } = useOnlineNotificationCount()
 const { unseenCount } = useOnlineNotificationCount()
 
 
 const { popover, popoverTarget, toggle, close } = usePopover()
 const { popover, popoverTarget, toggle, close } = usePopover()
+
+const { play, isEnabled } = useOnlineNotificationSound()
+
+const { notificationPermission, isGranted, requestNotification } =
+  useBrowserNotifications()
+
+const { show, isSupported } = useWebNotification()
+
+const {
+  notificationList,
+  loading: isLoading,
+  hasUnseenNotification,
+  refetch,
+} = useOnlineNotificationList()
+
+const watcher = whenever(unseenCount, (newCount, oldCount) => {
+  if (!isSupported.value) return watcher.stop()
+  if (!isGranted.value && newCount > oldCount) return
+
+  const notification = notificationList.value.at(-1)
+
+  if (!notification) return
+
+  const { message } = useActivityMessage(ref(notification))
+
+  const title = cleanupMarkup(message)
+
+  show({
+    title,
+    icon: `/assets/images/logo.svg`,
+    tag: notification.id,
+    silent: true,
+  })
+})
+
+watch(
+  unseenCount,
+  (newCount, oldCount) => {
+    if (!isEnabled.value || !oldCount) return
+    if (newCount > oldCount && isGranted.value) play()
+  },
+  {
+    flush: 'post',
+  },
+)
+
+/**
+ * ⚠️ Browsers enforce user interaction before allowing media playback
+ * @chrome https://developer.chrome.com/blog/autoplay
+ * @firefox https://support.mozilla.org/en-US/kb/block-autoplay
+ */
+onMounted(() => {
+  // If notificationPermission is undefined, we never have asked for permission
+  if (isEnabled.value && !notificationPermission.value) requestNotification()
+})
 </script>
 </script>
 
 
 <template>
 <template>
@@ -23,7 +86,14 @@ const { popover, popoverTarget, toggle, close } = usePopover()
     <NotificationButton :unseen-count="unseenCount" @show="toggle(true)" />
     <NotificationButton :unseen-count="unseenCount" @show="toggle(true)" />
 
 
     <CommonPopover ref="popover" orientation="right" :owner="popoverTarget">
     <CommonPopover ref="popover" orientation="right" :owner="popoverTarget">
-      <NotificationPopover :unseen-count="unseenCount" @close="close" />
+      <NotificationPopover
+        :unseen-count="unseenCount"
+        :loading="isLoading"
+        :has-unseen-notification="hasUnseenNotification"
+        :notification-list="notificationList"
+        @refetch="refetch"
+        @close="close"
+      />
     </CommonPopover>
     </CommonPopover>
   </div>
   </div>
 </template>
 </template>

+ 16 - 17
app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover.vue

@@ -5,7 +5,6 @@ import { useTemplateRef, type Ref } from 'vue'
 
 
 import { useOnlineNotificationActions } from '#shared/entities/online-notification/composables/useOnlineNotificationActions.ts'
 import { useOnlineNotificationActions } from '#shared/entities/online-notification/composables/useOnlineNotificationActions.ts'
 import { useOnlineNotificationCount } from '#shared/entities/online-notification/composables/useOnlineNotificationCount.ts'
 import { useOnlineNotificationCount } from '#shared/entities/online-notification/composables/useOnlineNotificationCount.ts'
-import { useOnlineNotificationList } from '#shared/entities/online-notification/composables/useOnlineNotificationList.ts'
 import type { OnlineNotification } from '#shared/graphql/types.ts'
 import type { OnlineNotification } from '#shared/graphql/types.ts'
 
 
 import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
 import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
@@ -13,27 +12,27 @@ import NotificationHeader from '#desktop/components/layout/LayoutSidebar/LeftSid
 import NotificationList from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover/NotificationList.vue'
 import NotificationList from '#desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/NotificationPopover/NotificationList.vue'
 import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
 import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
 
 
-let mutationTriggered = false
+interface Props {
+  notificationList: OnlineNotification[]
+  loading: boolean
+  hasUnseenNotification: boolean
+}
 
 
-const { notificationsCountSubscription } = useOnlineNotificationCount()
+const props = defineProps<Props>()
 
 
-const {
-  notificationList,
-  loading: isLoading,
-  hasUnseenNotification,
-  refetch,
-} = useOnlineNotificationList()
+const emit = defineEmits<{
+  refetch: []
+  close: []
+}>()
 
 
+let mutationTriggered = false
+
+const { notificationsCountSubscription } = useOnlineNotificationCount()
 notificationsCountSubscription.watchOnResult(() => {
 notificationsCountSubscription.watchOnResult(() => {
-  refetch()
-  if (!mutationTriggered) refetch()
+  if (!mutationTriggered) emit('refetch')
   mutationTriggered = false
   mutationTriggered = false
 })
 })
 
 
-const emit = defineEmits<{
-  close: []
-}>()
-
 const sectionElement = useTemplateRef('section')
 const sectionElement = useTemplateRef('section')
 
 
 const { reachedTop, isScrollable } = useElementScroll(
 const { reachedTop, isScrollable } = useElementScroll(
@@ -59,7 +58,7 @@ const removeNotification = async (notification: OnlineNotification) =>
 const runMarkAllRead = async () => {
 const runMarkAllRead = async () => {
   mutationTriggered = true
   mutationTriggered = true
 
 
-  const ids = notificationList.value.map((notification) => notification.id)
+  const ids = props.notificationList.map((notification) => notification.id)
 
 
   return markAllRead(ids).then(() => {
   return markAllRead(ids).then(() => {
     mutationTriggered = false
     mutationTriggered = false
@@ -77,7 +76,7 @@ const runMarkAllRead = async () => {
       :has-unseen-notification="hasUnseenNotification"
       :has-unseen-notification="hasUnseenNotification"
       @mark-all="runMarkAllRead"
       @mark-all="runMarkAllRead"
     />
     />
-    <CommonLoader :loading="isLoading">
+    <CommonLoader :loading="loading">
       <NotificationList
       <NotificationList
         :class="{ 'ltr:pr-5 rtl:pl-5': isScrollable }"
         :class="{ 'ltr:pr-5 rtl:pl-5': isScrollable }"
         :list="notificationList"
         :list="notificationList"

+ 14 - 14
app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/OnlineNotification/__tests__/NotificationPopover.spec.ts

@@ -62,7 +62,14 @@ describe('NotificationPopover', () => {
       },
       },
     })
     })
 
 
-    const wrapper = renderComponent(NotificationPopover, { router: true })
+    const wrapper = renderComponent(NotificationPopover, {
+      router: true,
+      props: {
+        loading: false,
+        hasUnseenNotification: true,
+        notificationList: [node],
+      },
+    })
 
 
     await wrapper.events.click(
     await wrapper.events.click(
       await wrapper.findByRole('button', { name: 'mark all as read' }),
       await wrapper.findByRole('button', { name: 'mark all as read' }),
@@ -76,22 +83,15 @@ describe('NotificationPopover', () => {
   })
   })
 
 
   it('removes a notification', async () => {
   it('removes a notification', async () => {
-    mockOnlineNotificationsQuery({
-      onlineNotifications: {
-        edges: [
-          {
-            node,
-          },
-        ],
-        pageInfo: {
-          endCursor: 'Nw',
-          hasNextPage: false,
-        },
+    const wrapper = renderComponent(NotificationPopover, {
+      router: true,
+      props: {
+        loading: false,
+        hasUnseenNotification: true,
+        notificationList: [node],
       },
       },
     })
     })
 
 
-    const wrapper = renderComponent(NotificationPopover, { router: true })
-
     const list = await wrapper.findByRole('list')
     const list = await wrapper.findByRole('list')
 
 
     await wrapper.events.click(await within(list).findByRole('button'))
     await wrapper.events.click(await within(list).findByRole('button'))

+ 121 - 0
app/frontend/apps/desktop/components/layout/LayoutSidebar/LeftSidebar/LeftSidebarHeader/__tests__/OnlineNotification.spec.ts

@@ -1,6 +1,8 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
 
 import renderComponent from '#tests/support/components/renderComponent.ts'
 import renderComponent from '#tests/support/components/renderComponent.ts'
+import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
 
 
 import { getOnlineNotificationsCountSubscriptionHandler } from '#shared/entities/online-notification/graphql/subscriptions/onlineNotificationsCount.mocks.ts'
 import { getOnlineNotificationsCountSubscriptionHandler } from '#shared/entities/online-notification/graphql/subscriptions/onlineNotificationsCount.mocks.ts'
 
 
@@ -8,7 +10,47 @@ import OnlineNotification from '#desktop/components/layout/LayoutSidebar/LeftSid
 
 
 import '#tests/graphql/builders/mocks.ts'
 import '#tests/graphql/builders/mocks.ts'
 
 
+const playSoundSpy = vi.hoisted(() => vi.fn())
+
+let notificationPermission = vi.hoisted<string | undefined>(() => 'granted')
+
+vi.mock(
+  '#shared/composables/useOnlineNotification/useOnlineNotificationSound.ts',
+  () => ({
+    useOnlineNotificationSound: () => ({
+      play: playSoundSpy,
+      isEnabled: { value: true },
+    }),
+  }),
+)
+
+vi.mock('@vueuse/core', async (importOriginal) => {
+  const module = await importOriginal()
+
+  return {
+    ...(module as typeof import('@vueuse/core')),
+    usePermission: () => ({
+      value: notificationPermission,
+    }),
+  }
+})
+
 describe('OnlineNotification', () => {
 describe('OnlineNotification', () => {
+  beforeEach(() => {
+    mockUserCurrent({
+      preferences: {
+        notification_sound: {
+          enabled: true,
+          notification_sound: 'Xylo.mp3',
+        },
+      },
+    })
+
+    vi.stubGlobal('Notification', {
+      requestPermission: () => Promise.resolve('granted'),
+    })
+  })
+
   it('displays notification logo without unseen notifications', async () => {
   it('displays notification logo without unseen notifications', async () => {
     const wrapper = renderComponent(OnlineNotification, {
     const wrapper = renderComponent(OnlineNotification, {
       props: { collapsed: false },
       props: { collapsed: false },
@@ -44,4 +86,83 @@ describe('OnlineNotification', () => {
       wrapper.getByRole('status', { name: 'Unseen notifications count' }),
       wrapper.getByRole('status', { name: 'Unseen notifications count' }),
     ).toHaveTextContent('10')
     ).toHaveTextContent('10')
   })
   })
+
+  it('makes a notification sound if a new unseen message comes in', async () => {
+    renderComponent(OnlineNotification)
+
+    await getOnlineNotificationsCountSubscriptionHandler().trigger({
+      onlineNotificationsCount: {
+        unseenCount: 1,
+      },
+    })
+
+    await getOnlineNotificationsCountSubscriptionHandler().trigger({
+      onlineNotificationsCount: {
+        unseenCount: 2,
+      },
+    })
+
+    expect(playSoundSpy).toHaveBeenCalled()
+  })
+
+  it('does not play a notification sound if the sound is disabled', async () => {
+    mockUserCurrent({
+      preferences: {
+        notification_sound: {
+          enabled: false,
+          notification_sound: 'Xylo.mp3',
+        },
+      },
+    })
+
+    renderComponent(OnlineNotification)
+
+    await getOnlineNotificationsCountSubscriptionHandler().trigger({
+      onlineNotificationsCount: {
+        unseenCount: 1,
+      },
+    })
+
+    expect(playSoundSpy).not.toHaveBeenCalled()
+  })
+
+  it('asks for notification permission if session starts for the first time', async () => {
+    notificationPermission = undefined
+
+    const spy = vi.spyOn(Notification, 'requestPermission')
+
+    renderComponent(OnlineNotification)
+
+    await waitForNextTick()
+
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it('does not play a sound if the user has not granted permission', async () => {
+    notificationPermission = 'denied'
+
+    renderComponent(OnlineNotification)
+
+    await getOnlineNotificationsCountSubscriptionHandler().trigger({
+      onlineNotificationsCount: {
+        unseenCount: 1,
+      },
+    })
+
+    expect(playSoundSpy).not.toHaveBeenCalled()
+  })
+
+  it('does not play a sound if the user has a pending permission prompt', async () => {
+    notificationPermission = 'prompt'
+
+    renderComponent(OnlineNotification)
+
+    await getOnlineNotificationsCountSubscriptionHandler().trigger({
+      onlineNotificationsCount: {
+        unseenCount: 1,
+      },
+    })
+
+    expect(playSoundSpy).not.toHaveBeenCalled()
+  })
 })
 })

+ 1 - 5
app/frontend/apps/desktop/pages/ticket/__tests__/ticket-detail-view/ticket-detail-view-online-notifications.spec.ts

@@ -124,11 +124,7 @@ describe('Ticket detail: sidebar - online notifications', () => {
 
 
     const list = await view.findByRole('region', { name: 'Notifications' })
     const list = await view.findByRole('region', { name: 'Notifications' })
 
 
-    await view.events.click(
-      within(list).getByRole('link', {
-        name: 'Avatar (Admin Foo) Admin Foo updated ticket Test 2024-11-18 16:28',
-      }),
-    )
+    await view.events.click(within(list).getByRole('link'))
 
 
     const calls = await waitForOnlineNotificationSeenMutationCalls()
     const calls = await waitForOnlineNotificationSeenMutationCalls()
 
 

+ 1 - 1
app/frontend/apps/mobile/pages/organization/views/OrganizationDetailView.vue

@@ -6,7 +6,7 @@ import { computed, toRef } from 'vue'
 import CommonOrganizationAvatar from '#shared/components/CommonOrganizationAvatar/CommonOrganizationAvatar.vue'
 import CommonOrganizationAvatar from '#shared/components/CommonOrganizationAvatar/CommonOrganizationAvatar.vue'
 import type { AvatarOrganization } from '#shared/components/CommonOrganizationAvatar/index.ts'
 import type { AvatarOrganization } from '#shared/components/CommonOrganizationAvatar/index.ts'
 import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
 import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
-import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotificationSeen.ts'
+import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotification/useOnlineNotificationSeen.ts'
 import { useOrganizationDetail } from '#shared/entities/organization/composables/useOrganizationDetail.ts'
 import { useOrganizationDetail } from '#shared/entities/organization/composables/useOrganizationDetail.ts'
 import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
 import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
 
 

+ 1 - 1
app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue

@@ -22,7 +22,7 @@ import type {
 } from '#shared/components/Form/types.ts'
 } from '#shared/components/Form/types.ts'
 import { useForm } from '#shared/components/Form/useForm.ts'
 import { useForm } from '#shared/components/Form/useForm.ts'
 import { useConfirmation } from '#shared/composables/useConfirmation.ts'
 import { useConfirmation } from '#shared/composables/useConfirmation.ts'
-import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotificationSeen.ts'
+import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotification/useOnlineNotificationSeen.ts'
 import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
 import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
 import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
 import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
 import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
 import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'

+ 1 - 1
app/frontend/apps/mobile/pages/user/views/UserDetailView.vue

@@ -5,7 +5,7 @@ import { computed, ref, toRef } from 'vue'
 
 
 import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
 import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
 import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
 import ObjectAttributes from '#shared/components/ObjectAttributes/ObjectAttributes.vue'
-import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotificationSeen.ts'
+import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotification/useOnlineNotificationSeen.ts'
 import { useUserDetail } from '#shared/entities/user/composables/useUserDetail.ts'
 import { useUserDetail } from '#shared/entities/user/composables/useUserDetail.ts'
 import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
 import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
 
 

+ 1 - 2
app/frontend/shared/composables/__tests__/useOnlineNotificationSeen.spec.ts

@@ -5,10 +5,9 @@ import { ref } from 'vue'
 import { waitUntil } from '#tests/support/utils.ts'
 import { waitUntil } from '#tests/support/utils.ts'
 
 
 import { mockOnlineNotificationSeenGql } from '#shared/composables/__tests__/mocks/online-notification.ts'
 import { mockOnlineNotificationSeenGql } from '#shared/composables/__tests__/mocks/online-notification.ts'
+import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotification/useOnlineNotificationSeen.ts'
 import type { ObjectWithId } from '#shared/types/utils.ts'
 import type { ObjectWithId } from '#shared/types/utils.ts'
 
 
-import { useOnlineNotificationSeen } from '../useOnlineNotificationSeen.ts'
-
 describe('useOnlineNotificationSeen', () => {
 describe('useOnlineNotificationSeen', () => {
   it('calls mutation when object changes', async () => {
   it('calls mutation when object changes', async () => {
     const mockSeen = mockOnlineNotificationSeenGql()
     const mockSeen = mockOnlineNotificationSeenGql()

+ 21 - 0
app/frontend/shared/composables/useBrowserNotifications.ts

@@ -0,0 +1,21 @@
+// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+
+import { usePermission } from '@vueuse/core'
+import { computed } from 'vue'
+
+export const useBrowserNotifications = () => {
+  const notificationPermission = usePermission('notifications')
+
+  const isGranted = computed(() => notificationPermission.value === 'granted')
+
+  const requestNotification = async () =>
+    'requestPermission' in Notification
+      ? Notification.requestPermission()
+      : Promise.resolve()
+
+  return {
+    notificationPermission,
+    isGranted,
+    requestNotification,
+  }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов