diff --git a/src/i18n/de-DE.i18n.json b/src/i18n/de-DE.i18n.json
index 1b0fcebf66..4ce4725386 100644
--- a/src/i18n/de-DE.i18n.json
+++ b/src/i18n/de-DE.i18n.json
@@ -64,20 +64,13 @@
"announcement": "Wählen Sie den zu teilenden Bildschirm aus"
},
"update": {
- "title": "Aktualisierung verfügbar",
"announcement": "Neue Aktualisierung verfügbar",
"message": "Eine neue Version der Rocket.Chat Desktop App ist verfügbar!",
- "currentVersion": "Aktuelle Version:",
- "newVersion": "Neue Version:",
+ "currentVersion": "Aktuelle Version",
+ "newVersion": "Neue Version",
"install": "Update installieren",
- "remindLater": "Später erinnern",
"skip": "Diese Version überspringen"
},
- "updateDownloading": {
- "title": "Aktualisierung wird heruntergeladen",
- "message": "Sie werden benachrichtigt, wenn die Aktualisierung bereit zur Installation ist",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Wird später installiert",
"message": "Die Aktualisierung wird durchgeführt, wenn Sie die App beenden",
diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json
index 4b6b8dcd0b..917d5df9fe 100644
--- a/src/i18n/en.i18n.json
+++ b/src/i18n/en.i18n.json
@@ -23,6 +23,7 @@
"checkUpdates": "Check for Updates",
"checkUpdatesOnStart": "Check for Updates on Start",
"noUpdatesAvailable": "No updates are available.",
+ "updateAvailable": "Version {{- version}} is available",
"copyright": "Copyright {{copyright}}",
"errorWhenLookingForUpdates": "An error has occurred when looking for updates",
"updateChannel": {
@@ -80,19 +81,12 @@
"announcement": "Select a screen to share"
},
"update": {
- "title": "Update Available",
- "announcement": "New Update is Available",
- "message": "A new version of the Rocket.Chat Desktop App is available!",
- "currentVersion": "Current Version:",
- "newVersion": "New Version:",
- "install": "Install Update",
- "remindLater": "Remind Me Later",
- "skip": "Skip This Version"
- },
- "updateDownloading": {
- "title": "Downloading Update",
- "message": "You will be notified when the update is ready to be installed",
- "ok": "OK"
+ "announcement": "New version is available",
+ "message": "A new version of the Rocket.Chat desktop app is available!",
+ "currentVersion": "Current version",
+ "newVersion": "New version",
+ "install": "Install update",
+ "skip": "Skip this version"
},
"updateInstallLater": {
"title": "Installing Later",
@@ -418,6 +412,7 @@
"copy": "&Copy",
"cut": "Cu&t",
"developerMode": "Developer Mode",
+ "simulateUpdate": "Simulate Update Flow",
"disableGpu": "Disable GPU",
"documentation": "Documentation",
"downloads": "Downloads",
@@ -531,6 +526,13 @@
"unreadMessage_other": "{{- count}} messages",
"unreadMessages": "Unread messages",
"meatballMenu": "Application menu",
+ "update": {
+ "available": "Update",
+ "updating": "Updating…",
+ "percent": "{{- percent}}%",
+ "restart": "Restart to update",
+ "tooltip": "New desktop app version is available, click to see details"
+ },
"windowControls": {
"minimize": "Minimize",
"maximize": "Maximize",
diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json
index 8a2ab0b3a8..6c1e668cad 100644
--- a/src/i18n/es.i18n.json
+++ b/src/i18n/es.i18n.json
@@ -71,20 +71,13 @@
"announcement": "Selecciona una pantalla para compartir"
},
"update": {
- "title": "Actualización disponible",
"announcement": "Nueva actualización disponible",
"message": "¡Hay una nueva versión de la aplicación de Rocket.Chat Desktop disponible!",
- "currentVersion": "Versión actual:",
- "newVersion": "Nueva versión:",
+ "currentVersion": "Versión actual",
+ "newVersion": "Nueva versión",
"install": "Instalar actualización",
- "remindLater": "Recordarme más tarde",
"skip": "Saltar esta versión"
},
- "updateDownloading": {
- "title": "Descargando actualización",
- "message": "Serás notificado cuando la actualización esté lista para ser instalada",
- "ok": "Aceptar"
- },
"updateInstallLater": {
"title": "Instalar más tarde",
"message": "La actualización se instalará cuando cierres la aplicación",
diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json
index b8aa5dee0f..fd4b64e3aa 100644
--- a/src/i18n/fi.i18n.json
+++ b/src/i18n/fi.i18n.json
@@ -71,20 +71,13 @@
"announcement": "Valitse jaettava näyttö"
},
"update": {
- "title": "Päivitys saatavilla",
"announcement": "Uusi päivitys on saatavilla",
"message": "Rocket.Chat-tietokonesovelluksen uusi versio on saatavilla!",
- "currentVersion": "Nykyinen versio:",
- "newVersion": "Uusi versio:",
+ "currentVersion": "Nykyinen versio",
+ "newVersion": "Uusi versio",
"install": "Asenna päivitys",
- "remindLater": "Muistuta myöhemmin",
"skip": "Ohita tämä versio"
},
- "updateDownloading": {
- "title": "Ladataan päivitystä",
- "message": "Saat ilmoituksen, kun päivitys on valmis asennettavaksi",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Asennetaan myöhemmin",
"message": "Päivitys asennetaan, kun suljet sovelluksen",
diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json
index b85041dd7a..0402be0c75 100644
--- a/src/i18n/fr.i18n.json
+++ b/src/i18n/fr.i18n.json
@@ -71,20 +71,13 @@
"announcement": "Sélectionnez un écran à partager"
},
"update": {
- "title": "Une mise à jour est disponible",
"announcement": "Une nouvelle mise à jour est disponible",
"message": "Une mise à jour de Rocket.Chat Desktop App est disponible !",
- "currentVersion": "Version actuelle :",
- "newVersion": "Nouvelle version :",
+ "currentVersion": "Version actuelle",
+ "newVersion": "Nouvelle version",
"install": "Installer la mise à jour",
- "remindLater": "Me rappeler plus tard",
"skip": "Ignorer cette version"
},
- "updateDownloading": {
- "title": "Téléchargement de la mise à jour",
- "message": "Vous serez averti quand la mise à jour sera prête à être installée",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "L'installation se fera plus tard",
"message": "La mise à jour sera installé à la fermeture de l'application",
diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json
index 30f3a15869..fab3a27793 100644
--- a/src/i18n/hu.i18n.json
+++ b/src/i18n/hu.i18n.json
@@ -80,20 +80,13 @@
"announcement": "Megosztandó képernyő kiválasztása"
},
"update": {
- "title": "Frissítés érhető el",
"announcement": "Új frissítés érhető el",
"message": "Elérhető a Rocket.Chat asztali alkalmazás új verziója!",
- "currentVersion": "Jelenlegi verzió:",
- "newVersion": "Új verzió:",
+ "currentVersion": "Jelenlegi verzió",
+ "newVersion": "Új verzió",
"install": "Frissítés telepítése",
- "remindLater": "Emlékeztessen később",
"skip": "A verzió kihagyása"
},
- "updateDownloading": {
- "title": "Frissítés letöltése",
- "message": "Értesítést fog kapni, ha a frissítés készen áll a telepítésre",
- "ok": "Rendben"
- },
"updateInstallLater": {
"title": "Telepítés később",
"message": "A frissítés akkor lesz telepítve, ha kilép az alkalmazásból",
diff --git a/src/i18n/ja.i18n.json b/src/i18n/ja.i18n.json
index 346349467a..eed537f7fd 100644
--- a/src/i18n/ja.i18n.json
+++ b/src/i18n/ja.i18n.json
@@ -50,20 +50,13 @@
"announcement": "共有する画面を選択"
},
"update": {
- "title": "利用可能な更新",
"announcement": "新しい更新が利用可能です",
"message": "Rocket.Chat デスクトップアプリの新しいバージョンが利用可能です!",
- "currentVersion": "現在のバージョン:",
- "newVersion": "新しいバージョン:",
+ "currentVersion": "現在のバージョン",
+ "newVersion": "新しいバージョン",
"install": "更新プログラムをインストール",
- "remindLater": "後で知らせる",
"skip": "このバージョンをスキップ"
},
- "updateDownloading": {
- "title": "更新プログラムをダウンロード中",
- "message": "更新プログラムのインストール準備が整うと通知されます",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "後でインストール",
"message": "アプリを終了すると更新プログラムがインストールされます",
diff --git a/src/i18n/no.i18n.json b/src/i18n/no.i18n.json
index b3e10c1b77..8d4bebf8df 100644
--- a/src/i18n/no.i18n.json
+++ b/src/i18n/no.i18n.json
@@ -80,20 +80,13 @@
"announcement": "Velg en skjerm du vil dele"
},
"update": {
- "title": "Oppdatering tilgjengelig",
"announcement": "Ny oppdatering er tilgjengelig",
"message": "En ny versjon av Rocket.Chat Desktop App er tilgjengelig!",
- "currentVersion": "Gjeldende versjon:",
- "newVersion": "Ny versjon:",
+ "currentVersion": "Gjeldende versjon",
+ "newVersion": "Ny versjon",
"install": "Installer oppdatering",
- "remindLater": "Påminn meg senere",
"skip": "Hopp over denne versjonen"
},
- "updateDownloading": {
- "title": "Laster ned oppdatering",
- "message": "Du vil bli varslet når oppdateringen er klar til å installeres",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Installerer senere",
"message": "Oppdatering vil bli installert når du avslutter appen",
diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json
index 15b0fcb9b2..fa285aab3f 100644
--- a/src/i18n/pl.i18n.json
+++ b/src/i18n/pl.i18n.json
@@ -57,20 +57,13 @@
"announcement": "Wybierz ekran do udostępnienia"
},
"update": {
- "title": "Dostępna aktualizacja",
"announcement": "Nowa aktualizacja jest dostępna",
"message": "Nowa wersja aplikacji Rocket.Chat Desktop jest dostępna!",
- "currentVersion": "Bieżąca wersja:",
- "newVersion": "Nowa wersja:",
+ "currentVersion": "Bieżąca wersja",
+ "newVersion": "Nowa wersja",
"install": "Zainstaluj aktualizację",
- "remindLater": "Przypomnij mi później",
"skip": "Pomiń tę wersję"
},
- "updateDownloading": {
- "title": "Pobieranie aktualizacji",
- "message": "Zostaniesz poinformowany gdy aktualizacja będzie gotowa do zainstalowania",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Instalowanie później",
"message": "Aktualizacja zostanie zainstalowana kiedy wyjdziesz z programu",
diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json
index f5d9e5d76e..fe7fb47091 100644
--- a/src/i18n/pt-BR.i18n.json
+++ b/src/i18n/pt-BR.i18n.json
@@ -80,20 +80,13 @@
"announcement": "Selecione uma tela para compartilhar"
},
"update": {
- "title": "Atualização Disponível",
"announcement": "Nova versão está disponível",
"message": "Uma nova versão do aplicativo desktop Rocket.Chat está disponível!",
- "currentVersion": "Versão Atual:",
- "newVersion": "Nova Versão:",
+ "currentVersion": "Versão Atual",
+ "newVersion": "Nova Versão",
"install": "Instalar Atualização",
- "remindLater": "Lembrar Depois",
"skip": "Pular Versão"
},
- "updateDownloading": {
- "title": "Baixando atualização",
- "message": "Você será notificado quando a atualização estiver pronta para instalação",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Instalando Depois",
"message": "Atualização será instalada quando você sair do aplicativo",
diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json
index f41c685f3f..e8a8209a96 100644
--- a/src/i18n/ru.i18n.json
+++ b/src/i18n/ru.i18n.json
@@ -65,20 +65,13 @@
"announcement": "Выберите экран для демонстрации"
},
"update": {
- "title": "Доступно обновление",
"announcement": "Доступно новое обновление",
"message": "Доступна новая версия приложения Rocket.Chat!",
- "currentVersion": "Текущая версия:",
- "newVersion": "Новая версия:",
+ "currentVersion": "Текущая версия",
+ "newVersion": "Новая версия",
"install": "Установить обновление",
- "remindLater": "Напомнить позже",
"skip": "Пропустить эту версию"
},
- "updateDownloading": {
- "title": "Загрузка обновления",
- "message": "Вы будете оповещены, когда обновление будет готово к установке",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Обновление будет установлено позже",
"message": "Обновление будет установлено, когда Вы закроете приложение",
diff --git a/src/i18n/sv.i18n.json b/src/i18n/sv.i18n.json
index 3379eab3e5..eeeb8e5828 100644
--- a/src/i18n/sv.i18n.json
+++ b/src/i18n/sv.i18n.json
@@ -80,20 +80,13 @@
"announcement": "Välj en skärm att dela"
},
"update": {
- "title": "Uppdatering finns tillgänglig",
"announcement": "En ny uppdatering finns tillgänglig",
"message": "En ny version av Rocket.Chat Desktop App är tillgänglig!",
- "currentVersion": "Nuvarande version:",
- "newVersion": "Ny version:",
+ "currentVersion": "Nuvarande version",
+ "newVersion": "Ny version",
"install": "Installera uppdatering",
- "remindLater": "Påminn mig senare",
"skip": "Hoppa över denna version"
},
- "updateDownloading": {
- "title": "Nedladdning av uppdatering",
- "message": "Du kommer att få ett meddelande när uppdateringen är klar att installeras",
- "ok": "OK"
- },
"updateInstallLater": {
"title": "Installera senare",
"message": "Uppdateringen kommer att installeras när du lämnar appen",
diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json
index 28e1676d44..cbb5bc59ea 100644
--- a/src/i18n/tr-TR.i18n.json
+++ b/src/i18n/tr-TR.i18n.json
@@ -50,20 +50,13 @@
"announcement": "Paylaşılacak ekran seçin"
},
"update": {
- "title": "Güncelleme kullanılabilir",
"announcement": "Yeni bir sürüm kullanılabilir",
"message": "Rocket.Chat için yeni bir masaüstü sürümü mevcut!",
- "currentVersion": "Mevcut versiyon:",
- "newVersion": "Yeni versiyon:",
+ "currentVersion": "Mevcut versiyon",
+ "newVersion": "Yeni versiyon",
"install": "Güncellemeyi yükle",
- "remindLater": "Daha sonra hatırlat",
"skip": "Bu sürümü atla"
},
- "updateDownloading": {
- "title": "Güncelleme indiriliyor",
- "message": "Güncelleme yüklenmeye hazır olduğunda size bildirilecek.",
- "ok": "TAMAM"
- },
"updateInstallLater": {
"title": "Installing Later",
"message": "Uygulamadan çıktığınızda güncelleme yüklenecek",
diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json
index 3da507a500..09217553f9 100644
--- a/src/i18n/uk-UA.i18n.json
+++ b/src/i18n/uk-UA.i18n.json
@@ -50,20 +50,13 @@
"announcement": "Виберіть екран для спільного використання"
},
"update": {
- "title": "Доступне оновлення",
"announcement": "Є нове оновлення",
"message": "Доступна нова версія застосунку Rocket.Chat!",
- "currentVersion": "Поточна версія:",
- "newVersion": "Нова версія:",
+ "currentVersion": "Поточна версія",
+ "newVersion": "Нова версія",
"install": "Встановити оновлення",
- "remindLater": "Нагадати пізніше",
"skip": "Пропустити цю версію"
},
- "updateDownloading": {
- "title": "Завантаження оновлення",
- "message": "Ви будете сповіщені, коли оновлення буде готове до встановлення",
- "ok": "Гаразд"
- },
"updateInstallLater": {
"title": "Оновлення буде встановлено пізніше",
"message": "Оновлення буде встановлено, коли Ви закриєте застосунок",
diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json
index 98883ea682..64f95e6978 100644
--- a/src/i18n/zh-CN.i18n.json
+++ b/src/i18n/zh-CN.i18n.json
@@ -51,20 +51,13 @@
"announcement": "选择要分享的画面"
},
"update": {
- "title": "可用的更新",
"announcement": "有新的更新程序",
"message": "有新版本的 Rocket.Chat 桌面应用程序!",
- "currentVersion": "目前版本:",
- "newVersion": "最新版本:",
+ "currentVersion": "目前版本",
+ "newVersion": "最新版本",
"install": "安裝更新",
- "remindLater": "稍后再提醒我",
"skip": "忽略这个版本"
},
- "updateDownloading": {
- "title": "正在下载更新",
- "message": "当准备好可以安装更新程序的时候,您将会收到通知。",
- "ok": "好"
- },
"updateInstallLater": {
"title": "稍后再安裝",
"message": "结束程序的时候将会安装更新",
diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json
index 3697080bef..6e769374ba 100644
--- a/src/i18n/zh-TW.i18n.json
+++ b/src/i18n/zh-TW.i18n.json
@@ -51,20 +51,13 @@
"announcement": "選擇要分享的畫面"
},
"update": {
- "title": "可用的更新",
"announcement": "有新的更新程式",
"message": "有新版本的 Rocket.Chat 桌面應用程式!",
- "currentVersion": "目前版本:",
- "newVersion": "最新版本:",
+ "currentVersion": "目前版本",
+ "newVersion": "最新版本",
"install": "安裝更新",
- "remindLater": "稍後再提醒我",
"skip": "略過這個版本"
},
- "updateDownloading": {
- "title": "正在下載更新",
- "message": "當準備好可以安裝更新程式的時候,您將會收到通知。",
- "ok": "好"
- },
"updateInstallLater": {
"title": "稍後再安裝",
"message": "結束程式的時候將會安裝更新",
diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts
index 7eb70e1ff9..5402c75a58 100644
--- a/src/store/rootReducer.ts
+++ b/src/store/rootReducer.ts
@@ -61,10 +61,13 @@ import {
doCheckForUpdatesOnStartup,
isCheckingForUpdates,
isEachUpdatesSettingConfigurable,
+ isUpdatePanelOpen,
isUpdatingAllowed,
isUpdatingEnabled,
newUpdateVersion,
skippedUpdateVersion,
+ updateDownloadProgress,
+ updateDownloadStatus,
updateError,
updateChannel,
} from '../updates/reducers';
@@ -92,6 +95,7 @@ export const rootReducer = combineReducers({
navigationLayout,
isTrayIconEnabled,
isMinimizeOnCloseEnabled,
+ isUpdatePanelOpen,
isUpdatingAllowed,
isUpdatingEnabled,
mainWindowTitle,
@@ -106,6 +110,8 @@ export const rootReducer = combineReducers({
skippedUpdateVersion,
trustedCertificates,
notTrustedCertificates,
+ updateDownloadProgress,
+ updateDownloadStatus,
updateError,
isReportEnabled,
isFlashFrameEnabled,
diff --git a/src/ui/actions.ts b/src/ui/actions.ts
index 98ee52c5e0..ba4cb2d3d2 100644
--- a/src/ui/actions.ts
+++ b/src/ui/actions.ts
@@ -52,13 +52,6 @@ export const TOUCH_BAR_FORMAT_BUTTON_TOUCHED =
'touch-bar/format-button-touched';
export const TOUCH_BAR_SELECT_SERVER_TOUCHED =
'touch-bar/select-server-touched';
-export const UPDATE_DIALOG_DISMISSED = 'update-dialog/dismissed';
-export const UPDATE_DIALOG_INSTALL_BUTTON_CLICKED =
- 'update-dialog/install-button-clicked';
-export const UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED =
- 'update-dialog/remind-update-later-clicked';
-export const UPDATE_DIALOG_SKIP_UPDATE_CLICKED =
- 'update-dialog/skip-update-clicked';
export const WEBVIEW_READY = 'webview/ready';
export const WEBVIEW_ATTACHED = 'webview/attached';
export const WEBVIEW_DID_FAIL_LOAD = 'webview/did-fail-load';
@@ -221,10 +214,6 @@ export type UiActionTypeToPayloadMap = {
| 'inline_code'
| 'multi_line';
[TOUCH_BAR_SELECT_SERVER_TOUCHED]: string;
- [UPDATE_DIALOG_DISMISSED]: void;
- [UPDATE_DIALOG_INSTALL_BUTTON_CLICKED]: void;
- [UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED]: void;
- [UPDATE_DIALOG_SKIP_UPDATE_CLICKED]: string | null;
[WEBVIEW_READY]: { url: Server['url']; webContentsId: number };
[WEBVIEW_ATTACHED]: { url: Server['url']; webContentsId: number };
[WEBVIEW_DID_FAIL_LOAD]: { url: Server['url']; isMainFrame: boolean };
diff --git a/src/ui/components/AboutDialog/index.tsx b/src/ui/components/AboutDialog/index.tsx
index 28151bcf19..a2256d446a 100644
--- a/src/ui/components/AboutDialog/index.tsx
+++ b/src/ui/components/AboutDialog/index.tsx
@@ -20,7 +20,10 @@ import type { Dispatch } from 'redux';
import { packageJsonInformation } from '../../../app/main/app';
import type { RootAction } from '../../../store/actions';
import type { RootState } from '../../../store/rootReducer';
-import { UPDATES_CHECK_FOR_UPDATES_REQUESTED } from '../../../updates/actions';
+import {
+ UPDATES_CHECK_FOR_UPDATES_REQUESTED,
+ UPDATES_PANEL_TOGGLED,
+} from '../../../updates/actions';
import { UPDATE_CHANNELS } from '../../../updates/common';
import {
ABOUT_DIALOG_TOGGLE_UPDATE_ON_START,
@@ -146,9 +149,20 @@ export const AboutDialog = () => {
return undefined;
}
+ // The titlebar update label owns the install flow from here on, so report
+ // the finding inline instead of handing over to a modal.
if (newUpdateVersion) {
- setCheckingForUpdates([false, null]);
- return undefined;
+ setCheckingForUpdates([
+ true,
+ t('dialog.about.updateAvailable', { version: newUpdateVersion }),
+ ]);
+ const messageTimer = setTimeout(() => {
+ setCheckingForUpdates([false, null]);
+ }, 5000);
+
+ return () => {
+ clearTimeout(messageTimer);
+ };
}
setCheckingForUpdates([true, t('dialog.about.noUpdatesAvailable')]);
@@ -161,7 +175,31 @@ export const AboutDialog = () => {
};
}, [updateError, isCheckingForUpdates, newUpdateVersion, t]);
+ // Set while a check started from this dialog is in flight, so only a manual
+ // check hands over to the titlebar panel.
+ const [hasRequestedCheck, setHasRequestedCheck] = useState(false);
+
+ useEffect(() => {
+ if (!isVisible) {
+ setHasRequestedCheck(false);
+ }
+ }, [isVisible]);
+
+ // A manual check that finds an update hands over to the titlebar update
+ // panel: close this dialog and open the panel, so the version details and the
+ // install action live in exactly one place.
+ useEffect(() => {
+ if (!hasRequestedCheck || isCheckingForUpdates || !newUpdateVersion) {
+ return;
+ }
+
+ setHasRequestedCheck(false);
+ dispatch({ type: ABOUT_DIALOG_DISMISSED });
+ dispatch({ type: UPDATES_PANEL_TOGGLED, payload: true });
+ }, [hasRequestedCheck, isCheckingForUpdates, newUpdateVersion, dispatch]);
+
const handleCheckForUpdatesButtonClick = (): void => {
+ setHasRequestedCheck(true);
dispatch({ type: UPDATES_CHECK_FOR_UPDATES_REQUESTED });
};
diff --git a/src/ui/components/Shell/index.spec.tsx b/src/ui/components/Shell/index.spec.tsx
index fa4711d778..87f3b4356a 100644
--- a/src/ui/components/Shell/index.spec.tsx
+++ b/src/ui/components/Shell/index.spec.tsx
@@ -118,11 +118,6 @@ jest.mock('../SelectClientCertificateDialog', () => ({
),
}));
-jest.mock('../UpdateDialog', () => ({
- __esModule: true,
- UpdateDialog: () =>
,
-}));
-
jest.mock('../ClearCacheDialog', () => ({
__esModule: true,
ClearCacheDialog: () => ,
@@ -204,7 +199,6 @@ describe('Shell', () => {
expect(screen.getByTestId('about-dialog')).toBeInTheDocument();
expect(screen.getByTestId('server-info-modal')).toBeInTheDocument();
expect(screen.getByTestId('supported-version-dialog')).toBeInTheDocument();
- expect(screen.getByTestId('update-dialog')).toBeInTheDocument();
expect(screen.getByTestId('clear-cache-dialog')).toBeInTheDocument();
expect(
screen.getByTestId('outlook-credentials-dialog')
diff --git a/src/ui/components/Shell/index.tsx b/src/ui/components/Shell/index.tsx
index 594cae9392..9edc11a6a7 100644
--- a/src/ui/components/Shell/index.tsx
+++ b/src/ui/components/Shell/index.tsx
@@ -22,7 +22,7 @@ import { TelephonyDefaultHandlerPromptModal } from '../TelephonyDefaultHandlerPr
import { TelephonyServerSelectModal } from '../TelephonyServerSelectModal';
import { TopBar } from '../TopBar';
import { ServerSwitcher } from '../TopBar/ServerSwitcher';
-import { UpdateDialog } from '../UpdateDialog';
+import { UpdateLabel } from '../TopBar/UpdateLabel';
import { useShellTheme } from '../hooks/useShellTheme';
import TooltipProvider from '../utils/TooltipProvider';
import { GlobalStyles, WindowDragBar } from './styles';
@@ -73,26 +73,40 @@ export const Shell = () => {
>
{navigationLayout === 'tabs' && process.platform === 'win32' && (
}
+ leadingSlot={
+ <>
+
+
+ >
+ }
trailingSlot={}
/>
)}
{navigationLayout === 'tabs' && process.platform !== 'win32' && (
- } />
+
+
+
+ >
+ }
+ />
)}
{navigationLayout !== 'tabs' && process.platform === 'darwin' && (
: undefined
}
+ trailingSlot={}
/>
)}
{navigationLayout !== 'tabs' && process.platform === 'win32' && (
- ) : undefined
+ <>
+ {navigationLayout === 'hidden' && }
+
+ >
}
centerSlot={
navigationLayout === 'hidden' ? : undefined
@@ -139,7 +153,6 @@ export const Shell = () => {
-
diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx
index f5a89227d2..51b00c2e9b 100644
--- a/src/ui/components/TabBar/styles.tsx
+++ b/src/ui/components/TabBar/styles.tsx
@@ -69,7 +69,7 @@ export const Strip = styled.div`
align-items: center;
width: 48px;
height: 100%;
- padding: ${process.platform === 'darwin' ? '8px 0px' : '0 0 8px 0'};
+ padding: ${process.platform === 'darwin' ? '8px 0px' : '4px 0 8px 0'};
padding-left: 2px;
`}
`;
diff --git a/src/ui/components/TopBar/UpdateLabel.spec.tsx b/src/ui/components/TopBar/UpdateLabel.spec.tsx
new file mode 100644
index 0000000000..998b286481
--- /dev/null
+++ b/src/ui/components/TopBar/UpdateLabel.spec.tsx
@@ -0,0 +1,237 @@
+import {
+ UPDATES_DOWNLOAD_REQUESTED,
+ UPDATES_INSTALL_REQUESTED,
+ UPDATES_PANEL_TOGGLED,
+ UPDATES_SKIP_REQUESTED,
+} from '../../../updates/actions';
+import {
+ fireEvent,
+ renderWithStore,
+ screen,
+ userEvent,
+} from '../../test-utils';
+import { UpdateLabel } from './UpdateLabel';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, options?: Record) =>
+ options ? `${key}:${JSON.stringify(options)}` : key,
+ i18n: { language: 'en', changeLanguage: jest.fn() },
+ }),
+ Trans: ({ children }: { children: React.ReactNode }) => children,
+ initReactI18next: { type: '3rdParty', init: () => {} },
+}));
+
+const mockDispatch = jest.fn();
+
+jest.mock('../../../store', () => ({
+ dispatch: (action: unknown) => mockDispatch(action),
+}));
+
+// Fuselage's positions itself with usePosition, which under jsdom has
+// no real layout and so renders its children as null. Replace only that
+// component with an inline passthrough; every other Fuselage component stays
+// real.
+jest.mock('@rocket.chat/fuselage', () => {
+ const actual = jest.requireActual('@rocket.chat/fuselage');
+ return {
+ __esModule: true,
+ ...actual,
+ Dropdown: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ };
+});
+
+const buildState = (overrides: Record = {}) =>
+ ({
+ appVersion: '4.8.0',
+ newUpdateVersion: '4.9.0',
+ updateDownloadStatus: 'idle',
+ updateDownloadProgress: 0,
+ isUpdatePanelOpen: false,
+ ...overrides,
+ }) as any;
+
+/** State with the panel already open, as the store would have it. */
+const openState = (overrides: Record = {}) =>
+ buildState({ isUpdatePanelOpen: true, ...overrides });
+
+describe('UpdateLabel', () => {
+ beforeEach(() => {
+ mockDispatch.mockClear();
+ });
+
+ it('renders nothing when no update is available', () => {
+ renderWithStore(, {
+ preloadedState: buildState({ newUpdateVersion: null }),
+ });
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('announces an available update', () => {
+ renderWithStore(, { preloadedState: buildState() });
+
+ const label = screen.getByRole('button');
+ expect(label).toHaveTextContent('tabBar.update.available');
+ expect(label).toHaveAttribute('data-update-status', 'idle');
+ });
+
+ it('requests the panel when clicked while available', async () => {
+ const user = userEvent.setup();
+ renderWithStore(, { preloadedState: buildState() });
+
+ expect(
+ screen.queryByText('dialog.update.announcement')
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button'));
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_PANEL_TOGGLED,
+ payload: true,
+ });
+ });
+
+ it('shows the version change while the panel is open', () => {
+ renderWithStore(, { preloadedState: openState() });
+
+ expect(screen.getByText('dialog.update.announcement')).toBeInTheDocument();
+ expect(screen.getByText('4.8.0')).toBeInTheDocument();
+ expect(screen.getByText('4.9.0')).toBeInTheDocument();
+ });
+
+ it('starts the download from the panel install action', async () => {
+ const user = userEvent.setup();
+ renderWithStore(, { preloadedState: openState() });
+
+ await user.click(screen.getByText('dialog.update.install'));
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_DOWNLOAD_REQUESTED,
+ });
+ });
+
+ it('skips the version from the panel skip action', async () => {
+ const user = userEvent.setup();
+ renderWithStore(, { preloadedState: openState() });
+
+ await user.click(screen.getByText('dialog.update.skip'));
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_SKIP_REQUESTED,
+ payload: '4.9.0',
+ });
+ });
+
+ it('does not offer remind-me-later in the panel', () => {
+ renderWithStore(, { preloadedState: openState() });
+
+ expect(
+ screen.queryByText('dialog.update.remindLater')
+ ).not.toBeInTheDocument();
+ });
+
+ it('closes the panel when clicking outside it', () => {
+ renderWithStore(, { preloadedState: openState() });
+
+ // The backdrop covers the whole viewport, including the whose
+ // clicks never reach the host document.
+ fireEvent.mouseDown(screen.getByTestId('update-panel-backdrop'));
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_PANEL_TOGGLED,
+ payload: false,
+ });
+ });
+
+ it('closes the panel on Escape', () => {
+ renderWithStore(, { preloadedState: openState() });
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_PANEL_TOGGLED,
+ payload: false,
+ });
+ });
+
+ it('has no backdrop while the panel is closed', () => {
+ renderWithStore(, { preloadedState: buildState() });
+
+ expect(
+ screen.queryByTestId('update-panel-backdrop')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the progress percentage while downloading', () => {
+ renderWithStore(, {
+ preloadedState: buildState({
+ updateDownloadStatus: 'downloading',
+ updateDownloadProgress: 45,
+ }),
+ });
+
+ const label = screen.getByRole('button');
+ expect(label).toHaveTextContent('45');
+ expect(label).toHaveAttribute('data-update-status', 'downloading');
+ });
+
+ it('isolates the percentage in its own element so its width can be reserved', () => {
+ const leadingTextAt = (percent: number): string => {
+ const { unmount } = renderWithStore(, {
+ preloadedState: buildState({
+ updateDownloadStatus: 'downloading',
+ updateDownloadProgress: percent,
+ }),
+ });
+
+ // Only the number lives in the percentage element; the static text sits
+ // outside it, so reserving a width there cannot shift the rest.
+ const percentage = screen.getByTestId('update-progress');
+ expect(percentage).toHaveTextContent(String(percent));
+ expect(percentage).not.toHaveTextContent('tabBar.update.updating');
+
+ const leading = screen.getByRole('button').firstChild?.textContent ?? '';
+ unmount();
+ return leading;
+ };
+
+ // Identical static text whether the number is one or two digits.
+ expect(leadingTextAt(5)).toBe(leadingTextAt(45));
+ });
+
+ it('does not dispatch while the download is in flight', async () => {
+ const user = userEvent.setup();
+ renderWithStore(, {
+ preloadedState: buildState({
+ updateDownloadStatus: 'downloading',
+ updateDownloadProgress: 45,
+ }),
+ });
+
+ await user.click(screen.getByRole('button'));
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('offers the restart once the update finished downloading', async () => {
+ const user = userEvent.setup();
+ renderWithStore(, {
+ preloadedState: buildState({
+ updateDownloadStatus: 'downloaded',
+ updateDownloadProgress: 100,
+ }),
+ });
+
+ const label = screen.getByRole('button');
+ expect(label).toHaveTextContent('tabBar.update.restart');
+
+ await user.click(label);
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: UPDATES_INSTALL_REQUESTED,
+ });
+ });
+});
diff --git a/src/ui/components/TopBar/UpdateLabel.tsx b/src/ui/components/TopBar/UpdateLabel.tsx
new file mode 100644
index 0000000000..8558fd64f4
--- /dev/null
+++ b/src/ui/components/TopBar/UpdateLabel.tsx
@@ -0,0 +1,324 @@
+import styled from '@emotion/styled';
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Chevron,
+ Dropdown,
+} from '@rocket.chat/fuselage';
+import { useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+
+import { dispatch } from '../../../store';
+import type { RootState } from '../../../store/rootReducer';
+import {
+ UPDATES_DOWNLOAD_REQUESTED,
+ UPDATES_INSTALL_REQUESTED,
+ UPDATES_PANEL_TOGGLED,
+ UPDATES_SKIP_REQUESTED,
+} from '../../../updates/actions';
+
+/**
+ * Blue pill in the window chrome announcing an available update.
+ *
+ * Clicking it opens a panel with the version change plus the install and skip
+ * actions. Once the download starts the pill turns into a live percentage, and
+ * when it completes it becomes the button that restarts into the new version.
+ * `progress` paints a brighter fill across the pill so the percentage reads as a
+ * progress bar rather than plain text.
+ */
+const Label = styled.button<{ progress: number }>`
+ appearance: none;
+ box-sizing: border-box;
+ border: none;
+ outline: none;
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1;
+ white-space: nowrap;
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 4px;
+ height: 22px;
+ padding: 0 10px;
+ border-radius: 11px;
+ cursor: pointer;
+ color: var(--rcx-color-button-font-on-primary, #ffffff);
+ -webkit-app-region: no-drag;
+
+ /* The shell overlays a fixed 22px WindowDragBar across the top of the window,
+ which is taller than the title bar itself. Being positioned lifts the pill
+ above it so the whole pill stays clickable instead of just the few pixels
+ poking out below the drag region. */
+ position: relative;
+ z-index: 1;
+
+ /* The fill is a hard-stop gradient so it doubles as the progress bar. */
+ background-color: var(--rcx-color-button-background-primary-default, #095ad2);
+ background-image: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 0.32) 0%,
+ rgba(255, 255, 255, 0.32) ${({ progress }) => progress}%,
+ transparent ${({ progress }) => progress}%,
+ transparent 100%
+ );
+ transition: background-image 150ms ease-out;
+
+ &:hover {
+ background-color: var(--rcx-color-button-background-primary-hover, #10529e);
+ }
+
+ &:active {
+ background-color: var(--rcx-color-button-background-primary-press, #10529e);
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 2px var(--rcx-color-stroke-extra-light-highlight, #d1ebfe);
+ }
+`;
+
+/**
+ * Invisible full-viewport layer that closes the panel on any click outside it.
+ *
+ * A document-level listener is not enough: the workspace content is a
+ * , and clicks inside it never reach the host document. This layer
+ * sits over the webview so those clicks are caught too.
+ */
+const Backdrop = styled.div`
+ position: fixed;
+ inset: 0;
+ z-index: 10;
+ -webkit-app-region: no-drag;
+`;
+
+/** Holds the panel above the backdrop that closes it. */
+const PanelLayer = styled.div`
+ position: relative;
+ z-index: 11;
+`;
+
+/**
+ * Monospaced, right-aligned percentage sized to hold two digits plus the percent
+ * sign, so the pill keeps a constant width as the number climbs (1% → 47%)
+ * instead of nudging the surrounding chrome on every progress tick.
+ */
+const Percentage = styled.span`
+ font-family: var(
+ --rcx-font-family-mono,
+ Menlo,
+ Monaco,
+ Consolas,
+ 'Liberation Mono',
+ 'Courier New',
+ monospace
+ );
+ font-variant-numeric: tabular-nums;
+ min-width: 3ch;
+ text-align: right;
+ flex: 0 0 auto;
+`;
+
+export const UpdateLabel = () => {
+ const { t } = useTranslation();
+
+ const currentVersion = useSelector(({ appVersion }: RootState) => appVersion);
+ const newUpdateVersion = useSelector(
+ ({ newUpdateVersion }: RootState) => newUpdateVersion
+ );
+ const updateDownloadStatus = useSelector(
+ ({ updateDownloadStatus }: RootState) => updateDownloadStatus
+ );
+ const updateDownloadProgress = useSelector(
+ ({ updateDownloadProgress }: RootState) => updateDownloadProgress
+ );
+
+ const reference = useRef(null);
+ const target = useRef(null);
+
+ // The pill leads the title bar on Windows (right after the meatball) and
+ // trails it elsewhere, so anchor the panel to the matching edge and let it
+ // open inward rather than off the side of the window.
+ const placement =
+ process.platform === 'win32' ? 'bottom-start' : 'bottom-end';
+
+ // Visibility lives in the store so the About dialog can open the panel after
+ // a manual check finds an update.
+ const isVisible = useSelector(
+ ({ isUpdatePanelOpen }: RootState) => isUpdatePanelOpen
+ );
+
+ const toggle = (next?: boolean): void => {
+ dispatch({ type: UPDATES_PANEL_TOGGLED, payload: next ?? !isVisible });
+ };
+
+ useEffect(() => {
+ if (!isVisible) {
+ return undefined;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === 'Escape') {
+ dispatch({ type: UPDATES_PANEL_TOGGLED, payload: false });
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isVisible]);
+
+ if (!newUpdateVersion) {
+ return null;
+ }
+
+ const isDownloading = updateDownloadStatus === 'downloading';
+ const isDownloaded = updateDownloadStatus === 'downloaded';
+
+ const getLabel = (): string => {
+ if (isDownloaded) {
+ return t('tabBar.update.restart');
+ }
+
+ if (isDownloading) {
+ return `${t('tabBar.update.updating')} ${t('tabBar.update.percent', {
+ percent: updateDownloadProgress,
+ })}`;
+ }
+
+ return t('tabBar.update.available');
+ };
+
+ // The accessible name stays a single string; the visible content splits the
+ // percentage into its own fixed-width element.
+ const label = getLabel();
+
+ const handleClick = (): void => {
+ if (isDownloaded) {
+ dispatch({ type: UPDATES_INSTALL_REQUESTED });
+ return;
+ }
+
+ // While the download is in flight the pill only reports progress.
+ if (isDownloading) {
+ return;
+ }
+
+ toggle();
+ };
+
+ const handleInstallClick = (): void => {
+ toggle(false);
+ dispatch({ type: UPDATES_DOWNLOAD_REQUESTED });
+ };
+
+ const handleSkipClick = (): void => {
+ toggle(false);
+ dispatch({ type: UPDATES_SKIP_REQUESTED, payload: newUpdateVersion });
+ };
+
+ return (
+ <>
+
+ {isVisible && (
+ <>
+ toggle(false)}
+ />
+
+
+
+ {t('dialog.update.announcement')}
+
+ {t('dialog.update.message')}
+
+
+
+
+
+ {t('dialog.update.currentVersion')}
+
+ {currentVersion}
+
+
+
+
+ {t('dialog.update.newVersion')}
+
+
+ {newUpdateVersion}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+};
+
+export default UpdateLabel;
diff --git a/src/ui/components/UpdateDialog/index.spec.tsx b/src/ui/components/UpdateDialog/index.spec.tsx
deleted file mode 100644
index 88f17253c8..0000000000
--- a/src/ui/components/UpdateDialog/index.spec.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { UpdateDialog } from '.';
-import {
- UPDATE_DIALOG_DISMISSED,
- UPDATE_DIALOG_INSTALL_BUTTON_CLICKED,
- UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED,
- UPDATE_DIALOG_SKIP_UPDATE_CLICKED,
-} from '../../actions';
-import { renderWithStore, screen, userEvent } from '../../test-utils';
-
-jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- i18n: { language: 'en', changeLanguage: jest.fn() },
- }),
- Trans: ({ children }: { children: React.ReactNode }) => children,
- initReactI18next: { type: '3rdParty', init: () => {} },
-}));
-
-const mockDispatch = jest.fn();
-
-jest.mock('react-redux', () => {
- const actual = jest.requireActual('react-redux');
- return {
- __esModule: true,
- ...actual,
- useDispatch: () => mockDispatch,
- };
-});
-
-const preloadedState = {
- appVersion: '4.0.0',
- newUpdateVersion: '4.1.0',
- openDialog: 'update',
-} as any;
-
-describe('UpdateDialog', () => {
- beforeEach(() => {
- mockDispatch.mockClear();
- });
-
- it('renders the announcement, message and both versions', () => {
- renderWithStore(, { preloadedState });
-
- expect(screen.getByText('dialog.update.announcement')).toBeInTheDocument();
- expect(screen.getByText('dialog.update.message')).toBeInTheDocument();
- expect(screen.getByText('4.0.0')).toBeInTheDocument();
- expect(screen.getByText('4.1.0')).toBeInTheDocument();
- });
-
- it('renders the skip, remind-later and install buttons', () => {
- renderWithStore(, { preloadedState });
-
- expect(screen.getByText('dialog.update.skip')).toBeInTheDocument();
- expect(screen.getByText('dialog.update.remindLater')).toBeInTheDocument();
- expect(screen.getByText('dialog.update.install')).toBeInTheDocument();
- });
-
- it('dispatches skip-update with the new version', async () => {
- const user = userEvent.setup();
- renderWithStore(, { preloadedState });
-
- await user.click(screen.getByText('dialog.update.skip'));
-
- expect(mockDispatch).toHaveBeenCalledWith({
- type: UPDATE_DIALOG_SKIP_UPDATE_CLICKED,
- payload: '4.1.0',
- });
- });
-
- it('dispatches remind-later', async () => {
- const user = userEvent.setup();
- renderWithStore(, { preloadedState });
-
- await user.click(screen.getByText('dialog.update.remindLater'));
-
- expect(mockDispatch).toHaveBeenCalledWith({
- type: UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED,
- });
- });
-
- it('dispatches install on the install button', async () => {
- const user = userEvent.setup();
- renderWithStore(, { preloadedState });
-
- await user.click(screen.getByText('dialog.update.install'));
-
- expect(mockDispatch).toHaveBeenCalledWith({
- type: UPDATE_DIALOG_INSTALL_BUTTON_CLICKED,
- });
- });
-
- it('dispatches dismissed when the dialog is closed', () => {
- renderWithStore(, { preloadedState });
-
- const dialog = document.querySelector('dialog') as HTMLDialogElement;
- dialog.dispatchEvent(new Event('close'));
-
- expect(mockDispatch).toHaveBeenCalledWith({
- type: UPDATE_DIALOG_DISMISSED,
- });
- });
-});
diff --git a/src/ui/components/UpdateDialog/index.tsx b/src/ui/components/UpdateDialog/index.tsx
deleted file mode 100644
index dae6d85dbc..0000000000
--- a/src/ui/components/UpdateDialog/index.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import {
- Box,
- Button,
- ButtonGroup,
- Chevron,
- Margins,
-} from '@rocket.chat/fuselage';
-import { useEffect, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useDispatch, useSelector } from 'react-redux';
-import type { Dispatch } from 'redux';
-
-import type { RootAction } from '../../../store/actions';
-import type { RootState } from '../../../store/rootReducer';
-import {
- UPDATE_DIALOG_SKIP_UPDATE_CLICKED,
- UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED,
- UPDATE_DIALOG_INSTALL_BUTTON_CLICKED,
- UPDATE_DIALOG_DISMISSED,
-} from '../../actions';
-import { Dialog } from '../Dialog';
-
-export const UpdateDialog = () => {
- const currentVersion = useSelector(({ appVersion }: RootState) => appVersion);
- const newVersion = useSelector(
- ({ newUpdateVersion }: RootState) => newUpdateVersion
- );
- const openDialog = useSelector(({ openDialog }: RootState) => openDialog);
- const isVisible = openDialog === 'update';
-
- const dispatch = useDispatch>();
-
- const { t } = useTranslation();
-
- const installButtonRef = useRef(null);
-
- useEffect(() => {
- if (!isVisible) {
- return;
- }
-
- installButtonRef.current?.focus();
- }, [isVisible]);
-
- const handleSkipButtonClick = (): void => {
- dispatch({ type: UPDATE_DIALOG_SKIP_UPDATE_CLICKED, payload: newVersion });
- };
-
- const handleRemindLaterButtonClick = (): void => {
- dispatch({ type: UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED });
- };
-
- const handleInstallButtonClick = (): void => {
- dispatch({ type: UPDATE_DIALOG_INSTALL_BUTTON_CLICKED });
- };
-
- const handleClose = (): void => {
- dispatch({ type: UPDATE_DIALOG_DISMISSED });
- };
-
- return (
-
- );
-};
diff --git a/src/ui/main/dialogs.spec.ts b/src/ui/main/dialogs.spec.ts
index c6ce8d0508..39b66cf1bc 100644
--- a/src/ui/main/dialogs.spec.ts
+++ b/src/ui/main/dialogs.spec.ts
@@ -13,7 +13,6 @@ import {
askForClearScreenCapturePermission,
askForMediaPermissionSettings,
warnAboutInvalidServerUrl,
- warnAboutUpdateDownload,
warnAboutUpdateSkipped,
warnAboutInstallUpdateLater,
showMicrophonePermissionDeniedMessage,
@@ -328,26 +327,20 @@ describe('ui/main/dialogs', () => {
});
});
- it('shows update download and skip informational dialogs', async () => {
+ it('shows skip informational dialogs', async () => {
showMessageBox.mockResolvedValue({ response: 0 } as never);
- await warnAboutUpdateDownload();
await warnAboutUpdateSkipped();
await warnAboutInstallUpdateLater();
- expect(showMessageBox).toHaveBeenCalledTimes(3);
+ expect(showMessageBox).toHaveBeenCalledTimes(2);
});
it('uses parent window for informational update dialogs', async () => {
const parent = getMockWindow();
showMessageBox.mockResolvedValue({ response: 0 } as never);
- await warnAboutUpdateDownload(parent);
await warnAboutUpdateSkipped(parent);
await warnAboutInstallUpdateLater(parent);
- expect(showMessageBox).toHaveBeenCalledWith(
- parent,
- expect.objectContaining({ buttons: ['dialog.updateDownloading.ok'] })
- );
expect(showMessageBox).toHaveBeenCalledWith(
parent,
expect.objectContaining({ buttons: ['dialog.updateSkip.ok'] })
diff --git a/src/ui/main/dialogs.ts b/src/ui/main/dialogs.ts
index 5eb6524bd4..b36e4d1f0b 100644
--- a/src/ui/main/dialogs.ts
+++ b/src/ui/main/dialogs.ts
@@ -128,18 +128,6 @@ export const askForCertificateTrust = async (
return AskForCertificateTrustResponse.NO;
};
-export const warnAboutUpdateDownload = async (
- parentWindow?: BrowserWindow
-): Promise => {
- await dialog.showMessageBox(parentWindow ?? (await getRootWindow()), {
- type: 'info',
- title: t('dialog.updateDownloading.title'),
- message: t('dialog.updateDownloading.message'),
- buttons: [t('dialog.updateDownloading.ok')],
- defaultId: 0,
- });
-};
-
export const warnAboutUpdateSkipped = async (
parentWindow?: BrowserWindow
): Promise => {
diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts
index 1868a5c9e2..1f33daa254 100644
--- a/src/ui/main/menuBar.ts
+++ b/src/ui/main/menuBar.ts
@@ -9,7 +9,10 @@ import { relaunchApp } from '../../app/main/app';
import { CERTIFICATES_CLEARED } from '../../navigation/actions';
import { dispatch, select, Service } from '../../store';
import type { RootState } from '../../store/rootReducer';
-import { UPDATES_CHECK_FOR_UPDATES_REQUESTED } from '../../updates/actions';
+import {
+ UPDATES_CHECK_FOR_UPDATES_REQUESTED,
+ UPDATES_SIMULATION_REQUESTED,
+} from '../../updates/actions';
import * as urls from '../../urls';
import { openExternal } from '../../utils/browserLauncher';
import { openVideoCallWebviewDevTools } from '../../videoCallWindow/ipc';
@@ -822,6 +825,21 @@ export const createHelpMenu = createSelector(
});
},
},
+ ...on(isDeveloperModeEnabled, () => [
+ {
+ id: 'simulateUpdate',
+ label: t('menus.simulateUpdate'),
+ click: async () => {
+ const browserWindow = await getRootWindow();
+
+ if (!browserWindow.isVisible()) {
+ browserWindow.showInactive();
+ }
+ browserWindow.focus();
+ dispatch({ type: UPDATES_SIMULATION_REQUESTED });
+ },
+ },
+ ]),
{
id: 'videoCallToolsSubmenu',
label: t('menus.videoCallTools'),
diff --git a/src/ui/reducers/__tests__/stateGroups.spec.ts b/src/ui/reducers/__tests__/stateGroups.spec.ts
index 06e357f869..57fb942866 100644
--- a/src/ui/reducers/__tests__/stateGroups.spec.ts
+++ b/src/ui/reducers/__tests__/stateGroups.spec.ts
@@ -323,9 +323,10 @@ describe('openDialog reducer', () => {
type: screenSharingActions.SCREEN_SHARING_DIALOG_DISMISSED,
} as any)
).toBe(null);
+ // An available update is surfaced by the titlebar label, not a modal.
expect(
openDialog(null, { type: UPDATES_NEW_VERSION_AVAILABLE } as any)
- ).toBe('update');
+ ).toBe(null);
expect(
openDialog(null, {
type: uiActions.WEBVIEW_SCREEN_SHARING_SOURCE_REQUESTED,
diff --git a/src/ui/reducers/openDialog.ts b/src/ui/reducers/openDialog.ts
index 9ce36e481d..76d2f4cc70 100644
--- a/src/ui/reducers/openDialog.ts
+++ b/src/ui/reducers/openDialog.ts
@@ -12,15 +12,10 @@ import {
} from '../../outlookCalendar/actions';
import { SCREEN_SHARING_DIALOG_DISMISSED } from '../../screenSharing/actions';
import type { ActionOf } from '../../store/actions';
-import { UPDATES_NEW_VERSION_AVAILABLE } from '../../updates/actions';
import type { SUPPORTED_VERSION_DIALOG_DISMISS } from '../actions';
import {
ABOUT_DIALOG_DISMISSED,
MENU_BAR_ABOUT_CLICKED,
- UPDATE_DIALOG_DISMISSED,
- UPDATE_DIALOG_INSTALL_BUTTON_CLICKED,
- UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED,
- UPDATE_DIALOG_SKIP_UPDATE_CLICKED,
WEBVIEW_SCREEN_SHARING_SOURCE_REQUESTED,
WEBVIEW_SCREEN_SHARING_SOURCE_RESPONDED,
} from '../actions';
@@ -32,11 +27,6 @@ type OpenDialogAction =
| ActionOf
| ActionOf
| ActionOf
- | ActionOf
- | ActionOf
- | ActionOf
- | ActionOf
- | ActionOf
| ActionOf
| ActionOf
| ActionOf
@@ -55,9 +45,6 @@ export const openDialog: Reducer = (
case WEBVIEW_SCREEN_SHARING_SOURCE_REQUESTED:
return 'screen-sharing';
- case UPDATES_NEW_VERSION_AVAILABLE:
- return 'update';
-
case CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED:
return 'select-client-certificate';
@@ -74,10 +61,6 @@ export const openDialog: Reducer = (
case WEBVIEW_SCREEN_SHARING_SOURCE_RESPONDED:
case SELECT_CLIENT_CERTIFICATE_DIALOG_CERTIFICATE_SELECTED:
case SELECT_CLIENT_CERTIFICATE_DIALOG_DISMISSED:
- case UPDATE_DIALOG_DISMISSED:
- case UPDATE_DIALOG_SKIP_UPDATE_CLICKED:
- case UPDATE_DIALOG_REMIND_UPDATE_LATER_CLICKED:
- case UPDATE_DIALOG_INSTALL_BUTTON_CLICKED:
case OUTLOOK_CALENDAR_DIALOG_DISMISSED:
case OUTLOOK_CALENDAR_SET_CREDENTIALS:
return null;
diff --git a/src/updates/actions.ts b/src/updates/actions.ts
index af46a2eeb1..3e9b9af54e 100644
--- a/src/updates/actions.ts
+++ b/src/updates/actions.ts
@@ -10,6 +10,22 @@ export const UPDATES_NEW_VERSION_NOT_AVAILABLE =
'updates/new-version-not-available';
export const UPDATES_READY = 'updates/ready';
export const UPDATES_CHANNEL_CHANGED = 'updates/channel-changed';
+export const UPDATES_DOWNLOAD_PROGRESSED = 'updates/download-progressed';
+export const UPDATES_UPDATE_DOWNLOADED = 'updates/update-downloaded';
+/** The titlebar update label was clicked while an update was available. */
+export const UPDATES_DOWNLOAD_REQUESTED = 'updates/download-requested';
+/** The titlebar update label was clicked once the update finished downloading. */
+export const UPDATES_INSTALL_REQUESTED = 'updates/install-requested';
+/** "Skip this version" was chosen in the titlebar update panel. */
+export const UPDATES_SKIP_REQUESTED = 'updates/skip-requested';
+/**
+ * Visibility of the titlebar update panel. Kept in the store rather than as
+ * local component state so other surfaces — the About dialog after a manual
+ * check — can open it.
+ */
+export const UPDATES_PANEL_TOGGLED = 'updates/panel-toggled';
+/** Developer-mode entry point that walks the whole flow without a real update. */
+export const UPDATES_SIMULATION_REQUESTED = 'updates/simulation-requested';
export type UpdatesActionTypeToPayloadMap = {
[UPDATE_SKIPPED]: string | null;
@@ -20,4 +36,11 @@ export type UpdatesActionTypeToPayloadMap = {
[UPDATES_NEW_VERSION_NOT_AVAILABLE]: void;
[UPDATES_READY]: UpdateConfiguration;
[UPDATES_CHANNEL_CHANGED]: string;
+ [UPDATES_DOWNLOAD_PROGRESSED]: number;
+ [UPDATES_UPDATE_DOWNLOADED]: void;
+ [UPDATES_DOWNLOAD_REQUESTED]: void;
+ [UPDATES_INSTALL_REQUESTED]: void;
+ [UPDATES_SKIP_REQUESTED]: string | null;
+ [UPDATES_PANEL_TOGGLED]: boolean;
+ [UPDATES_SIMULATION_REQUESTED]: void;
};
diff --git a/src/updates/common.ts b/src/updates/common.ts
index d05209a255..c485be5e65 100644
--- a/src/updates/common.ts
+++ b/src/updates/common.ts
@@ -28,3 +28,9 @@ export type UserLevelUpdateConfiguration = {
export const UPDATE_CHANNELS = ['latest', 'beta', 'alpha'] as const;
export type UpdateChannel = (typeof UPDATE_CHANNELS)[number];
+
+/**
+ * Download phase of an available update, driving the titlebar update label:
+ * `idle` (available, not downloading yet) → `downloading` → `downloaded`.
+ */
+export type UpdateDownloadStatus = 'idle' | 'downloading' | 'downloaded';
diff --git a/src/updates/main.ts b/src/updates/main.ts
index 8eaeb7af7f..ceb435958e 100644
--- a/src/updates/main.ts
+++ b/src/updates/main.ts
@@ -3,30 +3,31 @@ import path from 'path';
import { BrowserWindow, app, autoUpdater as nativeUpdater } from 'electron';
import { autoUpdater } from 'electron-updater';
-import { gt as semverGt } from 'semver';
+import { gt as semverGt, inc as semverInc } from 'semver';
import { listen, dispatch, select } from '../store';
import type { RootState } from '../store/rootReducer';
-import {
- UPDATE_DIALOG_SKIP_UPDATE_CLICKED,
- UPDATE_DIALOG_INSTALL_BUTTON_CLICKED,
- ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED,
-} from '../ui/actions';
+import { ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED } from '../ui/actions';
import {
askUpdateInstall,
AskUpdateInstallResponse,
warnAboutInstallUpdateLater,
- warnAboutUpdateDownload,
warnAboutUpdateSkipped,
} from '../ui/main/dialogs';
import {
UPDATE_SKIPPED,
UPDATES_CHECK_FOR_UPDATES_REQUESTED,
UPDATES_CHECKING_FOR_UPDATE,
+ UPDATES_DOWNLOAD_PROGRESSED,
+ UPDATES_DOWNLOAD_REQUESTED,
UPDATES_ERROR_THROWN,
+ UPDATES_INSTALL_REQUESTED,
UPDATES_NEW_VERSION_AVAILABLE,
UPDATES_NEW_VERSION_NOT_AVAILABLE,
UPDATES_READY,
+ UPDATES_SIMULATION_REQUESTED,
+ UPDATES_SKIP_REQUESTED,
+ UPDATES_UPDATE_DOWNLOADED,
UPDATES_CHANNEL_CHANGED,
} from './actions';
import type {
@@ -164,6 +165,150 @@ const loadConfiguration = async (): Promise => {
let isUserInitiatedCheck = false;
+/**
+ * Set while the download was started from the titlebar update label. The label
+ * reports progress and offers the restart itself, so the legacy modal prompts
+ * are skipped for that path.
+ */
+let isLabelInitiatedDownload = false;
+
+/** Developer-mode flow that never contacts the update server nor restarts. */
+let isSimulatingUpdate = false;
+let simulatedDownloadTimer: ReturnType | null = null;
+
+const stopSimulatedDownload = (): void => {
+ if (simulatedDownloadTimer) {
+ clearInterval(simulatedDownloadTimer);
+ simulatedDownloadTimer = null;
+ }
+};
+
+const startSimulatedDownload = (): void => {
+ stopSimulatedDownload();
+
+ let percent = 0;
+ simulatedDownloadTimer = setInterval(() => {
+ percent += 7;
+
+ if (percent >= 100) {
+ stopSimulatedDownload();
+ dispatch({ type: UPDATES_UPDATE_DOWNLOADED });
+ return;
+ }
+
+ dispatch({ type: UPDATES_DOWNLOAD_PROGRESSED, payload: percent });
+ }, 250);
+};
+
+const endSimulatedUpdate = (): void => {
+ stopSimulatedDownload();
+ isSimulatingUpdate = false;
+ isLabelInitiatedDownload = false;
+ dispatch({ type: UPDATES_NEW_VERSION_NOT_AVAILABLE });
+};
+
+const nativeUpdateDownloadedCallback = (): void => {
+ nativeUpdater.removeListener(
+ 'update-downloaded',
+ nativeUpdateDownloadedCallback
+ );
+ nativeUpdater.quitAndInstall();
+};
+
+/** Quits and relaunches into the freshly downloaded update. */
+const installDownloadedUpdate = (): void => {
+ setImmediate(() => {
+ app.removeAllListeners('window-all-closed');
+ if (process.platform === 'darwin') {
+ const allBrowserWindows = BrowserWindow.getAllWindows();
+ allBrowserWindows.forEach((browserWindow) => {
+ browserWindow.removeAllListeners('close');
+ browserWindow.destroy();
+ });
+ nativeUpdater.checkForUpdates();
+ nativeUpdater.on('update-downloaded', nativeUpdateDownloadedCallback);
+ } else {
+ autoUpdater.quitAndInstall(true, true);
+ }
+ });
+};
+
+const dispatchUpdateError = (error: unknown): void => {
+ if (error instanceof Error) {
+ dispatch({
+ type: UPDATES_ERROR_THROWN,
+ payload: {
+ message: error.message,
+ stack: error.stack,
+ name: error.name,
+ },
+ });
+ }
+};
+
+/**
+ * Wires the titlebar update label to the updater. Registered before the
+ * "updating not allowed/enabled" bail-out so the simulated flow stays available
+ * in builds that cannot self-update (e.g. unpackaged development runs).
+ */
+export const setupUpdateLabelFlow = (): void => {
+ listen(UPDATES_SIMULATION_REQUESTED, async () => {
+ stopSimulatedDownload();
+ isSimulatingUpdate = true;
+ isLabelInitiatedDownload = true;
+
+ const currentVersion = app.getVersion();
+ const simulatedVersion =
+ semverInc(currentVersion, 'minor') ?? `${currentVersion}-simulated`;
+
+ dispatch({
+ type: UPDATES_NEW_VERSION_AVAILABLE,
+ payload: simulatedVersion,
+ });
+ });
+
+ listen(UPDATES_DOWNLOAD_REQUESTED, async () => {
+ isLabelInitiatedDownload = true;
+
+ if (isSimulatingUpdate) {
+ startSimulatedDownload();
+ return;
+ }
+
+ try {
+ await autoUpdater.downloadUpdate();
+ } catch (error) {
+ isLabelInitiatedDownload = false;
+ dispatchUpdateError(error);
+ }
+ });
+
+ listen(UPDATES_INSTALL_REQUESTED, async () => {
+ if (isSimulatingUpdate) {
+ // Nothing to install — unwind the simulation instead of restarting.
+ endSimulatedUpdate();
+ return;
+ }
+
+ try {
+ installDownloadedUpdate();
+ } catch (error) {
+ dispatchUpdateError(error);
+ }
+ });
+
+ listen(UPDATES_SKIP_REQUESTED, async (action) => {
+ if (isSimulatingUpdate) {
+ // Skipping a fake version must not persist into the real settings.
+ endSimulatedUpdate();
+ return;
+ }
+
+ await warnAboutUpdateSkipped();
+ dispatch({ type: UPDATE_SKIPPED, payload: action.payload });
+ });
+};
+
export const setupUpdates = async (): Promise => {
// This is necessary to make the updater work in development mode
if (process.env.NODE_ENV === 'development') {
@@ -211,6 +356,10 @@ export const setupUpdates = async (): Promise => {
},
});
+ // Registered first so the simulated flow (and the label's own actions) stay
+ // reachable even when this build cannot self-update.
+ setupUpdateLabelFlow();
+
if (!isUpdatingAllowed || !isUpdatingEnabled) {
return;
}
@@ -274,15 +423,23 @@ export const setupUpdates = async (): Promise => {
dispatch({ type: UPDATES_NEW_VERSION_NOT_AVAILABLE });
});
- const nativeUpdateDownloadedCallback = () => {
- nativeUpdater.removeListener(
- 'update-downloaded',
- nativeUpdateDownloadedCallback
- );
- nativeUpdater.quitAndInstall();
- };
+ autoUpdater.addListener('download-progress', ({ percent }) => {
+ dispatch({
+ type: UPDATES_DOWNLOAD_PROGRESSED,
+ payload: Math.max(0, Math.min(100, Math.round(percent))),
+ });
+ });
autoUpdater.addListener('update-downloaded', async () => {
+ dispatch({ type: UPDATES_UPDATE_DOWNLOADED });
+
+ // Downloads started from the titlebar label surface the restart action in
+ // the label itself, so the modal prompt would be redundant.
+ if (isLabelInitiatedDownload) {
+ isLabelInitiatedDownload = false;
+ return;
+ }
+
const response = await askUpdateInstall();
if (response === AskUpdateInstallResponse.INSTALL_LATER) {
@@ -291,30 +448,9 @@ export const setupUpdates = async (): Promise => {
}
try {
- setImmediate(() => {
- app.removeAllListeners('window-all-closed');
- if (process.platform === 'darwin') {
- const allBrowserWindows = BrowserWindow.getAllWindows();
- allBrowserWindows.forEach((browserWindow) => {
- browserWindow.removeAllListeners('close');
- browserWindow.destroy();
- });
- nativeUpdater.checkForUpdates();
- nativeUpdater.on('update-downloaded', nativeUpdateDownloadedCallback);
- } else {
- autoUpdater.quitAndInstall(true, true);
- }
- });
+ installDownloadedUpdate();
} catch (error) {
- error instanceof Error &&
- dispatch({
- type: UPDATES_ERROR_THROWN,
- payload: {
- message: error.message,
- stack: error.stack,
- name: error.name,
- },
- });
+ dispatchUpdateError(error);
}
});
@@ -363,30 +499,4 @@ export const setupUpdates = async (): Promise => {
});
}
});
-
- listen(UPDATE_DIALOG_SKIP_UPDATE_CLICKED, async (action) => {
- await warnAboutUpdateSkipped();
- dispatch({
- type: UPDATE_SKIPPED,
- payload: action.payload,
- });
- });
-
- listen(UPDATE_DIALOG_INSTALL_BUTTON_CLICKED, async () => {
- await warnAboutUpdateDownload();
-
- try {
- await autoUpdater.downloadUpdate();
- } catch (error) {
- error instanceof Error &&
- dispatch({
- type: UPDATES_ERROR_THROWN,
- payload: {
- message: error.message,
- stack: error.stack,
- name: error.name,
- },
- });
- }
- });
};
diff --git a/src/updates/reducers.ts b/src/updates/reducers.ts
index f11ca7ebe7..986879affd 100644
--- a/src/updates/reducers.ts
+++ b/src/updates/reducers.ts
@@ -8,13 +8,19 @@ import {
} from '../ui/actions';
import {
UPDATES_CHECKING_FOR_UPDATE,
+ UPDATES_DOWNLOAD_PROGRESSED,
+ UPDATES_DOWNLOAD_REQUESTED,
UPDATES_ERROR_THROWN,
UPDATES_NEW_VERSION_AVAILABLE,
UPDATES_NEW_VERSION_NOT_AVAILABLE,
+ UPDATES_PANEL_TOGGLED,
UPDATES_READY,
+ UPDATES_SKIP_REQUESTED,
+ UPDATES_UPDATE_DOWNLOADED,
UPDATE_SKIPPED,
UPDATES_CHANNEL_CHANGED,
} from './actions';
+import type { UpdateDownloadStatus } from './common';
type DoCheckForUpdatesOnStartupAction =
| ActionOf
@@ -222,6 +228,92 @@ export const updateError: Reducer = (
}
};
+type UpdateDownloadAction =
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf;
+
+export const updateDownloadStatus: Reducer<
+ UpdateDownloadStatus,
+ UpdateDownloadAction
+> = (state = 'idle', action) => {
+ switch (action.type) {
+ case UPDATES_DOWNLOAD_REQUESTED:
+ case UPDATES_DOWNLOAD_PROGRESSED:
+ return 'downloading';
+
+ case UPDATES_UPDATE_DOWNLOADED:
+ return 'downloaded';
+
+ // A new version, a dismissed one, or a failed download all send the label
+ // back to its "available" resting state.
+ case UPDATES_NEW_VERSION_AVAILABLE:
+ case UPDATES_NEW_VERSION_NOT_AVAILABLE:
+ case UPDATES_ERROR_THROWN:
+ case UPDATE_SKIPPED:
+ return 'idle';
+
+ default:
+ return state;
+ }
+};
+
+export const updateDownloadProgress: Reducer = (
+ state = 0,
+ action
+) => {
+ switch (action.type) {
+ case UPDATES_DOWNLOAD_PROGRESSED:
+ return action.payload;
+
+ case UPDATES_UPDATE_DOWNLOADED:
+ return 100;
+
+ case UPDATES_DOWNLOAD_REQUESTED:
+ case UPDATES_NEW_VERSION_AVAILABLE:
+ case UPDATES_NEW_VERSION_NOT_AVAILABLE:
+ case UPDATES_ERROR_THROWN:
+ case UPDATE_SKIPPED:
+ return 0;
+
+ default:
+ return state;
+ }
+};
+
+type IsUpdatePanelOpenAction =
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf
+ | ActionOf;
+
+export const isUpdatePanelOpen: Reducer = (
+ state = false,
+ action
+) => {
+ switch (action.type) {
+ case UPDATES_PANEL_TOGGLED:
+ return action.payload;
+
+ // Acting on the panel, or the update going away, dismisses it.
+ case UPDATES_DOWNLOAD_REQUESTED:
+ case UPDATES_SKIP_REQUESTED:
+ case UPDATES_NEW_VERSION_NOT_AVAILABLE:
+ case UPDATES_ERROR_THROWN:
+ case UPDATE_SKIPPED:
+ return false;
+
+ default:
+ return state;
+ }
+};
+
type UpdateChannelAction =
| ActionOf
| ActionOf
diff --git a/src/updates/reducers/__tests__/updates.spec.ts b/src/updates/reducers/__tests__/updates.spec.ts
index f97321aef5..329e53d4a1 100644
--- a/src/updates/reducers/__tests__/updates.spec.ts
+++ b/src/updates/reducers/__tests__/updates.spec.ts
@@ -5,10 +5,15 @@ import {
} from '../../../ui/actions';
import {
UPDATES_CHECKING_FOR_UPDATE,
+ UPDATES_DOWNLOAD_PROGRESSED,
+ UPDATES_DOWNLOAD_REQUESTED,
UPDATES_ERROR_THROWN,
UPDATES_NEW_VERSION_AVAILABLE,
UPDATES_NEW_VERSION_NOT_AVAILABLE,
+ UPDATES_PANEL_TOGGLED,
UPDATES_READY,
+ UPDATES_SKIP_REQUESTED,
+ UPDATES_UPDATE_DOWNLOADED,
UPDATE_SKIPPED,
UPDATES_CHANNEL_CHANGED,
} from '../../actions';
@@ -16,10 +21,13 @@ import {
doCheckForUpdatesOnStartup,
isCheckingForUpdates,
isEachUpdatesSettingConfigurable,
+ isUpdatePanelOpen,
isUpdatingAllowed,
isUpdatingEnabled,
newUpdateVersion,
skippedUpdateVersion,
+ updateDownloadProgress,
+ updateDownloadStatus,
updateError,
updateChannel,
} from '../../reducers';
@@ -360,3 +368,113 @@ describe('updateChannel reducer', () => {
).toBe('beta');
});
});
+
+describe('updateDownloadStatus reducer', () => {
+ it('should default to idle', () => {
+ expect(updateDownloadStatus(undefined, unknown)).toBe('idle');
+ });
+
+ it('should enter downloading when a download is requested', () => {
+ expect(
+ updateDownloadStatus('idle', {
+ type: UPDATES_DOWNLOAD_REQUESTED,
+ } as any)
+ ).toBe('downloading');
+ });
+
+ it('should stay downloading while progress arrives', () => {
+ expect(
+ updateDownloadStatus('idle', {
+ type: UPDATES_DOWNLOAD_PROGRESSED,
+ payload: 42,
+ } as any)
+ ).toBe('downloading');
+ });
+
+ it('should become downloaded when the update is ready', () => {
+ expect(
+ updateDownloadStatus('downloading', {
+ type: UPDATES_UPDATE_DOWNLOADED,
+ } as any)
+ ).toBe('downloaded');
+ });
+
+ it.each([
+ UPDATES_NEW_VERSION_AVAILABLE,
+ UPDATES_NEW_VERSION_NOT_AVAILABLE,
+ UPDATES_ERROR_THROWN,
+ UPDATE_SKIPPED,
+ ])('should reset to idle on %s', (type) => {
+ expect(updateDownloadStatus('downloaded', { type } as any)).toBe('idle');
+ });
+});
+
+describe('updateDownloadProgress reducer', () => {
+ it('should default to 0', () => {
+ expect(updateDownloadProgress(undefined, unknown)).toBe(0);
+ });
+
+ it('should track the reported percentage', () => {
+ expect(
+ updateDownloadProgress(0, {
+ type: UPDATES_DOWNLOAD_PROGRESSED,
+ payload: 73,
+ } as any)
+ ).toBe(73);
+ });
+
+ it('should pin to 100 once downloaded', () => {
+ expect(
+ updateDownloadProgress(91, { type: UPDATES_UPDATE_DOWNLOADED } as any)
+ ).toBe(100);
+ });
+
+ it('should restart from 0 on a fresh download request', () => {
+ expect(
+ updateDownloadProgress(100, { type: UPDATES_DOWNLOAD_REQUESTED } as any)
+ ).toBe(0);
+ });
+
+ it('should reset to 0 when the update goes away', () => {
+ expect(
+ updateDownloadProgress(50, {
+ type: UPDATES_NEW_VERSION_NOT_AVAILABLE,
+ } as any)
+ ).toBe(0);
+ });
+});
+
+describe('isUpdatePanelOpen reducer', () => {
+ it('should default to closed', () => {
+ expect(isUpdatePanelOpen(undefined, unknown)).toBe(false);
+ });
+
+ it('should follow UPDATES_PANEL_TOGGLED', () => {
+ expect(
+ isUpdatePanelOpen(false, {
+ type: UPDATES_PANEL_TOGGLED,
+ payload: true,
+ } as any)
+ ).toBe(true);
+ expect(
+ isUpdatePanelOpen(true, {
+ type: UPDATES_PANEL_TOGGLED,
+ payload: false,
+ } as any)
+ ).toBe(false);
+ });
+
+ it.each([
+ UPDATES_DOWNLOAD_REQUESTED,
+ UPDATES_SKIP_REQUESTED,
+ UPDATES_NEW_VERSION_NOT_AVAILABLE,
+ UPDATES_ERROR_THROWN,
+ UPDATE_SKIPPED,
+ ])('should close on %s', (type) => {
+ expect(isUpdatePanelOpen(true, { type } as any)).toBe(false);
+ });
+
+ it('should stay open while the panel is merely re-rendered', () => {
+ expect(isUpdatePanelOpen(true, unknown)).toBe(true);
+ });
+});