From 445aeb0cb4cc23606b840ead27698fb7d4396523 Mon Sep 17 00:00:00 2001 From: FinnStutzenstein Date: Fri, 26 Oct 2018 10:23:14 +0200 Subject: [PATCH] change id client --- client/package.json | 2 +- client/src/app/core/services/auth.service.ts | 40 ++--- .../app/core/services/autoupdate.service.ts | 109 ++++++++++--- .../app/core/services/cache.service.spec.ts | 15 -- client/src/app/core/services/cache.service.ts | 151 ------------------ .../app/core/services/data-store.service.ts | 145 ++++++++++------- .../app/core/services/offline.service.spec.ts | 17 ++ .../src/app/core/services/offline.service.ts | 68 ++++++++ .../app/core/services/openslides.service.ts | 109 ++++++------- .../src/app/core/services/operator.service.ts | 34 ++-- .../app/core/services/storage.service.spec.ts | 15 ++ .../src/app/core/services/storage.service.ts | 61 +++++++ .../app/core/services/websocket.service.ts | 53 ++++-- .../login-mask/login-mask.component.ts | 27 ++-- 14 files changed, 484 insertions(+), 362 deletions(-) delete mode 100644 client/src/app/core/services/cache.service.spec.ts delete mode 100644 client/src/app/core/services/cache.service.ts create mode 100644 client/src/app/core/services/offline.service.spec.ts create mode 100644 client/src/app/core/services/offline.service.ts create mode 100644 client/src/app/core/services/storage.service.spec.ts create mode 100644 client/src/app/core/services/storage.service.ts diff --git a/client/package.json b/client/package.json index dc4ae9798..6c1d15275 100644 --- a/client/package.json +++ b/client/package.json @@ -10,7 +10,7 @@ "e2e": "ng e2e", "compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port", "extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json", - "format:fix": "pretty-quick --staged" + "prettify": "pretty-quick" }, "private": true, "dependencies": { diff --git a/client/src/app/core/services/auth.service.ts b/client/src/app/core/services/auth.service.ts index 77fd75c70..6d97133b1 100644 --- a/client/src/app/core/services/auth.service.ts +++ b/client/src/app/core/services/auth.service.ts @@ -1,13 +1,12 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; import { OperatorService } from 'app/core/services/operator.service'; import { OpenSlidesComponent } from '../../openslides.component'; import { environment } from 'environments/environment'; import { User } from '../../shared/models/users/user'; import { OpenSlidesService } from './openslides.service'; +import { Router } from '@angular/router'; +import { HttpService } from './http.service'; /** * The data returned by a post request to the login route. @@ -28,13 +27,16 @@ export class AuthService extends OpenSlidesComponent { * Initializes the httpClient and the {@link OperatorService}. * * Calls `super()` from the parent class. - * @param http HttpClient - * @param operator who is using OpenSlides + * @param http HttpService to send requests to the server + * @param operator Who is using OpenSlides + * @param OpenSlides The openslides service + * @param router To navigate */ public constructor( - private http: HttpClient, + private http: HttpService, private operator: OperatorService, - private OpenSlides: OpenSlidesService + private OpenSlides: OpenSlidesService, + private router: Router ) { super(); } @@ -48,18 +50,16 @@ export class AuthService extends OpenSlidesComponent { * * @param username * @param password + * @returns The login response. */ - public login(username: string, password: string): Observable { + public async login(username: string, password: string): Promise { const user = { username: username, password: password }; - return this.http.post(environment.urlPrefix + '/users/login/', user).pipe( - tap((response: LoginResponse) => { - this.operator.user = new User(response.user); - }), - catchError(this.handleError()) - ) as Observable; + const response = await this.http.post(environment.urlPrefix + '/users/login/', user); + this.operator.user = new User(response.user); + return response; } /** @@ -68,10 +68,14 @@ export class AuthService extends OpenSlidesComponent { * Will clear the current {@link OperatorService} and * send a `post`-request to `/apps/users/logout/'` */ - public logout(): void { + public async logout(): Promise { this.operator.user = null; - this.http.post(environment.urlPrefix + '/users/logout/', {}).subscribe(() => { - this.OpenSlides.reboot(); - }); + try { + await this.http.post(environment.urlPrefix + '/users/logout/', {}); + } catch (e) { + // We do nothing on failures. Reboot OpenSlides anyway. + } + this.router.navigate(['/']); + this.OpenSlides.reboot(); } } diff --git a/client/src/app/core/services/autoupdate.service.ts b/client/src/app/core/services/autoupdate.service.ts index 92176e3fd..4885c3875 100644 --- a/client/src/app/core/services/autoupdate.service.ts +++ b/client/src/app/core/services/autoupdate.service.ts @@ -5,6 +5,7 @@ import { WebsocketService } from './websocket.service'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; import { DataStoreService } from './data-store.service'; +import { BaseModel } from '../../shared/models/base/base-model'; interface AutoupdateFormat { /** @@ -22,9 +23,19 @@ interface AutoupdateFormat { }; /** - * The current change id for this autoupdate + * The lower change id bond for this autoupdate */ - change_id: number; + from_change_id: number; + + /** + * The upper change id bound for this autoupdate + */ + to_change_id: number; + + /** + * Flag, if this autoupdate contains all data. If so, the DS needs to be resetted. + */ + all_data: boolean; } /** @@ -41,14 +52,16 @@ export class AutoupdateService extends OpenSlidesComponent { /** * Constructor to create the AutoupdateService. Calls the constructor of the parent class. * @param websocketService + * @param DS + * @param modelMapper */ public constructor( - websocketService: WebsocketService, + private websocketService: WebsocketService, private DS: DataStoreService, private modelMapper: CollectionStringModelMapperService ) { super(); - websocketService.getOberservable('autoupdate').subscribe(response => { + this.websocketService.getOberservable('autoupdate').subscribe(response => { this.storeResponse(response); }); } @@ -56,37 +69,87 @@ export class AutoupdateService extends OpenSlidesComponent { /** * Handle the answer of incoming data via {@link WebsocketService}. * - * Bundles the data per action and collection. THis speeds up the caching in the DataStore. - * * Detects the Class of an incomming model, creates a new empty object and assigns - * the data to it using the deserialize function. + * the data to it using the deserialize function. Also models that are flagged as deleted + * will be removed from the data store. * - * Saves models in DataStore. + * Handles the change ids of all autoupdates. */ - public storeResponse(autoupdate: AutoupdateFormat): void { - // Delete the removed objects from the DataStore - Object.keys(autoupdate.deleted).forEach(collection => { - this.DS.remove(collection, autoupdate.deleted[collection], autoupdate.change_id); - }); + private async storeResponse(autoupdate: AutoupdateFormat): Promise { + console.log('got autoupdate', autoupdate); - // Add the objects to the DataStore. + if (autoupdate.all_data) { + await this.storeAllData(autoupdate); + } else { + await this.storePartialAutoupdate(autoupdate); + } + } + + /** + * Stores all data from the autoupdate. This means, that the DS is resettet and filled with just the + * given data from the autoupdate. + * @param autoupdate The autoupdate + */ + private async storeAllData(autoupdate: AutoupdateFormat): Promise { + let elements: BaseModel[] = []; Object.keys(autoupdate.changed).forEach(collection => { - const targetClass = this.modelMapper.getModelConstructor(collection); - if (!targetClass) { - throw new Error(`Unregistered resource ${collection}`); - } - this.DS.add(autoupdate.changed[collection].map(model => new targetClass(model)), autoupdate.change_id); + elements = elements.concat(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection])); }); + await this.DS.set(elements, autoupdate.to_change_id); + } + + /** + * handles a normal autoupdate that is not a full update (all_data=false). + * @param autoupdate The autoupdate + */ + private async storePartialAutoupdate(autoupdate: AutoupdateFormat): Promise { + const maxChangeId = this.DS.maxChangeId; + + if (autoupdate.from_change_id <= maxChangeId && autoupdate.to_change_id <= maxChangeId) { + console.log('ignore'); + return; // Ignore autoupdates, that lay full behind our changeid. + } + + // Normal autoupdate + if (autoupdate.from_change_id <= maxChangeId + 1 && autoupdate.to_change_id > maxChangeId) { + // Delete the removed objects from the DataStore + for (const collection of Object.keys(autoupdate.deleted)) { + await this.DS.remove(collection, autoupdate.deleted[collection]); + } + + // Add the objects to the DataStore. + for (const collection of Object.keys(autoupdate.changed)) { + await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection])); + } + + console.log('new max change id', autoupdate.to_change_id); + await this.DS.flushToStorage(autoupdate.to_change_id); + } else { + // autoupdate fully in the future. we are missing something! + this.requestChanges(); + } + } + + /** + * Creates baseModels for each plain object + * @param collection The collection all models have to be from. + * @param models All models that should be mapped to BaseModels + * @returns A list of basemodels constructed from the given models. + */ + private mapObjectsToBaseModels(collection: string, models: object[]): BaseModel[] { + const targetClass = this.modelMapper.getModelConstructor(collection); + if (!targetClass) { + throw new Error(`Unregistered resource ${collection}`); + } + return models.map(model => new targetClass(model)); } /** * Sends a WebSocket request to the Server with the maxChangeId of the DataStore. * The server should return an autoupdate with all new data. - * - * TODO: Wait for changeIds to be implemented on the server. */ public requestChanges(): void { - console.log('requesting changed objects'); - // this.websocketService.send('changeIdRequest', this.DS.maxChangeId); + console.log('requesting changed objects with DS max change id', this.DS.maxChangeId + 1); + this.websocketService.send('getElements', { change_id: this.DS.maxChangeId + 1 }); } } diff --git a/client/src/app/core/services/cache.service.spec.ts b/client/src/app/core/services/cache.service.spec.ts deleted file mode 100644 index 2c03bdba4..000000000 --- a/client/src/app/core/services/cache.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestBed, inject } from '@angular/core/testing'; - -import { CacheService } from './cache.service'; - -describe('WebsocketService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [CacheService] - }); - }); - - it('should be created', inject([CacheService], (service: CacheService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/client/src/app/core/services/cache.service.ts b/client/src/app/core/services/cache.service.ts deleted file mode 100644 index 93ac04fdd..000000000 --- a/client/src/app/core/services/cache.service.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Injectable } from '@angular/core'; -import { LocalStorage } from '@ngx-pwa/local-storage'; -import { Observable } from 'rxjs'; - -/** - * Container objects for the setQueue. - */ -interface SetContainer { - key: string; - item: any; - callback: (value: boolean) => void; -} - -/** - * Container objects for the removeQueue. - */ -interface RemoveContainer { - key: string; - callback: (value: boolean) => void; -} - -/** - * Provides an async API to an key-value store using ngx-pwa which is internally - * using IndexedDB or localStorage as a fallback. - */ -@Injectable({ - providedIn: 'root' -}) -export class CacheService { - /** - * The queue of waiting set requests. Just one request (with the same key, which is - * an often case) at the time can be handeled. The SetContainer encapsulates the key, - * item and callback. - */ - private setQueue: SetContainer[] = []; - - /** - * The queue of waiting remove requests. Same reason for the queue es the @name _setQueue. - */ - private removeQueue: RemoveContainer[] = []; - - /** - * Constructor to create the CacheService. Needs the localStorage service. - * @param localStorage - */ - public constructor(private localStorage: LocalStorage) {} - - /** - * Sets the item into the store asynchronously. - * @param key - * @param item - * @param callback An optional callback that is called on success - */ - public set(key: string, item: any, callback?: (value: boolean) => void): void { - if (!callback) { - callback = () => {}; - } - - // Put the set request into the queue - const queueObj: SetContainer = { - key: key, - item: item, - callback: callback - }; - this.setQueue.unshift(queueObj); - - // If this is the only object, put it into the cache. - if (this.setQueue.length === 1) { - this.localStorage.setItem(key, item).subscribe(this._setCallback.bind(this), this._error); - } - } - - /** - * gets called, if a set of the first item in the queue was successful. - * @param value success - */ - private _setCallback(success: boolean): void { - // Call the callback and remove the object from the queue - this.setQueue[0].callback(success); - this.setQueue.pop(); - // If there are objects left, insert the first one into the cache. - if (this.setQueue.length > 0) { - const queueObj = this.setQueue[0]; - this.localStorage.setItem(queueObj.key, queueObj.item).subscribe(this._setCallback.bind(this), this._error); - } - } - - /** - * get a value from the store. You need to subscribe to the request to retrieve the value. - * @param key The key to get the value from - */ - public get(key: string): Observable { - return this.localStorage.getItem(key); - } - - /** - * Remove the key from the store. - * @param key The key to remove the value from - * @param callback An optional callback that is called on success - */ - public remove(key: string, callback?: (value: boolean) => void): void { - if (!callback) { - callback = () => {}; - } - - // Put the remove request into the queue - const queueObj: RemoveContainer = { - key: key, - callback: callback - }; - this.removeQueue.unshift(queueObj); - - // If this is the only object, remove it from the cache. - if (this.removeQueue.length === 1) { - this.localStorage.removeItem(key).subscribe(this._removeCallback.bind(this), this._error); - } - } - - /** - * gets called, if a remove of the first item in the queue was successfull. - * @param value success - */ - private _removeCallback(success: boolean): void { - // Call the callback and remove the object from the queue - this.removeQueue[0].callback(success); - this.removeQueue.pop(); - // If there are objects left, remove the first one from the cache. - if (this.removeQueue.length > 0) { - const queueObj = this.removeQueue[0]; - this.localStorage.removeItem(queueObj.key).subscribe(this._removeCallback.bind(this), this._error); - } - } - - /** - * Clear the whole cache - * @param callback An optional callback that is called on success - */ - public clear(callback?: (value: boolean) => void): void { - if (!callback) { - callback = () => {}; - } - this.localStorage.clear().subscribe(callback, this._error); - } - - /** - * First error catching function. - */ - private _error(): void { - console.error('caching error', arguments); - } -} diff --git a/client/src/app/core/services/data-store.service.ts b/client/src/app/core/services/data-store.service.ts index 3f7426048..844c76409 100644 --- a/client/src/app/core/services/data-store.service.ts +++ b/client/src/app/core/services/data-store.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; -import { CacheService } from './cache.service'; +import { StorageService } from './storage.service'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; /** @@ -64,7 +64,7 @@ export class DataStoreService { * all cases equal! */ private modelStore: ModelStorage = {}; - private JsonStore: JsonStorage = {}; + private jsonStore: JsonStorage = {}; /** * Observable subject for changed models in the datastore. @@ -107,42 +107,51 @@ export class DataStoreService { } /** - * Empty constructor for dataStore - * @param cacheService use CacheService to cache the DataStore. + * @param storageService use StorageService to preserve the DataStore. + * @param modelMapper */ - public constructor(private cacheService: CacheService, private modelMapper: CollectionStringModelMapperService) {} + public constructor( + private storageService: StorageService, + private modelMapper: CollectionStringModelMapperService + ) {} /** * Gets the DataStore from cache and instantiate all models out of the serialized version. + * @returns The max change id. */ - public initFromCache(): Promise { + public async initFromStorage(): Promise { // This promise will be resolved with the maximal change id of the cache. - return new Promise(resolve => { - this.cacheService.get(DataStoreService.cachePrefix + 'DS').subscribe((store: JsonStorage) => { - if (store != null) { - // There is a store. Deserialize it - this.JsonStore = store; - this.modelStore = this.deserializeJsonStore(this.JsonStore); - // Get the maxChangeId from the cache - this.cacheService - .get(DataStoreService.cachePrefix + 'maxChangeId') - .subscribe((maxChangeId: number) => { - if (maxChangeId == null) { - maxChangeId = 0; - } - this._maxChangeId = maxChangeId; - resolve(maxChangeId); - }); - } else { - // No store here, so get all data from the server. - resolve(0); - } + const store = await this.storageService.get(DataStoreService.cachePrefix + 'DS'); + if (store) { + console.log('init from storage:', store); + // There is a store. Deserialize it + this.jsonStore = store; + this.modelStore = this.deserializeJsonStore(this.jsonStore); + // Get the maxChangeId from the cache + let maxChangeId = await this.storageService.get(DataStoreService.cachePrefix + 'maxChangeId'); + if (!maxChangeId) { + maxChangeId = 0; + } + this._maxChangeId = maxChangeId; + + // update observers + Object.keys(this.modelStore).forEach(collection => { + Object.keys(this.modelStore[collection]).forEach(id => { + this.changedSubject.next(this.modelStore[collection][id]); + }); }); - }); + } else { + this.jsonStore = {}; + this.modelStore = {}; + this._maxChangeId = 0; + } + return this.maxChangeId; } /** * Deserialze the given serializedStorage and returns a Storage. + * @param serializedStore The store to deserialize + * @returns The serialized storage */ private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage { const storage: ModelStorage = {}; @@ -161,17 +170,21 @@ export class DataStoreService { /** * Clears the complete DataStore and Cache. - * @param callback */ - public clear(callback?: (value: boolean) => void): void { + public async clear(): Promise { + console.log('DS clear'); this.modelStore = {}; - this.JsonStore = {}; + this.jsonStore = {}; this._maxChangeId = 0; - this.cacheService.remove(DataStoreService.cachePrefix + 'DS', () => { - this.cacheService.remove(DataStoreService.cachePrefix + 'maxChangeId', callback); - }); + await this.storageService.remove(DataStoreService.cachePrefix + 'DS'); + await this.storageService.remove(DataStoreService.cachePrefix + 'maxChangeId'); } + /** + * Returns the collection _string_ based on the model given. If a string is given, it's just returned. + * @param collectionType Either a Model constructor or a string. + * @returns the collection string + */ private getCollectionString>(collectionType: ModelConstructor | string): string { if (typeof collectionType === 'string') { return collectionType; @@ -262,62 +275,78 @@ export class DataStoreService { * Add one or multiple models to dataStore. * * @param models BaseModels to add to the store - * @param changeId The changeId of this update - * @example this.DS.add([new User(1)], changeId) - * @example this.DS.add([new User(2), new User(3)], changeId) + * @param changeId The changeId of this update. If given, the storage will be flushed to the + * cache. Else one can call {@method flushToStorage} to do this manually. + * @example this.DS.add([new User(1)]) + * @example this.DS.add([new User(2), new User(3)]) * @example this.DS.add(arrayWithUsers, changeId) */ - public add(models: BaseModel[], changeId: number): void { + public async add(models: BaseModel[], changeId?: number): Promise { models.forEach(model => { - const collectionString = model.collectionString; - if (this.modelStore[collectionString] === undefined) { - this.modelStore[collectionString] = {}; + const collection = model.collectionString; + if (this.modelStore[collection] === undefined) { + this.modelStore[collection] = {}; } - this.modelStore[collectionString][model.id] = model; + this.modelStore[collection][model.id] = model; - if (this.JsonStore[collectionString] === undefined) { - this.JsonStore[collectionString] = {}; + if (this.jsonStore[collection] === undefined) { + this.jsonStore[collection] = {}; } - this.JsonStore[collectionString][model.id] = JSON.stringify(model); + this.jsonStore[collection][model.id] = JSON.stringify(model); this.changedSubject.next(model); }); - this.storeToCache(changeId); + if (changeId) { + await this.flushToStorage(changeId); + } } /** * removes one or multiple models from dataStore. * - * @param Type The desired BaseModel type to be read from the datastore + * @param collectionString The desired BaseModel type to be removed from the datastore * @param ids A list of IDs of BaseModels to remove from the datastore - * @param changeId The changeId of this update - * @example this.DS.remove('users/user', [myUser.id, 3, 4], 38213) + * @param changeId The changeId of this update. If given, the storage will be flushed to the + * cache. Else one can call {@method flushToStorage} to do this manually. + * @example this.DS.remove('users/user', [myUser.id, 3, 4]) */ - public remove(collectionString: string, ids: number[], changeId: number): void { + public async remove(collectionString: string, ids: number[], changeId?: number): Promise { ids.forEach(id => { if (this.modelStore[collectionString]) { delete this.modelStore[collectionString][id]; } - if (this.JsonStore[collectionString]) { - delete this.JsonStore[collectionString][id]; + if (this.jsonStore[collectionString]) { + delete this.jsonStore[collectionString][id]; } this.deletedSubject.next({ collection: collectionString, id: id }); }); - this.storeToCache(changeId); + if (changeId) { + await this.flushToStorage(changeId); + } + } + + /** + * Resets the DataStore and set the given models as the new content. + * @param models A list of models to set the DataStore to. + * @param newMaxChangeId Optional. If given, the max change id will be updated. + */ + public async set(models: BaseModel[], newMaxChangeId?: number): Promise { + this.modelStore = {}; + this.jsonStore = {}; + await this.add(models, newMaxChangeId); } /** * Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger * @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache. */ - private storeToCache(changeId: number): void { - this.cacheService.set(DataStoreService.cachePrefix + 'DS', this.JsonStore); - if (changeId > this._maxChangeId) { - this._maxChangeId = changeId; - this.cacheService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId); - } + public async flushToStorage(changeId: number): Promise { + console.log('flush to storage'); + this._maxChangeId = changeId; + await this.storageService.set(DataStoreService.cachePrefix + 'DS', this.jsonStore); + await this.storageService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId); } /** diff --git a/client/src/app/core/services/offline.service.spec.ts b/client/src/app/core/services/offline.service.spec.ts new file mode 100644 index 000000000..1f0d94aed --- /dev/null +++ b/client/src/app/core/services/offline.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { OfflineService } from './offline.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('OfflineService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [OfflineService] + }); + }); + + it('should be created', inject([OfflineService], (service: OfflineService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/offline.service.ts b/client/src/app/core/services/offline.service.ts new file mode 100644 index 000000000..d567b5e85 --- /dev/null +++ b/client/src/app/core/services/offline.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { OpenSlidesComponent } from 'app/openslides.component'; +import { DataStoreService } from './data-store.service'; +import { WhoAmIResponse } from './operator.service'; + +/** + * This service handles everything connected with being offline. + * + * TODO: This is just a stub. Needs to be done in the future; Maybe we cancel this whole concept + * of this service. We'll see whats happens here.. + */ +@Injectable({ + providedIn: 'root' +}) +export class OfflineService extends OpenSlidesComponent { + private _offline = false; + + public get offline(): boolean { + return this._offline; + } + + /** + * Constructor to create the AutoupdateService. Calls the constructor of the parent class. + * @param DS + */ + public constructor(private DS: DataStoreService) { + super(); + } + + /** + * Sets the offline flag. Restores the DataStoreService to the last known configuration. + */ + public async goOfflineBecauseFailedWhoAmI(): Promise { + this._offline = true; + console.log('offline because whoami failed.'); + + // TODO: Init the DS from cache. + await this.DS.clear(); + } + + /** + * TODO: Should be somehow connected to the websocket service. + */ + public goOfflineBecauseConnectionLost(): void { + this._offline = true; + console.log('offline because connection lost.'); + } + + /** + * TODO: Should be somehow connected to the websocket service. + */ + public goOnline(): void { + this._offline = false; + } + + /** + * Returns the last cached WhoAmI response. + */ + public getLastWhoAmI(): WhoAmIResponse { + // TODO: use a cached WhoAmI response. + return { + user_id: null, + guest_enabled: false, + user: null + }; + } +} diff --git a/client/src/app/core/services/openslides.service.ts b/client/src/app/core/services/openslides.service.ts index b71e2eaaa..e8652bc46 100644 --- a/client/src/app/core/services/openslides.service.ts +++ b/client/src/app/core/services/openslides.service.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router'; import { OpenSlidesComponent } from 'app/openslides.component'; import { WebsocketService } from './websocket.service'; import { OperatorService } from './operator.service'; -import { CacheService } from './cache.service'; +import { StorageService } from './storage.service'; import { AutoupdateService } from './autoupdate.service'; import { DataStoreService } from './data-store.service'; @@ -21,15 +21,16 @@ export class OpenSlidesService extends OpenSlidesComponent { public redirectUrl: string; /** - * Constructor to create the NotifyService. Registers itself to the WebsocketService. - * @param cacheService + * Constructor to create the OpenSlidesService. Registers itself to the WebsocketService. + * @param storageService * @param operator * @param websocketService * @param router * @param autoupdateService + * @param DS */ public constructor( - private cacheService: CacheService, + private storageService: StorageService, private operator: OperatorService, private websocketService: WebsocketService, private router: Router, @@ -51,54 +52,51 @@ export class OpenSlidesService extends OpenSlidesComponent { * the bootup-sequence: Do a whoami request and if it was successful, do * {@method afterLoginBootup}. If not, redirect the user to the login page. */ - public bootup(): void { + public async bootup(): Promise { // start autoupdate if the user is logged in: - this.operator.whoAmI().subscribe(resp => { - this.operator.guestsEnabled = resp.guest_enabled; - if (!resp.user && !resp.guest_enabled) { - this.redirectUrl = location.pathname; - // Goto login, if the user isn't login and guests are not allowed - this.router.navigate(['/login']); - } else { - this.afterLoginBootup(resp.user_id); - } - }); + const response = await this.operator.whoAmI(); + this.operator.guestsEnabled = response.guest_enabled; + if (!response.user && !response.guest_enabled) { + this.redirectUrl = location.pathname; + // Goto login, if the user isn't login and guests are not allowed + this.router.navigate(['/login']); + } else { + await this.afterLoginBootup(response.user_id); + } } /** * the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore - * and websocket. + * and websocket. This "login" also may be the "login" of an anonymous when he is using + * OpenSlides as a guest. * @param userId */ - public afterLoginBootup(userId: number): void { + public async afterLoginBootup(userId: number): Promise { // Else, check, which user was logged in last time - this.cacheService.get('lastUserLoggedIn').subscribe((id: number) => { - // if the user id changed, reset the cache. - if (userId !== id) { - this.DS.clear((value: boolean) => { - this.setupDataStoreAndWebSocket(); - }); - this.cacheService.set('lastUserLoggedIn', userId); - } else { - this.setupDataStoreAndWebSocket(); - } - }); + const lastUserId = await this.storageService.get('lastUserLoggedIn'); + console.log('user transition:', lastUserId, '->', userId); + // if the user changed, reset the cache and save the new user. + if (userId !== lastUserId) { + await this.DS.clear(); + await this.storageService.set('lastUserLoggedIn', userId); + } + await this.setupDataStoreAndWebSocket(); } /** * Init DS from cache and after this start the websocket service. */ - private setupDataStoreAndWebSocket(): void { - this.DS.initFromCache().then((changeId: number) => { - this.websocketService.connect( - false, - changeId - ); - }); + private async setupDataStoreAndWebSocket(): Promise { + let changeId = await this.DS.initFromStorage(); + console.log('change ID on DS setup', changeId); + if (changeId > 0) { + changeId += 1; + } + this.websocketService.connect({ changeId: changeId }); // Request changes after changeId. } /** - * SHuts down OpenSlides. The websocket is closed and the operator is not set. + * Shuts OpenSlides down. The websocket is closed and the operator is not set. */ public shutdown(): void { this.websocketService.close(); @@ -108,32 +106,31 @@ export class OpenSlidesService extends OpenSlidesComponent { /** * Shutdown and bootup. */ - public reboot(): void { + public async reboot(): Promise { this.shutdown(); - this.bootup(); + await this.bootup(); } /** - * Verify that the operator is the same as it was before a reconnect. + * Verify that the operator is the same as it was before. Should be alled on a reconnect. */ - private checkOperator(): void { - this.operator.whoAmI().subscribe(resp => { - // User logged off. - if (!resp.user && !resp.guest_enabled) { - this.shutdown(); - this.router.navigate(['/login']); + private async checkOperator(): Promise { + const response = await this.operator.whoAmI(); + // User logged off. + if (!response.user && !response.guest_enabled) { + this.shutdown(); + this.router.navigate(['/login']); + } else { + if ( + (this.operator.user && this.operator.user.id !== response.user_id) || + (!this.operator.user && response.user_id) + ) { + // user changed + await this.reboot(); } else { - if ( - (this.operator.user && this.operator.user.id !== resp.user_id) || - (!this.operator.user && resp.user_id) - ) { - // user changed - this.reboot(); - } else { - // User is still the same, but check for missed autoupdates. - this.autoupdateService.requestChanges(); - } + // User is still the same, but check for missed autoupdates. + this.autoupdateService.requestChanges(); } - }); + } } } diff --git a/client/src/app/core/services/operator.service.ts b/client/src/app/core/services/operator.service.ts index bb254e671..a999a76b4 100644 --- a/client/src/app/core/services/operator.service.ts +++ b/client/src/app/core/services/operator.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@angular/core'; import { Observable, BehaviorSubject } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { tap, catchError } from 'rxjs/operators'; import { OpenSlidesComponent } from 'app/openslides.component'; import { Group } from 'app/shared/models/users/group'; import { User } from '../../shared/models/users/user'; import { environment } from 'environments/environment'; import { DataStoreService } from './data-store.service'; +import { OfflineService } from './offline.service'; /** * Permissions on the client are just strings. This makes clear, that @@ -17,7 +17,7 @@ export type Permission = string; /** * Response format of the WHoAMI request. */ -interface WhoAmIResponse { +export interface WhoAmIResponse { user_id: number; guest_enabled: boolean; user: User; @@ -77,9 +77,14 @@ export class OperatorService extends OpenSlidesComponent { private operatorSubject: BehaviorSubject = new BehaviorSubject(null); /** + * Sets up an observer for watching changes in the DS. If the operator user or groups are changed, + * the operator's permissions are updated. + * * @param http HttpClient + * @param DS + * @param offlineService */ - public constructor(private http: HttpClient, private DS: DataStoreService) { + public constructor(private http: HttpClient, private DS: DataStoreService, private offlineService: OfflineService) { super(); this.DS.changeObservable.subscribe(newModel => { @@ -101,16 +106,21 @@ export class OperatorService extends OpenSlidesComponent { /** * Calls `/apps/users/whoami` to find out the real operator. + * @returns The response of the WhoAmI request. */ - public whoAmI(): Observable { - return this.http.get(environment.urlPrefix + '/users/whoami/').pipe( - tap((response: WhoAmIResponse) => { - if (response && response.user_id) { - this.user = new User(response.user); - } - }), - catchError(this.handleError()) - ) as Observable; + public async whoAmI(): Promise { + try { + const response = await this.http.get(environment.urlPrefix + '/users/whoami/').toPromise(); + if (response && response.user) { + this.user = new User(response.user); + } + return response; + } catch (e) { + // TODO: Implement the offline service. Currently a guest-whoami response is returned and + // the DS cleared. + this.offlineService.goOfflineBecauseFailedWhoAmI(); + return this.offlineService.getLastWhoAmI(); + } } /** diff --git a/client/src/app/core/services/storage.service.spec.ts b/client/src/app/core/services/storage.service.spec.ts new file mode 100644 index 000000000..5f68fdff2 --- /dev/null +++ b/client/src/app/core/services/storage.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { StorageService } from './storage.service'; + +describe('StorageService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [StorageService] + }); + }); + + it('should be created', inject([StorageService], (service: StorageService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/storage.service.ts b/client/src/app/core/services/storage.service.ts new file mode 100644 index 000000000..3d2e33c9c --- /dev/null +++ b/client/src/app/core/services/storage.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { LocalStorage } from '@ngx-pwa/local-storage'; + +/** + * Provides an async API to an key-value store using ngx-pwa which is internally + * using IndexedDB or localStorage as a fallback. + */ +@Injectable({ + providedIn: 'root' +}) +export class StorageService { + /** + * Constructor to create the StorageService. Needs the localStorage service. + * @param localStorage + */ + public constructor(private localStorage: LocalStorage) {} + + /** + * Sets the item into the store asynchronously. + * @param key + * @param item + */ + public async set(key: string, item: any): Promise { + if (item === null || item === undefined) { + await this.remove(key); // You cannot do a setItem with null or undefined... + } else { + if (!(await this.localStorage.setItem(key, item).toPromise())) { + throw new Error('Could not set the item.'); + } + } + } + + /** + * get a value from the store. You need to subscribe to the request to retrieve the value. + * @param key The key to get the value from + * @returns The requested value to the key + */ + public async get(key: string): Promise { + return await this.localStorage.getItem(key).toPromise(); + } + + /** + * Remove the key from the store. + * @param key The key to remove the value from + */ + public async remove(key: string): Promise { + if (!(await this.localStorage.removeItem(key).toPromise())) { + throw new Error('Could not delete the item.'); + } + } + + /** + * Clear the whole cache + */ + public async clear(): Promise { + console.log('clear storage'); + if (!(await this.localStorage.clear().toPromise())) { + throw new Error('Could not clear the storage.'); + } + } +} diff --git a/client/src/app/core/services/websocket.service.ts b/client/src/app/core/services/websocket.service.ts index 6ad8c767b..b36d2ce8f 100644 --- a/client/src/app/core/services/websocket.service.ts +++ b/client/src/app/core/services/websocket.service.ts @@ -69,9 +69,17 @@ export class WebsocketService { */ private subjects: { [type: string]: Subject } = {}; + /** + * Saves, if the service is in retry mode to get a connection to a previos connection lost. + */ + private retry = false; + /** * Constructor that handles the router * @param router the URL Router + * @param matSnackBar + * @param zone + * @param translate */ public constructor( private router: Router, @@ -85,34 +93,46 @@ export class WebsocketService { * * Uses NgZone to let all callbacks run in the angular context. */ - public connect(retry: boolean = false, changeId?: number): void { + public connect( + options: { + changeId?: number; + enableAutoupdates?: boolean; + } = {} + ): void { if (this.websocket) { - return; + this.close(); } + + // set defaults + options = Object.assign(options, { + enableAutoupdates: true + }); + const queryParams: QueryParams = { - 'change_id': 0, - 'autoupdate': true, + autoupdate: options.enableAutoupdates }; - // comment-in if changes IDs are supported on server side. - /*if (changeId !== undefined) { - queryParams.changeId = changeId.toString(); - }*/ + + if (options.changeId !== undefined) { + queryParams.change_id = options.changeId; + } // Create the websocket const socketProtocol = this.getWebSocketProtocol(); const socketServer = window.location.hostname + ':' + window.location.port; const socketPath = this.getWebSocketPath(queryParams); + console.log('connect to', socketProtocol + socketServer + socketPath); this.websocket = new WebSocket(socketProtocol + socketServer + socketPath); // connection established. If this connect attept was a retry, // The error notice will be removed and the reconnectSubject is published. this.websocket.onopen = (event: Event) => { this.zone.run(() => { - if (retry) { + if (this.retry) { if (this.connectionErrorNotice) { this.connectionErrorNotice.dismiss(); this.connectionErrorNotice = null; } + this.retry = false; this._reconnectEvent.emit(); } this._connectEvent.emit(); @@ -151,7 +171,8 @@ export class WebsocketService { // A random retry timeout between 2000 and 5000 ms. const timeout = Math.floor(Math.random() * 3000 + 2000); setTimeout(() => { - this.connect((retry = true)); + this.retry = true; + this.connect({ enableAutoupdates: true }); }, timeout); } }); @@ -222,11 +243,13 @@ export class WebsocketService { const keys: string[] = Object.keys(queryParams); if (keys.length > 0) { - path += '?' + keys - .map(key => { - return key + '=' + queryParams[key].toString(); - }) - .join('&'); + path += + '?' + + keys + .map(key => { + return key + '=' + queryParams[key].toString(); + }) + .join('&'); } return path; } diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.ts b/client/src/app/site/login/components/login-mask/login-mask.component.ts index d545c63d2..ca5bdd183 100644 --- a/client/src/app/site/login/components/login-mask/login-mask.component.ts +++ b/client/src/app/site/login/components/login-mask/login-mask.component.ts @@ -127,26 +127,27 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr * * Send username and password to the {@link AuthService} */ - public formLogin(): void { + public async formLogin(): Promise { this.loginErrorMsg = ''; this.inProcess = true; - this.authService.login(this.loginForm.value.username, this.loginForm.value.password).subscribe(res => { + try { + const res = await this.authService.login(this.loginForm.value.username, this.loginForm.value.password); this.inProcess = false; - - if (res instanceof HttpErrorResponse) { + this.OpenSlides.afterLoginBootup(res.user_id); + let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/'; + if (redirect.includes('login')) { + redirect = '/'; + } + this.router.navigate([redirect]); + } catch (e) { + if (e instanceof HttpErrorResponse) { this.loginForm.setErrors({ notFound: true }); - this.loginErrorMsg = res.error.detail; - } else { - this.OpenSlides.afterLoginBootup(res.user_id); - let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/'; - if (redirect.includes('login')) { - redirect = '/'; - } - this.router.navigate([redirect]); + this.loginErrorMsg = e.error.detail; + this.inProcess = false; } - }); + } } /**