Skip to content

Commit 2b2cdb2

Browse files
committed
assign a leader tab to fetch conversations only once in 30 seconds
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
1 parent fab7809 commit 2b2cdb2

5 files changed

Lines changed: 124 additions & 32 deletions

File tree

src/components/LeftSidebar/LeftSidebar.spec.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ jest.mock('../../services/conversationsService', () => ({
2626
// short-circuit debounce
2727
jest.mock('debounce', () => jest.fn().mockImplementation(fn => fn))
2828

29+
window.navigator.locks = {
30+
request: jest.fn().mockImplementation((name, callback) => {
31+
callback()
32+
return new Promise(() => {})
33+
}),
34+
}
35+
2936
describe('LeftSidebar.vue', () => {
3037
let store
3138
let localVue
@@ -38,8 +45,8 @@ describe('LeftSidebar.vue', () => {
3845

3946
const SEARCH_TERM = 'search'
4047

41-
const mountComponent = () => {
42-
return mount(LeftSidebar, {
48+
const mountComponent = async () => {
49+
const wrapper = mount(LeftSidebar, {
4350
localVue,
4451
router,
4552
store,
@@ -51,6 +58,8 @@ describe('LeftSidebar.vue', () => {
5158
NcModal: true,
5259
},
5360
})
61+
await flushPromises()
62+
return wrapper
5463
}
5564

5665
beforeEach(() => {
@@ -129,7 +138,7 @@ describe('LeftSidebar.vue', () => {
129138
EventBus.$once('conversations-received', conversationsReceivedEvent)
130139
fetchConversationsAction.mockResolvedValueOnce()
131140

132-
const wrapper = mountComponent()
141+
const wrapper = await mountComponent()
133142

134143
expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
135144
expect(conversationsListMock).toHaveBeenCalled()
@@ -138,14 +147,10 @@ describe('LeftSidebar.vue', () => {
138147
expect(conversationListItems).toHaveLength(conversationsList.length)
139148

140149
expect(wrapper.vm.searchText).toBe('')
141-
expect(wrapper.vm.initialisedConversations).toBeFalsy()
150+
expect(wrapper.vm.initialisedConversations).toBeTruthy()
142151

143-
expect(conversationsReceivedEvent).not.toHaveBeenCalled()
152+
expect(conversationsReceivedEvent).toHaveBeenCalled()
144153

145-
// move on past the fetchConversation call
146-
await flushPromises()
147-
148-
expect(wrapper.vm.initialisedConversations).toBeTruthy()
149154
expect(conversationListItems.at(0).props('item')).toStrictEqual(conversationsList[2])
150155
expect(conversationListItems.at(1).props('item')).toStrictEqual(conversationsList[0])
151156
expect(conversationListItems.at(2).props('item')).toStrictEqual(conversationsList[1])
@@ -156,7 +161,7 @@ describe('LeftSidebar.vue', () => {
156161
})
157162

158163
test('re-fetches conversations every 30 seconds', async () => {
159-
const wrapper = mountComponent()
164+
const wrapper = await mountComponent()
160165
expect(wrapper.exists()).toBeTruthy()
161166
expect(fetchConversationsAction).toHaveBeenCalled()
162167

@@ -174,7 +179,7 @@ describe('LeftSidebar.vue', () => {
174179
})
175180

176181
test('re-fetches conversations when receiving bus event', async () => {
177-
const wrapper = mountComponent()
182+
const wrapper = await mountComponent()
178183
expect(wrapper.exists()).toBeTruthy()
179184
expect(fetchConversationsAction).toHaveBeenCalled()
180185

@@ -301,7 +306,7 @@ describe('LeftSidebar.vue', () => {
301306
loadStateSettings = loadStateSettingsOverride
302307
}
303308

304-
const wrapper = mountComponent()
309+
const wrapper = await mountComponent()
305310

306311
expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
307312
expect(conversationsListMock).toHaveBeenCalled()
@@ -704,18 +709,18 @@ describe('LeftSidebar.vue', () => {
704709
conversationsListMock.mockReturnValue([])
705710
fetchConversationsAction.mockResolvedValueOnce()
706711
})
707-
test('shows new conversation button if user can start conversations', () => {
712+
test('shows new conversation button if user can start conversations', async () => {
708713
loadStateSettings.start_conversations = true
709714

710-
const wrapper = mountComponent()
715+
const wrapper = await mountComponent()
711716
const newConversationbutton = findNcActionButton(wrapper, 'Create a new conversation')
712717
expect(newConversationbutton.exists()).toBeTruthy()
713718

714719
})
715-
test('does not show new conversation button if user cannot start conversations', () => {
720+
test('does not show new conversation button if user cannot start conversations', async () => {
716721
loadStateSettings.start_conversations = false
717722

718-
const wrapper = mountComponent()
723+
const wrapper = await mountComponent()
719724
const newConversationbutton = findNcActionButton(wrapper, 'Create a new conversation')
720725
expect(newConversationbutton.exists()).toBeFalsy()
721726
})
@@ -725,7 +730,7 @@ describe('LeftSidebar.vue', () => {
725730
conversationsListMock.mockImplementation(() => [])
726731
const eventHandler = jest.fn()
727732
subscribe('show-settings', eventHandler)
728-
const wrapper = mountComponent()
733+
const wrapper = await mountComponent()
729734

730735
const button = wrapper.find('.settings-button')
731736
expect(button.exists()).toBeTruthy()

src/components/LeftSidebar/LeftSidebar.vue

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ import {
262262
} from '../../services/conversationsService.js'
263263
import { EventBus } from '../../services/EventBus.js'
264264
import CancelableRequest from '../../utils/cancelableRequest.js'
265+
import { onTalkLeaderTab } from '../../utils/talkLeader.js'
266+
267+
const talkBroadcastChannel = new BroadcastChannel('nextcloud:talk')
265268
266269
export default {
267270
@@ -331,6 +334,8 @@ export default {
331334
preventFindingUnread: false,
332335
roomListModifiedBefore: 0,
333336
forceFullRoomListRefreshAfterXLoops: 0,
337+
isFetchingConversations: false,
338+
isLeaderTab: false,
334339
isFocused: false,
335340
isFiltered: null,
336341
}
@@ -412,19 +417,28 @@ export default {
412417
413418
// Restore last fetched conversations from browser storage,
414419
// before updated ones come from server
415-
this.$store.dispatch('restoreConversations')
416-
417-
this.fetchConversations()
418-
},
420+
this.restoreConversations()
419421
420-
mounted() {
421-
// Refreshes the conversations every 30 seconds
422-
this.refreshTimer = window.setInterval(() => {
423-
if (!this.isFetchingConversations) {
422+
onTalkLeaderTab().then(() => {
423+
this.isLeaderTab = true
424+
this.fetchConversations()
425+
// Refreshes the conversations list every 30 seconds
426+
this.refreshTimer = window.setInterval(() => {
424427
this.fetchConversations()
428+
}, 30000)
429+
})
430+
431+
talkBroadcastChannel.onmessage = (event) => {
432+
if (!this.isLeaderTab && event.data.message === 'talk:update-conversations') {
433+
this.$store.dispatch('patchConversations', {
434+
conversations: event.data.conversations,
435+
withRemoving: event.data.withRemoving,
436+
})
425437
}
426-
}, 30000)
438+
}
439+
},
427440
441+
mounted() {
428442
EventBus.$on('should-refresh-conversations', this.handleShouldRefreshConversations)
429443
EventBus.$once('conversations-received', this.handleUnreadMention)
430444
EventBus.$on('route-change', this.onRouteChange)
@@ -634,12 +648,14 @@ export default {
634648
},
635649
636650
debounceFetchConversations: debounce(function() {
637-
if (!this.isFetchingConversations) {
638-
this.fetchConversations()
639-
}
651+
this.fetchConversations()
640652
}, 3000),
641653
642654
async fetchConversations() {
655+
if (this.isFetchingConversations) {
656+
return
657+
}
658+
643659
this.isFetchingConversations = true
644660
if (this.forceFullRoomListRefreshAfterXLoops === 0) {
645661
this.roomListModifiedBefore = 0
@@ -672,16 +688,24 @@ export default {
672688
* Emits a global event that is used in App.vue to update the page title once the
673689
* ( if the current route is a conversation and once the conversations are received)
674690
*/
675-
EventBus.$emit('conversations-received', {
676-
singleConversation: false,
677-
})
691+
EventBus.$emit('conversations-received', { singleConversation: false })
678692
this.isFetchingConversations = false
679693
} catch (error) {
680694
console.debug('Error while fetching conversations: ', error)
681695
this.isFetchingConversations = false
682696
}
683697
},
684698
699+
async restoreConversations() {
700+
try {
701+
await this.$store.dispatch('restoreConversations')
702+
this.initialisedConversations = true
703+
EventBus.$emit('conversations-received', { singleConversation: false })
704+
} catch (error) {
705+
console.debug('Error while restoring conversations: ', error)
706+
}
707+
},
708+
685709
// Checks whether the conversations list is scrolled all the way to the top
686710
// or not
687711
handleScroll() {

src/store/conversationsStore.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ import {
6464
stopCallRecording,
6565
} from '../services/recordingService.js'
6666

67+
const talkBroadcastChannel = new BroadcastChannel('nextcloud:talk')
68+
6769
const DUMMY_CONVERSATION = {
6870
token: '',
6971
displayName: '',
@@ -766,6 +768,12 @@ const actions = {
766768

767769
dispatch('cacheConversations')
768770

771+
// Inform other tabs about successful fetch
772+
talkBroadcastChannel.postMessage({
773+
message: 'talk:update-conversations',
774+
conversations: response.data.ocs.data,
775+
withRemoving: modifiedSince === 0,
776+
})
769777
return response
770778
} catch (error) {
771779
if (error?.response) {

src/test-setup.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ function myArrayBuffer() {
9898

9999
global.Blob.prototype.arrayBuffer = Blob.prototype.arrayBuffer || myArrayBuffer
100100

101+
global.BroadcastChannel = jest.fn(() => ({
102+
postMessage: jest.fn(),
103+
}))
104+
101105
const originalConsoleError = console.error
102106
console.error = function(error) {
103107
if (error?.message?.includes('Could not parse CSS stylesheet')) {

src/utils/talkLeader.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @copyright Copyright (c) 2023 Maksim Sukharev <antreesy.web@gmail.com>
3+
*
4+
* @author Maksim Sukharev <antreesy.web@gmail.com>
5+
* @author Grigorii Shartsev <me@shgk.me>
6+
*
7+
* @license AGPL-3.0-or-later
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as
11+
* published by the Free Software Foundation, either version 3 of the
12+
* License, or (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*
22+
*/
23+
24+
/**
25+
* Assign the first window, which requested that, a 'fetching leader'.
26+
* It will fetch conversations with defined interval and cache to BrowserStorage
27+
* Other will update list with defined interval from the BrowserStorage
28+
* Once 'leader' is closed, next requested tab will be assigned, and so on
29+
*/
30+
const onTalkLeaderTab = async () => {
31+
return new Promise((resolve) => {
32+
// Locks are supported only with HTTPS protocol,
33+
// so we don't lock anything for another cases
34+
if (window.location.protocol !== 'https:') {
35+
resolve()
36+
return
37+
}
38+
39+
window.navigator.locks.request('talk:leader', () => {
40+
// resolve a promise for the first requested tab
41+
resolve()
42+
43+
// return an infinity promise, resource is blocked until 'leader' tab is closed
44+
return new Promise(() => {})
45+
})
46+
})
47+
}
48+
49+
export {
50+
onTalkLeaderTab,
51+
}

0 commit comments

Comments
 (0)