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: `
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
@if (loading()) {
} @else if (error(); as loadError) {
@if (loadError.kind === 'not-found') {
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND'
| translate: { version: loadError.version }
}}
} @else {
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED'
| translate
}}
{{ loadError.message }}
}
} @else {
}
@if (error()) {
} @else if (notes()?.htmlUrl; as htmlUrl) {
}
`,
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(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef
);
private readonly sanitizer = inject(DomSanitizer);
readonly loading = signal(false);
readonly error = signal(null);
readonly notes = signal(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 {
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();
}
}