From 2525f27fea22dcd53046797e0e43b7291ad6fb8b Mon Sep 17 00:00:00 2001 From: Jackson Dean Date: Wed, 20 Sep 2023 10:07:38 -0700 Subject: [PATCH] feature: new component for sub account UI --- src/app/app.module.ts | 2 + .../grouped-account-select.component.html | 157 +++++++++ .../grouped-account-select.component.scss | 0 .../grouped-account-select.component.ts | 330 ++++++++++++++++++ src/styles.scss | 10 +- 5 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/app/grouped-account-select/grouped-account-select.component.html create mode 100644 src/app/grouped-account-select/grouped-account-select.component.scss create mode 100644 src/app/grouped-account-select/grouped-account-select.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e59a2157..c5439a7f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { ErrorCallbackComponent } from './error-callback/error-callback.componen import { FreeDeSoDisclaimerComponent } from './free-deso-message/free-deso-disclaimer/free-deso-disclaimer.component'; import { FreeDesoMessageComponent } from './free-deso-message/free-deso-message.component'; import { GetDesoComponent } from './get-deso/get-deso.component'; +import { GroupedAccountSelectComponent } from './grouped-account-select/grouped-account-select.component'; import { HomeComponent } from './home/home.component'; import { IconsModule } from './icons/icons.module'; import { IdentityService } from './identity.service'; @@ -98,6 +99,7 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ TransactionSpendingLimitAssociationComponent, TransactionSpendingLimitAccessGroupComponent, TransactionSpendingLimitAccessGroupMemberComponent, + GroupedAccountSelectComponent, ], imports: [ BrowserModule, diff --git a/src/app/grouped-account-select/grouped-account-select.component.html b/src/app/grouped-account-select/grouped-account-select.component.html new file mode 100644 index 00000000..d02e72b8 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.html @@ -0,0 +1,157 @@ +
+ +
+
+ Select an account +
+ + +
+
    +
  • +
    + + +
    +
  • +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+
