From ffab43e4bc52cc7accad7aecc55834028c6b13b3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Sun, 27 Mar 2022 14:56:29 -0600 Subject: [PATCH] [WIP] Add online and offline saving indicator --- .../ClientApp/src/app/app.component.html | 10 +-- .../ClientApp/src/app/app.component.ts | 36 +++++++- .../avatar/avatar.component.html | 2 +- .../xforge-common/avatar/avatar.component.ts | 18 +++- .../memory-realtime-remote-store.ts | 6 ++ .../src/xforge-common/models/realtime-doc.ts | 18 +++- .../xforge-common/realtime-remote-store.ts | 2 + .../src/xforge-common/realtime.service.ts | 2 + .../xforge-common/save-status.service.spec.ts | 16 ++++ .../src/xforge-common/save-status.service.ts | 86 +++++++++++++++++++ .../sharedb-realtime-remote-store.ts | 11 ++- 11 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html index a6744e1cbf..7b22622d39 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html @@ -123,13 +123,9 @@
-
- cloud - {{ t("online") }} -
-
- cloud_off - {{ t("offline") }} +
+ {{ (onlineStatus$ | async)?.icon }} + {{ t((onlineStatus$ | async)?.text) }}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index c2c939d1c2..7f4d6ccc15 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -27,6 +27,7 @@ import { RealtimeQuery } from 'xforge-common/models/realtime-query'; import { UserDoc } from 'xforge-common/models/user-doc'; import { NoticeService } from 'xforge-common/notice.service'; import { PwaService } from 'xforge-common/pwa.service'; +import { SaveStatusService } from 'xforge-common/save-status.service'; import { BrowserIssue, SupportedBrowsersDialogComponent @@ -82,6 +83,38 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest private _isDrawerPermanent: boolean = true; private readonly questionCountQueries = new Map(); + readonly onlineStatus$ = combineLatest([ + this.saveStatusService.uiSaving$, + this.saveStatusService.uiJustFinishedSaving$ + ]).pipe( + map(([savingStatus, finishedSavingStatus]) => { + const icon = savingStatus.saving + ? savingStatus.online + ? 'cloud' + : 'cloud_off' + : finishedSavingStatus.finishedSaving + ? finishedSavingStatus.online + ? 'cloud' + : 'cloud_off' + : this.isAppOnline + ? 'cloud' + : 'cloud_off'; + const text = savingStatus.saving + ? savingStatus.online + ? 'saving_to_cloud' + : 'saving_to_device' + : finishedSavingStatus.finishedSaving + ? finishedSavingStatus.online + ? 'saved_to_cloud' + : 'saved_to_device' + : this.isAppOnline + ? 'online' + : 'offline'; + + return { icon, text }; + }) + ); + constructor( private readonly router: Router, private readonly authService: AuthService, @@ -100,7 +133,8 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest readonly media: MediaObserver, private readonly pwaService: PwaService, iconRegistry: MdcIconRegistry, - sanitizer: DomSanitizer + sanitizer: DomSanitizer, + private readonly saveStatusService: SaveStatusService ) { super(noticeService); this.subscribe(media.media$, (change: MediaChange) => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.html b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.html index cac2187200..cc59edf5c6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.html @@ -1,4 +1,4 @@
- cloud_off + {{ icon }}
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.ts index a203748f22..c8717e63df 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/avatar/avatar.component.ts @@ -1,6 +1,8 @@ import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { UserProfile } from 'realtime-server/lib/esm/common/models/user'; -import { PwaService } from 'xforge-common/pwa.service'; +import { SaveStatusService } from 'xforge-common/save-status.service'; @Component({ selector: 'app-avatar', @@ -13,7 +15,19 @@ export class AvatarComponent { @Input() user?: UserProfile; @Input() showOnlineStatus: boolean = false; - constructor(readonly pwaService: PwaService) {} + readonly statusIcon$: Observable = this.docSaveNotificationService.uiSaving$.pipe( + map(({ online, saving }) => { + if (saving) { + return 'cached'; + } else if (!online) { + return 'cloud_off'; + } else { + return undefined; + } + }) + ); + + constructor(private readonly docSaveNotificationService: SaveStatusService) {} get avatarUrl(): string { return this.user != null ? this.user.avatarUrl : ''; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/memory-realtime-remote-store.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/memory-realtime-remote-store.ts index b5dd32c03e..9b743379b2 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/memory-realtime-remote-store.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/memory-realtime-remote-store.ts @@ -60,6 +60,7 @@ export class MemoryRealtimeDocAdapter implements RealtimeDocAdapter { readonly remoteChanges$ = new Subject(); readonly create$ = new Subject(); readonly delete$ = new Subject(); + readonly beforeLocalChanges$ = new Subject(); readonly idle$ = EMPTY; constructor( @@ -105,6 +106,7 @@ export class MemoryRealtimeDocAdapter implements RealtimeDocAdapter { if (op != null && this.type.normalize != null) { op = this.type.normalize(op); } + this.emitBeforeOp(); this.data = this.type.apply(this.data, op); this.version++; if (!source) { @@ -144,6 +146,10 @@ export class MemoryRealtimeDocAdapter implements RealtimeDocAdapter { emitDelete(): void { this.delete$.next(); } + + emitBeforeOp(): void { + this.beforeOp$.next(); + } } export class MemoryRealtimeQueryAdapter implements RealtimeQueryAdapter { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts index ecd8c8ab0e..596636096e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/models/realtime-doc.ts @@ -1,4 +1,5 @@ import { merge, Observable, Subject, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { RealtimeService } from 'xforge-common/realtime.service'; import { RealtimeDocAdapter } from '../realtime-remote-store'; import { RealtimeOfflineData } from './realtime-offline-data'; @@ -18,8 +19,9 @@ export interface RealtimeDocConstructor { * @template Ops The operations data type. */ export abstract class RealtimeDoc { - private updateOfflineDataSub: Subscription; - private onDeleteSub: Subscription; + private readonly updateOfflineDataSub: Subscription; + private readonly onDeleteSub: Subscription; + private readonly saveNotificationSub: Subscription; private offlineSnapshotVersion?: number; private subscribePromise?: Promise; private localDelete$ = new Subject(); @@ -39,6 +41,10 @@ export abstract class RealtimeDoc { } ); this.onDeleteSub = this.adapter.delete$.subscribe(() => this.onDelete()); + this.saveNotificationSub = merge( + this.adapter.beforeOp$.pipe(tap(() => this.realtimeService.saveStatusService.startedSavingDocOnline(this.id))), + this.adapter.idle$.pipe(tap(() => this.realtimeService.saveStatusService.finishedSavingDocOnline(this.id))) + ).subscribe(); } get id(): string { @@ -151,6 +157,7 @@ export abstract class RealtimeDoc { } this.updateOfflineDataSub.unsubscribe(); this.onDeleteSub.unsubscribe(); + this.saveNotificationSub.unsubscribe(); await this.adapter.destroy(); this.subscribedState = false; await this.realtimeService.onLocalDocDispose(this); @@ -203,7 +210,12 @@ export abstract class RealtimeDoc { type: this.adapter.type.name, pendingOps }; - await this.realtimeService.offlineStore.put(this.collection, offlineData); + this.realtimeService.saveStatusService.startedSavingDocOffline(this.id); + await this.realtimeService.offlineStore.put(this.collection, offlineData).finally(() => { + if (this.adapter.version === this.offlineSnapshotVersion) { + this.realtimeService.saveStatusService.finishedSavingDocOffline(this.id); + } + }); } private async loadOfflineData(): Promise { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime-remote-store.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime-remote-store.ts index 72ad84aabf..a4fb5a8b1f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime-remote-store.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime-remote-store.ts @@ -30,6 +30,8 @@ export interface RealtimeDocAdapter { /** Fires when underlying data is recreated. */ readonly create$: Observable; readonly delete$: Observable; + /** Fire before changes are made to underlying data */ + readonly beforeOp$: Observable; /** Fires when there are changes to underlying data. */ readonly remoteChanges$: Observable; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts index 76be2f20f9..864c6543ca 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/realtime.service.ts @@ -1,4 +1,5 @@ import { Injectable, Optional } from '@angular/core'; +import { SaveStatusService } from 'xforge-common/save-status.service'; import { AppError } from 'xforge-common/exception-handling-service'; import { FileService } from './file.service'; import { RealtimeDoc } from './models/realtime-doc'; @@ -29,6 +30,7 @@ export class RealtimeService { private readonly typeRegistry: TypeRegistry, public readonly remoteStore: RealtimeRemoteStore, public readonly offlineStore: OfflineStore, + public readonly saveStatusService: SaveStatusService, @Optional() public readonly fileService?: FileService, @Optional() public readonly pwaService?: PwaService ) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.spec.ts new file mode 100644 index 0000000000..d00e36a17c --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SaveStatusService } from './doc-save-notification.service'; + +describe('DocSaveNotificationService', () => { + let service: SaveStatusService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SaveStatusService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.ts new file mode 100644 index 0000000000..291c0de747 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/save-status.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import { isEmpty, isEqual } from 'lodash-es'; +import { BehaviorSubject, merge, timer } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, map, pairwise, switchMap, throttleTime } from 'rxjs/operators'; +import { PwaService } from 'xforge-common/pwa.service'; + +const UI_DISPLAY_TIME = 3000; + +@Injectable({ + providedIn: 'root' +}) +export class SaveStatusService { + private readonly savingOfflineSubject = new BehaviorSubject(false); + private readonly savingOnlineSubject = new BehaviorSubject(false); + + private readonly docsSavingOffline = new Set(); + private readonly docsSavingOnline = new Set(); + + private readonly savingOffline$ = this.savingOfflineSubject.asObservable(); + //.pipe(tap(off => console.log('s offline', off))); + private readonly savingOnline$ = this.savingOnlineSubject.asObservable(); + //.pipe(tap(off => console.log('s online', off))); + + readonly saving$ = this.pwaService.onlineStatus.pipe( + //tap(online => console.log('online', online)), + switchMap(online => { + const saving$ = online ? this.savingOnline$ : this.savingOffline$; + return saving$.pipe(map(saving => ({ saving, online }))); + }), + distinctUntilChanged(isEqual) + ); + + private readonly uiShowSaving$ = this.saving$.pipe( + //tap(s => console.log('saving', s)), + pairwise(), + filter(([was, is]) => !was.saving && is.saving), + map(([_, is]) => is), + throttleTime(UI_DISPLAY_TIME, undefined, { leading: true, trailing: false }) + ); + + private readonly uiHideSaving$ = this.uiShowSaving$.pipe( + //tap(() => console.log('s')), + switchMap(() => timer(UI_DISPLAY_TIME).pipe(switchMap(() => this.saving$))), + //switchMap(() => saving$), // do we get the last value? + //tap(saving => console.log('_', saving)), + filter(status => !status.saving) + //tap(saving => console.log('e', saving)) + ); + + readonly uiSaving$ = merge(this.uiShowSaving$, this.uiHideSaving$); + + private readonly uiShowFinishedSaving$ = this.uiSaving$.pipe( + pairwise(), + filter(([was, is]) => was.saving && !is.saving), + map(([_, is]) => ({ finishedSaving: true, online: is.online })) + ); + + private readonly uiHideFinishedSaving$ = this.uiShowFinishedSaving$.pipe( + debounceTime(UI_DISPLAY_TIME), + map(({ online }) => ({ finishedSaving: false, online })) + ); + + readonly uiJustFinishedSaving$ = merge(this.uiShowFinishedSaving$, this.uiHideFinishedSaving$); + + constructor(private readonly pwaService: PwaService) {} + + startedSavingDocOffline(docId: String): void { + this.docsSavingOffline.add(docId); + this.savingOfflineSubject.next(!isEmpty(this.docsSavingOffline)); + } + + finishedSavingDocOffline(docId: String): void { + this.docsSavingOffline.delete(docId); + this.savingOfflineSubject.next(!isEmpty(this.docsSavingOffline)); + } + + startedSavingDocOnline(docId: String): void { + this.docsSavingOnline.add(docId); + this.savingOnlineSubject.next(!isEmpty(this.docsSavingOnline)); + } + + finishedSavingDocOnline(docId: String): void { + this.docsSavingOnline.delete(docId); + this.savingOnlineSubject.next(!isEmpty(this.docsSavingOnline)); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/sharedb-realtime-remote-store.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/sharedb-realtime-remote-store.ts index 305968644a..03091f9480 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/sharedb-realtime-remote-store.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/sharedb-realtime-remote-store.ts @@ -3,7 +3,7 @@ import cloneDeep from 'lodash-es/cloneDeep'; import ReconnectingWebSocket from 'reconnecting-websocket'; import * as RichText from 'rich-text'; import { fromEvent, Observable, Subject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, tap } from 'rxjs/operators'; import { Connection, Doc, OTType, Query, Snapshot, types } from 'sharedb/lib/client'; import { PwaService } from 'xforge-common/pwa.service'; import { environment } from '../environments/environment'; @@ -89,13 +89,18 @@ export class SharedbRealtimeDocAdapter implements RealtimeDocAdapter { readonly idle$: Observable; readonly create$: Observable; readonly delete$: Observable; + readonly beforeLocalChanges$: Observable; readonly remoteChanges$: Observable; constructor(private readonly doc: Doc) { - this.idle$ = fromEvent(this.doc, 'no write pending'); + this.idle$ = fromEvent(this.doc, 'no write pending').pipe(tap(() => console.log('idle', doc.id))); this.create$ = fromEvent(this.doc, 'create'); this.delete$ = fromEvent(this.doc, 'del'); - this.remoteChanges$ = fromEvent<[any, any]>(this.doc, 'op').pipe( + this.beforeLocalChanges$ = fromEvent<[any, boolean]>(this.doc, 'before op').pipe( + tap(([, source]) => console.log('before-op', doc.id, source)), + filter(([, source]) => source) + ); + this.remoteChanges$ = fromEvent<[any, boolean]>(this.doc, 'op').pipe( filter(([, source]) => !source), map(([ops]) => ops) );