From af90f21cfddf32a3f18e7350a8070b85613db2cc Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 3 Jun 2024 15:54:19 +0530 Subject: [PATCH] feat: Reconnect logic (#9453) Co-authored-by: Muhsin Keloth Co-authored-by: Shivam Mishra --- app/javascript/dashboard/App.vue | 9 + app/javascript/dashboard/api/account.js | 7 + .../components/NetworkNotification.vue | 171 ++++++--- .../dashboard/composables/emitter.js | 20 + .../composables/spec/emitter.spec.js | 51 +++ .../dashboard/helper/ReconnectService.js | 140 +++++++ .../dashboard/helper/actionCable.js | 29 +- .../dashboard/helper/routeHelpers.js | 13 +- .../helper/specs/ReconnectService.spec.js | 342 ++++++++++++++++++ .../helper/specs/routeHelpers.spec.js | 8 + .../i18n/locale/en/generalSettings.json | 4 +- .../dashboard/store/modules/accounts.js | 4 + .../store/modules/conversations/index.js | 5 +- .../specs/conversations/mutations.spec.js | 24 +- .../FluentIcon/dashboard-icons.json | 1 + app/javascript/shared/constants/busEvents.js | 2 + .../helpers/BaseActionCableConnector.js | 4 - package.json | 1 + yarn.lock | 32 ++ 19 files changed, 774 insertions(+), 93 deletions(-) create mode 100644 app/javascript/dashboard/composables/emitter.js create mode 100644 app/javascript/dashboard/composables/spec/emitter.spec.js create mode 100644 app/javascript/dashboard/helper/ReconnectService.js create mode 100644 app/javascript/dashboard/helper/specs/ReconnectService.spec.js diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 481c671ce..c343fbe4f 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -27,6 +27,7 @@ + - - diff --git a/app/javascript/dashboard/composables/emitter.js b/app/javascript/dashboard/composables/emitter.js new file mode 100644 index 000000000..c547099ab --- /dev/null +++ b/app/javascript/dashboard/composables/emitter.js @@ -0,0 +1,20 @@ +import { emitter } from 'shared/helpers/mitt'; +import { onMounted, onBeforeUnmount } from 'vue'; + +// this will automatically add event listeners to the emitter +// and remove them when the component is destroyed +const useEmitter = (eventName, callback) => { + const cleanup = () => { + emitter.off(eventName, callback); + }; + + onMounted(() => { + emitter.on(eventName, callback); + }); + + onBeforeUnmount(cleanup); + + return cleanup; +}; + +export { useEmitter }; diff --git a/app/javascript/dashboard/composables/spec/emitter.spec.js b/app/javascript/dashboard/composables/spec/emitter.spec.js new file mode 100644 index 000000000..c732b7c58 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/emitter.spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { emitter } from 'shared/helpers/mitt'; +import { useEmitter } from '../emitter'; + +jest.mock('shared/helpers/mitt', () => ({ + emitter: { + on: jest.fn(), + off: jest.fn(), + }, +})); + +describe('useEmitter', () => { + let wrapper; + const eventName = 'my-event'; + const callback = jest.fn(); + + beforeEach(() => { + wrapper = shallowMount({ + template: ` +
+ Hello world +
+ `, + setup() { + return { + cleanup: useEmitter(eventName, callback), + }; + }, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should add an event listener on mount', () => { + expect(emitter.on).toHaveBeenCalledWith(eventName, callback); + }); + + it('should remove the event listener when the component is unmounted', () => { + wrapper.destroy(); + expect(emitter.off).toHaveBeenCalledWith(eventName, callback); + }); + + it('should return the cleanup function', () => { + const cleanup = wrapper.vm.cleanup; + expect(typeof cleanup).toBe('function'); + cleanup(); + expect(emitter.off).toHaveBeenCalledWith(eventName, callback); + }); +}); diff --git a/app/javascript/dashboard/helper/ReconnectService.js b/app/javascript/dashboard/helper/ReconnectService.js new file mode 100644 index 000000000..d2058073c --- /dev/null +++ b/app/javascript/dashboard/helper/ReconnectService.js @@ -0,0 +1,140 @@ +import { emitter } from 'shared/helpers/mitt'; +import { BUS_EVENTS } from 'shared/constants/busEvents'; +import { differenceInSeconds } from 'date-fns'; +import { + isAConversationRoute, + isAInboxViewRoute, + isNotificationRoute, +} from 'dashboard/helper/routeHelpers'; + +const MAX_DISCONNECT_SECONDS = 10800; + +class ReconnectService { + constructor(store, router) { + this.store = store; + this.router = router; + this.disconnectTime = null; + + this.setupEventListeners(); + } + + disconnect = () => this.removeEventListeners(); + + setupEventListeners = () => { + window.addEventListener('online', this.handleOnlineEvent); + emitter.on(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect); + emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect); + }; + + removeEventListeners = () => { + window.removeEventListener('online', this.handleOnlineEvent); + emitter.off(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect); + emitter.off(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect); + }; + + getSecondsSinceDisconnect = () => + this.disconnectTime + ? Math.max(differenceInSeconds(new Date(), this.disconnectTime), 0) + : 0; + + // Force reload if the user is disconnected for more than 3 hours + handleOnlineEvent = () => { + if (this.getSecondsSinceDisconnect() >= MAX_DISCONNECT_SECONDS) { + window.location.reload(); + } + }; + + fetchConversations = async () => { + await this.store.dispatch('updateChatListFilters', { + page: null, + updatedWithin: this.getSecondsSinceDisconnect(), + }); + await this.store.dispatch('fetchAllConversations'); + // Reset the updatedWithin in the store chat list filter after fetching conversations when the user is reconnected + await this.store.dispatch('updateChatListFilters', { + updatedWithin: null, + }); + }; + + fetchFilteredOrSavedConversations = async queryData => { + await this.store.dispatch('fetchFilteredConversations', { + queryData, + page: 1, + }); + }; + + fetchConversationsOnReconnect = async () => { + const { + getAppliedConversationFiltersQuery, + 'customViews/getActiveConversationFolder': activeFolder, + } = this.store.getters; + const query = getAppliedConversationFiltersQuery?.payload?.length + ? getAppliedConversationFiltersQuery + : activeFolder?.query; + if (query) { + await this.fetchFilteredOrSavedConversations(query); + } else { + await this.fetchConversations(); + } + }; + + fetchConversationMessagesOnReconnect = async () => { + const { conversation_id: conversationId } = this.router.currentRoute.params; + if (conversationId) { + await this.store.dispatch('syncActiveConversationMessages', { + conversationId: Number(conversationId), + }); + } + }; + + fetchNotificationsOnReconnect = async filter => { + await this.store.dispatch('notifications/index', { ...filter, page: 1 }); + }; + + revalidateCaches = async () => { + const { label, inbox, team } = await this.store.dispatch( + 'accounts/getCacheKeys' + ); + await Promise.all([ + this.store.dispatch('labels/revalidate', { newKey: label }), + this.store.dispatch('inboxes/revalidate', { newKey: inbox }), + this.store.dispatch('teams/revalidate', { newKey: team }), + ]); + }; + + handleRouteSpecificFetch = async () => { + const currentRoute = this.router.currentRoute.name; + if (isAConversationRoute(currentRoute, true)) { + await this.fetchConversationsOnReconnect(); + await this.fetchConversationMessagesOnReconnect(); + } else if (isAInboxViewRoute(currentRoute, true)) { + await this.fetchNotificationsOnReconnect( + this.store.getters['notifications/getNotificationFilters'] + ); + } else if (isNotificationRoute(currentRoute)) { + await this.fetchNotificationsOnReconnect(); + } + }; + + setConversationLastMessageId = async () => { + const { conversation_id: conversationId } = this.router.currentRoute.params; + if (conversationId) { + await this.store.dispatch('setConversationLastMessageId', { + conversationId: Number(conversationId), + }); + } + }; + + onDisconnect = () => { + this.disconnectTime = new Date(); + this.setConversationLastMessageId(); + }; + + onReconnect = async () => { + await this.handleRouteSpecificFetch(); + await this.revalidateCaches(); + emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED); + }; +} + +export default ReconnectService; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 3b61695db..729f298b7 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -1,6 +1,7 @@ import AuthAPI from '../api/auth'; import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; +import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; class ActionCableConnector extends BaseActionCableConnector { @@ -33,34 +34,14 @@ class ActionCableConnector extends BaseActionCableConnector { }; } + // eslint-disable-next-line class-methods-use-this onReconnect = () => { - this.syncActiveConversationMessages(); + emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT); }; + // eslint-disable-next-line class-methods-use-this onDisconnected = () => { - this.setActiveConversationLastMessageId(); - }; - - setActiveConversationLastMessageId = () => { - const { - params: { conversation_id }, - } = this.app.$route; - if (conversation_id) { - this.app.$store.dispatch('setConversationLastMessageId', { - conversationId: Number(conversation_id), - }); - } - }; - - syncActiveConversationMessages = () => { - const { - params: { conversation_id }, - } = this.app.$route; - if (conversation_id) { - this.app.$store.dispatch('syncActiveConversationMessages', { - conversationId: Number(conversation_id), - }); - } + emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT); }; isAValidEvent = data => { diff --git a/app/javascript/dashboard/helper/routeHelpers.js b/app/javascript/dashboard/helper/routeHelpers.js index 86c286368..68cf0a627 100644 --- a/app/javascript/dashboard/helper/routeHelpers.js +++ b/app/javascript/dashboard/helper/routeHelpers.js @@ -109,5 +109,14 @@ export const getConversationDashboardRoute = routeName => { } }; -export const isAInboxViewRoute = routeName => - ['inbox_view_conversation'].includes(routeName); +export const isAInboxViewRoute = (routeName, includeBase = false) => { + const baseRoutes = ['inbox_view']; + const extendedRoutes = ['inbox_view_conversation']; + const routeNames = includeBase + ? [...baseRoutes, ...extendedRoutes] + : extendedRoutes; + return routeNames.includes(routeName); +}; + +export const isNotificationRoute = routeName => + routeName === 'notifications_index'; diff --git a/app/javascript/dashboard/helper/specs/ReconnectService.spec.js b/app/javascript/dashboard/helper/specs/ReconnectService.spec.js new file mode 100644 index 000000000..1d0878d4d --- /dev/null +++ b/app/javascript/dashboard/helper/specs/ReconnectService.spec.js @@ -0,0 +1,342 @@ +import { emitter } from 'shared/helpers/mitt'; +import { BUS_EVENTS } from 'shared/constants/busEvents'; +import { differenceInSeconds } from 'date-fns'; +import { + isAConversationRoute, + isAInboxViewRoute, + isNotificationRoute, +} from 'dashboard/helper/routeHelpers'; +import ReconnectService from 'dashboard/helper/ReconnectService'; + +jest.mock('shared/helpers/mitt', () => ({ + emitter: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('date-fns', () => ({ + differenceInSeconds: jest.fn(), +})); + +jest.mock('dashboard/helper/routeHelpers', () => ({ + isAConversationRoute: jest.fn(), + isAInboxViewRoute: jest.fn(), + isNotificationRoute: jest.fn(), +})); + +const storeMock = { + dispatch: jest.fn(), + getters: { + getAppliedConversationFiltersQuery: [], + 'customViews/getActiveConversationFolder': { query: {} }, + 'notifications/getNotificationFilters': {}, + }, +}; + +const routerMock = { + currentRoute: { + name: '', + params: { conversation_id: null }, + }, +}; + +describe('ReconnectService', () => { + let reconnectService; + + beforeEach(() => { + window.addEventListener = jest.fn(); + window.removeEventListener = jest.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: jest.fn() }, + }); + reconnectService = new ReconnectService(storeMock, routerMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with store, router, and setup event listeners', () => { + expect(reconnectService.store).toBe(storeMock); + expect(reconnectService.router).toBe(routerMock); + expect(window.addEventListener).toHaveBeenCalledWith( + 'online', + reconnectService.handleOnlineEvent + ); + expect(emitter.on).toHaveBeenCalledWith( + BUS_EVENTS.WEBSOCKET_RECONNECT, + reconnectService.onReconnect + ); + expect(emitter.on).toHaveBeenCalledWith( + BUS_EVENTS.WEBSOCKET_DISCONNECT, + reconnectService.onDisconnect + ); + }); + }); + + describe('disconnect', () => { + it('should remove event listeners', () => { + reconnectService.disconnect(); + expect(window.removeEventListener).toHaveBeenCalledWith( + 'online', + reconnectService.handleOnlineEvent + ); + expect(emitter.off).toHaveBeenCalledWith( + BUS_EVENTS.WEBSOCKET_RECONNECT, + reconnectService.onReconnect + ); + expect(emitter.off).toHaveBeenCalledWith( + BUS_EVENTS.WEBSOCKET_DISCONNECT, + reconnectService.onDisconnect + ); + }); + }); + + describe('getSecondsSinceDisconnect', () => { + it('should return 0 if disconnectTime is null', () => { + reconnectService.disconnectTime = null; + expect(reconnectService.getSecondsSinceDisconnect()).toBe(0); + }); + + it('should return the number of seconds since disconnect', () => { + reconnectService.disconnectTime = new Date(); + differenceInSeconds.mockReturnValue(100); + expect(reconnectService.getSecondsSinceDisconnect()).toBe(100); + }); + }); + + describe('handleOnlineEvent', () => { + it('should reload the page if disconnected for more than 3 hours', () => { + reconnectService.getSecondsSinceDisconnect = jest + .fn() + .mockReturnValue(10801); + reconnectService.handleOnlineEvent(); + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('should not reload the page if disconnected for less than 3 hours', () => { + reconnectService.getSecondsSinceDisconnect = jest + .fn() + .mockReturnValue(10799); + reconnectService.handleOnlineEvent(); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); + + describe('fetchConversations', () => { + it('should dispatch updateChatListFilters and fetchAllConversations', async () => { + reconnectService.getSecondsSinceDisconnect = jest + .fn() + .mockReturnValue(100); + await reconnectService.fetchConversations(); + expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', { + page: null, + updatedWithin: 100, + }); + expect(storeMock.dispatch).toHaveBeenCalledWith('fetchAllConversations'); + }); + + it('should dispatch updateChatListFilters and reset updatedWithin', async () => { + reconnectService.getSecondsSinceDisconnect = jest + .fn() + .mockReturnValue(100); + await reconnectService.fetchConversations(); + expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', { + updatedWithin: null, + }); + }); + }); + + describe('fetchFilteredOrSavedConversations', () => { + it('should dispatch fetchFilteredConversations', async () => { + const payload = { test: 'data' }; + await reconnectService.fetchFilteredOrSavedConversations(payload); + expect(storeMock.dispatch).toHaveBeenCalledWith( + 'fetchFilteredConversations', + { queryData: payload, page: 1 } + ); + }); + }); + + describe('fetchConversationsOnReconnect', () => { + it('should fetch filtered or saved conversations if query exists', async () => { + storeMock.getters.getAppliedConversationFiltersQuery = { + payload: [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'], + }, + ], + }; + const spy = jest.spyOn( + reconnectService, + 'fetchFilteredOrSavedConversations' + ); + + await reconnectService.fetchConversationsOnReconnect(); + + expect(spy).toHaveBeenCalledWith( + storeMock.getters.getAppliedConversationFiltersQuery + ); + }); + + it('should fetch all conversations if no query exists', async () => { + storeMock.getters.getAppliedConversationFiltersQuery = []; + storeMock.getters['customViews/getActiveConversationFolder'] = { + query: null, + }; + + const spy = jest.spyOn(reconnectService, 'fetchConversations'); + + await reconnectService.fetchConversationsOnReconnect(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should fetch filtered or saved conversations if active folder query exists and no applied query', async () => { + storeMock.getters.getAppliedConversationFiltersQuery = []; + storeMock.getters['customViews/getActiveConversationFolder'] = { + query: { test: 'activeFolderQuery' }, + }; + + const spy = jest.spyOn( + reconnectService, + 'fetchFilteredOrSavedConversations' + ); + + await reconnectService.fetchConversationsOnReconnect(); + + expect(spy).toHaveBeenCalledWith({ test: 'activeFolderQuery' }); + }); + }); + + describe('fetchConversationMessagesOnReconnect', () => { + it('should dispatch syncActiveConversationMessages if conversationId exists', async () => { + routerMock.currentRoute.params.conversation_id = 1; + await reconnectService.fetchConversationMessagesOnReconnect(); + expect(storeMock.dispatch).toHaveBeenCalledWith( + 'syncActiveConversationMessages', + { conversationId: 1 } + ); + }); + + it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => { + routerMock.currentRoute.params.conversation_id = null; + await reconnectService.fetchConversationMessagesOnReconnect(); + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + 'syncActiveConversationMessages', + expect.anything() + ); + }); + }); + + describe('fetchNotificationsOnReconnect', () => { + it('should dispatch notifications/index', async () => { + const filter = { test: 'filter' }; + await reconnectService.fetchNotificationsOnReconnect(filter); + expect(storeMock.dispatch).toHaveBeenCalledWith('notifications/index', { + ...filter, + page: 1, + }); + }); + }); + + describe('revalidateCaches', () => { + it('should dispatch revalidate actions for labels, inboxes, and teams', async () => { + storeMock.dispatch.mockResolvedValueOnce({ + label: 'labelKey', + inbox: 'inboxKey', + team: 'teamKey', + }); + await reconnectService.revalidateCaches(); + expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys'); + expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', { + newKey: 'labelKey', + }); + expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', { + newKey: 'inboxKey', + }); + expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', { + newKey: 'teamKey', + }); + }); + }); + + describe('handleRouteSpecificFetch', () => { + it('should fetch conversations and messages if current route is a conversation route', async () => { + isAConversationRoute.mockReturnValue(true); + const spyConversations = jest.spyOn( + reconnectService, + 'fetchConversationsOnReconnect' + ); + const spyMessages = jest.spyOn( + reconnectService, + 'fetchConversationMessagesOnReconnect' + ); + await reconnectService.handleRouteSpecificFetch(); + expect(spyConversations).toHaveBeenCalled(); + expect(spyMessages).toHaveBeenCalled(); + }); + + it('should fetch notifications if current route is an inbox view route', async () => { + isAInboxViewRoute.mockReturnValue(true); + const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect'); + await reconnectService.handleRouteSpecificFetch(); + expect(spy).toHaveBeenCalled(); + }); + + it('should fetch notifications if current route is a notification route', async () => { + isNotificationRoute.mockReturnValue(true); + const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect'); + await reconnectService.handleRouteSpecificFetch(); + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('setConversationLastMessageId', () => { + it('should dispatch setConversationLastMessageId if conversationId exists', async () => { + routerMock.currentRoute.params.conversation_id = 1; + await reconnectService.setConversationLastMessageId(); + expect(storeMock.dispatch).toHaveBeenCalledWith( + 'setConversationLastMessageId', + { conversationId: 1 } + ); + }); + + it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => { + routerMock.currentRoute.params.conversation_id = null; + await reconnectService.setConversationLastMessageId(); + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + 'setConversationLastMessageId', + expect.anything() + ); + }); + }); + + describe('onDisconnect', () => { + it('should set disconnectTime and call setConversationLastMessageId', () => { + reconnectService.setConversationLastMessageId = jest.fn(); + reconnectService.onDisconnect(); + expect(reconnectService.disconnectTime).toBeInstanceOf(Date); + expect(reconnectService.setConversationLastMessageId).toHaveBeenCalled(); + }); + }); + + describe('onReconnect', () => { + it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => { + reconnectService.handleRouteSpecificFetch = jest.fn(); + reconnectService.revalidateCaches = jest.fn(); + await reconnectService.onReconnect(); + expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled(); + expect(reconnectService.revalidateCaches).toHaveBeenCalled(); + expect(emitter.emit).toHaveBeenCalledWith( + BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED + ); + }); + }); +}); diff --git a/app/javascript/dashboard/helper/specs/routeHelpers.spec.js b/app/javascript/dashboard/helper/specs/routeHelpers.spec.js index 40467709e..1e100a678 100644 --- a/app/javascript/dashboard/helper/specs/routeHelpers.spec.js +++ b/app/javascript/dashboard/helper/specs/routeHelpers.spec.js @@ -186,4 +186,12 @@ describe('isAInboxViewRoute', () => { expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true); expect(isAInboxViewRoute('inbox_conversation')).toBe(false); }); + + it('returns true if base inbox view route name is provided and includeBase is true', () => { + expect(isAInboxViewRoute('inbox_view', true)).toBe(true); + }); + + it('returns false if base inbox view route name is provided and includeBase is false', () => { + expect(isAInboxViewRoute('inbox_view')).toBe(false); + }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index e7b740f45..bba50316c 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -95,7 +95,9 @@ }, "NETWORK": { "NOTIFICATION": { - "OFFLINE": "Offline" + "OFFLINE": "Offline", + "RECONNECTING": "Reconnecting...", + "RECONNECT_SUCCESS": "Reconnected" }, "BUTTON": { "REFRESH": "Refresh" diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 3b67c69dc..c757b7fcb 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -109,6 +109,10 @@ export const actions = { // silent error } }, + + getCacheKeys: async () => { + return AccountAPI.getCacheKeys(); + }, }; export const mutations = { diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index adfb74202..769354ed2 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -41,11 +41,14 @@ export const mutations = { newAllConversations[indexInCurrentList] = conversation; } else { // If the conversation is already in the list and selectedChatId is the same, - // replace all data except the messages array + // replace all data except the messages array, attachments, dataFetched, allMessagesLoaded const existingConversation = newAllConversations[indexInCurrentList]; newAllConversations[indexInCurrentList] = { ...conversation, + allMessagesLoaded: existingConversation.allMessagesLoaded, messages: existingConversation.messages, + dataFetched: existingConversation.dataFetched, + attachments: existingConversation.attachments, }; } }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 2694e3456..b10a24ea0 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -307,9 +307,17 @@ describe('#mutations', () => { expect(state.allConversations).toEqual(data); }); - it('set all conversation in reconnect if selected chat id and conversation id is the same then do not update messages', () => { + it('set all conversation in reconnect if selected chat id and conversation id is the same then do not update messages, attachments, dataFetched, allMessagesLoaded', () => { const state = { - allConversations: [{ id: 1, messages: [{ id: 1, content: 'test' }] }], + allConversations: [ + { + id: 1, + messages: [{ id: 1, content: 'test' }], + attachments: [{ id: 1, name: 'test1.png' }], + dataFetched: true, + allMessagesLoaded: true, + }, + ], selectedChatId: 1, }; const data = [ @@ -317,10 +325,20 @@ describe('#mutations', () => { id: 1, name: 'test', messages: [{ id: 1, content: 'updated message' }], + attachments: [{ id: 1, name: 'test.png' }], + dataFetched: true, + allMessagesLoaded: true, }, ]; const expected = [ - { id: 1, name: 'test', messages: [{ id: 1, content: 'test' }] }, + { + id: 1, + name: 'test', + messages: [{ id: 1, content: 'test' }], + attachments: [{ id: 1, name: 'test1.png' }], + dataFetched: true, + allMessagesLoaded: true, + }, ]; mutations[types.SET_ALL_CONVERSATION](state, data); expect(state.allConversations).toEqual(expected); diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index f58c2a7dd..561129c00 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -199,6 +199,7 @@ "video-add-outline": "M13.75 4.5A3.25 3.25 0 0 1 17 7.75v.173l3.864-2.318A.75.75 0 0 1 22 6.248V17.75a.75.75 0 0 1-1.136.643L17 16.075v.175a3.25 3.25 0 0 1-3.25 3.25h-1.063c.154-.478.255-.98.294-1.5h.769a1.75 1.75 0 0 0 1.75-1.75v-8.5A1.75 1.75 0 0 0 13.75 6h-8.5A1.75 1.75 0 0 0 3.5 7.75v3.982A6.517 6.517 0 0 0 2 12.81V7.75A3.25 3.25 0 0 1 5.25 4.5h8.5Zm6.75 3.073L17 9.674v4.651l3.5 2.1V7.573ZM12 17.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0ZM7 18l.001 2.503a.5.5 0 1 1-1 0V18H3.496a.5.5 0 0 1 0-1H6v-2.5a.5.5 0 1 1 1 0V17h2.497a.5.5 0 0 1 0 1H7Z", "warning-outline": "M10.91 2.782a2.25 2.25 0 0 1 2.975.74l.083.138 7.759 14.009a2.25 2.25 0 0 1-1.814 3.334l-.154.006H4.243a2.25 2.25 0 0 1-2.041-3.197l.072-.143L10.031 3.66a2.25 2.25 0 0 1 .878-.878Zm9.505 15.613-7.76-14.008a.75.75 0 0 0-1.254-.088l-.057.088-7.757 14.008a.75.75 0 0 0 .561 1.108l.095.006h15.516a.75.75 0 0 0 .696-1.028l-.04-.086-7.76-14.008 7.76 14.008ZM12 16.002a.999.999 0 1 1 0 1.997.999.999 0 0 1 0-1.997ZM11.995 8.5a.75.75 0 0 1 .744.647l.007.102.004 4.502a.75.75 0 0 1-1.494.103l-.006-.102-.004-4.502a.75.75 0 0 1 .75-.75Z", "wifi-off-outline": "m12.858 14.273 7.434 7.434a1 1 0 0 0 1.414-1.414l-17.999-18a1 1 0 1 0-1.414 1.414L5.39 6.804c-.643.429-1.254.927-1.821 1.495a12.382 12.382 0 0 0-1.39 1.683 1 1 0 0 0 1.644 1.14c.363-.524.761-1.01 1.16-1.41a9.94 9.94 0 0 1 1.855-1.46L7.99 9.405a8.14 8.14 0 0 0-3.203 3.377 1 1 0 0 0 1.784.903 6.08 6.08 0 0 1 1.133-1.563 6.116 6.116 0 0 1 1.77-1.234l1.407 1.407A5.208 5.208 0 0 0 8.336 13.7a5.25 5.25 0 0 0-1.09 1.612 1 1 0 0 0 1.832.802c.167-.381.394-.722.672-1a3.23 3.23 0 0 1 3.108-.841Zm-1.332-5.93 2.228 2.229a6.1 6.1 0 0 1 2.616 1.55c.444.444.837.995 1.137 1.582a1 1 0 1 0 1.78-.911 8.353 8.353 0 0 0-1.503-2.085 8.108 8.108 0 0 0-6.258-2.365ZM8.51 5.327l1.651 1.651a9.904 9.904 0 0 1 10.016 4.148 1 1 0 1 0 1.646-1.136A11.912 11.912 0 0 0 8.51 5.327Zm4.552 11.114a1.501 1.501 0 1 1-2.123 2.123 1.501 1.501 0 0 1 2.123-2.123Z", + "wifi-outline": "M17.745 10.75a8.292 8.292 0 0 1 1.492 2.07a.75.75 0 1 1-1.336.683a6.797 6.797 0 0 0-1.217-1.692A6.562 6.562 0 0 0 6.19 13.484a.75.75 0 1 1-1.338-.677a8.062 8.062 0 0 1 12.893-2.057Zm-2.102 3.07c.448.447.816.997 1.072 1.582a.75.75 0 1 1-1.374.602a3.719 3.719 0 0 0-.759-1.124a3.592 3.592 0 0 0-5.08 0c-.31.31-.562.689-.747 1.11a.75.75 0 1 1-1.374-.6a5.11 5.11 0 0 1 1.061-1.57a5.092 5.092 0 0 1 7.201 0Zm4.805-5.541c.51.509.99 1.09 1.408 1.697a.75.75 0 1 1-1.234.852a10.822 10.822 0 0 0-1.235-1.489c-4.08-4.08-10.695-4.08-14.775 0c-.422.422-.84.934-1.222 1.484a.75.75 0 0 1-1.232-.855c.43-.62.904-1.2 1.394-1.69c4.665-4.665 12.23-4.665 16.896 0Zm-7.387 8.16a1.5 1.5 0 1 1-2.122 2.122a1.5 1.5 0 0 1 2.122-2.122Z", "whatsapp-outline": "M19.05 4.91A9.816 9.816 0 0 0 12.04 2c-5.46 0-9.91 4.45-9.91 9.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38c1.45.79 3.08 1.21 4.74 1.21c5.46 0 9.91-4.45 9.91-9.91c0-2.65-1.03-5.14-2.9-7.01zm-7.01 15.24c-1.48 0-2.93-.4-4.2-1.15l-.3-.18l-3.12.82l.83-3.04l-.2-.31a8.264 8.264 0 0 1-1.26-4.38c0-4.54 3.7-8.24 8.24-8.24c2.2 0 4.27.86 5.82 2.42a8.183 8.183 0 0 1 2.41 5.83c.02 4.54-3.68 8.23-8.22 8.23zm4.52-6.16c-.25-.12-1.47-.72-1.69-.81c-.23-.08-.39-.12-.56.12c-.17.25-.64.81-.78.97c-.14.17-.29.19-.54.06c-.25-.12-1.05-.39-1.99-1.23c-.74-.66-1.23-1.47-1.38-1.72c-.14-.25-.02-.38.11-.51c.11-.11.25-.29.37-.43s.17-.25.25-.41c.08-.17.04-.31-.02-.43s-.56-1.34-.76-1.84c-.2-.48-.41-.42-.56-.43h-.48c-.17 0-.43.06-.66.31c-.22.25-.86.85-.86 2.07c0 1.22.89 2.4 1.01 2.56c.12.17 1.75 2.67 4.23 3.74c.59.26 1.05.41 1.41.52c.59.19 1.13.16 1.56.1c.48-.07 1.47-.6 1.67-1.18c.21-.58.21-1.07.14-1.18s-.22-.16-.47-.28z", "subtract-solid": "M3.997 13H20a1 1 0 1 0 0-2H3.997a1 1 0 1 0 0 2Z", "table-switch-outline": [ diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js index dc6892e26..2229a8b4c 100644 --- a/app/javascript/shared/constants/busEvents.js +++ b/app/javascript/shared/constants/busEvents.js @@ -8,6 +8,8 @@ export const BUS_EVENTS = { TOGGLE_SIDEMENU: 'TOGGLE_SIDEMENU', ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL', WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT', + WEBSOCKET_RECONNECT: 'WEBSOCKET_RECONNECT', + WEBSOCKET_RECONNECT_COMPLETED: 'WEBSOCKET_RECONNECT_COMPLETED', TOGGLE_REPLY_TO_MESSAGE: 'TOGGLE_REPLY_TO_MESSAGE', SHOW_TOAST: 'newToastMessage', NEW_CONVERSATION_MODAL: 'newConversationModal', diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 270a2b809..3eb61a80a 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -1,6 +1,4 @@ import { createConsumer } from '@rails/actioncable'; -import { BUS_EVENTS } from 'shared/constants/busEvents'; -import { emitter } from 'shared/helpers/mitt'; const PRESENCE_INTERVAL = 20000; const RECONNECT_INTERVAL = 1000; @@ -28,8 +26,6 @@ class BaseActionCableConnector { BaseActionCableConnector.isDisconnected = true; this.onDisconnected(); this.initReconnectTimer(); - // TODO: Remove this after completing the conversation list refetching - emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT); }, } ); diff --git a/package.json b/package.json index bfce2e0be..0163878aa 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@tailwindcss/typography": "^0.5.9", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", + "@vueuse/core": "^10.10.0", "activestorage": "^5.2.6", "autoprefixer": "^10.4.14", "axios": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 288115117..538954eda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6550,6 +6550,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + "@types/webpack-env@^1.16.0": version "1.18.1" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.18.1.tgz#49699bb508961e14a3bfb68c78cd87b296889d1d" @@ -6818,6 +6823,28 @@ dependencies: vue-demi "^0.13.11" +"@vueuse/core@^10.10.0": + version "10.10.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.10.0.tgz#05a98d3c5674762455a2c552c915d461d83e6490" + integrity sha512-vexJ/YXYs2S42B783rI95lMt3GzEwkxzC8Hb0Ndpd8rD+p+Lk/Za4bd797Ym7yq4jXqdSyj3JLChunF/vyYjUw== + dependencies: + "@types/web-bluetooth" "^0.0.20" + "@vueuse/metadata" "10.10.0" + "@vueuse/shared" "10.10.0" + vue-demi ">=0.14.7" + +"@vueuse/metadata@10.10.0": + version "10.10.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.10.0.tgz#53e61e9380670e342cbe6e03d852f3319308cb5b" + integrity sha512-UNAo2sTCAW5ge6OErPEHb5z7NEAg3XcO9Cj7OK45aZXfLLH1QkexDcZD77HBi5zvEiLOm1An+p/4b5K3Worpug== + +"@vueuse/shared@10.10.0": + version "10.10.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.10.0.tgz#93f7c2210151ff43c2c7677963f7aa3aef5d9896" + integrity sha512-2aW33Ac0Uk0U+9yo3Ypg9s5KcR42cuehRWl7vnUHadQyFvCktseyxxEPBi1Eiq4D2yBGACOnqLZpx1eMc7g5Og== + dependencies: + vue-demi ">=0.14.7" + "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -20597,6 +20624,11 @@ vue-color@2.8.1: material-colors "^1.0.0" tinycolor2 "^1.1.2" +vue-demi@>=0.14.7: + version "0.14.8" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a" + integrity sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q== + vue-demi@^0.13.11: version "0.13.11" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"