feat(update-notification): implement update notification system for new versions

- Added a service worker message handler to check for updates and notify users.
- Created `UpdateNotification` component to display update prompts with options to reload or dismiss.
- Introduced `UpdateNotificationProvider` to manage update state and notifications globally.
- Implemented `useUpdateChecker` hook for periodic update checks and user notification management.
- Updated localization files to include new strings related to update notifications.
- Enhanced service worker functionality to support hard reloads and update checks.
This commit is contained in:
Chamika J
2025-07-31 16:12:04 +05:30
parent 14c89dec24
commit 7c42087854
14 changed files with 454 additions and 14 deletions

View File

@@ -6,5 +6,12 @@
"reconnecting": "Jeni shkëputur nga serveri.",
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
"connection-restored": "U lidhët me serverin me sukses",
"cancel": "Anulo"
"cancel": "Anulo",
"update-available": "Worklenz u përditesua!",
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
"update-now": "Përditeso tani",
"update-later": "Më vonë",
"updating": "Duke u përditesuar..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"update-available": "Worklenz aktualisiert!",
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
"update-now": "Jetzt aktualisieren",
"update-later": "Später",
"updating": "Wird aktualisiert..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully",
"cancel": "Cancel"
"cancel": "Cancel",
"update-available": "Worklenz Updated!",
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
"update-now": "Update Now",
"update-later": "Later",
"updating": "Updating..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "¡Worklenz actualizado!",
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
"update-now": "Actualizar ahora",
"update-later": "Más tarde",
"updating": "Actualizando..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "Worklenz atualizado!",
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
"update-now": "Atualizar agora",
"update-later": "Mais tarde",
"updating": "Atualizando..."
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器",
"cancel": "取消"
"cancel": "取消",
"update-available": "Worklenz 已更新!",
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
"update-now": "立即更新",
"update-later": "稍后",
"updating": "正在更新..."
}

View File

@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CHECK_FOR_UPDATES':
checkForUpdates().then((hasUpdates) => {
event.ports[0].postMessage({ hasUpdates });
});
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
@@ -349,6 +355,44 @@ async function clearAllCaches() {
console.log('Service Worker: All caches cleared');
}
async function checkForUpdates() {
try {
// Check if there's a new service worker available
const registration = await self.registration.update();
const hasNewWorker = registration.installing || registration.waiting;
if (hasNewWorker) {
console.log('Service Worker: New version detected');
return true;
}
// Also check if the main app files have been updated by trying to fetch index.html
// and comparing it with the cached version
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
const cachedResponse = await cache.match('/');
const networkResponse = await fetch('/', { cache: 'no-cache' });
if (cachedResponse && networkResponse.ok) {
const cachedContent = await cachedResponse.text();
const networkContent = await networkResponse.text();
if (cachedContent !== networkContent) {
console.log('Service Worker: App content has changed');
return true;
}
}
} catch (error) {
console.log('Service Worker: Could not check for content updates', error);
}
return false;
} catch (error) {
console.error('Service Worker: Error checking for updates', error);
return false;
}
}
async function handleLogout() {
try {
// Clear all caches

View File

@@ -6,6 +6,7 @@ import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
import { UpdateNotificationProvider } from './components/update-notification';
// Routes
import router from './app/routes';
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
<UpdateNotificationProvider>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
</UpdateNotificationProvider>
</ThemeWrapper>
</Suspense>
);

View File

@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
export { default as Progress } from './Progress';
export { default as Tag } from './Tag';
export { default as Tooltip } from './Tooltip';
// Update Notification Components
export * from './update-notification';

View File

@@ -0,0 +1,121 @@
// Update Notification Component
// Shows a notification when new build is available and provides update options
import React from 'react';
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
const { Text, Title } = Typography;
interface UpdateNotificationProps {
visible: boolean;
onClose: () => void;
onUpdate: () => void;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
visible,
onClose,
onUpdate
}) => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = React.useState(false);
const { hardReload } = useServiceWorker();
const handleUpdate = async () => {
setIsUpdating(true);
try {
if (hardReload) {
await hardReload();
} else {
// Fallback to regular reload
window.location.reload();
}
onUpdate();
} catch (error) {
console.error('Error during update:', error);
// Fallback to regular reload
window.location.reload();
}
};
const handleLater = () => {
onClose();
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<DownloadOutlined style={{ color: '#1890ff' }} />
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
{t('update-available')}
</Title>
</div>
}
open={visible}
onCancel={handleLater}
footer={null}
centered
closable={false}
maskClosable={false}
width={460}
styles={{
body: { padding: '20px 24px' }
}}
>
<div style={{ marginBottom: '20px' }}>
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
{t('update-description')}
</Text>
<br />
<br />
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
{t('update-instruction')}
</Text>
</div>
<div style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '6px',
padding: '12px',
marginBottom: '20px'
}}>
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
{t('update-whats-new', {
interpolation: { escapeValue: false }
})}
</Text>
</div>
<Space
style={{
width: '100%',
justifyContent: 'flex-end'
}}
size="middle"
>
<Button
icon={<CloseOutlined />}
onClick={handleLater}
disabled={isUpdating}
>
{t('update-later')}
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={isUpdating}
onClick={handleUpdate}
>
{isUpdating ? t('updating') : t('update-now')}
</Button>
</Space>
</Modal>
);
};
export default UpdateNotification;

