diff --git a/changelog/unreleased/enhancement-sse-get-notifications-instantly b/changelog/unreleased/enhancement-sse-get-notifications-instantly new file mode 100644 index 00000000000..e6c0d4039d4 --- /dev/null +++ b/changelog/unreleased/enhancement-sse-get-notifications-instantly @@ -0,0 +1,8 @@ +Enhancement: Add SSE to get notifications instantly + +We've added SSE to the notifications which allows us to be notified +about new notifications instantly and from the server without polling +every few seconds. + +https://github.com/owncloud/web/pull/9451 +https://github.com/owncloud/web/issues/9434 diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index dae274c7604..a33427230dd 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -14,6 +14,7 @@ "directory": "packages/web-pkg" }, "peerDependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@casl/ability": "^6.3.3", "@casl/vue": "^2.2.1", "axios": "^0.27.2 || ^1.0.0", diff --git a/packages/web-pkg/src/composables/capability/useCapability.ts b/packages/web-pkg/src/composables/capability/useCapability.ts index 8ad5987fa5f..8df471becdf 100644 --- a/packages/web-pkg/src/composables/capability/useCapability.ts +++ b/packages/web-pkg/src/composables/capability/useCapability.ts @@ -29,6 +29,7 @@ export const useCapabilityCoreSupportUrlSigning = createCapabilityComposable( 'core.support-url-signing', false ) +export const useCapabilityCoreSSE = createCapabilityComposable('core.support-sse', false) export const useCapabilityGraphPersonalDataExport = createCapabilityComposable( 'graph.personal-data-export', false diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index 5df6c8734cb..10f6ddd97e9 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -17,3 +17,4 @@ export * from './store' export * from './fileListHeaderPosition' export * from './viewMode' export * from './search' +export * from './sse' diff --git a/packages/web-pkg/src/composables/sse/index.ts b/packages/web-pkg/src/composables/sse/index.ts new file mode 100644 index 00000000000..f12353de45e --- /dev/null +++ b/packages/web-pkg/src/composables/sse/index.ts @@ -0,0 +1 @@ +export * from './useServerSentEvents' diff --git a/packages/web-pkg/src/composables/sse/useServerSentEvents.ts b/packages/web-pkg/src/composables/sse/useServerSentEvents.ts new file mode 100644 index 00000000000..0f64b2e5030 --- /dev/null +++ b/packages/web-pkg/src/composables/sse/useServerSentEvents.ts @@ -0,0 +1,64 @@ +import { EventSourceMessage, fetchEventSource } from '@microsoft/fetch-event-source' +import { ref, unref, watch } from 'vue' +import { v4 as uuidV4 } from 'uuid' +import { useGettext } from 'vue3-gettext' +import { configurationManager, useAccessToken, useStore } from 'web-pkg/src' + +export interface ServerSentEventsOptions { + url: string + onOpen?: (response: Response) => void + onMessage?: (msg: EventSourceMessage) => void +} + +export const useServerSentEvents = (options: ServerSentEventsOptions) => { + const store = useStore() + const language = useGettext() + const accessToken = useAccessToken({ store }) + const ctrl = ref(new AbortController()) + const retryCounter = ref(0) + + watch( + () => language.current, + () => { + unref(ctrl).abort() + } + ) + const setupServerSentEvents = () => { + if (unref(retryCounter) >= 5) { + unref(ctrl).abort() + throw new Error('Too many retries') + } + const setupSSE = async () => { + retryCounter.value++ + try { + ctrl.value = new AbortController() + await fetchEventSource(new URL(options.url, configurationManager.serverUrl).href, { + signal: unref(ctrl).signal, + headers: { + Authorization: `Bearer ${accessToken.value}`, + 'Accept-Language': unref(language).current, + 'X-Request-ID': uuidV4() + }, + async onopen(response) { + if (response.status === 401) { + unref(ctrl).abort() + return + } + retryCounter.value = 0 + await options.onOpen?.(response) + }, + onmessage(msg) { + options.onMessage?.(msg) + } + }) + } catch (e) { + console.error(e) + } + } + setupSSE().then(() => { + setupServerSentEvents() + }) + } + + return setupServerSentEvents +} diff --git a/packages/web-runtime/src/components/Topbar/Notifications.vue b/packages/web-runtime/src/components/Topbar/Notifications.vue index 4dfd6cf0d71..36415662373 100644 --- a/packages/web-runtime/src/components/Topbar/Notifications.vue +++ b/packages/web-runtime/src/components/Topbar/Notifications.vue @@ -98,7 +98,9 @@ import { formatRelativeDateFromISO, useClientService, useRoute, - useStore + useStore, + useServerSentEvents, + useCapabilityCoreSSE } from 'web-pkg' import { useGettext } from 'vue3-gettext' import { useTask } from 'vue-concurrency' @@ -121,6 +123,30 @@ export default { const notificationsInterval = ref() const dropdownOpened = ref(false) + const sseEnabled = useCapabilityCoreSSE() + let setupServerSentEvents + if (unref(sseEnabled)) { + setupServerSentEvents = useServerSentEvents({ + url: 'ocs/v2.php/apps/notifications/api/v1/notifications/sse', + onOpen: (response): void => { + fetchNotificationsTask.perform() + if (!response.ok) { + console.error(`SSE notifications couldn't be set up ${response.status}`) + } + }, + onMessage: (msg): void => { + if (msg.event === 'FatalError') { + console.error(`SSE notifications error: ${msg.data}`) + return + } + const data = JSON.parse(msg.data) + if (data.notification_id) { + notifications.value = [data, ...unref(notifications)] + } + } + }) + } + const formatDate = (date) => { return formatDateFromISO(date, currentLanguage) } @@ -291,11 +317,15 @@ export default { setAdditionalData() } - onMounted(() => { - fetchNotificationsTask.perform() - notificationsInterval.value = setInterval(() => { + onMounted(async () => { + if (unref(sseEnabled)) { + await setupServerSentEvents() + } else { + notificationsInterval.value = setInterval(() => { + fetchNotificationsTask.perform() + }, POLLING_INTERVAL) fetchNotificationsTask.perform() - }, POLLING_INTERVAL) + } }) onUnmounted(() => { diff --git a/packages/web-runtime/tests/unit/components/Topbar/Notifications.spec.ts b/packages/web-runtime/tests/unit/components/Topbar/Notifications.spec.ts index 07789b22b2c..b20baf70fc9 100644 --- a/packages/web-runtime/tests/unit/components/Topbar/Notifications.spec.ts +++ b/packages/web-runtime/tests/unit/components/Topbar/Notifications.spec.ts @@ -24,6 +24,8 @@ const selectors = { notificationActions: '.oc-notifications-actions' } +jest.mock('web-pkg/src/composables/sse/useServerSentEvents') + describe('Notification component', () => { it('renders the notification bell and no notifications if there are none', () => { const { wrapper } = getWrapper() @@ -34,6 +36,7 @@ describe('Notification component', () => { it('renders a set of notifications', async () => { const notifications = [mock({ messageRich: undefined })] const { wrapper } = getWrapper({ notifications }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last expect(wrapper.find(selectors.noNewNotifications).exists()).toBeFalsy() expect(wrapper.findAll(selectors.notificationItem).length).toBe(notifications.length) @@ -41,6 +44,7 @@ describe('Notification component', () => { it('renders the loading state', async () => { const notifications = [mock({ messageRich: undefined })] const { wrapper } = getWrapper({ notifications }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last wrapper.vm.loading = true await wrapper.vm.$nextTick() @@ -49,10 +53,11 @@ describe('Notification component', () => { it('marks all notifications as read', async () => { const notifications = [mock({ messageRich: undefined })] const { wrapper, mocks } = getWrapper({ notifications }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last await wrapper.find(selectors.markAll).trigger('click') expect(wrapper.find(selectors.notificationItem).exists()).toBeFalsy() - expect(mocks.$clientService.owncloudSdk.requests.ocs).toHaveBeenCalledTimes(2) + expect(mocks.$clientService.owncloudSdk.requests.ocs).toHaveBeenCalledTimes(3) }) describe('avatar', () => { it('loads based on the username', async () => { @@ -61,6 +66,7 @@ describe('Notification component', () => { user: 'einstein' }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last const avatarImageStub = wrapper.findComponent(selectors.avatarImageStub) expect(avatarImageStub.attributes('userid')).toEqual(notification.user) @@ -74,6 +80,7 @@ describe('Notification component', () => { messageRichParameters: { user: { displayname, name } } }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last const avatarImageStub = wrapper.findComponent(selectors.avatarImageStub) expect(avatarImageStub.attributes('userid')).toEqual(name) @@ -87,6 +94,7 @@ describe('Notification component', () => { message: undefined }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last expect(wrapper.find(selectors.notificationSubject).exists()).toBeTruthy() }) @@ -98,6 +106,7 @@ describe('Notification component', () => { message: 'some message' }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last wrapper.vm.showDrop() await wrapper.vm.$nextTick() @@ -112,6 +121,7 @@ describe('Notification component', () => { } }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last wrapper.vm.showDrop() await wrapper.vm.$nextTick() @@ -127,6 +137,7 @@ describe('Notification component', () => { link: 'http://some-link.com' }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last expect(wrapper.find(selectors.notificationLink).exists()).toBeTruthy() }) @@ -142,6 +153,7 @@ describe('Notification component', () => { } }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last wrapper.vm.showDrop() await wrapper.vm.$nextTick() @@ -168,6 +180,7 @@ describe('Notification component', () => { } }) const { wrapper } = getWrapper({ notifications: [notification], spaces: [spaceMock] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last wrapper.vm.showDrop() await wrapper.vm.$nextTick() @@ -187,6 +200,7 @@ describe('Notification component', () => { actions: [mock()] }) const { wrapper } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last expect(wrapper.find(selectors.notificationActions).exists()).toBeTruthy() }) @@ -197,6 +211,7 @@ describe('Notification component', () => { actions: [mock({ link: 'http://some-link.com' })] }) const { wrapper, mocks } = getWrapper({ notifications: [notification] }) + await wrapper.vm.fetchNotificationsTask.perform() await wrapper.vm.fetchNotificationsTask.last expect(wrapper.find(selectors.notificationItem).exists()).toBeTruthy() const jsonResponse = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3f425e96f3..f4dc3bec52b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true @@ -907,6 +907,9 @@ importers: '@casl/vue': specifier: ^2.2.1 version: 2.2.1(@casl/ability@6.3.3)(vue@3.3.4) + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 axios: specifier: ^0.27.2 || ^1.0.0 version: 1.4.0 @@ -5845,6 +5848,10 @@ packages: '@lezer/common': 1.0.3 dev: true + /@microsoft/fetch-event-source@2.0.1: + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + dev: false + /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} dependencies: