mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-04-22 19:27:57 +00:00
420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
// Copyright 2017-2026 @pezkuwi/react-components authors & contributors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import type { DropdownItemProps } from 'semantic-ui-react';
|
|
import type { KeyringOption$Type, KeyringOptions, KeyringSectionOption } from '@pezkuwi/ui-keyring/options/types';
|
|
import type { Option } from './types.js';
|
|
|
|
import React from 'react';
|
|
import store from 'store';
|
|
|
|
import { withMulti, withObservable } from '@pezkuwi/react-api/hoc';
|
|
import { keyring } from '@pezkuwi/ui-keyring';
|
|
import { createOptionItem } from '@pezkuwi/ui-keyring/options/item';
|
|
import { isNull, isUndefined } from '@pezkuwi/util';
|
|
import { isAddress } from '@pezkuwi/util-crypto';
|
|
|
|
import Dropdown from '../Dropdown.js';
|
|
import Static from '../Static.js';
|
|
import { styled } from '../styled.js';
|
|
import { getAddressName, toAddress } from '../util/index.js';
|
|
import createHeader from './createHeader.js';
|
|
import createItem from './createItem.js';
|
|
|
|
interface Props {
|
|
className?: string;
|
|
defaultValue?: Uint8Array | string | null;
|
|
filter?: string[] | null;
|
|
hideAddress?: boolean;
|
|
isDisabled?: boolean;
|
|
isError?: boolean;
|
|
isInput?: boolean;
|
|
isMultiple?: boolean;
|
|
label?: React.ReactNode;
|
|
labelExtra?: React.ReactNode;
|
|
onChange?: (value: string | null) => void;
|
|
onChangeMulti?: (value: string[]) => void;
|
|
options?: KeyringSectionOption[] | null;
|
|
optionsAll?: Record<string, Option[]>;
|
|
placeholder?: string;
|
|
type?: KeyringOption$Type;
|
|
value?: string | Uint8Array | string[] | null;
|
|
withEllipsis?: boolean;
|
|
withExclude?: boolean;
|
|
withLabel?: boolean;
|
|
}
|
|
|
|
type ExportedType = React.ComponentType<Props> & {
|
|
createOption: (option: KeyringSectionOption, isUppercase?: boolean) => Option | null;
|
|
setLastValue: (type: KeyringOption$Type, value: string) => void;
|
|
};
|
|
|
|
interface State {
|
|
lastValue?: string;
|
|
value?: string | string[];
|
|
}
|
|
|
|
const STORAGE_KEY = 'options:InputAddress';
|
|
const DEFAULT_TYPE = 'all';
|
|
const MULTI_DEFAULT: string[] = [];
|
|
|
|
function transformToAddress (value?: string | Uint8Array | null): string | null {
|
|
try {
|
|
return toAddress(value, false, keyring.keyring.type === 'ethereum' ? 20 : 32) || null;
|
|
} catch {
|
|
// noop, handled by return
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function transformToAccountId (value: string): string | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const accountId = transformToAddress(value);
|
|
|
|
return !accountId
|
|
? null
|
|
: accountId;
|
|
}
|
|
|
|
function createOption (address: string): Option | null {
|
|
let isRecent: boolean | undefined;
|
|
const pair = keyring.getAccount(address);
|
|
let name: string | undefined;
|
|
|
|
if (pair) {
|
|
name = pair.meta.name;
|
|
} else {
|
|
const addr = keyring.getAddress(address);
|
|
|
|
if (addr) {
|
|
name = addr.meta.name;
|
|
isRecent = addr.meta.isRecent;
|
|
} else {
|
|
isRecent = true;
|
|
}
|
|
}
|
|
|
|
return createItem(createOptionItem(address, name), !isRecent);
|
|
}
|
|
|
|
function readOptions (): Record<string, Record<string, string>> {
|
|
return store.get(STORAGE_KEY) as Record<string, Record<string, string>> || { defaults: {} };
|
|
}
|
|
|
|
function getLastValue (type: KeyringOption$Type = DEFAULT_TYPE): string {
|
|
const options = readOptions();
|
|
|
|
return options.defaults[type];
|
|
}
|
|
|
|
function setLastValue (type: KeyringOption$Type = DEFAULT_TYPE, value: string): void {
|
|
const options = readOptions();
|
|
|
|
options.defaults[type] = value;
|
|
store.set(STORAGE_KEY, options);
|
|
}
|
|
|
|
function dedupe (options: Option[]): Option[] {
|
|
return options.reduce<Option[]>((all, o, index) => {
|
|
const hasDupe = all.some(({ key }, eindex) =>
|
|
eindex !== index &&
|
|
key === o.key
|
|
);
|
|
|
|
if (!hasDupe) {
|
|
all.push(o);
|
|
}
|
|
|
|
return all;
|
|
}, []);
|
|
}
|
|
|
|
class InputAddress extends React.PureComponent<Props, State> {
|
|
public override state: State = {};
|
|
|
|
public static getDerivedStateFromProps ({ type, value }: Props, { lastValue }: State): Pick<State, never> | null {
|
|
try {
|
|
return {
|
|
lastValue: lastValue || getLastValue(type),
|
|
value: Array.isArray(value)
|
|
? value.map((v) => toAddress(v))
|
|
: (toAddress(value) || undefined)
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public override render (): React.ReactNode {
|
|
const { className = '', defaultValue, hideAddress = false, isDisabled = false, isError, isMultiple, label, labelExtra, options, optionsAll, placeholder, type = DEFAULT_TYPE, withEllipsis, withLabel } = this.props;
|
|
const hasOptions = (options && options.length !== 0) || (optionsAll && Object.keys(optionsAll[type]).length !== 0);
|
|
|
|
// the options could be delayed, don't render without
|
|
if (!hasOptions && !isDisabled && !['allPlus'].includes(type)) {
|
|
// This is nasty, but since this things is non-functional, there is not much
|
|
// we can do (well, wrap it, however that approach is deprecated here)
|
|
return (
|
|
<Static
|
|
className={className}
|
|
label={label}
|
|
>
|
|
No accounts are available for selection.
|
|
</Static>
|
|
);
|
|
}
|
|
|
|
const { lastValue, value } = this.state;
|
|
const lastOption = this.getLastOptionValue();
|
|
const actualValue = transformToAddress(
|
|
isDisabled || (defaultValue && defaultValue !== '0x' && (this.hasValue(defaultValue) || type === 'allPlus'))
|
|
? defaultValue
|
|
: this.hasValue(lastValue)
|
|
? lastValue
|
|
: lastOption?.value
|
|
);
|
|
const actualOptions: Option[] = options
|
|
? dedupe(
|
|
options
|
|
.map((o) => createItem(o))
|
|
.filter((o): o is Option => !!o)
|
|
)
|
|
: isDisabled && actualValue
|
|
? [createOption(actualValue)].filter((o): o is Option => !!o)
|
|
: actualValue
|
|
? this.addActual(actualValue)
|
|
: this.getFiltered();
|
|
const _defaultValue = (isMultiple || !isUndefined(value))
|
|
? undefined
|
|
: actualValue;
|
|
|
|
return (
|
|
<StyledDropdown
|
|
className={`${className} ui--InputAddress ${hideAddress ? 'hideAddress' : ''}`}
|
|
defaultValue={_defaultValue}
|
|
isDisabled={isDisabled}
|
|
isError={isError}
|
|
isMultiple={isMultiple}
|
|
label={label}
|
|
labelExtra={labelExtra}
|
|
onChange={
|
|
isMultiple
|
|
? this.onChangeMulti
|
|
: this.onChange
|
|
}
|
|
onSearch={this.onSearch}
|
|
options={
|
|
// FIXME: this is a "bit" of a HACK - the issue is that the "null"
|
|
// value from Option is not correct for the supplied type. (This
|
|
// originates in the ui repo for the KeyringOption)
|
|
actualOptions as unknown as React.ReactNode[]
|
|
}
|
|
placeholder={placeholder}
|
|
renderLabel={
|
|
isMultiple
|
|
? this.renderLabel
|
|
: undefined
|
|
}
|
|
value={
|
|
isMultiple && !value
|
|
? MULTI_DEFAULT
|
|
: value
|
|
}
|
|
withEllipsis={withEllipsis}
|
|
withLabel={withLabel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
private addActual (actualValue: string): Option[] {
|
|
const base = this.getFiltered();
|
|
|
|
return this.hasValue(actualValue)
|
|
? base
|
|
: base.concat(...[createOption(actualValue)].filter((o): o is Option => !!o));
|
|
}
|
|
|
|
private renderLabel = ({ value }: KeyringSectionOption): React.ReactNode => {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
return getAddressName(value);
|
|
};
|
|
|
|
private getLastOptionValue (): KeyringSectionOption | undefined {
|
|
const available = this.getFiltered();
|
|
|
|
return available.length
|
|
? available[available.length - 1]
|
|
: undefined;
|
|
}
|
|
|
|
private hasValue (test?: Uint8Array | string | null): boolean {
|
|
const address = test?.toString();
|
|
|
|
return this.getFiltered().some(({ value }) => value === address);
|
|
}
|
|
|
|
private getFiltered (): Option[] {
|
|
const { filter, optionsAll, type = DEFAULT_TYPE, withExclude = false } = this.props;
|
|
|
|
return !optionsAll
|
|
? []
|
|
: dedupe(optionsAll[type]).filter(({ value }) =>
|
|
!filter || (
|
|
!!value && (
|
|
withExclude
|
|
? !filter.includes(value)
|
|
: filter.includes(value)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
private onChange = (address: string): void => {
|
|
const { filter, onChange, type } = this.props;
|
|
|
|
!filter && setLastValue(type, address);
|
|
|
|
onChange && onChange(
|
|
!!address && (this.hasValue(address) || (type === 'allPlus' && isAddress(address)))
|
|
? transformToAccountId(address)
|
|
: null
|
|
);
|
|
};
|
|
|
|
private onChangeMulti = (addresses: string[]): void => {
|
|
const { onChangeMulti } = this.props;
|
|
|
|
if (onChangeMulti) {
|
|
onChangeMulti(
|
|
addresses
|
|
.map(transformToAccountId)
|
|
.filter((address): address is string => !!address)
|
|
);
|
|
}
|
|
};
|
|
|
|
private onSearch = (filteredOptions: DropdownItemProps[], _query: string): DropdownItemProps[] => {
|
|
const { isInput = true } = this.props;
|
|
const query = _query.trim();
|
|
const queryLower = query.toLowerCase();
|
|
const matches = filteredOptions.filter((item): boolean =>
|
|
!!item.value && typeof item.name === 'string' && (
|
|
(item.name.toLowerCase?.().includes(queryLower)) ||
|
|
item.value.toString().toLowerCase().includes(queryLower)
|
|
)
|
|
);
|
|
|
|
if (isInput && matches.length === 0) {
|
|
const accountId = transformToAccountId(query);
|
|
|
|
if (accountId) {
|
|
const account = keyring.getAccount(accountId);
|
|
|
|
if (account) {
|
|
matches.push({
|
|
key: account.address,
|
|
name: account.meta.name,
|
|
value: account.address
|
|
});
|
|
} else {
|
|
const item = keyring.saveRecent(
|
|
accountId.toString()
|
|
).option;
|
|
|
|
matches.push({
|
|
key: item.key,
|
|
name: item.name,
|
|
value: item.value || undefined
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// FIXME The return here is _very_ suspect, but it actually does exactly
|
|
// what it is meant to do... filter and return the options for clicking
|
|
//
|
|
// (effectively it seems to be the value type that should allow undefined
|
|
// instead of null in there...)
|
|
return matches.filter((item, index): boolean => {
|
|
const isLast = index === matches.length - 1;
|
|
const nextItem = matches[index + 1];
|
|
const hasNext = nextItem?.value;
|
|
|
|
return !(isNull(item.value) || isUndefined(item.value)) || (!isLast && !!hasNext);
|
|
});
|
|
};
|
|
}
|
|
|
|
const StyledDropdown = styled(Dropdown)`
|
|
.ui.dropdown .text {
|
|
width: 100%;
|
|
}
|
|
|
|
.ui.disabled.search {
|
|
pointer-events: all;
|
|
}
|
|
|
|
.ui.search.selection.dropdown {
|
|
> .text > .ui--KeyPair {
|
|
.ui--IdentityIcon {
|
|
left: -2.75rem;
|
|
top: -1.05rem;
|
|
|
|
> div,
|
|
img,
|
|
svg {
|
|
height: 32px !important;
|
|
width: 32px !important;
|
|
}
|
|
}
|
|
|
|
.name {
|
|
margin-left: 0;
|
|
|
|
> .ui--AccountName {
|
|
height: auto;
|
|
}
|
|
}
|
|
}
|
|
|
|
> .menu > div.item > .ui--KeyPair > .name > .ui--AccountName {
|
|
height: auto;
|
|
}
|
|
}
|
|
|
|
&.hideAddress .ui.search.selection.dropdown > .text > .ui--KeyPair .address {
|
|
flex: 0;
|
|
max-width: 0;
|
|
}
|
|
`;
|
|
|
|
const ExportedComponent = withMulti(
|
|
InputAddress,
|
|
withObservable(keyring.keyringOption.optionsSubject, {
|
|
propName: 'optionsAll',
|
|
transform: (optionsAll: KeyringOptions): Record<string, (Option | React.ReactNode)[]> =>
|
|
Object.entries(optionsAll).reduce((result: Record<string, (Option | React.ReactNode)[]>, [type, options]): Record<string, (Option | React.ReactNode)[]> => {
|
|
result[type] = options
|
|
.map((option): Option | React.ReactNode | null =>
|
|
option.value === null
|
|
? createHeader(option)
|
|
: createItem(option)
|
|
)
|
|
.filter((o): o is Option | React.ReactNode => !!o);
|
|
|
|
return result;
|
|
}, {})
|
|
})
|
|
) as ExportedType;
|
|
|
|
ExportedComponent.createOption = createItem;
|
|
ExportedComponent.setLastValue = setLastValue;
|
|
|
|
export default ExportedComponent;
|