View File

@@ -0,0 +1,50 @@
// Update Notification Provider
// Provides global update notification management
import React from 'react';
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
import UpdateNotification from './UpdateNotification';
interface UpdateNotificationProviderProps {
children: React.ReactNode;
checkInterval?: number;
enableAutoCheck?: boolean;
}
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
children,
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true
}) => {
const {
showUpdateNotification,
setShowUpdateNotification,
dismissUpdate
} = useUpdateChecker({
checkInterval,
enableAutoCheck,
showNotificationOnUpdate: true
});
const handleClose = () => {
dismissUpdate();
};
const handleUpdate = () => {
// The hardReload function in UpdateNotification will handle the actual update
setShowUpdateNotification(false);
};
return (
<>
{children}
<UpdateNotification
visible={showUpdateNotification}
onClose={handleClose}
onUpdate={handleUpdate}
/>
</>
);
};
export default UpdateNotificationProvider;

View File

@@ -0,0 +1,2 @@
export { default as UpdateNotification } from './UpdateNotification';
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';

View File

@@ -0,0 +1,141 @@
// Update Checker Hook
// Periodically checks for app updates and manages update notifications
import React from 'react';
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
interface UseUpdateCheckerOptions {
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
}
interface UseUpdateCheckerReturn {
hasUpdate: boolean;
isChecking: boolean;
lastChecked: Date | null;
checkForUpdates: () => Promise<void>;
dismissUpdate: () => void;
showUpdateNotification: boolean;
setShowUpdateNotification: (show: boolean) => void;
}
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
const {
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true,
showNotificationOnUpdate = true
} = options;
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
const [hasUpdate, setHasUpdate] = React.useState(false);
const [isChecking, setIsChecking] = React.useState(false);
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
const [updateDismissed, setUpdateDismissed] = React.useState(false);
// Check for updates function
const checkForUpdates = React.useCallback(async () => {
if (!serviceWorkerCheckUpdates || isChecking) return;
setIsChecking(true);
try {
const hasUpdates = await serviceWorkerCheckUpdates();
setHasUpdate(hasUpdates);
setLastChecked(new Date());
// Show notification if update found and user hasn't dismissed it
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
setShowUpdateNotification(true);
}
console.log('Update check completed:', { hasUpdates });
} catch (error) {
console.error('Error checking for updates:', error);
} finally {
setIsChecking(false);
}
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
// Dismiss update notification
const dismissUpdate = React.useCallback(() => {
setUpdateDismissed(true);
setShowUpdateNotification(false);
}, []);
// Set up automatic checking interval
React.useEffect(() => {
if (!enableAutoCheck || !swManager) return;
// Initial check after a short delay
const initialTimeout = setTimeout(() => {
checkForUpdates();
}, 10000); // 10 seconds after component mount
// Set up interval for periodic checks
const intervalId = setInterval(() => {
checkForUpdates();
}, checkInterval);
return () => {
clearTimeout(initialTimeout);
clearInterval(intervalId);
};
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
// Listen for visibility change to check for updates when user returns to tab
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleVisibilityChange = () => {
if (!document.hidden && swManager) {
// Check for updates when user returns to the tab
setTimeout(() => {
checkForUpdates();
}, 2000); // 2 second delay
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enableAutoCheck, swManager, checkForUpdates]);
// Listen for focus events to check for updates
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleFocus = () => {
if (swManager && !isChecking) {
// Check for updates when window regains focus
setTimeout(() => {
checkForUpdates();
}, 1000); // 1 second delay
}
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
// Reset dismissed state when new update is found
React.useEffect(() => {
if (hasUpdate && updateDismissed) {
setUpdateDismissed(false);
}
}, [hasUpdate, updateDismissed]);
return {
hasUpdate,
isChecking,
lastChecked,
checkForUpdates,
dismissUpdate,
showUpdateNotification,
setShowUpdateNotification
};
}

View File

@@ -198,6 +198,17 @@ export class ServiceWorkerManager {
}
}
// Check for updates
async checkForUpdates(): Promise<boolean> {
try {
const response = await this.sendMessage('CHECK_FOR_UPDATES');
return response.hasUpdates;
} catch (error) {
console.error('Failed to check for updates:', error);
return false;
}
}
// Force update service worker
async forceUpdate(): Promise<void> {
if (!this.registration) return;
@@ -212,6 +223,27 @@ export class ServiceWorkerManager {
}
}
// Perform hard reload (clear cache and reload)
async hardReload(): Promise<void> {
try {
// Clear all caches first
await this.clearCache();
// Force update the service worker
if (this.registration) {
await this.registration.update();
await this.sendMessage('SKIP_WAITING');
}
// Perform hard reload by clearing browser cache
window.location.reload();
} catch (error) {
console.error('Failed to perform hard reload:', error);
// Fallback to regular reload
window.location.reload();
}
}
// Check if app is running offline
isOffline(): boolean {
return !navigator.onLine;
@@ -268,6 +300,8 @@ export function useServiceWorker() {
swManager,
clearCache: () => swManager?.clearCache(),
forceUpdate: () => swManager?.forceUpdate(),
hardReload: () => swManager?.hardReload(),
checkForUpdates: () => swManager?.checkForUpdates(),
getVersion: () => swManager?.getVersion(),
};
}