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")}
+
+
+
+
+
+ {/* Parsed data table */}
+
+ {t("transaction.commonMetaParsed")}
+
+
+
+
+ >;
+}
+
+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;
}