Version Update Modal Rework + A few bugfixes (#3664)

This commit is contained in:
Joe Milazzo 2025-03-22 15:05:48 -05:00 committed by GitHub
parent 9fb3bdd548
commit 43d0d1277f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1963 additions and 805 deletions

View file

@ -53,8 +53,8 @@ export class ServerService {
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
}
checkHowOutOfDate() {
return this.http.get<string>(this.baseUrl + 'server/check-out-of-date', TextResonse)
checkHowOutOfDate(stableOnly: boolean = true) {
return this.http.get<string>(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse)
.pipe(map(r => parseInt(r, 10)));
}

View file

@ -2,7 +2,7 @@ import {inject, Injectable, OnDestroy} from '@angular/core';
import {interval, Subscription, switchMap} from 'rxjs';
import {ServerService} from "./server.service";
import {AccountService} from "./account.service";
import {filter, tap} from "rxjs/operators";
import {filter, take} from "rxjs/operators";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component";
@ -16,82 +16,191 @@ export class VersionService implements OnDestroy{
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
public static readonly versionKey = 'kavita--version';
private readonly checkInterval = 600000; // 10 minutes (600000)
private periodicCheckSubscription?: Subscription;
public static readonly SERVER_VERSION_KEY = 'kavita--version';
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown';
public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown';
// Notification intervals
private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once)
private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds
// Check intervals
private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours
private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date"
private versionCheckSubscription?: Subscription;
private outOfDateCheckSubscription?: Subscription;
private modalOpen = false;
constructor() {
this.startPeriodicUpdateCheck();
this.startVersionCheck();
this.startOutOfDateCheck();
}
ngOnDestroy() {
this.periodicCheckSubscription?.unsubscribe();
this.versionCheckSubscription?.unsubscribe();
this.outOfDateCheckSubscription?.unsubscribe();
}
private startOutOfDateCheck() {
// Every hour, have the UI check for an update. People seriously stay out of date
this.outOfDateCheckSubscription = interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
/**
* Periodic check for server version to detect client refreshes and new updates
*/
private startVersionCheck(): void {
console.log('Starting version checker');
this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL)
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionOutOfDate => {
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
}),
tap(versionOutOfDate => {
if (!this.modalService.hasOpenModals()) {
const ref = this.modalService.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
ref.componentInstance.versionsOutOfDate = versionOutOfDate;
}
})
)
.subscribe();
}
private startPeriodicUpdateCheck(): void {
console.log('Starting periodic version update checker');
this.periodicCheckSubscription = interval(this.checkInterval)
.pipe(
switchMap(_ => this.accountService.currentUser$),
filter(user => user !== undefined && !this.modalOpen),
filter(user => !!user && !this.modalOpen),
switchMap(user => this.serverService.getVersion(user!.apiKey)),
filter(update => !!update),
).subscribe(version => this.handleVersionUpdate(version));
}
private handleVersionUpdate(version: string) {
/**
* Checks if the server is out of date compared to the latest release
*/
private startOutOfDateCheck() {
console.log('Starting out-of-date checker');
this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL)
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen),
switchMap(_ => this.serverService.checkHowOutOfDate(true)),
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT),
)
.subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate));
}
/**
* Handles the version check response to determine if client refresh or new update notification is needed
*/
private handleVersionUpdate(serverVersion: string) {
if (this.modalOpen) return;
// Pause periodic checks while the modal is open
this.periodicCheckSubscription?.unsubscribe();
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);
const cachedVersion = localStorage.getItem(VersionService.versionKey);
console.log('Kavita version: ', version, ' Running version: ', cachedVersion);
const hasChanged = cachedVersion == null || cachedVersion != version;
if (hasChanged) {
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg', keyboard: false});
ref.componentInstance.version = version;
ref.componentInstance.update = changelog[0];
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
});
const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion;
// Case 1: Client Refresh needed (server has updated since last client load)
if (isNewServerVersion) {
this.showClientRefreshNotification(serverVersion);
}
// Case 2: Check for new updates (for server admin)
else {
this.checkForNewUpdates();
}
localStorage.setItem(VersionService.versionKey, version);
// Always update the cached version
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
}
private onModalClosed() {
/**
* Shows a notification that client refresh is needed due to server update
*/
private showClientRefreshNotification(newVersion: string): void {
this.pauseChecks();
// Client refresh notifications should always show (once)
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, {
size: 'lg',
keyboard: false,
backdrop: 'static' // Prevent closing by clicking outside
});
ref.componentInstance.version = newVersion;
ref.componentInstance.update = changelog[0];
ref.componentInstance.requiresRefresh = true;
// Update the last shown timestamp
localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString());
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
});
}
/**
* Checks for new server updates and shows notification if appropriate
*/
private checkForNewUpdates(): void {
this.accountService.currentUser$
.pipe(
take(1),
filter(user => user !== undefined && this.accountService.hasAdminRole(user)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT)
)
.subscribe(versionsOutOfDate => {
const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0');
const currentTime = Date.now();
// Show notification if it hasn't been shown in the last week
if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) {
this.pauseChecks();
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' });
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
ref.componentInstance.update = changelog[0];
ref.componentInstance.requiresRefresh = false;
// Update the last shown timestamp
localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString());
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
});
}
});
}
/**
* Handles the notification for servers that are significantly out of date
*/
private handleOutOfDateNotification(versionsOutOfDate: number): void {
const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0');
const currentTime = Date.now();
// Show notification if it hasn't been shown in the last month
if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) {
this.pauseChecks();
this.modalOpen = true;
const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' });
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
// Update the last shown timestamp
localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString());
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
}
}
/**
* Pauses all version checks while modals are open
*/
private pauseChecks(): void {
this.versionCheckSubscription?.unsubscribe();
this.outOfDateCheckSubscription?.unsubscribe();
}
/**
* Resumes all checks when modals are closed
*/
private onModalClosed(): void {
this.modalOpen = false;
this.startPeriodicUpdateCheck();
this.startVersionCheck();
this.startOutOfDateCheck();
}
}