+ or +
+ diff --git a/src/app/grouped-account-select/grouped-account-select.component.scss b/src/app/grouped-account-select/grouped-account-select.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/grouped-account-select.component.ts b/src/app/grouped-account-select/grouped-account-select.component.ts new file mode 100644 index 00000000..3c51bd6d --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.ts @@ -0,0 +1,330 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { escape } from 'lodash'; +import { finalize, take } from 'rxjs/operators'; +import { + LoginMethod, + SubAccountMetadata, + UserProfile, +} from 'src/types/identity'; +import Swal from 'sweetalert2'; +import { isValid32BitUnsignedInt } from '../../lib/account-number'; +import { AccountService } from '../account.service'; +import { BackendAPIService } from '../backend-api.service'; +import { GlobalVarsService } from '../global-vars.service'; + +type AccountViewModel = SubAccountMetadata & + UserProfile & { publicKey: string } & { lastUsed?: boolean }; + +function sortAccounts(a: AccountViewModel, b: AccountViewModel) { + // sort accounts by last login timestamp DESC, + // secondarily by balance DESC + return ( + (b.lastLoginTimestamp ?? 0) - (a.lastLoginTimestamp ?? 0) || + b.balanceNanos - a.balanceNanos + ); +} + +@Component({ + selector: 'grouped-account-select', + templateUrl: './grouped-account-select.component.html', + styleUrls: ['./grouped-account-select.component.scss'], +}) +export class GroupedAccountSelectComponent implements OnInit { + @Output() onAccountSelect: EventEmitter = new EventEmitter(); + + /** + * Accounts are grouped by root public key. The root public key is the public + * key derived at account index 0 for a given seed phrase. + */ + accountGroups: Map< + string, + { + showRecoverSubAccountInput?: boolean; + accounts: AccountViewModel[]; + } + > = new Map(); + + /** + * Bound to a UI text input and used to recover a sub account. + */ + accountNumberToRecover = 0; + + /** + * UI loading state flag. + */ + loadingAccounts: boolean = false; + + get hasVisibleAccounts() { + // if any group has at least 1 visible account, return true. + return !!Array.from(this.accountGroups.values()).find( + (group) => group.accounts.length > 0 + ); + } + + constructor( + public accountService: AccountService, + public globalVars: GlobalVarsService, + private backendApi: BackendAPIService + ) {} + + ngOnInit(): void { + this.initializeAccountGroups(); + } + + initializeAccountGroups() { + this.loadingAccounts = true; + const storedUsers = Object.entries( + this.accountService.getRootLevelUsers() + ).sort( + ( + [kA, { lastLoginTimestamp: timestampA = 0 }], + [kb, { lastLoginTimestamp: timestampB = 0 }] + ) => { + // sort groups by last login timestamp DESC. We don't have balance info here. + return timestampB - timestampA; + } + ); + const accountGroupsByRootKey = new Map< + string, + { publicKey: string; accountNumber: number; lastLoginTimestamp: number }[] + >(); + + for (const [rootPublicKey, userInfo] of storedUsers) { + const accounts = !userInfo.isHidden + ? [ + { + publicKey: rootPublicKey, + accountNumber: 0, + lastLoginTimestamp: userInfo.lastLoginTimestamp ?? 0, + }, + ] + : []; + + const subAccounts = userInfo?.subAccounts ?? []; + + for (const subAccount of subAccounts) { + if (subAccount.isHidden) { + continue; + } + + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + + accounts.push({ + publicKey: publicKeyBase58, + accountNumber: subAccount.accountNumber, + lastLoginTimestamp: subAccount.lastLoginTimestamp ?? 0, + }); + } + + accountGroupsByRootKey.set(rootPublicKey, accounts); + } + + const profileKeysToFetch = Array.from(accountGroupsByRootKey.values()) + .flat() + .map((a) => a.publicKey); + + // Fetch profiles and balances so we can show usernames in the UI (if we have them) + this.backendApi + .GetUserProfiles(profileKeysToFetch) + .pipe( + take(1), + finalize(() => (this.loadingAccounts = false)) + ) + .subscribe((users) => { + Array.from(accountGroupsByRootKey.entries()).forEach( + ([key, accounts]) => { + this.accountGroups.set(key, { + showRecoverSubAccountInput: false, + accounts: accounts + .map((account, j) => ({ + ...account, + ...users[account.publicKey], + })) + .sort(sortAccounts), + }); + + // get the first account in the list and set it as the lastUsed. + const firstKey = this.accountGroups.keys().next().value; + const firstGroup = this.accountGroups.get(firstKey); + if (firstGroup) { + firstGroup.accounts[0].lastUsed = true; + this.accountGroups.set(firstKey, firstGroup); + } + } + ); + }); + } + + /** + * We need this to address angular's weird default sorting of Maps by key when + * iterating in the template. See this issue for details. We just want to + * preserve the natural order of the Map entries: + * https://github.com/angular/angular/issues/31420 + */ + keyValueSort() { + return 1; + } + + getLoginMethodIcon(loginMethod: LoginMethod = LoginMethod.DESO): string { + return { + [LoginMethod.DESO]: 'assets/logo-deso-mark.svg', + [LoginMethod.GOOGLE]: 'assets/google_logo.svg', + [LoginMethod.METAMASK]: 'assets/metamask.png', + }[loginMethod]; + } + + selectAccount(publicKey: string) { + this.accountService.updateAccountInfo(publicKey, { + lastLoginTimestamp: Date.now(), + }); + this.onAccountSelect.emit(publicKey); + } + + hideAccount(groupKey: string, account: AccountViewModel) { + // NOTE: if there is at least 1 sub account left in the group after hiding this account, + // the user only needs the account number to recover it. If there are no sub accounts left, + // the user needs the seed phrase + the account number to recover it. + const group = this.accountGroups.get(groupKey) ?? { + accounts: [], + }; + // get a copy of the underlying array so we can preview what it looks like when hiding this account + const hiddenPreview = group.accounts + .slice() + .filter((a) => a.accountNumber !== account.accountNumber); + const hasAccountsAfterHiding = hiddenPreview.length > 0; + const { displayName, displayAccountNumber } = { + displayName: escape(this.getAccountDisplayName(account)), + displayAccountNumber: escape(account.accountNumber.toString()), + }; + Swal.fire({ + title: 'Remove Account?', + html: hasAccountsAfterHiding + ? ` +
+

+ ${displayName} +

+

You can recover this account as long as you have the account number.

+

+ +

+
+ ` + : ` +
+

+ ${displayName} +

+

+ Your account will be irrecoverable if you lose your seed phrase. +

+

Make sure you have backed up your seed phrase before continuing!

+
+ `, + showCancelButton: true, + }).then(({ isConfirmed }) => { + if (isConfirmed) { + this.accountService.updateAccountInfo(account.publicKey, { + isHidden: true, + }); + group.accounts = hiddenPreview; + this.accountGroups.set(groupKey, group); + } + }); + } + + addSubAccount( + rootPublicKey: string, + { accountNumber }: { accountNumber?: number } = {} + ) { + const addedAccountNumber = this.accountService.addSubAccount( + rootPublicKey, + { accountNumber } + ); + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + addedAccountNumber + ); + // Check if this account has profile, balance, etc, and add it to the list. + // TODO: some loading state while fetching profile data? + this.backendApi + .GetUserProfiles([publicKeyBase58]) + .pipe(take(1)) + .subscribe((users) => { + const account = { + publicKey: publicKeyBase58, + accountNumber: addedAccountNumber, + ...users[publicKeyBase58], + }; + + const group = this.accountGroups.get(rootPublicKey) ?? { + accounts: [], + }; + + // if the account is already in the list, don't add it again... + if (!group.accounts.find((a) => a.accountNumber === accountNumber)) { + // Insert recovered/added account at the top of the list so + // easy to see that it was added. + group.accounts.unshift(account); + } + + this.accountGroups.set(rootPublicKey, group); + }); + } + + /** + * Shows and hides the "recover sub account" text input. + */ + toggleRecoverSubAccountForm(rootPublicKey: string) { + const group = this.accountGroups.get(rootPublicKey); + if (!group) { + return; + } + group.showRecoverSubAccountInput = !group.showRecoverSubAccountInput; + this.accountGroups.set(rootPublicKey, group); + } + + recoverSubAccount(event: SubmitEvent, rootPublicKey: string) { + event.preventDefault(); + + if (!isValid32BitUnsignedInt(this.accountNumberToRecover)) { + Swal.fire({ + title: 'Invalid Account Number', + html: `Please enter a valid account number.`, + }); + return; + } + + this.addSubAccount(rootPublicKey, { + accountNumber: this.accountNumberToRecover, + }); + } + + getAccountDisplayName(account: { username?: string; publicKey: string }) { + return account.username ?? account.publicKey; + } + + isMetaMaskAccountGroup(rootPublicKey: string) { + const rootAccount = this.accountService.getAccountInfo(rootPublicKey); + return this.accountService.isMetamaskAccount(rootAccount); + } +} diff --git a/src/styles.scss b/src/styles.scss index 4082bdab..3a7edd45 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -164,6 +164,7 @@ $colors: ( // SPACING UNITS //------------------------------- $spacing-units: ( + 'auto': auto, 'none': 0, 'xsmall': 4px, 'small': 8px, @@ -806,7 +807,6 @@ a, @include spacing('padding-right', 'medium'); @include spacing('padding-top', 'small'); @include spacing('padding-bottom', 'small'); - @include spacing('margin-top', 'small'); @include color('text', 'lighter', 'color'); @include color('blue', 'darker', 'background-color'); @include color('blue', 'dark', 'border-color'); @@ -1162,3 +1162,11 @@ a, background-color: rgba(0, 0, 0, 0.8) !important; } } + +.styleless-button { + border: none; + background: none; + padding: 0; + margin: 0; + cursor: pointer; +}