diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx
index c0e12da..d25d8fc 100644
--- a/src/layout/sidebar/Sidebar.tsx
+++ b/src/layout/sidebar/Sidebar.tsx
@@ -28,12 +28,12 @@
{ icon:
, name: "myWallets", to: "/wallets" },
{ icon:
, name: "addressBook", to: "/friends", nyi: true },
{ icon:
, name: "transactions", to: "/me/transactions" },
- { icon:
, name: "names", to: "/me/names", nyi: true },
+ { icon:
, name: "names", to: "/me/names" },
{ icon:
, name: "mining", to: "/mining", nyi: true },
{ group: "network", icon:
, name: "blocks", to: "/network/blocks", nyi: true },
{ group: "network", icon:
, name: "transactions", to: "/network/transactions" },
- { group: "network", icon:
, name: "names", to: "/network/names", nyi: true },
+ { group: "network", icon:
, name: "names", to: "/network/names" },
{ group: "network", icon:
, name: "statistics", to: "/network/statistics", nyi: true },
];
diff --git a/src/pages/names/NamesPage.tsx b/src/pages/names/NamesPage.tsx
new file mode 100644
index 0000000..bf3e28c
--- /dev/null
+++ b/src/pages/names/NamesPage.tsx
@@ -0,0 +1,105 @@
+// 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 React, { useState, useMemo, Dispatch, SetStateAction } from "react";
+
+import { useTranslation, TFunction } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import { PageLayout } from "../../layout/PageLayout";
+import { NamesResult } from "./NamesResult";
+import { NamesTable } from "./NamesTable";
+
+import { useWallets } from "../../krist/wallets/Wallet";
+
+/** The type of name listing to search by. */
+export enum ListingType {
+ /** Names owned by the user's wallets */
+ WALLETS,
+
+ /** Names across the whole network */
+ NETWORK_ALL,
+ /** Network names filtered to a particular owner */
+ NETWORK_ADDRESS
+}
+
+const LISTING_TYPE_TITLES: Record
= {
+ [ListingType.WALLETS]: "names.titleWallets",
+ [ListingType.NETWORK_ALL]: "names.titleNetworkAll",
+ [ListingType.NETWORK_ADDRESS]: "names.titleNetworkAddress"
+};
+
+interface ParamTypes {
+ address?: string;
+}
+
+interface Props {
+ listingType: ListingType;
+}
+
+function getSiteTitle(t: TFunction, listingType: ListingType, address?: string): string {
+ switch (listingType) {
+ case ListingType.WALLETS:
+ return t("names.siteTitleWallets");
+ case ListingType.NETWORK_ALL:
+ return t("names.siteTitleNetworkAll");
+ case ListingType.NETWORK_ADDRESS:
+ return t("names.siteTitleNetworkAddress", { address });
+ }
+}
+
+export function NamesPage({ listingType }: Props): JSX.Element {
+ const { t } = useTranslation();
+ const { address } = useParams();
+
+ // If there is an error (e.g. the lookup rejected the address list due to an
+ // invalid address), the table will bubble it up to here
+ const [error, setError] = useState();
+
+ const siteTitle = getSiteTitle(t, listingType, address);
+ const subTitle = listingType === ListingType.NETWORK_ADDRESS
+ ? address : undefined;
+
+ return
+ {error
+ ?
+ : (listingType === ListingType.WALLETS
+ // Version of the table component that memoises the wallets
+ ?
+ : )}
+ ;
+}
+
+/**
+ * This is equivalent to TransactionsPage.TransactionsTableWithWallets. See that
+ * component for some comments and review on why this is necessary, and how it
+ * could be improved in the future.
+ */
+function NamesTableWithWallets({ setError }: { setError: Dispatch> }): JSX.Element {
+ const { walletAddressMap } = useWallets();
+
+ // See TransactionsPage.tsx for comments
+ // TODO: improve this
+ const addresses = Object.keys(walletAddressMap);
+ addresses.sort();
+ const addressList = addresses.join(",");
+
+ const table = useMemo(() => (
+
+ ), [addressList, setError]);
+
+ return table;
+}
diff --git a/src/pages/names/NamesResult.tsx b/src/pages/names/NamesResult.tsx
new file mode 100644
index 0000000..9208add
--- /dev/null
+++ b/src/pages/names/NamesResult.tsx
@@ -0,0 +1,41 @@
+// 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 React from "react";
+import { ExclamationCircleOutlined, QuestionCircleOutlined } from "@ant-design/icons";
+
+import { useTranslation } from "react-i18next";
+
+import { SmallResult } from "../../components/SmallResult";
+import { APIError } from "../../krist/api";
+
+interface Props {
+ error: Error;
+}
+
+export function NamesResult({ error }: Props): JSX.Element {
+ const { t } = useTranslation();
+
+ // Handle the most commonly expected errors from the API
+ if (error instanceof APIError) {
+ // Invalid address list
+ if (error.message === "invalid_parameter") {
+ return }
+ title={t("names.resultInvalidTitle")}
+ subTitle={t("names.resultInvalid")}
+ fullPage
+ />;
+ }
+ }
+
+ // Unknown error
+ return }
+ title={t("names.resultUnknownTitle")}
+ subTitle={t("names.resultUnknown")}
+ fullPage
+ />;
+}
diff --git a/src/pages/names/NamesTable.tsx b/src/pages/names/NamesTable.tsx
new file mode 100644
index 0000000..4e78efa
--- /dev/null
+++ b/src/pages/names/NamesTable.tsx
@@ -0,0 +1,174 @@
+// 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 React, { useState, useEffect, Dispatch, SetStateAction } from "react";
+import { Table } from "antd";
+
+import { useTranslation } from "react-i18next";
+
+import { KristName } from "../../krist/api/types";
+import { convertSorterOrder, lookupNames, LookupNamesOptions, LookupNamesResponse, SortableNameFields } from "../../krist/api/lookup";
+
+import { KristNameLink } from "../../components/KristNameLink";
+import { ContextualAddress } from "../../components/ContextualAddress";
+import { TransactionConciseMetadata } from "../../components/transactions/TransactionConciseMetadata";
+import { DateTime } from "../../components/DateTime";
+
+import Debug from "debug";
+const debug = Debug("kristweb:names-table");
+
+interface Props {
+ addresses?: string[];
+ setError?: Dispatch>;
+}
+
+export function NamesTable({ addresses, setError }: Props): JSX.Element {
+ const { t } = useTranslation();
+
+ const [loading, setLoading] = useState(true);
+ const [res, setRes] = useState();
+ const [options, setOptions] = useState({
+ limit: 20,
+ offset: 0,
+ orderBy: "name",
+ order: "ASC"
+ });
+
+ // Fetch the names from the API, mapping the table options
+ useEffect(() => {
+ debug("looking up names for %s", addresses ? addresses.join(",") : "network");
+ setLoading(true);
+
+ lookupNames(addresses, options)
+ .then(setRes)
+ .catch(setError)
+ .finally(() => setLoading(false));
+ }, [addresses, setError, options]);
+
+ debug("results? %b res.names.length: %d res.count: %d res.total: %d", !!res, res?.names?.length, res?.count, res?.total);
+
+ return
+ className="names-table"
+ size="small"
+
+ loading={loading}
+ dataSource={res?.names || []}
+ rowKey="name"
+
+ // Triggered whenever the filter, sorting, or pagination changes
+ onChange={(pagination, _, sorter) => {
+ const pageSize = (pagination?.pageSize) || 20;
+
+ // This will trigger a data re-fetch
+ setOptions({
+ ...options,
+
+ limit: pageSize,
+ offset: pageSize * ((pagination?.current || 1) - 1),
+
+ orderBy: sorter instanceof Array ? undefined : sorter.field as SortableNameFields,
+ order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
+ });
+ }}
+
+ pagination={{
+ size: "default",
+ position: ["topRight", "bottomRight"],
+
+ showSizeChanger: true,
+ defaultPageSize: 20,
+
+ total: res?.total || 0,
+ showTotal: total => t("names.tableTotal", { count: total || 0 })
+ }}
+
+ columns={[
+ // Name
+ {
+ title: t("names.columnName"),
+ dataIndex: "name", key: "name",
+
+ render: name => ,
+
+ sorter: true,
+ defaultSortOrder: "ascend"
+ },
+
+ // Owner
+ {
+ title: t("names.columnOwner"),
+ dataIndex: "owner", key: "owner",
+
+ render: owner => owner && (
+
+ ),
+
+ sorter: true
+ },
+
+ // Original owner
+ {
+ title: t("names.columnOriginalOwner"),
+ dataIndex: "original_owner", key: "original_owner",
+
+ render: owner => owner && (
+
+ ),
+
+ sorter: true
+ },
+
+ // A record
+ {
+ title: t("names.columnARecord"),
+ dataIndex: "a", key: "a",
+
+ render: a => ,
+
+ sorter: true
+ },
+
+ // Unpaid blocks
+ {
+ title: t("names.columnUnpaid"),
+ dataIndex: "unpaid", key: "unpaid",
+
+ // TODO: highlight this?
+ render: unpaid => unpaid && unpaid.toLocaleString(),
+ width: 50,
+
+ sorter: true
+ },
+
+ // Registered time
+ {
+ title: t("names.columnRegistered"),
+ dataIndex: "registered", key: "registered",
+
+ render: time => ,
+ width: 200,
+
+ sorter: true
+ },
+
+ // Updated time
+ {
+ title: t("names.columnUpdated"),
+ dataIndex: "updated", key: "updated",
+
+ render: time => ,
+ width: 200,
+
+ sorter: true
+ }
+ ]}
+ />;
+}
diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx
index aa73e59..71bf1a2 100644
--- a/src/pages/transactions/TransactionsPage.tsx
+++ b/src/pages/transactions/TransactionsPage.tsx
@@ -84,6 +84,10 @@
className="transactions-page"
withoutTopPadding
+ // If there's no "Include mined transactions" switch, pull the table's
+ // pagination up to the page header's extra area
+ negativeMargin={!!name}
+
// Alter the page title depending on the listing type
titleKey={LISTING_TYPE_TITLES[listingType]}
siteTitle={siteTitle}
diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx
index 1e0ee8b..2e3c560 100644
--- a/src/pages/transactions/TransactionsTable.tsx
+++ b/src/pages/transactions/TransactionsTable.tsx
@@ -115,7 +115,7 @@
title: t("transactions.columnID"),
dataIndex: "id", key: "id",
- render: id => <>{id.toLocaleString()}>,
+ render: id => id.toLocaleString(),
width: 100
// Don't allow sorting by ID to save a bit of width in the columns;