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 ( - - - - {t('dialog.update.announcement')} - {t('dialog.update.message')} - - - - - - - {t('dialog.update.currentVersion')} - {currentVersion} - - - - {t('dialog.update.newVersion')} - {newVersion} - - - - - - - - - - - - - ); -}; 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); + }); +});