Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 80 additions & 65 deletions src/NotificationsApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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,
}
},

Expand All @@ -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.
Expand Down Expand Up @@ -230,7 +235,7 @@ export default {
return this.backgroundFetching
&& this.webNotificationsGranted
&& this.userStatus !== 'dnd'
&& this.tabId === this.lastTabId
&& this.isLeader
},

emptyContentMessage() {
Expand Down Expand Up @@ -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()
})
Expand All @@ -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)
Expand All @@ -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() {
Expand All @@ -355,7 +364,6 @@ export default {
})
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could steal the leadership

await setCurrentTabAsActive(this.tabId)
await this._fetch()
},

Expand All @@ -365,12 +373,18 @@ export default {
},

handleNetworkOnline() {
this._fetch()
if (this.isLeader) {
this._fetch()

@Antreesy Antreesy Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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)
Expand All @@ -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'))
Expand All @@ -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()
},

/**
Expand Down Expand Up @@ -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.')
Expand Down
75 changes: 75 additions & 0 deletions src/composables/useTabSync.js
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

@Antreesy Antreesy Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • should generate a console.debug
  • should handle the stealing, downgrade leadership and request again
  • should broadcast polling interval between all windows

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,
}
}
6 changes: 6 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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')
Loading
Loading