Skip to content

Commit

Permalink
feat: Reconnect logic (chatwoot#9453)
Browse files Browse the repository at this point in the history
Co-authored-by: Muhsin Keloth <[email protected]>
Co-authored-by: Shivam Mishra <[email protected]>
  • Loading branch information
3 people authored Jun 3, 2024
1 parent 00da2ac commit af90f21
Show file tree
Hide file tree
Showing 19 changed files with 774 additions and 93 deletions.
9 changes: 9 additions & 0 deletions app/javascript/dashboard/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +44,7 @@ import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default {
name: 'App',
Expand All @@ -64,6 +66,7 @@ export default {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
},
Expand Down Expand Up @@ -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);
Expand All @@ -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 => {
Expand Down
7 changes: 7 additions & 0 deletions app/javascript/dashboard/api/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
171 changes: 113 additions & 58 deletions app/javascript/dashboard/components/NetworkNotification.vue
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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>
20 changes: 20 additions & 0 deletions app/javascript/dashboard/composables/emitter.js
Original file line number Diff line number Diff line change
@@ -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 };
51 changes: 51 additions & 0 deletions app/javascript/dashboard/composables/spec/emitter.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit af90f21

Please sign in to comment.