Skip to content

Commit 0796db6

Browse files
authored
Merge pull request #10203 from nextcloud/feat/noid/cache-conversations-to-storage
feat(conversationsStore) - cache conversations to BrowserStorage
2 parents 0cd7d45 + 2bd8ce1 commit 0796db6

9 files changed

Lines changed: 288 additions & 15 deletions

File tree

src/components/LeftSidebar/LeftSidebar.spec.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { searchPossibleConversations, searchListedConversations } from '../../se
1414
import { EventBus } from '../../services/EventBus.js'
1515
import storeConfig from '../../store/storeConfig.js'
1616
import { findNcListItems, findNcActionButton } from '../../test-helpers.js'
17+
import { requestTabLeadership } from '../../utils/requestTabLeadership.js'
1718

1819
jest.mock('@nextcloud/initial-state', () => ({
1920
loadState: jest.fn(),
@@ -131,6 +132,8 @@ describe('LeftSidebar.vue', () => {
131132

132133
const wrapper = mountComponent()
133134

135+
await requestTabLeadership()
136+
134137
expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
135138

136139
expect(wrapper.vm.searchText).toBe('')
@@ -156,6 +159,9 @@ describe('LeftSidebar.vue', () => {
156159

157160
test('re-fetches conversations every 30 seconds', async () => {
158161
const wrapper = mountComponent()
162+
163+
await requestTabLeadership()
164+
159165
expect(wrapper.exists()).toBeTruthy()
160166
expect(fetchConversationsAction).toHaveBeenCalled()
161167

@@ -174,6 +180,9 @@ describe('LeftSidebar.vue', () => {
174180

175181
test('re-fetches conversations when receiving bus event', async () => {
176182
const wrapper = mountComponent()
183+
184+
await requestTabLeadership()
185+
177186
expect(wrapper.exists()).toBeTruthy()
178187
expect(fetchConversationsAction).toHaveBeenCalled()
179188

@@ -302,6 +311,8 @@ describe('LeftSidebar.vue', () => {
302311

303312
const wrapper = mountComponent()
304313

314+
await requestTabLeadership()
315+
305316
expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
306317

307318
const searchBox = wrapper.findComponent({ name: 'SearchBox' })

src/components/LeftSidebar/LeftSidebar.vue

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,9 @@ import {
267267
searchListedConversations,
268268
} from '../../services/conversationsService.js'
269269
import { EventBus } from '../../services/EventBus.js'
270+
import { talkBroadcastChannel } from '../../services/talkBroadcastChannel.js'
270271
import CancelableRequest from '../../utils/cancelableRequest.js'
272+
import { requestTabLeadership } from '../../utils/requestTabLeadership.js'
271273
272274
export default {
273275
@@ -339,6 +341,8 @@ export default {
339341
preventFindingUnread: false,
340342
roomListModifiedBefore: 0,
341343
forceFullRoomListRefreshAfterXLoops: 0,
344+
isFetchingConversations: false,
345+
isCurrentTabLeader: false,
342346
isFocused: false,
343347
isFiltered: null,
344348
}
@@ -423,17 +427,44 @@ export default {
423427
this.abortSearch()
424428
})
425429
426-
this.fetchConversations()
427-
},
430+
// Restore last fetched conversations from browser storage,
431+
// before updated ones come from server
432+
this.restoreConversations()
428433
429-
mounted() {
430-
// Refreshes the conversations every 30 seconds
431-
this.refreshTimer = window.setInterval(() => {
432-
if (!this.isFetchingConversations) {
434+
requestTabLeadership().then(() => {
435+
this.isCurrentTabLeader = true
436+
this.fetchConversations()
437+
// Refreshes the conversations list every 30 seconds
438+
this.refreshTimer = window.setInterval(() => {
433439
this.fetchConversations()
440+
}, 30000)
441+
})
442+
443+
talkBroadcastChannel.addEventListener('message', (event) => {
444+
if (this.isCurrentTabLeader) {
445+
switch (event.data.message) {
446+
case 'force-fetch-all-conversations':
447+
this.roomListModifiedBefore = 0
448+
this.debounceFetchConversations()
449+
break
450+
}
451+
} else {
452+
switch (event.data.message) {
453+
case 'update-conversations':
454+
this.$store.dispatch('patchConversations', {
455+
conversations: event.data.conversations,
456+
withRemoving: event.data.withRemoving,
457+
})
458+
break
459+
case 'update-nextcloud-talk-hash':
460+
this.$store.dispatch('setNextcloudTalkHash', event.data.hash)
461+
break
462+
}
434463
}
435-
}, 30000)
464+
})
465+
},
436466
467+
mounted() {
437468
EventBus.$on('should-refresh-conversations', this.handleShouldRefreshConversations)
438469
EventBus.$once('conversations-received', this.handleUnreadMention)
439470
EventBus.$on('route-change', this.onRouteChange)
@@ -623,7 +654,13 @@ export default {
623654
*/
624655
async handleShouldRefreshConversations(options) {
625656
if (options?.all === true) {
626-
this.roomListModifiedBefore = 0
657+
if (this.isCurrentTabLeader) {
658+
this.roomListModifiedBefore = 0
659+
} else {
660+
// Force leader tab to do a full fetch
661+
talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations' })
662+
return
663+
}
627664
} else if (options?.token && options?.properties) {
628665
await this.$store.dispatch('setConversationProperties', {
629666
token: options.token,
@@ -635,12 +672,14 @@ export default {
635672
},
636673
637674
debounceFetchConversations: debounce(function() {
638-
if (!this.isFetchingConversations) {
639-
this.fetchConversations()
640-
}
675+
this.fetchConversations()
641676
}, 3000),
642677
643678
async fetchConversations() {
679+
if (this.isFetchingConversations) {
680+
return
681+
}
682+
644683
this.isFetchingConversations = true
645684
if (this.forceFullRoomListRefreshAfterXLoops === 0) {
646685
this.roomListModifiedBefore = 0
@@ -673,16 +712,24 @@ export default {
673712
* Emits a global event that is used in App.vue to update the page title once the
674713
* ( if the current route is a conversation and once the conversations are received)
675714
*/
676-
EventBus.$emit('conversations-received', {
677-
singleConversation: false,
678-
})
715+
EventBus.$emit('conversations-received', { singleConversation: false })
679716
this.isFetchingConversations = false
680717
} catch (error) {
681718
console.debug('Error while fetching conversations: ', error)
682719
this.isFetchingConversations = false
683720
}
684721
},
685722
723+
async restoreConversations() {
724+
try {
725+
await this.$store.dispatch('restoreConversations')
726+
this.initialisedConversations = true
727+
EventBus.$emit('conversations-received', { singleConversation: false })
728+
} catch (error) {
729+
console.debug('Error while restoring conversations: ', error)
730+
}
731+
},
732+
686733
// Checks whether the conversations list is scrolled all the way to the top
687734
// or not
688735
handleScroll() {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
*
3+
* @copyright Copyright (c) 2023 Maksim Sukharev <antreesy.web@gmail.com>
4+
*
5+
* @author Maksim Sukharev <antreesy.web@gmail.com>
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+
* Broadcast channel to send messages between active tabs.
26+
*/
27+
const talkBroadcastChannel = new BroadcastChannel('nextcloud:talk')
28+
29+
export {
30+
talkBroadcastChannel,
31+
}

src/store/conversationsStore.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
PARTICIPANT,
3232
WEBINAR,
3333
} from '../constants.js'
34+
import BrowserStorage from '../services/BrowserStorage.js'
3435
import {
3536
makePublic,
3637
makePrivate,
@@ -62,6 +63,7 @@ import {
6263
startCallRecording,
6364
stopCallRecording,
6465
} from '../services/recordingService.js'
66+
import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js'
6567

6668
const DUMMY_CONVERSATION = {
6769
token: '',
@@ -329,8 +331,9 @@ const actions = {
329331
* @param {object} payload the payload
330332
* @param {object[]} payload.conversations new conversations list
331333
* @param {boolean} payload.withRemoving whether to remove conversations that are not in the new list
334+
* @param {boolean} payload.withCaching whether to cache conversations to BrowserStorage with patch
332335
*/
333-
patchConversations(context, { conversations, withRemoving = false }) {
336+
patchConversations(context, { conversations, withRemoving = false, withCaching = false }) {
334337
const currentConversations = context.state.conversations
335338
const newConversations = Object.fromEntries(
336339
conversations.map((conversation) => [conversation.token, conversation])
@@ -353,6 +356,45 @@ const actions = {
353356
context.dispatch('updateConversationIfHasChanged', newConversation)
354357
}
355358
}
359+
360+
if (withCaching) {
361+
context.dispatch('cacheConversations')
362+
}
363+
},
364+
365+
/**
366+
* Restores conversations from BrowserStorage and add them to the store state
367+
*
368+
* @param {object} context default store context
369+
*/
370+
restoreConversations(context) {
371+
const cachedConversations = BrowserStorage.getItem('cachedConversations')
372+
if (!cachedConversations?.length) {
373+
return
374+
}
375+
376+
context.dispatch('patchConversations', {
377+
conversations: JSON.parse(cachedConversations),
378+
withRemoving: true,
379+
})
380+
381+
console.debug('Conversations have been restored from BrowserStorage')
382+
},
383+
384+
/**
385+
* Save conversations to BrowserStorage from the store state
386+
*
387+
* @param {object} context default store context
388+
*/
389+
cacheConversations(context) {
390+
const conversations = context.getters.conversationsList
391+
if (!conversations.length) {
392+
return
393+
}
394+
395+
const serializedConversations = JSON.stringify(conversations)
396+
BrowserStorage.setItem('cachedConversations', serializedConversations)
397+
console.debug(`Conversations were saved to BrowserStorage. Estimated object size: ${(serializedConversations.length / 1024).toFixed(2)} kB`)
356398
},
357399

358400
/**
@@ -366,6 +408,7 @@ const actions = {
366408
await deleteConversation(token)
367409
// upon success, also delete from store
368410
await context.dispatch('deleteConversation', token)
411+
talkBroadcastChannel.postMessage({ message: 'force-fetch-all-conversations' })
369412
},
370413

371414
/**
@@ -737,6 +780,14 @@ const actions = {
737780
withRemoving: modifiedSince === 0,
738781
})
739782

783+
dispatch('cacheConversations')
784+
785+
// Inform other tabs about successful fetch
786+
talkBroadcastChannel.postMessage({
787+
message: 'update-conversations',
788+
conversations: response.data.ocs.data,
789+
withRemoving: modifiedSince === 0,
790+
})
740791
return response
741792
} catch (error) {
742793
if (error?.response) {

src/store/conversationsStore.spec.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
PARTICIPANT,
1212
ATTENDEE,
1313
} from '../constants.js'
14+
import BrowserStorage from '../services/BrowserStorage.js'
1415
import {
1516
makePublic,
1617
makePrivate,
@@ -56,6 +57,11 @@ jest.mock('../services/conversationsService', () => ({
5657

5758
jest.mock('@nextcloud/event-bus')
5859

60+
jest.mock('../services/BrowserStorage.js', () => ({
61+
getItem: jest.fn(),
62+
setItem: jest.fn(),
63+
}))
64+
5965
describe('conversationsStore', () => {
6066
const testToken = 'XXTOKENXX'
6167
const previousLastMessage = {
@@ -196,6 +202,31 @@ describe('conversationsStore', () => {
196202
expect(deleteConversation).not.toHaveBeenCalled()
197203
})
198204

205+
test('restores conversations cached in BrowserStorage', () => {
206+
const testConversations = [
207+
{
208+
token: 'one_token',
209+
attendeeId: 'attendee-id-1',
210+
lastActivity: Date.parse('2023-02-01T00:00:00.000Z') / 1000,
211+
},
212+
{
213+
token: 'another_token',
214+
attendeeId: 'attendee-id-2',
215+
lastActivity: Date.parse('2023-01-01T00:00:00.000Z') / 1000,
216+
},
217+
]
218+
219+
BrowserStorage.getItem.mockReturnValueOnce(
220+
'[{"token":"one_token","attendeeId":"attendee-id-1","lastActivity":1675209600},{"token":"another_token","attendeeId":"attendee-id-2","lastActivity":1672531200}]'
221+
)
222+
223+
store.dispatch('restoreConversations')
224+
225+
expect(BrowserStorage.getItem).toHaveBeenCalledWith('cachedConversations')
226+
expect(store.getters.conversationsList).toHaveLength(2)
227+
expect(store.getters.conversationsList).toEqual(testConversations)
228+
})
229+
199230
test('deletes conversation from server', async () => {
200231
store.dispatch('addConversation', testConversation)
201232

@@ -258,6 +289,37 @@ describe('conversationsStore', () => {
258289
expect(store.getters.conversationsList).toStrictEqual(testConversations)
259290
})
260291

292+
test('sets fetched conversations to BrowserStorage', async () => {
293+
const testConversations = [
294+
{
295+
token: 'one_token',
296+
attendeeId: 'attendee-id-1',
297+
lastActivity: Date.parse('2023-02-01T00:00:00.000Z') / 1000,
298+
},
299+
{
300+
token: 'another_token',
301+
attendeeId: 'attendee-id-2',
302+
lastActivity: Date.parse('2023-01-01T00:00:00.000Z') / 1000,
303+
},
304+
]
305+
306+
const response = {
307+
data: {
308+
ocs: {
309+
data: testConversations,
310+
},
311+
},
312+
}
313+
314+
fetchConversations.mockResolvedValue(response)
315+
316+
await store.dispatch('fetchConversations', {})
317+
318+
expect(BrowserStorage.setItem).toHaveBeenCalledWith('cachedConversations',
319+
'[{"token":"one_token","attendeeId":"attendee-id-1","lastActivity":1675209600},{"token":"another_token","attendeeId":"attendee-id-2","lastActivity":1672531200}]'
320+
)
321+
})
322+
261323
test('fetches all conversations and add new received conversations', async () => {
262324
const oldConversation = {
263325
token: 'tokenOne',

0 commit comments

Comments
 (0)