diff --git a/apps/meteor/client/components/AutoupdateToastMessage.tsx b/apps/meteor/client/components/AutoupdateToastMessage.tsx new file mode 100644 index 00000000000..84e2fe45d66 --- /dev/null +++ b/apps/meteor/client/components/AutoupdateToastMessage.tsx @@ -0,0 +1,30 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { useIdleDetection } from '../hooks/useIdleDetection'; + +export const AutoupdateToastMessage = () => { + const { t } = useTranslation(); + useIdleDetection( + () => { + window.location.reload(); + }, + { awayOnWindowBlur: true }, + ); + + return ( + + {t('An_update_is_available')} + + + ); +}; diff --git a/apps/meteor/client/hooks/useAutoupdate.spec.ts b/apps/meteor/client/hooks/useAutoupdate.spec.ts new file mode 100644 index 00000000000..5e4aadee690 --- /dev/null +++ b/apps/meteor/client/hooks/useAutoupdate.spec.ts @@ -0,0 +1,54 @@ +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { renderHook } from '@testing-library/react'; + +import { useAutoupdate } from './useAutoupdate'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + useToastMessageDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('useAutoupdate', () => { + it('should add event listener to document on mount', () => { + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + renderHook(() => useAutoupdate()); + expect(addEventListenerSpy).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function)); + }); + + it('should remove event listener on unmount', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + const { unmount } = renderHook(() => useAutoupdate()); + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalled(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function)); + }); + + it('should call toast function when client_changed event is fired', () => { + const toastMock = jest.fn(); + (useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock); + renderHook(() => useAutoupdate()); + + const event = new Event('client_changed'); + document.dispatchEvent(event); + + expect(toastMock).toHaveBeenCalledTimes(1); + expect(toastMock).toHaveBeenCalledWith({ + type: 'info', + message: expect.anything(), + options: { isPersistent: true }, + }); + }); + + it('should not call toast function when client_changed event is not fired', () => { + const toastMock = jest.fn(); + (useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock); + renderHook(() => useAutoupdate()); + expect(toastMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/hooks/useAutoupdate.tsx b/apps/meteor/client/hooks/useAutoupdate.tsx new file mode 100644 index 00000000000..6d73dee3560 --- /dev/null +++ b/apps/meteor/client/hooks/useAutoupdate.tsx @@ -0,0 +1,32 @@ +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AutoupdateToastMessage } from '../components/AutoupdateToastMessage'; + +export const useAutoupdate = () => { + const toast = useToastMessageDispatch(); + const { t } = useTranslation(); + const isDevMode = process.env.NODE_ENV === 'development'; + + useEffect(() => { + const fn = () => { + // To test this feature locally, comment the if statement below + if (isDevMode) { + window.location.reload(); + return; + } + toast({ + type: 'info', + options: { isPersistent: true }, + message: , + }); + }; + + document.addEventListener('client_changed', fn); + + return () => { + document.removeEventListener('client_changed', fn); + }; + }, [isDevMode, t, toast]); +}; diff --git a/apps/meteor/client/hooks/useIdleDetection.ts b/apps/meteor/client/hooks/useIdleDetection.ts new file mode 100644 index 00000000000..326d0f3d5b8 --- /dev/null +++ b/apps/meteor/client/hooks/useIdleDetection.ts @@ -0,0 +1,56 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEffect } from 'react'; + +const events = ['mousemove', 'mousedown', 'touchend', 'touchstart', 'keypress']; + +/** + * useIdleDetection is a custom hook that triggers a callback function when the user is detected to be idle. + * The idle state is determined based on the absence of certain user interactions for a specified time period. + * + * @param callback - The callback function to be called when the user is detected to be idle. + * @param options - An optional configuration object. + * @param options.time - The time in milliseconds to consider the user idle. Defaults to 600000 ms (10 minutes). + * @param options.awayOnWindowBlur - A boolean flag to trigger the callback when the window loses focus. Defaults to false. + * + */ + +export const useIdleDetection = (callback: () => void, { time = 600000, awayOnWindowBlur = false } = {}) => { + const stableCallback = useEffectEvent(callback); + + useEffect(() => { + let interval: ReturnType; + const handleIdle = () => { + clearTimeout(interval); + interval = setTimeout(() => { + document.dispatchEvent(new Event('idle')); + }, time); + }; + + handleIdle(); + + events.forEach((key) => document.addEventListener(key, handleIdle)); + return () => { + clearTimeout(interval); + events.forEach((key) => document.removeEventListener(key, handleIdle)); + }; + }, [stableCallback, time]); + + useEffect(() => { + if (!awayOnWindowBlur) { + return; + } + + window.addEventListener('blur', stableCallback); + return () => { + window.removeEventListener('blur', stableCallback); + }; + }, [awayOnWindowBlur, stableCallback]); + + useEffect(() => { + document.addEventListener('idle', stableCallback); + + return () => { + document.removeEventListener('idle', stableCallback); + }; + }, [stableCallback]); +}; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index 5a3bab6d6b3..23a1373f6cf 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -20,6 +20,7 @@ import { useLivechatEnterprise } from '../../../app/livechat-enterprise/hooks/us import { useNextcloud } from '../../../app/nextcloud/client/useNextcloud'; import { useTokenPassAuth } from '../../../app/tokenpass/client/hooks/useTokenPassAuth'; import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking'; +import { useAutoupdate } from '../../hooks/useAutoupdate'; import { useLoadRoomForAllowedAnonymousRead } from '../../hooks/useLoadRoomForAllowedAnonymousRead'; import { useNotifyUser } from '../../hooks/useNotifyUser'; import { appLayout } from '../../lib/appLayout'; @@ -57,6 +58,7 @@ const AppLayout = () => { useOTRMessaging(); useUpdateVideoConfUser(); useStoreCookiesOnLogin(); + useAutoupdate(); const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot); diff --git a/apps/meteor/packages/autoupdate/autoupdate_client.js b/apps/meteor/packages/autoupdate/autoupdate_client.js index dfd924c7f1c..c7b07ca85eb 100644 --- a/apps/meteor/packages/autoupdate/autoupdate_client.js +++ b/apps/meteor/packages/autoupdate/autoupdate_client.js @@ -117,9 +117,7 @@ Autoupdate._retrySubscription = () => { doc.versionNonRefreshable, `Page will reload in ${reloadDelayInSeconds} seconds`, ); - setTimeout(() => { - Package.reload.Reload._reload(); - }, reloadDelayInSeconds * 1000); + document.dispatchEvent(new Event('client_changed')); } return; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b47f90547c2..3fce23220d0 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6787,5 +6787,7 @@ "Recent": "Recent", "On_All_Contacts": "On All Contacts", "Once": "Once", - "__unreadTitle__from__roomTitle__": "{{unreadTitle}} from {{roomTitle}}" -} \ No newline at end of file + "__unreadTitle__from__roomTitle__": "{{unreadTitle}} from {{roomTitle}}", + "An_update_is_available": "An update is available", + "Reload_to_update": "Reload to update" +}