diff --git a/public/locales/en.json b/public/locales/en.json index a7324a7..d85940b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -528,5 +528,46 @@ "resultNotFound": "That block does not exist.", "resultUnknownTitle": "Unknown error", "resultUnknown": "See console for details." + }, + + "transaction": { + "title": "Transaction", + "siteTitle": "Transaction", + "siteTitleTransaction": "Transaction #{{id, number}}", + "subTitleTransaction": "#{{id, number}}", + + "type": "Type", + "from": "From", + "to": "To", + "address": "Address", + "name": "Name", + "value": "Value", + "time": "Time", + "aRecord": "A record", + + "cardMetadataTitle": "Metadata", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Raw", + + "commonMetaError": "CommonMeta parsing failed.", + "commonMetaParsed": "Parsed records", + "commonMetaParsedHelp": "These values were not directly contained in the transaction metadata, but they were inferred by the CommonMeta parser.", + "commonMetaCustom": "Transaction records", + "commonMetaCustomHelp": "These values were directly contained in the transaction metadata.", + "commonMetaColumnKey": "Key", + "commonMetaColumnValue": "Value", + + "cardRawDataTitle": "Raw data", + "cardRawDataHelp": "The transaction exactly as it was returned by the Krist API.", + + "rawDataColumnKey": "Key", + "rawDataColumnValue": "Value", + + "resultInvalidTitle": "Invalid transaction ID", + "resultInvalid": "That does not look like a valid transaction ID.", + "resultNotFoundTitle": "Transaction not found", + "resultNotFound": "That transaction does not exist.", + "resultUnknownTitle": "Unknown error", + "resultUnknown": "See console for details." } } diff --git a/src/components/HelpIcon.less b/src/components/HelpIcon.less new file mode 100644 index 0000000..b75ea17 --- /dev/null +++ b/src/components/HelpIcon.less @@ -0,0 +1,14 @@ +// 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"; + +.kw-help-icon { + display: inline-block; + margin-left: @padding-xs; + + font-size: 90%; + + color: @text-color-secondary; + cursor: pointer; +} diff --git a/src/components/HelpIcon.tsx b/src/components/HelpIcon.tsx new file mode 100644 index 0000000..4be61cd --- /dev/null +++ b/src/components/HelpIcon.tsx @@ -0,0 +1,27 @@ +// 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 { Tooltip } from "antd"; +import { QuestionCircleOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import "./HelpIcon.less"; + +interface Props { + text?: string; + textKey?: string; + className?: string; +} + +export function HelpIcon({ text, textKey, className }: Props): JSX.Element { + const { t } = useTranslation(); + + const classes = classNames("kw-help-icon", className); + + return + + ; +} diff --git a/src/components/NameARecordLink.less b/src/components/NameARecordLink.less new file mode 100644 index 0000000..593f5e1 --- /dev/null +++ b/src/components/NameARecordLink.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"; + +.name-a-record-link { + background: @kw-darker; + border-radius: @border-radius-base; + + display: inline-block; + margin-top: @padding-xs; + padding: 0.25rem @padding-xs; + + font-size: @font-size-base * 0.9; + font-family: monospace; +} diff --git a/src/components/NameARecordLink.tsx b/src/components/NameARecordLink.tsx new file mode 100644 index 0000000..009ffe5 --- /dev/null +++ b/src/components/NameARecordLink.tsx @@ -0,0 +1,61 @@ +// 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 { stripNameSuffix } from "../utils/currency"; + +import { KristNameLink } from "./KristNameLink"; + +import "./NameARecordLink.less"; + +function forceURL(link: string): string { + // TODO: this is rather crude + if (!link.startsWith("http")) return "https://" + link; + return link; +} + +interface Props { + a?: string; + className?: string; +} + +export function NameARecordLink({ a, className }: Props): JSX.Element | null { + const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + + if (!a) return null; + + const classes = classNames("name-a-record-link", className); + + // I don't have a citation for this other than a vague memory, but there are + // (as of writing this) 45 names in the database whose A records begin with + // `$` and then point to another name. There is an additional 1 name that + // actually points to a domain, but still begins with `$` and ends with the + // name suffix. 40 of these names end in the `.kst` suffix. Since I cannot + // find any specification or documentation on it right now, I support both + // formats. The suffix is stripped if it is present. + if (a.startsWith("$")) { + // Probably a name redirect + const withoutPrefix = a.replace(/^\$/, ""); + const nameWithoutSuffix = stripNameSuffix(nameSuffix, withoutPrefix); + + return ; + } + + return + {a} + ; +} diff --git a/src/components/transactions/TransactionType.tsx b/src/components/transactions/TransactionType.tsx index 3d0f288..5a5280f 100644 --- a/src/components/transactions/TransactionType.tsx +++ b/src/components/transactions/TransactionType.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { KristTransaction } from "../../krist/api/types"; +import { KristTransaction, KristTransactionType } from "../../krist/api/types"; import { Wallet, useWallets } from "../../krist/wallets/Wallet"; import "./TransactionType.less"; @@ -15,8 +15,13 @@ export type InternalTransactionType = "transferred" | "sent" | "received" | "mined" | "name_a_record" | "name_transferred" | "name_sent" | "name_received" | "name_purchased" | "unknown"; -export const INTERNAL_TYPES_SHOW_VALUE = ["transferred", "sent", "received", "mined", "name_purchased"]; -export const TYPES_SHOW_VALUE = ["transfer", "mined", "name_purchase"]; +export const INTERNAL_TYPES_SHOW_VALUE: InternalTransactionType[] = [ + "transferred", "sent", "received", "mined", "name_purchased" +]; + +export const TYPES_SHOW_VALUE: KristTransactionType[] = [ + "transfer", "mined", "name_purchase" +]; export function getTransactionType(tx: KristTransaction, from?: Wallet, to?: Wallet): InternalTransactionType { switch (tx.type) { diff --git a/src/components/ws/SyncMOTD.tsx b/src/components/ws/SyncMOTD.tsx deleted file mode 100644 index e442dcc..0000000 --- a/src/components/ws/SyncMOTD.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// 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 { useEffect } from "react"; - -import { useSelector } from "react-redux"; -import { RootState } from "../../store"; -import * as nodeActions from "../../store/actions/NodeActions"; - -import { store } from "../../App"; - -import * as api from "../../krist/api"; -import { KristMOTD, KristMOTDBase } from "../../krist/api/types"; - -import { recalculateWallets, useWallets } from "../../krist/wallets/Wallet"; - -import Debug from "debug"; -const debug = Debug("kristweb:sync-motd"); - -export async function updateMOTD(): Promise { - debug("updating motd"); - const data = await api.get("motd"); - - debug("motd: %s", data.motd); - store.dispatch(nodeActions.setCurrency(data.currency)); - store.dispatch(nodeActions.setConstants(data.constants)); - - if (data.last_block) { - debug("motd last block id: %d", data.last_block.height); - store.dispatch(nodeActions.setLastBlockID(data.last_block.height)); - } - - const motdBase: KristMOTDBase = { - motd: data.motd, - motdSet: new Date(data.motd_set), - debugMode: data.debug_mode, - miningEnabled: data.mining_enabled - }; - store.dispatch(nodeActions.setMOTD(motdBase)); -} - -/** Sync the MOTD with the Krist node on startup. */ -export function SyncMOTD(): JSX.Element | null { - const syncNode = api.useSyncNode(); - const connectionState = useSelector((s: RootState) => s.websocket.connectionState); - - // All these are used to determine if we need to recalculate the addresses - const addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); - const masterPassword = useSelector((s: RootState) => s.walletManager.masterPassword); - const { wallets } = useWallets(); - - // Update the MOTD when the sync node changes, and on startup - useEffect(() => { - if (connectionState !== "connected") return; - updateMOTD().catch(console.error); - }, [syncNode, connectionState]); - - // When the currency's address prefix changes, or our master password appears, - // recalculate the addresses if necessary - useEffect(() => { - if (!addressPrefix || !masterPassword) return; - recalculateWallets(masterPassword, wallets, addressPrefix).catch(console.error); - }, [addressPrefix, masterPassword, wallets]); - - return null; -} diff --git a/src/components/ws/SyncWork.tsx b/src/components/ws/SyncWork.tsx deleted file mode 100644 index c488570..0000000 --- a/src/components/ws/SyncWork.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// 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 { useEffect } from "react"; - -import { useSelector } from "react-redux"; -import { RootState } from "../../store"; -import * as nodeActions from "../../store/actions/NodeActions"; - -import { store } from "../../App"; - -import * as api from "../../krist/api"; -import { KristWorkDetailed } from "../../krist/api/types"; - -import Debug from "debug"; -const debug = Debug("kristweb:sync-work"); - -export async function updateDetailedWork(): Promise { - debug("updating detailed work"); - const data = await api.get("work/detailed"); - - debug("work: %d", data.work); - store.dispatch(nodeActions.setDetailedWork(data)); -} - -/** Sync the work with the Krist node on startup. */ -export function SyncWork(): JSX.Element | null { - const { lastBlockID } = useSelector((s: RootState) => s.node); - - useEffect(() => { - // TODO: show errors to the user? - updateDetailedWork().catch(console.error); - }, [lastBlockID]); - - return null; -} diff --git a/src/components/ws/WebsocketService.tsx b/src/components/ws/WebsocketService.tsx deleted file mode 100644 index 7915477..0000000 --- a/src/components/ws/WebsocketService.tsx +++ /dev/null @@ -1,289 +0,0 @@ -// 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 } from "react"; - -import { store } from "../../App"; -import { WalletMap } from "../../store/reducers/WalletsReducer"; -import * as wsActions from "../../store/actions/WebsocketActions"; -import * as nodeActions from "../../store/actions/NodeActions"; - -import * as api from "../../krist/api"; -import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; -import { useWallets, findWalletByAddress, syncWallet, syncWalletUpdate, Wallet } from "../../krist/wallets/Wallet"; -import WebSocketAsPromised from "websocket-as-promised"; - -import { throttle } from "lodash-es"; - -import Debug from "debug"; -const debug = Debug("kristweb:ws"); - -const REFRESH_THROTTLE_MS = 500; -const DEFAULT_CONNECT_DEBOUNCE_MS = 1000; -const MAX_CONNECT_DEBOUNCE_MS = 360000; - -class WebsocketConnection { - private wallets?: WalletMap; - private ws?: WebSocketAsPromised; - private reconnectionTimer?: number; - - private messageID = 1; - private connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; - - private forceClosing = false; - - // TODO: automatically clean this up? - private refreshThrottles: Record void> = {}; - - constructor(public syncNode: string) { - debug("WS component init"); - this.attemptConnect(); - } - - setWallets(wallets: WalletMap): void { - this.wallets = wallets; - } - - private async connect() { - debug("attempting connection to server..."); - this.setConnectionState("disconnected"); - - // Get a websocket token - const { url } = await api.post<{ url: string }>("ws/start"); - - this.setConnectionState("connecting"); - - // Connect to the websocket server - this.ws = new WebSocketAsPromised(url, { - packMessage: data => JSON.stringify(data), - unpackMessage: data => JSON.parse(data.toString()) - }); - - this.ws.onUnpackedMessage.addListener(this.handleMessage.bind(this)); - this.ws.onClose.addListener(this.handleClose.bind(this)); - - this.messageID = 1; - await this.ws.open(); - this.connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; - } - - async attemptConnect() { - try { - await this.connect(); - } catch (err) { - this.handleDisconnect(err); - } - } - - private handleDisconnect(err?: Error) { - if (this.reconnectionTimer) window.clearTimeout(this.reconnectionTimer); - - // TODO: show errors to the user? - this.setConnectionState("disconnected"); - debug("failed to connect to server, reconnecting in %d ms", this.connectDebounce, err); - - this.reconnectionTimer = window.setTimeout(() => { - this.connectDebounce = Math.min(this.connectDebounce * 2, MAX_CONNECT_DEBOUNCE_MS); - this.attemptConnect(); - }, this.connectDebounce); - } - - handleClose(event: { code: number; reason: string }) { - debug("ws closed with code %d reason %s", event.code, event.reason); - this.handleDisconnect(); - } - - /** Forcibly disconnect this instance from the websocket server (e.g. on - * component unmount) */ - forceClose(): void { - debug("received force close request"); - - if (this.forceClosing) return; - this.forceClosing = true; - - if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; - debug("force closing ws"); - this.ws.close(); - } - - private setConnectionState(state: WSConnectionState) { - store.dispatch(wsActions.setConnectionState(state)); - } - - handleMessage(data: WSIncomingMessage) { - if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; - - if (data.ok === false || data.error) - debug("message ERR: %d %s", data.id || -1, data.error); - - if (data.type === "hello") { - // Initial connection - debug("connected"); - this.setConnectionState("connected"); - - // Subscribe to all the events - this.subscribe("transactions"); - this.subscribe("blocks"); - this.subscribe("names"); - this.subscribe("motd"); - - // Re-sync all balances just in case - this.refreshBalances(); - } else if (data.address && this.wallets) { - // Probably a response to `refreshBalance` - const address: KristAddress = data.address; - const wallet = findWalletByAddress(this.wallets, address.address); - if (!wallet) return; - - debug("syncing %s to %s (balance: %d)", address.address, wallet.id, address.balance); - syncWalletUpdate(wallet, address); - } else if (data.type === "event" && data.event && this.wallets) { - // Handle events - switch (data.event) { - case "transaction": { - // If we receive a transaction relevant to any of our wallets, refresh - // the balances. - const transaction = data.transaction as KristTransaction; - debug("transaction [%s] from %s to %s", transaction.type, transaction.from || "null", transaction.to || "null"); - - const fromWallet = findWalletByAddress(this.wallets, transaction.from || undefined); - const toWallet = findWalletByAddress(this.wallets, transaction.to); - - this.updateTransactionIDs(transaction, fromWallet, toWallet); - - switch (transaction.type) { - // Update the name counts using the address lookup - case "name_purchase": - case "name_transfer": - if (fromWallet) syncWallet(fromWallet); - if (toWallet) syncWallet(toWallet); - break; - - // Any other transaction; refresh the balances via the websocket - default: - if (fromWallet) this.refreshBalance(fromWallet.address); - if (toWallet) this.refreshBalance(toWallet.address); - break; - } - - break; - } - case "block": { - // Update the last block ID, which will trigger a re-fetch for - // work-related and block value-related components. - const block = data.block as KristBlock; - debug("block id now %d", block.height); - - store.dispatch(nodeActions.setLastBlockID(block.height)); - - break; - } - } - } - } - - private updateTransactionIDs(transaction: KristTransaction, fromWallet?: Wallet | null, toWallet?: Wallet | null) { - // Updating these last IDs will trigger auto-refreshes on pages that - // need them - const id = transaction.id; - - debug("lastTransactionID now %d", id); - store.dispatch(nodeActions.setLastTransactionID(id)); - - if (fromWallet || toWallet) { - debug("lastOwnTransactionID now %d", id); - store.dispatch(nodeActions.setLastOwnTransactionID(id)); - } - - if (transaction.type.startsWith("name_")) { - debug("lastNameTransactionID now %d", id); - store.dispatch(nodeActions.setLastNameTransactionID(id)); - - if (fromWallet || toWallet) { - debug("lastOwnNameTransactionID now %d", id); - store.dispatch(nodeActions.setLastOwnNameTransactionID(id)); - } - } - - if (transaction.type !== "mined") { - debug("lastNonMinedTransactionID now %d", id); - store.dispatch(nodeActions.setLastNonMinedTransactionID(id)); - } - } - - /** Queues a command to re-fetch an address's balance. The response will be - * handled in {@link handleMessage}. This is automatically throttled to - * execute on the leading edge of 500ms (REFRESH_THROTTLE_MS). */ - refreshBalance(address: string) { - if (this.refreshThrottles[address]) { - // Use the existing throttled function if it exists - this.refreshThrottles[address](address); - } else { - // Create and cache a new throttle function for this address - const throttled = throttle( - this._refreshBalance.bind(this), - REFRESH_THROTTLE_MS, - { leading: true, trailing: false } - ); - - this.refreshThrottles[address] = throttled; - throttled(address); - } - } - - private _refreshBalance(address: string) { - debug("refreshing balance of %s", address); - this.ws?.sendPacked({ type: "address", id: this.messageID++, address }); - } - - /** Re-syncs balances for all the wallets, just in case. */ - refreshBalances() { - debug("refreshing all balances"); - - const { wallets } = this; - if (!wallets) return; - - for (const id in wallets) { - this.refreshBalance(wallets[id].address); - } - } - - /** Subscribe to a Krist WS event. */ - subscribe(event: WSSubscriptionLevel) { - this.ws?.sendPacked({ type: "subscribe", event, id: this.messageID++ }); - } -} - -export function WebsocketService(): JSX.Element | null { - const { wallets } = useWallets(); - const syncNode = api.useSyncNode(); - - const [connection, setConnection] = useState(); - - // On first render, or if the sync node changes, create the websocket - // connection - useEffect(() => { - // Don't reconnect if we already have a connection and the sync node hasn't - // changed (prevents infinite loops) - if (connection && connection.syncNode === syncNode) return; - - // Close any existing connections - if (connection) connection.forceClose(); - - // Connect to the Krist websocket server - setConnection(new WebsocketConnection(syncNode)); - - // On unmount, force close the existing connection - return () => { - if (connection) connection.forceClose(); - }; - }, [syncNode, connection]); - - // If the wallets change, let the websocket service know so that it can keep - // track of events related to any new wallets - useEffect(() => { - if (connection) connection.setWallets(wallets); - }, [wallets, connection]); - - return null; -} diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 6d51276..85a5f16 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -11,6 +11,7 @@ import { BlocksPage } from "../pages/blocks/BlocksPage"; import { BlockPage } from "../pages/blocks/BlockPage"; import { TransactionsPage, ListingType as TXListing } from "../pages/transactions/TransactionsPage"; +import { TransactionPage } from "../pages/transactions/TransactionPage"; import { NamesPage, ListingType as NamesListing } from "../pages/names/NamesPage"; import { NamePage } from "../pages/names/NamePage"; @@ -48,6 +49,8 @@ { path: "/network/blocks/:id", name: "block", component: }, { path: "/network/transactions", name: "transactions", component: }, + { path: "/network/transactions/:id", name: "transaction", + component: }, { path: "/network/names", name: "networkNames", component: }, { path: "/network/names/new", name: "networkNamesNew", diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx index 66105ec..b59c838 100644 --- a/src/global/AppServices.tsx +++ b/src/global/AppServices.tsx @@ -5,9 +5,9 @@ import { SyncWallets } from "../components/wallets/SyncWallets"; import { ForcedAuth } from "../components/auth/ForcedAuth"; -import { WebsocketService } from "../components/ws/WebsocketService"; -import { SyncWork } from "../components/ws/SyncWork"; -import { SyncMOTD } from "../components/ws/SyncMOTD"; +import { WebsocketService } from "./ws/WebsocketService"; +import { SyncWork } from "./ws/SyncWork"; +import { SyncMOTD } from "./ws/SyncMOTD"; export function AppServices(): JSX.Element { return <> diff --git a/src/global/ws/SyncMOTD.tsx b/src/global/ws/SyncMOTD.tsx new file mode 100644 index 0000000..e442dcc --- /dev/null +++ b/src/global/ws/SyncMOTD.tsx @@ -0,0 +1,66 @@ +// 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 { useEffect } from "react"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; +import * as nodeActions from "../../store/actions/NodeActions"; + +import { store } from "../../App"; + +import * as api from "../../krist/api"; +import { KristMOTD, KristMOTDBase } from "../../krist/api/types"; + +import { recalculateWallets, useWallets } from "../../krist/wallets/Wallet"; + +import Debug from "debug"; +const debug = Debug("kristweb:sync-motd"); + +export async function updateMOTD(): Promise { + debug("updating motd"); + const data = await api.get("motd"); + + debug("motd: %s", data.motd); + store.dispatch(nodeActions.setCurrency(data.currency)); + store.dispatch(nodeActions.setConstants(data.constants)); + + if (data.last_block) { + debug("motd last block id: %d", data.last_block.height); + store.dispatch(nodeActions.setLastBlockID(data.last_block.height)); + } + + const motdBase: KristMOTDBase = { + motd: data.motd, + motdSet: new Date(data.motd_set), + debugMode: data.debug_mode, + miningEnabled: data.mining_enabled + }; + store.dispatch(nodeActions.setMOTD(motdBase)); +} + +/** Sync the MOTD with the Krist node on startup. */ +export function SyncMOTD(): JSX.Element | null { + const syncNode = api.useSyncNode(); + const connectionState = useSelector((s: RootState) => s.websocket.connectionState); + + // All these are used to determine if we need to recalculate the addresses + const addressPrefix = useSelector((s: RootState) => s.node.currency.address_prefix); + const masterPassword = useSelector((s: RootState) => s.walletManager.masterPassword); + const { wallets } = useWallets(); + + // Update the MOTD when the sync node changes, and on startup + useEffect(() => { + if (connectionState !== "connected") return; + updateMOTD().catch(console.error); + }, [syncNode, connectionState]); + + // When the currency's address prefix changes, or our master password appears, + // recalculate the addresses if necessary + useEffect(() => { + if (!addressPrefix || !masterPassword) return; + recalculateWallets(masterPassword, wallets, addressPrefix).catch(console.error); + }, [addressPrefix, masterPassword, wallets]); + + return null; +} diff --git a/src/global/ws/SyncWork.tsx b/src/global/ws/SyncWork.tsx new file mode 100644 index 0000000..c488570 --- /dev/null +++ b/src/global/ws/SyncWork.tsx @@ -0,0 +1,36 @@ +// 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 { useEffect } from "react"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; +import * as nodeActions from "../../store/actions/NodeActions"; + +import { store } from "../../App"; + +import * as api from "../../krist/api"; +import { KristWorkDetailed } from "../../krist/api/types"; + +import Debug from "debug"; +const debug = Debug("kristweb:sync-work"); + +export async function updateDetailedWork(): Promise { + debug("updating detailed work"); + const data = await api.get("work/detailed"); + + debug("work: %d", data.work); + store.dispatch(nodeActions.setDetailedWork(data)); +} + +/** Sync the work with the Krist node on startup. */ +export function SyncWork(): JSX.Element | null { + const { lastBlockID } = useSelector((s: RootState) => s.node); + + useEffect(() => { + // TODO: show errors to the user? + updateDetailedWork().catch(console.error); + }, [lastBlockID]); + + return null; +} diff --git a/src/global/ws/WebsocketService.tsx b/src/global/ws/WebsocketService.tsx new file mode 100644 index 0000000..7915477 --- /dev/null +++ b/src/global/ws/WebsocketService.tsx @@ -0,0 +1,289 @@ +// 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 } from "react"; + +import { store } from "../../App"; +import { WalletMap } from "../../store/reducers/WalletsReducer"; +import * as wsActions from "../../store/actions/WebsocketActions"; +import * as nodeActions from "../../store/actions/NodeActions"; + +import * as api from "../../krist/api"; +import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; +import { useWallets, findWalletByAddress, syncWallet, syncWalletUpdate, Wallet } from "../../krist/wallets/Wallet"; +import WebSocketAsPromised from "websocket-as-promised"; + +import { throttle } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:ws"); + +const REFRESH_THROTTLE_MS = 500; +const DEFAULT_CONNECT_DEBOUNCE_MS = 1000; +const MAX_CONNECT_DEBOUNCE_MS = 360000; + +class WebsocketConnection { + private wallets?: WalletMap; + private ws?: WebSocketAsPromised; + private reconnectionTimer?: number; + + private messageID = 1; + private connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; + + private forceClosing = false; + + // TODO: automatically clean this up? + private refreshThrottles: Record void> = {}; + + constructor(public syncNode: string) { + debug("WS component init"); + this.attemptConnect(); + } + + setWallets(wallets: WalletMap): void { + this.wallets = wallets; + } + + private async connect() { + debug("attempting connection to server..."); + this.setConnectionState("disconnected"); + + // Get a websocket token + const { url } = await api.post<{ url: string }>("ws/start"); + + this.setConnectionState("connecting"); + + // Connect to the websocket server + this.ws = new WebSocketAsPromised(url, { + packMessage: data => JSON.stringify(data), + unpackMessage: data => JSON.parse(data.toString()) + }); + + this.ws.onUnpackedMessage.addListener(this.handleMessage.bind(this)); + this.ws.onClose.addListener(this.handleClose.bind(this)); + + this.messageID = 1; + await this.ws.open(); + this.connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; + } + + async attemptConnect() { + try { + await this.connect(); + } catch (err) { + this.handleDisconnect(err); + } + } + + private handleDisconnect(err?: Error) { + if (this.reconnectionTimer) window.clearTimeout(this.reconnectionTimer); + + // TODO: show errors to the user? + this.setConnectionState("disconnected"); + debug("failed to connect to server, reconnecting in %d ms", this.connectDebounce, err); + + this.reconnectionTimer = window.setTimeout(() => { + this.connectDebounce = Math.min(this.connectDebounce * 2, MAX_CONNECT_DEBOUNCE_MS); + this.attemptConnect(); + }, this.connectDebounce); + } + + handleClose(event: { code: number; reason: string }) { + debug("ws closed with code %d reason %s", event.code, event.reason); + this.handleDisconnect(); + } + + /** Forcibly disconnect this instance from the websocket server (e.g. on + * component unmount) */ + forceClose(): void { + debug("received force close request"); + + if (this.forceClosing) return; + this.forceClosing = true; + + if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; + debug("force closing ws"); + this.ws.close(); + } + + private setConnectionState(state: WSConnectionState) { + store.dispatch(wsActions.setConnectionState(state)); + } + + handleMessage(data: WSIncomingMessage) { + if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; + + if (data.ok === false || data.error) + debug("message ERR: %d %s", data.id || -1, data.error); + + if (data.type === "hello") { + // Initial connection + debug("connected"); + this.setConnectionState("connected"); + + // Subscribe to all the events + this.subscribe("transactions"); + this.subscribe("blocks"); + this.subscribe("names"); + this.subscribe("motd"); + + // Re-sync all balances just in case + this.refreshBalances(); + } else if (data.address && this.wallets) { + // Probably a response to `refreshBalance` + const address: KristAddress = data.address; + const wallet = findWalletByAddress(this.wallets, address.address); + if (!wallet) return; + + debug("syncing %s to %s (balance: %d)", address.address, wallet.id, address.balance); + syncWalletUpdate(wallet, address); + } else if (data.type === "event" && data.event && this.wallets) { + // Handle events + switch (data.event) { + case "transaction": { + // If we receive a transaction relevant to any of our wallets, refresh + // the balances. + const transaction = data.transaction as KristTransaction; + debug("transaction [%s] from %s to %s", transaction.type, transaction.from || "null", transaction.to || "null"); + + const fromWallet = findWalletByAddress(this.wallets, transaction.from || undefined); + const toWallet = findWalletByAddress(this.wallets, transaction.to); + + this.updateTransactionIDs(transaction, fromWallet, toWallet); + + switch (transaction.type) { + // Update the name counts using the address lookup + case "name_purchase": + case "name_transfer": + if (fromWallet) syncWallet(fromWallet); + if (toWallet) syncWallet(toWallet); + break; + + // Any other transaction; refresh the balances via the websocket + default: + if (fromWallet) this.refreshBalance(fromWallet.address); + if (toWallet) this.refreshBalance(toWallet.address); + break; + } + + break; + } + case "block": { + // Update the last block ID, which will trigger a re-fetch for + // work-related and block value-related components. + const block = data.block as KristBlock; + debug("block id now %d", block.height); + + store.dispatch(nodeActions.setLastBlockID(block.height)); + + break; + } + } + } + } + + private updateTransactionIDs(transaction: KristTransaction, fromWallet?: Wallet | null, toWallet?: Wallet | null) { + // Updating these last IDs will trigger auto-refreshes on pages that + // need them + const id = transaction.id; + + debug("lastTransactionID now %d", id); + store.dispatch(nodeActions.setLastTransactionID(id)); + + if (fromWallet || toWallet) { + debug("lastOwnTransactionID now %d", id); + store.dispatch(nodeActions.setLastOwnTransactionID(id)); + } + + if (transaction.type.startsWith("name_")) { + debug("lastNameTransactionID now %d", id); + store.dispatch(nodeActions.setLastNameTransactionID(id)); + + if (fromWallet || toWallet) { + debug("lastOwnNameTransactionID now %d", id); + store.dispatch(nodeActions.setLastOwnNameTransactionID(id)); + } + } + + if (transaction.type !== "mined") { + debug("lastNonMinedTransactionID now %d", id); + store.dispatch(nodeActions.setLastNonMinedTransactionID(id)); + } + } + + /** Queues a command to re-fetch an address's balance. The response will be + * handled in {@link handleMessage}. This is automatically throttled to + * execute on the leading edge of 500ms (REFRESH_THROTTLE_MS). */ + refreshBalance(address: string) { + if (this.refreshThrottles[address]) { + // Use the existing throttled function if it exists + this.refreshThrottles[address](address); + } else { + // Create and cache a new throttle function for this address + const throttled = throttle( + this._refreshBalance.bind(this), + REFRESH_THROTTLE_MS, + { leading: true, trailing: false } + ); + + this.refreshThrottles[address] = throttled; + throttled(address); + } + } + + private _refreshBalance(address: string) { + debug("refreshing balance of %s", address); + this.ws?.sendPacked({ type: "address", id: this.messageID++, address }); + } + + /** Re-syncs balances for all the wallets, just in case. */ + refreshBalances() { + debug("refreshing all balances"); + + const { wallets } = this; + if (!wallets) return; + + for (const id in wallets) { + this.refreshBalance(wallets[id].address); + } + } + + /** Subscribe to a Krist WS event. */ + subscribe(event: WSSubscriptionLevel) { + this.ws?.sendPacked({ type: "subscribe", event, id: this.messageID++ }); + } +} + +export function WebsocketService(): JSX.Element | null { + const { wallets } = useWallets(); + const syncNode = api.useSyncNode(); + + const [connection, setConnection] = useState(); + + // On first render, or if the sync node changes, create the websocket + // connection + useEffect(() => { + // Don't reconnect if we already have a connection and the sync node hasn't + // changed (prevents infinite loops) + if (connection && connection.syncNode === syncNode) return; + + // Close any existing connections + if (connection) connection.forceClose(); + + // Connect to the Krist websocket server + setConnection(new WebsocketConnection(syncNode)); + + // On unmount, force close the existing connection + return () => { + if (connection) connection.forceClose(); + }; + }, [syncNode, connection]); + + // If the wallets change, let the websocket service know so that it can keep + // track of events related to any new wallets + useEffect(() => { + if (connection) connection.setWallets(wallets); + }, [wallets, connection]); + + return null; +} diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index 93e700f..b4f046a 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -11,7 +11,8 @@ firstseen: string; } -export type KristTransactionType = "unknown" | "mined" | "name_purchase" | "name_a_record" | "name_transfer" | "transfer"; +export type KristTransactionType = "unknown" | "mined" | "name_purchase" + | "name_a_record" | "name_transfer" | "transfer"; export interface KristTransaction { id: number; from: string | null; diff --git a/src/pages/names/NameARecordLink.tsx b/src/pages/names/NameARecordLink.tsx deleted file mode 100644 index cce03ec..0000000 --- a/src/pages/names/NameARecordLink.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// 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 { useSelector } from "react-redux"; -import { RootState } from "../../store"; -import { stripNameSuffix } from "../../utils/currency"; - -import { KristNameLink } from "../../components/KristNameLink"; - -function forceURL(link: string): string { - // TODO: this is rather crude - if (!link.startsWith("http")) return "https://" + link; - return link; -} - -interface Props { - a?: string; -} - -export function NameARecordLink({ a }: Props): JSX.Element | null { - const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); - - if (!a) return null; - - // I don't have a citation for this other than a vague memory, but there are - // (as of writing this) 45 names in the database whose A records begin with - // `$` and then point to another name. There is an additional 1 name that - // actually points to a domain, but still begins with `$` and ends with the - // name suffix. 40 of these names end in the `.kst` suffix. Since I cannot - // find any specification or documentation on it right now, I support both - // formats. The suffix is stripped if it is present. - if (a.startsWith("$")) { - // Probably a name redirect - const withoutPrefix = a.replace(/^\$/, ""); - const nameWithoutSuffix = stripNameSuffix(nameSuffix, withoutPrefix); - - return ; - } - - return - {a} - ; -} diff --git a/src/pages/names/NamePage.less b/src/pages/names/NamePage.less index 1b738cd..d475528 100644 --- a/src/pages/names/NamePage.less +++ b/src/pages/names/NamePage.less @@ -51,18 +51,6 @@ max-width: 768px; margin-bottom: @margin-lg; - .kw-statistic .kw-statistic-value { - background: @kw-darker; - border-radius: @border-radius-base; - - display: inline-block; - margin-top: @padding-xs; - padding: 0.25em @padding-xs; - - font-size: 90%; - font-family: monospace; - } - .name-a-record-edit { margin-left: @padding-xs; } diff --git a/src/pages/names/NamePage.tsx b/src/pages/names/NamePage.tsx index 88c357b..a83589e 100644 --- a/src/pages/names/NamePage.tsx +++ b/src/pages/names/NamePage.tsx @@ -17,6 +17,7 @@ import { Statistic } from "../../components/Statistic"; import { ContextualAddress } from "../../components/ContextualAddress"; import { DateTime } from "../../components/DateTime"; +import { NameARecordLink } from "../../components/NameARecordLink"; import * as api from "../../krist/api"; import { KristName } from "../../krist/api/types"; @@ -25,7 +26,6 @@ import { useBooleanSetting } from "../../utils/settings"; import { NameButtonRow } from "./NameButtonRow"; -import { NameARecordLink } from "./NameARecordLink"; import { NameTransactionsCard } from "./NameTransactionsCard"; import "./NamePage.less"; diff --git a/src/pages/transactions/TransactionMetadataCard.tsx b/src/pages/transactions/TransactionMetadataCard.tsx new file mode 100644 index 0000000..c6083cd --- /dev/null +++ b/src/pages/transactions/TransactionMetadataCard.tsx @@ -0,0 +1,126 @@ +// 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 } from "react"; +import { Card, Table, TableProps, Typography } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; + +import { parseCommonMeta } from "../../utils/commonmeta"; + +import { HelpIcon } from "../../components/HelpIcon"; + +const { Text, Title } = Typography; + +// TODO: This is definitely too crude for my taste, but I had no better ideas +const HAS_COMMONMETA = /[=;]/; + +export function CommonMetaTable({ metadata, nameSuffix }: { metadata: string; nameSuffix: string }): JSX.Element { + const { t } = useTranslation(); + + // Parse the CommonMeta from the transaction, showing an error if it fails + // (which shouldn't really happen) + const parsed = parseCommonMeta(nameSuffix, metadata); + if (!parsed) return {t("transaction.commonMetaError")}; + + // Convert the CommonMeta objects to an array of entries {key, value} + const processedCustom = Object.entries(parsed.custom) + .map(([key, value]) => ({ key, value })); + const processedParsed = Object.entries(parsed) + .map(([key, value]) => ({ key, value })) + .filter(o => o.key !== "custom"); // Hide the 'custom' object + + // Both tables display the same columns + const columns = [ + // Key + { + title: t("transaction.commonMetaColumnKey"), + dataIndex: "key", key: "key" + }, + + // Value + { + title: t("transaction.commonMetaColumnValue"), + dataIndex: "value", key: "value", + className: "transaction-metadata-cell-value" + } + ]; + + // Props common to both tables + const tableProps: TableProps<{ key: string; value: string }> = { + size: "small", + rowKey: "key", + columns, + pagination: false, + scroll: { y: 160 } // Give it a fixed height + }; + + return <> + {/* Custom data table */} + + {t("transaction.commonMetaCustom")} + <HelpIcon textKey="transaction.commonMetaCustomHelp" /> + + + + + {/* Parsed data table */} + + {t("transaction.commonMetaParsed")} + <HelpIcon textKey="transaction.commonMetaParsedHelp" /> + + +
+ ; +} + +export function TransactionMetadataCard({ metadata }: { metadata: string }): JSX.Element { + const { t } = useTranslation(); + const nameSuffix = useSelector((s: RootState) => s.node.currency.name_suffix); + + // Estimate in advance if a CommonMeta tab should be showed + const hasCommonMeta = HAS_COMMONMETA.test(metadata); + const [activeTab, setActiveTab] = useState<"commonMeta" | "raw">( + hasCommonMeta ? "commonMeta" : "raw" + ); + + // Tab list for the card + const commonMetaTab = { key: "commonMeta", tab: t("transaction.tabCommonMeta") }; + const rawTab = { key: "raw", tab: t("transaction.tabRaw") }; + + // Parsing the CommonMeta and rendering the table is a little too expensive + // for my tastes, so it's memoised here. + const commonMetaTable = useMemo(() => (hasCommonMeta + ? + : null), [hasCommonMeta, metadata, nameSuffix]); + + return setActiveTab(key as "commonMeta" | "raw")} + > + {hasCommonMeta && activeTab === "commonMeta" + ? ( // Parsed CommonMeta table + commonMetaTable + ) + : ( // Raw metadata +
+ {metadata} +
+ )} +
; +} diff --git a/src/pages/transactions/TransactionPage.less b/src/pages/transactions/TransactionPage.less new file mode 100644 index 0000000..d40ccc2 --- /dev/null +++ b/src/pages/transactions/TransactionPage.less @@ -0,0 +1,76 @@ +// 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-page { + .transaction-info-row { + max-width: 768px; + margin-top: -@margin-lg; + margin-bottom: @margin-lg; + + .kw-statistic { + margin-top: @margin-lg; + margin-right: @margin-lg; + + .transaction-type { + font-size: @font-size-base * 1.5; + &, a { font-weight: normal; } + } + + &.transaction-statistic-address { + .kw-statistic-value { + // Hack to deal with the line heights being huge while wrapping + line-height: 1; + + // And to make it line up with the other stats, + display: inline-block; + margin-top: 0.275em; + } + + .contextual-address { + font-size: @font-size-base * 1.1; + + .address-address { + font-size: @font-size-base * 1.5; + } + + .address-original { + display: block; + margin-top: @padding-sm; + font-size: @font-size-base; + } + } + } + + .date-time { + font-size: @font-size-base * 1.5; + } + } + } + + .transaction-card-row { + .transaction-card-metadata { + .ant-card-body { + padding: @card-padding-base; + } + } + + .transaction-metadata-raw, td.transaction-metadata-cell-value, + td.transaction-raw-data-cell-value { + color: @kw-text-tertiary; + + font-family: monospace; + font-size: 90%; + + line-height: 1.5; + } + + .transaction-raw-data-null { + color: @text-color-secondary; + + font-family: @font-family; + font-style: italic; + } + } +} diff --git a/src/pages/transactions/TransactionPage.tsx b/src/pages/transactions/TransactionPage.tsx new file mode 100644 index 0000000..4a66da6 --- /dev/null +++ b/src/pages/transactions/TransactionPage.tsx @@ -0,0 +1,169 @@ +// 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 } from "react"; +import { Row, Col, Skeleton } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { PageLayout } from "../../layout/PageLayout"; +import { TransactionResult } from "./TransactionResult"; + +import { Statistic } from "../../components/Statistic"; +import { TransactionType, TYPES_SHOW_VALUE } from "../../components/transactions/TransactionType"; +import { ContextualAddress } from "../../components/ContextualAddress"; +import { KristNameLink } from "../../components/KristNameLink"; +import { KristValue } from "../../components/KristValue"; +import { DateTime } from "../../components/DateTime"; +import { NameARecordLink } from "../../components/NameARecordLink"; + +import * as api from "../../krist/api"; +import { KristTransaction, KristTransactionType } from "../../krist/api/types"; + +import { TransactionMetadataCard } from "./TransactionMetadataCard"; +import { TransactionRawDataCard } from "./TransactionRawDataCard"; + +import "./TransactionPage.less"; + +/** Set of network transaction types that should only display a single address + * instead of both 'from' and 'to' */ +const SINGLE_ADDRESS_TYPES: KristTransactionType[] = [ + "mined", "name_purchase", "name_a_record" +]; + +interface ParamTypes { + id: string; +} + +function PageContents({ transaction }: { transaction: KristTransaction }): JSX.Element { + const { type, from, to, name, metadata } = transaction; + + // Whether or not a single address should be shown, instead of both 'from' and + // 'to' (e.g. mined, name purchase) + const onlySingleAddress = SINGLE_ADDRESS_TYPES.includes(type); + const singleAddress = onlySingleAddress + ? (type === "mined" ? to : from) + : undefined; + + return <> + + {/* Type */} +
+ } + /> + + + {/* Address, if there's only one involved */} + {singleAddress && + } + /> + } + + {/* From address */} + {!singleAddress && + } + /> + } + + {/* To address */} + {!singleAddress && + } + /> + } + + {/* Name */} + {name && + } + /> + } + + {/* Value */} + {TYPES_SHOW_VALUE.includes(type) && + } + /> + } + + {/* Time (explicitly 12 grid units wide) */} + {+ } + /> + } + + {/* A record */} + {type === "name_a_record" && + } + /> + } + + + {/* Metadata and raw data card row */} + + {/* Metadata */} + {type !== "name_a_record" && metadata && + + } + + {/* Raw data */} + {+ + } + + ; +} + +export function TransactionPage(): JSX.Element { + // Used to refresh the transaction data on syncNode change + const syncNode = api.useSyncNode(); + const { t } = useTranslation(); + + const { id } = useParams(); + const [kristTransaction, setKristTransaction] = useState(); + const [error, setError] = useState(); + + // Load the transaction on page load + useEffect(() => { + api.get<{ transaction: KristTransaction }>("transactions/" + encodeURIComponent(id)) + .then(res => setKristTransaction(res.transaction)) + .catch(err => { console.error(err); setError(err); }); + }, [syncNode, id]); + + // Change the page title depending on whether or not the tx has loaded + const titleData = kristTransaction + ? { + siteTitle: t("transaction.siteTitleTransaction", { id: kristTransaction.id }), + subTitle: t("transaction.subTitleTransaction", { id: kristTransaction.id }) + } + : { siteTitleKey: "transaction.siteTransaction" }; + + return + {error + ? + : (kristTransaction + ? + : )} + ; +} diff --git a/src/pages/transactions/TransactionRawDataCard.tsx b/src/pages/transactions/TransactionRawDataCard.tsx new file mode 100644 index 0000000..480224c --- /dev/null +++ b/src/pages/transactions/TransactionRawDataCard.tsx @@ -0,0 +1,59 @@ +// 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 { Card, Table } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { KristTransaction } from "../../krist/api/types"; + +import { HelpIcon } from "../../components/HelpIcon"; + +export function TransactionRawDataCard({ transaction }: { transaction: KristTransaction }): JSX.Element { + const { t } = useTranslation(); + + // Convert the transaction object to an array of entries {key, value} + const processed = Object.entries(transaction) + .map(([key, value]) => ({ key, value })); + + return + {t("transaction.cardRawDataTitle")} + + } + > +
{ + if (value === null || value === undefined) + return null; + + return value.toString(); + } + } + ]} + + pagination={false} + /> + ; +} diff --git a/src/pages/transactions/TransactionResult.tsx b/src/pages/transactions/TransactionResult.tsx new file mode 100644 index 0000000..978cf19 --- /dev/null +++ b/src/pages/transactions/TransactionResult.tsx @@ -0,0 +1,52 @@ +// 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 { FrownOutlined, 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 TransactionResult({ error }: Props): JSX.Element { + const { t } = useTranslation(); + + // Handle the most commonly expected errors from the API + if (error instanceof APIError) { + // Invalid transaction ID + if (error.message === "invalid_parameter") { + return } + title={t("transaction.resultInvalidTitle")} + subTitle={t("transaction.resultInvalid")} + fullPage + />; + } + + // Transaction not found + if (error.message === "transaction_not_found") { + return } + title={t("transaction.resultNotFoundTitle")} + subTitle={t("transaction.resultNotFound")} + fullPage + />; + } + } + + // Unknown error + return } + title={t("transaction.resultUnknownTitle")} + subTitle={t("transaction.resultUnknown")} + fullPage + />; +} diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx index 30d7e22..7f5d456 100644 --- a/src/pages/transactions/TransactionsTable.tsx +++ b/src/pages/transactions/TransactionsTable.tsx @@ -5,6 +5,7 @@ import { Table } from "antd"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; import { KristTransaction } from "../../krist/api/types"; import { lookupTransactions, LookupTransactionsOptions, LookupTransactionsResponse, LookupTransactionType } from "../../krist/api/lookup"; @@ -92,7 +93,11 @@ 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; @@ -110,7 +115,7 @@ title: t("transactions.columnFrom"), dataIndex: "from", key: "from", - render: (from, tx) => from && ( + render: (from, tx) => from && tx.type !== "mined" && ( to && tx.type !== "name_a_record" && ( + render: (to, tx) => to && tx.type !== "name_purchase" && tx.type !== "name_a_record" && ( ; } export function parseCommonMeta(nameSuffix: string, metadata: string | undefined | null): CommonMeta | null { if (!metadata) return null; - const parts: CommonMeta = {}; + const custom: Record = {}; + const out: CommonMeta = { custom }; const metaParts = metadata.split(";"); if (metaParts.length <= 0) return null; const nameMatches = getNameRegex(nameSuffix).exec(metaParts[0]); if (nameMatches) { - if (nameMatches[1]) parts.metaname = nameMatches[1]; - if (nameMatches[2]) parts.name = nameMatches[2]; + if (nameMatches[1]) out.metaname = nameMatches[1]; + if (nameMatches[2]) out.name = nameMatches[2]; - parts.recipient = nameMatches[1] ? nameMatches[1] + "@" + nameMatches[2] : nameMatches[2]; + out.recipient = nameMatches[1] ? nameMatches[1] + "@" + nameMatches[2] : nameMatches[2]; } for (let i = 0; i < metaParts.length; i++) { @@ -38,21 +40,22 @@ if (i === 0 && nameMatches) continue; if (kv.length === 1) { - parts[i.toString()] = kv[0]; + custom[i.toString()] = kv[0]; } else { - parts[kv[0]] = kv.slice(1).join("="); + custom[kv[0]] = kv.slice(1).join("="); } } - if (parts.return) { - const returnMatches = getNameRegex(nameSuffix).exec(parts.return); + const rawReturn = out.return = custom.return; + if (rawReturn) { + const returnMatches = getNameRegex(nameSuffix).exec(rawReturn); if (returnMatches) { - if (returnMatches[1]) parts.returnMetaname = returnMatches[1]; - if (returnMatches[2]) parts.returnName = returnMatches[2]; + if (returnMatches[1]) out.returnMetaname = returnMatches[1]; + if (returnMatches[2]) out.returnName = returnMatches[2]; - parts.returnRecipient = returnMatches[1] ? returnMatches[1] + "@" + returnMatches[2] : returnMatches[2]; + out.returnRecipient = returnMatches[1] ? returnMatches[1] + "@" + returnMatches[2] : returnMatches[2]; } } - return parts; + return out; }