diff --git a/.vscode/settings.json b/.vscode/settings.json index c5f8f1a..00bbe57 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,6 +34,7 @@ "languagedetector", "linkify", "localisation", + "memoises", "metaname", "middot", "midiots", diff --git a/public/locales/en.json b/public/locales/en.json index bb0342d..cf043eb 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -363,6 +363,32 @@ "itemName": "<0>Name: <1 />", "itemARecord": "<0>A record: <1 />", "itemARecordRemoved": "(removed)", + "seeMore": "See all {{count, number}}..." + }, + + "transactions": { + "title": "Network Transactions", + "myTransactionsTitle": "My Transactions", + + "columnID": "ID", + "columnType": "Type", + "columnFrom": "From", + "columnTo": "To", + "columnValue": "Value", + "columnName": "Name", + "columnMetadata": "Metadata", + "columnTime": "Time", + + "tableTotal": "{{count, number}} item", + "tableTotal_plural": "{{count, number}} items", + + "includeMined": "Include mined transactions", + + "resultInvalidTitle": "Invalid address", + "resultInvalid": "That does not look like a valid Krist address.", + "resultUnknownTitle": "Unknown error", + "resultUnknown": "See console for details.", + "types": { "transferred": "Transferred", "sent": "Sent", @@ -374,7 +400,6 @@ "name_received": "Received name", "name_purchased": "Purchased name", "unknown": "Unknown" - }, - "seeMore": "See all {{count, number}}..." + } } } diff --git a/src/components/ContextualAddress.less b/src/components/ContextualAddress.less index 156acdf..1ce8da7 100644 --- a/src/components/ContextualAddress.less +++ b/src/components/ContextualAddress.less @@ -4,8 +4,14 @@ @import (reference) "../App.less"; .contextual-address { - .address-metaname, .address-name, .address-raw-metaname, .address-original, - .address-wallet, .address-address { + &:not(.contextual-address-allow-wrap) { + .address-metaname, .address-name, .address-raw-metaname, + .address-wallet { + white-space: nowrap; + } + } + + .address-address, .address-original { white-space: nowrap; } diff --git a/src/components/ContextualAddress.tsx b/src/components/ContextualAddress.tsx index 39e203e..33cd274 100644 --- a/src/components/ContextualAddress.tsx +++ b/src/components/ContextualAddress.tsx @@ -2,6 +2,7 @@ // 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 classNames from "classnames"; import { Tooltip } from "antd"; import { useSelector } from "react-redux"; @@ -10,7 +11,7 @@ import { Link } from "react-router-dom"; import { KristAddress } from "../krist/api/types"; -import { Wallet } from "../krist/wallets/Wallet"; +import { Wallet, useWallets } from "../krist/wallets/Wallet"; import { parseCommonMeta, CommonMeta } from "../utils/commonmeta"; import { stripNameSuffix } from "../utils/currency"; @@ -20,10 +21,12 @@ interface Props { address: KristAddress | string | null; - wallet?: Wallet; + wallet?: Wallet | false; metadata?: string; source?: boolean; hideNameAddress?: boolean; + allowWrap?: boolean; + className?: string; } interface AddressMetanameProps { @@ -63,8 +66,17 @@ ); } -export function ContextualAddress({ address: origAddress, wallet, metadata, source, hideNameAddress }: Props): JSX.Element { +export function ContextualAddress({ + address: origAddress, + wallet: origWallet, + metadata, + source, + hideNameAddress, + allowWrap, + className +}: Props): JSX.Element { const { t } = useTranslation(); + const { walletAddressMap } = useWallets(); const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); if (!origAddress) return ( @@ -72,10 +84,21 @@ ); const address = typeof origAddress === "object" ? origAddress.address : origAddress; + + // If we were given a wallet, use it. Otherwise, look it up, unless it was + // explicitly excluded (e.g. the Wallets table) + const wallet = origWallet !== false + ? (origWallet || walletAddressMap[address]) + : undefined; + const commonMeta = parseCommonMeta(nameSuffix, metadata); const hasMetaname = source ? !!commonMeta?.returnRecipient : !!commonMeta?.recipient; - return + const classes = classNames("contextual-address", className, { + "contextual-address-allow-wrap": allowWrap + }); + + return {commonMeta && hasMetaname ? ( // Display the metaname and link to the name if possible diff --git a/src/components/KristNameLink.tsx b/src/components/KristNameLink.tsx index 7faf1e6..1227981 100644 --- a/src/components/KristNameLink.tsx +++ b/src/components/KristNameLink.tsx @@ -15,12 +15,15 @@ } type Props = React.HTMLProps & OwnProps; -export function KristNameLink({ name, noLink, ...props }: Props): JSX.Element { +export function KristNameLink({ name, noLink, ...props }: Props): JSX.Element | null { const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); - const contents = `${name}.${nameSuffix}`; + if (!name) return null; - return + const contents = `${name}.${nameSuffix}`; + const classes = classNames("krist-name", props.className); + + return {noLink ? contents : {contents}} diff --git a/src/components/transactions/TransactionConciseMetadata.less b/src/components/transactions/TransactionConciseMetadata.less new file mode 100644 index 0000000..c610c10 --- /dev/null +++ b/src/components/transactions/TransactionConciseMetadata.less @@ -0,0 +1,16 @@ +// 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 (reference) "../../App.less"; + +.transaction-concise-metadata { + color: @kw-text-tertiary; + font-family: monospace; + font-size: 85%; + + &-truncated::after { + content: "\2026"; + color: @text-color-secondary; + user-select: none; + } +} diff --git a/src/components/transactions/TransactionConciseMetadata.tsx b/src/components/transactions/TransactionConciseMetadata.tsx new file mode 100644 index 0000000..9c315ff --- /dev/null +++ b/src/components/transactions/TransactionConciseMetadata.tsx @@ -0,0 +1,46 @@ +// 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 classNames from "classnames"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; + +import { KristTransaction } from "../../krist/api/types"; +import { stripNameFromMetadata } from "../../utils/currency"; + +import "./TransactionConciseMetadata.less"; + +interface Props { + transaction: KristTransaction; + limit?: number; + className?: string; +} + +/** + * Trims the name and metaname from the start of metadata, and truncates it + * to a specified amount of characters. + */ +export function TransactionConciseMetadata({ transaction, limit = 30, className }: Props): JSX.Element | null { + const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + + // Don't render anything if there's no metadata (after the hooks) + if (!transaction || !transaction.metadata) return null; + + // Strip the name from the start of the transaction metadata, if it is present + const hasName = transaction.sent_name || transaction.sent_metaname; + const withoutName = hasName + ? stripNameFromMetadata(nameSuffix, transaction.metadata) + : transaction.metadata; + + // Trim it down to the limit if necessary + const wasTruncated = withoutName.length > limit; + const truncated = wasTruncated ? withoutName.substr(0, limit) : withoutName; + + const classes = classNames("transaction-concise-metadata", className, { + "transaction-concise-metadata-truncated": wasTruncated + }); + + return {truncated}; +} diff --git a/src/components/transactions/TransactionItem.tsx b/src/components/transactions/TransactionItem.tsx index 397d058..f8c93f1 100644 --- a/src/components/transactions/TransactionItem.tsx +++ b/src/components/transactions/TransactionItem.tsx @@ -14,11 +14,7 @@ import { KristValue } from "../KristValue"; import { KristNameLink } from "../KristNameLink"; import { ContextualAddress } from "../ContextualAddress"; - -type InternalTxType = "transferred" | "sent" | "received" | "mined" | - "name_a_record" | "name_transferred" | "name_sent" | "name_received" | - "name_purchased" | "unknown"; -const TYPES_SHOW_VALUE = ["transferred", "sent", "received", "mined", "name_purchased"]; +import { getTransactionType, TransactionType, TYPES_SHOW_VALUE } from "./TransactionType"; const MAX_A_LENGTH = 24; @@ -29,29 +25,6 @@ wallets: Record; } -function getTxType(tx: KristTransaction, from: Wallet | undefined, to: Wallet | undefined): InternalTxType { - switch (tx.type) { - case "transfer": - if (from && to) return "transferred"; - if (from) return "sent"; - if (to) return "received"; - return "transferred"; - - case "name_transfer": - if (from && to) return "name_transferred"; - if (from) return "name_sent"; - if (to) return "name_received"; - return "name_transferred"; - - case "name_a_record": return "name_a_record"; - case "name_purchase": return "name_purchased"; - - case "mined": return "mined"; - - default: return "unknown"; - } -} - export function TransactionARecord({ metadata }: { metadata: string | undefined | null }): JSX.Element { const { t } = useTranslation(); @@ -77,9 +50,9 @@ // Whether or not the from/to addresses are a wallet we own // TODO: Address book here too const fromWallet = tx.from ? wallets[tx.from] : undefined; - const toWallet = tx.to ? wallets[tx.to] : undefined; + const toWallet = tx.to ? wallets[tx.to] : undefined; - const type = getTxType(tx, fromWallet, toWallet); + const type = getTransactionType(tx, fromWallet, toWallet); const txTime = new Date(tx.time); const isNew = (new Date().getTime() - txTime.getTime()) < 360000; @@ -96,9 +69,7 @@ {/* Transaction type and link to transaction */} - - {t("transactionSummary.types." + type)} - + {/* Transaction time */} diff --git a/src/components/transactions/TransactionSummary.less b/src/components/transactions/TransactionSummary.less index 01206ea..799e996 100644 --- a/src/components/transactions/TransactionSummary.less +++ b/src/components/transactions/TransactionSummary.less @@ -11,22 +11,6 @@ flex-direction: column; justify-content: center; - .transaction-type { - a { - font-weight: bold; - color: @text-color-secondary; - } - - &-transferred a, &-name_transferred a { color: @kw-primary; } - &-sent a, &-name_sent a, &-name_purchased a { color: @kw-orange; } - &-received a, &-mined a, &-name_received a { color: @kw-green; } - &-name_a_record a { color: @kw-purple; } - - @media (max-width: @screen-xl) { - font-size: 90%; - } - } - .transaction-time { color: @text-color-secondary; font-size: 90%; diff --git a/src/components/transactions/TransactionType.less b/src/components/transactions/TransactionType.less new file mode 100644 index 0000000..6ea5205 --- /dev/null +++ b/src/components/transactions/TransactionType.less @@ -0,0 +1,26 @@ +// 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 (reference) "../../App.less"; + +.transaction-type { + a { + user-select: none; + + font-weight: bold; + color: @text-color-secondary; + } + + &-transferred a, &-name_transferred a { color: @kw-primary; } + &-sent a, &-name_sent a, &-name_purchased a { color: @kw-orange; } + &-received a, &-mined a, &-name_received a { color: @kw-green; } + &-name_a_record a { color: @kw-purple; } + + &-no-link a { + cursor: default; + } + + @media (max-width: @screen-xl) { + font-size: 90%; + } +} diff --git a/src/components/transactions/TransactionType.tsx b/src/components/transactions/TransactionType.tsx new file mode 100644 index 0000000..f770e16 --- /dev/null +++ b/src/components/transactions/TransactionType.tsx @@ -0,0 +1,74 @@ +// 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 classNames from "classnames"; + +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { KristTransaction } from "../../krist/api/types"; +import { Wallet, useWallets } from "../../krist/wallets/Wallet"; + +import "./TransactionType.less"; + +export type InternalTransactionType = "transferred" | "sent" | "received" | "mined" | + "name_a_record" | "name_transferred" | "name_sent" | "name_received" | + "name_purchased" | "unknown"; +export const TYPES_SHOW_VALUE = ["transferred", "sent", "received", "mined", "name_purchased"]; + +export function getTransactionType(tx: KristTransaction, from?: Wallet, to?: Wallet): InternalTransactionType { + switch (tx.type) { + case "transfer": + if (from && to) return "transferred"; + if (from) return "sent"; + if (to) return "received"; + return "transferred"; + + case "name_transfer": + if (from && to) return "name_transferred"; + if (from) return "name_sent"; + if (to) return "name_received"; + return "name_transferred"; + + case "name_a_record": return "name_a_record"; + case "name_purchase": return "name_purchased"; + + case "mined": return "mined"; + + default: return "unknown"; + } +} + +interface OwnProps { + type?: InternalTransactionType; + transaction?: KristTransaction; + from?: Wallet; + to?: Wallet; + link?: string; +} +type Props = React.HTMLProps & OwnProps; + +export function TransactionType({ type, transaction, from, to, link, className }: Props): JSX.Element { + const { t } = useTranslation(); + const { walletAddressMap } = useWallets(); + + // If we weren't already given the wallets (and we need them to calculate the + // type), get them + const fromWallet = !type && transaction?.from ? (from || walletAddressMap[transaction.from]) : undefined; + const toWallet = !type && transaction?.to ? (to || walletAddressMap[transaction.to]) : undefined; + + // If we weren't already given the type, calculate it + const finalType = type || (transaction ? getTransactionType(transaction, fromWallet, toWallet) : "unknown"); + + const contents = t("transactions.types." + finalType); + const classes = classNames("transaction-type", "transaction-type-" + finalType, className, { + "transaction-type-no-link": !link + }); + + return + {link + ? {contents} + : {contents}} + ; +} diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index a173c64..1211ee0 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -8,6 +8,7 @@ import { WalletsPage } from "../pages/wallets/WalletsPage"; import { AddressPage } from "../pages/addresses/AddressPage"; +import { TransactionsPage, ListingType } from "../pages/transactions/TransactionsPage"; import { SettingsPage } from "../pages/settings/SettingsPage"; import { SettingsTranslations } from "../pages/settings/SettingsTranslations"; @@ -25,8 +26,23 @@ export const APP_ROUTES: AppRoute[] = [ { path: "/", name: "dashboard", component: }, { path: "/wallets", name: "wallets", component: }, + { + path: "/me/transactions", + name: "myTransactions", + component: + }, { path: "/network/addresses/:address", name: "address", component: }, + { + path: "/network/addresses/:address/transactions", + name: "addressTransactions", + component: + }, + { + path: "/network/transactions", + name: "transactions", + component: + }, { path: "/settings", name: "settings", component: }, { path: "/settings/debug", name: "settingsDebug" }, diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts index 8e59f13..70f6d29 100644 --- a/src/krist/api/lookup.ts +++ b/src/krist/api/lookup.ts @@ -52,11 +52,13 @@ // ============================================================================= // Transactions // ============================================================================= -interface LookupTransactionsOptions { +export type SortableTransactionFields = "id" | "from" | "to" | "value" | "time" + | "sent_name" | "sent_metaname"; +export interface LookupTransactionsOptions { includeMined?: boolean; limit?: number; offset?: number; - orderBy?: "id" | "from" | "to" | "value" | "time"; + orderBy?: SortableTransactionFields; order?: "ASC" | "DESC"; } @@ -66,9 +68,7 @@ transactions: KristTransaction[]; } -export async function lookupTransactions(addresses: string[], opts: LookupTransactionsOptions): Promise { - if (!addresses || addresses.length === 0) return { count: 0, total: 0, transactions: [] }; - +export async function lookupTransactions(addresses: string[] | undefined, opts: LookupTransactionsOptions): Promise { const qs = new URLSearchParams(); if (opts.includeMined) qs.append("includeMined", ""); if (opts.limit) qs.append("limit", opts.limit.toString()); @@ -78,7 +78,9 @@ return await api.get( "lookup/transactions/" - + encodeURIComponent(addresses.join(",")) + + (addresses && addresses.length > 0 + ? encodeURIComponent(addresses.join(",")) + : "") + "?" + qs ); } @@ -86,10 +88,12 @@ // ============================================================================= // Names // ============================================================================= -interface LookupNamesOptions { +export type SortableNameFields = "name" | "owner" | "original_owner" + | "registered" | "updated" | "a" | "unpaid"; +export interface LookupNamesOptions { limit?: number; offset?: number; - orderBy?: "name" | "owner" | "original_owner" | "registered" | "updated" | "a" | "unpaid"; + orderBy?: SortableNameFields; order?: "ASC" | "DESC"; } @@ -114,3 +118,12 @@ + "?" + qs ); } + +export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined { + switch (order) { + case "ascend": + return "ASC"; + case "descend": + return "DESC"; + } +} diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less index a659af1..efddcd7 100644 --- a/src/layout/PageLayout.less +++ b/src/layout/PageLayout.less @@ -17,4 +17,10 @@ padding: @padding-lg; } + + &.page-layout-no-top-padding { + .page-layout-contents { + padding-top: 0; + } + } } diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index bd36c7d..3d26a31 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -2,6 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { FC, useEffect } from "react"; +import classNames from "classnames"; import { PageHeader } from "antd"; import { useTranslation } from "react-i18next"; @@ -21,6 +22,7 @@ noHeader?: boolean; className?: string; + withoutTopPadding?: boolean; } export const PageLayout: FC = ({ @@ -31,6 +33,7 @@ extra, noHeader, className, + withoutTopPadding, children, ...rest }) => { @@ -42,7 +45,11 @@ else if (siteTitleKey) document.title = `${t(siteTitleKey)} - KristWeb`; }, [t, siteTitle, siteTitleKey]); - return
+ const classes = classNames("page-layout", className, { + "page-layout-no-top-padding": withoutTopPadding + }); + + return
{/* Page header */} {!noHeader && (title || titleKey) && , name: "dashboard", to: "/" }, { icon: , name: "myWallets", to: "/wallets" }, { icon: , name: "addressBook", to: "/friends", nyi: true }, - { icon: , name: "transactions", to: "/me/transactions", nyi: true }, + { icon: , name: "transactions", to: "/me/transactions" }, { icon: , name: "names", to: "/me/names", nyi: true }, { 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", nyi: true }, + { group: "network", icon: , name: "transactions", to: "/network/transactions" }, { group: "network", icon: , name: "names", to: "/network/names", nyi: true }, { group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true }, ]; diff --git a/src/pages/addresses/AddressNamesCard.tsx b/src/pages/addresses/AddressNamesCard.tsx index cac9a62..afafe27 100644 --- a/src/pages/addresses/AddressNamesCard.tsx +++ b/src/pages/addresses/AddressNamesCard.tsx @@ -38,6 +38,10 @@ useEffect(() => { if (!syncNode) return; + // Remove the existing results in case the address changed + setRes(undefined); + setLoading(true); + fetchNames(address) .then(setRes) .catch(setError) diff --git a/src/pages/addresses/AddressPage.less b/src/pages/addresses/AddressPage.less index fe54b42..adbdfbd 100644 --- a/src/pages/addresses/AddressPage.less +++ b/src/pages/addresses/AddressPage.less @@ -8,12 +8,23 @@ display: flex; align-items: center; - h1.address { + .address { display: inline-block; margin-right: @margin-lg; margin-bottom: 0; font-size: @font-size-base * 2; + font-weight: 500; + + .ant-typography-copy { + line-height: 1 !important; + margin-left: @padding-xs; + + .anticon { + font-size: @font-size-base; + vertical-align: 0; + } + } } .ant-btn { diff --git a/src/pages/addresses/AddressPage.tsx b/src/pages/addresses/AddressPage.tsx index 4c3a206..c49846a 100644 --- a/src/pages/addresses/AddressPage.tsx +++ b/src/pages/addresses/AddressPage.tsx @@ -2,7 +2,7 @@ // 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 } from "react"; -import { Row, Col, Skeleton, Tag } from "antd"; +import { Row, Col, Skeleton, Tag, Typography } from "antd"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; @@ -24,6 +24,8 @@ import "./AddressPage.less"; +const { Text } = Typography; + interface ParamTypes { address: string; } @@ -39,7 +41,9 @@ {/* Address and buttons */} {/* Address */} -

{address.address}

+ + {address.address} + {/* Buttons (e.g. Send Krist, Add friend) */} diff --git a/src/pages/addresses/AddressTransactionsCard.tsx b/src/pages/addresses/AddressTransactionsCard.tsx index 2914713..4a0c70b 100644 --- a/src/pages/addresses/AddressTransactionsCard.tsx +++ b/src/pages/addresses/AddressTransactionsCard.tsx @@ -39,6 +39,10 @@ useEffect(() => { if (!syncNode) return; + // Remove the existing results in case the address changed + setRes(undefined); + setLoading(true); + fetchTransactions(address) .then(setRes) .catch(setError) diff --git a/src/pages/dashboard/TransactionsCard.tsx b/src/pages/dashboard/TransactionsCard.tsx index c6d8b0f..4305122 100644 --- a/src/pages/dashboard/TransactionsCard.tsx +++ b/src/pages/dashboard/TransactionsCard.tsx @@ -25,6 +25,12 @@ async function _fetchTransactions(wallets: WalletMap): Promise { debug("fetching transactions"); + // If we have no addresses, don't make a request, because it will return + // _all_ network transactions (in hindsight, this was kinda bad API design) + const addresses = Object.values(wallets).map(w => w.address); + if (!addresses || addresses.length === 0) + return { count: 0, total: 0, transactions: [] }; + return lookupTransactions( Object.values(wallets).map(w => w.address), { includeMined: true, limit: 5, orderBy: "id", order: "DESC" } diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx new file mode 100644 index 0000000..268424c --- /dev/null +++ b/src/pages/transactions/TransactionsPage.tsx @@ -0,0 +1,127 @@ +// 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 { Switch } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { PageLayout } from "../../layout/PageLayout"; +import { TransactionsResult } from "./TransactionsResult"; +import { TransactionsTable } from "./TransactionsTable"; + +import { useWallets } from "../../krist/wallets/Wallet"; + +/** The type of transaction listing to search by. */ +export enum ListingType { + /** Transactions involving the user's wallets */ + WALLETS, + + /** Transactions across the whole network */ + NETWORK_ALL, + /** Network transactions filtered to a particular address */ + NETWORK_ADDRESS +} + +interface ParamTypes { + address?: string; +} + +interface Props { + listingType: ListingType; +} + +export function TransactionsPage({ 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 [includeMined, setIncludeMined] = useState(false); + + return + + {t("transactions.includeMined")} + } + > + {error + ? + : (listingType === ListingType.WALLETS + ? ( + // Version of the table component that memoises the wallets + + ) + : ( + + ))} + ; +} + +interface TableWithWalletsProps { + includeMined: boolean; + setError: Dispatch>; +} + +/** + * We only want to fetch the wallets (because changes will cause re-renders) + * if this is a wallet transactions listing. In order to achieve a conditional + * hook, the table is wrapped in this component only if the listing type is + * WALLETS. + */ +function TransactionsTableWithWallets({ includeMined, setError }: TableWithWalletsProps): JSX.Element { + const { walletAddressMap } = useWallets(); + const addresses = Object.keys(walletAddressMap); + + // The instance created by Object.keys is going to be different every time, + // but its values probably won't change. So, we're going to need a rather + // crude deep equality check. + // TODO: Perhaps do this check on the whole wallets object, so that we can + // auto-refresh the table when balances change, but still avoid + // refreshing otherwise. + // REVIEW: Another idea is to store a 'lastTransactionID' in Redux, + // concerning only our own wallets (and, in the future, a subscribed + // wallet when viewing an external address?). This way, we can still + // auto-refresh whenever we have to (a wallet is added/removed, a + // transaction is made to one of our wallets), and never when we + // don't. + addresses.sort(); + const addressList = addresses.join(","); + + const table = useMemo(() => ( + + ), [addressList, includeMined, setError]); + + return table; +} diff --git a/src/pages/transactions/TransactionsResult.tsx b/src/pages/transactions/TransactionsResult.tsx new file mode 100644 index 0000000..1047c5d --- /dev/null +++ b/src/pages/transactions/TransactionsResult.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 TransactionsResult({ 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("transactions.resultInvalidTitle")} + subTitle={t("transactions.resultInvalid")} + fullPage + />; + } + } + + // Unknown error + return } + title={t("transactions.resultUnknownTitle")} + subTitle={t("transactions.resultUnknown")} + fullPage + />; +} diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx new file mode 100644 index 0000000..4f1f21c --- /dev/null +++ b/src/pages/transactions/TransactionsTable.tsx @@ -0,0 +1,176 @@ +// 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 { KristTransaction } from "../../krist/api/types"; +import { convertSorterOrder, lookupTransactions, LookupTransactionsOptions, LookupTransactionsResponse, SortableTransactionFields } from "../../krist/api/lookup"; + +import { TransactionType } from "../../components/transactions/TransactionType"; +import { ContextualAddress } from "../../components/ContextualAddress"; +import { KristValue } from "../../components/KristValue"; +import { KristNameLink } from "../../components/KristNameLink"; +import { TransactionConciseMetadata } from "../../components/transactions/TransactionConciseMetadata"; +import { DateTime } from "../../components/DateTime"; + +import Debug from "debug"; +const debug = Debug("kristweb:transactions-table"); + +interface Props { + addresses?: string[]; + includeMined?: boolean; + setError?: Dispatch>; +} + +export function TransactionsTable({ addresses, includeMined, 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: "time", + order: "DESC" + }); + + // Fetch the transactions from the API, mapping the table options + useEffect(() => { + debug("looking up transactions for %s", addresses ? addresses.join(",") : "network"); + setLoading(true); + + lookupTransactions(addresses, { ...options, includeMined }) + .then(setRes) + .catch(setError) + .finally(() => setLoading(false)); + }, [addresses, setError, options, includeMined ]); + + debug("results? %b res.transactions.length: %d res.count: %d res.total: %d", !!res, res?.transactions?.length, res?.count, res?.total); + + return + className="transactions-table" + size="small" + + loading={loading} + dataSource={res?.transactions || []} + rowKey="id" + + onChange={(pagination, _, sorter) => { + const pageSize = (pagination?.pageSize) || 20; + setOptions({ + ...options, + + limit: pageSize, + offset: pageSize * ((pagination?.current || 1) - 1), + + orderBy: sorter instanceof Array ? undefined : sorter.field as SortableTransactionFields, + 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("transactions.tableTotal", { count: total || 0 }) + }} + + columns={[ + // ID + { + title: t("transactions.columnID"), + dataIndex: "id", key: "id", + + render: id => <>{id.toLocaleString()}, + width: 100, + }, + // Type + { + title: t("transactions.columnType"), + dataIndex: "type", key: "type", + render: (_, tx) => + }, + + // From + { + title: t("transactions.columnFrom"), + dataIndex: "from", key: "from", + + render: (from, tx) => from && ( + + ), + + sorter: true + }, + // To + { + title: t("transactions.columnTo"), + dataIndex: "to", key: "to", + + render: (to, tx) => to && tx.type !== "name_a_record" && ( + + ), + + sorter: true + }, + + // Value + { + title: t("transactions.columnValue"), + dataIndex: "value", key: "value", + + render: value => , + width: 100, + + sorter: true + }, + + // Name + { + title: t("transactions.columnName"), + dataIndex: "name", key: "name", + + render: name => , + + sorter: true + }, + + // Metadata + { + title: t("transactions.columnMetadata"), + dataIndex: "metadata", key: "metadata", + + render: (_, transaction) => , + width: 260 + }, + + // Time + { + title: t("transactions.columnTime"), + dataIndex: "time", key: "time", + render: time => , + width: 200, + + sorter: true, + defaultSortOrder: "descend" + } + ]} + />; +} diff --git a/src/pages/wallets/WalletsTable.tsx b/src/pages/wallets/WalletsTable.tsx index e0b562d..50613b4 100644 --- a/src/pages/wallets/WalletsTable.tsx +++ b/src/pages/wallets/WalletsTable.tsx @@ -106,7 +106,7 @@ title: t("myWallets.columnAddress"), dataIndex: "address", key: "address", - render: address => , + render: address => , sorter: (a, b) => a.address.localeCompare(b.address) }, diff --git a/src/style/components.less b/src/style/components.less index 67cc0d4..60a2508 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -148,6 +148,11 @@ background: @kw-primary; } } + + .ant-pagination-jump-prev .ant-pagination-item-container .ant-pagination-item-ellipsis, + .ant-pagination-jump-next .ant-pagination-item-container .ant-pagination-item-ellipsis { + color: lighten(@kw-lighter, 5%); + } } .text-small { diff --git a/src/style/theme.less b/src/style/theme.less index 6c03583..dca0f5e 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -6,6 +6,7 @@ // --- @kw-text: #eaf0fe; @kw-text-secondary: #8991ab; +@kw-text-tertiary: mix(@kw-text, @kw-text-secondary, 30%); @kw-light: #343a56; @kw-lighter: #434a6b; diff --git a/src/utils/consoleWarning.ts b/src/utils/consoleWarning.ts index a8a1c15..4e65c9d 100644 --- a/src/utils/consoleWarning.ts +++ b/src/utils/consoleWarning.ts @@ -1,3 +1,7 @@ +// 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 + // Present a warning to the user warning about the dangers of Self-XSS. // Shamelessly based on Facebook and Discord's warning. // diff --git a/src/utils/currency.ts b/src/utils/currency.ts index 11e8757..f1b903f 100644 --- a/src/utils/currency.ts +++ b/src/utils/currency.ts @@ -14,8 +14,8 @@ // Cheap way to avoid RegExp DoS const MAX_NAME_SUFFIX_LENGTH = 6; -const _getNameRegex = (nameSuffix: string | undefined | null): RegExp => - new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64}\\.${cleanNameSuffix(nameSuffix)})$`); +const _getNameRegex = (nameSuffix: string | undefined | null, metadata?: boolean): RegExp => + new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64}\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`); export const getNameRegex = memoize(_getNameRegex); const _stripNameSuffixRegExp = (nameSuffix: string | undefined | null): RegExp => @@ -25,6 +25,9 @@ export const stripNameSuffix = (nameSuffix: string | undefined | null, inp: string): string => inp.replace(stripNameSuffixRegExp(nameSuffix), ""); +export const stripNameFromMetadata = (nameSuffix: string | undefined | null, metadata: string): string => + metadata.replace(getNameRegex(nameSuffix, true), ""); + /** * Estimates the network mining hash-rate, returning it as a formatted string. *