View file

@ -29,7 +29,7 @@ export interface RelatedSeriesPair {
styleUrl: './related-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RelatedTabComponent implements OnInit {
export class RelatedTabComponent {
protected readonly imageService = inject(ImageService);
protected readonly router = inject(Router);
@ -40,10 +40,6 @@ export class RelatedTabComponent implements OnInit {
@Input() bookmarks: Array<PageBookmark> = [];
@Input() libraryId!: number;
ngOnInit() {
console.log('bookmarks: ', this.bookmarks);
}
openReadingList(readingList: ReadingList) {
this.router.navigate(['lists', readingList.id]);
}

View file

@ -224,7 +224,6 @@ export class LicenseComponent implements OnInit {
toggleViewMode() {
this.isViewMode = !this.isViewMode;
console.log('edit mode: ', !this.isViewMode)
this.cdRef.markForCheck();
this.resetForm();
}

View file

@ -64,7 +64,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy {
// unsubscribe from signalr connection
if (this.hubConnection) {
this.hubConnection.stop().catch(err => console.error(err));
console.log('Stoping log connection');
console.log('Stopping log connection');
}
}

View file

@ -7,7 +7,7 @@ import {shareReplay} from 'rxjs/operators';
import {debounceTime, defer, distinctUntilChanged, filter, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job';
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
import {UpdateNotificationModalComponent} from 'src/app/announcements/_components/update-notification/update-notification-modal.component';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {DefaultValuePipe} from '../../_pipes/default-value.pipe';
@ -134,6 +134,7 @@ export class ManageTasksSettingsComponent implements OnInit {
}
},
];
customOption = 'custom';
@ -305,7 +306,6 @@ export class ManageTasksSettingsComponent implements OnInit {
modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value;
}
console.log('modelSettings: ', modelSettings);
return modelSettings;
}

View file

@ -112,7 +112,6 @@ export class AllSeriesComponent implements OnInit {
private readonly cdRef: ChangeDetectorRef) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
console.log('url: ', this.route.snapshot);
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;

View file

@ -7,13 +7,14 @@ import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {VersionService} from "../../../_services/version.service";
import {ChangelogUpdateItemComponent} from "../changelog-update-item/changelog-update-item.component";
/**
* This modal is used when an update occurred and the UI needs to be refreshed to get the latest JS libraries
*/
@Component({
selector: 'app-new-update-modal',
standalone: true,
imports: [
TranslocoDirective,
UpdateSectionComponent,
SafeHtmlPipe,
ChangelogUpdateItemComponent
],
templateUrl: './new-update-modal.component.html',
@ -41,8 +42,6 @@ export class NewUpdateModalComponent {
private applyUpdate(version: string): void {
this.bustLocaleCache();
console.log('Setting version key: ', version);
localStorage.setItem(VersionService.versionKey, version);
location.reload();
}
@ -54,8 +53,10 @@ export class NewUpdateModalComponent {
(this.translocoService as any).cache.delete(locale);
(this.translocoService as any).cache.clear();
// TODO: Retrigger transloco
this.translocoService.setActiveLang(locale);
// Retrigger transloco
setTimeout(() => {
this.translocoService.setActiveLang(locale);
}, 10);
}
}

View file

@ -4,8 +4,9 @@
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<h5>{{updateData.updateTitle}}</h5>
<pre class="update-body" [innerHtml]="updateData.updateBody | safeHtml"></pre>
@if (updateData) {
<app-changelog-update-item [update]="updateData" [showExtras]="false"></app-changelog-update-item>
}
</div>
<div class="modal-footer">

View file

@ -2,15 +2,18 @@ import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
import {CommonModule} from "@angular/common";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {TranslocoDirective} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki";
import {WikiLink} from "../../../_models/wiki";
import {
ChangelogUpdateItemComponent
} from "../changelog-update-item/changelog-update-item.component";
@Component({
selector: 'app-update-notification-modal',
standalone: true,
imports: [CommonModule, NgbModalModule, SafeHtmlPipe, TranslocoDirective],
imports: [CommonModule, NgbModalModule, SafeHtmlPipe, TranslocoDirective, ChangelogUpdateItemComponent],
templateUrl: './update-notification-modal.component.html',
styleUrls: ['./update-notification-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -20,6 +23,8 @@ export class UpdateNotificationModalComponent implements OnInit {
@Input({required: true}) updateData!: UpdateVersionEvent;
updateUrl: string = WikiLink.UpdateNative;
// TODO: I think I can remove this and just use NewUpdateModalComponent instead which handles both Nightly/Stable
constructor(public modal: NgbActiveModal) { }
ngOnInit() {

View file

@ -97,6 +97,7 @@ export class AppComponent implements OnInit {
return user.preferences.noTransitions;
}), takeUntilDestroyed(this.destroyRef));
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
}
@ -113,7 +114,6 @@ export class AppComponent implements OnInit {
this.setDocHeight();
this.setCurrentUser();
this.themeService.setColorScape('');
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
}

View file

@ -220,7 +220,6 @@ export class VolumeCardComponent implements OnInit {
read(event: any) {
event.stopPropagation();
event.preventDefault();
console.log('reading volume');
this.readerService.readVolume(this.libraryId, this.seriesId, this.volume, false);
}

View file

@ -12,7 +12,7 @@ import { NgbModal, NgbModalRef, NgbPopover } from '@ng-bootstrap/ng-bootstrap';
import {BehaviorSubject, debounceTime, startWith} from 'rxjs';
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
import { UpdateNotificationModalComponent } from 'src/app/announcements/_components/update-notification/update-notification-modal.component';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { ErrorEvent } from 'src/app/_models/events/error-event';
import { InfoEvent } from 'src/app/_models/events/info-event';

View file

@ -26,9 +26,9 @@
{{item.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{{t('comics-label', {value: item.comicsTime})}}</li>
<li class="list-group-item">{{t('manga-label', {value: item.mangaTime})}}</li>
<li class="list-group-item">{{t('books-label', {value: item.booksTime})}}</li>
<li class="list-group-item">{{t('comics-label', {value: item.comicsTime | number:'1.0-1'})}}</li>
<li class="list-group-item">{{t('manga-label', {value: item.mangaTime | number:'1.0-1'})}}</li>
<li class="list-group-item">{{t('books-label', {value: item.booksTime | number:'1.0-1'})}}</li>
</ul>
</div>
</ng-template>

View file

@ -11,7 +11,7 @@ import { Observable, switchMap, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { TopUserRead } from '../../_models/top-reads';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { AsyncPipe } from '@angular/common';
import {AsyncPipe, DecimalPipe} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
@ -29,18 +29,20 @@ export const TimePeriods: Array<{title: string, value: number}> =
styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent]
imports: [ReactiveFormsModule, AsyncPipe, TranslocoDirective, CarouselReelComponent, DecimalPipe]
})
export class TopReadersComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly statsService = inject(StatisticsService);
private readonly cdRef = inject(ChangeDetectorRef);
formGroup: FormGroup;
timePeriods = TimePeriods;
users$: Observable<TopUserRead[]>;
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
constructor() {
this.formGroup = new FormGroup({
'days': new FormControl(this.timePeriods[0].value, []),
});