feat: Reconnect logic (#9453)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sivin Varghese
2024-06-03 15:54:19 +05:30
committed by GitHub
parent 00da2ac847
commit af90f21cfd
19 changed files with 774 additions and 93 deletions

View File

@@ -27,6 +27,7 @@
<script>
import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
@@ -43,6 +44,7 @@ import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default {
name: 'App',
@@ -64,6 +66,7 @@ export default {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
},
@@ -102,6 +105,11 @@ export default {
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
},
beforeDestroy() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
},
methods: {
initializeColorTheme() {
setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -125,6 +133,7 @@ export default {
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {

View File

@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();

View File

@@ -1,26 +1,133 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useEmitter } from 'dashboard/composables/emitter';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import { useEventListener } from '@vueuse/core';
const { t } = useI18n();
const route = useRoute();
const RECONNECTED_BANNER_TIMEOUT = 2000;
const showNotification = ref(!navigator.onLine);
const isDisconnected = ref(false);
const isReconnecting = ref(false);
const isReconnected = ref(false);
let reconnectTimeout = null;
const bannerText = computed(() => {
if (isReconnecting.value) return t('NETWORK.NOTIFICATION.RECONNECTING');
if (isReconnected.value) return t('NETWORK.NOTIFICATION.RECONNECT_SUCCESS');
return t('NETWORK.NOTIFICATION.OFFLINE');
});
const iconName = computed(() => (isReconnected.value ? 'wifi' : 'wifi-off'));
const canRefresh = computed(
() => !isReconnecting.value && !isReconnected.value
);
const refreshPage = () => {
window.location.reload();
};
const closeNotification = () => {
showNotification.value = false;
isReconnected.value = false;
clearTimeout(reconnectTimeout);
};
const isInAnyOfTheRoutes = routeName => {
return (
isAConversationRoute(routeName, true) ||
isAInboxViewRoute(routeName, true) ||
isNotificationRoute(routeName, true)
);
};
const updateWebsocketStatus = () => {
isDisconnected.value = true;
showNotification.value = true;
};
const handleReconnectionCompleted = () => {
isDisconnected.value = false;
isReconnecting.value = false;
isReconnected.value = true;
showNotification.value = true;
reconnectTimeout = setTimeout(closeNotification, RECONNECTED_BANNER_TIMEOUT);
};
const handleReconnecting = () => {
if (isInAnyOfTheRoutes(route.name)) {
isReconnecting.value = true;
isReconnected.value = false;
showNotification.value = true;
} else {
handleReconnectionCompleted();
}
};
const updateOnlineStatus = event => {
// Case: Websocket is not disconnected
// If the app goes offline, show the notification
// If the app goes online, close the notification
// Case: Websocket is disconnected
// If the app goes offline, show the notification
// If the app goes online but the websocket is disconnected, don't close the notification
// If the app goes online and the websocket is not disconnected, close the notification
if (event.type === 'offline') {
showNotification.value = true;
} else if (event.type === 'online' && !isDisconnected.value) {
handleReconnectionCompleted();
}
};
useEventListener('online', updateOnlineStatus);
useEventListener('offline', updateOnlineStatus);
useEmitter(BUS_EVENTS.WEBSOCKET_DISCONNECT, updateWebsocketStatus);
useEmitter(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED,
handleReconnectionCompleted
);
useEmitter(BUS_EVENTS.WEBSOCKET_RECONNECT, handleReconnecting);
onBeforeUnmount(() => {
clearTimeout(reconnectTimeout);
});
</script>
<template>
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
<div
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
>
<fluent-icon
icon="wifi-off"
:icon="iconName"
class="text-yellow-700/50 dark:text-yellow-50"
size="18"
/>
<span
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
class="px-2 text-xs font-medium tracking-wide text-yellow-700/70 dark:text-yellow-50"
>
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
{{ bannerText }}
</span>
<woot-button
v-if="canRefresh"
:title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small"
color-scheme="warning"
icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage"
/>
<woot-button
@@ -34,55 +141,3 @@
</div>
</transition>
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
mixins: [globalConfigMixin],
data() {
return {
showNotification: !navigator.onLine,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
this.$emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
});
},
beforeDestroy() {
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
refreshPage() {
window.location.reload();
},
closeNotification() {
this.showNotification = false;
},
updateOnlineStatus(event) {
if (event.type === 'offline') {
this.showNotification = true;
}
},
},
};
</script>

View File

@@ -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 };

View File

@@ -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: `
<div>
Hello world
</div>
`,
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);
});
});

View File

@@ -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;

View File

@@ -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 => {

View File

@@ -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';

View File

@@ -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
);
});
});
});

View File

@@ -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);
});
});

View File

@@ -95,7 +95,9 @@
},
"NETWORK": {
"NOTIFICATION": {
"OFFLINE": "Offline"
"OFFLINE": "Offline",
"RECONNECTING": "Reconnecting...",
"RECONNECT_SUCCESS": "Reconnected"
},
"BUTTON": {
"REFRESH": "Refresh"

View File

@@ -109,6 +109,10 @@ export const actions = {
// silent error
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
};
export const mutations = {

View File

@@ -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,
};
}
});

View File

@@ -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);