diff --git a/src/App.tsx b/src/App.tsx
index 96414ad..c3c796c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,6 +16,7 @@
import { AppLoading } from "./global/AppLoading";
import { CheckStatus } from "./pages/CheckStatus";
import { AppServices } from "./global/AppServices";
+import { WebsocketProvider } from "./global/ws/WebsocketProvider";
import Debug from "debug";
const debug = Debug("kristweb:app");
@@ -28,12 +29,14 @@
return }>
-
-
+
+
+
- {/* Services, etc. */}
-
-
+ {/* Services, etc. */}
+
+
+
;
}
diff --git a/src/global/ws/WebsocketConnection.ts b/src/global/ws/WebsocketConnection.ts
new file mode 100644
index 0000000..dff8d0c
--- /dev/null
+++ b/src/global/ws/WebsocketConnection.ts
@@ -0,0 +1,293 @@
+// 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 { store } from "@app";
+import * as wsActions from "@actions/WebsocketActions";
+import * as nodeActions from "@actions/NodeActions";
+
+import * as api from "@api";
+import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "@api/types";
+import { Wallet, WalletMap, findWalletByAddress, syncWallet, syncWalletUpdate } from "@wallets";
+import WebSocketAsPromised from "websocket-as-promised";
+
+import { WSSubscription } from "./WebsocketSubscription";
+
+import { throttle } from "lodash-es";
+
+import Debug from "debug";
+const debug = Debug("kristweb:websocket-connection");
+
+const REFRESH_THROTTLE_MS = 500;
+const DEFAULT_CONNECT_DEBOUNCE_MS = 1000;
+const MAX_CONNECT_DEBOUNCE_MS = 360000;
+
+export 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> = {};
+
+ private subscriptions: Record = {};
+
+ 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(): Promise {
+ 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 }): void {
+ 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): void {
+ 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,
+ from, to,
+ name: txName, sent_name: txSentName,
+ type
+ } = transaction;
+
+ // Generic "last transaction ID", used for network transaction tables
+ debug("lastTransactionID now %d", id);
+ store.dispatch(nodeActions.setLastTransactionID(id));
+
+ // Transactions to/from our own wallets, for the "my transactions" table
+ if (fromWallet || toWallet) {
+ debug("lastOwnTransactionID now %d", id);
+ store.dispatch(nodeActions.setLastOwnTransactionID(id));
+ }
+
+ // Name transactions to/from our own names and network names
+ if (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));
+ }
+ }
+
+ // Non-mined transactions for transaction tables that exclude mined
+ // transactions
+ if (type !== "mined") {
+ debug("lastNonMinedTransactionID now %d", id);
+ store.dispatch(nodeActions.setLastNonMinedTransactionID(id));
+ }
+
+ // Find addresses/names that we are subscribed to
+ for (const subID in this.subscriptions) {
+ const { address, name } = this.subscriptions[subID];
+
+ // Found a subscription interested in the addresses involved in this tx
+ if (from && address === from || to && address === to) {
+ debug("[address] updating subscription %s id to %d", subID, id);
+ store.dispatch(wsActions.updateSubscription(subID, id));
+ }
+
+ // Found a subscription interested in the name involved in this tx
+ if ((txName && txName === name) || (txSentName && txSentName === name)) {
+ debug("[name] updating subscription %s id to %d", subID, id);
+ store.dispatch(wsActions.updateSubscription(subID, 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): void {
+ 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(): void {
+ 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): void {
+ this.ws?.sendPacked({ type: "subscribe", event, id: this.messageID++ });
+ }
+
+ addSubscription(id: string, subscription: WSSubscription): void {
+ debug("ws received added subscription %s", id);
+ this.subscriptions[id] = subscription;
+ }
+
+ removeSubscription(id: string): void {
+ debug("ws received removed subscription %s", id);
+ if (this.subscriptions[id]) delete this.subscriptions[id];
+ }
+}
diff --git a/src/global/ws/WebsocketProvider.tsx b/src/global/ws/WebsocketProvider.tsx
new file mode 100644
index 0000000..45cbb75
--- /dev/null
+++ b/src/global/ws/WebsocketProvider.tsx
@@ -0,0 +1,25 @@
+// 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, { FC, createContext, useState, Dispatch, SetStateAction } from "react";
+
+import { WebsocketConnection } from "./WebsocketConnection";
+
+import Debug from "debug";
+const debug = Debug("kristweb:websocket-provider");
+
+export interface WSContextType {
+ connection?: WebsocketConnection;
+ setConnection?: Dispatch>;
+}
+export const WebsocketContext = createContext({});
+
+export const WebsocketProvider: FC = ({ children }): JSX.Element => {
+ const [connection, setConnection] = useState();
+
+ debug("ws provider re-rendering");
+
+ return
+ {children}
+ ;
+};
diff --git a/src/global/ws/WebsocketService.tsx b/src/global/ws/WebsocketService.tsx
index be4050c..2bc715e 100644
--- a/src/global/ws/WebsocketService.tsx
+++ b/src/global/ws/WebsocketService.tsx
@@ -1,263 +1,22 @@
// 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 { useEffect, useContext } from "react";
-import { store } from "@app";
-import * as wsActions from "@actions/WebsocketActions";
-import * as nodeActions from "@actions/NodeActions";
+import { WebsocketContext } from "./WebsocketProvider";
+import { WebsocketConnection } from "./WebsocketConnection";
import * as api from "@api";
-import { KristAddress, KristBlock, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "@api/types";
-import { Wallet, WalletMap, useWallets, findWalletByAddress, syncWallet, syncWalletUpdate } from "@wallets";
-import WebSocketAsPromised from "websocket-as-promised";
-
-import { throttle } from "lodash-es";
+import { useWallets } from "@wallets";
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++ });
- }
-}
+const debug = Debug("kristweb:websocket-service");
export function WebsocketService(): JSX.Element | null {
const { wallets } = useWallets();
const syncNode = api.useSyncNode();
- const [connection, setConnection] = useState();
+ const { connection, setConnection } = useContext(WebsocketContext);
// On first render, or if the sync node changes, create the websocket
// connection
@@ -269,6 +28,11 @@
// Close any existing connections
if (connection) connection.forceClose();
+ if (!setConnection) {
+ debug("ws provider setConnection is missing!");
+ return;
+ }
+
// Connect to the Krist websocket server
setConnection(new WebsocketConnection(syncNode));
@@ -276,7 +40,7 @@
return () => {
if (connection) connection.forceClose();
};
- }, [syncNode, connection]);
+ }, [syncNode, connection, setConnection]);
// If the wallets change, let the websocket service know so that it can keep
// track of events related to any new wallets
diff --git a/src/global/ws/WebsocketSubscription.ts b/src/global/ws/WebsocketSubscription.ts
new file mode 100644
index 0000000..522c18b
--- /dev/null
+++ b/src/global/ws/WebsocketSubscription.ts
@@ -0,0 +1,106 @@
+// 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 { useContext, useState, useEffect } from "react";
+import { v4 as uuid } from "uuid";
+
+import { useSelector } from "react-redux";
+import { RootState } from "@store";
+import * as actions from "@actions/WebsocketActions";
+
+import { WebsocketContext } from "./WebsocketProvider";
+
+import Debug from "debug";
+const debug = Debug("kristweb:websocket-subscription");
+
+export interface WSSubscription {
+ address?: string;
+ name?: string;
+ lastTransactionID: number;
+}
+
+export function createSubscription(address?: string, name?: string): [string, WSSubscription] {
+ const id = uuid();
+
+ // It's okay to initialise at 0, since it will still render appropriately;
+ // it will be updated whenever a relevant transaction comes in
+ const subscription = {
+ address, name, lastTransactionID: 0
+ };
+
+ // Dispatch the new subscription to the Redux store
+ actions.initSubscription(id, subscription);
+
+ return [id, subscription];
+}
+
+export function removeSubscription(id: string): void {
+ // Dispatch the changes subscription to the Redux store
+ actions.removeSubscription(id);
+}
+
+/** Creates a subscription to an address or name's last transaction ID.
+ * Will return 0 unless a transaction was detected after the subscription was
+ * created. */
+export function useSubscription({ address, name }: { address?: string; name?: string }): number {
+ const { connection } = useContext(WebsocketContext);
+ const [subscriptionID, setSubscriptionID] = useState();
+
+ // Don't select anything if there's no address or name anymore
+ const selector = address || name ? (subscriptionID || "") : "";
+ const storeSubscription = useSelector((s: RootState) => s.websocket.subscriptions[selector]);
+
+ // Create the subscription on mount if we don't have one
+ useEffect(() => {
+ if (!connection && subscriptionID) {
+ debug("connection lost, wiping subscription ID");
+ removeSubscription(subscriptionID);
+ setSubscriptionID(undefined);
+ return;
+ } else if (!connection || subscriptionID) return;
+
+ // This hook may still get called if there's nothing the caller wants to
+ // subscribe to, so stop here if that's the case
+ if (!address && !name) return;
+
+ debug("ws subscription has no id yet, registering one");
+ const [id, subscription] = createSubscription(address, name);
+ connection.addSubscription(id, subscription);
+ setSubscriptionID(id);
+ debug("new subscription id is %s", id);
+ }, [connection, subscriptionID, address, name]);
+
+ // If the address or name change, wipe the subscription ID
+ useEffect(() => {
+ if (subscriptionID) {
+ debug("address or name changed, wiping subscription");
+ if (connection) connection.removeSubscription(subscriptionID);
+ removeSubscription(subscriptionID);
+ setSubscriptionID(undefined);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [address, name]);
+
+ // Unsubscribe when unmounted
+ useEffect(() => {
+ return () => {
+ if (!subscriptionID) return;
+ debug("ws subscription %s being removed due to unmount", subscriptionID);
+
+ if (connection) connection.removeSubscription(subscriptionID);
+ removeSubscription(subscriptionID);
+ };
+ }, [connection, subscriptionID]);
+
+ if (!connection) {
+ debug("ws subscription returning 0 because no connection yet");
+ return 0;
+ }
+
+ const out = storeSubscription
+ ? storeSubscription.lastTransactionID
+ : 0;
+
+ debug("ws subscription %s is %d", subscriptionID, out);
+ return out;
+}
diff --git a/src/pages/addresses/AddressPage.tsx b/src/pages/addresses/AddressPage.tsx
index d4e34c9..7278970 100644
--- a/src/pages/addresses/AddressPage.tsx
+++ b/src/pages/addresses/AddressPage.tsx
@@ -17,6 +17,7 @@
import * as api from "@api";
import { lookupAddress, KristAddressWithNames } from "@api/lookup";
import { useWallets } from "@wallets";
+import { useSubscription } from "@global/ws/WebsocketSubscription";
import { AddressButtonRow } from "./AddressButtonRow";
import { AddressTransactionsCard } from "./AddressTransactionsCard";
@@ -30,7 +31,12 @@
address: string;
}
-function PageContents({ address }: { address: KristAddressWithNames }): JSX.Element {
+interface PageContentsProps {
+ address: KristAddressWithNames;
+ lastTransactionID: number;
+}
+
+function PageContents({ address, lastTransactionID }: PageContentsProps): JSX.Element {
const { t } = useTranslation();
const { wallets } = useWallets();
@@ -95,11 +101,15 @@
{/* Recent transactions */}
-
+
{/* Names */}
+ {/* TODO: Subscription for this card */}
@@ -114,6 +124,9 @@
const [kristAddress, setKristAddress] = useState();
const [error, setError] = useState();
+ // Used to refresh the address data when a transaction is made to it
+ const lastTransactionID = useSubscription({ address });
+
// Load the address on page load
// TODO: passthrough router state to pre-load from search
// REVIEW: The search no longer clears the LRU cache on each open, meaning it
@@ -130,7 +143,7 @@
lookupAddress(address, true)
.then(setKristAddress)
.catch(setError);
- }, [syncNode, address]);
+ }, [syncNode, address, lastTransactionID]);
// Change the page title depending on whether or not the address has loaded
const title = kristAddress
@@ -156,7 +169,12 @@
/>
)
: (kristAddress
- ?
+ ? (
+
+ )
: )}
;
}
diff --git a/src/pages/addresses/AddressTransactionsCard.tsx b/src/pages/addresses/AddressTransactionsCard.tsx
index ea97eac..795048e 100644
--- a/src/pages/addresses/AddressTransactionsCard.tsx
+++ b/src/pages/addresses/AddressTransactionsCard.tsx
@@ -25,7 +25,12 @@
);
}
-export function AddressTransactionsCard({ address }: { address: string }): JSX.Element {
+interface Props {
+ address: string;
+ lastTransactionID: number;
+}
+
+export function AddressTransactionsCard({ address, lastTransactionID }: Props): JSX.Element {
const { t } = useTranslation();
const syncNode = useSyncNode();
@@ -34,8 +39,6 @@
const [loading, setLoading] = useState(true);
// Fetch transactions on page load or sync node reload
- // TODO: set up something to temporarily subscribe to an address via the
- // websocket service, so this can be updated in realtime
useEffect(() => {
if (!syncNode) return;
@@ -47,7 +50,7 @@
.then(setRes)
.catch(setError)
.finally(() => setLoading(false));
- }, [syncNode, address]);
+ }, [syncNode, address, lastTransactionID]);
const isEmpty = !loading && (error || !res || res.count === 0);
const classes = classNames("kw-card", "address-card-transactions", {
diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx
index 9ac8421..58119e4 100644
--- a/src/pages/transactions/TransactionsPage.tsx
+++ b/src/pages/transactions/TransactionsPage.tsx
@@ -18,6 +18,7 @@
import { TransactionsTable } from "./TransactionsTable";
import { useWallets } from "@wallets";
+import { useSubscription } from "@global/ws/WebsocketSubscription";
import { useBooleanSetting } from "@utils/settings";
import { useLinkedPagination } from "@utils/table";
import { KristNameLink } from "@comp/names/KristNameLink";
@@ -118,15 +119,21 @@
}
/** Returns the correct auto-refresh ID for the given listing type. */
-function getRefreshID(listingType: ListingType, includeMined: boolean, node: NodeState): number {
+function getRefreshID(
+ listingType: ListingType,
+ includeMined: boolean,
+ node: NodeState,
+ subscribedRefreshID: number
+): number {
switch (listingType) {
case ListingType.WALLETS:
return node.lastOwnTransactionID;
case ListingType.NAME_HISTORY:
- return node.lastNameTransactionID;
case ListingType.NAME_SENT:
+ case ListingType.NETWORK_ADDRESS:
+ // Use the websocket subscription
+ return subscribedRefreshID;
case ListingType.NETWORK_ALL:
- case ListingType.NETWORK_ADDRESS: // TODO: subscribe to a single name
// Prevent annoying refreshes when blocks are mined
return includeMined
? node.lastTransactionID
@@ -188,6 +195,7 @@
// Used to handle memoisation and auto-refreshing
const { joinedAddressList } = useWallets();
const nodeState = useSelector((s: RootState) => s.node, shallowEqual);
+ const subscribedRefreshID = useSubscription({ address, name });
const shouldAutoRefresh = useBooleanSetting("autoRefreshTables");
// Comma-separated list of addresses, used as an optimisation for
@@ -197,7 +205,8 @@
// If auto-refresh is disabled, use a static refresh ID
const usedRefreshID = shouldAutoRefresh
- ? getRefreshID(listingType, includeMined, nodeState) : 0;
+ ? getRefreshID(listingType, includeMined, nodeState, subscribedRefreshID)
+ : 0;
// Memoise the table so that it only updates the props (thus triggering a
// re-fetch of the transactions) when something relevant changes
diff --git a/src/store/actions/WebsocketActions.ts b/src/store/actions/WebsocketActions.ts
index be5ec74..4c289e0 100644
--- a/src/store/actions/WebsocketActions.ts
+++ b/src/store/actions/WebsocketActions.ts
@@ -4,6 +4,20 @@
import { createAction } from "typesafe-actions";
import { WSConnectionState } from "@api/types";
+import { WSSubscription } from "@global/ws/WebsocketSubscription";
+
import * as constants from "../constants";
export const setConnectionState = createAction(constants.CONNECTION_STATE)();
+
+export interface InitSubscriptionPayload { id: string; subscription: WSSubscription }
+export const initSubscription = createAction(constants.INIT_SUBSCRIPTION,
+ (id, subscription): InitSubscriptionPayload =>
+ ({ id, subscription }))();
+
+export interface UpdateSubscriptionPayload { id: string; lastTransactionID: number }
+export const updateSubscription = createAction(constants.UPDATE_SUBSCRIPTION,
+ (id, lastTransactionID): UpdateSubscriptionPayload =>
+ ({ id, lastTransactionID }))();
+
+export const removeSubscription = createAction(constants.REMOVE_SUBSCRIPTION)();
diff --git a/src/store/constants.ts b/src/store/constants.ts
index 6a7ed4a..f5bf7fc 100644
--- a/src/store/constants.ts
+++ b/src/store/constants.ts
@@ -26,6 +26,9 @@
// Websockets
// ---
export const CONNECTION_STATE = "CONNECTION_STATE";
+export const INIT_SUBSCRIPTION = "INIT_SUBSCRIPTION";
+export const UPDATE_SUBSCRIPTION = "UPDATE_SUBSCRIPTION";
+export const REMOVE_SUBSCRIPTION = "REMOVE_SUBSCRIPTION";
// Node state (auto-refreshing)
// ---
diff --git a/src/store/reducers/WebsocketReducer.ts b/src/store/reducers/WebsocketReducer.ts
index fc9257a..394da7f 100644
--- a/src/store/reducers/WebsocketReducer.ts
+++ b/src/store/reducers/WebsocketReducer.ts
@@ -1,20 +1,50 @@
// 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 { createReducer, ActionType } from "typesafe-actions";
+import { createReducer } from "typesafe-actions";
import { WSConnectionState } from "@api/types";
-import { setConnectionState } from "@actions/WebsocketActions";
+import * as actions from "@actions/WebsocketActions";
+
+import { WSSubscription } from "@global/ws/WebsocketSubscription";
export interface State {
readonly connectionState: WSConnectionState;
+ readonly subscriptions: Record;
}
export const initialState: State = {
- connectionState: "disconnected"
+ connectionState: "disconnected",
+ subscriptions: {}
};
export const WebsocketReducer = createReducer(initialState)
- .handleAction(setConnectionState, (state: State, action: ActionType) => ({
+ // Set websocket connection state
+ .handleAction(actions.setConnectionState, (state, { payload }) => ({
...state,
- connectionState: action.payload
- }));
+ connectionState: payload
+ }))
+ // Initialise websocket subscription
+ .handleAction(actions.initSubscription, (state, { payload }) => ({
+ ...state,
+ subscriptions: {
+ ...state.subscriptions,
+ [payload.id]: payload.subscription
+ }
+ }))
+ // Update websocket subscription
+ .handleAction(actions.updateSubscription, (state, { payload }) => ({
+ ...state,
+ subscriptions: {
+ ...state.subscriptions,
+ [payload.id]: {
+ ...state.subscriptions[payload.id],
+ lastTransactionID: payload.lastTransactionID
+ }
+ }
+ }))
+ // Remove websocket subscription
+ .handleAction(actions.removeSubscription, (state, { payload }) => {
+ // Get the subscriptions without the one we want to remove
+ const { [payload]: _, ...subscriptions } = state.subscriptions;
+ return { ...state, subscriptions };
+ });