feat: initial Pezkuwi Apps rebrand from polkadot-apps

Rebranded terminology:
- Polkadot → Pezkuwi
- Kusama → Dicle
- Westend → Zagros
- Rococo → PezkuwiChain
- Substrate → Bizinikiwi
- parachain → teyrchain

Custom logos with Kurdistan brand colors (#e6007a → #86e62a):
- bizinikiwi-hexagon.svg
- sora-bizinikiwi.svg
- hezscanner.svg
- heztreasury.svg
- pezkuwiscan.svg
- pezkuwistats.svg
- pezkuwiassembly.svg
- pezkuwiholic.svg
This commit is contained in:
2026-01-07 13:05:27 +03:00
commit d21bfb1320
5867 changed files with 329019 additions and 0 deletions
@@ -0,0 +1,295 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
import type { HexString } from '@pezkuwi/util/types';
import React, { useCallback, useEffect, useState } from 'react';
import { AddressInfo, AddressSmall, Button, ChainLock, Columar, Forget, LinkExternal, Menu, Popup, Table, Tags, TransferModal } from '@pezkuwi/react-components';
import { MATCHERS } from '@pezkuwi/react-components/AccountName';
import { useApi, useBalancesAll, useDeriveAccountInfo, useToggle } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { isFunction } from '@pezkuwi/util';
import { useTranslation } from '../translate.js';
interface Props {
address: string;
className?: string;
filter: string;
isFavorite: boolean;
isVisible: boolean;
toggleFavorite: (address: string) => void;
toggleVisible: (address: string, isVisible: boolean) => void
}
const isEditable = true;
const BAL_OPTS_DEFAULT = {
available: false,
bonded: false,
locked: false,
redeemable: false,
reserved: false,
total: true,
unlocking: false,
vested: false
};
const BAL_OPTS_EXPANDED = {
available: true,
bonded: true,
locked: true,
nonce: true,
redeemable: true,
reserved: true,
total: false,
unlocking: true,
vested: true
};
function Address ({ address, className = '', filter, isFavorite, isVisible, toggleFavorite, toggleVisible }: Props): React.ReactElement<Props> | null {
const { t } = useTranslation();
const api = useApi();
const info = useDeriveAccountInfo(address);
const balancesAll = useBalancesAll(address);
const [tags, setTags] = useState<string[]>([]);
const [accName, setAccName] = useState('');
const [current, setCurrent] = useState<KeyringAddress | null>(null);
const [genesisHash, setGenesisHash] = useState<string | null>(null);
const [isForgetOpen, setIsForgetOpen] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [isExpanded, toggleIsExpanded] = useToggle(false);
const _setTags = useCallback(
(tags: string[]): void => setTags(tags.sort()),
[]
);
useEffect((): void => {
const current = keyring.getAddress(address);
setCurrent(current || null);
setGenesisHash((current?.meta.genesisHash) || null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect((): void => {
let known: string | null = null;
for (let i = 0; known === null && i < MATCHERS.length; i++) {
known = MATCHERS[i](address);
}
if (known) {
setAccName(known);
} else {
const { identity, nickname } = info || {};
if (isFunction(api.apiIdentity.query.identity?.identityOf)) {
if (identity?.display) {
setAccName(identity.display);
}
} else if (nickname) {
setAccName(nickname);
}
}
}, [address, api, info]);
useEffect((): void => {
const account = keyring.getAddress(address);
_setTags(account?.meta?.tags || []);
setAccName(account?.meta?.name || '');
}, [_setTags, address]);
useEffect((): void => {
const _filter = filter.trim().toLowerCase();
let isVisible = true;
if (_filter.length !== 0) {
isVisible = keyring.encodeAddress(address, 0).toLowerCase().includes(_filter) ||
address.toLowerCase().includes(_filter) ||
tags.reduce((result: boolean, tag: string): boolean => {
return result || tag.toLowerCase().includes(_filter);
}, accName.toLowerCase().includes(_filter));
}
toggleVisible(address, isVisible);
}, [accName, address, filter, tags, toggleVisible]);
const _onGenesisChange = useCallback(
(genesisHash: HexString | null): void => {
setGenesisHash(genesisHash);
const account = keyring.getAddress(address);
account && keyring.saveAddress(address, { ...account.meta, genesisHash });
setGenesisHash(genesisHash);
},
[address]
);
const _toggleForget = useCallback(
(): void => setIsForgetOpen(!isForgetOpen),
[isForgetOpen]
);
const _toggleTransfer = useCallback(
(): void => setIsTransferOpen(!isTransferOpen),
[isTransferOpen]
);
const _onForget = useCallback(
(): void => {
if (address) {
const status: Partial<ActionStatus> = {
account: address,
action: 'forget'
};
try {
keyring.forgetAddress(address);
status.status = 'success';
status.message = t('address forgotten');
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
}
},
[address, t]
);
if (!isVisible) {
return null;
}
const PopupDropdown = (
<Menu>
<Menu.Item
isDisabled={!isEditable}
label={t('Forget this address')}
onClick={_toggleForget}
/>
{isEditable && !api.isDevelopment && (
<>
<Menu.Divider />
<ChainLock
className='addresses--network-toggle'
genesisHash={genesisHash}
onChange={_onGenesisChange}
/>
</>
)}
</Menu>
);
return (
<>
<tr className={`${className} isExpanded isFirst packedBottom`}>
<Table.Column.Favorite
address={address}
isFavorite={isFavorite}
toggle={toggleFavorite}
/>
<td className='address all'>
<AddressSmall
value={address}
withShortAddress
/>
{address && current && (
<>
{isForgetOpen && (
<Forget
address={current.address}
key='modal-forget-account'
mode='address'
onClose={_toggleForget}
onForget={_onForget}
/>
)}
{isTransferOpen && (
<TransferModal
key='modal-transfer'
onClose={_toggleTransfer}
recipientId={address}
/>
)}
</>
)}
</td>
<td className='actions button'>
<Button.Group>
{(isFunction(api.api.tx.balances?.transferAllowDeath) || isFunction(api.api.tx.balances?.transfer)) && (
<Button
className='send-button'
icon='paper-plane'
key='send'
label={t('send')}
onClick={_toggleTransfer}
/>
)}
<Popup value={PopupDropdown} />
</Button.Group>
</td>
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleIsExpanded}
/>
</tr>
<tr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
<td />
<td
className='balance all'
colSpan={2}
>
<AddressInfo
address={address}
balancesAll={balancesAll}
withBalance={BAL_OPTS_DEFAULT}
/>
</td>
<td />
</tr>
<tr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
<td />
<td
className='balance columar'
colSpan={2}
>
<AddressInfo
address={address}
balancesAll={balancesAll}
withBalance={BAL_OPTS_EXPANDED}
/>
<Columar size='tiny'>
<Columar.Column>
<div data-testid='tags'>
<Tags
value={tags}
withTitle
/>
</div>
</Columar.Column>
</Columar>
<Columar is100>
<Columar.Column>
<LinkExternal
data={address}
type='address'
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</tr>
</>
);
}
export default React.memo(Address);
@@ -0,0 +1,49 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SortedAddress } from './types.js';
import FileSaver from 'file-saver';
import React, { useCallback } from 'react';
import { Button } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
import { useTranslation } from '../translate.js';
interface Props {
sortedAddresses?: SortedAddress[]
}
function Export ({ sortedAddresses }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onExport = useCallback(() => {
const accounts = sortedAddresses?.map(({ address, isFavorite }) => {
const account = keyring.getAddress(address); // get account info
return { address, isFavorite, name: account?.meta.name || address };
});
/** **************** Export accounts as JSON ******************/
const blob = new Blob([JSON.stringify(accounts, null, 2)], { type: 'application/json; charset=utf-8' });
// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(blob, `batch_exported_address_book_${new Date().getTime()}.json`);
/** ********************* ************** ************************/
}, [sortedAddresses]);
return sortedAddresses?.length
? (
<Button
icon='file-export'
label={t('Export')}
onClick={onExport}
/>
)
: <></>;
}
export default React.memo(Export);
@@ -0,0 +1,201 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveAccountInfo } from '@pezkuwi/api-derive/types';
import type { ActionStatus, ActionStatusBase } from '@pezkuwi/react-components/Status/types';
import type { FunInputFile, SaveFile } from './types.js';
import React, { useCallback, useRef } from 'react';
import { Button, InputAddress } from '@pezkuwi/react-components';
import { useApi } from '@pezkuwi/react-hooks';
import keyring from '@pezkuwi/ui-keyring';
import { hexToU8a } from '@pezkuwi/util';
import { ethereumEncode } from '@pezkuwi/util-crypto';
import { useTranslation } from '../translate.js';
interface Props {
favorites: string[];
onStatusChange: (status: ActionStatus) => void;
toggleFavorite: (address: string) => void;
}
function Import ({ favorites, onStatusChange, toggleFavorite }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isEthereum } = useApi();
const importInputRef = useRef<HTMLInputElement>(null);
const _onImportResult = useCallback<(m: string, s?: ActionStatusBase['status']) => void>(
(message, status = 'queued') => {
onStatusChange?.({
action: t('Import file'),
message,
status
});
},
[onStatusChange, t]
);
const validateAccountInfo = useCallback(({ address: addressInput, name }: SaveFile) => {
let address = '';
let isAddressValid = true;
let isAddressExisting = false;
let isPublicKey = false;
let isNameValid = !!name.trim();
try {
if (isEthereum) {
const rawAddress = hexToU8a(addressInput);
address = ethereumEncode(rawAddress);
isPublicKey = rawAddress.length === 20;
} else {
const publicKey = keyring.decodeAddress(addressInput);
address = keyring.encodeAddress(publicKey);
isPublicKey = publicKey.length === 32;
}
const old = keyring.getAddress(address);
if (old) {
const newName = old.meta.name || name;
isAddressExisting = true;
isAddressValid = true;
isNameValid = !!(newName || '').trim();
}
} catch {
isAddressValid = false;
}
return {
address,
isAddressExisting,
isAddressValid,
isNameValid,
isPublicKey
};
}, [isEthereum]);
const _onAddAccount = useCallback(
async (account: SaveFile): Promise<boolean> => {
const { address, name } = account;
const info: DeriveAccountInfo = await api.derive.accounts.info(address);
const { isAddressExisting, isAddressValid, isNameValid } = validateAccountInfo(account);
const isValid = (isAddressValid && isNameValid) && !!info?.accountId;
if (!isValid || !info?.accountId || isAddressExisting) {
return false;
}
try {
const address = info.accountId.toString();
// Save address
keyring.saveAddress(address, { genesisHash: keyring.genesisHash, name: name.trim(), tags: [] });
InputAddress.setLastValue('address', address);
if (account.isFavorite && !favorites.includes(address)) {
toggleFavorite(address);
}
return true;
} catch (_) {
return false;
}
},
[api.derive.accounts, favorites, toggleFavorite, validateAccountInfo]
);
const onImport = useCallback(() => {
if (!importInputRef.current) {
return;
}
importInputRef.current.value = '';
importInputRef.current.click();
}, []);
const _onInputImportFile = useCallback<FunInputFile>((e) => {
try {
_onImportResult(t('Importing'), 'queued');
const fileReader = new FileReader();
const files = e.target.files;
if (!files) {
return _onImportResult(t('no file chosen'), 'error');
}
// Read uploaded file
fileReader.readAsText(files[0], 'UTF-8');
// Check if the selected file does not have a .json extension.
// If invalid, return error message.
if (!(/(.json)$/i.test(e.target.value))) {
return _onImportResult(t('file error'), 'error');
}
fileReader.onload = async (e) => {
try {
// Try parsing file data
const _list = JSON.parse(e.target?.result as string) as SaveFile[];
if (!Array.isArray(_list)) {
return _onImportResult(t('file content error'), 'error');
}
const fitter: SaveFile[] = [];
// Filter out items that match the required schema, ensuring only valid entries are retained.
for (const item of _list) {
if (item.name && item.address) {
fitter.push(item);
}
}
let importedAccounts = 0;
// Add each valid account
for (const account of fitter) {
try {
const flag = await _onAddAccount(account);
importedAccounts += Number(flag);
} catch { }
}
if (importedAccounts > 0) {
_onImportResult(t('Success'), 'success');
} else {
_onImportResult(t('no account imported'), 'eventWarn');
}
} catch {
_onImportResult(t('file content error'), 'error');
}
};
} catch {
_onImportResult(t('file content error'), 'error');
}
}, [_onAddAccount, _onImportResult, t]);
return (
<>
<input
accept='application/json'
onChange={_onInputImportFile}
ref={importInputRef}
style={{ display: 'none' }}
type={'file'}
/>
<Button
icon='file-import'
label={t('Import')}
onClick={onImport}
/>
</>
);
}
export default React.memo(Import);
@@ -0,0 +1,138 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
import type { Table } from '@pezkuwi/test-support/pagesElements';
import { screen } from '@testing-library/react';
import i18next from '@pezkuwi/react-components/i18n';
import { aContactWithBalance } from '@pezkuwi/test-support/creation/contact';
import { MemoryStore } from '@pezkuwi/test-support/keyring';
import { balance } from '@pezkuwi/test-support/utils';
import { keyring } from '@pezkuwi/ui-keyring';
import { AddressesPage } from '../../test/pages/addressesPage.js';
// FIXME isSplit Table
// eslint-disable-next-line jest/no-disabled-tests
describe.skip('Addresses page', () => {
let addressesPage: AddressesPage;
beforeAll(async () => {
await i18next.changeLanguage('en');
if (keyring.getAccounts().length === 0) {
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
}
});
beforeEach(() => {
addressesPage = new AddressesPage();
addressesPage.clearAccounts();
});
describe('when no contacts', () => {
let addressesTable: Table;
beforeEach(async () => {
addressesPage.render([]);
addressesTable = await addressesPage.getTable();
});
it('shows a table', () => {
expect(addressesTable).not.toBeNull();
});
it('the contacts table contains no contact rows', async () => {
expect(await addressesTable.getRows()).toHaveLength(0);
});
// eslint-disable-next-line jest/expect-expect
it('the contacts table contains a message about no contacts available', async () => {
const noContactsMessage = 'no addresses saved yet, add any existing address';
await addressesTable.assertText(noContactsMessage);
});
it('no summary is displayed', () => {
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
expect(summaries).toHaveLength(0);
});
});
describe('when some contacts exist', () => {
it('the contacts table contains some contact rows', async () => {
addressesPage.renderDefaultContacts(2);
const rows = await addressesPage.getAddressesRows();
expect(rows).toHaveLength(2);
});
// eslint-disable-next-line jest/expect-expect
it('contact rows display the total balance info', async () => {
addressesPage.renderContactsWithDefaultAddresses(
aContactWithBalance({ freeBalance: balance(500) }),
aContactWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertBalancesTotal(balance(500));
await rows[1].assertBalancesTotal(balance(350));
});
// eslint-disable-next-line jest/expect-expect
it('contact rows display the details balance info', async () => {
addressesPage.renderContactsWithDefaultAddresses(
aContactWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
aContactWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertBalancesDetails([
{ amount: balance(0), name: 'transferable' },
{ amount: balance(30), name: 'locked' }]);
await rows[1].assertBalancesDetails([
{ amount: balance(50), name: 'transferable' },
{ amount: balance(150), name: 'reserved' }]);
});
// eslint-disable-next-line jest/expect-expect
it('when a contact is not tagged, details row displays no tags info', async () => {
addressesPage.renderDefaultContacts(1);
const rows = await addressesPage.getAddressesRows();
await rows[0].assertTags('none');
});
it('when a contact is tagged, the details row displays tags', async () => {
const injectedAddress = '5CMCFVfsauWXmKaUB6tbznVUpBxcUZyU78DzvPTYrhdXe8Xp';
addressesPage.renderContacts([
[injectedAddress, { meta: { tags: ['my tag', 'Super Tag'] } }]
]);
const rows = await addressesPage.getAddressesRows();
expect(rows).toHaveLength(1);
await rows[0].assertTags('Super Tagmy tag');
});
it('contact details rows toggled on icon toggle click', async () => {
addressesPage.renderDefaultContacts(1);
const row = (await addressesPage.getAddressesRows())[0];
expect(row.detailsRow).toHaveClass('isCollapsed');
await row.expand();
expect(row.detailsRow).toHaveClass('isExpanded');
});
});
});
@@ -0,0 +1,118 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { SortedAddress } from './types.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, FilterInput, styled, SummaryBox, Table } from '@pezkuwi/react-components';
import { useAddresses, useFavorites, useNextTick, useToggle } from '@pezkuwi/react-hooks';
import CreateModal from '../modals/Create.js';
import { useTranslation } from '../translate.js';
import Address from './Address.js';
import Export from './Export.js';
import Import from './Import.js';
interface Props {
className?: string;
onStatusChange: (status: ActionStatus) => void;
}
const STORE_FAVS = 'accounts:favorites';
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { allAddresses } = useAddresses();
const [isCreateOpen, toggleCreate] = useToggle(false);
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [sortedAddresses, setSortedAddresses] = useState<SortedAddress[] | undefined>();
const [filterOn, setFilter] = useState<string>('');
const isNextTick = useNextTick();
const headerRef = useRef<([React.ReactNode?, string?, number?] | false)[]>([
[t('contacts'), 'start', 4]
]);
useEffect((): void => {
setSortedAddresses(
allAddresses
.map((address): SortedAddress => ({ address, isFavorite: favorites.includes(address), isVisible: true }))
.sort((a, b): number =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1
)
);
}, [allAddresses, favorites]);
const toggleVisible = useCallback((address: string, isVisible: boolean) => {
setSortedAddresses((account) => account
?.map((e) => e.address === address ? { ...e, isVisible } : e)
.sort((a, b) => a.isVisible === b.isVisible ? 0 : b.isVisible ? 1 : -1)
);
}, []);
return (
<StyledDiv className={className}>
{isCreateOpen && (
<CreateModal
onClose={toggleCreate}
onStatusChange={onStatusChange}
/>
)}
<SummaryBox className='summary-box-contacts'>
<section>
<FilterInput
className='media--1000'
filterOn={filterOn}
label={t('filter by name or tags')}
setFilter={setFilter}
/>
</section>
<Button.Group>
<Import
favorites={favorites}
onStatusChange={onStatusChange}
toggleFavorite={toggleFavorite}
/>
<Export sortedAddresses={sortedAddresses} />
<Button
icon='plus'
label={t('Add contact')}
onClick={toggleCreate}
/>
</Button.Group>
</SummaryBox>
<Table
empty={isNextTick && sortedAddresses && t('no addresses saved yet, add any existing address')}
header={headerRef.current}
isSplit
>
{isNextTick && sortedAddresses?.map(({ address, isFavorite, isVisible }): React.ReactNode => (
<Address
address={address}
filter={filterOn}
isFavorite={isFavorite}
isVisible={isVisible}
key={address}
toggleFavorite={toggleFavorite}
toggleVisible={toggleVisible}
/>
))}
</Table>
</StyledDiv>
);
}
const StyledDiv = styled.div`
.summary-box-contacts {
align-items: center;
}
`;
export default React.memo(Overview);
@@ -0,0 +1,10 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type React from 'react';
export interface SortedAddress { address: string; isFavorite: boolean, isVisible: boolean }
export interface SaveFile { address: string; isFavorite: boolean, name: string }
export type FunInputFile = (e: React.ChangeEvent<HTMLInputElement>) => void
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AppProps as Props } from '@pezkuwi/react-components/types';
import React, { useRef } from 'react';
import { Route, Routes } from 'react-router';
import { Tabs } from '@pezkuwi/react-components';
import Contacts from './Contacts/index.js';
import { useTranslation } from './translate.js';
function AddressesApp ({ basePath, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const itemsRef = useRef([
{
isRoot: true,
name: 'contacts',
text: t('My contacts')
}
]);
return (
<main>
<Tabs
basePath={basePath}
items={itemsRef.current}
/>
<Routes>
<Route path={basePath}>
<Route
element={
<Contacts onStatusChange={onStatusChange} />
}
index
/>
</Route>
</Routes>
</main>
);
}
export default React.memo(AddressesApp);
@@ -0,0 +1,162 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DeriveAccountInfo } from '@pezkuwi/api-derive/types';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { ModalProps as Props } from '../types.js';
import React, { useCallback, useMemo, useState } from 'react';
import { AddressRow, Button, Input, InputAddress, Modal } from '@pezkuwi/react-components';
import { useApi, useCall } from '@pezkuwi/react-hooks';
import { keyring } from '@pezkuwi/ui-keyring';
import { hexToU8a } from '@pezkuwi/util';
import { ethereumEncode } from '@pezkuwi/util-crypto';
import { useTranslation } from '../translate.js';
interface AddrState {
address: string;
addressInput: string;
isAddressExisting: boolean;
isAddressValid: boolean;
isPublicKey: boolean;
}
interface NameState {
isNameValid: boolean;
name: string;
}
function Create ({ onClose, onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isEthereum } = useApi();
const [{ isNameValid, name }, setName] = useState<NameState>({ isNameValid: false, name: '' });
const [{ address, addressInput, isAddressExisting, isAddressValid }, setAddress] = useState<AddrState>({ address: '', addressInput: '', isAddressExisting: false, isAddressValid: false, isPublicKey: false });
const info = useCall<DeriveAccountInfo>(!!address && isAddressValid && api.derive.accounts.info, [address]);
const isValid = useMemo(() => (isAddressValid && isNameValid) && !!info?.accountId, [isAddressValid, isNameValid, info]);
const _onChangeAddress = useCallback(
(addressInput: string): void => {
let address = '';
let isAddressValid = true;
let isAddressExisting = false;
let isPublicKey = false;
try {
if (isEthereum) {
const rawAddress = hexToU8a(addressInput);
address = ethereumEncode(rawAddress);
isPublicKey = rawAddress.length === 20;
} else {
const publicKey = keyring.decodeAddress(addressInput);
address = keyring.encodeAddress(publicKey);
isPublicKey = publicKey.length === 32;
}
if (!isAddressValid) {
const old = keyring.getAddress(address);
if (old) {
const newName = old.meta.name || name;
isAddressExisting = true;
isAddressValid = true;
setName({ isNameValid: !!(newName || '').trim(), name: newName });
}
}
} catch {
isAddressValid = false;
}
setAddress({ address: isAddressValid ? address : '', addressInput, isAddressExisting, isAddressValid, isPublicKey });
},
[isEthereum, name]
);
const _onChangeName = useCallback(
(name: string) => setName({ isNameValid: !!name.trim(), name }),
[]
);
const _onCommit = useCallback(
(): void => {
const status = { action: 'create' } as ActionStatus;
if (!isValid || !info?.accountId) {
return;
}
try {
const address = info.accountId.toString();
keyring.saveAddress(address, { genesisHash: keyring.genesisHash, name: name.trim(), tags: [] });
status.account = address;
status.status = address ? 'success' : 'error';
status.message = isAddressExisting
? t('address edited')
: t('address created');
InputAddress.setLastValue('address', address);
} catch (error) {
status.status = 'error';
status.message = (error as Error).message;
}
onStatusChange(status);
onClose();
},
[info, isAddressExisting, isValid, name, onClose, onStatusChange, t]
);
return (
<Modal
header={t('Add an address')}
onClose={onClose}
>
<Modal.Content>
<AddressRow
defaultName={name}
noDefaultNameOpacity
value={
isAddressValid
? info?.accountId?.toString()
: undefined
}
>
<Input
autoFocus
className='full'
isError={!isAddressValid}
label={t('address')}
onChange={_onChangeAddress}
onEnter={_onCommit}
placeholder={t('new address')}
value={addressInput}
/>
<Input
className='full'
isError={!isNameValid}
label={t('name')}
onChange={_onChangeName}
onEnter={_onCommit}
value={name}
/>
</AddressRow>
</Modal.Content>
<Modal.Actions>
<Button
icon='save'
isDisabled={!isValid}
label={t('Save')}
onClick={_onCommit}
/>
</Modal.Actions>
</Modal>
);
}
export default React.memo(Create);
+8
View File
@@ -0,0 +1,8 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useTranslation as useTranslationBase } from 'react-i18next';
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
return useTranslationBase('app-addresses');
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WithTranslation } from 'react-i18next';
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
import type { Balance, Conviction } from '@pezkuwi/types/interfaces';
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
export interface BareProps {
className?: string;
}
export interface I18nProps extends BareProps, WithTranslation {}
export interface ModalProps {
onClose: () => void;
onStatusChange: (status: ActionStatus) => void;
}
export interface Delegation {
accountDelegated: string
amount: Balance
conviction: Conviction
}
export interface SortedAccount {
account: KeyringAddress;
children: SortedAccount[];
delegation?: Delegation;
isFavorite: boolean;
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2017-2025 @pezkuwi/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
import type { SortedAccount } from './types.js';
import React from 'react';
import { Menu } from '@pezkuwi/react-components';
import { keyring } from '@pezkuwi/ui-keyring';
export function createMenuGroup (items: (React.ReactNode | false | undefined | null)[]): React.ReactNode | null {
const filtered = items.filter((item): item is React.ReactNode => !!item);
return filtered.length
? <>{filtered}<Menu.Divider /></>
: null;
}
function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] {
mapped.push(entry);
entry.children.forEach((entry): void => {
expandList(mapped, entry);
});
return mapped;
}
export function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] {
const mapped = addresses
.map((address) => keyring.getAccount(address))
.filter((account): account is KeyringAddress => !!account)
.map((account): SortedAccount => ({
account,
children: [],
isFavorite: favorites.includes(account.address)
}))
.sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0));
return mapped
.filter((entry): boolean => {
const parentAddress = entry.account.meta.parentAddress;
if (parentAddress) {
const parent = mapped.find(({ account: { address } }) => address === parentAddress);
if (parent) {
parent.children.push(entry);
return false;
}
}
return true;
})
.reduce(expandList, [])
.sort((a, b): number =>
a.isFavorite === b.isFavorite
? 0
: b.isFavorite
? 1
: -1
);
}