diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index d0cf5485a..c42cb7b46 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -80,6 +80,7 @@ class SessionController extends ApiController { /** * delete a session if existing * @NoAdminRequired + * @NoCSRFRequired * @param $boardId * @return bool */ diff --git a/src/components/Controls.vue b/src/components/Controls.vue index e1f55daba..a29cc5a2e 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -227,7 +227,7 @@ import FilterOffIcon from 'vue-material-design-icons/FilterOff.vue' import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue' import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue' import SessionList from './SessionList' -import { isNotifyPushEnabled } from '../listeners' +import { isNotifyPushEnabled } from '../sessions' export default { name: 'Controls', diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 1bc019ab0..05e8be3db 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -81,8 +81,7 @@ import Stack from './Stack.vue' import { NcEmptyContent } from '@nextcloud/vue' import GlobalSearchResults from '../search/GlobalSearchResults.vue' import { showError } from '../../helpers/errors.js' -import { sessionApi } from '../../services/SessionApi' -import { isNotifyPushEnabled } from '../../listeners' +import { createSession } from '../../sessions.js' export default { name: 'Board', @@ -131,13 +130,11 @@ export default { }, watch: { id(newValue, oldValue) { - if (oldValue) { + if (this.session) { // close old session - sessionApi.closeSession(oldValue, this.token) - this.token = null + this.session.close() } - // create new session - this.ensureSession(newValue) + this.session = createSession(newValue) this.fetchData() }, @@ -146,35 +143,11 @@ export default { }, }, created() { - if (isNotifyPushEnabled()) { - // create a session - this.ensureSession() - } - + this.session = createSession(this.id) 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() { - if (isNotifyPushEnabled()) { - sessionApi.closeSession(this.id, this.token) - clearInterval(this.sessionInterval) - clearInterval(this.refreshInterval) - } + this.session.close() }, methods: { async fetchData() { @@ -189,28 +162,6 @@ export default { 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() { await this.$store.dispatch('refreshBoard', this.id) }, diff --git a/src/listeners.js b/src/listeners.js deleted file mode 100644 index 4f99ebf10..000000000 --- a/src/listeners.js +++ /dev/null @@ -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 . - * - */ - -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) -}) diff --git a/src/main.js b/src/main.js index d7857426b..8f519b11e 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ import { subscribe } from '@nextcloud/event-bus' import { Tooltip } from '@nextcloud/vue' import ClickOutside from 'vue-click-outside' import './models/index.js' -import './listeners' +import './sessions.js' // the server snap.js conflicts with vertical scrolling so we disable it document.body.setAttribute('data-snap-ignore', 'true') diff --git a/src/services/SessionApi.js b/src/services/SessionApi.js index 33c52f4e6..2bcf9d4d0 100644 --- a/src/services/SessionApi.js +++ b/src/services/SessionApi.js @@ -40,6 +40,18 @@ export class SessionApi { 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() diff --git a/src/sessions.js b/src/sessions.js new file mode 100644 index 000000000..7b86fdb47 --- /dev/null +++ b/src/sessions.js @@ -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 . + * + */ + +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) + }, + } +}