// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import {
useState, useEffect, useMemo, useRef, Dispatch, SetStateAction, Ref,
MutableRefObject
} from "react";
import { Select, Form, Input, Button } from "antd";
import { RefSelectProps } from "antd/lib/select";
import { useTranslation, TFunction } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import { WalletAddressMap, useWallets } from "@wallets";
import { NameOptionGroup, fetchNames } from "./lookupNames";
import { useNameSuffix } from "@utils/currency";
import shallowEqual from "shallowequal";
import { throttle } from "lodash-es";
import Debug from "debug";
const debug = Debug("kristweb:name-picker");
const FETCH_THROTTLE = 2000;
export async function _fetchNames(
t: TFunction,
nameSuffix: string,
wallets: WalletAddressMap,
setOptions: Dispatch<SetStateAction<NameOptionGroup[] | null>>,
isMounted: MutableRefObject<boolean>
): Promise<void> {
if (!isMounted.current) {
debug("unmounted name picker lookup result skipped");
return;
}
setOptions(await fetchNames(t, nameSuffix, wallets));
}
interface Props {
formName: string;
label?: string;
tabIndex?: number;
value?: string[];
setValue?: (value: string[]) => void;
filterOwner?: string;
suppressUpdates?: boolean;
multiple?: boolean;
allowAll?: boolean;
inputRef?: Ref<RefSelectProps>;
}
export function NamePicker({
formName,
label,
tabIndex,
value,
setValue,
filterOwner,
suppressUpdates,
multiple,
allowAll,
inputRef,
...props
}: Props): JSX.Element {
const { t } = useTranslation();
// Used for clean-up
const isMounted = useRef(true);
// Used to fetch the list of available names
const { walletAddressMap, joinedAddressList } = useWallets();
const throttledFetchNames = useMemo(() =>
throttle(_fetchNames, FETCH_THROTTLE, { leading: true }), []);
const nameSuffix = useNameSuffix();
// The actual list of available names (pre-filtered, not rendered yet)
const [nameOptions, setNameOptions]
= useState<NameOptionGroup[] | null>(null);
const [filteredOptions, setFilteredOptions]
= useState<JSX.Element[] | null>(null);
// Used for auto-refreshing the names if they happen to update
const refreshID = useSelector((s: RootState) => s.node.lastOwnNameTransactionID);
// Whether or not to show the 'All' button for bulk name management. On by
// default.
const showAllButton = allowAll !== false && multiple !== false;
// Fetch the name list on mount/when the address list changes, or when one of
// our wallets receives a name transaction.
useEffect(() => {
// Skip doing anything when unmounted to avoid illegal state updates
if (!isMounted.current) return debug("unmounted skipped lookup useEffect");
if (suppressUpdates) return debug("name picker lookup suppressed");
debug(
"addressList updated (%s, %s, %d)",
joinedAddressList, nameSuffix, refreshID
);
throttledFetchNames(
t,
nameSuffix,
walletAddressMap,
setNameOptions,
isMounted
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
throttledFetchNames, t, nameSuffix, refreshID, joinedAddressList,
suppressUpdates
]);
// If passed an address, filter out that address from the results. Used to
// prevent sending names to the existing owner. Renders the name options.
useEffect(() => {
// Skip doing anything when unmounted to avoid illegal state updates
if (!isMounted.current) return debug("unmounted skipped filter useEffect");
if (!nameOptions) {
setFilteredOptions(null);
return;
}
const filteredGroups = nameOptions
.filter(group => filterOwner ? group.key !== filterOwner : true);
// If there are any invalid names in the form value, remove them here
if (value && setValue) {
// Convert the available names to a lookup table
const filteredNames = filteredGroups
.flatMap(g => g.names)
.reduce((out, o) => {
out[o.key] = true;
return out;
}, {} as Record<string, boolean>);
// Remove any names in the value that aren't in the lookup table
const newValue = value?.filter(v => !!filteredNames[v]);
const equal = shallowEqual(value, newValue);
debug(
"updating value (filterOwner: %s) to %o (equal: %b)",
filterOwner, newValue, equal
);
// If the values are different, set the new one
if (!equal) setValue(newValue);
}
setFilteredOptions(filteredGroups.map(renderNameOptions));
}, [nameOptions, filterOwner, value, setValue]);
// Clean up the debounced function when unmounting
useEffect(() => {
return () => {
debug("unmounting name picker");
isMounted.current = false;
throttledFetchNames?.cancel();
};
}, [throttledFetchNames]);
// Select all available names
function selectAll() {
if (!nameOptions || !setValue) return;
// Filter out names from filterOwner if applicable
const filteredGroups = nameOptions
.filter(group => filterOwner ? group.key !== filterOwner : true);
const names = filteredGroups
.flatMap(g => g.names)
.map(n => n.value);
setValue(names);
}
return <Form.Item
label={label}
required
{...props}
>
<Input.Group compact style={{ display: "flex" }}>
{/* Name select */}
<Form.Item
name={formName}
style={{ flex: 1, marginBottom: 0 }}
validateFirst
rules={[
{ required: true, message: t("nameTransfer.errorNameRequired") }
]}
>
<Select
showSearch
placeholder={multiple !== false
? t("namePicker.placeholderMultiple")
: t("namePicker.placeholder")}
style={{ width: "100%" }}
allowClear
mode={multiple !== false ? "multiple" : undefined}
maxTagCount={5}
loading={!nameOptions}
// Filter by name with suffix case insensitively
filterOption={(input, option) => {
// Display all groups
if (option?.options) return false;
const name = option?.["data-name"] || option?.value;
if (!name) return false;
return name.toUpperCase().indexOf(input.toUpperCase()) >= 0;
}}
ref={inputRef}
tabIndex={tabIndex}
>
{filteredOptions}
</Select>
</Form.Item>
{/* "All" button */}
{showAllButton && <div>
<Button onClick={selectAll} >
{t("namePicker.buttonAll")}
</Button>
</div>}
</Input.Group>
</Form.Item>;
}
function renderNameOptions(group: NameOptionGroup): JSX.Element {
// Group by owning wallet
return <Select.OptGroup key={group.key} label={group.label}>
{/* Each individual name */}
{group.names.map(name => (
<Select.Option
key={name.key}
value={name.value}
data-name={name.name}
>
{name.name}
</Select.Option>
))}
</Select.OptGroup>;
}