Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add online and offline saving indicator #1283

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,9 @@
</div>
<mdc-list-divider></mdc-list-divider>
<div class="online-status">
<div *ngIf="isAppOnline" fxLayout="row" fxLayoutAlign="center center">
<mdc-icon>cloud</mdc-icon>
{{ t("online") }}
</div>
<div *ngIf="!isAppOnline" fxLayout="row" fxLayoutAlign="center center">
<mdc-icon mdcListItemGraphic>cloud_off</mdc-icon>
{{ t("offline") }}
<div fxLayout="row" fxLayoutAlign="center center">
<mdc-icon>{{ (onlineStatus$ | async)?.icon }}</mdc-icon>
{{ t((onlineStatus$ | async)?.text) }}
</div>
</div>
</mdc-list-group>
Expand Down
36 changes: 35 additions & 1 deletion src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,6 +83,38 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest
private _isDrawerPermanent: boolean = true;
private readonly questionCountQueries = new Map<number, RealtimeQuery>();

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,
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ngx-avatar [src]="avatarUrl" [name]="name" [size]="size" [round]="round" [cornerRadius]="3"></ngx-avatar>
<div *ngIf="showOnlineStatus" class="online-status">
<mdc-icon *ngIf="(pwaService.onlineStatus | async) !== true">cloud_off</mdc-icon>
<mdc-icon *ngIf="statusIcon$ | async as icon">{{ icon }}</mdc-icon>
</div>
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -13,7 +15,19 @@ export class AvatarComponent {
@Input() user?: UserProfile;
@Input() showOnlineStatus: boolean = false;

constructor(readonly pwaService: PwaService) {}
readonly statusIcon$: Observable<string | undefined> = 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 : '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export class MemoryRealtimeDocAdapter implements RealtimeDocAdapter {
readonly remoteChanges$ = new Subject<any>();
readonly create$ = new Subject<void>();
readonly delete$ = new Subject<void>();
readonly beforeLocalChanges$ = new Subject<any>();
readonly idle$ = EMPTY;

constructor(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -144,6 +146,10 @@ export class MemoryRealtimeDocAdapter implements RealtimeDocAdapter {
emitDelete(): void {
this.delete$.next();
}

emitBeforeOp(): void {
this.beforeOp$.next();
}
}

export class MemoryRealtimeQueryAdapter implements RealtimeQueryAdapter {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,8 +19,9 @@ export interface RealtimeDocConstructor {
* @template Ops The operations data type.
*/
export abstract class RealtimeDoc<T = any, Ops = any> {
private updateOfflineDataSub: Subscription;
private onDeleteSub: Subscription;
private readonly updateOfflineDataSub: Subscription;
private readonly onDeleteSub: Subscription;
private readonly saveNotificationSub: Subscription;
private offlineSnapshotVersion?: number;
private subscribePromise?: Promise<void>;
private localDelete$ = new Subject<void>();
Expand All @@ -39,6 +41,10 @@ export abstract class RealtimeDoc<T = any, Ops = any> {
}
);
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 {
Expand Down Expand Up @@ -151,6 +157,7 @@ export abstract class RealtimeDoc<T = any, Ops = any> {
}
this.updateOfflineDataSub.unsubscribe();
this.onDeleteSub.unsubscribe();
this.saveNotificationSub.unsubscribe();
await this.adapter.destroy();
this.subscribedState = false;
await this.realtimeService.onLocalDocDispose(this);
Expand Down Expand Up @@ -203,7 +210,12 @@ export abstract class RealtimeDoc<T = any, Ops = any> {
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<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface RealtimeDocAdapter {
/** Fires when underlying data is recreated. */
readonly create$: Observable<void>;
readonly delete$: Observable<void>;
/** Fire before changes are made to underlying data */
readonly beforeOp$: Observable<any>;
/** Fires when there are changes to underlying data. */
readonly remoteChanges$: Observable<any>;

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
private readonly savingOnlineSubject = new BehaviorSubject<boolean>(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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,13 +89,18 @@ export class SharedbRealtimeDocAdapter implements RealtimeDocAdapter {
readonly idle$: Observable<void>;
readonly create$: Observable<void>;
readonly delete$: Observable<void>;
readonly beforeLocalChanges$: Observable<any>;
readonly remoteChanges$: Observable<any>;

constructor(private readonly doc: Doc) {
this.idle$ = fromEvent(this.doc, 'no write pending');
this.idle$ = fromEvent<void>(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)
);
Expand Down