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 }; + });