Skip to content

Commit

Permalink
Implement SSE notifications (#9451)
Browse files Browse the repository at this point in the history
* Implement PoC Polyfill
  • Loading branch information
lookacat authored and Jan committed Aug 15, 2023
1 parent 48c078e commit 376f039
Show file tree
Hide file tree
Showing 9 changed files with 135 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/web-pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from './store'
export * from './fileListHeaderPosition'
export * from './viewMode'
export * from './search'
export * from './sse'
1 change: 1 addition & 0 deletions packages/web-pkg/src/composables/sse/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useServerSentEvents'
64 changes: 64 additions & 0 deletions packages/web-pkg/src/composables/sse/useServerSentEvents.ts
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 35 additions & 5 deletions packages/web-runtime/src/components/Topbar/Notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
}
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -34,13 +36,15 @@ describe('Notification component', () => {
it('renders a set of notifications', async () => {
const notifications = [mock<Notification>({ 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)
})
it('renders the loading state', async () => {
const notifications = [mock<Notification>({ messageRich: undefined })]
const { wrapper } = getWrapper({ notifications })
await wrapper.vm.fetchNotificationsTask.perform()
await wrapper.vm.fetchNotificationsTask.last
wrapper.vm.loading = true
await wrapper.vm.$nextTick()
Expand All @@ -49,10 +53,11 @@ describe('Notification component', () => {
it('marks all notifications as read', async () => {
const notifications = [mock<Notification>({ 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 () => {
Expand All @@ -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<any>(selectors.avatarImageStub)
expect(avatarImageStub.attributes('userid')).toEqual(notification.user)
Expand All @@ -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<any>(selectors.avatarImageStub)
expect(avatarImageStub.attributes('userid')).toEqual(name)
Expand All @@ -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()
})
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
})
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -187,6 +200,7 @@ describe('Notification component', () => {
actions: [mock<NotificationAction>()]
})
const { wrapper } = getWrapper({ notifications: [notification] })
await wrapper.vm.fetchNotificationsTask.perform()
await wrapper.vm.fetchNotificationsTask.last
expect(wrapper.find(selectors.notificationActions).exists()).toBeTruthy()
})
Expand All @@ -197,6 +211,7 @@ describe('Notification component', () => {
actions: [mock<NotificationAction>({ 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 = {
Expand Down
9 changes: 8 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 376f039

Please sign in to comment.