From 1f05ddf0abf1dafd36c530b73cc0c2fa267fc174 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 14 Aug 2023 19:57:49 +0200 Subject: [PATCH 1/2] add actions to storing and recovering fetched conversations from BrowserStorage Signed-off-by: Maksim Sukharev --- src/components/LeftSidebar/LeftSidebar.vue | 26 ++++++--- src/store/conversationsStore.js | 45 +++++++++++++++- src/store/conversationsStore.spec.js | 62 ++++++++++++++++++++++ 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue index 966511a516c..665c12c3b27 100644 --- a/src/components/LeftSidebar/LeftSidebar.vue +++ b/src/components/LeftSidebar/LeftSidebar.vue @@ -410,6 +410,10 @@ export default { this.abortSearch() }) + // Restore last fetched conversations from browser storage, + // before updated ones come from server + this.restoreConversations() + this.fetchConversations() }, @@ -630,12 +634,14 @@ export default { }, debounceFetchConversations: debounce(function() { - if (!this.isFetchingConversations) { - this.fetchConversations() - } + this.fetchConversations() }, 3000), async fetchConversations() { + if (this.isFetchingConversations) { + return + } + this.isFetchingConversations = true if (this.forceFullRoomListRefreshAfterXLoops === 0) { this.roomListModifiedBefore = 0 @@ -668,9 +674,7 @@ export default { * Emits a global event that is used in App.vue to update the page title once the * ( if the current route is a conversation and once the conversations are received) */ - EventBus.$emit('conversations-received', { - singleConversation: false, - }) + EventBus.$emit('conversations-received', { singleConversation: false }) this.isFetchingConversations = false } catch (error) { console.debug('Error while fetching conversations: ', error) @@ -678,6 +682,16 @@ export default { } }, + async restoreConversations() { + try { + await this.$store.dispatch('restoreConversations') + this.initialisedConversations = true + EventBus.$emit('conversations-received', { singleConversation: false }) + } catch (error) { + console.debug('Error while restoring conversations: ', error) + } + }, + // Checks whether the conversations list is scrolled all the way to the top // or not handleScroll() { diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 36e037354e0..623ab0e362f 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -31,6 +31,7 @@ import { PARTICIPANT, WEBINAR, } from '../constants.js' +import BrowserStorage from '../services/BrowserStorage.js' import { makePublic, makePrivate, @@ -315,8 +316,9 @@ const actions = { * @param {object} payload the payload * @param {object[]} payload.conversations new conversations list * @param {boolean} payload.withRemoving whether to remove conversations that are not in the new list + * @param {boolean} payload.withCaching whether to cache conversations to BrowserStorage with patch */ - patchConversations(context, { conversations, withRemoving = false }) { + patchConversations(context, { conversations, withRemoving = false, withCaching = false }) { const currentConversations = context.state.conversations const newConversations = Object.fromEntries( conversations.map((conversation) => [conversation.token, conversation]) @@ -339,6 +341,45 @@ const actions = { context.dispatch('updateConversationIfHasChanged', newConversation) } } + + if (withCaching) { + context.dispatch('cacheConversations') + } + }, + + /** + * Restores conversations from BrowserStorage and add them to the store state + * + * @param {object} context default store context + */ + restoreConversations(context) { + const cachedConversations = BrowserStorage.getItem('cachedConversations') + if (!cachedConversations?.length) { + return + } + + context.dispatch('patchConversations', { + conversations: JSON.parse(cachedConversations), + withRemoving: true, + }) + + console.debug('Conversations have been restored from BrowserStorage') + }, + + /** + * Save conversations to BrowserStorage from the store state + * + * @param {object} context default store context + */ + cacheConversations(context) { + const conversations = context.getters.conversationsList + if (!conversations.length) { + return + } + + const serializedConversations = JSON.stringify(conversations) + BrowserStorage.setItem('cachedConversations', serializedConversations) + console.debug(`Conversations were saved to BrowserStorage. Estimated object size: ${(serializedConversations.length / 1024).toFixed(2)} kB`) }, /** @@ -723,6 +764,8 @@ const actions = { withRemoving: modifiedSince === 0, }) + dispatch('cacheConversations') + return response } catch (error) { if (error?.response) { diff --git a/src/store/conversationsStore.spec.js b/src/store/conversationsStore.spec.js index f76d6768165..3fe4b6fc044 100644 --- a/src/store/conversationsStore.spec.js +++ b/src/store/conversationsStore.spec.js @@ -11,6 +11,7 @@ import { PARTICIPANT, ATTENDEE, } from '../constants.js' +import BrowserStorage from '../services/BrowserStorage.js' import { makePublic, makePrivate, @@ -56,6 +57,11 @@ jest.mock('../services/conversationsService', () => ({ jest.mock('@nextcloud/event-bus') +jest.mock('../services/BrowserStorage.js', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), +})) + describe('conversationsStore', () => { const testToken = 'XXTOKENXX' const previousLastMessage = { @@ -196,6 +202,31 @@ describe('conversationsStore', () => { expect(deleteConversation).not.toHaveBeenCalled() }) + test('restores conversations cached in BrowserStorage', () => { + const testConversations = [ + { + token: 'one_token', + attendeeId: 'attendee-id-1', + lastActivity: Date.parse('2023-02-01T00:00:00.000Z') / 1000, + }, + { + token: 'another_token', + attendeeId: 'attendee-id-2', + lastActivity: Date.parse('2023-01-01T00:00:00.000Z') / 1000, + }, + ] + + BrowserStorage.getItem.mockReturnValueOnce( + '[{"token":"one_token","attendeeId":"attendee-id-1","lastActivity":1675209600},{"token":"another_token","attendeeId":"attendee-id-2","lastActivity":1672531200}]' + ) + + store.dispatch('restoreConversations') + + expect(BrowserStorage.getItem).toHaveBeenCalledWith('cachedConversations') + expect(store.getters.conversationsList).toHaveLength(2) + expect(store.getters.conversationsList).toEqual(testConversations) + }) + test('deletes conversation from server', async () => { store.dispatch('addConversation', testConversation) @@ -258,6 +289,37 @@ describe('conversationsStore', () => { expect(store.getters.conversationsList).toStrictEqual(testConversations) }) + test('sets fetched conversations to BrowserStorage', async () => { + const testConversations = [ + { + token: 'one_token', + attendeeId: 'attendee-id-1', + lastActivity: Date.parse('2023-02-01T00:00:00.000Z') / 1000, + }, + { + token: 'another_token', + attendeeId: 'attendee-id-2', + lastActivity: Date.parse('2023-01-01T00:00:00.000Z') / 1000, + }, + ] + + const response = { + data: { + ocs: { + data: testConversations, + }, + }, + } + + fetchConversations.mockResolvedValue(response) + + await store.dispatch('fetchConversations', {}) + + expect(BrowserStorage.setItem).toHaveBeenCalledWith('cachedConversations', + '[{"token":"one_token","attendeeId":"attendee-id-1","lastActivity":1675209600},{"token":"another_token","attendeeId":"attendee-id-2","lastActivity":1672531200}]' + ) + }) + test('fetches all conversations and add new received conversations', async () => { const oldConversation = { token: 'tokenOne', From 2bd8ce1464c1ce28b122e03d191cb9981a5d23e3 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 18 Aug 2023 18:52:49 +0200 Subject: [PATCH 2/2] request tab leadership to fetch conversations and update talk-hash every 30 seconds only from one source Signed-off-by: Maksim Sukharev --- .../LeftSidebar/LeftSidebar.spec.js | 11 ++++ src/components/LeftSidebar/LeftSidebar.vue | 51 ++++++++++++++--- src/services/talkBroadcastChannel.js | 31 ++++++++++ src/store/conversationsStore.js | 8 +++ src/store/participantsStore.js | 2 + src/store/talkHashStore.js | 8 +++ src/test-setup.js | 5 ++ src/utils/requestTabLeadership.js | 56 +++++++++++++++++++ 8 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 src/services/talkBroadcastChannel.js create mode 100644 src/utils/requestTabLeadership.js diff --git a/src/components/LeftSidebar/LeftSidebar.spec.js b/src/components/LeftSidebar/LeftSidebar.spec.js index 4709d23f8c6..4ca4e64861f 100644 --- a/src/components/LeftSidebar/LeftSidebar.spec.js +++ b/src/components/LeftSidebar/LeftSidebar.spec.js @@ -14,6 +14,7 @@ import { searchPossibleConversations, searchListedConversations } from '../../se import { EventBus } from '../../services/EventBus.js' import storeConfig from '../../store/storeConfig.js' import { findNcListItems, findNcActionButton } from '../../test-helpers.js' +import { requestTabLeadership } from '../../utils/requestTabLeadership.js' jest.mock('@nextcloud/initial-state', () => ({ loadState: jest.fn(), @@ -131,6 +132,8 @@ describe('LeftSidebar.vue', () => { const wrapper = mountComponent() + await requestTabLeadership() + expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything()) expect(conversationsListMock).toHaveBeenCalled() @@ -157,6 +160,9 @@ describe('LeftSidebar.vue', () => { test('re-fetches conversations every 30 seconds', async () => { const wrapper = mountComponent() + + await requestTabLeadership() + expect(wrapper.exists()).toBeTruthy() expect(fetchConversationsAction).toHaveBeenCalled() @@ -175,6 +181,9 @@ describe('LeftSidebar.vue', () => { test('re-fetches conversations when receiving bus event', async () => { const wrapper = mountComponent() + + await requestTabLeadership() + expect(wrapper.exists()).toBeTruthy() expect(fetchConversationsAction).toHaveBeenCalled() @@ -303,6 +312,8 @@ describe('LeftSidebar.vue', () => { const wrapper = mountComponent() + await requestTabLeadership() + expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything()) expect(conversationsListMock).toHaveBeenCalled() diff --git a/src/components/LeftSidebar/LeftSidebar.vue b/src/components/LeftSidebar/LeftSidebar.vue index 665c12c3b27..4b795ed2c27 100644 --- a/src/components/LeftSidebar/LeftSidebar.vue +++ b/src/components/LeftSidebar/LeftSidebar.vue @@ -261,7 +261,9 @@ import { searchListedConversations, } from '../../services/conversationsService.js' import { EventBus } from '../../services/EventBus.js' +import { talkBroadcastChannel } from '../../services/talkBroadcastChannel.js' import CancelableRequest from '../../utils/cancelableRequest.js' +import { requestTabLeadership } from '../../utils/requestTabLeadership.js' export default { @@ -331,6 +333,8 @@ export default { preventFindingUnread: false, roomListModifiedBefore: 0, forceFullRoomListRefreshAfterXLoops: 0, + isFetchingConversations: false, + isCurrentTabLeader: false, isFocused: false, isFiltered: null, } @@ -414,17 +418,40 @@ export default { // before updated ones come from server this.restoreConversations() - this.fetchConversations() - }, - - mounted() { - // Refreshes the conversations every 30 seconds - this.refreshTimer = window.setInterval(() => { - if (!this.isFetchingConversations) { + requestTabLeadership().then(() => { + this.isCurrentTabLeader = true + this.fetchConversations() + // Refreshes the conversations list every 30 seconds + this.refreshTimer = window.setInterval(() => { this.fetchConversations() + }, 30000) + }) + + talkBroadcastChannel.addEventListener('message', (event) => { + if (this.isCurrentTabLeader) { + switch (event.data.message) { + case 'force-fetch-all-conversations': + this.roomListModifiedBefore = 0 + this.debounceFetchConversations() + break + } + } else { + switch (event.data.message) { + case 'update-conversations': + this.$store.dispatch('patchConversations', { + conversations: event.data.conversations, + withRemoving: event.data.withRemoving, + }) + break + case 'update-nextcloud-talk-hash': + this.$store.dispatch('setNextcloudTalkHash', event.data.hash) + break + } } - }, 30000) + }) + }, + mounted() { EventBus.$on('should-refresh-conversations', this.handleShouldRefreshConversations) EventBus.$once('conversations-received', this.handleUnreadMention) EventBus.$on('route-change', this.onRouteChange) @@ -622,7 +649,13 @@ export default { */ async handleShouldRefreshConversations(options) { if (options?.all === true) { - this.roomListModifiedBefore = 0 + if (this.isCurrentTabLeader) { + this.roomListModifiedBefore = 0 + } else { + // Force leader tab to do a full fetch + talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations' }) + return + } } else if (options?.token && options?.properties) { await this.$store.dispatch('setConversationProperties', { token: options.token, diff --git a/src/services/talkBroadcastChannel.js b/src/services/talkBroadcastChannel.js new file mode 100644 index 00000000000..746541204b9 --- /dev/null +++ b/src/services/talkBroadcastChannel.js @@ -0,0 +1,31 @@ +/** + * + * @copyright Copyright (c) 2023 Maksim Sukharev + * + * @author Maksim Sukharev + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +/** + * Broadcast channel to send messages between active tabs. + */ +const talkBroadcastChannel = new BroadcastChannel('nextcloud:talk') + +export { + talkBroadcastChannel, +} diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 623ab0e362f..ce55b7b9a16 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -63,6 +63,7 @@ import { startCallRecording, stopCallRecording, } from '../services/recordingService.js' +import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js' const DUMMY_CONVERSATION = { token: '', @@ -393,6 +394,7 @@ const actions = { await deleteConversation(token) // upon success, also delete from store await context.dispatch('deleteConversation', token) + talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations' }) }, /** @@ -766,6 +768,12 @@ const actions = { dispatch('cacheConversations') + // Inform other tabs about successful fetch + talkBroadcastChannel.postMessage({ + message: 'update-conversations', + conversations: response.data.ocs.data, + withRemoving: modifiedSince === 0, + }) return response } catch (error) { if (error?.response) { diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index e12dce55f5d..9fbc2fa13d1 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -45,6 +45,7 @@ import { setTyping, } from '../services/participantsService.js' import SessionStorage from '../services/SessionStorage.js' +import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js' const state = { attendees: { @@ -742,6 +743,7 @@ const actions = { await removeCurrentUserFromConversation(token) // If successful, deletes the conversation from the store await context.dispatch('deleteConversation', token) + talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations' }) }, /** diff --git a/src/store/talkHashStore.js b/src/store/talkHashStore.js index efa0c0d47d3..549ff790f68 100644 --- a/src/store/talkHashStore.js +++ b/src/store/talkHashStore.js @@ -22,6 +22,8 @@ import { showError, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' +import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js' + const state = { initialNextcloudTalkHash: '', isNextcloudTalkHashDirty: false, @@ -94,6 +96,12 @@ const actions = { } context.dispatch('setNextcloudTalkHash', newTalkCacheBusterHash) + + // Inform other tabs about changed hash + talkBroadcastChannel.postMessage({ + message: 'update-nextcloud-talk-hash', + hash: newTalkCacheBusterHash, + }) }, checkMaintenanceMode(context, response) { diff --git a/src/test-setup.js b/src/test-setup.js index 801e2bf7f5a..aac2595a414 100644 --- a/src/test-setup.js +++ b/src/test-setup.js @@ -98,6 +98,11 @@ function myArrayBuffer() { global.Blob.prototype.arrayBuffer = Blob.prototype.arrayBuffer || myArrayBuffer +global.BroadcastChannel = jest.fn(() => ({ + postMessage: jest.fn(), + addEventListener: jest.fn(), +})) + const originalConsoleError = console.error console.error = function(error) { if (error?.message?.includes('Could not parse CSS stylesheet')) { diff --git a/src/utils/requestTabLeadership.js b/src/utils/requestTabLeadership.js new file mode 100644 index 00000000000..25a89876895 --- /dev/null +++ b/src/utils/requestTabLeadership.js @@ -0,0 +1,56 @@ +/** + * @copyright Copyright (c) 2023 Maksim Sukharev + * + * @author Maksim Sukharev + * @author Grigorii Shartsev + * + * @license AGPL-3.0-or-later + * + * 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 . + * + */ + +/** + * @type {Promise|null} + */ +let requestingTabLeadershipPromise = null + +/** + * Assign the first window, which requested that, a 'fetching leader'. + * It will fetch conversations with defined interval and cache to BrowserStorage + * Other will update list with defined interval from the BrowserStorage + * Once 'leader' is closed, next requested tab will be assigned, and so on + */ +export function requestTabLeadership() { + if (!requestingTabLeadershipPromise) { + requestingTabLeadershipPromise = new Promise((resolve) => { + // Locks are supported only with HTTPS protocol, + // so we don't lock anything for another cases + if (navigator.locks === undefined) { + resolve() + return + } + + navigator.locks.request('talk:leader', () => { + // resolve a promise for the first requested tab + resolve() + + // return an infinity promise, resource is blocked until 'leader' tab is closed + return new Promise(() => { + }) + }) + }) + } + return requestingTabLeadershipPromise +}