From 6ddbf45bbd8e459229fc1e55898f45028a8f8804 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Mon, 7 Dec 2020 22:36:29 +0800 Subject: [PATCH 01/23] Remove unused rx-operators. --- src/app/pages/home/home.page.ts | 5 ++--- src/app/utils/rx-operators.ts | 12 ++---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 90c7de980..5380745c1 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -3,7 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { MatTabChangeEvent } from '@angular/material/tabs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { groupBy } from 'lodash'; -import { combineLatest, defer, interval, of, zip } from 'rxjs'; +import { combineLatest, defer, forkJoin, interval, of, zip } from 'rxjs'; import { concatMap, concatMapTo, @@ -21,7 +21,6 @@ import { PushNotificationService } from '../../services/push-notification/push-n import { getOldProof } from '../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../services/repositories/proof/proof-repository.service'; import { capture } from '../../utils/camera'; -import { forkJoinWithDefault } from '../../utils/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -112,7 +111,7 @@ export class HomePage implements OnInit { concatMap(transactions => zip( of(transactions), - forkJoinWithDefault( + forkJoin( transactions.map(transaction => this.diaBackendAssetRepository.getById$(transaction.asset.id) ) diff --git a/src/app/utils/rx-operators.ts b/src/app/utils/rx-operators.ts index d8f5861ee..ca5886694 100644 --- a/src/app/utils/rx-operators.ts +++ b/src/app/utils/rx-operators.ts @@ -1,5 +1,5 @@ -import { forkJoin, Observable } from 'rxjs'; -import { defaultIfEmpty, filter } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; export function isNonNullable() { return (source$: Observable) => @@ -7,11 +7,3 @@ export function isNonNullable() { filter((v): v is NonNullable => v !== null && v !== undefined) ); } - -// TODO: Remove this function as we have promisify most utils methods. See #233. -export function forkJoinWithDefault( - sources: Observable[], - defaultValue: T[] = [] -) { - return forkJoin(sources).pipe(defaultIfEmpty(defaultValue)); -} From 8c56b0640d9c1c5d11df1c574fb99d609ae19287 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Mon, 7 Dec 2020 22:36:52 +0800 Subject: [PATCH 02/23] Implement DiaBackendContactRepository. --- .../capacitor-filesystem-table.spec.ts | 12 ++- .../capacitor-filesystem-table.ts | 40 +++++----- src/app/services/database/table/table.ts | 8 +- .../auth/dia-backend-auth.service.spec.ts | 48 ++++++------ ...backend-contact-repository.service.spec.ts | 36 +++++++++ .../dia-backend-contact-repository.service.ts | 75 +++++++++++++++++++ 6 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts create mode 100644 src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts index bb1908a99..4919344e6 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts @@ -1,5 +1,5 @@ import { Plugins } from '@capacitor/core'; -import { Table, Tuple } from '../table'; +import { OnConflictStrategy, Table, Tuple } from '../table'; import { CapacitorFilesystemTable } from './capacitor-filesystem-table'; const { Filesystem } = Plugins; @@ -87,6 +87,16 @@ describe('CapacitorFilesystemTable', () => { await expectAsync(table.insert([sameTuple])).toBeRejected(); }); + it('should ignore on inserting existed tuple if the conflict strategy is IGNORE', async () => { + const sameTuple: TestTuple = { ...testTuple1 }; + await table.insert([testTuple1]); + + await table.insert([sameTuple, testTuple2], OnConflictStrategy.IGNORE); + + const all = await table.queryAll(); + expect(all).toEqual([testTuple1, testTuple2]); + }); + it('should remove by tuple contents not reference', async done => { const sameTuple: TestTuple = { ...testTuple1 }; diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts index 4a15fb838..64a8d58b8 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts @@ -4,10 +4,10 @@ import { FilesystemPlugin, } from '@capacitor/core'; import { Mutex } from 'async-mutex'; -import { equals } from 'lodash/fp'; +import { differenceWith, intersectionWith, isEqual, uniqWith } from 'lodash'; import { BehaviorSubject, defer } from 'rxjs'; import { concatMapTo } from 'rxjs/operators'; -import { Table, Tuple } from '../table'; +import { OnConflictStrategy, Table, Tuple } from '../table'; export class CapacitorFilesystemTable implements Table { private readonly directory = FilesystemDirectory.Data; @@ -85,19 +85,25 @@ export class CapacitorFilesystemTable implements Table { this.tuples$.next(JSON.parse(result.data)); } - async insert(tuples: T[]) { + async insert(tuples: T[], onConflict = OnConflictStrategy.ABORT) { return this.mutex.runExclusive(async () => { assertNoDuplicatedTuples(tuples); - this.assertNoConflictWithExistedTuples(tuples); await this.initialize(); - this.tuples$.next([...this.tuples$.value, ...tuples]); + if (onConflict === OnConflictStrategy.ABORT) { + this.assertNoConflictWithExistedTuples(tuples); + this.tuples$.next([...this.tuples$.value, ...tuples]); + } else if (onConflict === OnConflictStrategy.IGNORE) { + this.tuples$.next( + uniqWith([...this.tuples$.value, ...tuples], isEqual) + ); + } await this.dumpJson(); return tuples; }); } private assertNoConflictWithExistedTuples(tuples: T[]) { - const conflicted = intersaction(tuples, this.tuples$.value); + const conflicted = intersectionWith(tuples, this.tuples$.value, isEqual); if (conflicted.length !== 0) { throw new Error(`Tuples existed: ${JSON.stringify(conflicted)}`); } @@ -108,7 +114,7 @@ export class CapacitorFilesystemTable implements Table { this.assertTuplesExist(tuples); await this.initialize(); const afterDeletion = this.tuples$.value.filter( - tuple => !tuples.map(t => equals(tuple)(t)).includes(true) + tuple => !tuples.map(t => isEqual(tuple, t)).includes(true) ); this.tuples$.next(afterDeletion); await this.dumpJson(); @@ -118,11 +124,9 @@ export class CapacitorFilesystemTable implements Table { private assertTuplesExist(tuples: T[]) { const nonexistent = tuples.filter( - tuple => !this.tuples$.value.find(t => equals(tuple)(t)) + tuple => !this.tuples$.value.find(t => isEqual(tuple, t)) ); if (nonexistent.length !== 0) { - console.error(JSON.stringify(this.tuples$.value)); - throw new Error( `Cannot delete nonexistent tuples: ${JSON.stringify(nonexistent)}` ); @@ -154,19 +158,9 @@ export class CapacitorFilesystemTable implements Table { } function assertNoDuplicatedTuples(tuples: T[]) { - const conflicted: T[] = []; - tuples.forEach((a, index) => { - for (let bIndex = index + 1; bIndex < tuples.length; bIndex += 1) { - if (equals(a)(tuples[bIndex])) { - conflicted.push(a); - } - } - }); - if (conflicted.length !== 0) { + const unique = uniqWith(tuples, isEqual); + if (tuples.length !== unique.length) { + const conflicted = differenceWith(tuples, unique, isEqual); throw new Error(`Tuples duplicated: ${JSON.stringify(conflicted)}`); } } - -function intersaction(list1: T[], list2: T[]) { - return list1.filter(tuple1 => list2.find(tuple2 => equals(tuple1)(tuple2))); -} diff --git a/src/app/services/database/table/table.ts b/src/app/services/database/table/table.ts index a2a004e5f..380e15f6f 100644 --- a/src/app/services/database/table/table.ts +++ b/src/app/services/database/table/table.ts @@ -4,7 +4,7 @@ export interface Table { readonly id: string; queryAll$(): Observable; queryAll(): Promise; - insert(tuples: T[]): Promise; + insert(tuples: T[], onConflict?: OnConflictStrategy): Promise; delete(tuples: T[]): Promise; drop(): Promise; } @@ -15,6 +15,12 @@ export interface Tuple { | number | string | undefined + | null | Tuple | Tuple[]; } + +export const enum OnConflictStrategy { + ABORT, + IGNORE, +} diff --git a/src/app/services/dia-backend/auth/dia-backend-auth.service.spec.ts b/src/app/services/dia-backend/auth/dia-backend-auth.service.spec.ts index 47c7ef5a0..88caaa3b5 100644 --- a/src/app/services/dia-backend/auth/dia-backend-auth.service.spec.ts +++ b/src/app/services/dia-backend/auth/dia-backend-auth.service.spec.ts @@ -7,11 +7,6 @@ import { SharedTestingModule } from '../../../shared/shared-testing.module'; import { BASE_URL } from '../secret'; import { DiaBackendAuthService } from './dia-backend-auth.service'; -const sampleUsername = 'test'; -const sampleEmail = 'test@test.com'; -const samplePassword = 'testpassword'; -const sampleToken = '0123-4567-89ab-cdef'; - describe('DiaBackendAuthService', () => { let service: DiaBackendAuthService; let httpClient: HttpClient; @@ -50,7 +45,7 @@ describe('DiaBackendAuthService', () => { it('should get username and email from result after logged in', done => { service - .login$(sampleEmail, samplePassword) + .login$(EMAIL, PASSWORD) .pipe( tap(result => { expect(result.username).toBeTruthy(); @@ -62,7 +57,7 @@ describe('DiaBackendAuthService', () => { it('should get username after logged in', done => { service - .login$(sampleEmail, samplePassword) + .login$(EMAIL, PASSWORD) .pipe( concatMapTo(defer(() => service.getUsername())), tap(username => expect(username).toBeTruthy()) @@ -72,7 +67,7 @@ describe('DiaBackendAuthService', () => { it('should get email after logged in', done => { service - .login$(sampleEmail, samplePassword) + .login$(EMAIL, PASSWORD) .pipe( concatMapTo(defer(() => service.getEmail())), tap(email => expect(email).toBeTruthy()) @@ -82,7 +77,7 @@ describe('DiaBackendAuthService', () => { it('should indicate has-logged-in after logged in', done => { service - .login$(sampleEmail, samplePassword) + .login$(EMAIL, PASSWORD) .pipe( concatMapTo(defer(() => service.hasLoggedIn())), tap(hasLoggedIn => expect(hasLoggedIn).toBeTrue()) @@ -92,7 +87,7 @@ describe('DiaBackendAuthService', () => { it('should clear email after logged out', done => { service - .login$(sampleEmail, samplePassword) + .login$(EMAIL, PASSWORD) .pipe( concatMapTo(service.logout$()), concatMapTo(defer(() => service.getEmail())), @@ -102,38 +97,41 @@ describe('DiaBackendAuthService', () => { }); it('should create user', done => { - service - .createUser$(sampleUsername, sampleEmail, samplePassword) - .subscribe(result => { - expect(result).toBeTruthy(); - done(); - }); + service.createUser$(USERNAME, EMAIL, PASSWORD).subscribe(result => { + expect(result).toBeTruthy(); + done(); + }); }); }); function mockHttpClient(httpClient: HttpClient) { spyOn(httpClient, 'post') .withArgs(`${BASE_URL}/auth/token/login/`, { - email: sampleEmail, - password: samplePassword, + email: EMAIL, + password: PASSWORD, }) - .and.returnValue(of({ auth_token: sampleToken })) + .and.returnValue(of({ auth_token: TOKEN })) .withArgs( `${BASE_URL}/auth/token/logout/`, {}, - { headers: { authorization: `token ${sampleToken}` } } + { headers: { authorization: `token ${TOKEN}` } } ) .and.returnValue(of(EMPTY)) .withArgs(`${BASE_URL}/auth/users/`, { - username: sampleUsername, - email: sampleEmail, - password: samplePassword, + username: USERNAME, + email: EMAIL, + password: PASSWORD, }) .and.returnValue(of(EMPTY)); spyOn(httpClient, 'get') .withArgs(`${BASE_URL}/auth/users/me/`, { - headers: { authorization: `token ${sampleToken}` }, + headers: { authorization: `token ${TOKEN}` }, }) - .and.returnValue(of({ username: sampleUsername, email: sampleEmail })); + .and.returnValue(of({ username: USERNAME, email: EMAIL })); } + +const USERNAME = 'test'; +const EMAIL = 'test@test.com'; +const PASSWORD = 'testpassword'; +const TOKEN = '0123-4567-89ab-cdef'; diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts new file mode 100644 index 000000000..1d5f051bf --- /dev/null +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts @@ -0,0 +1,36 @@ +import { TestBed } from '@angular/core/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; +import { DiaBackendContactRepository } from './dia-backend-contact-repository.service'; + +xdescribe('DiaBackendContactRepository', () => { + let repository: DiaBackendContactRepository; + let authService: DiaBackendAuthService; + + beforeEach(done => { + TestBed.configureTestingModule({ + imports: [SharedModule], + }); + repository = TestBed.inject(DiaBackendContactRepository); + authService = TestBed.inject(DiaBackendAuthService); + authService.login$(EMAIL, PASSWORD).subscribe(done); + }); + + afterEach(done => authService.logout$().subscribe(done)); + + it('should be created', () => expect(repository).toBeTruthy()); + + it('should get all local cache first then via HTTP request', done => { + // tslint:disable-next-line: no-console + repository.getAll$().subscribe(result => console.log(result)); + }); + + it('should invite new contact', done => { + const inviteEmail = 'invite@test.com'; + // tslint:disable-next-line: no-console + repository.invite$(inviteEmail).subscribe(result => console.log(result)); + }); +}); + +const EMAIL = 'sean@numbersprotocol.io'; +const PASSWORD = 'testpassword'; diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts new file mode 100644 index 000000000..cddca4cf6 --- /dev/null +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -0,0 +1,75 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { isEqual } from 'lodash'; +import { defer, merge } from 'rxjs'; +import { + concatMap, + distinctUntilChanged, + pluck, + switchMapTo, +} from 'rxjs/operators'; +import { Database } from '../../database/database.service'; +import { OnConflictStrategy, Tuple } from '../../database/table/table'; +import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; +import { BASE_URL } from '../secret'; + +@Injectable({ + providedIn: 'root', +}) +export class DiaBackendContactRepository { + private readonly table = this.database.getTable( + DiaBackendContactRepository.name + ); + + constructor( + private readonly httpClient: HttpClient, + private readonly database: Database, + private readonly authService: DiaBackendAuthService + ) {} + + getAll$() { + return merge(this.fetchAll$(), this.table.queryAll$()).pipe( + distinctUntilChanged(isEqual) + ); + } + + invite$(email: string) { + return defer(() => this.authService.getAuthHeaders()).pipe( + concatMap(headers => + this.httpClient.post( + `${BASE_URL}/api/v2/contacts/invite/`, + { email }, + { headers } + ) + ), + switchMapTo(this.fetchAll$()) + ); + } + + private fetchAll$() { + return defer(() => this.authService.getAuthHeaders()).pipe( + concatMap(headers => + this.httpClient.get( + `${BASE_URL}/api/v2/contacts/`, + { headers } + ) + ), + pluck('results'), + concatMap(contacts => + this.table.insert(contacts, OnConflictStrategy.IGNORE) + ) + ); + } +} + +interface DiaBackendContact extends Tuple { + readonly contact: string | null; + readonly fake_contact_email: string | null; +} + +interface ListContactResponse { + readonly results: DiaBackendContact[]; +} + +// tslint:disable-next-line: no-empty-interface +interface InviteContactResponse {} From 8ae3a3839c3d45a58b04a8ac7d4886100614fc67 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Mon, 7 Dec 2020 22:37:26 +0800 Subject: [PATCH 03/23] Show contacts on contact-selection-dialog. --- .../contact-selection-dialog.component.html | 7 ++++++ .../contact-selection-dialog.component.ts | 22 ++++++------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html index fc2158428..82cf4aaf9 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html @@ -2,6 +2,13 @@

{{ t('selectContact') }}

+ + person +
{{ contact.contact || contact.fake_contact_email }}
+
share
{{ t('inviteFriend') }}
diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts index 44ace8758..34895364d 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts @@ -1,14 +1,8 @@ -import { Component, Inject } from '@angular/core'; -import { - MatDialog, - MatDialogRef, - MAT_DIALOG_DATA, -} from '@angular/material/dialog'; +import { Component } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { DiaBackendContactRepository } from '../../../../services/dia-backend/contact/dia-backend-contact-repository.service'; import { isNonNullable } from '../../../../utils/rx-operators'; -import { - FriendInvitationDialogComponent, - SubmittedFriendInvitation, -} from './friend-invitation-dialog/friend-invitation-dialog.component'; +import { FriendInvitationDialogComponent } from './friend-invitation-dialog/friend-invitation-dialog.component'; @Component({ selector: 'app-contact-selection-dialog', @@ -16,10 +10,12 @@ import { styleUrls: ['./contact-selection-dialog.component.scss'], }) export class ContactSelectionDialogComponent { + readonly contacts$ = this.diaBackendContactRepository.getAll$(); + constructor( private readonly dialog: MatDialog, private readonly dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: SelectedContact + private readonly diaBackendContactRepository: DiaBackendContactRepository ) {} openFriendInvitationDialog() { @@ -37,7 +33,3 @@ export class ContactSelectionDialogComponent { this.dialogRef.close(); } } - -export interface SelectedContact { - email?: string; -} From 11e908f358ec8e9bc5566759e44b3ed9c4ab3b6b Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Mon, 7 Dec 2020 23:20:28 +0800 Subject: [PATCH 04/23] Show progress spinner when fetching contacts with empty cache. --- .../contact-selection-dialog.component.html | 4 +++ .../contact-selection-dialog.component.scss | 36 ++----------------- .../contact-selection-dialog.component.ts | 1 + .../dia-backend-contact-repository.service.ts | 15 ++++++-- 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html index 82cf4aaf9..65994ec1f 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html @@ -1,6 +1,10 @@

{{ t('selectContact') }}

+ ( DiaBackendContactRepository.name ); @@ -46,8 +49,13 @@ export class DiaBackendContactRepository { ); } + isFetching$() { + return this._isFetching$.asObservable(); + } + private fetchAll$() { - return defer(() => this.authService.getAuthHeaders()).pipe( + return of(this._isFetching$.next(true)).pipe( + concatMapTo(defer(() => this.authService.getAuthHeaders())), concatMap(headers => this.httpClient.get( `${BASE_URL}/api/v2/contacts/`, @@ -57,7 +65,8 @@ export class DiaBackendContactRepository { pluck('results'), concatMap(contacts => this.table.insert(contacts, OnConflictStrategy.IGNORE) - ) + ), + tap(() => this._isFetching$.next(false)) ); } } From a021f40208953a4af21299f001fd251417555316 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 14:00:59 +0800 Subject: [PATCH 05/23] Add argument for comparator to Table.insert method. --- .../capacitor-filesystem-table.spec.ts | 158 +++++++++++------- .../capacitor-filesystem-table.ts | 28 +++- src/app/services/database/table/table.ts | 6 +- 3 files changed, 125 insertions(+), 67 deletions(-) diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts index 4919344e6..1396e9ab0 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts @@ -6,47 +6,11 @@ const { Filesystem } = Plugins; describe('CapacitorFilesystemTable', () => { let table: Table; - const tableId = 'tableId'; - const testTuple1: TestTuple = { - id: 1, - name: 'Rick Sanchez', - happy: false, - skills: [ - { - name: 'Create Stuff', - level: Number.POSITIVE_INFINITY, - }, - { - name: 'Destroy Stuff', - level: Number.POSITIVE_INFINITY, - }, - ], - address: { - country: 'USA on Earth C-137', - city: 'Washington', - }, - }; - const testTuple2: TestTuple = { - id: 2, - name: 'Butter Robot', - happy: false, - skills: [ - { - name: 'Pass Butter', - level: 1, - }, - { - name: 'Oh My God', - level: Number.NEGATIVE_INFINITY, - }, - ], - address: { - country: 'USA on Earth C-137', - city: 'Washington', - }, - }; - beforeEach(() => (table = new CapacitorFilesystemTable(tableId, Filesystem))); + beforeEach(() => { + const tableId = 'tableId'; + table = new CapacitorFilesystemTable(tableId, Filesystem); + }); afterEach(async () => table.drop()); @@ -65,42 +29,81 @@ describe('CapacitorFilesystemTable', () => { }); it('should emit new query on inserting tuple', async done => { - await table.insert([testTuple1]); - await table.insert([testTuple2]); + await table.insert([TUPLE1]); + await table.insert([TUPLE2]); table.queryAll$().subscribe(tuples => { - expect(tuples).toEqual([testTuple1, testTuple2]); + expect(tuples).toEqual([TUPLE1, TUPLE2]); done(); }); }); it('should throw on inserting same tuple', async () => { - const sameTuple: TestTuple = { ...testTuple1 }; + const sameTuple: TestTuple = { ...TUPLE1 }; - await expectAsync(table.insert([testTuple1, sameTuple])).toBeRejected(); + await expectAsync(table.insert([TUPLE1, sameTuple])).toBeRejected(); + }); + + it('should throw on inserting same tuple with comparator', async () => { + const sameIdTuple: TestTuple = { ...TUPLE2, id: TUPLE1_ID }; + + await expectAsync( + table.insert( + [TUPLE1, sameIdTuple], + OnConflictStrategy.ABORT, + (x, y) => x.id === y.id + ) + ).toBeRejected(); }); it('should throw on inserting existed tuple', async () => { - const sameTuple: TestTuple = { ...testTuple1 }; - await table.insert([testTuple1]); + const sameTuple: TestTuple = { ...TUPLE1 }; + await table.insert([TUPLE1]); await expectAsync(table.insert([sameTuple])).toBeRejected(); }); + it('should throw on inserting existed tuple with comparator', async () => { + const sameIdTuple: TestTuple = { ...TUPLE2, id: TUPLE1_ID }; + await table.insert([TUPLE1]); + + await expectAsync( + table.insert( + [sameIdTuple], + OnConflictStrategy.ABORT, + (x, y) => x.id === y.id + ) + ).toBeRejected(); + }); + it('should ignore on inserting existed tuple if the conflict strategy is IGNORE', async () => { - const sameTuple: TestTuple = { ...testTuple1 }; - await table.insert([testTuple1]); + const sameTuple: TestTuple = { ...TUPLE1 }; + await table.insert([TUPLE1]); - await table.insert([sameTuple, testTuple2], OnConflictStrategy.IGNORE); + await table.insert([sameTuple, TUPLE2], OnConflictStrategy.IGNORE); const all = await table.queryAll(); - expect(all).toEqual([testTuple1, testTuple2]); + expect(all).toEqual([TUPLE1, TUPLE2]); + }); + + it('should ignore on inserting existed tuple with comparator if the conflict strategy is IGNORE', async () => { + const sameIdTuple: TestTuple = { ...TUPLE2, id: TUPLE1_ID }; + await table.insert([TUPLE1]); + + await table.insert( + [sameIdTuple, TUPLE2], + OnConflictStrategy.IGNORE, + (x, y) => x.id === y.id + ); + + const all = await table.queryAll(); + expect(all).toEqual([TUPLE1, TUPLE2]); }); it('should remove by tuple contents not reference', async done => { - const sameTuple: TestTuple = { ...testTuple1 }; + const sameTuple: TestTuple = { ...TUPLE1 }; - await table.insert([testTuple1]); + await table.insert([TUPLE1]); await table.delete([sameTuple]); table.queryAll$().subscribe(tuples => { @@ -110,19 +113,19 @@ describe('CapacitorFilesystemTable', () => { }); it('should not emit removed tuples', async done => { - const sameTuple1: TestTuple = { ...testTuple1 }; + const sameTuple1: TestTuple = { ...TUPLE1 }; - await table.insert([testTuple1, testTuple2]); + await table.insert([TUPLE1, TUPLE2]); await table.delete([sameTuple1]); table.queryAll$().subscribe(tuples => { - expect(tuples).toEqual([testTuple2]); + expect(tuples).toEqual([TUPLE2]); done(); }); }); it('should throw on deleting non-existent tuples', async () => { - await expectAsync(table.delete([testTuple1])).toBeRejected(); + await expectAsync(table.delete([TUPLE1])).toBeRejected(); }); it('should insert atomically', async done => { @@ -181,3 +184,44 @@ interface TestTuple extends Tuple { city: string; }; } + +const TUPLE1_ID = 1; +const TUPLE1: TestTuple = { + id: TUPLE1_ID, + name: 'Rick Sanchez', + happy: false, + skills: [ + { + name: 'Create Stuff', + level: Number.POSITIVE_INFINITY, + }, + { + name: 'Destroy Stuff', + level: Number.POSITIVE_INFINITY, + }, + ], + address: { + country: 'USA on Earth C-137', + city: 'Washington', + }, +}; +const TUPLE2_ID = 2; +const TUPLE2: TestTuple = { + id: TUPLE2_ID, + name: 'Butter Robot', + happy: false, + skills: [ + { + name: 'Pass Butter', + level: 1, + }, + { + name: 'Oh My God', + level: Number.NEGATIVE_INFINITY, + }, + ], + address: { + country: 'USA on Earth C-137', + city: 'Washington', + }, +}; diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts index 64a8d58b8..813e126fc 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts @@ -85,16 +85,20 @@ export class CapacitorFilesystemTable implements Table { this.tuples$.next(JSON.parse(result.data)); } - async insert(tuples: T[], onConflict = OnConflictStrategy.ABORT) { + async insert( + tuples: T[], + onConflict = OnConflictStrategy.ABORT, + comparator = isEqual + ) { return this.mutex.runExclusive(async () => { - assertNoDuplicatedTuples(tuples); + assertNoDuplicatedTuples(tuples, comparator); await this.initialize(); if (onConflict === OnConflictStrategy.ABORT) { - this.assertNoConflictWithExistedTuples(tuples); + this.assertNoConflictWithExistedTuples(tuples, comparator); this.tuples$.next([...this.tuples$.value, ...tuples]); } else if (onConflict === OnConflictStrategy.IGNORE) { this.tuples$.next( - uniqWith([...this.tuples$.value, ...tuples], isEqual) + uniqWith([...this.tuples$.value, ...tuples], comparator) ); } await this.dumpJson(); @@ -102,8 +106,11 @@ export class CapacitorFilesystemTable implements Table { }); } - private assertNoConflictWithExistedTuples(tuples: T[]) { - const conflicted = intersectionWith(tuples, this.tuples$.value, isEqual); + private assertNoConflictWithExistedTuples( + tuples: T[], + comparator: (x: T, y: T) => boolean + ) { + const conflicted = intersectionWith(tuples, this.tuples$.value, comparator); if (conflicted.length !== 0) { throw new Error(`Tuples existed: ${JSON.stringify(conflicted)}`); } @@ -157,10 +164,13 @@ export class CapacitorFilesystemTable implements Table { private static readonly initializationMutex = new Mutex(); } -function assertNoDuplicatedTuples(tuples: T[]) { - const unique = uniqWith(tuples, isEqual); +function assertNoDuplicatedTuples( + tuples: T[], + comparator: (x: T, y: T) => boolean +) { + const unique = uniqWith(tuples, comparator); if (tuples.length !== unique.length) { - const conflicted = differenceWith(tuples, unique, isEqual); + const conflicted = differenceWith(tuples, unique, comparator); throw new Error(`Tuples duplicated: ${JSON.stringify(conflicted)}`); } } diff --git a/src/app/services/database/table/table.ts b/src/app/services/database/table/table.ts index 380e15f6f..172038f9f 100644 --- a/src/app/services/database/table/table.ts +++ b/src/app/services/database/table/table.ts @@ -4,7 +4,11 @@ export interface Table { readonly id: string; queryAll$(): Observable; queryAll(): Promise; - insert(tuples: T[], onConflict?: OnConflictStrategy): Promise; + insert( + tuples: T[], + onConflict?: OnConflictStrategy, + comparator?: (x: T, y: T) => boolean + ): Promise; delete(tuples: T[]): Promise; drop(): Promise; } From 34c1f4e217c93de3fb2cbffe5171af0082c0ad5b Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 14:19:30 +0800 Subject: [PATCH 06/23] Implement OnConflictStrategy.REPLACE for Table.insert. --- .../capacitor-filesystem-table.spec.ts | 24 +++++++++++++++++++ .../capacitor-filesystem-table.ts | 4 ++++ src/app/services/database/table/table.ts | 1 + 3 files changed, 29 insertions(+) diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts index 1396e9ab0..33ea0eb2b 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts @@ -100,6 +100,30 @@ describe('CapacitorFilesystemTable', () => { expect(all).toEqual([TUPLE1, TUPLE2]); }); + it('should replace on inserting existed tuple if the conflict strategy is REPLACE', async () => { + const sameTuple: TestTuple = { ...TUPLE1 }; + await table.insert([TUPLE1]); + + await table.insert([sameTuple, TUPLE2], OnConflictStrategy.REPLACE); + + const all = await table.queryAll(); + expect(all).toEqual([sameTuple, TUPLE2]); + }); + + it('should replace on inserting existed tuple with comparator if the conflict strategy is REPLACE', async () => { + const sameIdTuple: TestTuple = { ...TUPLE2, id: TUPLE1_ID }; + await table.insert([TUPLE1]); + + await table.insert( + [sameIdTuple, TUPLE2], + OnConflictStrategy.REPLACE, + (x, y) => x.id === y.id + ); + + const all = await table.queryAll(); + expect(all).toEqual([sameIdTuple, TUPLE2]); + }); + it('should remove by tuple contents not reference', async done => { const sameTuple: TestTuple = { ...TUPLE1 }; diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts index 813e126fc..6d46f08e3 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts @@ -100,6 +100,10 @@ export class CapacitorFilesystemTable implements Table { this.tuples$.next( uniqWith([...this.tuples$.value, ...tuples], comparator) ); + } else if (onConflict === OnConflictStrategy.REPLACE) { + this.tuples$.next( + uniqWith([...tuples, ...this.tuples$.value], comparator) + ); } await this.dumpJson(); return tuples; diff --git a/src/app/services/database/table/table.ts b/src/app/services/database/table/table.ts index 172038f9f..7c765f996 100644 --- a/src/app/services/database/table/table.ts +++ b/src/app/services/database/table/table.ts @@ -27,4 +27,5 @@ export interface Tuple { export const enum OnConflictStrategy { ABORT, IGNORE, + REPLACE, } From afff62b2299d2a0828a5348d4b0658f56fcd2a1f Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 14:56:58 +0800 Subject: [PATCH 07/23] Implement comparator for Table.delete method. --- .../capacitor-filesystem-table.spec.ts | 18 ++++++++++++++++++ .../capacitor-filesystem-table.ts | 16 ++++++++-------- src/app/services/database/table/table.ts | 2 +- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts index 33ea0eb2b..07439c81c 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.spec.ts @@ -148,10 +148,28 @@ describe('CapacitorFilesystemTable', () => { }); }); + it('should not emit removed tuples with comparator', async done => { + const sameIdTuple1: TestTuple = { ...TUPLE2, id: TUPLE1_ID }; + + await table.insert([TUPLE1, TUPLE2]); + await table.delete([sameIdTuple1], (x, y) => x.id === y.id); + + table.queryAll$().subscribe(tuples => { + expect(tuples).toEqual([TUPLE2]); + done(); + }); + }); + it('should throw on deleting non-existent tuples', async () => { await expectAsync(table.delete([TUPLE1])).toBeRejected(); }); + it('should throw on deleting non-existent tuples with comparator', async () => { + await table.insert([TUPLE1, TUPLE2]); + + await expectAsync(table.delete([TUPLE1], () => false)).toBeRejected(); + }); + it('should insert atomically', async done => { const tupleCount = 100; const expectedTuples: TestTuple[] = [...Array(tupleCount).keys()].map( diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts index 6d46f08e3..7b059befc 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts @@ -120,12 +120,14 @@ export class CapacitorFilesystemTable implements Table { } } - async delete(tuples: T[]) { + async delete(tuples: T[], comparator = isEqual) { return this.mutex.runExclusive(async () => { - this.assertTuplesExist(tuples); + this.assertTuplesExist(tuples, comparator); await this.initialize(); - const afterDeletion = this.tuples$.value.filter( - tuple => !tuples.map(t => isEqual(tuple, t)).includes(true) + const afterDeletion = differenceWith( + this.tuples$.value, + tuples, + comparator ); this.tuples$.next(afterDeletion); await this.dumpJson(); @@ -133,10 +135,8 @@ export class CapacitorFilesystemTable implements Table { }); } - private assertTuplesExist(tuples: T[]) { - const nonexistent = tuples.filter( - tuple => !this.tuples$.value.find(t => isEqual(tuple, t)) - ); + private assertTuplesExist(tuples: T[], comparator: (x: T, y: T) => boolean) { + const nonexistent = differenceWith(tuples, this.tuples$.value, comparator); if (nonexistent.length !== 0) { throw new Error( `Cannot delete nonexistent tuples: ${JSON.stringify(nonexistent)}` diff --git a/src/app/services/database/table/table.ts b/src/app/services/database/table/table.ts index 7c765f996..ef2f4efea 100644 --- a/src/app/services/database/table/table.ts +++ b/src/app/services/database/table/table.ts @@ -9,7 +9,7 @@ export interface Table { onConflict?: OnConflictStrategy, comparator?: (x: T, y: T) => boolean ): Promise; - delete(tuples: T[]): Promise; + delete(tuples: T[], comparator?: (x: T, y: T) => boolean): Promise; drop(): Promise; } From d79fb9b0c870568dfbc4c5ac9f3dc8a879a704cb Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 15:08:53 +0800 Subject: [PATCH 08/23] Update DiaBackendContact endpoint APIs. --- .../contact-selection-dialog.component.html | 4 ++-- ...backend-contact-repository.service.spec.ts | 6 ----- .../dia-backend-contact-repository.service.ts | 24 ++++++------------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html index 65994ec1f..3dad8b486 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.html @@ -8,10 +8,10 @@

{{ t('selectContact') }}

person -
{{ contact.contact || contact.fake_contact_email }}
+
{{ contact.contact_name || contact.contact_email }}
share diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts index 1d5f051bf..bd6daf69e 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts @@ -24,12 +24,6 @@ xdescribe('DiaBackendContactRepository', () => { // tslint:disable-next-line: no-console repository.getAll$().subscribe(result => console.log(result)); }); - - it('should invite new contact', done => { - const inviteEmail = 'invite@test.com'; - // tslint:disable-next-line: no-console - repository.invite$(inviteEmail).subscribe(result => console.log(result)); - }); }); const EMAIL = 'sean@numbersprotocol.io'; diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts index 00df2b9c2..1aa145b64 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -7,7 +7,6 @@ import { concatMapTo, distinctUntilChanged, pluck, - switchMapTo, tap, } from 'rxjs/operators'; import { Database } from '../../database/database.service'; @@ -36,19 +35,6 @@ export class DiaBackendContactRepository { ); } - invite$(email: string) { - return defer(() => this.authService.getAuthHeaders()).pipe( - concatMap(headers => - this.httpClient.post( - `${BASE_URL}/api/v2/contacts/invite/`, - { email }, - { headers } - ) - ), - switchMapTo(this.fetchAll$()) - ); - } - isFetching$() { return this._isFetching$.asObservable(); } @@ -64,7 +50,11 @@ export class DiaBackendContactRepository { ), pluck('results'), concatMap(contacts => - this.table.insert(contacts, OnConflictStrategy.IGNORE) + this.table.insert( + contacts, + OnConflictStrategy.REPLACE, + (x, y) => x.contact_email === y.contact_email + ) ), tap(() => this._isFetching$.next(false)) ); @@ -72,8 +62,8 @@ export class DiaBackendContactRepository { } interface DiaBackendContact extends Tuple { - readonly contact: string | null; - readonly fake_contact_email: string | null; + readonly contact_email: string; + readonly contact_name: string; } interface ListContactResponse { From deb9a13b1892575d0adb677cad7608363c685ab1 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 17:43:56 +0800 Subject: [PATCH 09/23] Refactor PushNotificationService. --- src/app/app.component.ts | 10 +- src/app/pages/home/home.page.ts | 10 +- .../capacitor-filesystem-table.ts | 2 - .../auth/dia-backend-auth.service.ts | 48 ++++-- .../push-notification.service.spec.ts | 4 +- .../push-notification.service.ts | 144 +++++------------- .../capacitor-plugins-testing.module.ts | 6 + .../capacitor-plugins.module.ts | 13 +- .../mock-push-notifications-plugin.ts | 84 ++++++++++ src/app/utils/camera.ts | 1 + 10 files changed, 190 insertions(+), 132 deletions(-) create mode 100644 src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5a5e01ee7..69a9317ef 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -10,8 +10,10 @@ import { CollectorService } from './services/collector/collector.service'; import { CapacitorFactsProvider } from './services/collector/facts/capacitor-facts-provider/capacitor-facts-provider.service'; import { WebCryptoApiSignatureProvider } from './services/collector/signature/web-crypto-api-signature-provider/web-crypto-api-signature-provider.service'; import { DiaBackendAssetRepository } from './services/dia-backend/asset/dia-backend-asset-repository.service'; +import { DiaBackendAuthService } from './services/dia-backend/auth/dia-backend-auth.service'; import { LanguageService } from './services/language/language.service'; import { NotificationService } from './services/notification/notification.service'; +import { PushNotificationService } from './services/push-notification/push-notification.service'; import { restoreKilledCapture } from './utils/camera'; const { SplashScreen } = Plugins; @@ -32,16 +34,22 @@ export class AppComponent { private readonly webCryptoApiSignatureProvider: WebCryptoApiSignatureProvider, private readonly diaBackendAssetRepository: DiaBackendAssetRepository, notificationService: NotificationService, - langaugeService: LanguageService + pushNotificationService: PushNotificationService, + langaugeService: LanguageService, + diaBackendAuthService: DiaBackendAuthService ) { notificationService.requestPermission(); + pushNotificationService.register(); langaugeService.initialize(); + diaBackendAuthService.initialize$().subscribe(); this.restoreAppStatus(); this.initializeApp(); this.initializeCollector(); this.registerIcon(); } + // TODO: Error if user logout during app killed. Use BehaviorSubject instead. + // Extract this to a standalone CameraService. restoreAppStatus() { return defer(restoreKilledCapture) .pipe( diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 90c7de980..bdeeb9012 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -1,5 +1,5 @@ import { formatDate } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { MatTabChangeEvent } from '@angular/material/tabs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { groupBy } from 'lodash'; @@ -17,7 +17,6 @@ import { DiaBackendAssetRepository } from '../../services/dia-backend/asset/dia- import { DiaBackendAuthService } from '../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; import { IgnoredTransactionRepository } from '../../services/dia-backend/transaction/ignored-transaction-repository.service'; -import { PushNotificationService } from '../../services/push-notification/push-notification.service'; import { getOldProof } from '../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../services/repositories/proof/proof-repository.service'; import { capture } from '../../utils/camera'; @@ -29,7 +28,7 @@ import { forkJoinWithDefault } from '../../utils/rx-operators'; templateUrl: './home.page.html', styleUrls: ['./home.page.scss'], }) -export class HomePage implements OnInit { +export class HomePage { readonly capturesByDate$ = this.getCaptures$().pipe( map(captures => groupBy(captures, c => @@ -54,14 +53,9 @@ export class HomePage implements OnInit { private readonly diaBackendAuthService: DiaBackendAuthService, private readonly diaBackendAssetRepository: DiaBackendAssetRepository, private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository, - private readonly pushNotificationService: PushNotificationService, private readonly ignoredTransactionRepository: IgnoredTransactionRepository ) {} - ngOnInit() { - this.pushNotificationService.configure(); - } - private getCaptures$() { const originallyOwnedAssets$ = this.diaBackendAssetRepository .getAll$() diff --git a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts index 4a15fb838..c0578c1d6 100644 --- a/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts +++ b/src/app/services/database/table/capacitor-filesystem-table/capacitor-filesystem-table.ts @@ -121,8 +121,6 @@ export class CapacitorFilesystemTable implements Table { tuple => !this.tuples$.value.find(t => equals(tuple)(t)) ); if (nonexistent.length !== 0) { - console.error(JSON.stringify(this.tuples$.value)); - throw new Error( `Cannot delete nonexistent tuples: ${JSON.stringify(nonexistent)}` ); diff --git a/src/app/services/dia-backend/auth/dia-backend-auth.service.ts b/src/app/services/dia-backend/auth/dia-backend-auth.service.ts index 1cf3b2bad..edabab97f 100644 --- a/src/app/services/dia-backend/auth/dia-backend-auth.service.ts +++ b/src/app/services/dia-backend/auth/dia-backend-auth.service.ts @@ -1,9 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Plugins } from '@capacitor/core'; -import { defer, forkJoin, Observable } from 'rxjs'; -import { concatMap, concatMapTo, map } from 'rxjs/operators'; +import { reject } from 'lodash'; +import { combineLatest, defer, forkJoin, Observable } from 'rxjs'; +import { concatMap, concatMapTo, filter, map } from 'rxjs/operators'; import { PreferenceManager } from '../../preference-manager/preference-manager.service'; +import { PushNotificationService } from '../../push-notification/push-notification.service'; import { BASE_URL } from '../secret'; const { Device } = Plugins; @@ -18,9 +20,14 @@ export class DiaBackendAuthService { constructor( private readonly httpClient: HttpClient, - private readonly preferenceManager: PreferenceManager + private readonly preferenceManager: PreferenceManager, + private readonly pushNotificationService: PushNotificationService ) {} + initialize$() { + return this.updateDevice$(); + } + login$(email: string, password: string): Observable { return this.httpClient .post(`${BASE_URL}/auth/token/login/`, { @@ -75,12 +82,13 @@ export class DiaBackendAuthService { }); } - // TODO: Internally depend on PushNotificationService and remove parameters. - createDevice$(fcmToken: string) { - return defer(() => - forkJoin([this.getAuthHeaders(), Device.getInfo()]) - ).pipe( - concatMap(([headers, deviceInfo]) => + private updateDevice$() { + return combineLatest([ + this.pushNotificationService.getToken$(), + this.getAuthHeaders$(), + defer(() => Device.getInfo()), + ]).pipe( + concatMap(([fcmToken, headers, deviceInfo]) => this.httpClient.post( `${BASE_URL}/auth/devices/`, { @@ -133,8 +141,28 @@ export class DiaBackendAuthService { return { authorization: `token ${await this.getToken()}` }; } + getAuthHeaders$() { + return this.getToken$().pipe( + map(token => ({ authorization: `token ${token}` })) + ); + } + private async getToken() { - return this.preferences.getString(PrefKeys.TOKEN); + return new Promise(resolve => { + this.preferences.getString(PrefKeys.TOKEN).then(token => { + if (token.length !== 0) { + resolve(token); + } else { + reject(new Error('Cannot get DIA backend token which is empty.')); + } + }); + }); + } + + private getToken$() { + return this.preferences + .getString$(PrefKeys.TOKEN) + .pipe(filter(token => token.length !== 0)); } private async setToken(value: string) { diff --git a/src/app/services/push-notification/push-notification.service.spec.ts b/src/app/services/push-notification/push-notification.service.spec.ts index cf3ea20f2..2df350076 100644 --- a/src/app/services/push-notification/push-notification.service.spec.ts +++ b/src/app/services/push-notification/push-notification.service.spec.ts @@ -12,7 +12,5 @@ describe('PushNotificationService', () => { service = TestBed.inject(PushNotificationService); }); - it('should be created', () => { - expect(service).toBeTruthy(); - }); + it('should be created', () => expect(service).toBeTruthy()); }); diff --git a/src/app/services/push-notification/push-notification.service.ts b/src/app/services/push-notification/push-notification.service.ts index 84e499477..298806108 100644 --- a/src/app/services/push-notification/push-notification.service.ts +++ b/src/app/services/push-notification/push-notification.service.ts @@ -1,125 +1,55 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { - Capacitor, - Plugins, - PushNotification, - PushNotificationToken, -} from '@capacitor/core'; -import { tap } from 'rxjs/operators'; -import { DiaBackendAssetRepository } from '../dia-backend/asset/dia-backend-asset-repository.service'; -import { DiaBackendAuthService } from '../dia-backend/auth/dia-backend-auth.service'; -import { ImageStore } from '../image-store/image-store.service'; -import { getProof } from '../repositories/proof/old-proof-adapter'; -import { ProofRepository } from '../repositories/proof/proof-repository.service'; - -const { Device, PushNotifications } = Plugins; - -/** - * TODO: Refactor this class. Decouple from DIA Backend. - */ +import { Inject, Injectable } from '@angular/core'; +import { Capacitor, PushNotificationsPlugin } from '@capacitor/core'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { PUSH_NOTIFICATIONS_PLUGIN } from '../../shared/capacitor-plugins/capacitor-plugins.module'; +import { isNonNullable } from '../../utils/rx-operators'; @Injectable({ providedIn: 'root', }) export class PushNotificationService { - constructor( - private readonly diaBackendAuthService: DiaBackendAuthService, - private readonly diaBackendAssetRepository: DiaBackendAssetRepository, - private readonly proofRepository: ProofRepository, - private readonly httpClient: HttpClient, - private readonly imageStore: ImageStore - ) {} + // tslint:disable-next-line: rxjs-no-explicit-generics + private readonly token$ = new BehaviorSubject(undefined); + private readonly pushData$ = new Subject<{ [key: string]: string }>(); - configure(): void { - if (Capacitor.platform === 'web') { - return; + constructor( + @Inject(PUSH_NOTIFICATIONS_PLUGIN) + private readonly pushNotificationsPlugin: PushNotificationsPlugin + ) { + if (Capacitor.isPluginAvailable('PushNotifications')) { + this.pushNotificationsPlugin.addListener( + 'pushNotificationReceived', + notification => { + this.pushData$.next(notification.data); + } + ); } - this.requestPermission(); - this.addRegisterListener( - token => { - this.uploadToken$(token.value).subscribe(() => - // tslint:disable-next-line: no-console - console.log(`token ${token.value} uploaded`) - ); - const message = `Push registration success, token: ${token.value}`; - // tslint:disable-next-line: no-console - console.log(message); - }, - error => { - throw error; - } - ); - this.addReceivedListener(notification => - this.storeExpiredPostCapture(notification) - ); - } - - private uploadToken$(token: string) { - return this.diaBackendAuthService.createDevice$(token).pipe( - // tslint:disable-next-line: no-console - tap(() => console.log('Token Uploaded!')) - ); } - // tslint:disable-next-line: prefer-function-over-method - private requestPermission(): void { - // Request permission to use push notifications - // iOS will prompt user and return if they granted permission or not - // Android will just grant without prompting - PushNotifications.requestPermission().then(result => { - if (result.granted) { - // Register with Apple / Google to receive push via APNS/FCM - PushNotifications.register(); - } else { - throw Error('Failed to request permission'); - } - }); - } - - // tslint:disable-next-line: prefer-function-over-method - private addRegisterListener( - onSuccess: (token: PushNotificationToken) => void, - onError: (error: any) => void - ): void { - PushNotifications.addListener('registration', onSuccess); - PushNotifications.addListener('registrationError', onError); + getToken$() { + return this.token$.asObservable().pipe(isNonNullable()); } - // tslint:disable-next-line: prefer-function-over-method - private addReceivedListener( - callback: (notification: PushNotification) => void - ): void { - PushNotifications.addListener('pushNotificationReceived', callback); + getPushData$() { + return this.pushData$.asObservable(); } - private async storeExpiredPostCapture(pushNotification: PushNotification) { - const data: NumbersStorageNotification = pushNotification.data; - if (data.app_message_type !== 'transaction_expired') { + async register() { + if (!Capacitor.isPluginAvailable('PushNotifications')) { return; } + const result = await this.pushNotificationsPlugin.requestPermission(); - const asset = await this.diaBackendAssetRepository - .getById$(data.id) - .toPromise(); - await this.diaBackendAssetRepository.addAssetDirectly(asset); - const rawImage = await this.httpClient - .get(asset.asset_file, { responseType: 'blob' }) - .toPromise(); - const proof = await getProof( - this.imageStore, - rawImage, - asset.information, - asset.signature - ); - await this.proofRepository.add(proof); + return new Promise((resolve, reject) => { + if (!result.granted) { + reject(new Error('Push notification permission denied.')); + } + this.pushNotificationsPlugin.addListener('registration', token => { + this.token$.next(token.value); + resolve(token.value); + }); + this.pushNotificationsPlugin.addListener('registrationError', reject); + this.pushNotificationsPlugin.register(); + }); } } - -interface NumbersStorageNotification { - readonly app_message_type: - | 'transaction_received' - | 'transaction_accepted' - | 'transaction_expired'; - readonly id: string; -} diff --git a/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts b/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts index 40e37e915..b85e4d2c2 100644 --- a/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts +++ b/src/app/shared/capacitor-plugins/capacitor-plugins-testing.module.ts @@ -3,11 +3,13 @@ import { FILESYSTEM_PLUGIN, GEOLOCATION_PLUGIN, LOCAL_NOTIFICATIONS_PLUGIN, + PUSH_NOTIFICATIONS_PLUGIN, STORAGE_PLUGIN, } from './capacitor-plugins.module'; import { MockFilesystemPlugin } from './mock-filesystem-plugin'; import { MockGeolocationPlugin } from './mock-geolocation-plugin'; import { MockLocalNotificationsPlugin } from './mock-local-notifications-plugin'; +import { MockPushNotificationsPlugin } from './mock-push-notifications-plugin'; import { MockStoragePlugin } from './mock-storage-plugin'; @NgModule({ @@ -28,6 +30,10 @@ import { MockStoragePlugin } from './mock-storage-plugin'; provide: STORAGE_PLUGIN, useClass: MockStoragePlugin, }, + { + provide: PUSH_NOTIFICATIONS_PLUGIN, + useClass: MockPushNotificationsPlugin, + }, ], }) export class CapacitorPluginsTestingModule {} diff --git a/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts b/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts index ca6f479a1..05b50de7f 100644 --- a/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts +++ b/src/app/shared/capacitor-plugins/capacitor-plugins.module.ts @@ -4,10 +4,17 @@ import { GeolocationPlugin, LocalNotificationsPlugin, Plugins, + PushNotificationsPlugin, StoragePlugin, } from '@capacitor/core'; -const { Filesystem, Geolocation, LocalNotifications, Storage } = Plugins; +const { + Filesystem, + Geolocation, + LocalNotifications, + Storage, + PushNotifications, +} = Plugins; export const GEOLOCATION_PLUGIN = new InjectionToken( 'GEOLOCATION_PLUGIN' @@ -21,6 +28,9 @@ export const LOCAL_NOTIFICATIONS_PLUGIN = new InjectionToken( 'STORAGE_PLUGIN' ); +export const PUSH_NOTIFICATIONS_PLUGIN = new InjectionToken( + 'PUSH_NOTIFICATIONS_PLUGIN' +); @NgModule({ providers: [ @@ -28,6 +38,7 @@ export const STORAGE_PLUGIN = new InjectionToken( { provide: FILESYSTEM_PLUGIN, useValue: Filesystem }, { provide: LOCAL_NOTIFICATIONS_PLUGIN, useValue: LocalNotifications }, { provide: STORAGE_PLUGIN, useValue: Storage }, + { provide: PUSH_NOTIFICATIONS_PLUGIN, useValue: PushNotifications }, ], }) export class CapacitorPluginsModule {} diff --git a/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts b/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts new file mode 100644 index 000000000..c43f5ae48 --- /dev/null +++ b/src/app/shared/capacitor-plugins/mock-push-notifications-plugin.ts @@ -0,0 +1,84 @@ +// tslint:disable: prefer-function-over-method no-async-without-await +import { + NotificationChannel, + NotificationChannelList, + NotificationPermissionResponse, + PluginListenerHandle, + PushNotification, + PushNotificationActionPerformed, + PushNotificationDeliveredList, + PushNotificationsPlugin, + PushNotificationToken, +} from '@capacitor/core'; + +export class MockPushNotificationsPlugin implements PushNotificationsPlugin { + async register(): Promise { + throw new Error('Method not implemented.'); + } + + async requestPermission(): Promise { + throw new Error('Method not implemented.'); + } + + async getDeliveredNotifications(): Promise { + throw new Error('Method not implemented.'); + } + + async removeDeliveredNotifications( + delivered: PushNotificationDeliveredList + ): Promise { + throw new Error('Method not implemented.'); + } + + async removeAllDeliveredNotifications(): Promise { + throw new Error('Method not implemented.'); + } + + async createChannel(channel: NotificationChannel): Promise { + throw new Error('Method not implemented.'); + } + + async deleteChannel(channel: NotificationChannel): Promise { + throw new Error('Method not implemented.'); + } + + async listChannels(): Promise { + throw new Error('Method not implemented.'); + } + + addListener( + eventName: 'registration', + listenerFunc: (token: PushNotificationToken) => void + ): PluginListenerHandle; + addListener( + eventName: 'registrationError', + listenerFunc: (error: any) => void + ): PluginListenerHandle; + addListener( + eventName: 'pushNotificationReceived', + listenerFunc: (notification: PushNotification) => void + ): PluginListenerHandle; + addListener( + eventName: 'pushNotificationActionPerformed', + listenerFunc: (notification: PushNotificationActionPerformed) => void + ): PluginListenerHandle; + addListener( + eventName: + | 'registration' + | 'registrationError' + | 'pushNotificationReceived' + | 'pushNotificationActionPerformed', + listenerFunc: + | ((token: PushNotificationToken) => void) + | ((error: any) => void) + | ((notification: PushNotification) => void) + | ((notification: PushNotificationActionPerformed) => void) + ): PluginListenerHandle { + // tslint:disable-next-line: no-empty + return { remove: () => {} }; + } + + removeAllListeners(): void { + throw new Error('Method not implemented.'); + } +} diff --git a/src/app/utils/camera.ts b/src/app/utils/camera.ts index 9000ef040..5ce38674d 100644 --- a/src/app/utils/camera.ts +++ b/src/app/utils/camera.ts @@ -22,6 +22,7 @@ export async function capture(): Promise { }; } +// TODO: This promise may never resolve. Use BehaviorSubject instead. export async function restoreKilledCapture() { return new Promise(resolve => { App.addListener('appRestoredResult', appState => { From b14b60814330b6776292fc9081b756f1e5a0b035 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 20:50:09 +0800 Subject: [PATCH 10/23] Remove unused InviteContactResponse interface. --- .../contact/dia-backend-contact-repository.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts index 1aa145b64..7b9da020e 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -69,6 +69,3 @@ interface DiaBackendContact extends Tuple { interface ListContactResponse { readonly results: DiaBackendContact[]; } - -// tslint:disable-next-line: no-empty-interface -interface InviteContactResponse {} From a08e3142f7cf6ddcbcff5f5647a94aedc4ec8340 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 21:07:52 +0800 Subject: [PATCH 11/23] Replace test account with mock account. --- .../contact/dia-backend-contact-repository.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts index bd6daf69e..577a82e87 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.spec.ts @@ -26,5 +26,5 @@ xdescribe('DiaBackendContactRepository', () => { }); }); -const EMAIL = 'sean@numbersprotocol.io'; +const EMAIL = 'test@test.com'; const PASSWORD = 'testpassword'; From 02054ff4239b50f0f530d007d8cf47f2ecd7ce73 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 22:44:24 +0800 Subject: [PATCH 12/23] Show notification when receving PostCapture. --- src/app/app.component.ts | 10 +++- .../dia-backend-notification.service.spec.ts | 16 ++++++ .../dia-backend-notification.service.ts | 50 +++++++++++++++++++ src/assets/i18n/en-us.json | 6 ++- src/assets/i18n/zh-tw.json | 6 ++- 5 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts create mode 100644 src/app/services/dia-backend/notification/dia-backend-notification.service.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 69a9317ef..7cd91c60a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -11,6 +11,7 @@ import { CapacitorFactsProvider } from './services/collector/facts/capacitor-fac import { WebCryptoApiSignatureProvider } from './services/collector/signature/web-crypto-api-signature-provider/web-crypto-api-signature-provider.service'; import { DiaBackendAssetRepository } from './services/dia-backend/asset/dia-backend-asset-repository.service'; import { DiaBackendAuthService } from './services/dia-backend/auth/dia-backend-auth.service'; +import { DiaBackendNotificationService } from './services/dia-backend/notification/dia-backend-notification.service'; import { LanguageService } from './services/language/language.service'; import { NotificationService } from './services/notification/notification.service'; import { PushNotificationService } from './services/push-notification/push-notification.service'; @@ -36,12 +37,17 @@ export class AppComponent { notificationService: NotificationService, pushNotificationService: PushNotificationService, langaugeService: LanguageService, - diaBackendAuthService: DiaBackendAuthService + diaBackendAuthService: DiaBackendAuthService, + diaBackendNotificationService: DiaBackendNotificationService ) { notificationService.requestPermission(); pushNotificationService.register(); langaugeService.initialize(); - diaBackendAuthService.initialize$().subscribe(); + diaBackendAuthService.initialize$().pipe(untilDestroyed(this)).subscribe(); + diaBackendNotificationService + .initialize$() + .pipe(untilDestroyed(this)) + .subscribe(); this.restoreAppStatus(); this.initializeApp(); this.initializeCollector(); diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts new file mode 100644 index 000000000..ae0fdffd8 --- /dev/null +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { SharedTestingModule } from '../../../shared/shared-testing.module'; +import { DiaBackendNotificationService } from './dia-backend-notification.service'; + +describe('DiaBackendNotificationService', () => { + let service: DiaBackendNotificationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SharedTestingModule], + }); + service = TestBed.inject(DiaBackendNotificationService); + }); + + it('should be created', () => expect(service).toBeTruthy()); +}); diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts new file mode 100644 index 000000000..6b3ef7d21 --- /dev/null +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { TranslocoService } from '@ngneat/transloco'; +import { EMPTY, Observable } from 'rxjs'; +import { concatMap, filter } from 'rxjs/operators'; +import { NotificationService } from '../../notification/notification.service'; +import { PushNotificationService } from '../../push-notification/push-notification.service'; + +@Injectable({ + providedIn: 'root', +}) +export class DiaBackendNotificationService { + constructor( + private readonly pushNotificationService: PushNotificationService, + private readonly notificationService: NotificationService, + private readonly translocoService: TranslocoService + ) {} + + initialize$() { + return this.pushNotificationService.getPushData$().pipe( + isDiaBackendPushNotificationData(), + concatMap(data => { + if (data.app_message_type === 'transaction_received') { + return this.notificationService.notify( + this.translocoService.translate('newTransactionReceived'), + this.translocoService.translate('message.newTransactionReceived') + ); + } + return EMPTY; + }) + ); + } +} + +interface PushNotificationData { + app_message_type: + | 'transaction_received' + | 'transaction_accepted' + | 'transaction_expired'; + sender: 'string'; +} + +function isDiaBackendPushNotificationData() { + return (source$: Observable) => + source$.pipe( + filter( + (v): v is PushNotificationData => + v.app_message_type !== undefined && v.sender !== undefined + ) + ); +} diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json index eab8a1cb0..c5ec78d3e 100644 --- a/src/assets/i18n/en-us.json +++ b/src/assets/i18n/en-us.json @@ -79,8 +79,9 @@ "waitingToBeAccepted": "Waiting to be Accepted", "sentFrom": "Sent from", "receiver": "Receiver", - ".message": "Message", "location": "Location", + "newTransactionReceived": "New PostCapture Received", + ".message": "Message", "message": { "areYouSure": "This action cannot be undone.", "verificationEmailSent": "A verification email has been sent to your email address.", @@ -101,6 +102,7 @@ "copiedToClipboard": "Has copied to clipboard.", "invitationEmail": "Enter friend's email to send the invitation.", "clickingSignupToAgreePolicy": "By clicking Sign Up, you agree to our Terms and Privacy Policy.", - "sendPostCaptureAlert": "After a PostCapture is sent, the ownership will be transferred to the selected friend. Are you sure?" + "sendPostCaptureAlert": "After a PostCapture is sent, the ownership will be transferred to the selected friend. Are you sure?", + "newTransactionReceived": "A new PostCapture has received." } } diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index f29a5b6a0..e8aa1fa2b 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -79,8 +79,9 @@ "returned": "已退回", "sentFrom": "發送自", "receiver": "收件人", - ".message": "訊息", + "newTransactionReceived": "收到新 PostCapture", "location": "位置", + ".message": "訊息", "message": { "areYouSure": "此操作不可撤銷。", "verificationEmailSent": "驗證電子郵件已發送到您的電子郵件地址。", @@ -101,6 +102,7 @@ "copiedToClipboard": "已複製至剪貼簿。", "invitationEmail": "輸入好友電子信箱發送邀請。", "clickingSignupToAgreePolicy": "點擊「註冊」即表示你同意我們的《服務條款》和《資料政策》。", - "sendPostCaptureAlert": "PostCapture 送出後,它的所有權將轉移至選定的朋友。您確定嗎?" + "sendPostCaptureAlert": "PostCapture 送出後,它的所有權將轉移至選定的朋友。您確定嗎?", + "newTransactionReceived": "收到了新的 PostCapture。" } } From eabdfb954084df8bebef1b0bc72fff01041352e3 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Tue, 8 Dec 2020 23:45:18 +0800 Subject: [PATCH 13/23] WIP: Implement polling to get expired assets. --- src/app/pages/home/home.page.spec.ts | 6 +- src/app/pages/home/home.page.ts | 124 +++++++++++++----- .../dia-backend-asset-repository.service.ts | 42 +----- .../dia-backend-notification.service.ts | 4 +- .../proof/old-proof-adapter.spec.ts | 5 +- .../repositories/proof/old-proof-adapter.ts | 44 ++++++- 6 files changed, 146 insertions(+), 79 deletions(-) diff --git a/src/app/pages/home/home.page.spec.ts b/src/app/pages/home/home.page.spec.ts index f1a85844d..c9e99925d 100644 --- a/src/app/pages/home/home.page.spec.ts +++ b/src/app/pages/home/home.page.spec.ts @@ -17,8 +17,6 @@ describe('HomePage', () => { fixture.detectChanges(); })); - // TODO: Enable this after removing the polling WORKAROUND - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); + // TODO: Enable this test after we remove the ugly WORKAROUND in ngOnInit(). + // it('should create', () => expect(component).toBeTruthy()); }); diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index bdeeb9012..03de71556 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -1,23 +1,28 @@ import { formatDate } from '@angular/common'; -import { Component } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Component, OnInit } from '@angular/core'; import { MatTabChangeEvent } from '@angular/material/tabs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { groupBy } from 'lodash'; -import { combineLatest, defer, interval, of, zip } from 'rxjs'; +import { combineLatest, defer, forkJoin, interval, of, zip } from 'rxjs'; import { concatMap, - concatMapTo, distinctUntilChanged, first, map, pluck, + switchMapTo, } from 'rxjs/operators'; import { CollectorService } from '../../services/collector/collector.service'; import { DiaBackendAssetRepository } from '../../services/dia-backend/asset/dia-backend-asset-repository.service'; import { DiaBackendAuthService } from '../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; import { IgnoredTransactionRepository } from '../../services/dia-backend/transaction/ignored-transaction-repository.service'; -import { getOldProof } from '../../services/repositories/proof/old-proof-adapter'; +import { ImageStore } from '../../services/image-store/image-store.service'; +import { + getOldProof, + getProof, +} from '../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../services/repositories/proof/proof-repository.service'; import { capture } from '../../utils/camera'; import { forkJoinWithDefault } from '../../utils/rx-operators'; @@ -28,7 +33,7 @@ import { forkJoinWithDefault } from '../../utils/rx-operators'; templateUrl: './home.page.html', styleUrls: ['./home.page.scss'], }) -export class HomePage { +export class HomePage implements OnInit { readonly capturesByDate$ = this.getCaptures$().pipe( map(captures => groupBy(captures, c => @@ -43,7 +48,30 @@ export class HomePage { postCaptures$ = this.getPostCaptures$(); readonly username$ = this.diaBackendAuthService.getUsername$(); captureButtonShow = true; - inboxCount$ = this.pollingInbox$().pipe( + + /** + * TODO: Use repository pattern to cache the inbox data. + */ + inboxCount$ = this.pollingTransaction$().pipe( + concatMap(postCaptures => + zip(of(postCaptures), this.diaBackendAuthService.getEmail()) + ), + map(([postCaptures, email]) => + postCaptures.filter( + postCapture => + postCapture.receiver_email === email && + !postCapture.fulfilled_at && + !postCapture.expired + ) + ), + concatMap(postCaptures => + zip(of(postCaptures), this.ignoredTransactionRepository.getAll$()) + ), + map(([postCaptures, ignoredTransactions]) => + postCaptures.filter( + postcapture => !ignoredTransactions.includes(postcapture.id) + ) + ), map(transactions => transactions.length) ); @@ -53,9 +81,64 @@ export class HomePage { private readonly diaBackendAuthService: DiaBackendAuthService, private readonly diaBackendAssetRepository: DiaBackendAssetRepository, private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository, - private readonly ignoredTransactionRepository: IgnoredTransactionRepository + private readonly ignoredTransactionRepository: IgnoredTransactionRepository, + private readonly imageStore: ImageStore, + private readonly httpClient: HttpClient ) {} + ngOnInit() { + /** + * TODO: Remove this ugly WORKAROUND by using repository pattern to cache the expired assets and proofs. + */ + this.pollingTransaction$() + .pipe( + concatMap(transactions => + forkJoin([ + of(transactions), + defer(() => this.diaBackendAuthService.getEmail()), + ]) + ), + map(([transactions, email]) => + transactions.filter(t => t.expired && t.sender === email) + ), + concatMap(expiredTransactions => + forkJoin( + expiredTransactions.map(t => + this.diaBackendAssetRepository.getById$(t.asset.id) + ) + ) + ), + concatMap(expiredAssets => + forkJoin([ + of(expiredAssets), + defer(() => + Promise.all( + expiredAssets.map(async a => { + return this.proofRepository.add( + await getProof( + this.imageStore, + await this.httpClient + .get(a.asset_file, { responseType: 'blob' }) + .toPromise(), + a.information, + a.signature + ) + ); + }) + ) + ), + ]) + ), + concatMap(([expiredAssets]) => + this.diaBackendAssetRepository.addAssetDirectly( + expiredAssets /* TODO: onConflict.REPLACE */ + ) + ), + untilDestroyed(this) + ) + .subscribe(); + } + private getCaptures$() { const originallyOwnedAssets$ = this.diaBackendAssetRepository .getAll$() @@ -145,32 +228,13 @@ export class HomePage { } /** - * TODO: Use repository pattern to cache the inbox data. + * TODO: Use repository pattern to cache the transaction data. */ - private pollingInbox$() { + private pollingTransaction$() { // tslint:disable-next-line: no-magic-numbers return interval(10000).pipe( - concatMapTo(this.diaBackendTransactionRepository.getAll$()), - pluck('results'), - concatMap(postCaptures => - zip(of(postCaptures), this.diaBackendAuthService.getEmail()) - ), - map(([postCaptures, email]) => - postCaptures.filter( - postCapture => - postCapture.receiver_email === email && - !postCapture.fulfilled_at && - !postCapture.expired - ) - ), - concatMap(postCaptures => - zip(of(postCaptures), this.ignoredTransactionRepository.getAll$()) - ), - map(([postCaptures, ignoredTransactions]) => - postCaptures.filter( - postcapture => !ignoredTransactions.includes(postcapture.id) - ) - ) + switchMapTo(this.diaBackendTransactionRepository.getAll$()), + pluck('results') ); } } diff --git a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts index aaf89970f..205d3a7aa 100644 --- a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts +++ b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts @@ -10,11 +10,10 @@ import { NotificationService } from '../../notification/notification.service'; import { getOldSignatures, getSortedProofInformation, - OldDefaultInformationName, OldSignature, SortedProofInformation, } from '../../repositories/proof/old-proof-adapter'; -import { DefaultFactId, Proof } from '../../repositories/proof/proof'; +import { Proof } from '../../repositories/proof/proof'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; import { BASE_URL } from '../secret'; @@ -84,8 +83,8 @@ export class DiaBackendAssetRepository { } // TODO: use repository to remove this method. - async addAssetDirectly(asset: DiaBackendAsset) { - return this.table.insert([asset]); + async addAssetDirectly(assets: DiaBackendAsset[]) { + return this.table.insert(assets); } // TODO: use repository to remove this method. @@ -112,8 +111,7 @@ async function buildFormDataToCreateAsset(proof: Proof) { const formData = new FormData(); const info = await getSortedProofInformation(proof); - const oldInfo = replaceDefaultFactIdWithOldDefaultInformationName(info); - formData.set('meta', JSON.stringify(oldInfo)); + formData.set('meta', JSON.stringify(info)); formData.set('signature', JSON.stringify(getOldSignatures(proof))); @@ -125,35 +123,3 @@ async function buildFormDataToCreateAsset(proof: Proof) { return formData; } - -function replaceDefaultFactIdWithOldDefaultInformationName( - sortedProofInformation: SortedProofInformation -): SortedProofInformation { - return { - proof: sortedProofInformation.proof, - information: sortedProofInformation.information.map(info => { - if (info.name === DefaultFactId.DEVICE_NAME) { - return { - provider: info.provider, - value: info.value, - name: OldDefaultInformationName.DEVICE_NAME, - }; - } - if (info.name === DefaultFactId.GEOLOCATION_LATITUDE) { - return { - provider: info.provider, - value: info.value, - name: OldDefaultInformationName.GEOLOCATION_LATITUDE, - }; - } - if (info.name === DefaultFactId.GEOLOCATION_LONGITUDE) { - return { - provider: info.provider, - value: info.value, - name: OldDefaultInformationName.GEOLOCATION_LONGITUDE, - }; - } - return info; - }), - }; -} diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts index 6b3ef7d21..de19457d4 100644 --- a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts @@ -32,11 +32,11 @@ export class DiaBackendNotificationService { } interface PushNotificationData { - app_message_type: + readonly app_message_type: | 'transaction_received' | 'transaction_accepted' | 'transaction_expired'; - sender: 'string'; + readonly sender: 'string'; } function isDiaBackendPushNotificationData() { diff --git a/src/app/services/repositories/proof/old-proof-adapter.spec.ts b/src/app/services/repositories/proof/old-proof-adapter.spec.ts index 764edaa22..092a59bf5 100644 --- a/src/app/services/repositories/proof/old-proof-adapter.spec.ts +++ b/src/app/services/repositories/proof/old-proof-adapter.spec.ts @@ -17,6 +17,7 @@ import { getProof, getSortedProofInformation, OldSignature, + replaceOldDefaultInformationNameWithDefaultFactId, SortedProofInformation, } from './old-proof-adapter'; @@ -40,7 +41,7 @@ describe('old-proof-adapter', () => { expect(oldProof.timestamp).toEqual(TRUTH.timestamp); }); - it('should convert Proof SortedProofInformation', async () => { + it('should convert Proof to SortedProofInformation', async () => { const sortedProofInformation = await getSortedProofInformation(proof); expect(sortedProofInformation.proof.hash).toEqual(ASSET1_SHA256); @@ -51,7 +52,7 @@ describe('old-proof-adapter', () => { expect( Object.values(proof.truth.providers) .flatMap(facts => Object.keys(facts)) - .includes(name) + .includes(replaceOldDefaultInformationNameWithDefaultFactId(name)) ).toBeTrue(); }); }); diff --git a/src/app/services/repositories/proof/old-proof-adapter.ts b/src/app/services/repositories/proof/old-proof-adapter.ts index 7ca159501..dd72c1937 100644 --- a/src/app/services/repositories/proof/old-proof-adapter.ts +++ b/src/app/services/repositories/proof/old-proof-adapter.ts @@ -3,7 +3,7 @@ import { blobToBase64 } from '../../../utils/encoding/encoding'; import { MimeType } from '../../../utils/mime-type'; import { Tuple } from '../../database/table/table'; import { ImageStore } from '../../image-store/image-store.service'; -import { Proof, Signature } from './proof'; +import { DefaultFactId, Proof, Signature } from './proof'; /** * Only for migration and connection to backend. Subject to change. @@ -29,10 +29,29 @@ export async function getSortedProofInformation( ): Promise { return { proof: getOldProof(proof), - information: createSortedEssentialInformation(proof), + information: createSortedEssentialInformation(proof).map(info => ({ + provider: info.provider, + name: replaceDefaultFactIdWithOldDefaultInformationName(info.name), + value: info.value, + })), }; } +export function replaceDefaultFactIdWithOldDefaultInformationName( + name: string +): string { + if (name === DefaultFactId.DEVICE_NAME) { + return OldDefaultInformationName.DEVICE_NAME; + } + if (name === DefaultFactId.GEOLOCATION_LATITUDE) { + return OldDefaultInformationName.GEOLOCATION_LATITUDE; + } + if (name === DefaultFactId.GEOLOCATION_LONGITUDE) { + return OldDefaultInformationName.GEOLOCATION_LONGITUDE; + } + return name; +} + function createSortedEssentialInformation( proof: Proof ): OldEssentialInformation[] { @@ -79,7 +98,11 @@ export async function getProof( ): Promise { const base64 = await blobToBase64(raw); const groupedByProvider = groupObjectsBy( - sortedProofInformation.information, + sortedProofInformation.information.map(info => ({ + provider: info.provider, + name: replaceOldDefaultInformationNameWithDefaultFactId(info.name), + value: info.value, + })), 'provider' ); const providers = flow( @@ -107,6 +130,21 @@ export async function getProof( ); } +export function replaceOldDefaultInformationNameWithDefaultFactId( + name: string +): string { + if (name === OldDefaultInformationName.DEVICE_NAME) { + return DefaultFactId.DEVICE_NAME; + } + if (name === OldDefaultInformationName.GEOLOCATION_LATITUDE) { + return DefaultFactId.GEOLOCATION_LATITUDE; + } + if (name === OldDefaultInformationName.GEOLOCATION_LONGITUDE) { + return DefaultFactId.GEOLOCATION_LONGITUDE; + } + return name; +} + /** * Group by the key. The returned collection does not have the original key property. */ From 804f8e5e0181940b6969d69586321d2dc6287c1c Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 00:15:27 +0800 Subject: [PATCH 14/23] WORKAROUND: Implement polling to get expired assets. --- src/app/pages/home/home.page.ts | 7 +++++-- .../asset/dia-backend-asset-repository.service.ts | 10 +++++++--- .../repositories/proof/proof-repository.service.ts | 5 +++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index e959aa1e3..340915ce5 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -14,6 +14,7 @@ import { switchMapTo, } from 'rxjs/operators'; import { CollectorService } from '../../services/collector/collector.service'; +import { OnConflictStrategy } from '../../services/database/table/table'; import { DiaBackendAssetRepository } from '../../services/dia-backend/asset/dia-backend-asset-repository.service'; import { DiaBackendAuthService } from '../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; @@ -121,7 +122,8 @@ export class HomePage implements OnInit { .toPromise(), a.information, a.signature - ) + ), + OnConflictStrategy.REPLACE ); }) ) @@ -130,7 +132,8 @@ export class HomePage implements OnInit { ), concatMap(([expiredAssets]) => this.diaBackendAssetRepository.addAssetDirectly( - expiredAssets /* TODO: onConflict.REPLACE */ + expiredAssets, + OnConflictStrategy.REPLACE ) ), untilDestroyed(this) diff --git a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts index 205d3a7aa..5ed2c0012 100644 --- a/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts +++ b/src/app/services/dia-backend/asset/dia-backend-asset-repository.service.ts @@ -5,7 +5,7 @@ import { defer, forkJoin } from 'rxjs'; import { concatMap, single } from 'rxjs/operators'; import { base64ToBlob } from '../../../utils/encoding/encoding'; import { Database } from '../../database/database.service'; -import { Tuple } from '../../database/table/table'; +import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { NotificationService } from '../../notification/notification.service'; import { getOldSignatures, @@ -83,8 +83,12 @@ export class DiaBackendAssetRepository { } // TODO: use repository to remove this method. - async addAssetDirectly(assets: DiaBackendAsset[]) { - return this.table.insert(assets); + async addAssetDirectly( + assets: DiaBackendAsset[], + onConflict = OnConflictStrategy.ABORT, + comparator = (x: DiaBackendAsset, y: DiaBackendAsset) => x.id === y.id + ) { + return this.table.insert(assets, onConflict, comparator); } // TODO: use repository to remove this method. diff --git a/src/app/services/repositories/proof/proof-repository.service.ts b/src/app/services/repositories/proof/proof-repository.service.ts index 7fbd4072f..ff992ed56 100644 --- a/src/app/services/repositories/proof/proof-repository.service.ts +++ b/src/app/services/repositories/proof/proof-repository.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { Database } from '../../database/database.service'; +import { OnConflictStrategy } from '../../database/table/table'; import { ImageStore } from '../../image-store/image-store.service'; import { IndexedProofView, Proof } from './proof'; @@ -27,8 +28,8 @@ export class ProofRepository { ); } - async add(proof: Proof) { - await this.table.insert([proof.getIndexedProofView()]); + async add(proof: Proof, onConflict = OnConflictStrategy.ABORT) { + await this.table.insert([proof.getIndexedProofView()], onConflict); return proof; } From 008d71ef48c19bfc3ef39d9764e661918a6678b0 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 00:24:58 +0800 Subject: [PATCH 15/23] Show notification on a PostCapture expired. --- .../notification/dia-backend-notification.service.ts | 10 ++++++++-- src/assets/i18n/en-us.json | 6 ++++-- src/assets/i18n/zh-tw.json | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts index de19457d4..528383287 100644 --- a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts @@ -21,8 +21,14 @@ export class DiaBackendNotificationService { concatMap(data => { if (data.app_message_type === 'transaction_received') { return this.notificationService.notify( - this.translocoService.translate('newTransactionReceived'), - this.translocoService.translate('message.newTransactionReceived') + this.translocoService.translate('transactionReceived'), + this.translocoService.translate('message.transactionReceived') + ); + } + if (data.app_message_type === 'transaction_expired') { + return this.notificationService.notify( + this.translocoService.translate('transactionExpired'), + this.translocoService.translate('message.transactionExpired') ); } return EMPTY; diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json index c5ec78d3e..63a91c6f9 100644 --- a/src/assets/i18n/en-us.json +++ b/src/assets/i18n/en-us.json @@ -80,7 +80,8 @@ "sentFrom": "Sent from", "receiver": "Receiver", "location": "Location", - "newTransactionReceived": "New PostCapture Received", + "transactionReceived": "New PostCapture Received", + "transactionExpired": "PostCapture Returned", ".message": "Message", "message": { "areYouSure": "This action cannot be undone.", @@ -103,6 +104,7 @@ "invitationEmail": "Enter friend's email to send the invitation.", "clickingSignupToAgreePolicy": "By clicking Sign Up, you agree to our Terms and Privacy Policy.", "sendPostCaptureAlert": "After a PostCapture is sent, the ownership will be transferred to the selected friend. Are you sure?", - "newTransactionReceived": "A new PostCapture has received." + "transactionReceived": "A new PostCapture has received.", + "transactionExpired": "A PostCapture you delivered has been returned." } } diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index e8aa1fa2b..3393b9441 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -79,8 +79,9 @@ "returned": "已退回", "sentFrom": "發送自", "receiver": "收件人", - "newTransactionReceived": "收到新 PostCapture", "location": "位置", + "transactionReceived": "收到新 PostCapture", + "transactionExpired": "PostCapture 被退回", ".message": "訊息", "message": { "areYouSure": "此操作不可撤銷。", @@ -103,6 +104,7 @@ "invitationEmail": "輸入好友電子信箱發送邀請。", "clickingSignupToAgreePolicy": "點擊「註冊」即表示你同意我們的《服務條款》和《資料政策》。", "sendPostCaptureAlert": "PostCapture 送出後,它的所有權將轉移至選定的朋友。您確定嗎?", - "newTransactionReceived": "收到了新的 PostCapture。" + "transactionReceived": "收到了新的 PostCapture。", + "transactionExpired": "您寄出的 PostCapture 已被退回。" } } From 218b1ec4ab7fcf162ff68ddfa9ef2162ebf8ed5c Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 00:41:34 +0800 Subject: [PATCH 16/23] Show only empty badge on inbox icon. --- src/app/pages/home/home.page.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/pages/home/home.page.html b/src/app/pages/home/home.page.html index 71201568f..525bb54b3 100644 --- a/src/app/pages/home/home.page.html +++ b/src/app/pages/home/home.page.html @@ -42,8 +42,9 @@ - {{ t('activity') }} - - -
- - - - -
{{ activity.asset.id }}
-
{{ activity.created_at | date: 'short' }}
- -
- -
-
-
diff --git a/src/app/pages/home/home-routing.module.ts b/src/app/pages/home/home-routing.module.ts index 0bb553ddf..75e072034 100644 --- a/src/app/pages/home/home-routing.module.ts +++ b/src/app/pages/home/home-routing.module.ts @@ -18,9 +18,11 @@ const routes: Routes = [ import('./inbox/inbox.module').then(m => m.InboxPageModule), }, { - path: 'activity', + path: 'transaction', loadChildren: () => - import('./activity/activity.module').then(m => m.ActivityPageModule), + import('./transaction/transaction.module').then( + m => m.TransactionPageModule + ), }, ]; diff --git a/src/app/pages/home/home.page.html b/src/app/pages/home/home.page.html index 71201568f..94b2689cd 100644 --- a/src/app/pages/home/home.page.html +++ b/src/app/pages/home/home.page.html @@ -37,7 +37,7 @@ menu - + {{ t('transaction') }} + + +
+ + + + +
{{ transaction.asset.id }}
+
{{ transaction.created_at | date: 'short' }}
+ +
+ +
+
+
diff --git a/src/app/pages/home/activity/activity.page.scss b/src/app/pages/home/transaction/transaction.page.scss similarity index 100% rename from src/app/pages/home/activity/activity.page.scss rename to src/app/pages/home/transaction/transaction.page.scss diff --git a/src/app/pages/home/activity/activity.page.spec.ts b/src/app/pages/home/transaction/transaction.page.spec.ts similarity index 52% rename from src/app/pages/home/activity/activity.page.spec.ts rename to src/app/pages/home/transaction/transaction.page.spec.ts index 0df6fb218..b9d41fcdf 100644 --- a/src/app/pages/home/activity/activity.page.spec.ts +++ b/src/app/pages/home/transaction/transaction.page.spec.ts @@ -1,23 +1,21 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SharedTestingModule } from '../../../shared/shared-testing.module'; -import { ActivityPage } from './activity.page'; +import { TransactionPage } from './transaction.page'; -describe('ActivityPage', () => { - let component: ActivityPage; - let fixture: ComponentFixture; +describe('TransactionPage', () => { + let component: TransactionPage; + let fixture: ComponentFixture; beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ActivityPage], + declarations: [TransactionPage], imports: [SharedTestingModule], }).compileComponents(); - fixture = TestBed.createComponent(ActivityPage); + fixture = TestBed.createComponent(TransactionPage); component = fixture.componentInstance; fixture.detectChanges(); })); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => expect(component).toBeTruthy()); }); diff --git a/src/app/pages/home/activity/activity.page.ts b/src/app/pages/home/transaction/transaction.page.ts similarity index 68% rename from src/app/pages/home/activity/activity.page.ts rename to src/app/pages/home/transaction/transaction.page.ts index 17df72b80..0e280f817 100644 --- a/src/app/pages/home/activity/activity.page.ts +++ b/src/app/pages/home/transaction/transaction.page.ts @@ -9,21 +9,21 @@ import { @UntilDestroy({ checkProperties: true }) @Component({ - selector: 'app-activity', - templateUrl: './activity.page.html', - styleUrls: ['./activity.page.scss'], + selector: 'app-transaction', + templateUrl: './transaction.page.html', + styleUrls: ['./transaction.page.scss'], }) -export class ActivityPage { +export class TransactionPage { readonly status = Status; - readonly activitiesWithStatus$ = this.diaBackendTransactionRepository + readonly transactionsWithStatus$ = this.diaBackendTransactionRepository .getAll$() .pipe( pluck('results'), concatMap(activities => Promise.all( - activities.map(async activity => ({ - ...activity, - status: await this.getStatus(activity), + activities.map(async transaction => ({ + ...transaction, + status: await this.getStatus(transaction), })) ) ) @@ -34,18 +34,18 @@ export class ActivityPage { private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository ) {} - private async getStatus(activity: DiaBackendTransaction) { + private async getStatus(transaction: DiaBackendTransaction) { const email = await this.diaBackendAuthService.getEmail(); - if (activity.expired) { + if (transaction.expired) { return Status.Returned; } - if (!activity.fulfilled_at) { - if (activity.receiver_email === email) { + if (!transaction.fulfilled_at) { + if (transaction.receiver_email === email) { return Status.InProgress; } return Status.waitingToBeAccepted; } - if (activity.sender === email) { + if (transaction.sender === email) { return Status.Delivered; } return Status.Accepted; diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json index f04381a5b..184e27a92 100644 --- a/src/assets/i18n/en-us.json +++ b/src/assets/i18n/en-us.json @@ -70,7 +70,7 @@ "inbox": "Inbox", "ignore": "Ignore", "accept": "Accept", - "activity": "Activity", + "transaction": "Activity", "transactionDetails": "Activity Details", "accepted": "Accepted", "delivered": "Delivered", diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index b2dbbc631..9eac047a3 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -70,7 +70,7 @@ "inbox": "收件匣", "ignore": "忽略", "accept": "接收", - "activity": "活動紀錄", + "transaction": "活動紀錄", "transactionDetails": "活動資訊", "accepted": "已接收", "delivered": "已送出", From 9e3210cd6bec62e57b580206e08c0738b3c1a3fe Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 14:32:33 +0800 Subject: [PATCH 18/23] WIP --- src/app/pages/home/home.page.ts | 6 +- src/app/pages/home/inbox/inbox.page.ts | 5 +- .../home/transaction/transaction.page.ts | 5 +- .../dia-backend-contact-repository.service.ts | 2 +- ...-backend-transaction-repository.service.ts | 58 +++++++++++++++++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 5380745c1..4f636b6be 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -97,7 +97,7 @@ export class HomePage implements OnInit { private getPostCaptures$() { return zip( - this.diaBackendTransactionRepository.getAll$(), + this.diaBackendTransactionRepository.oldFetchAll$().pipe(first()), this.diaBackendAuthService.getEmail() ).pipe( map(([transactionListResponse, email]) => @@ -155,7 +155,9 @@ export class HomePage implements OnInit { private pollingInbox$() { // tslint:disable-next-line: no-magic-numbers return interval(10000).pipe( - concatMapTo(this.diaBackendTransactionRepository.getAll$()), + concatMapTo( + this.diaBackendTransactionRepository.oldFetchAll$().pipe(first()) + ), pluck('results'), concatMap(postCaptures => zip(of(postCaptures), this.diaBackendAuthService.getEmail()) diff --git a/src/app/pages/home/inbox/inbox.page.ts b/src/app/pages/home/inbox/inbox.page.ts index 553fb2f8d..21baaf878 100644 --- a/src/app/pages/home/inbox/inbox.page.ts +++ b/src/app/pages/home/inbox/inbox.page.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { of, zip } from 'rxjs'; -import { concatMap, map, pluck, tap } from 'rxjs/operators'; +import { concatMap, first, map, pluck, tap } from 'rxjs/operators'; import { BlockingActionService } from '../../../services/blocking-action/blocking-action.service'; import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; @@ -24,7 +24,8 @@ export class InboxPage { ) {} private listInbox$() { - return this.diaBackendTransactionRepository.getAll$().pipe( + return this.diaBackendTransactionRepository.oldFetchAll$().pipe( + first(), pluck('results'), concatMap(postCaptures => zip(of(postCaptures), this.diaBackendAuthService.getEmail()) diff --git a/src/app/pages/home/transaction/transaction.page.ts b/src/app/pages/home/transaction/transaction.page.ts index 0e280f817..b34586c8c 100644 --- a/src/app/pages/home/transaction/transaction.page.ts +++ b/src/app/pages/home/transaction/transaction.page.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { concatMap, pluck } from 'rxjs/operators'; +import { concatMap, first, pluck } from 'rxjs/operators'; import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransaction, @@ -16,8 +16,9 @@ import { export class TransactionPage { readonly status = Status; readonly transactionsWithStatus$ = this.diaBackendTransactionRepository - .getAll$() + .oldFetchAll$() .pipe( + first(), pluck('results'), concatMap(activities => Promise.all( diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts index 7b9da020e..ebd655db1 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -18,10 +18,10 @@ import { BASE_URL } from '../secret'; providedIn: 'root', }) export class DiaBackendContactRepository { - private readonly _isFetching$ = new BehaviorSubject(false); private readonly table = this.database.getTable( DiaBackendContactRepository.name ); + private readonly _isFetching$ = new BehaviorSubject(false); constructor( private readonly httpClient: HttpClient, diff --git a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts index 5fa876367..43b39e80f 100644 --- a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts @@ -1,7 +1,16 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { defer } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import { BehaviorSubject, defer, merge, of } from 'rxjs'; +import { + concatMap, + concatMapTo, + distinctUntilChanged, + pluck, + tap, +} from 'rxjs/operators'; +import { Database } from '../../database/database.service'; +import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; import { BASE_URL } from '../secret'; @@ -9,12 +18,50 @@ import { BASE_URL } from '../secret'; providedIn: 'root', }) export class DiaBackendTransactionRepository { + private readonly table = this.database.getTable( + DiaBackendTransactionRepository.name + ); + private readonly _isFetching$ = new BehaviorSubject(false); + constructor( private readonly httpClient: HttpClient, - private readonly authService: DiaBackendAuthService + private readonly authService: DiaBackendAuthService, + private readonly database: Database ) {} getAll$() { + return merge(this.fetchAll$(), this.table.queryAll$()).pipe( + distinctUntilChanged(isEqual) + ); + } + + isFetching$() { + return this._isFetching$.asObservable(); + } + + private fetchAll$() { + return of(this._isFetching$.next(true)).pipe( + concatMapTo(defer(() => this.authService.getAuthHeaders())), + concatMap(headers => + this.httpClient.get( + `${BASE_URL}/api/v2/transactions/`, + { headers } + ) + ), + pluck('results'), + concatMap(transactions => + this.table.insert( + transactions, + OnConflictStrategy.REPLACE, + (x, y) => x.id === y.id + ) + ), + tap(() => this._isFetching$.next(false)) + ); + } + + // TODO: make private by using repository pattern + oldFetchAll$() { return defer(() => this.authService.getAuthHeaders()).pipe( concatMap(headers => this.httpClient.get( @@ -33,7 +80,8 @@ export class DiaBackendTransactionRepository { { asset_id: assetId, email: targetEmail, caption }, { headers } ) - ) + ), + tap(response => console.log(response)) ); } @@ -50,7 +98,7 @@ export class DiaBackendTransactionRepository { } } -export interface DiaBackendTransaction { +export interface DiaBackendTransaction extends Tuple { readonly id: string; readonly asset: { readonly id: string; From dd3a21fb87f4e3daff7864ecb6ac463da5506e02 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 16:23:31 +0800 Subject: [PATCH 19/23] Refactor DiaBackendTransactionRepository with related pages. --- src/app/pages/home/asset/asset.page.ts | 2 +- .../contact-selection-dialog.component.ts | 2 +- .../asset/information/information.page.ts | 2 +- .../sending-post-capture.page.ts | 2 +- src/app/pages/home/home.page.ts | 13 ++--- src/app/pages/home/inbox/inbox.page.ts | 5 +- .../transaction-details.page.html | 20 ++++--- .../transaction-details.page.ts | 54 +++++++++++++++++-- .../home/transaction/transaction.page.html | 12 +++-- .../home/transaction/transaction.page.ts | 50 ++++------------- .../dia-backend-contact-repository.service.ts | 4 +- ...-backend-transaction-repository.service.ts | 36 +++++++------ src/app/utils/rx-operators.ts | 9 ---- .../utils/rx-operators/rx-operators.spec.ts | 36 +++++++++++++ src/app/utils/rx-operators/rx-operators.ts | 19 +++++++ 15 files changed, 165 insertions(+), 101 deletions(-) delete mode 100644 src/app/utils/rx-operators.ts create mode 100644 src/app/utils/rx-operators/rx-operators.spec.ts create mode 100644 src/app/utils/rx-operators/rx-operators.ts diff --git a/src/app/pages/home/asset/asset.page.ts b/src/app/pages/home/asset/asset.page.ts index 1675eb95b..9617202b6 100644 --- a/src/app/pages/home/asset/asset.page.ts +++ b/src/app/pages/home/asset/asset.page.ts @@ -19,7 +19,7 @@ import { ConfirmAlert } from '../../../services/confirm-alert/confirm-alert.serv import { DiaBackendAssetRepository } from '../../../services/dia-backend/asset/dia-backend-asset-repository.service'; import { getOldProof } from '../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../utils/rx-operators'; +import { isNonNullable } from '../../../utils/rx-operators/rx-operators'; import { ContactSelectionDialogComponent } from './contact-selection-dialog/contact-selection-dialog.component'; import { Option, diff --git a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts index 3f51a6c14..beafead16 100644 --- a/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts +++ b/src/app/pages/home/asset/contact-selection-dialog/contact-selection-dialog.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { DiaBackendContactRepository } from '../../../../services/dia-backend/contact/dia-backend-contact-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; import { FriendInvitationDialogComponent } from './friend-invitation-dialog/friend-invitation-dialog.component'; @Component({ diff --git a/src/app/pages/home/asset/information/information.page.ts b/src/app/pages/home/asset/information/information.page.ts index dce9e8d6a..85f36876d 100644 --- a/src/app/pages/home/asset/information/information.page.ts +++ b/src/app/pages/home/asset/information/information.page.ts @@ -9,7 +9,7 @@ import { getOldSignatures, } from '../../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ diff --git a/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts b/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts index d43a8a4c5..5eeb70cdd 100644 --- a/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts +++ b/src/app/pages/home/asset/sending-post-capture/sending-post-capture.page.ts @@ -10,7 +10,7 @@ import { DiaBackendAssetRepository } from '../../../../services/dia-backend/asse import { DiaBackendTransactionRepository } from '../../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; import { getOldProof } from '../../../../services/repositories/proof/old-proof-adapter'; import { ProofRepository } from '../../../../services/repositories/proof/proof-repository.service'; -import { isNonNullable } from '../../../../utils/rx-operators'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 4f636b6be..5d470e957 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -10,7 +10,6 @@ import { distinctUntilChanged, first, map, - pluck, } from 'rxjs/operators'; import { CollectorService } from '../../services/collector/collector.service'; import { DiaBackendAssetRepository } from '../../services/dia-backend/asset/dia-backend-asset-repository.service'; @@ -95,13 +94,14 @@ export class HomePage implements OnInit { ); } + // TODO: Clean up this ugly WORKAROUND with repository pattern. private getPostCaptures$() { return zip( - this.diaBackendTransactionRepository.oldFetchAll$().pipe(first()), + this.diaBackendTransactionRepository.getAll$().pipe(first()), this.diaBackendAuthService.getEmail() ).pipe( - map(([transactionListResponse, email]) => - transactionListResponse.results.filter( + map(([transactions, email]) => + transactions.filter( transaction => transaction.sender !== email && !transaction.expired && @@ -155,10 +155,7 @@ export class HomePage implements OnInit { private pollingInbox$() { // tslint:disable-next-line: no-magic-numbers return interval(10000).pipe( - concatMapTo( - this.diaBackendTransactionRepository.oldFetchAll$().pipe(first()) - ), - pluck('results'), + concatMapTo(this.diaBackendTransactionRepository.getAll$().pipe(first())), concatMap(postCaptures => zip(of(postCaptures), this.diaBackendAuthService.getEmail()) ), diff --git a/src/app/pages/home/inbox/inbox.page.ts b/src/app/pages/home/inbox/inbox.page.ts index 21baaf878..4a438f9c5 100644 --- a/src/app/pages/home/inbox/inbox.page.ts +++ b/src/app/pages/home/inbox/inbox.page.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { of, zip } from 'rxjs'; -import { concatMap, first, map, pluck, tap } from 'rxjs/operators'; +import { concatMap, first, map, tap } from 'rxjs/operators'; import { BlockingActionService } from '../../../services/blocking-action/blocking-action.service'; import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; @@ -24,9 +24,8 @@ export class InboxPage { ) {} private listInbox$() { - return this.diaBackendTransactionRepository.oldFetchAll$().pipe( + return this.diaBackendTransactionRepository.getAll$().pipe( first(), - pluck('results'), concatMap(postCaptures => zip(of(postCaptures), this.diaBackendAuthService.getEmail()) ), diff --git a/src/app/pages/home/transaction/transaction-details/transaction-details.page.html b/src/app/pages/home/transaction/transaction-details/transaction-details.page.html index 013430fb6..ea1fe2537 100644 --- a/src/app/pages/home/transaction/transaction-details/transaction-details.page.html +++ b/src/app/pages/home/transaction/transaction-details/transaction-details.page.html @@ -5,26 +5,24 @@ {{ t('transactionDetails') }}
-

{{ (details$ | async)?.created_at | date: 'short' }}

+

+ {{ (transaction$ | async)?.created_at | date: 'short' }} +

- {{ (details$ | async)?.asset.id }} - - + {{ t('sentFrom') }} : {{ (details$ | async)?.sender }}{{ t('sentFrom') }} : {{ (transaction$ | async)?.sender }} {{ t('receiver') }} : - {{ (details$ | async)?.receiver_email }} diff --git a/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts index 5f5108210..d187072bd 100644 --- a/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts +++ b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts @@ -1,7 +1,13 @@ import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { map } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; +import { DiaBackendAuthService } from '../../../../services/dia-backend/auth/dia-backend-auth.service'; +import { + DiaBackendTransaction, + DiaBackendTransactionRepository, +} from '../../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; +import { isNonNullable } from '../../../../utils/rx-operators/rx-operators'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'app-transaction-details', @@ -9,7 +15,49 @@ import { map } from 'rxjs/operators'; styleUrls: ['./transaction-details.page.scss'], }) export class TransactionDetailsPage { - details$ = this.route.paramMap.pipe(map(() => history.state)); + readonly transaction$ = this.route.paramMap.pipe( + map(params => params.get('id')), + isNonNullable(), + switchMap(id => this.diaBackendTransactionRepository.getById$(id)), + isNonNullable() + ); + readonly status$ = this.transaction$.pipe( + switchMap(transaction => + getStatus(transaction, this.diaBackendAuthService.getEmail()) + ) + ); - constructor(private readonly route: ActivatedRoute) {} + constructor( + private readonly route: ActivatedRoute, + private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository, + private readonly diaBackendAuthService: DiaBackendAuthService + ) {} +} + +export async function getStatus( + transaction: DiaBackendTransaction, + email: string | Promise +) { + email = await email; + if (transaction.expired) { + return Status.Returned; + } + if (!transaction.fulfilled_at) { + if (transaction.receiver_email === email) { + return Status.InProgress; + } + return Status.waitingToBeAccepted; + } + if (transaction.sender === email) { + return Status.Delivered; + } + return Status.Accepted; +} + +enum Status { + waitingToBeAccepted = 'waitingToBeAccepted', + InProgress = 'inProgress', + Returned = 'returned', + Delivered = 'delivered', + Accepted = 'accepted', } diff --git a/src/app/pages/home/transaction/transaction.page.html b/src/app/pages/home/transaction/transaction.page.html index ede339ab6..f0f69e7ec 100644 --- a/src/app/pages/home/transaction/transaction.page.html +++ b/src/app/pages/home/transaction/transaction.page.html @@ -13,12 +13,18 @@ last as isLast " > - +
{{ transaction.asset.id }}
{{ transaction.created_at | date: 'short' }}
-
diff --git a/src/app/pages/home/transaction/transaction.page.ts b/src/app/pages/home/transaction/transaction.page.ts index b34586c8c..49b6139fc 100644 --- a/src/app/pages/home/transaction/transaction.page.ts +++ b/src/app/pages/home/transaction/transaction.page.ts @@ -1,11 +1,9 @@ import { Component } from '@angular/core'; import { UntilDestroy } from '@ngneat/until-destroy'; -import { concatMap, first, pluck } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; -import { - DiaBackendTransaction, - DiaBackendTransactionRepository, -} from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; +import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; +import { getStatus } from './transaction-details/transaction-details.page'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -14,19 +12,14 @@ import { styleUrls: ['./transaction.page.scss'], }) export class TransactionPage { - readonly status = Status; readonly transactionsWithStatus$ = this.diaBackendTransactionRepository - .oldFetchAll$() + .getAll$() .pipe( - first(), - pluck('results'), - concatMap(activities => - Promise.all( - activities.map(async transaction => ({ - ...transaction, - status: await this.getStatus(transaction), - })) - ) + map(transactions => + transactions.map(transaction => ({ + ...transaction, + status: getStatus(transaction, this.diaBackendAuthService.getEmail()), + })) ) ); @@ -34,29 +27,4 @@ export class TransactionPage { private readonly diaBackendAuthService: DiaBackendAuthService, private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository ) {} - - private async getStatus(transaction: DiaBackendTransaction) { - const email = await this.diaBackendAuthService.getEmail(); - if (transaction.expired) { - return Status.Returned; - } - if (!transaction.fulfilled_at) { - if (transaction.receiver_email === email) { - return Status.InProgress; - } - return Status.waitingToBeAccepted; - } - if (transaction.sender === email) { - return Status.Delivered; - } - return Status.Accepted; - } -} - -export enum Status { - waitingToBeAccepted = 'waitingToBeAccepted', - InProgress = 'inProgress', - Returned = 'returned', - Delivered = 'delivered', - Accepted = 'accepted', } diff --git a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts index ebd655db1..88e4cf800 100644 --- a/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts +++ b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { isEqual } from 'lodash'; -import { BehaviorSubject, defer, merge, of } from 'rxjs'; +import { BehaviorSubject, defer, merge, Observable, of } from 'rxjs'; import { concatMap, concatMapTo, @@ -29,7 +29,7 @@ export class DiaBackendContactRepository { private readonly authService: DiaBackendAuthService ) {} - getAll$() { + getAll$(): Observable { return merge(this.fetchAll$(), this.table.queryAll$()).pipe( distinctUntilChanged(isEqual) ); diff --git a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts index 43b39e80f..d1e7a5756 100644 --- a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts @@ -1,14 +1,19 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { isEqual } from 'lodash'; -import { BehaviorSubject, defer, merge, of } from 'rxjs'; +import { BehaviorSubject, defer, merge, Observable, of } from 'rxjs'; import { concatMap, concatMapTo, distinctUntilChanged, + map, pluck, tap, } from 'rxjs/operators'; +import { + switchTap, + switchTapTo, +} from '../../../utils/rx-operators/rx-operators'; import { Database } from '../../database/database.service'; import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; @@ -29,12 +34,20 @@ export class DiaBackendTransactionRepository { private readonly database: Database ) {} - getAll$() { + getAll$(): Observable { return merge(this.fetchAll$(), this.table.queryAll$()).pipe( distinctUntilChanged(isEqual) ); } + getById$(id: string) { + return this.getAll$().pipe( + map(transactions => + transactions.find(transaction => transaction.id === id) + ) + ); + } + isFetching$() { return this._isFetching$.asObservable(); } @@ -60,18 +73,6 @@ export class DiaBackendTransactionRepository { ); } - // TODO: make private by using repository pattern - oldFetchAll$() { - return defer(() => this.authService.getAuthHeaders()).pipe( - concatMap(headers => - this.httpClient.get( - `${BASE_URL}/api/v2/transactions/`, - { headers } - ) - ) - ); - } - add$(assetId: string, targetEmail: string, caption: string) { return defer(() => this.authService.getAuthHeaders()).pipe( concatMap(headers => @@ -81,7 +82,7 @@ export class DiaBackendTransactionRepository { { headers } ) ), - tap(response => console.log(response)) + switchTap(response => defer(() => this.table.insert([response]))) ); } @@ -93,7 +94,8 @@ export class DiaBackendTransactionRepository { {}, { headers } ) - ) + ), + switchTapTo(this.fetchAll$()) ); } } @@ -117,7 +119,7 @@ interface ListTransactionResponse { } // tslint:disable-next-line: no-empty-interface -interface CreateTransactionResponse {} +interface CreateTransactionResponse extends DiaBackendTransaction {} // tslint:disable-next-line: no-empty-interface interface AcceptTransactionResponse {} diff --git a/src/app/utils/rx-operators.ts b/src/app/utils/rx-operators.ts deleted file mode 100644 index ca5886694..000000000 --- a/src/app/utils/rx-operators.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Observable } from 'rxjs'; -import { filter } from 'rxjs/operators'; - -export function isNonNullable() { - return (source$: Observable) => - source$.pipe( - filter((v): v is NonNullable => v !== null && v !== undefined) - ); -} diff --git a/src/app/utils/rx-operators/rx-operators.spec.ts b/src/app/utils/rx-operators/rx-operators.spec.ts new file mode 100644 index 000000000..d646f5b87 --- /dev/null +++ b/src/app/utils/rx-operators/rx-operators.spec.ts @@ -0,0 +1,36 @@ +// tslint:disable: no-null-keyword +import { of } from 'rxjs'; +import { toArray } from 'rxjs/operators'; +import { isNonNullable, switchTap, switchTapTo } from './rx-operators'; + +describe('rx-operators', () => { + it('should filter null and undefined values with type guard', done => { + const expected = [0, 1, 2]; + of(expected[0], expected[1], null, undefined, expected[2]) + .pipe(isNonNullable(), toArray()) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); + + it('should switchTap behaving the same with switchMapTo but ignore the result', done => { + const expected = 1; + of(expected) + .pipe(switchTap(v => of(v + v))) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); + + it('should switchTapTo behaving the same with switchMapTo but ignore the result', done => { + const expected = 1; + of(expected) + .pipe(switchTapTo(of(2))) + .subscribe(result => { + expect(result).toEqual(expected); + done(); + }); + }); +}); diff --git a/src/app/utils/rx-operators/rx-operators.ts b/src/app/utils/rx-operators/rx-operators.ts new file mode 100644 index 000000000..9daeecfdf --- /dev/null +++ b/src/app/utils/rx-operators/rx-operators.ts @@ -0,0 +1,19 @@ +import { Observable } from 'rxjs'; +import { filter, mapTo, switchMap } from 'rxjs/operators'; + +export function isNonNullable() { + return (source$: Observable) => + source$.pipe( + filter((v): v is NonNullable => v !== null && v !== undefined) + ); +} + +export function switchTap(func: (value: T) => Observable) { + return (source$: Observable) => + source$.pipe(switchMap(value => func(value).pipe(mapTo(value)))); +} + +export function switchTapTo(observable$: Observable) { + return (source$: Observable) => + source$.pipe(switchMap(value => observable$.pipe(mapTo(value)))); +} From 728f60fc023dc244c6274b8c62b069be17d84c41 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 18:11:05 +0800 Subject: [PATCH 20/23] Show spinner on transaction page when transaction cache is empty and is fetching. --- src/app/pages/home/transaction/transaction.page.html | 6 ++++++ src/app/pages/home/transaction/transaction.page.scss | 5 +++++ src/app/pages/home/transaction/transaction.page.ts | 1 + .../dia-backend-transaction-repository.service.ts | 7 ++++++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/pages/home/transaction/transaction.page.html b/src/app/pages/home/transaction/transaction.page.html index f0f69e7ec..8a8f74d1c 100644 --- a/src/app/pages/home/transaction/transaction.page.html +++ b/src/app/pages/home/transaction/transaction.page.html @@ -6,6 +6,12 @@
+ - {{ - t('nothingHere') - }} + + {{ t('nothingHere') }} diff --git a/src/app/pages/home/inbox/inbox.page.scss b/src/app/pages/home/inbox/inbox.page.scss index 8018e670e..ecaad3e91 100644 --- a/src/app/pages/home/inbox/inbox.page.scss +++ b/src/app/pages/home/inbox/inbox.page.scss @@ -1,4 +1,8 @@ .page-content { + mat-spinner { + margin: 36px auto 0; + } + span.nothing-here { display: flex; width: 100%; diff --git a/src/app/pages/home/inbox/inbox.page.ts b/src/app/pages/home/inbox/inbox.page.ts index 4a438f9c5..0792d674a 100644 --- a/src/app/pages/home/inbox/inbox.page.ts +++ b/src/app/pages/home/inbox/inbox.page.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { of, zip } from 'rxjs'; -import { concatMap, first, map, tap } from 'rxjs/operators'; +import { combineLatest } from 'rxjs'; +import { first, map } from 'rxjs/operators'; import { BlockingActionService } from '../../../services/blocking-action/blocking-action.service'; import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; @@ -14,7 +14,22 @@ import { IgnoredTransactionRepository } from '../../../services/dia-backend/tran styleUrls: ['./inbox.page.scss'], }) export class InboxPage { - postCaptures$ = this.listInbox$(); + readonly receivedTransactions$ = combineLatest([ + this.diaBackendTransactionRepository.getAll$(), + this.ignoredTransactionRepository.getAll$(), + this.diaBackendAuthService.getEmail$(), + ]).pipe( + map(([transactions, ignoredTransactions, email]) => + transactions.filter( + transaction => + transaction.receiver_email === email && + !transaction.fulfilled_at && + !transaction.expired && + !ignoredTransactions.includes(transaction.id) + ) + ) + ); + readonly isFetching$ = this.diaBackendTransactionRepository.isFetching$(); constructor( private readonly diaBackendAuthService: DiaBackendAuthService, @@ -23,35 +38,10 @@ export class InboxPage { private readonly blockingActionService: BlockingActionService ) {} - private listInbox$() { - return this.diaBackendTransactionRepository.getAll$().pipe( - first(), - concatMap(postCaptures => - zip(of(postCaptures), this.diaBackendAuthService.getEmail()) - ), - map(([postCaptures, email]) => - postCaptures.filter( - postCapture => - postCapture.receiver_email === email && - !postCapture.fulfilled_at && - !postCapture.expired - ) - ), - concatMap(postCaptures => - zip(of(postCaptures), this.ignoredTransactionRepository.getAll$()) - ), - map(([postCaptures, ignoredTransactions]) => - postCaptures.filter( - postcapture => !ignoredTransactions.includes(postcapture.id) - ) - ) - ); - } - accept(id: string) { const action$ = this.diaBackendTransactionRepository .accept$(id) - .pipe(tap(_ => this.refresh())); + .pipe(first()); this.blockingActionService .run$(action$) @@ -61,10 +51,5 @@ export class InboxPage { async ignore(id: string) { await this.ignoredTransactionRepository.add(id); - this.refresh(); - } - - private refresh() { - this.postCaptures$ = this.listInbox$(); } } diff --git a/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts index d187072bd..9f35e5c4d 100644 --- a/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts +++ b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts @@ -38,17 +38,17 @@ export async function getStatus( transaction: DiaBackendTransaction, email: string | Promise ) { - email = await email; + const resolvedEmail = await email; if (transaction.expired) { return Status.Returned; } if (!transaction.fulfilled_at) { - if (transaction.receiver_email === email) { + if (transaction.receiver_email === resolvedEmail) { return Status.InProgress; } return Status.waitingToBeAccepted; } - if (transaction.sender === email) { + if (transaction.sender === resolvedEmail) { return Status.Delivered; } return Status.Accepted; diff --git a/src/app/pages/home/transaction/transaction.page.scss b/src/app/pages/home/transaction/transaction.page.scss index a19637d1f..9286331e9 100644 --- a/src/app/pages/home/transaction/transaction.page.scss +++ b/src/app/pages/home/transaction/transaction.page.scss @@ -13,8 +13,7 @@ mat-toolbar { .page-content { mat-spinner { - margin: auto; - margin-top: 36px; + margin: 36px auto 0; } mat-list-item { diff --git a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts index d1a04f858..c304b4f66 100644 --- a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { isEqual } from 'lodash'; +import { isEqual, omit } from 'lodash'; import { BehaviorSubject, defer, merge, Observable, of } from 'rxjs'; import { concatMap, @@ -38,8 +38,8 @@ export class DiaBackendTransactionRepository { return merge(this.fetchAll$(), this.table.queryAll$()).pipe( distinctUntilChanged((transactionsX, transactionsY) => isEqual( - transactionsX.map(x => x.id), - transactionsY.map(y => y.id) + transactionsX.map(x => omit(x, 'asset.asset_file_thumbnail')), + transactionsY.map(y => omit(y, 'asset.asset_file_thumbnail')) ) ) ); @@ -115,7 +115,7 @@ export interface DiaBackendTransaction extends Tuple { readonly sender: string; readonly receiver_email: string; readonly created_at: string; - readonly fulfilled_at: string; + readonly fulfilled_at: string | null; readonly expired: boolean; } diff --git a/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts index 0c0a81112..d43105bbd 100644 --- a/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/ignored-transaction-repository.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { Database } from '../../database/database.service'; import { Tuple } from '../../database/table/table'; @@ -13,10 +15,11 @@ export class IgnoredTransactionRepository { constructor(private readonly database: Database) {} - getAll$() { - return this.table - .queryAll$() - .pipe(map(tuples => tuples.map(tuple => tuple.id))); + getAll$(): Observable { + return this.table.queryAll$().pipe( + map(tuples => tuples.map(tuple => tuple.id)), + distinctUntilChanged(isEqual) + ); } async add(id: string) { From f2988a9423908e3707ee972815730dab31b59d57 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 22:52:36 +0800 Subject: [PATCH 22/23] Remove polling for inbox count. --- src/app/pages/home/home.page.ts | 45 +++---------------- src/app/pages/home/inbox/inbox.page.ts | 21 +-------- ...-backend-transaction-repository.service.ts | 31 ++++++++++++- 3 files changed, 36 insertions(+), 61 deletions(-) diff --git a/src/app/pages/home/home.page.ts b/src/app/pages/home/home.page.ts index 5d470e957..19eab4d8d 100644 --- a/src/app/pages/home/home.page.ts +++ b/src/app/pages/home/home.page.ts @@ -3,14 +3,8 @@ import { Component, OnInit } from '@angular/core'; import { MatTabChangeEvent } from '@angular/material/tabs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { groupBy } from 'lodash'; -import { combineLatest, defer, forkJoin, interval, of, zip } from 'rxjs'; -import { - concatMap, - concatMapTo, - distinctUntilChanged, - first, - map, -} from 'rxjs/operators'; +import { combineLatest, defer, forkJoin, of, zip } from 'rxjs'; +import { concatMap, distinctUntilChanged, first, map } from 'rxjs/operators'; import { CollectorService } from '../../services/collector/collector.service'; import { DiaBackendAssetRepository } from '../../services/dia-backend/asset/dia-backend-asset-repository.service'; import { DiaBackendAuthService } from '../../services/dia-backend/auth/dia-backend-auth.service'; @@ -42,9 +36,9 @@ export class HomePage implements OnInit { postCaptures$ = this.getPostCaptures$(); readonly username$ = this.diaBackendAuthService.getUsername$(); captureButtonShow = true; - inboxCount$ = this.pollingInbox$().pipe( - map(transactions => transactions.length) - ); + inboxCount$ = this.diaBackendTransactionRepository + .getInbox$() + .pipe(map(transactions => transactions.length)); constructor( private readonly proofRepository: ProofRepository, @@ -148,33 +142,4 @@ export class HomePage implements OnInit { this.postCaptures$ = this.getPostCaptures$(); } } - - /** - * TODO: Use repository pattern to cache the inbox data. - */ - private pollingInbox$() { - // tslint:disable-next-line: no-magic-numbers - return interval(10000).pipe( - concatMapTo(this.diaBackendTransactionRepository.getAll$().pipe(first())), - concatMap(postCaptures => - zip(of(postCaptures), this.diaBackendAuthService.getEmail()) - ), - map(([postCaptures, email]) => - postCaptures.filter( - postCapture => - postCapture.receiver_email === email && - !postCapture.fulfilled_at && - !postCapture.expired - ) - ), - concatMap(postCaptures => - zip(of(postCaptures), this.ignoredTransactionRepository.getAll$()) - ), - map(([postCaptures, ignoredTransactions]) => - postCaptures.filter( - postcapture => !ignoredTransactions.includes(postcapture.id) - ) - ) - ); - } } diff --git a/src/app/pages/home/inbox/inbox.page.ts b/src/app/pages/home/inbox/inbox.page.ts index 0792d674a..a00f18056 100644 --- a/src/app/pages/home/inbox/inbox.page.ts +++ b/src/app/pages/home/inbox/inbox.page.ts @@ -1,9 +1,7 @@ import { Component } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { combineLatest } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { BlockingActionService } from '../../../services/blocking-action/blocking-action.service'; -import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.service'; import { DiaBackendTransactionRepository } from '../../../services/dia-backend/transaction/dia-backend-transaction-repository.service'; import { IgnoredTransactionRepository } from '../../../services/dia-backend/transaction/ignored-transaction-repository.service'; @@ -14,25 +12,10 @@ import { IgnoredTransactionRepository } from '../../../services/dia-backend/tran styleUrls: ['./inbox.page.scss'], }) export class InboxPage { - readonly receivedTransactions$ = combineLatest([ - this.diaBackendTransactionRepository.getAll$(), - this.ignoredTransactionRepository.getAll$(), - this.diaBackendAuthService.getEmail$(), - ]).pipe( - map(([transactions, ignoredTransactions, email]) => - transactions.filter( - transaction => - transaction.receiver_email === email && - !transaction.fulfilled_at && - !transaction.expired && - !ignoredTransactions.includes(transaction.id) - ) - ) - ); + readonly receivedTransactions$ = this.diaBackendTransactionRepository.getInbox$(); readonly isFetching$ = this.diaBackendTransactionRepository.isFetching$(); constructor( - private readonly diaBackendAuthService: DiaBackendAuthService, private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository, private readonly ignoredTransactionRepository: IgnoredTransactionRepository, private readonly blockingActionService: BlockingActionService diff --git a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts index c304b4f66..6eaf0a730 100644 --- a/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts +++ b/src/app/services/dia-backend/transaction/dia-backend-transaction-repository.service.ts @@ -1,7 +1,14 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { isEqual, omit } from 'lodash'; -import { BehaviorSubject, defer, merge, Observable, of } from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + defer, + merge, + Observable, + of, +} from 'rxjs'; import { concatMap, concatMapTo, @@ -18,6 +25,7 @@ import { Database } from '../../database/database.service'; import { OnConflictStrategy, Tuple } from '../../database/table/table'; import { DiaBackendAuthService } from '../auth/dia-backend-auth.service'; import { BASE_URL } from '../secret'; +import { IgnoredTransactionRepository } from './ignored-transaction-repository.service'; @Injectable({ providedIn: 'root', @@ -31,7 +39,8 @@ export class DiaBackendTransactionRepository { constructor( private readonly httpClient: HttpClient, private readonly authService: DiaBackendAuthService, - private readonly database: Database + private readonly database: Database, + private readonly ignoredTransactionRepository: IgnoredTransactionRepository ) {} getAll$(): Observable { @@ -103,6 +112,24 @@ export class DiaBackendTransactionRepository { switchTapTo(this.fetchAll$()) ); } + + getInbox$() { + return combineLatest([ + this.getAll$(), + this.ignoredTransactionRepository.getAll$(), + this.authService.getEmail$(), + ]).pipe( + map(([transactions, ignoredTransactions, email]) => + transactions.filter( + transaction => + transaction.receiver_email === email && + !transaction.fulfilled_at && + !transaction.expired && + !ignoredTransactions.includes(transaction.id) + ) + ) + ); + } } export interface DiaBackendTransaction extends Tuple { From 9c8f65ee6519829864077a0de5cf9d1f28868e69 Mon Sep 17 00:00:00 2001 From: Sean Wu Date: Wed, 9 Dec 2020 23:09:06 +0800 Subject: [PATCH 23/23] Remove all polling methods in home page component with repository pattern. --- .../dia-backend-notification.service.ts | 25 ++++++++++++------- .../push-notification.service.ts | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts index 528383287..80229013c 100644 --- a/src/app/services/dia-backend/notification/dia-backend-notification.service.ts +++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts @@ -1,9 +1,11 @@ import { Injectable } from '@angular/core'; import { TranslocoService } from '@ngneat/transloco'; -import { EMPTY, Observable } from 'rxjs'; +import { defer, EMPTY, Observable } from 'rxjs'; import { concatMap, filter } from 'rxjs/operators'; +import { switchTapTo } from '../../../utils/rx-operators/rx-operators'; import { NotificationService } from '../../notification/notification.service'; import { PushNotificationService } from '../../push-notification/push-notification.service'; +import { DiaBackendTransactionRepository } from '../transaction/dia-backend-transaction-repository.service'; @Injectable({ providedIn: 'root', @@ -11,6 +13,7 @@ import { PushNotificationService } from '../../push-notification/push-notificati export class DiaBackendNotificationService { constructor( private readonly pushNotificationService: PushNotificationService, + private readonly transactionRepository: DiaBackendTransactionRepository, private readonly notificationService: NotificationService, private readonly translocoService: TranslocoService ) {} @@ -20,16 +23,20 @@ export class DiaBackendNotificationService { isDiaBackendPushNotificationData(), concatMap(data => { if (data.app_message_type === 'transaction_received') { - return this.notificationService.notify( - this.translocoService.translate('transactionReceived'), - this.translocoService.translate('message.transactionReceived') - ); + return defer(() => + this.notificationService.notify( + this.translocoService.translate('transactionReceived'), + this.translocoService.translate('message.transactionReceived') + ) + ).pipe(switchTapTo(this.transactionRepository.getAll$())); } if (data.app_message_type === 'transaction_expired') { - return this.notificationService.notify( - this.translocoService.translate('transactionExpired'), - this.translocoService.translate('message.transactionExpired') - ); + return defer(() => + this.notificationService.notify( + this.translocoService.translate('transactionExpired'), + this.translocoService.translate('message.transactionExpired') + ) + ).pipe(switchTapTo(this.transactionRepository.getAll$())); } return EMPTY; }) diff --git a/src/app/services/push-notification/push-notification.service.ts b/src/app/services/push-notification/push-notification.service.ts index 298806108..7e1461573 100644 --- a/src/app/services/push-notification/push-notification.service.ts +++ b/src/app/services/push-notification/push-notification.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { Capacitor, PushNotificationsPlugin } from '@capacitor/core'; import { BehaviorSubject, Subject } from 'rxjs'; import { PUSH_NOTIFICATIONS_PLUGIN } from '../../shared/capacitor-plugins/capacitor-plugins.module'; -import { isNonNullable } from '../../utils/rx-operators'; +import { isNonNullable } from '../../utils/rx-operators/rx-operators'; @Injectable({ providedIn: 'root',