mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* 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> * fix(settings): re-render OnPush sections when the form changes outside them Review follow-ups (Greptile, Codex): - The settings sections read form values in their templates (selected theme and cover size, epgField.value, form().value.player), and the parent changes the form outside their events: Discard and backup import patch it, the store hydrates it, the EPG file picker sets a control after an await. Under OnPush the section kept the old selection or EPG status. Each section now marks itself on its form's events (markSectionForCheckOnFormEvents). - The value-only patch test no longer forces detectChanges(); with the fixture rendering on its own it fails without the marking, and so does a new test for a control set outside the EPG section. - The zoneless guard counts only changeDetection metadata outside comments, so a comment naming the strategy is not an Eager component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(settings): guard the unsaved-changes bar after a save off the sections Review follow-up (Codex): Save marks the form pristine after an async store write, also on Backup, Reset or search, where no form section is rendered. The OnPush page re-renders anyway because pristine and valid read the form's state signals; the new test checks that on the Backup page without forcing a render (it waits for the scheduled one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import {
|
|
Component,
|
|
computed,
|
|
inject,
|
|
OnInit,
|
|
SecurityContext,
|
|
signal,
|
|
ViewEncapsulation,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { DatePipe } from '@angular/common';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import {
|
|
MAT_DIALOG_DATA,
|
|
MatDialogModule,
|
|
MatDialogRef,
|
|
} from '@angular/material/dialog';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
import { TranslatePipe } from '@ngx-translate/core';
|
|
import { marked } from 'marked';
|
|
import {
|
|
appUpdateReleasesListUrl,
|
|
appVersionChannel,
|
|
ElectronBridgeAppUpdateReleaseNotes,
|
|
ElectronBridgeAppUpdateReleaseNotesDirection,
|
|
ElectronBridgeAppUpdateReleaseNotesRequest,
|
|
isAppUpdateReleaseNotesNotFoundMessage,
|
|
} from '@iptvnator/shared/interfaces';
|
|
|
|
const RELEASE_NOTES_IMAGE_CLASS = 'release-notes-dialog__image';
|
|
|
|
/** `ipcRenderer.invoke` wraps a main-process rejection; the wrapper is noise. */
|
|
const IPC_REJECTION_PREFIX =
|
|
/^Error invoking remote method '[^']*': (?:Error: )?/;
|
|
|
|
interface ReleaseNotesLoadError {
|
|
/** `not-found`: GitHub has no release for that version; `failed`: anything else. */
|
|
kind: 'not-found' | 'failed';
|
|
message: string;
|
|
version?: string;
|
|
}
|
|
|
|
export interface AppUpdateReleaseNotesDialogData {
|
|
initialVersion?: string;
|
|
fallbackToLatest?: boolean;
|
|
}
|
|
|
|
function decorateReleaseNotesHtml(html: string): string {
|
|
if (typeof document === 'undefined') {
|
|
return html;
|
|
}
|
|
|
|
const template = document.createElement('template');
|
|
template.innerHTML = html;
|
|
|
|
template.content.querySelectorAll('img').forEach((image) => {
|
|
image.classList.add(RELEASE_NOTES_IMAGE_CLASS);
|
|
});
|
|
|
|
return template.innerHTML;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-update-release-notes-dialog',
|
|
imports: [
|
|
MatButtonModule,
|
|
MatDialogModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule,
|
|
DatePipe,
|
|
TranslatePipe,
|
|
],
|
|
encapsulation: ViewEncapsulation.None,
|
|
template: `
|
|
<h2 mat-dialog-title>
|
|
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
|
</h2>
|
|
|
|
<mat-dialog-content class="release-notes-dialog mat-typography">
|
|
<div class="release-notes-dialog__toolbar">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
[disabled]="loading() || !notes()?.hasPrevious"
|
|
(click)="load('previous')"
|
|
data-test-id="release-notes-previous"
|
|
>
|
|
<mat-icon>chevron_left</mat-icon>
|
|
</button>
|
|
|
|
<div class="release-notes-dialog__version">
|
|
<strong>{{
|
|
notes()?.releaseName || notes()?.tagName
|
|
}}</strong>
|
|
@if (notes()?.publishedAt; as publishedAt) {
|
|
<span>{{ publishedAt | date: 'mediumDate' }}</span>
|
|
}
|
|
</div>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
[disabled]="loading() || !notes()?.hasNext"
|
|
(click)="load('next')"
|
|
data-test-id="release-notes-next"
|
|
>
|
|
<mat-icon>chevron_right</mat-icon>
|
|
</button>
|
|
</div>
|
|
|
|
@if (loading()) {
|
|
<div class="release-notes-dialog__loading">
|
|
<mat-spinner diameter="28" />
|
|
</div>
|
|
} @else if (error(); as loadError) {
|
|
<div
|
|
class="release-notes-dialog__error"
|
|
[class.release-notes-dialog__error--failed]="
|
|
loadError.kind === 'failed'
|
|
"
|
|
data-test-id="release-notes-error"
|
|
>
|
|
@if (loadError.kind === 'not-found') {
|
|
<p>
|
|
{{
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND'
|
|
| translate: { version: loadError.version }
|
|
}}
|
|
</p>
|
|
} @else {
|
|
<p>
|
|
{{
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED'
|
|
| translate
|
|
}}
|
|
</p>
|
|
<small>{{ loadError.message }}</small>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<article
|
|
class="release-notes-dialog__body"
|
|
data-test-id="release-notes-body"
|
|
[innerHTML]="renderedMarkdown()"
|
|
></article>
|
|
}
|
|
</mat-dialog-content>
|
|
|
|
<mat-dialog-actions align="end">
|
|
<!-- The error is checked first: a failed Previous/Next keeps the
|
|
earlier notes for navigation, but the body shows the error,
|
|
so the action must not open that earlier release. -->
|
|
@if (error()) {
|
|
<button
|
|
mat-button
|
|
type="button"
|
|
(click)="openRelease(releasesPageUrl())"
|
|
data-test-id="release-notes-open-releases"
|
|
>
|
|
<mat-icon>open_in_new</mat-icon>
|
|
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASES_PAGE' | translate }}
|
|
</button>
|
|
} @else if (notes()?.htmlUrl; as htmlUrl) {
|
|
<button
|
|
mat-button
|
|
type="button"
|
|
(click)="openRelease(htmlUrl)"
|
|
data-test-id="release-notes-open-release"
|
|
>
|
|
<mat-icon>open_in_new</mat-icon>
|
|
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
|
</button>
|
|
}
|
|
<button mat-flat-button type="button" (click)="close()">
|
|
{{ 'CLOSE' | translate }}
|
|
</button>
|
|
</mat-dialog-actions>
|
|
`,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
styles: [
|
|
`
|
|
.release-notes-dialog {
|
|
min-width: min(640px, calc(100vw - 64px));
|
|
max-width: min(760px, calc(100vw - 32px));
|
|
}
|
|
|
|
.release-notes-dialog__toolbar {
|
|
display: grid;
|
|
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 4px 0 12px;
|
|
border-bottom: 1px solid var(--app-separator);
|
|
}
|
|
|
|
.release-notes-dialog__version {
|
|
min-width: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.release-notes-dialog__version strong,
|
|
.release-notes-dialog__version span {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.release-notes-dialog__version span {
|
|
margin-top: 2px;
|
|
color: var(--app-muted-color);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.release-notes-dialog__loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 32px;
|
|
}
|
|
|
|
.release-notes-dialog__error {
|
|
padding: 16px 2px 0;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
.release-notes-dialog__error--failed {
|
|
color: var(--app-error-color, #ef4444);
|
|
}
|
|
|
|
.release-notes-dialog__error p {
|
|
margin: 0;
|
|
}
|
|
|
|
.release-notes-dialog__error small {
|
|
display: block;
|
|
margin-top: 6px;
|
|
color: var(--app-muted-color);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.release-notes-dialog__body {
|
|
max-height: min(58vh, 560px);
|
|
overflow: auto;
|
|
padding: 12px 2px 0;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
.release-notes-dialog__body h1,
|
|
.release-notes-dialog__body h2,
|
|
.release-notes-dialog__body h3 {
|
|
color: var(--app-heading-color);
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.release-notes-dialog__body h1 {
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
.release-notes-dialog__body h2 {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.release-notes-dialog__body h3 {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.release-notes-dialog__body code {
|
|
border-radius: 4px;
|
|
padding: 1px 4px;
|
|
background: var(--app-hover-overlay);
|
|
}
|
|
|
|
.release-notes-dialog__body img,
|
|
.release-notes-dialog__image {
|
|
display: block;
|
|
box-sizing: border-box;
|
|
max-width: 100%;
|
|
height: auto;
|
|
margin: 16px auto;
|
|
object-fit: contain;
|
|
}
|
|
`,
|
|
],
|
|
})
|
|
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
|
|
private readonly data =
|
|
inject<AppUpdateReleaseNotesDialogData>(MAT_DIALOG_DATA);
|
|
private readonly dialogRef = inject(
|
|
MatDialogRef<AppUpdateReleaseNotesDialogComponent>
|
|
);
|
|
private readonly sanitizer = inject(DomSanitizer);
|
|
|
|
readonly loading = signal(false);
|
|
readonly error = signal<ReleaseNotesLoadError | null>(null);
|
|
readonly notes = signal<ElectronBridgeAppUpdateReleaseNotes | null>(null);
|
|
/** Release list of the channel the failed request was about. */
|
|
readonly releasesPageUrl = computed(() =>
|
|
appUpdateReleasesListUrl(
|
|
appVersionChannel(
|
|
this.error()?.version ??
|
|
this.notes()?.version ??
|
|
this.data.initialVersion
|
|
)
|
|
)
|
|
);
|
|
readonly renderedMarkdown = computed(() => {
|
|
const markdown = this.notes()?.bodyMarkdown ?? '';
|
|
const html = marked.parse(markdown, { async: false }) as string;
|
|
const sanitizedHtml =
|
|
this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
|
|
|
|
return decorateReleaseNotesHtml(sanitizedHtml);
|
|
});
|
|
|
|
ngOnInit(): void {
|
|
void this.load();
|
|
}
|
|
|
|
async load(
|
|
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
|
|
): Promise<void> {
|
|
if (!window.electron?.getAppUpdateReleaseNotes) {
|
|
this.error.set({
|
|
kind: 'failed',
|
|
message: 'Release notes are not available in this build.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const version = direction
|
|
? this.notes()?.tagName
|
|
: this.data.initialVersion;
|
|
const fallbackToLatest = direction
|
|
? undefined
|
|
: this.data.fallbackToLatest;
|
|
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
|
|
try {
|
|
const request: ElectronBridgeAppUpdateReleaseNotesRequest = {};
|
|
|
|
if (direction) {
|
|
request.direction = direction;
|
|
}
|
|
|
|
if (version) {
|
|
request.version = version;
|
|
}
|
|
|
|
if (fallbackToLatest) {
|
|
request.fallbackToLatest = true;
|
|
}
|
|
|
|
this.notes.set(
|
|
await window.electron.getAppUpdateReleaseNotes(request)
|
|
);
|
|
} catch (error) {
|
|
const message = (
|
|
error instanceof Error ? error.message : String(error)
|
|
).replace(IPC_REJECTION_PREFIX, '');
|
|
|
|
this.error.set({
|
|
kind: isAppUpdateReleaseNotesNotFoundMessage(message)
|
|
? 'not-found'
|
|
: 'failed',
|
|
message,
|
|
version,
|
|
});
|
|
} finally {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
openRelease(url: string): void {
|
|
window.open(url, '_blank', 'noreferrer');
|
|
}
|
|
|
|
close(): void {
|
|
this.dialogRef.close();
|
|
}
|
|
}
|