Repository navigation
refactor: sync notifications with BroadcastChannel #3552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,7 +97,7 @@ | |
| </template> | ||
|
|
||
| <script> | ||
| import { getCurrentUser, getRequestToken } from '@nextcloud/auth' | ||
| import { getCurrentUser } from '@nextcloud/auth' | ||
| import axios from '@nextcloud/axios' | ||
| import { getCapabilities } from '@nextcloud/capabilities' | ||
| import { showError } from '@nextcloud/dialogs' | ||
|
|
@@ -114,12 +114,13 @@ import IconClose from 'vue-material-design-icons/Close.vue' | |
| import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue' | ||
| import IconNotification from './Components/IconNotification.vue' | ||
| import NotificationItem from './Components/NotificationItem.vue' | ||
| import { useTabSync } from './composables/useTabSync.js' | ||
| import { | ||
| getNotificationsData, | ||
| setCurrentTabAsActive, | ||
| } from './services/notificationsService.js' | ||
| import { createWebNotification } from './services/webNotificationsService.js' | ||
| import { | ||
| listenForWebPush, | ||
| setWebPush, | ||
| } from './services/webPushService.js' | ||
|
|
||
|
|
@@ -170,11 +171,17 @@ export default { | |
| } | ||
| } | ||
|
|
||
| // Only the leader tab polls and shows browser notifications | ||
| const { isLeader, startTabSync, shareData } = useTabSync('notifications-' + getCurrentUser().uid) | ||
|
|
||
| return { | ||
| fairUsePolicyNotification, | ||
| hasThrottledPushNotifications, | ||
| itemRefs, | ||
| setItemRef, | ||
| isLeader, | ||
| startTabSync, | ||
| shareData, | ||
| } | ||
| }, | ||
|
|
||
|
|
@@ -186,9 +193,7 @@ export default { | |
| shutdown: false, | ||
| notifications: [], | ||
| lastETag: null, | ||
| lastTabId: null, | ||
| userStatus: null, | ||
| tabId: null, | ||
|
|
||
| /** | ||
| * Notifications older than this ID will not do a web notification. | ||
|
|
@@ -230,7 +235,7 @@ export default { | |
| return this.backgroundFetching | ||
| && this.webNotificationsGranted | ||
| && this.userStatus !== 'dnd' | ||
| && this.tabId === this.lastTabId | ||
| && this.isLeader | ||
| }, | ||
|
|
||
| emptyContentMessage() { | ||
|
|
@@ -266,14 +271,10 @@ export default { | |
| }, | ||
|
|
||
| mounted() { | ||
| this.tabId = getRequestToken() || ('' + Math.random()) | ||
| this._oldcount = 0 | ||
|
|
||
| this.checkWebNotificationPermissions() | ||
|
|
||
| // Initial call to the notification endpoint | ||
| this._fetch() | ||
|
|
||
| const hasPush = listen('notify_notification', () => { | ||
| this._fetchAfterNotifyPush() | ||
| }) | ||
|
|
@@ -283,22 +284,28 @@ export default { | |
| this.hasNotifyPush = true | ||
| } | ||
|
|
||
| // set the polling interval after checking web push status | ||
| // if web push may be configured | ||
| if (webPushForBrowsersEnabled && this.webNotificationsGranted === true) { | ||
| // We don't fetch on push if notify_push is enabled, to avoid concurrency fetch. | ||
| // We could do the other way: fallback to notify_push only if we don't have | ||
| // web push | ||
| if (document.readyState === 'complete') { | ||
| this._setWebPush() | ||
| } else { | ||
| window.addEventListener('load', this._setWebPush, { once: true }) | ||
| } | ||
| } else { | ||
| // Set up the background checker | ||
| this._setPollingInterval(this.pollIntervalBase) | ||
| const hasWebPush = webPushForBrowsersEnabled && this.webNotificationsGranted === true | ||
| if (hasWebPush) { | ||
| listenForWebPush(this._onWebPushActivated, this._onWebPush) | ||
| } | ||
|
|
||
| this.startTabSync(() => { | ||
| // The response sets the polling interval | ||
| this._fetch() | ||
| // The push subscription belongs to the browser, so only one tab registers it | ||
| if (hasWebPush) { | ||
| if (document.readyState === 'complete') { | ||
| this._setWebPush() | ||
| } else { | ||
| window.addEventListener('load', this._setWebPush, { once: true }) | ||
| } | ||
| } | ||
| }, (response) => { | ||
| // The first data in a follower tab is its initial load | ||
| this._processResponse(response) | ||
| this.backgroundFetching = true | ||
| }) | ||
|
|
||
| this._watchTabVisibility() | ||
| subscribe('networkOffline', this.handleNetworkOffline) | ||
| subscribe('networkOnline', this.handleNetworkOnline) | ||
|
|
@@ -321,25 +328,27 @@ export default { | |
| }, | ||
|
|
||
| async _setWebPush() { | ||
| return setWebPush( | ||
| // onActivated= | ||
| (hasWebPush) => { | ||
| if (hasWebPush) { | ||
| console.debug('Has web push, slowing polling to 15 minutes') | ||
| this.pollIntervalBase = 15 * 60 * 1000 | ||
| } | ||
| // Set polling interval for the default or new value | ||
| this._setPollingInterval(this.pollIntervalBase) | ||
| }, | ||
| // onPush= | ||
| () => { | ||
| if (!this.hasNotifyPush) { | ||
| this._fetchAfterWebPush() | ||
| } else { | ||
| console.debug('Has notify_push, no need to fetch from web push.') | ||
| } | ||
| }, | ||
| ) | ||
| return setWebPush(this._onWebPushActivated, this._onWebPush) | ||
| }, | ||
|
|
||
| _onWebPushActivated(hasWebPush) { | ||
| if (hasWebPush) { | ||
| console.debug('Has web push, slowing polling to 15 minutes') | ||
| this.pollIntervalBase = 15 * 60 * 1000 | ||
| } | ||
| // Set polling interval for the default or new value | ||
| this._setPollingInterval(this.pollIntervalBase) | ||
| }, | ||
|
|
||
| _onWebPush() { | ||
| // We don't fetch on push if notify_push is enabled, to avoid concurrency fetch. | ||
| // We could do the other way: fallback to notify_push only if we don't have | ||
| // web push | ||
| if (!this.hasNotifyPush) { | ||
| this._fetchAfterWebPush() | ||
| } else { | ||
| console.debug('Has notify_push, no need to fetch from web push.') | ||
| } | ||
| }, | ||
|
|
||
| async onOpen() { | ||
|
|
@@ -355,7 +364,6 @@ export default { | |
| }) | ||
| } | ||
|
|
||
| await setCurrentTabAsActive(this.tabId) | ||
| await this._fetch() | ||
| }, | ||
|
|
||
|
|
@@ -365,12 +373,18 @@ export default { | |
| }, | ||
|
|
||
| handleNetworkOnline() { | ||
| this._fetch() | ||
| if (this.isLeader) { | ||
| this._fetch() | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could steal the leadership |
||
| } | ||
| console.debug('Network is online, reseting pollingInterval to ' + this.pollIntervalBase) | ||
| this._setPollingInterval(this.pollIntervalBase) | ||
| }, | ||
|
|
||
| setupBackgroundFetcher() { | ||
| if (!this.isLeader) { | ||
| return | ||
| } | ||
|
|
||
| if (sessionKeepAlive) { | ||
| console.debug('Started background fetcher as session_keepalive is enabled') | ||
| this.interval = window.setInterval(this._backgroundFetch.bind(this), this.pollIntervalCurrent) | ||
|
|
@@ -387,7 +401,7 @@ export default { | |
| setTimeout(() => { | ||
| this.open = false | ||
| }, 3_000) // confetti-burst animation length + a bit of extra time | ||
| setCurrentTabAsActive(this.tabId) | ||
| this._fetch() | ||
| }) | ||
| .catch(() => { | ||
| showError(t('notifications', 'Failed to dismiss all notifications')) | ||
|
|
@@ -399,7 +413,7 @@ export default { | |
| const neighbor = this.notifications[index + 1] || this.notifications[index - 1] | ||
| this.itemRefs.get(neighbor?.notificationId)?.focus() | ||
| this.notifications.splice(index, 1) | ||
| setCurrentTabAsActive(this.tabId) | ||
| this._fetch() | ||
| }, | ||
|
|
||
| /** | ||
|
|
@@ -434,51 +448,52 @@ export default { | |
| * Performs the AJAX request to retrieve the notifications | ||
| */ | ||
| _fetchAfterNotifyPush() { | ||
| this.backgroundFetching = true | ||
| if (this.hasNotifyPush && this.tabId !== this.lastTabId) { | ||
| console.debug('Deferring notification refresh from browser storage are notify_push event to give the last tab the chance to do it') | ||
| setTimeout(() => { | ||
| this._fetch() | ||
| }, 5000) | ||
| } else { | ||
| console.debug('Refreshing notifications are notify_push event') | ||
| this._fetch() | ||
| if (!this.isLeader) { | ||
| return | ||
| } | ||
| this.backgroundFetching = true | ||
| console.debug('Refreshing notifications after notify_push event') | ||
| this._fetch() | ||
| }, | ||
|
|
||
| /** | ||
| * Performs the AJAX request to retrieve the notifications | ||
| * | ||
| * Unlike with notify_push, we don't have to check if we are the last tab before | ||
| * fetching events. The service worker sends the event to only one tab, that may | ||
| * not be the last one | ||
| * Unlike with notify_push, any tab fetches, not only the leader. | ||
| * The service worker sends the event to only one tab, that may not be the leader | ||
| */ | ||
| _fetchAfterWebPush() { | ||
| this.backgroundFetching = true | ||
| console.debug('Refreshing notifications following web push event') | ||
| this._fetch(true) | ||
| this._fetch() | ||
| }, | ||
|
|
||
| /** | ||
| * Performs the AJAX request to retrieve the notifications | ||
| * | ||
| * @param {bool} force Refresh the list of notifications right away | ||
| */ | ||
| async _fetch(force = false) { | ||
| async _fetch() { | ||
| const response = await getNotificationsData(this.lastETag) | ||
| if (response.status === 200) { | ||
| this.shareData(response) | ||
| } | ||
| this._processResponse(response) | ||
| }, | ||
|
|
||
| /** | ||
| * @param {object} response notifications data fetched by this or another tab | ||
| */ | ||
| _processResponse(response) { | ||
| if (this.notifications.length && this.notifications[0].notificationId > this.webNotificationsThresholdId) { | ||
| this.webNotificationsThresholdId = this.notifications[0].notificationId | ||
| } | ||
|
|
||
| const response = await getNotificationsData(this.tabId, this.lastETag, force || !this.backgroundFetching, this.hasNotifyPush) | ||
|
|
||
| if (response.status === 204) { | ||
| // 204 No Content - Intercept when no notifiers are there. | ||
| console.debug('Fetching notifications but no content, slowing down polling to ' + this.pollIntervalBase * 10) | ||
| this._setPollingInterval(this.pollIntervalBase * 10) | ||
| } else if (response.status === 200) { | ||
| this.userStatus = response.headers['x-nextcloud-user-status'] | ||
| this.lastETag = response.headers.etag | ||
| this.lastTabId = response.tabId | ||
| this.notifications = response.data | ||
| this.processWebNotifications(response.data) | ||
| console.debug('Got notification data, restoring default polling interval.') | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| import { ref } from 'vue' | ||
|
|
||
| /** | ||
| * Elect one leader tab and share data between tabs. | ||
| * Web Locks are only available in a secure context. Without them, every tab is its own leader. | ||
| * A new follower tab gets the last shared data from the leader on start. | ||
| * | ||
| * Messages on the BroadcastChannel: | ||
| * - 'request': sent by a new tab on start, the leader answers with the last shared data | ||
| * - any other value: data shared by a tab with shareData(), or the leader's answer to 'request' | ||
| * | ||
| * shortcut: no cleanup on unmount, the header app is never unmounted; add it when used in a component that unmounts | ||
| * | ||
| * @param {string} name lock and channel name | ||
| */ | ||
| export function useTabSync(name) { | ||
| const isLeader = ref(false) | ||
| /** @type {BroadcastChannel|null} */ | ||
| let channel = null | ||
| let lastData = null | ||
|
|
||
| /** | ||
| * @param {() => void} onLeader called once, when this tab becomes the leader | ||
| * @param {(data: object) => void} onData called with the data shared by another tab | ||
| */ | ||
| function startTabSync(onLeader, onData) { | ||
| if (!('locks' in navigator)) { | ||
| isLeader.value = true | ||
| onLeader() | ||
| return | ||
| } | ||
|
|
||
| channel = new BroadcastChannel(name) | ||
| channel.onmessage = (event) => { | ||
| if (event.data === 'request') { | ||
| if (isLeader.value && lastData) { | ||
| channel.postMessage(lastData) | ||
| } | ||
| return | ||
| } | ||
| lastData = event.data | ||
| onData(event.data) | ||
| } | ||
| navigator.locks.request(name, () => { | ||
| isLeader.value = true | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| try { | ||
| onLeader() | ||
| } catch (error) { | ||
| console.error(error) | ||
| } | ||
| // Hold the lock until the tab is closed | ||
| return new Promise(() => {}) | ||
| }) | ||
| channel.postMessage('request') | ||
| } | ||
|
|
||
| /** | ||
| * @param {object} data data to send to other tabs | ||
| */ | ||
| function shareData(data) { | ||
| lastData = data | ||
| channel?.postMessage(data) | ||
| } | ||
|
|
||
| return { | ||
| isLeader, | ||
| startTabSync, | ||
| shareData, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,10 +4,16 @@ | |
| */ | ||
|
|
||
| import { createApp, defineAsyncComponent } from 'vue' | ||
| import BrowserStorage from './services/BrowserStorage.js' | ||
|
|
||
| // Styles | ||
| import './styles/styles.scss' | ||
| import '@nextcloud/dialogs/style.css' | ||
|
|
||
| // TODO: remove in v37. Notifications were cached in the browser storage before v36 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. v39 |
||
| for (const key of ['status', 'headers', 'data', 'tabId', 'lastUpdated']) { | ||
| BrowserStorage.removeItem(key) | ||
| } | ||
|
|
||
| const NotificationsApp = defineAsyncComponent(() => import('./NotificationsApp.vue')) | ||
| export default createApp(NotificationsApp).mount('#notifications') | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could steal the leadership