better session handling

- separated from component
- handle visibilityState / closing of the tab

Signed-off-by: chandi Langecker <git@chandi.it>
This commit is contained in:
chandi Langecker
2022-09-05 11:53:13 +02:00
parent 6bfb54e2b3
commit 38aed97d69
7 changed files with 143 additions and 98 deletions

View File

@@ -80,6 +80,7 @@ class SessionController extends ApiController {
/** /**
* delete a session if existing * delete a session if existing
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired
* @param $boardId * @param $boardId
* @return bool * @return bool
*/ */

View File

@@ -227,7 +227,7 @@ import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue'
import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue' import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue' import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
import SessionList from './SessionList' import SessionList from './SessionList'
import { isNotifyPushEnabled } from '../listeners' import { isNotifyPushEnabled } from '../sessions'
export default { export default {
name: 'Controls', name: 'Controls',

View File

@@ -81,8 +81,7 @@ import Stack from './Stack.vue'
import { NcEmptyContent } from '@nextcloud/vue' import { NcEmptyContent } from '@nextcloud/vue'
import GlobalSearchResults from '../search/GlobalSearchResults.vue' import GlobalSearchResults from '../search/GlobalSearchResults.vue'
import { showError } from '../../helpers/errors.js' import { showError } from '../../helpers/errors.js'
import { sessionApi } from '../../services/SessionApi' import { createSession } from '../../sessions.js'
import { isNotifyPushEnabled } from '../../listeners'
export default { export default {
name: 'Board', name: 'Board',
@@ -131,13 +130,11 @@ export default {
}, },
watch: { watch: {
id(newValue, oldValue) { id(newValue, oldValue) {
if (oldValue) { if (this.session) {
// close old session // close old session
sessionApi.closeSession(oldValue, this.token) this.session.close()
this.token = null
} }
// create new session this.session = createSession(newValue)
this.ensureSession(newValue)
this.fetchData() this.fetchData()
}, },
@@ -146,35 +143,11 @@ export default {
}, },
}, },
created() { created() {
if (isNotifyPushEnabled()) { this.session = createSession(this.id)
// create a session
this.ensureSession()
}
this.fetchData() this.fetchData()
if (isNotifyPushEnabled()) {
// regularly let the server know that we are still here
this.sessionInterval = setInterval(() => {
this.ensureSession()
}, 25 * 1000)
// we don't get events pushed for sessions that have expired,
// so we poll the list of sessions every minute when there
// are other sessions active
this.refreshInterval = setInterval(() => {
if (this.board?.activeSessions?.length) {
this.refreshData()
}
}, 60 * 1000)
}
}, },
beforeDestroy() { beforeDestroy() {
if (isNotifyPushEnabled()) { this.session.close()
sessionApi.closeSession(this.id, this.token)
clearInterval(this.sessionInterval)
clearInterval(this.refreshInterval)
}
}, },
methods: { methods: {
async fetchData() { async fetchData() {
@@ -189,28 +162,6 @@ export default {
this.loading = false this.loading = false
}, },
async ensureSession(boardId = this.id) {
if (this.token) {
try {
await sessionApi.syncSession(boardId, this.token)
} catch (err) {
// session probably expired, let's try again
// with a fresh session
this.token = null
setTimeout(() => {
this.ensureSession()
}, 100)
}
} else {
try {
const res = await sessionApi.createSession(boardId)
this.token = res.token
} catch (err) {
showError(err)
}
}
},
async refreshData() { async refreshData() {
await this.$store.dispatch('refreshBoard', this.id) await this.$store.dispatch('refreshBoard', this.id)
}, },

View File

@@ -1,41 +0,0 @@
/*
* @copyright Copyright (c) 2022, chandi Langecker (git@chandi.it)
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { listen } from '@nextcloud/notify_push'
import store from './store/main'
let hasPush = false
/**
* is the notify_push app active and can
* provide us with real time updates?
*/
export function isNotifyPushEnabled() {
return hasPush
}
hasPush = listen('DeckBoardUpdate', (name, body) => {
const currentBoardId = store.state.currentBoard?.id
// only handle update event for the currently open board
if (body.id !== currentBoardId) return
store.dispatch('refreshBoard', currentBoardId)
})

View File

@@ -31,7 +31,7 @@ import { subscribe } from '@nextcloud/event-bus'
import { Tooltip } from '@nextcloud/vue' import { Tooltip } from '@nextcloud/vue'
import ClickOutside from 'vue-click-outside' import ClickOutside from 'vue-click-outside'
import './models/index.js' import './models/index.js'
import './listeners' import './sessions.js'
// the server snap.js conflicts with vertical scrolling so we disable it // the server snap.js conflicts with vertical scrolling so we disable it
document.body.setAttribute('data-snap-ignore', 'true') document.body.setAttribute('data-snap-ignore', 'true')

View File

@@ -40,6 +40,18 @@ export class SessionApi {
return await axios.post(this.url('/session/close'), { boardId, token }) return await axios.post(this.url('/session/close'), { boardId, token })
} }
async closeSessionViaBeacon(boardId, token) {
const body = {
boardId,
token,
}
const headers = {
type: 'application/json',
}
const blob = new Blob([JSON.stringify(body)], headers)
navigator.sendBeacon(this.url('/session/close'), blob)
}
} }
export const sessionApi = new SessionApi() export const sessionApi = new SessionApi()

122
src/sessions.js Normal file
View File

@@ -0,0 +1,122 @@
/*
* @copyright Copyright (c) 2022, chandi Langecker (git@chandi.it)
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { listen } from '@nextcloud/notify_push'
import { sessionApi } from './services/SessionApi'
import store from './store/main'
const SESSION_INTERVAL = 25 // in seconds
let hasPush = false
hasPush = listen('deck_board_update', (name, body) => {
triggerDeckReload(body.id)
})
/**
* is the notify_push app active and can
* provide us with real time updates?
*/
export function isNotifyPushEnabled() {
return hasPush
}
/**
* Triggers a reload of the deck, if the provided id
* matches the current open deck
*
* @param triggeredBoardId
*/
export function triggerDeckReload(triggeredBoardId) {
const currentBoardId = store.state.currentBoard?.id
// only handle update events for the currently open board
if (triggeredBoardId !== currentBoardId) return
store.dispatch('refreshBoard', currentBoardId)
}
/**
*
* @param boardId
*/
export function createSession(boardId) {
if (!isNotifyPushEnabled()) {
// return a dummy object
return {
async close() {},
}
}
// let's try to make createSession() synchronous, so that
// the component doesn't need to bother about the asynchronousness
let tokenPromise
let token
const create = () => {
tokenPromise = sessionApi.createSession(boardId).then(res => res.token)
tokenPromise.then((t) => {
token = t
})
}
create()
const ensureSession = async () => {
if (!tokenPromise) {
create()
return
}
try {
await sessionApi.syncSession(boardId, await tokenPromise)
} catch (err) {
// session probably expired, let's
// create a fresh session
create()
}
}
// periodically notify the server that we are still here
const interval = setInterval(ensureSession, SESSION_INTERVAL * 1000)
// close session when
const visibilitychangeListener = () => {
if (document.visibilityState === 'hidden') {
sessionApi.closeSessionViaBeacon(boardId, token)
tokenPromise = null
token = null
} else {
// tab is back in focus or was restored from the bfcache
ensureSession()
// we must assume that the websocket connection was
// paused and we have missed updates in the meantime.
triggerDeckReload()
}
}
document.addEventListener('visibilitychange', visibilitychangeListener)
return {
async close() {
clearInterval(interval)
document.removeEventListener('visibilitychange', visibilitychangeListener)
await sessionApi.closeSession(boardId, await tokenPromise)
},
}
}