diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ac45d6..155d135 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "jwalelset", "languagedetector", "localisation", + "motd", "multiline", "pnpm", "privatekeys", diff --git a/package.json b/package.json index e35ba65..8e239c7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "base64-arraybuffer": "^0.2.0", "csv-stringify": "^5.6.1", "dayjs": "^1.10.4", + "debug": "^4.3.1", "file-saver": "^2.0.5", "i18next": "^19.7.0", "i18next-browser-languagedetector": "^6.0.1", @@ -38,7 +39,8 @@ "spu-md5": "0.0.4", "typesafe-actions": "^5.1.0", "uuid": "^8.3.2", - "web-vitals": "^1.1.0" + "web-vitals": "^1.1.0", + "websocket-as-promised": "^2.0.1" }, "scripts": { "start": "craco start", @@ -62,6 +64,7 @@ }, "devDependencies": { "@craco/craco": "^6.1.1", + "@types/debug": "^4.1.5", "@types/file-saver": "^2.0.1", "@types/jest": "^26.0.20", "@types/node": "^12.19.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fad53..364322f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ base64-arraybuffer: 0.2.0 csv-stringify: 5.6.1 dayjs: 1.10.4 + debug: 4.3.1 file-saver: 2.0.5 i18next: 19.8.7 i18next-browser-languagedetector: 6.0.1 @@ -23,8 +24,10 @@ typesafe-actions: 5.1.0 uuid: 8.3.2 web-vitals: 1.1.0 + websocket-as-promised: 2.0.1 devDependencies: '@craco/craco': 6.1.1_react-scripts@4.0.2 + '@types/debug': 4.1.5 '@types/file-saver': 2.0.1 '@types/jest': 26.0.20 '@types/node': 12.20.0 @@ -1984,6 +1987,10 @@ dev: true resolution: integrity: sha512-kSjgDMZONiIfSH1Nxcr5JIRMwUetDki63FSQfpTCz8ogF3Ulqm8+mr5f78dUYs6vMiB6gBusQqfQmBvHZj/lwg== + /@types/debug/4.1.5: + dev: true + resolution: + integrity: sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== /@types/eslint/7.2.6: dependencies: '@types/estree': 0.0.46 @@ -3486,7 +3493,6 @@ dependencies: function-bind: 1.1.1 get-intrinsic: 1.1.1 - dev: true resolution: integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== /caller-callsite/2.0.0: @@ -3603,6 +3609,10 @@ dev: true resolution: integrity: sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== + /chnl/1.2.0: + dev: false + resolution: + integrity: sha512-g5gJb59edwCliFbX2j7G6sBfY4sX9YLy211yctONI2GRaiX0f2zIbKWmBm+sPqFNEpM7Ljzm7IJX/xrjiEbPrw== /chokidar/2.1.8: dependencies: anymatch: 2.0.0 @@ -4403,7 +4413,6 @@ /debug/4.3.1: dependencies: ms: 2.1.2 - dev: true engines: node: '>=6.0' peerDependencies: @@ -4479,7 +4488,6 @@ /define-properties/1.1.3: dependencies: object-keys: 1.1.1 - dev: true engines: node: '>= 0.4' resolution: @@ -4858,7 +4866,6 @@ object.assign: 4.1.2 string.prototype.trimend: 1.0.3 string.prototype.trimstart: 1.0.3 - dev: true engines: node: '>= 0.4' resolution: @@ -4889,7 +4896,6 @@ is-callable: 1.2.3 is-date-object: 1.0.2 is-symbol: 1.0.3 - dev: true engines: node: '>= 0.4' resolution: @@ -5819,7 +5825,6 @@ resolution: integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== /function-bind/1.1.1: - dev: true resolution: integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== /functional-red-black-tree/1.0.1: @@ -5843,7 +5848,6 @@ function-bind: 1.1.1 has: 1.0.3 has-symbols: 1.0.1 - dev: true resolution: integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== /get-own-enumerable-property-symbols/3.0.2: @@ -6033,7 +6037,6 @@ resolution: integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== /has-symbols/1.0.1: - dev: true engines: node: '>= 0.4' resolution: @@ -6076,7 +6079,6 @@ /has/1.0.3: dependencies: function-bind: 1.1.1 - dev: true engines: node: '>= 0.4.0' resolution: @@ -6592,7 +6594,6 @@ resolution: integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== /is-callable/1.2.3: - dev: true engines: node: '>= 0.4' resolution: @@ -6638,7 +6639,6 @@ resolution: integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== /is-date-object/1.0.2: - dev: true engines: node: '>= 0.4' resolution: @@ -6810,7 +6810,6 @@ dependencies: call-bind: 1.0.2 has-symbols: 1.0.1 - dev: true engines: node: '>= 0.4' resolution: @@ -6860,7 +6859,6 @@ /is-symbol/1.0.3: dependencies: has-symbols: 1.0.1 - dev: true engines: node: '>= 0.4' resolution: @@ -8232,7 +8230,6 @@ resolution: integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== /ms/2.1.2: - dev: true resolution: integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== /ms/2.1.3: @@ -8483,7 +8480,6 @@ resolution: integrity: sha1-fn2Fi3gb18mRpBupde04EnVOmYw= /object-inspect/1.9.0: - dev: true resolution: integrity: sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== /object-is/1.1.4: @@ -8496,7 +8492,6 @@ resolution: integrity: sha512-1ZvAZ4wlF7IyPVOcE1Omikt7UpaFlOQq0HlSti+ZvDH3UiD2brwGMwDbyV43jao2bKJ+4+WdPJHSd7kgzKYVqg== /object-keys/1.1.1: - dev: true engines: node: '>= 0.4' resolution: @@ -8515,7 +8510,6 @@ define-properties: 1.1.3 has-symbols: 1.0.1 object-keys: 1.1.1 - dev: true engines: node: '>= 0.4' resolution: @@ -9822,16 +9816,38 @@ node: '>=0.4.0' resolution: integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + /promise-controller/1.0.0: + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-goA0zA9L91tuQbUmiMinSYqlyUtEgg4fxJcjYnLYOQnrktb4o4UqciXDNXiRUPiDBPACmsr1k8jDW4r7UDq9Qw== /promise-inflight/1.0.1: dev: true resolution: integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM= + /promise.prototype.finally/3.1.2: + dependencies: + define-properties: 1.1.3 + es-abstract: 1.17.7 + function-bind: 1.1.1 + dev: false + engines: + node: '>= 0.4' + resolution: + integrity: sha512-A2HuJWl2opDH0EafgdjwEw7HysI8ff/n4lW4QEVBCUXFk9QeGecBWv0Deph0UmLe3tTNYegz8MOjsVuE6SMoJA== /promise/8.1.0: dependencies: asap: 2.0.6 dev: true resolution: integrity: sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q== + /promised-map/1.0.0: + dev: false + engines: + node: '>=10' + resolution: + integrity: sha512-fP9VSMgcml+U2uJ9PBc4/LDQ3ZkJCH4blLNCS6gbH7RHyRZCYs91zxWHqiUy+heFiEMiB2op/qllYoFqmIqdWA== /prompts/2.4.0: dependencies: kleur: 3.0.3 @@ -11901,14 +11917,12 @@ dependencies: call-bind: 1.0.2 define-properties: 1.1.3 - dev: true resolution: integrity: sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== /string.prototype.trimstart/1.0.3: dependencies: call-bind: 1.0.2 define-properties: 1.1.3 - dev: true resolution: integrity: sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== /string_decoder/1.1.1: @@ -12958,6 +12972,17 @@ optional: true resolution: integrity: sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q== + /websocket-as-promised/2.0.1: + dependencies: + chnl: 1.2.0 + promise-controller: 1.0.0 + promise.prototype.finally: 3.1.2 + promised-map: 1.0.0 + dev: false + engines: + node: '>=6' + resolution: + integrity: sha512-ePV26D/D37ughXU9j+DjGmwUbelWJrC/vi+6GK++fRlBJmS7aU9T8ABu47KFF0O7r6XN2NAuqJRpegbUwXZxQg== /websocket-driver/0.6.5: dependencies: websocket-extensions: 0.1.4 @@ -13337,6 +13362,7 @@ '@testing-library/jest-dom': ^5.11.9 '@testing-library/react': ^11.2.5 '@testing-library/user-event': ^12.7.1 + '@types/debug': ^4.1.5 '@types/file-saver': ^2.0.1 '@types/jest': ^26.0.20 '@types/node': ^12.19.16 @@ -13354,6 +13380,7 @@ craco-less: ^1.17.1 csv-stringify: ^5.6.1 dayjs: ^1.10.4 + debug: ^4.3.1 eslint: ^7.20.0 eslint-config-prettier: ^7.2.0 eslint-plugin-react: ^7.22.0 @@ -13380,3 +13407,4 @@ utility-types: ^3.10.0 uuid: ^8.3.2 web-vitals: ^1.1.0 + websocket-as-promised: ^2.0.1 diff --git a/public/locales/en.json b/public/locales/en.json index 7132156..a3eaedd 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -234,5 +234,10 @@ "settings": "Settings", "settingsDebug": "Debug", "settingsTranslations": "Translations" + }, + + "ws": { + "errorToken": "There was an error connecting to the Krist websocket server.", + "errorWS": "There was an error connecting to the Krist websocket server (code <1>{{code}})." } } diff --git a/src/App.tsx b/src/App.tsx index 626f380..20022f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { AppLayout } from "./layout/AppLayout"; import { SyncWallets } from "./components/wallets/SyncWallets"; import { ForcedAuth } from "./components/auth/ForcedAuth"; +import { WebsocketService } from "./components/ws/WebsocketService"; export const store = createStore( rootReducer, @@ -34,8 +35,11 @@ + + {/* Services, etc. */} + ; diff --git a/src/components/auth/ForcedAuth.tsx b/src/components/auth/ForcedAuth.tsx index e72ed88..f63f558 100644 --- a/src/components/auth/ForcedAuth.tsx +++ b/src/components/auth/ForcedAuth.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { message } from "antd"; import { useTranslation, TFunction } from "react-i18next"; @@ -8,6 +7,8 @@ import { authMasterPassword } from "../../krist/wallets/WalletManager"; +import { useMountEffect } from "../../utils"; + async function forceAuth(t: TFunction, dispatch: AppDispatch, salt: string, tester: string): Promise { try { const password = localStorage.getItem("forcedAuth"); @@ -28,14 +29,11 @@ const dispatch = useDispatch(); const { t } = useTranslation(); - const [attemptedAuth, setAttemptedAuth] = useState(false); - useEffect(() => { - if (attemptedAuth || isAuthed || !hasMasterPassword || !salt || !tester) return; - setAttemptedAuth(true); - + useMountEffect(() => { + if (isAuthed || !hasMasterPassword || !salt || !tester) return; forceAuth(t, dispatch, salt, tester); - }, [attemptedAuth]); + }); return null; } diff --git a/src/components/wallets/SyncWallets.tsx b/src/components/wallets/SyncWallets.tsx index 85430b3..b614076 100644 --- a/src/components/wallets/SyncWallets.tsx +++ b/src/components/wallets/SyncWallets.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useMountEffect } from "../../utils"; import { useDispatch, useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; @@ -10,15 +10,10 @@ const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual); const dispatch = useDispatch(); - const [synced, setSynced] = useState(false); - - useEffect(() => { - if (synced) return; - setSynced(true); - + useMountEffect(() => { // TODO: show errors to the user? syncWallets(dispatch, wallets).catch(console.error); - }, [synced]); + }); return null; } diff --git a/src/components/ws/WebsocketService.tsx b/src/components/ws/WebsocketService.tsx new file mode 100644 index 0000000..c51a39b --- /dev/null +++ b/src/components/ws/WebsocketService.tsx @@ -0,0 +1,178 @@ +import React, { useMemo, useEffect } from "react"; + +import { useSelector, shallowEqual, useDispatch } from "react-redux"; +import { AppDispatch } from "../../App"; +import { RootState } from "../../store"; +import { WalletMap } from "../../store/reducers/WalletsReducer"; +import * as wsActions from "../../store/actions/WebsocketActions"; + +import packageJson from "../../../package.json"; + +import { APIResponse, KristAddress, KristTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "../../krist/api/types"; +import { findWalletByAddress, syncWalletUpdate } from "../../krist/wallets/Wallet"; +import WebSocketAsPromised from "websocket-as-promised"; + +import Debug from "debug"; +const debug = Debug("kristweb:ws"); + +const DEFAULT_CONNECT_DEBOUNCE = 1000; +const MAX_DEBOUNCE = 360000; +class WebsocketConnection { + private wallets?: WalletMap; + private ws?: WebSocketAsPromised; + private reconnectionTimer?: number; + + private messageID = 1; + private connectDebounce = DEFAULT_CONNECT_DEBOUNCE; + + constructor(private dispatch: AppDispatch) { + debug("WS component init"); + this.attemptConnect(); + } + + setWallets(wallets: WalletMap): void { + this.wallets = wallets; + } + + private async connect() { + debug("attempting connection to server..."); + this.setConnectionState("disconnected"); + + const syncNode = packageJson.defaultSyncNode; // TODO: support alt nodes + + // Get a websocket token + const res = await fetch(syncNode + "/ws/start", { method: "POST" }); + if (!res.ok || res.status !== 200) throw new Error("ws.errorToken"); + const data: APIResponse<{ url: string }> = await res.json(); + if (!data.ok || data.error) throw new Error("ws.errorToken"); + + this.setConnectionState("connecting"); + + // Connect to the websocket server + this.ws = new WebSocketAsPromised(data.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; + } + + async attemptConnect() { + try { + await this.connect(); + } catch (err) { + this.handleDisconnect(err); + } + } + + private handleDisconnect(err?: Error | number) { + 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_DEBOUNCE); + this.attemptConnect(); + }, this.connectDebounce); + } + + handleClose(event: { reason: number }) { + this.handleDisconnect(event.reason); + } + + private setConnectionState(state: WSConnectionState) { + this.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(this.dispatch, 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 from %s to %s", transaction.from || "null", transaction.to || "null"); + + const fromWallet = findWalletByAddress(this.wallets, transaction.from); + if (fromWallet) this.refreshBalance(fromWallet.address); + + const toWallet = findWalletByAddress(this.wallets, transaction.to); + if (toWallet) this.refreshBalance(toWallet.address); + + break; + } + } + } + } + + /** Queues a command to re-fetch an address's balance. The response will be + * handled in {@link handleMessage}. */ + 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 } = useSelector((s: RootState) => s.wallets, shallowEqual); + const dispatch = useDispatch(); + + const connection = useMemo(() => new WebsocketConnection(dispatch), []); + + useEffect(() => { + connection.setWallets(wallets); + }, [wallets]); + + return null; +} diff --git a/src/index.tsx b/src/index.tsx index a68daeb..a143e4f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,11 @@ +import "./utils/setup"; + import React from "react"; import ReactDOM from "react-dom"; + import "./index.css"; import App from "./App"; + import reportWebVitals from "./reportWebVitals"; ReactDOM.render( diff --git a/src/krist/api/types.ts b/src/krist/api/types.ts index 7c6c312..19dc3e0 100644 --- a/src/krist/api/types.ts +++ b/src/krist/api/types.ts @@ -8,7 +8,33 @@ firstseen: string; } +export type KristTransactionType = "unknown" | "mined" | "name_purchase" | "name_a_record" | "name_transfer" | "transfer"; +export interface KristTransaction { + id: number; + from: string; + to: string; + value: number; + time: string; + name: string; + metadata: string; + type: KristTransactionType; +} + export type APIResponse> = T & { ok: boolean; error?: string; } + +export type WSConnectionState = "connected" | "disconnected" | "connecting"; +export type WSSubscriptionLevel = "blocks" | "ownBlocks" | "transactions" | "ownTransactions" | "names" | "ownNames" | "motd"; +export type WSEvent = "block" | "transaction" | "name" | "motd"; +export interface WSIncomingMessage { + id?: number; + ok?: boolean; + error?: string; + type?: "keepalive" | "hello" | "event"; + + event?: WSEvent; + + [key: string]: any; +} diff --git a/src/krist/wallets/Wallet.ts b/src/krist/wallets/Wallet.ts index 231fcd4..e7e69cc 100644 --- a/src/krist/wallets/Wallet.ts +++ b/src/krist/wallets/Wallet.ts @@ -121,9 +121,9 @@ function syncWalletProperties(wallet: Wallet, address: KristAddressWithNames, syncTime: Date): Wallet { return { ...wallet, - balance: address.balance, - names: address.names, - firstSeen: address.firstseen, + ...(address.balance !== undefined ? { balance: address.balance } : {}), + ...(address.names !== undefined ? { names: address.names } : {}), + ...(address.firstseen !== undefined ? { firstSeen: address.firstseen } : {}), lastSynced: syncTime.toISOString() }; } @@ -131,8 +131,6 @@ /** Sync the data for a single wallet from the sync node, save it to local * storage, and dispatch the change to the Redux store. */ export async function syncWallet(dispatch: AppDispatch, wallet: Wallet): Promise { - const syncTime = new Date(); - // Fetch the data from the sync node (e.g. balance) const { address } = wallet; const lookupResults = await lookupAddresses([address], true); @@ -140,7 +138,14 @@ const kristAddress = lookupResults[address]; if (!kristAddress) return; // Skip unsyncable wallet - const updatedWallet = syncWalletProperties(wallet, kristAddress, syncTime); + syncWalletUpdate(dispatch, wallet, kristAddress); +} + +/** Given an already synced wallet, save it to local storage, and dispatch the + * change to the Redux store. */ +export function syncWalletUpdate(dispatch: AppDispatch, wallet: Wallet, address: KristAddressWithNames): void { + const syncTime = new Date(); + const updatedWallet = syncWalletProperties(wallet, address, syncTime); // Save the wallet to local storage (unless dontSave is set) saveWallet(updatedWallet); @@ -297,3 +302,14 @@ return null; } } + +/** Finds a wallet in the wallet map by the given Krist address. */ +export function findWalletByAddress(wallets: WalletMap, address?: string): Wallet | null { + if (!address) return null; + + for (const id in wallets) + if (wallets[id].address === address) + return wallets[id]; + + return null; +} diff --git a/src/layout/AppLayout.tsx b/src/layout/AppLayout.tsx index 008e9b9..649a1a3 100644 --- a/src/layout/AppLayout.tsx +++ b/src/layout/AppLayout.tsx @@ -1,12 +1,7 @@ import React, { useState } from "react"; -import { Layout, Menu, Grid, AutoComplete, Input } from "antd"; -import { SendOutlined, DownloadOutlined, MenuOutlined, SettingOutlined } from "@ant-design/icons"; +import { Layout, Grid } from "antd"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -import { Brand } from "./nav/Brand"; -import { CymbalIndicator } from "./nav/CymbalIndicator"; +import { AppHeader } from "./nav/AppHeader"; import { Sidebar } from "./sidebar/Sidebar"; import { AppRouter } from "./AppRouter"; @@ -15,50 +10,11 @@ const { useBreakpoint } = Grid; export function AppLayout(): JSX.Element { - const { t } = useTranslation(); const [sidebarCollapsed, setSidebarCollapsed] = useState(true); const bps = useBreakpoint(); return - - {/* Sidebar toggle for mobile */} - {!bps.md && ( - - setSidebarCollapsed(!sidebarCollapsed)}> - - - - )} - - {/* Logo */} - {bps.md && } - - {/* Send and receive buttons */} - {bps.md && - }>{t("nav.send")} - }>{t("nav.request")} - } - - {/* Spacer to push search box to the right */} - {bps.md &&
} - - {/* Search box */} -
- - - -
- - {/* Cymbal indicator */} - - - {/* Settings button */} - - } title={t("nav.settings")}> - - - - + diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index 3914602..c38ec9c 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -37,7 +37,7 @@ useEffect(() => { if (siteTitle) document.title = `${siteTitle} - KristWeb`; else if (siteTitleKey) document.title = `${t(siteTitleKey)} - KristWeb`; - }); + }, []); return
{/* Page header */} diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx new file mode 100644 index 0000000..149bccd --- /dev/null +++ b/src/layout/nav/AppHeader.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { Layout, Menu, AutoComplete, Input, Grid } from "antd"; +import { SendOutlined, DownloadOutlined, MenuOutlined, SettingOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { Brand } from "./Brand"; +import { ConnectionIndicator } from "./ConnectionIndicator"; +import { CymbalIndicator } from "./CymbalIndicator"; + +const { useBreakpoint } = Grid; + +interface Props { + sidebarCollapsed: boolean; + setSidebarCollapsed: React.Dispatch>; +} + +export function AppHeader({ sidebarCollapsed, setSidebarCollapsed }: Props): JSX.Element { + const { t } = useTranslation(); + const bps = useBreakpoint(); + + return + {/* Sidebar toggle for mobile */} + {!bps.md && ( + + setSidebarCollapsed(!sidebarCollapsed)}> + + + + )} + + {/* Logo */} + {bps.md && } + + {/* Send and receive buttons */} + {bps.md && + }>{t("nav.send")} + }>{t("nav.request")} + } + + {/* Spacer to push search box to the right */} + {bps.md &&
} + + {/* Search box */} +
+ + + +
+ + {/* Connection indicator */} + + + {/* Cymbal indicator */} + + + {/* Settings button */} + + } title={t("nav.settings")}> + + + + ; +} diff --git a/src/layout/nav/ConnectionIndicator.less b/src/layout/nav/ConnectionIndicator.less new file mode 100644 index 0000000..466b0dc --- /dev/null +++ b/src/layout/nav/ConnectionIndicator.less @@ -0,0 +1,28 @@ +@import (reference) "../../App.less"; + +.connection-indicator { + vertical-align: middle; + line-height: @layout-header-height; + + &::after { + content: " "; + display: inline-block; + + width: 12px; + height: 12px; + border-radius: 50%; + + background-color: @kw-green; + box-shadow: 0 0 0 3px rgba(@kw-green, 0.3); + } + + &.connection-connecting::after { + background-color: @kw-secondary; + box-shadow: 0 0 0 3px rgba(@kw-secondary, 0.3); + } + + &.connection-disconnected::after { + background-color: @kw-red; + box-shadow: 0 0 0 3px rgba(@kw-red, 0.3); + } +} diff --git a/src/layout/nav/ConnectionIndicator.tsx b/src/layout/nav/ConnectionIndicator.tsx new file mode 100644 index 0000000..5f5d521 --- /dev/null +++ b/src/layout/nav/ConnectionIndicator.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Tooltip } from "antd"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; +import { useTranslation } from "react-i18next"; + +import { WSConnectionState } from "../../krist/api/types"; + +import "./ConnectionIndicator.less"; + +const CONN_STATE_TOOLTIPS: Record = { + "connected": "nav.connection.online", + "disconnected": "nav.connection.offline", + "connecting": "nav.connection.connecting" +}; + +export function ConnectionIndicator(): JSX.Element { + const { t } = useTranslation(); + const connectionState = useSelector((s: RootState) => s.websocket.connectionState); + + return
+ +
+ +
; +} diff --git a/src/layout/sidebar/SidebarFooter.tsx b/src/layout/sidebar/SidebarFooter.tsx index b0ad641..8710ab6 100644 --- a/src/layout/sidebar/SidebarFooter.tsx +++ b/src/layout/sidebar/SidebarFooter.tsx @@ -1,17 +1,16 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { useTranslation, Trans } from "react-i18next"; import packageJson from "../../../package.json"; import { Link } from "react-router-dom"; +import { useMountEffect } from "../../utils"; + export function SidebarFooter(): JSX.Element { const { t } = useTranslation(); - const [host, setHost] = useState<{ name: string; url: string } | false | undefined>(); + const [host, setHost] = useState<{ name: string; url: string } | undefined>(); - useEffect(() => { - if (host !== undefined) return; - setHost(false); - + useMountEffect(() => { (async () => { try { // Add the host information if host.json exists @@ -22,7 +21,7 @@ // Ignored } })(); - }, [host]); + }); const authorName = packageJson.author || "Lemmmy"; const authorURL = `https://github.com/${authorName}`; diff --git a/src/pages/credits/Supporters.tsx b/src/pages/credits/Supporters.tsx index 043596b..af91137 100644 --- a/src/pages/credits/Supporters.tsx +++ b/src/pages/credits/Supporters.tsx @@ -1,10 +1,12 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Space, Spin, Button } from "antd"; import { DollarOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import packageJson from "../../../package.json"; +import { useMountEffect } from "../../utils"; + interface Supporter { name: string; url?: string; @@ -19,16 +21,12 @@ const { supportURL, supportersURL } = packageJson; const { t } = useTranslation(); - const [fetched, setFetched] = useState(false); const [supportersState, setSupportersState] = useState({ loaded: false, supporters: undefined }); - useEffect(() => { - if (fetched) return; - setFetched(true); - + useMountEffect(() => { (async () => { // GPU required for this function: const res = await fetch(supportersURL); @@ -39,7 +37,7 @@ supporters: data.supporters }); })(); - }, [fetched, supportersURL]); + }); if (!supportURL) return null; return diff --git a/src/pages/settings/SettingsTranslations.tsx b/src/pages/settings/SettingsTranslations.tsx index 7136bd4..512ef25 100644 --- a/src/pages/settings/SettingsTranslations.tsx +++ b/src/pages/settings/SettingsTranslations.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Table, Progress, Result, Typography, Tooltip, Button } from "antd"; import { ExclamationCircleOutlined, FileExcelOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { getLanguages, Language } from "../../utils/i18n"; +import { useMountEffect } from "../../utils"; import csvStringify from "csv-stringify"; import { saveAs } from "file-saver"; @@ -96,7 +97,6 @@ export function SettingsTranslations(): JSX.Element { const { t } = useTranslation(); - const [fetched, setFetched] = useState(false); const [loading, setLoading] = useState(true); const [analysed, setAnalysed] = useState<{ enKeyCount: number; @@ -148,11 +148,7 @@ saveAs(blob, "kristweb-translations.csv"); } - useEffect(() => { - if (fetched) return; - setFetched(true); - loadLanguages(); - }, [fetched]); + useMountEffect(() => { loadLanguages().catch(console.error); }); return } onClick={exportCSV}> diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index 7c86e43..5f39d8d 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -68,12 +68,22 @@ try { if (editing) { // Edit wallet - // Double check the wallet exists + // Double check the destination wallet exists if (!wallets[editing.id]) return notification.error({ message: t("addWallet.errorMissingWalletTitle"), description: t("addWallet.errorMissingWalletDescription") }); + // If the address changed, check that a wallet doesn't already exist + // with this address + if (editing.address !== calculatedAddress + && Object.values(wallets).find(w => w.address === calculatedAddress)) { + return notification.error({ + message: t("addWallet.errorDuplicateWalletTitle"), + description: t("addWallet.errorDuplicateWalletDescription") + }); + } + await editWallet(dispatch, masterPassword, editing, values, values.password); message.success(t("addWallet.messageSuccessEdit")); diff --git a/src/store/actions/Settings.ts b/src/store/actions/Settings.ts deleted file mode 100644 index 97e5898..0000000 --- a/src/store/actions/Settings.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PickByValue } from "utility-types"; -import { createAction } from "typesafe-actions"; - -import * as constants from "../constants"; - -import { State } from "../reducers/SettingsReducer"; - -export interface SetBooleanSettingPayload { - settingName: keyof PickByValue; - value: boolean; -} -export const setBooleanSetting = createAction(constants.SET_BOOLEAN_SETTING, - (settingName, value): SetBooleanSettingPayload => - ({ settingName, value }))(); diff --git a/src/store/actions/SettingsActions.ts b/src/store/actions/SettingsActions.ts new file mode 100644 index 0000000..97e5898 --- /dev/null +++ b/src/store/actions/SettingsActions.ts @@ -0,0 +1,14 @@ +import { PickByValue } from "utility-types"; +import { createAction } from "typesafe-actions"; + +import * as constants from "../constants"; + +import { State } from "../reducers/SettingsReducer"; + +export interface SetBooleanSettingPayload { + settingName: keyof PickByValue; + value: boolean; +} +export const setBooleanSetting = createAction(constants.SET_BOOLEAN_SETTING, + (settingName, value): SetBooleanSettingPayload => + ({ settingName, value }))(); diff --git a/src/store/actions/WebsocketActions.ts b/src/store/actions/WebsocketActions.ts new file mode 100644 index 0000000..b697367 --- /dev/null +++ b/src/store/actions/WebsocketActions.ts @@ -0,0 +1,6 @@ +import { createAction } from "typesafe-actions"; +import { WSConnectionState } from "../../krist/api/types"; + +import * as constants from "../constants"; + +export const setConnectionState = createAction(constants.CONNECTION_STATE)(); diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts index 8f3aab8..16d89e2 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -1,10 +1,12 @@ import * as walletManagerActions from "./WalletManagerActions"; import * as walletsActions from "./WalletsActions"; -import * as settingsActions from "./Settings"; +import * as settingsActions from "./SettingsActions"; +import * as websocketActions from "./WebsocketActions"; const RootAction = { walletManager: walletManagerActions, wallets: walletsActions, settings: settingsActions, + websocket: websocketActions }; export default RootAction; diff --git a/src/store/constants.ts b/src/store/constants.ts index 3cac3b6..59b31e5 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -15,3 +15,7 @@ // Settings // --- export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING"; + +// Websockets +// --- +export const CONNECTION_STATE = "CONNECTION_STATE"; diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts index 6a2e4be..3d7ecfd 100644 --- a/src/store/reducers/RootReducer.ts +++ b/src/store/reducers/RootReducer.ts @@ -3,9 +3,11 @@ import { WalletManagerReducer } from "./WalletManagerReducer"; import { WalletsReducer } from "./WalletsReducer"; import { SettingsReducer } from "./SettingsReducer"; +import { WebsocketReducer } from "./WebsocketReducer"; export default combineReducers({ walletManager: WalletManagerReducer, wallets: WalletsReducer, settings: SettingsReducer, + websocket: WebsocketReducer }); diff --git a/src/store/reducers/SettingsReducer.ts b/src/store/reducers/SettingsReducer.ts index a888d3f..b148d3a 100644 --- a/src/store/reducers/SettingsReducer.ts +++ b/src/store/reducers/SettingsReducer.ts @@ -1,6 +1,6 @@ import { createReducer, ActionType } from "typesafe-actions"; import { loadSettings, SettingsState } from "../../utils/settings"; -import { setBooleanSetting } from "../actions/Settings"; +import { setBooleanSetting } from "../actions/SettingsActions"; export type State = SettingsState; diff --git a/src/store/reducers/WebsocketReducer.ts b/src/store/reducers/WebsocketReducer.ts new file mode 100644 index 0000000..a002037 --- /dev/null +++ b/src/store/reducers/WebsocketReducer.ts @@ -0,0 +1,17 @@ +import { createReducer, ActionType } from "typesafe-actions"; +import { WSConnectionState } from "../../krist/api/types"; +import { setConnectionState } from "../actions/WebsocketActions"; + +export interface State { + readonly connectionState: WSConnectionState; +} + +export const initialState: State = { + connectionState: "disconnected" +}; + +export const WebsocketReducer = createReducer(initialState) + .handleAction(setConnectionState, (state: State, action: ActionType) => ({ + ...state, + connectionState: action.payload + })); diff --git a/src/utils/index.ts b/src/utils/index.ts index 95834cc..9d20cad 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +import { EffectCallback, useEffect } from "react"; + export const toHex = (input: ArrayBufferLike | Uint8Array): string => [...(input instanceof Uint8Array ? input : new Uint8Array(input))] .map(b => b.toString(16).padStart(2, "0")) @@ -79,3 +81,5 @@ : (va as any) - (vb as any); } }; + +export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index b43fd94..3965c38 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,7 +1,7 @@ import { PickByValue } from "utility-types"; import { AppDispatch } from "../App"; -import * as actions from "../store/actions/Settings"; +import * as actions from "../store/actions/SettingsActions"; export interface SettingsState { /** Whether or not advanced wallet formats are enabled. */ diff --git a/src/utils/setup.ts b/src/utils/setup.ts new file mode 100644 index 0000000..e2e45ca --- /dev/null +++ b/src/utils/setup.ts @@ -0,0 +1,8 @@ +import { toHex } from "./"; +import Debug from "debug"; + +// Set up custom debug formatters +// Booleans (%b) +Debug.formatters.b = (v: boolean) => v ? "true" : "false"; +// Buffers as hex strings (%x) +Debug.formatters.x = (v: ArrayBufferLike | Uint8Array) => toHex(v);