diff --git a/src/app/account/account.model.ts b/src/app/account/account.model.ts index 25c45ce3590dc09fc88470751c970f83cc0801b5..dbacaf0139935b01d4d770c1b293addd1366d72e 100644 --- a/src/app/account/account.model.ts +++ b/src/app/account/account.model.ts @@ -2,7 +2,7 @@ import { HexString } from '@polkadot/util/types'; import { ListItem } from '@app/shared/popover/list.popover'; import { formatAddress } from '@app/shared/currencies'; import { IdentityStatusEnum } from '@app/network/indexer/indexer-types.generated'; -import { isEmptyArray } from '@app/shared/functions'; +import { isEmptyArray, isNotNilOrBlank } from '@app/shared/functions'; import { combineLoadResults, LoadResult } from '@app/shared/services/service.model'; export interface AddressSquid { @@ -19,6 +19,10 @@ export interface Account { data?: AccountData; } +export interface DerivationAccount extends Account { + derivation: string; +} + //export declare type IdentityStatus = 'CREATION'; export interface AccountMeta { @@ -76,6 +80,14 @@ export function parseAddressSquid(data: string): AddressSquid { } export class AccountUtils { + static isAccount(account: any): account is Account { + return account && isNotNilOrBlank(account.address); + } + + static isDerivationAccount(account: any): account is DerivationAccount { + return account && isNotNilOrBlank(account.address) && isNotNilOrBlank(account.derivation); + } + static getBalance(account: Partial<Account>): number { if (!account?.data) return undefined; // Data not loaded. This should be done by the account service return (account.data.free || 0) + (account.data.reserved || 0); diff --git a/src/app/account/accounts.service.ts b/src/app/account/accounts.service.ts index 06ac33c5641c9a4949873a8b941e5dd3e4e19766..cd2435f07786fe1a79f575c49756c3d93c984b06 100644 --- a/src/app/account/accounts.service.ts +++ b/src/app/account/accounts.service.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@angular/core'; import { NetworkService } from '../network/network.service'; import { ApiPromise } from '@polkadot/api'; -import { Account, AccountUtils, LoginOptions, SelectAccountOptions } from './account.model'; +import { Account, AccountUtils, DerivationAccount, LoginOptions, SelectAccountOptions } from './account.model'; import { keyring } from '@polkadot/ui-keyring'; import { environment } from '@environments/environment'; import { KeyringStorage } from '@app/shared/services/storage/keyring-storage'; -import { base58Encode, cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto'; +import { base58Encode, cryptoWaitReady, encodeAddress, mnemonicGenerate } from '@polkadot/util-crypto'; import { firstArrayValue, isEmptyArray, @@ -314,6 +314,16 @@ export class AccountsService extends RxStartableService<AccountsState> { return mnemonicGenerate(numWords); } + generateAddress(mnemonicWithDerivation: string): string { + try { + const pair = keyring.createFromUri(mnemonicWithDerivation, null, 'sr25519'); + return encodeAddress(pair.address); + } catch (error) { + console.error('Error generating address from mnemonic:', error); + return ''; + } + } + async createPair(data: AuthData): Promise<KeyringPair> { if (!this._keyringInitialized) await this.ready(); @@ -732,6 +742,32 @@ export class AccountsService extends RxStartableService<AccountsState> { // TODO process status } + public async getBalance(addressOrAccount: string | Account): Promise<number> { + if (!this.started) await this.ready(); + + const account = typeof addressOrAccount === 'string' ? { address: addressOrAccount } : (addressOrAccount as Account); + await this.loadData(account, { withBalance: true }); + return AccountUtils.getBalance(account); + } + + async scanDerivations(mnemonic: string, numberOfDerivations = 30): Promise<DerivationAccount[]> { + if (isNilOrBlank(mnemonic)) throw new Error("Missing argument 'mnemonic'"); + if (numberOfDerivations <= 0) throw new Error("Invalid argument 'numberOfDerivations'"); + const derivationAccounts: DerivationAccount[] = []; + + for (let i = -1; i <= numberOfDerivations; i++) { + const derivationPath = i === -1 ? '' : `//${i}`; + const address = this.generateAddress(`${mnemonic}${derivationPath}`); + const shortAddress = formatAddress(address); + derivationAccounts.push({ derivation: derivationPath, address: shortAddress }); + } + // Load balances data (e.g. balande + await Promise.all(derivationAccounts.map((account) => this.loadData(account, { withBalance: true }))); + + // Filter to exclude balance === 0 + return derivationAccounts.filter((account) => AccountUtils.getBalance(account) > 0); + } + /** * Load account data (balance, tx history, etc.). * This load can be skipped, when data already loaded (See options) diff --git a/src/app/account/auth/auth.modal.ts b/src/app/account/auth/auth.modal.ts index e2d73a05dc71bb831170b4d0828a4ed6aae5880a..3117b1bdcfa062d5caf41e8f2ab5cf16ded3d719 100644 --- a/src/app/account/auth/auth.modal.ts +++ b/src/app/account/auth/auth.modal.ts @@ -5,10 +5,11 @@ import { firstNotNilPromise } from '@app/shared/observables'; import { APP_AUTH_CONTROLLER, AuthData, IAuthController } from '@app/account/auth/auth.model'; import { AppEvent } from '@app/shared/types'; -import { LoginMethodType } from '@app/account/account.model'; +import { AccountUtils, DerivationAccount, LoginMethodType } from '@app/account/account.model'; import { SettingsService } from '@app/settings/settings.service'; import { AppForm } from '@app/shared/form.class'; import { toBoolean } from '@app/shared/functions'; +import { DerivationSelectionComponent } from '@app/account/auth/derivation-selection/derivation-selection.component'; export interface AuthModalOptions { auth?: boolean; @@ -80,6 +81,43 @@ export class AuthModal implements OnInit, AuthModalOptions { // Disable the form this.form.disable(); + if (data.v2.mnemonic.includes('//')) { + const account = await this.accountService.addAccount(data); + return this.modalCtrl.dismiss(account, <AuthModalRole>'VALIDATE'); + } + + // Scan for derivations + const derivations = await this.accountService.scanDerivations(data.v2.mnemonic); + + // Only one derivation: use it + let selectedDerivationAccount: DerivationAccount; + if (derivations?.length === 1) { + selectedDerivationAccount = derivations[0]; + } + + // Many derivation: let the user choose + else if (derivations?.length > 1) { + const modal = await this.modalCtrl.create({ + component: DerivationSelectionComponent, + componentProps: { + mnemonic: data.v2.mnemonic, + derivations, + }, + }); + + await modal.present(); + const res = await modal.onDidDismiss(); + if (AccountUtils.isDerivationAccount(res?.data)) { + selectedDerivationAccount = res.data; + } + } + + // Update mnemonic, to use the derivation + if (selectedDerivationAccount) { + console.info(`[auth] Will use derivation: ${selectedDerivationAccount?.derivation}`); + data.v2.mnemonic += selectedDerivationAccount?.derivation; + } + const account = await this.accountService.addAccount(data); return this.modalCtrl.dismiss(account, <AuthModalRole>'VALIDATE'); @@ -100,6 +138,10 @@ export class AuthModal implements OnInit, AuthModalOptions { } } + get value(): AuthData { + return this.form.value; + } + async changeAuthMethod(event: AppEvent) { const loginMethod = await this.authController.selectLoginMethod(event, { auth: this.auth }); if (!loginMethod) return; // Cancelled diff --git a/src/app/account/auth/auth.module.ts b/src/app/account/auth/auth.module.ts index 8027acbabdc38aaa379b3fa99f87c92fc81713cb..afd9653808000abdbc869c71e21f422fba9c80b1 100644 --- a/src/app/account/auth/auth.module.ts +++ b/src/app/account/auth/auth.module.ts @@ -3,10 +3,11 @@ import { AuthForm } from './auth.form'; import { AuthModal } from './auth.modal'; import { AppSharedModule } from '@app/shared/shared.module'; import { TranslateModule } from '@ngx-translate/core'; -import { MmnemonicForm } from './mnemonic/mnemonic.form'; +import { MnemonicForm } from './mnemonic/mnemonic.form'; import { AppRegisterModule } from '@app/account/register/register.module'; import { PubkeyForm } from '@app/account/auth/pubkey/pubkey.form'; import { AddressForm } from '@app/account/auth/address/address.form'; +import { DerivationSelectionComponent } from '@app/account/auth/derivation-selection/derivation-selection.component'; @NgModule({ imports: [ @@ -14,7 +15,7 @@ import { AddressForm } from '@app/account/auth/address/address.form'; AppSharedModule, AppRegisterModule, ], - declarations: [AuthForm, AuthModal, MmnemonicForm, PubkeyForm, AddressForm], - exports: [AuthForm, AuthModal, MmnemonicForm, PubkeyForm, AddressForm, TranslateModule], + declarations: [AuthForm, AuthModal, MnemonicForm, PubkeyForm, AddressForm, DerivationSelectionComponent], + exports: [AuthForm, AuthModal, MnemonicForm, PubkeyForm, AddressForm, DerivationSelectionComponent, TranslateModule], }) export class AppAuthModule {} diff --git a/src/app/account/auth/derivation-selection/derivation-selection.component.html b/src/app/account/auth/derivation-selection/derivation-selection.component.html new file mode 100644 index 0000000000000000000000000000000000000000..bf3359dc9582fc26b7ecbe45fc27dce9017f1221 --- /dev/null +++ b/src/app/account/auth/derivation-selection/derivation-selection.component.html @@ -0,0 +1,61 @@ +<ion-header> + <ion-toolbar> + <ion-title translate>AUTH.SELECT_DERIVATION.TITLE</ion-title> + </ion-toolbar> +</ion-header> + +<ion-content> + @if (loading) { + <ion-item lines="none"> + <label>LOGIN.SCAN_DERIVATIONS</label> + </ion-item> + } @else { + <ion-list> + <ion-radio-group [(ngModel)]="selectedDerivation" (ngModelChange)="onSelectionChange()"> + <ion-list-header> + <label translate>AUTH.SELECT_DERIVATION.AVAILABLE_WALLETS</label> + </ion-list-header> + @for (account of derivations; track account.address) { + <ion-item (click)="selectDerivation(account)" tappable [class.selected]="selectedDerivation === account"> + <ion-icon aria-hidden="true" slot="start" name="key"></ion-icon> + <ion-label> + <h2 translate>COMMON.ADDRESS</h2> + <p class="ion-text-nowrap"> + <span>{{ account.address | addressFormat }}</span> + </p> + </ion-label> + <ion-badge slot="end" color="light"> + {{ account | balance | amountFormat }} + </ion-badge> + </ion-item> + } + </ion-radio-group> + </ion-list> + } +</ion-content> + +<!-- TODO: Consider using common form buttons used in the rest of the app for consistency, reusability and responsiveness --> +<ion-footer> + <ion-toolbar> + <ion-row class="ion-no-padding"> + <ion-col></ion-col> + + <!-- buttons --> + <ion-col size="auto"> + <ion-button fill="clear" color="dark" (click)="onCancel()"> + <ion-label translate>COMMON.BTN_CANCEL</ion-label> + </ion-button> + + <ion-button + [fill]="invalid ? 'clear' : 'solid'" + [disabled]="!selectedDerivation" + (click)="doSubmit()" + (keyup.enter)="doSubmit()" + color="tertiary" + > + <ion-label translate>COMMON.BTN_CONTINUE</ion-label> + </ion-button> + </ion-col> + </ion-row> + </ion-toolbar> +</ion-footer> diff --git a/src/app/account/auth/derivation-selection/derivation-selection.component.scss b/src/app/account/auth/derivation-selection/derivation-selection.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..12b21d35167911adab0c26336cd7e5be03959488 --- /dev/null +++ b/src/app/account/auth/derivation-selection/derivation-selection.component.scss @@ -0,0 +1,6 @@ + +ion-list { + ion-item.selected { + --background: rgba(var(--ion-color-primary-rgb), 0.14); + } +} diff --git a/src/app/account/auth/derivation-selection/derivation-selection.component.ts b/src/app/account/auth/derivation-selection/derivation-selection.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bb9debc5962f032d0715fd251b616aaafcddda7 --- /dev/null +++ b/src/app/account/auth/derivation-selection/derivation-selection.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { AccountsService } from '@app/account/accounts.service'; +import { ModalController } from '@ionic/angular'; +import { DerivationAccount } from '@app/account/account.model'; +import { isNotEmptyArray } from '@app/shared/functions'; + +@Component({ + selector: 'app-derivation-selection', + templateUrl: 'derivation-selection.component.html', + styleUrls: ['./derivation-selection.component.scss'], +}) +export class DerivationSelectionComponent implements OnInit { + @Input() mnemonic: string; + @Input() derivations: DerivationAccount[]; + + selectedDerivation: DerivationAccount; + loading: boolean = true; + + get invalid(): boolean { + return this.selectedDerivation == null; + } + + constructor( + private accountService: AccountsService, + private cd: ChangeDetectorRef, + private modalCtrl: ModalController + ) {} + + ngOnInit() { + if (isNotEmptyArray(this.derivations)) { + this.loading = false; + } else if (this.mnemonic) { + this.scanDerivations(); + } + } + + async scanDerivations() { + this.loading = true; + this.cd.detectChanges(); + + try { + this.derivations = await this.accountService.scanDerivations(this.mnemonic); + } finally { + this.loading = false; + this.cd.detectChanges(); + } + } + + selectDerivation(derivation: DerivationAccount) { + this.selectedDerivation = derivation; + this.cd.detectChanges(); + } + + onSelectionChange() { + this.cd.detectChanges(); + } + + async doSubmit() { + if (this.selectedDerivation) { + await this.modalCtrl.dismiss(this.selectedDerivation); + } + } + + async onCancel() { + await this.modalCtrl.dismiss(); + } +} diff --git a/src/app/account/auth/mnemonic/mnemonic.form.html b/src/app/account/auth/mnemonic/mnemonic.form.html index 88ac499480b2e82c80d5e03efeca88e965bca042..eede827ab9de52ae38a905dec22308f7f90654c0 100644 --- a/src/app/account/auth/mnemonic/mnemonic.form.html +++ b/src/app/account/auth/mnemonic/mnemonic.form.html @@ -7,16 +7,16 @@ </ion-item> <!-- Mnemonic --> - <ion-item> - <ion-textarea - *ngIf="showMnemonic; else hideMnemonic" - formControlName="mnemonic" - [label]="'LOGIN.MNEMONIC' | translate" - labelPlacement="floating" - autocomplete="off" - required - ></ion-textarea> - <ng-template #hideMnemonic> + <ion-item [class.invalid]="form.get('mnemonic').invalid && form.get('mnemonic').touched"> + @if (showMnemonic) { + <ion-textarea + formControlName="mnemonic" + [label]="'LOGIN.MNEMONIC' | translate" + labelPlacement="floating" + autocomplete="off" + required + ></ion-textarea> + } @else { <ion-textarea formControlName="mnemonic" type="password" @@ -25,7 +25,7 @@ autocomplete="off" required ></ion-textarea> - </ng-template> + } <!-- show/hide button --> <ion-button slot="end" (click)="toggleShowMnemonic($event)" fill="clear" color="medium" [tabindex]="-1"> @@ -34,8 +34,8 @@ </ion-item> <!-- Address --> - <ion-item *ngIf="address; let address" @slideUpDownAnimation> - <ion-textarea [value]="address" [label]="'COMMON.ADDRESS' | translate" labelPlacement="floating" [disabled]="true"></ion-textarea> + <ion-item *ngIf="generatedAddress" @slideUpDownAnimation> + <ion-textarea [value]="generatedAddress" [label]="'COMMON.ADDRESS' | translate" labelPlacement="floating" [disabled]="true"></ion-textarea> </ion-item> </ion-list> diff --git a/src/app/account/auth/mnemonic/mnemonic.form.ts b/src/app/account/auth/mnemonic/mnemonic.form.ts index 119b4d0379a574ef3288b043f6e09d18f3eb83bd..5ef065b9680b2b5df8c99724ad44ebda11129a85 100644 --- a/src/app/account/auth/mnemonic/mnemonic.form.ts +++ b/src/app/account/auth/mnemonic/mnemonic.form.ts @@ -5,9 +5,12 @@ import { AppForm } from '@app/shared/form.class'; import { SettingsService } from '@app/settings/settings.service'; import { environment } from '@environments/environment'; import { FormUtils } from '@app/shared/forms'; -import { isNil } from '@app/shared/functions'; import { setTimeout } from '@rx-angular/cdk/zone-less/browser'; import { AuthData } from '@app/account/auth/auth.model'; +import { debounceTime } from 'rxjs/operators'; +import { AccountsService } from '@app/account/accounts.service'; +import { formatAddress } from '@app/shared/currencies'; +import { SharedValidators } from '@app/shared/form/form-validators'; @Component({ selector: 'app-mnemonic-form', @@ -15,18 +18,36 @@ import { AuthData } from '@app/account/auth/auth.model'; animations: [slideUpDownAnimation], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MmnemonicForm extends AppForm<AuthData> implements OnInit { +export class MnemonicForm extends AppForm<AuthData> implements OnInit { protected showMnemonic = false; + protected generatedAddress: string = ''; - constructor(settings: SettingsService, formBuilder: FormBuilder) { + constructor( + settings: SettingsService, + formBuilder: FormBuilder, + private accountService: AccountsService + ) { super( formBuilder.group({ - mnemonic: [null, Validators.required], + mnemonic: [null, [Validators.required, SharedValidators.mnemonic]], }) ); this.mobile = settings.mobile; this._enable = true; + + this.form + .get('mnemonic') + .valueChanges.pipe(debounceTime(300)) + .subscribe(() => { + const mnemonicControl = this.form.get('mnemonic'); + if (mnemonicControl.valid) { + this.generatedAddress = formatAddress(this.accountService.generateAddress(mnemonicControl.value)); + } else { + this.generatedAddress = ''; + } + this.markForCheck(); + }); } disable(opts?: { onlySelf?: boolean; emitEvent?: boolean }) { @@ -82,12 +103,12 @@ export class MmnemonicForm extends AppForm<AuthData> implements OnInit { // get address corresponding to form input get address(): string { - const data = this.form.value; + const data = this.form.value?.trim(); // prevent displaying for empty credentials - if (isNil(data.mnemonic)) { + if (!data.mnemonic) { return ''; } - return ''; + return this.accountService.generateAddress(data.mnemonic); } /* -- protected functions -- */ diff --git a/src/app/account/image/account-image.component.html b/src/app/account/image/account-image.component.html index 6dc41645ae6f1e20ab132f7da061836ba6fce8f5..2b4078444e19f0836cec9707d186fbd03a6518b2 100644 --- a/src/app/account/image/account-image.component.html +++ b/src/app/account/image/account-image.component.html @@ -1,7 +1,7 @@ -@if (account) { +@if (account?.address) { @if (account | accountAvatar; as avatar) { - <ion-img [src]="avatar"></ion-img> + <ion-img [src]="avatar" (ionImgDidLoad)="onImageLoaded(avatar)"></ion-img> } @else { - <svg width="40" height="40" [data-jdenticon-value]="account.address"></svg> + <svg width="38" height="38" [data-jdenticon-value]="account.address"></svg> } } diff --git a/src/app/account/image/account-image.component.scss b/src/app/account/image/account-image.component.scss index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..6593b1964e5380b0d103ad7a6ba77f5dfeef4bd5 100644 --- a/src/app/account/image/account-image.component.scss +++ b/src/app/account/image/account-image.component.scss @@ -0,0 +1,15 @@ +:host { + display: block; + + ion-img { + opacity: 0; + transition: opacity 0.3s ease-in-out; + } + + &.loaded { + ion-img { + opacity: 1 !important; + } + } +} + diff --git a/src/app/account/image/account-image.component.ts b/src/app/account/image/account-image.component.ts index 329151eb044c5d33756f81ba693a3a2c0f1510c4..aaeb09ffee12c6e7f914c4de6a7d007a87374bd4 100644 --- a/src/app/account/image/account-image.component.ts +++ b/src/app/account/image/account-image.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, Renderer2 } from '@angular/core'; import { Account } from '@app/account/account.model'; @Component({ @@ -10,5 +10,15 @@ import { Account } from '@app/account/account.model'; export class AccountImageComponent { @Input() account: Account; - constructor() {} + constructor( + private renderer: Renderer2, + private el: ElementRef, + private _cd: ChangeDetectorRef + ) {} + + onImageLoaded(avatar: string) { + console.debug('[app-account-image] onImageLoaded ', avatar); + this.renderer.addClass(this.el.nativeElement, 'loaded'); + this._cd.markForCheck(); + } } diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index 792edb98ac9cf5c3fce7a839af6851cf12e1aaa2..6b532d86628ebedb97726724ad27dd3a4fc0fc91 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -4,6 +4,7 @@ <ion-menu-button></ion-menu-button> </ion-buttons> <ion-buttons slot="end"> + <ng-container [ngTemplateOutlet]="darkModeButton"></ng-container> <ng-container [ngTemplateOutlet]="localeButton"></ng-container> </ion-buttons> </ion-toolbar> @@ -22,6 +23,7 @@ </ion-buttons> <ion-buttons slot="end"> + <ng-container *ngTemplateOutlet="darkModeButton; context: { $implicit: 'light' }"></ng-container> <ng-container *ngTemplateOutlet="localeButton; context: { $implicit: 'light' }"></ng-container> </ion-buttons> </ion-toolbar> @@ -102,3 +104,11 @@ </ng-template> </ion-popover> </ng-template> + +<ng-template #darkModeButton let-buttonColor> + <ion-button (click)="toggleDarkMode()" + [color]="buttonColor" + [title]="'SETTINGS.BTN_DARK_MODE' | translate"> + <ion-icon slot="icon-only" [name]="settings.darkMode ? 'sunny' : 'moon'"></ion-icon> + </ion-button> +</ng-template> diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 7bf388a4e37a93a5c38177fa1f719e168ac052ce..8fb25a686176bf6e7695aa9ea91344053b1eef7b 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; import { APP_LOCALES, LocaleConfig, Settings } from '@app/settings/settings.model'; import { AppPage, AppPageState } from '@app/shared/pages/base-page.class'; import { NetworkService } from '@app/network/network.service'; @@ -45,6 +45,7 @@ export class HomePage extends AppPage<HomePageState> implements OnInit { protected authController: AuthController, protected transferController: TransferController, protected router: Router, + private cd: ChangeDetectorRef, @Inject(APP_LOCALES) public locales: LocaleConfig[] ) { super({ name: 'home' }); @@ -122,4 +123,8 @@ export class HomePage extends AppPage<HomePageState> implements OnInit { transfer() { return this.transferController.transfer(); } + + toggleDarkMode() { + this.settings.patchValue({ darkMode: !this.settings.darkMode}); + } } diff --git a/src/app/network/network.service.ts b/src/app/network/network.service.ts index dc844e40f6b7974397b9e8d8921aa7a103614c4d..a2fcb75c5c2ce6c2cb18b1fd1151faa1a40531f1 100644 --- a/src/app/network/network.service.ts +++ b/src/app/network/network.service.ts @@ -6,9 +6,9 @@ import { abbreviate, WELL_KNOWN_CURRENCIES } from '@app/shared/currencies'; import { Currency } from '../currency/currency.model'; import { RxStartableService } from '@app/shared/services/rx-startable-service.class'; import { RxStateProperty, RxStateSelect } from '@app/shared/decorator/state.decorator'; -import { mergeMap, Observable, tap } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; -import { arrayRandomPick, isNotNil, isNotNilOrBlank, toNumber } from '@app/shared/functions'; +import { EMPTY, firstValueFrom, mergeMap, Observable, of, race, tap } from 'rxjs'; +import { catchError, filter, map, timeout } from 'rxjs/operators'; +import { arrayRandomPick, isNotNilOrBlank, toNumber } from '@app/shared/functions'; import { IndexerService } from './indexer/indexer.service'; import { fromDateISOString } from '@app/shared/dates'; import { ContextService } from '@app/shared/services/storage/context.service'; @@ -169,29 +169,54 @@ export class NetworkService extends RxStartableService<NetworkState> { return super.ngOnStop(); } - protected async filterAlivePeers( - peers: string[], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - opts?: { - timeout?: number; + protected async filterAlivePeers(peers: string[]): Promise<Peer[]> { + const peerObjects = peers.map((peer) => Peers.fromUri(peer)); + + try { + // Try to get the first responding peer + const firstPeer = await firstValueFrom(race(peerObjects.map((peer) => this.isPeerAlive(peer)))); + return [firstPeer]; + } catch { + // If no quick response, check all peers + console.warn(`${this._logPrefix}No quick response, checking all peers...`); + const results = await Promise.all(peerObjects.map((peer) => firstValueFrom(this.isPeerAlive(peer).pipe(catchError(() => of(undefined)))))); + return results.filter((peer): peer is Peer => !!peer); } - ): Promise<Peer[]> { - return ( - await Promise.all( - peers.map((peer) => Peers.fromUri(peer)).map((peer) => this.isPeerAlive(peer, opts).then((alive) => (alive ? peer : undefined))) - ) - ).filter(isNotNil); } - protected async isPeerAlive( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - peer: Peer, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - opts?: { - timeout?: number; - } - ): Promise<boolean> { - // TODO - return Promise.resolve(true); + protected isPeerAlive(peer: Peer): Observable<Peer> { + const timeoutDuration = 4000; + return new Observable<Peer>((subscriber) => { + const wsUri = Peers.getWsUri(peer); + const ws = new WebSocket(wsUri); + + ws.onopen = () => { + const healthRequest = { + id: 1, + jsonrpc: '2.0', + method: 'system_health', + params: [], + }; + ws.send(JSON.stringify(healthRequest)); + }; + + ws.onmessage = () => { + subscriber.next(peer); + subscriber.complete(); + ws.close(); + }; + + ws.onerror = () => { + subscriber.error(new Error('Connection error')); + ws.close(); + }; + + return () => { + ws.close(); + }; + }).pipe( + timeout(timeoutDuration), + catchError(() => EMPTY) // Return an empty Observable on error + ); } } diff --git a/src/app/shared/form/form-validators.ts b/src/app/shared/form/form-validators.ts index d8bc4ba1b1817a85de11b1242f9125d0c203655a..ed144c6c1316fb8e472ca8aa2160ee09e3e311aa 100644 --- a/src/app/shared/form/form-validators.ts +++ b/src/app/shared/form/form-validators.ts @@ -3,6 +3,7 @@ import { isMoment, Moment, unitOfTime } from 'moment'; import { ADDRESS_REGEXP, PUBKEY_REGEXP, UID_REGEXP } from '../constants'; import { isEmptyArray, isNil, isNilOrBlank, isNotNil, isNotNilOrBlank, toBoolean } from '../functions'; import { fromDateISOString } from '../dates'; +import { mnemonicValidate } from '@polkadot/util-crypto'; // @dynamic export class SharedValidators { @@ -25,6 +26,21 @@ export class SharedValidators { return this._DOUBLE_REGEXP_CACHE[maxDecimals]; } + static mnemonic(control: AbstractControl): ValidationErrors | null { + const value = control.value?.trim(); + + // Remove derivation part + const [mnemonic] = (value || '').split('//', 2); + + // Validate + if (isNilOrBlank(mnemonic) || !mnemonicValidate(mnemonic.trim())) { + return { mnemonic: true }; + } + + // OK: no error + return null; + } + static readonly I18N_ERROR_KEYS = { required: 'ERROR.FIELD_REQUIRED', min: 'ERROR.FIELD_MIN', @@ -47,6 +63,7 @@ export class SharedValidators { email: 'ERROR.FIELD_NOT_VALID_EMAIL', pattern: 'ERROR.FIELD_NOT_VALID_PATTERN', unique: 'ERROR.FIELD_NOT_UNIQUE', + mnemonic: 'ERROR.FIELD_NOT_VALID_MNEMONIC', }; static empty(control: UntypedFormControl): ValidationErrors | null { diff --git a/src/app/shared/pipes/account.pipes.ts b/src/app/shared/pipes/account.pipes.ts index 9e7ad3a71e2106f22712673d8c307810af22faf6..1c0e75b3dee5c497e41cbdc94ebd2e5e937d4087 100644 --- a/src/app/shared/pipes/account.pipes.ts +++ b/src/app/shared/pipes/account.pipes.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, inject, Injectable, Pipe, PipeTransform } from '@angular/core'; +import { ChangeDetectorRef, inject, Injectable, OnDestroy, Pipe, PipeTransform } from '@angular/core'; import { Account, AccountUtils } from '@app/account/account.model'; import { equals, getPropertyByPath } from '@app/shared/functions'; import { Subscription } from 'rxjs'; @@ -12,7 +12,8 @@ export interface AccountAbstractPipeOptions { * A common pipe, that will subscribe to all account changes, to refresh its value */ @Injectable() -export abstract class AccountAbstractPipe<T, O extends Object = AccountAbstractPipeOptions> implements PipeTransform { +export abstract class AccountAbstractPipe<T, O extends Object = AccountAbstractPipeOptions> + implements PipeTransform, OnDestroy { private value: T = null; private _lastAccount: Partial<Account> | null = null; private _lastOptions: O = null; @@ -33,7 +34,7 @@ export abstract class AccountAbstractPipe<T, O extends Object = AccountAbstractP } // if we ask another time for the same account and opts, return the last value - if (account === this._lastAccount && equals(opts, this._lastOptions)) { + if (equals(account, this._lastAccount) && equals(opts, this._lastOptions)) { return this.value; } diff --git a/src/app/transfer/history/transfer-history.page.html b/src/app/transfer/history/transfer-history.page.html index d66823d551b1fb3bb571441869b91fd76d0d30ed..8a866e00eb5262f9410c37e310e5d88015de00f2 100644 --- a/src/app/transfer/history/transfer-history.page.html +++ b/src/app/transfer/history/transfer-history.page.html @@ -23,7 +23,7 @@ <ion-header [translucent]="true"> <ion-item color="secondary" lines="none"> <ion-avatar slot="start" [style.background-color]="'white'"> - <app-account-image *rxIf="account$; let account" [account]="account"></app-account-image> + <app-account-image [account]="account$|push"></app-account-image> </ion-avatar> @if (account$ | push | isUserAccount) { diff --git a/src/app/wot/wot-details.page.html b/src/app/wot/wot-details.page.html index 1e20f914a19686a82666bf333350c35769494a1a..688edc77bc26bb0ddbdf95ec6c38f5a5c2b92c4b 100644 --- a/src/app/wot/wot-details.page.html +++ b/src/app/wot/wot-details.page.html @@ -18,7 +18,9 @@ <ion-header [translucent]="true"> <ion-item color="secondary" lines="none"> <ion-avatar slot="start" [style.background-color]="'white'"> - <app-account-image [account]="account$ | push"></app-account-image> + @if (account$ | push; as account) { + <app-account-image [account]="account"></app-account-image> + } </ion-avatar> <ion-label>{{ account$ | push | accountName }}</ion-label> diff --git a/src/assets/i18n/ca.json b/src/assets/i18n/ca.json index 018a690f19268798d2c687644f75c529d7e0892f..8ff034abebd1045c00300cca2166bcaab1bf717a 100644 --- a/src/assets/i18n/ca.json +++ b/src/assets/i18n/ca.json @@ -474,7 +474,9 @@ }, "FILE": { "HELP": "Format d'arxiu esperat: <b>.yml</b> o <b>.dunikey</b> (tipus PubSec, WIF o EWIF)." - } + }, + "AVAILABLE_WALLETS": "Monederos disponibles", + "SCAN_DERIVATIONS": "Escaneando derivaciones..." }, "AUTH": { "TITLE": "<i class=\"icon ion-locked\"></i> Autenticació", diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 34953d6aca08b1d73c5ba51e9380fbc69970bcca..7ba358e787d91144578f0d56d670719c0590ec71 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -488,7 +488,12 @@ "GENERAL_HELP": "Please authenticate yourself:", "EXPECTED_UID_HELP": "Please authenticate to the account <i class=\"ion-person\"></i> {{uid}}:", "EXPECTED_PUBKEY_HELP": "Please authenticate to the wallet <i class=\"ion-key\"></i> {{pubkey|formatPubkey}}:", - "SCAN_FORM_HELP": "Scan the QR code of the <b>private key</b> of the wallet." + "SCAN_FORM_HELP": "Scan the QR code of the <b>private key</b> of the wallet.", + "SELECT_DERIVATION": { + "TITLE": "Select a derivation", + "AVAILABLE_WALLETS": "Available wallets", + "SCAN_DERIVATIONS": "Scanning derivations..." + } }, "ACCOUNT": { "TITLE": "My Account", diff --git a/src/assets/i18n/es-ES.json b/src/assets/i18n/es-ES.json index 3d5baed52a719f186d17bd739ecd694c7af391cb..85833486751c2201993524be19eab6cd05f51478 100644 --- a/src/assets/i18n/es-ES.json +++ b/src/assets/i18n/es-ES.json @@ -561,7 +561,12 @@ "GENERAL_HELP": "Por favor, autentÃquese:", "EXPECTED_UID_HELP": "Por favor inicie sesión en la cuenta de <i class=\"ion-person\"></i> {{uid}}:", "EXPECTED_PUBKEY_HELP": "Por favor, autentÃquese en el monedero <br class=\"visible-xs\"/><i class=\"ion-key\"></i> {{pubkey|formatPubkey}} :", - "SCAN_FORM_HELP": "Escanee el código QR de la <b>llave privada</b> del monedero." + "SCAN_FORM_HELP": "Escanee el código QR de la <b>llave privada</b> del monedero.", + "SELECT_DERIVATION": { + "TITLE": "Selección de la derivación", + "AVAILABLE_WALLETS": "Carteras disponibles", + "SCAN_DERIVATIONS": "Escaneo de las derivaciones..." + } }, "ACCOUNT": { "TITLE": "Mi cuenta", diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index d592908946c22b177522b8d8b2b35ffc97a284f8..e89f33cd2657371df870250fa164c579c0f104c7 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -499,7 +499,12 @@ "GENERAL_HELP": "Veuillez vous authentifier :", "EXPECTED_UID_HELP": "Veuillez vous authentifier sur le compte <i class=\"ion-person\"></i> {{uid}} :", "EXPECTED_PUBKEY_HELP": "Veuillez vous authentifier sur le portefeuille <br class=\"visible-xs\"/><i class=\"ion-key\"></i> {{pubkey|formatPubkey}} :", - "SCAN_FORM_HELP": "Scanner le QR code de la <b>clef privée</b> du portefeuille." + "SCAN_FORM_HELP": "Scanner le QR code de la <b>clef privée</b> du portefeuille.", + "SELECT_DERIVATION": { + "TITLE": "Sélection de la dérivation", + "AVAILABLE_WALLETS": "Portefeuilles disponibles :", + "SCAN_DERIVATIONS": "Scan des dérivations..." + } }, "ACCOUNT": { "TITLE": "Mon compte", @@ -749,7 +754,8 @@ "FIELD_NOT_VALID_LONGITUDE": "Longitude invalide", "FIELD_NOT_VALID_PATTERN": "Format incorrect", "FIELD_NOT_VALID_PUBKEY": "Clé publique invalide", - "FIELD_NOT_VALID_ADDRESS": "Addresse SS58 invalide", + "FIELD_NOT_VALID_ADDRESS": "Adresse SS58 invalide", + "FIELD_NOT_VALID_MNEMONIC": "Phrase de restauration invalide", "PASSWORD_NOT_CONFIRMED": "Ne correspond pas au mot de passe", "SALT_NOT_CONFIRMED": "Ne correspond pas à l'identifiant secret", "SEND_IDENTITY_FAILED": "Échec de l'inscription",