-
{{ (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/activity/transaction-details/transaction-details.page.scss b/src/app/pages/home/transaction/transaction-details/transaction-details.page.scss
similarity index 100%
rename from src/app/pages/home/activity/transaction-details/transaction-details.page.scss
rename to src/app/pages/home/transaction/transaction-details/transaction-details.page.scss
diff --git a/src/app/pages/home/activity/transaction-details/transaction-details.page.spec.ts b/src/app/pages/home/transaction/transaction-details/transaction-details.page.spec.ts
similarity index 100%
rename from src/app/pages/home/activity/transaction-details/transaction-details.page.spec.ts
rename to src/app/pages/home/transaction/transaction-details/transaction-details.page.spec.ts
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
new file mode 100644
index 000000000..9f35e5c4d
--- /dev/null
+++ b/src/app/pages/home/transaction/transaction-details/transaction-details.page.ts
@@ -0,0 +1,63 @@
+import { Component } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { UntilDestroy } from '@ngneat/until-destroy';
+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',
+ templateUrl: './transaction-details.page.html',
+ styleUrls: ['./transaction-details.page.scss'],
+})
+export class TransactionDetailsPage {
+ 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,
+ private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository,
+ private readonly diaBackendAuthService: DiaBackendAuthService
+ ) {}
+}
+
+export async function getStatus(
+ transaction: DiaBackendTransaction,
+ email: string | Promise
+) {
+ const resolvedEmail = await email;
+ if (transaction.expired) {
+ return Status.Returned;
+ }
+ if (!transaction.fulfilled_at) {
+ if (transaction.receiver_email === resolvedEmail) {
+ return Status.InProgress;
+ }
+ return Status.waitingToBeAccepted;
+ }
+ if (transaction.sender === resolvedEmail) {
+ 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/activity/activity-routing.module.ts b/src/app/pages/home/transaction/transaction-routing.module.ts
similarity index 76%
rename from src/app/pages/home/activity/activity-routing.module.ts
rename to src/app/pages/home/transaction/transaction-routing.module.ts
index aa64348a2..6d56d80bc 100644
--- a/src/app/pages/home/activity/activity-routing.module.ts
+++ b/src/app/pages/home/transaction/transaction-routing.module.ts
@@ -1,11 +1,11 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
-import { ActivityPage } from './activity.page';
+import { TransactionPage } from './transaction.page';
const routes: Routes = [
{
path: '',
- component: ActivityPage,
+ component: TransactionPage,
},
{
path: 'transaction-details',
@@ -20,4 +20,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
-export class ActivityPageRoutingModule {}
+export class TransactionPageRoutingModule {}
diff --git a/src/app/pages/home/transaction/transaction.module.ts b/src/app/pages/home/transaction/transaction.module.ts
new file mode 100644
index 000000000..ca0b8d4f8
--- /dev/null
+++ b/src/app/pages/home/transaction/transaction.module.ts
@@ -0,0 +1,10 @@
+import { NgModule } from '@angular/core';
+import { SharedModule } from '../../../shared/shared.module';
+import { TransactionPageRoutingModule } from './transaction-routing.module';
+import { TransactionPage } from './transaction.page';
+
+@NgModule({
+ imports: [SharedModule, TransactionPageRoutingModule],
+ declarations: [TransactionPage],
+})
+export class TransactionPageModule {}
diff --git a/src/app/pages/home/transaction/transaction.page.html b/src/app/pages/home/transaction/transaction.page.html
new file mode 100644
index 000000000..8a8f74d1c
--- /dev/null
+++ b/src/app/pages/home/transaction/transaction.page.html
@@ -0,0 +1,39 @@
+
+
+ arrow_back
+
+ {{ t('transaction') }}
+
+
+
+
+
+
+
+
+ {{ transaction.asset.id }}
+ {{ transaction.created_at | date: 'short' }}
+
+ {{ t(transaction.status | async) }}
+
+
+
+
+
+
diff --git a/src/app/pages/home/activity/activity.page.scss b/src/app/pages/home/transaction/transaction.page.scss
similarity index 95%
rename from src/app/pages/home/activity/activity.page.scss
rename to src/app/pages/home/transaction/transaction.page.scss
index dcfee7d88..9286331e9 100644
--- a/src/app/pages/home/activity/activity.page.scss
+++ b/src/app/pages/home/transaction/transaction.page.scss
@@ -12,6 +12,10 @@ mat-toolbar {
}
.page-content {
+ mat-spinner {
+ margin: 36px auto 0;
+ }
+
mat-list-item {
height: initial;
padding-top: 16px;
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/transaction/transaction.page.ts b/src/app/pages/home/transaction/transaction.page.ts
new file mode 100644
index 000000000..04d75d867
--- /dev/null
+++ b/src/app/pages/home/transaction/transaction.page.ts
@@ -0,0 +1,31 @@
+import { Component } from '@angular/core';
+import { UntilDestroy } from '@ngneat/until-destroy';
+import { map } from 'rxjs/operators';
+import { DiaBackendAuthService } from '../../../services/dia-backend/auth/dia-backend-auth.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({
+ selector: 'app-transaction',
+ templateUrl: './transaction.page.html',
+ styleUrls: ['./transaction.page.scss'],
+})
+export class TransactionPage {
+ readonly transactionsWithStatus$ = this.diaBackendTransactionRepository
+ .getAll$()
+ .pipe(
+ map(transactions =>
+ transactions.map(transaction => ({
+ ...transaction,
+ status: getStatus(transaction, this.diaBackendAuthService.getEmail()),
+ }))
+ )
+ );
+ readonly isFetching$ = this.diaBackendTransactionRepository.isFetching$();
+
+ constructor(
+ private readonly diaBackendAuthService: DiaBackendAuthService,
+ private readonly diaBackendTransactionRepository: DiaBackendTransactionRepository
+ ) {}
+}
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..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,16 +5,15 @@ 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,
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,12 @@ export class DiaBackendAssetRepository {
}
// TODO: use repository to remove this method.
- async addAssetDirectly(asset: DiaBackendAsset) {
- return this.table.insert([asset]);
+ 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.
@@ -112,8 +115,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 +127,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/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/dia-backend/contact/dia-backend-contact-repository.service.ts b/src/app/services/dia-backend/contact/dia-backend-contact-repository.service.ts
index 7b9da020e..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,
@@ -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,
@@ -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/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..80229013c
--- /dev/null
+++ b/src/app/services/dia-backend/notification/dia-backend-notification.service.ts
@@ -0,0 +1,63 @@
+import { Injectable } from '@angular/core';
+import { TranslocoService } from '@ngneat/transloco';
+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',
+})
+export class DiaBackendNotificationService {
+ constructor(
+ private readonly pushNotificationService: PushNotificationService,
+ private readonly transactionRepository: DiaBackendTransactionRepository,
+ 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 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 defer(() =>
+ this.notificationService.notify(
+ this.translocoService.translate('transactionExpired'),
+ this.translocoService.translate('message.transactionExpired')
+ )
+ ).pipe(switchTapTo(this.transactionRepository.getAll$()));
+ }
+ return EMPTY;
+ })
+ );
+ }
+}
+
+interface PushNotificationData {
+ readonly app_message_type:
+ | 'transaction_received'
+ | 'transaction_accepted'
+ | 'transaction_expired';
+ readonly 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/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..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,27 +1,89 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
-import { defer } from 'rxjs';
-import { concatMap } from 'rxjs/operators';
+import { isEqual, omit } from 'lodash';
+import {
+ BehaviorSubject,
+ combineLatest,
+ 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';
import { BASE_URL } from '../secret';
+import { IgnoredTransactionRepository } from './ignored-transaction-repository.service';
@Injectable({
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,
+ private readonly ignoredTransactionRepository: IgnoredTransactionRepository
) {}
- getAll$() {
- return defer(() => this.authService.getAuthHeaders()).pipe(
+ getAll$(): Observable {
+ return merge(this.fetchAll$(), this.table.queryAll$()).pipe(
+ distinctUntilChanged((transactionsX, transactionsY) =>
+ isEqual(
+ transactionsX.map(x => omit(x, 'asset.asset_file_thumbnail')),
+ transactionsY.map(y => omit(y, 'asset.asset_file_thumbnail'))
+ )
+ )
+ );
+ }
+
+ getById$(id: string) {
+ return this.getAll$().pipe(
+ map(transactions =>
+ transactions.find(transaction => transaction.id === id)
+ )
+ );
+ }
+
+ 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))
);
}
@@ -33,7 +95,8 @@ export class DiaBackendTransactionRepository {
{ asset_id: assetId, email: targetEmail, caption },
{ headers }
)
- )
+ ),
+ switchTap(response => defer(() => this.table.insert([response])))
);
}
@@ -45,12 +108,31 @@ export class DiaBackendTransactionRepository {
{},
{ headers }
)
+ ),
+ 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 {
+export interface DiaBackendTransaction extends Tuple {
readonly id: string;
readonly asset: {
readonly id: string;
@@ -60,7 +142,7 @@ export interface DiaBackendTransaction {
readonly sender: string;
readonly receiver_email: string;
readonly created_at: string;
- readonly fulfilled_at: string;
+ readonly fulfilled_at: string | null;
readonly expired: boolean;
}
@@ -69,7 +151,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/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) {
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..7e1461573 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/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/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.
*/
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;
}
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 => {
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))));
+}
diff --git a/src/assets/i18n/en-us.json b/src/assets/i18n/en-us.json
index 2d0bc314c..ca328aafc 100644
--- a/src/assets/i18n/en-us.json
+++ b/src/assets/i18n/en-us.json
@@ -67,12 +67,14 @@
"inbox": "Inbox",
"ignore": "Ignore",
"accept": "Accept",
- "activity": "Activity",
+ "transaction": "Activity",
"transactionDetails": "Activity Details",
"sentFrom": "Sent from",
"receiver": "Receiver",
"location": "Location",
"locationNotProvided": "Location Not Provided",
+ "transactionReceived": "New PostCapture Received",
+ "transactionExpired": "PostCapture Returned",
"dismiss": "Dismiss",
".message": "Message",
"message": {
@@ -96,6 +98,8 @@
"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?",
+ "transactionReceived": "A new PostCapture has received.",
+ "transactionExpired": "A PostCapture you delivered has been returned.",
"viewAll": "open more..."
},
"error": {
diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json
index 077d03733..60eb33dba 100644
--- a/src/assets/i18n/zh-tw.json
+++ b/src/assets/i18n/zh-tw.json
@@ -67,12 +67,14 @@
"inbox": "收件匣",
"ignore": "忽略",
"accept": "接收",
- "activity": "活動紀錄",
+ "transaction": "活動紀錄",
"transactionDetails": "活動資訊",
"sentFrom": "發送自",
"receiver": "收件人",
"location": "位置",
"locationNotProvided": "沒有位置資訊",
+ "transactionReceived": "收到新 PostCapture",
+ "transactionExpired": "PostCapture 被退回",
"dismiss": "關閉",
".message": "訊息",
"message": {
@@ -96,6 +98,8 @@
"invitationEmail": "輸入好友電子信箱發送邀請。",
"clickingSignupToAgreePolicy": "點擊「註冊」即表示你同意我們的《服務條款》和《資料政策》。",
"sendPostCaptureAlert": "PostCapture 送出後,它的所有權將轉移至選定的朋友。您確定嗎?",
+ "transactionReceived": "收到了新的 PostCapture。",
+ "transactionExpired": "您寄出的 PostCapture 已被退回。",
"viewAll": "檢視全部"
},
"error": {