Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(conversationsStore) - cache conversations to BrowserStorage #10203

Merged
merged 2 commits into from
Aug 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/components/LeftSidebar/LeftSidebar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -131,6 +132,8 @@ describe('LeftSidebar.vue', () => {

const wrapper = mountComponent()

await requestTabLeadership()

expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
expect(conversationsListMock).toHaveBeenCalled()

Expand All @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -303,6 +312,8 @@ describe('LeftSidebar.vue', () => {

const wrapper = mountComponent()

await requestTabLeadership()

expect(fetchConversationsAction).toHaveBeenCalledWith(expect.anything(), expect.anything())
expect(conversationsListMock).toHaveBeenCalled()

Expand Down
75 changes: 61 additions & 14 deletions src/components/LeftSidebar/LeftSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -331,6 +333,8 @@ export default {
preventFindingUnread: false,
roomListModifiedBefore: 0,
forceFullRoomListRefreshAfterXLoops: 0,
isFetchingConversations: false,
isCurrentTabLeader: false,
isFocused: false,
isFiltered: null,
}
Expand Down Expand Up @@ -410,17 +414,44 @@ export default {
this.abortSearch()
})

this.fetchConversations()
},
// Restore last fetched conversations from browser storage,
// before updated ones come from server
this.restoreConversations()

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)
Expand Down Expand Up @@ -618,7 +649,13 @@ export default {
*/
async handleShouldRefreshConversations(options) {
if (options?.all === true) {
this.roomListModifiedBefore = 0
if (this.isCurrentTabLeader) {
this.roomListModifiedBefore = 0
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
} 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,
Expand All @@ -630,12 +667,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
Expand Down Expand Up @@ -668,16 +707,24 @@ 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)
this.isFetchingConversations = false
}
},

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() {
Expand Down
31 changes: 31 additions & 0 deletions src/services/talkBroadcastChannel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
*
* @copyright Copyright (c) 2023 Maksim Sukharev <antreesy.web@gmail.com>
*
* @author Maksim Sukharev <antreesy.web@gmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/

/**
* Broadcast channel to send messages between active tabs.
*/
const talkBroadcastChannel = new BroadcastChannel('nextcloud:talk')

export {
talkBroadcastChannel,
}
53 changes: 52 additions & 1 deletion src/store/conversationsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
PARTICIPANT,
WEBINAR,
} from '../constants.js'
import BrowserStorage from '../services/BrowserStorage.js'
import {
makePublic,
makePrivate,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
startCallRecording,
stopCallRecording,
} from '../services/recordingService.js'
import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js'

const DUMMY_CONVERSATION = {
token: '',
Expand Down Expand Up @@ -315,8 +317,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])
Expand All @@ -339,6 +342,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`)
},

/**
Expand All @@ -352,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' })
},

/**
Expand Down Expand Up @@ -723,6 +766,14 @@ const actions = {
withRemoving: modifiedSince === 0,
})

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) {
Expand Down
62 changes: 62 additions & 0 deletions src/store/conversationsStore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
PARTICIPANT,
ATTENDEE,
} from '../constants.js'
import BrowserStorage from '../services/BrowserStorage.js'
import {
makePublic,
makePrivate,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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',
Expand Down
Loading