Files
iptvnator/apps/web/src/app/app-update-notification-panel.component.ts
T
4grayandClaude Opus 5.5 dd8293e7cf perf(web): make the app root and settings components OnPush
Plan item C6 step 3 for apps/web: the fifteen Eager components switch to
OnPush, among them the app root and the update notification panel that
the idle audit found re-rendering on every idle tick. Their template
state is signals from the settings facades, signal inputs and the shared
reactive settings form.

The checklist flagged the backup import, which patches the form from a
detached file input with no template event. A new spec patches only a
value, which changes no form status, and confirms the OnPush general
section still shows the new theme; it guards that path for the zoneless
flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:16:42 +02:00

312 lines
11 KiB
TypeScript

import {
Component,
computed,
OnDestroy,
OnInit,
signal,
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateInstallService } from './services/app-update-install.service';
@Component({
selector: 'app-update-notification-panel',
imports: [MatButtonModule, MatIconModule, TranslatePipe],
template: `
@if (isVisible(); as visible) {
<section
class="app-update-notification"
data-test-id="app-update-notification"
>
<header class="app-update-notification__header">
<span>
<mat-icon>system_update</mat-icon>
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
</span>
<button
mat-icon-button
type="button"
(click)="dismiss()"
[attr.aria-label]="'CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</header>
<div class="app-update-notification__body">
<strong>
{{
labelKey()
| translate
: {
version:
status()?.latestVersion ||
status()?.currentVersion,
}
}}
</strong>
@if (status()?.progress; as progress) {
<progress
[value]="progress.percent"
max="100"
></progress>
}
</div>
<div class="app-update-notification__actions">
<button
mat-stroked-button
type="button"
(click)="openReleaseNotes()"
data-test-id="app-update-notification-release-notes"
>
<mat-icon>article</mat-icon>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</button>
@if (status()?.status === appUpdateStatuses.Downloaded) {
<button
mat-flat-button
type="button"
(click)="installUpdate()"
data-test-id="app-update-notification-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
} @else {
<button
mat-flat-button
type="button"
[disabled]="
status()?.status ===
appUpdateStatuses.Downloading
"
(click)="downloadUpdate()"
data-test-id="app-update-notification-download"
>
<mat-icon>{{ primaryActionIcon() }}</mat-icon>
{{ primaryActionLabelKey() | translate }}
</button>
}
</div>
</section>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.app-update-notification {
position: fixed;
right: 20px;
bottom: 20px;
width: 320px;
max-width: calc(100vw - 40px);
z-index: 901;
overflow: hidden;
border: 1px solid var(--app-separator);
border-radius: 12px;
background: var(--app-widget-bg);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 0, 0, 0.18),
0 16px 48px rgba(0, 0, 0, 0.12);
}
.app-update-notification__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 8px 8px 12px;
border-bottom: 1px solid var(--app-separator);
}
.app-update-notification__header span {
display: flex;
align-items: center;
gap: 8px;
color: var(--app-heading-color);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-update-notification__header mat-icon {
color: var(--app-selection-color);
}
.app-update-notification__body {
display: grid;
gap: 8px;
padding: 12px;
color: var(--app-body-color);
font-size: 0.88rem;
}
.app-update-notification__body progress {
width: 100%;
}
.app-update-notification__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
padding: 0 12px 12px;
}
@media (max-width: 560px) {
.app-update-notification {
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-width: none;
}
}
`,
],
})
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
readonly dismissedVersion = signal<string | null>(null);
readonly labelKey = computed(() =>
this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
: 'SETTINGS.APP_UPDATE_AVAILABLE'
);
readonly primaryActionLabelKey = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
);
readonly primaryActionIcon = computed(() =>
this.status()?.supportedSelfUpdate === false
? 'open_in_new'
: 'download'
);
readonly isVisible = computed(() => {
const status = this.status();
if (!status?.latestVersion) {
return false;
}
if (this.dismissedVersion() === status.latestVersion) {
return false;
}
return (
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
ngOnInit(): void {
if (!window.electron?.getAppUpdateStatus) {
return;
}
this.unsubscribeStatus =
window.electron.onAppUpdateStatusChange?.((status) => {
this.status.set(status);
}) ?? null;
void window.electron.getAppUpdateStatus().then((status) => {
this.status.set(status);
});
}
ngOnDestroy(): void {
this.unsubscribeStatus?.();
this.unsubscribeStatus = null;
}
/**
* Loaded on demand: the dialog renders Markdown with `marked`, which this
* always-mounted panel would otherwise put on the initial path. A field so
* specs can substitute it.
*/
loadReleaseNotesDialog = () =>
import('./settings/app-update-release-notes-dialog.component');
async openReleaseNotes(): Promise<void> {
const status = this.status();
const latestVersion = status?.latestVersion;
let dialogModule: Awaited<
ReturnType<typeof this.loadReleaseNotesDialog>
>;
try {
dialogModule = await this.loadReleaseNotesDialog();
} catch (error) {
// The same notes are on the releases page; the next click retries.
console.error('Could not load the release notes dialog:', error);
if (status?.manualDownloadUrl) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
}
return;
}
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion: latestVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
async downloadUpdate(): Promise<void> {
const status = this.status();
if (!status) {
return;
}
if (!status.supportedSelfUpdate) {
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
return;
}
if (!window.electron?.downloadAppUpdate) {
return;
}
this.status.set(await window.electron.downloadAppUpdate());
}
async installUpdate(): Promise<void> {
// Never window.electron.installAppUpdate() directly: the install
// quits the app, and AppUpdateInstallService stands the settings
// unload guard down first — this panel is global, so the settings
// form can be dirty right now.
const status = await this.installService.installAppUpdate();
if (status) {
this.status.set(status);
}
}
dismiss(): void {
this.dismissedVersion.set(this.status()?.latestVersion ?? null);
}
}