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)
);