From 003aeef016e68afb1015e72c209ceb2e43c49a2e Mon Sep 17 00:00:00 2001 From: Eugene P Date: Tue, 13 Feb 2024 15:56:00 +0200 Subject: [PATCH 1/4] enhancement: update legend for visualizer (#1112) Signed-off-by: Eugene Panteleymonchuk --- .../visualizer-threejs/wrapper/KeyPanel.scss | 26 +----- .../visualizer-threejs/wrapper/KeyPanel.tsx | 85 +++++++++++++------ 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss index ef0a9631f..354e27b6a 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss @@ -36,31 +36,7 @@ width: 12px; height: 12px; margin-right: 10px; - border-radius: 3px; - - &.vertex-state--pending { - background-color: #bbbbbb; - } - - &.vertex-state--included { - background-color: #4caaff; - } - - &.vertex-state--referenced { - background-color: #61e884; - } - - &.vertex-state--conflicting { - background-color: #ff8b5c; - } - - &.vertex-state--milestone { - background-color: #666af6; - } - - &.vertex-state--search-result { - background-color: #c061e8; - } + border-radius: 50%; } .key-label { diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx index b9e006a99..f2affbbea 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx @@ -1,34 +1,63 @@ import React from "react"; +import { BlockState } from "@iota/sdk-wasm-nova/web"; import "./KeyPanel.scss"; -export const KeyPanel: React.FC = () => ( -
-
-
-
-
Pending
-
-
-
-
Included
-
-
-
-
Referenced
-
-
-
-
Conflicting
-
-
-
-
Milestone
-
-
-
-
Search result
+export const KeyPanel: React.FC = () => { + const statuses: { + label: string; + state: BlockState; + color: string; + }[] = [ + { + label: "Pending", + state: "pending", + color: "#A6C3FC", + }, + { + label: "Accepted", + state: "accepted", + color: "#0101AB", + }, + { + label: "Confirmed", + state: "confirmed", + color: "#0000DB", + }, + { + label: "Finalized", + state: "finalized", + color: "#0101FF", + }, + { + label: "Rejected", + state: "rejected", + color: "#252525", + }, + { + label: "Failed", + state: "failed", + color: "#ff1d38", + }, + ]; + + return ( +
+
+ {statuses.map((s) => { + return ( +
+
+
{s.label}
+
+ ); + })}
-
-); + ); +}; From 072a52a86ee6ef368a222f940faa2e3093dee261 Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 13 Feb 2024 16:07:20 +0100 Subject: [PATCH 2/4] feat: Add nova utils for unix timestamp to slot/epoch index conversion (#1099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add nova utils for unix timestamp to slot index conversion * chore: Remove useless hook (on second thought) * fix: Fix computation of genesisSlot time range and fix test * feat: Add utils for slot and unix timestamp to epoch * feat: Add tests for epoch utils * fix: Return early also if targetSlot is exactly genesisSlot in slotIndexToTimeRange * fix: Fix variable name in tests * feat: Add hook for Slot/Epoch to time conversions * chore: rename function * chore: rename tests * doc: add jsdoc * doc: update --------- Co-authored-by: Begoña Álvarez de la Cruz --- .../helpers/nova/hooks/useNovaTimeConvert.ts | 23 ++ client/src/helpers/nova/novaTimeUtils.spec.ts | 240 ++++++++++++++++++ client/src/helpers/nova/novaTimeUtils.ts | 84 ++++++ 3 files changed, 347 insertions(+) create mode 100644 client/src/helpers/nova/hooks/useNovaTimeConvert.ts create mode 100644 client/src/helpers/nova/novaTimeUtils.spec.ts create mode 100644 client/src/helpers/nova/novaTimeUtils.ts diff --git a/client/src/helpers/nova/hooks/useNovaTimeConvert.ts b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts new file mode 100644 index 000000000..a4da973f3 --- /dev/null +++ b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts @@ -0,0 +1,23 @@ +import { useNetworkInfoNova } from "../networkInfo"; +import { + slotIndexToEpochIndexConverter, + slotIndexToUnixTimeRangeConverter, + unixTimestampToEpochIndexConverter, + unixTimestampToSlotIndexConverter, +} from "../novaTimeUtils"; + +export function useNovaTimeConvert(): { + unixTimestampToSlotIndex: ((unixTimestampSeconds: number) => number) | null; + slotIndexToUnixTimeRange: ((slotIndex: number) => { from: number; to: number }) | null; + slotIndexToEpochIndex: ((targetSlotIndex: number) => number) | null; + unixTimestampToEpochIndex: ((unixTimestampSeconds: number) => number) | null; +} { + const { protocolInfo } = useNetworkInfoNova((s) => s.networkInfo); + + return { + unixTimestampToSlotIndex: protocolInfo ? unixTimestampToSlotIndexConverter(protocolInfo) : null, + slotIndexToUnixTimeRange: protocolInfo ? slotIndexToUnixTimeRangeConverter(protocolInfo) : null, + slotIndexToEpochIndex: protocolInfo ? slotIndexToEpochIndexConverter(protocolInfo) : null, + unixTimestampToEpochIndex: protocolInfo ? unixTimestampToEpochIndexConverter(protocolInfo) : null, + }; +} diff --git a/client/src/helpers/nova/novaTimeUtils.spec.ts b/client/src/helpers/nova/novaTimeUtils.spec.ts new file mode 100644 index 000000000..4821ec04c --- /dev/null +++ b/client/src/helpers/nova/novaTimeUtils.spec.ts @@ -0,0 +1,240 @@ +import { ProtocolInfo } from "@iota/sdk-wasm-nova/web"; +import { + unixTimestampToSlotIndexConverter, + slotIndexToUnixTimeRangeConverter, + slotIndexToEpochIndexConverter, + unixTimestampToEpochIndexConverter, +} from "./novaTimeUtils"; + +const mockProtocolInfo: ProtocolInfo = { + // @ts-expect-error Irrelevant fields omitted + parameters: { + type: 0, + version: 3, + networkName: "test", + bech32Hrp: "rms", + tokenSupply: 1813620509061365n, + + // + genesisSlot: 5, + genesisUnixTimestamp: 1707321857n, // 7 February 2024 16:04:17 + slotDurationInSeconds: 10, + slotsPerEpochExponent: 13, + // + + stakingUnbondingPeriod: 10, + validationBlocksPerSlot: 10, + punishmentEpochs: 10, + livenessThresholdLowerBound: 15, + livenessThresholdUpperBound: 30, + minCommittableAge: 10, + maxCommittableAge: 20, + epochNearingThreshold: 60, + targetCommitteeSize: 32, + chainSwitchingThreshold: 3, + }, + startEpoch: 0, +}; + +const genesisSlot = mockProtocolInfo.parameters.genesisSlot; +const genesisUnixTimestamp = Number(mockProtocolInfo.parameters.genesisUnixTimestamp); // 7 February 2024 16:04:17 +const slotDurationInSeconds = mockProtocolInfo.parameters.slotDurationInSeconds; +const slotsPerEpochExponent = mockProtocolInfo.parameters.slotsPerEpochExponent; + +const slotHalfSeconds = Math.floor(slotDurationInSeconds / 2); +const slotsInEpoch = Math.pow(2, slotsPerEpochExponent); // 8192 + +const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(mockProtocolInfo); +const slotIndexToUnixTimeRange = slotIndexToUnixTimeRangeConverter(mockProtocolInfo); +const slotIndexToEpochIndex = slotIndexToEpochIndexConverter(mockProtocolInfo); +const unixTimestampToEpochIndex = unixTimestampToEpochIndexConverter(mockProtocolInfo); + +describe("unixTimestampToSlotIndex", () => { + test("should return genesis slot when timestamp is lower than genesisUnixTimestamp", () => { + const target = 1707321853; // 7 February 2024 16:04:13 + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(mockProtocolInfo.parameters.genesisSlot); + }); + + test("should return genesis slot + 1 when passed genesisUnixTimestamp", () => { + const target = genesisUnixTimestamp; + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(genesisSlot + 1); + }); + + test("should return the correct slot", () => { + const target = genesisUnixTimestamp + 42 * slotDurationInSeconds; // 42 slots after genesis (in unix seconds) + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(genesisSlot + 43); // we are in 43rd slot + }); + + test("should work for big inputs", () => { + const target = 5680281601; // 1 January 2150 00:00:01 + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(397295980); + }); +}); + +describe("slotIndexToUnixTimeRange", () => { + test("should return genesis slot timestamp when passed a slotIndex lower than genesisSlot", () => { + let target = genesisSlot - 1; // 4 + const expectedGenesisTimestampRange = { + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }; + + let slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + + target = genesisSlot - 2; // 3 + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + + target = genesisSlot - 3; // 2 + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + }); + + test("should return correct genesis slot timestamp range", () => { + const target = genesisSlot; + + const slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }); + }); + + test("should return timestamp range of 'genesis slot + x' when passed 'genesis slot + x'", () => { + let target = genesisSlot + 1; + + let slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp, + to: genesisUnixTimestamp + 1 * slotDurationInSeconds, + }); + + target = genesisSlot + 2; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 1 * slotDurationInSeconds, + to: genesisUnixTimestamp + 2 * slotDurationInSeconds, + }); + + target = genesisSlot + 3; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 2 * slotDurationInSeconds, + to: genesisUnixTimestamp + 3 * slotDurationInSeconds, + }); + + target = genesisSlot + 5; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 4 * slotDurationInSeconds, + to: genesisUnixTimestamp + 5 * slotDurationInSeconds, + }); + + target = genesisSlot + 8; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 7 * slotDurationInSeconds, + to: genesisUnixTimestamp + 8 * slotDurationInSeconds, + }); + }); + + test("should work for big inputs", () => { + const target = 397295980; // Slot of 1 January 2150 00:00:01 + + const slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: 5680281597, + to: 5680281607, + }); + }); +}); + +describe("slotIndexToUnixTimeRange & unixTimestampToSlotIndex", () => { + test("should be able to go from slot to timestamp and back correctly", () => { + const targetSlotIndex = 12; // Slot of 1 January 2150 00:00:01 + + const targetSlotUnixTimeRange = slotIndexToUnixTimeRange(targetSlotIndex); + + expect(targetSlotUnixTimeRange).toStrictEqual({ + from: 1707321917, + to: 1707321927, + }); + + const resultSlotIndex = unixTimestampToSlotIndex(targetSlotUnixTimeRange.from + slotHalfSeconds); + + expect(resultSlotIndex).toBe(targetSlotIndex); + }); + + test("should be able to go from timestamp to slot and back correctly", () => { + const targetTimestamp = 1707484909; // 9 February 2024 13:21:49 + + const slotIndex = unixTimestampToSlotIndex(targetTimestamp); + + expect(slotIndex).toBe(16311); + + const slotUnixTimeRange = slotIndexToUnixTimeRange(slotIndex); + + expect(slotUnixTimeRange.from).toBeLessThan(targetTimestamp); + expect(slotUnixTimeRange.to).toBeGreaterThan(targetTimestamp); + }); +}); + +describe("slotIndexToEpochIndex", () => { + test("should return epoch 0 for slot index less then slotsInEpoch", () => { + const targetSlotIndex = slotsInEpoch - 100; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(0); + }); + + test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { + const targetSlotIndex = slotsInEpoch + 100; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(1); + }); + + test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { + const targetSlotIndex = 50000; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(6); // 50000 / 8192 = 6.1 + }); +}); + +describe("unixTimestampToEpochIndex", () => { + test("should return the correct epoch index based on timestamp", () => { + const targetTimestamp = 1707493847; // 9 February 2024 15:50:47 + + const epochIndex = unixTimestampToEpochIndex(targetTimestamp); + + expect(epochIndex).toBe(2); + }); +}); diff --git a/client/src/helpers/nova/novaTimeUtils.ts b/client/src/helpers/nova/novaTimeUtils.ts new file mode 100644 index 000000000..25d38e2d9 --- /dev/null +++ b/client/src/helpers/nova/novaTimeUtils.ts @@ -0,0 +1,84 @@ +import { ProtocolInfo } from "@iota/sdk-wasm-nova/web"; + +// Note: genesisUnixTimestamp is the first second that falls into genesisSlot + 1 + +/** + * Convert a UNIX timestamp to a slot index. + * @param protocolInfo The protocol information. + * @param unixTimestampSeconds The UNIX timestamp in seconds. + * @returns The slot index. + */ +export function unixTimestampToSlotIndexConverter(protocolInfo: ProtocolInfo): (unixTimestampSeconds: number) => number { + return (unixTimestampSeconds: number) => { + const genesisSlot = protocolInfo.parameters.genesisSlot; + const genesisUnixTimestamp = protocolInfo.parameters.genesisUnixTimestamp; + const slotDurationInSeconds = protocolInfo.parameters.slotDurationInSeconds; + + const elapsedTime = unixTimestampSeconds - Number(genesisUnixTimestamp); + + if (elapsedTime < 0) { + return genesisSlot; + } + + return genesisSlot + Math.floor(elapsedTime / slotDurationInSeconds) + 1; + }; +} + +/** + * Convert a slot index to a UNIX time range, in seconds. + * @param protocolInfo The protocol information. + * @param targetSlotIndex The target slot index. + * @returns The UNIX time range in seconds: from (inclusive) and to (exclusive). + */ +export function slotIndexToUnixTimeRangeConverter(protocolInfo: ProtocolInfo): (targetSlotIndex: number) => { from: number; to: number } { + return (targetSlotIndex: number) => { + const genesisSlot = protocolInfo.parameters.genesisSlot; + const genesisUnixTimestamp = Number(protocolInfo.parameters.genesisUnixTimestamp); + const slotDurationInSeconds = protocolInfo.parameters.slotDurationInSeconds; + + if (targetSlotIndex <= genesisSlot) { + return { + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }; + } + + const slotsElapsed = targetSlotIndex - genesisSlot - 1; + const elapsedTime = slotsElapsed * slotDurationInSeconds; + const targetSlotFromTimestamp = Number(genesisUnixTimestamp) + elapsedTime; + + return { + from: targetSlotFromTimestamp, + to: targetSlotFromTimestamp + slotDurationInSeconds, + }; + }; +} + +/** + * Convert a slot index to an epoch index. + * @param protocolInfo The protocol information. + * @param targetSlotIndex The target slot index. + * @returns The epoch index. + */ +export function slotIndexToEpochIndexConverter(protocolInfo: ProtocolInfo): (targetSlotIndex: number) => number { + return (targetSlotIndex: number) => { + const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; + return targetSlotIndex >> slotsPerEpochExponent; + }; +} + +/** + * Convert a UNIX timestamp to an epoch index. + * @param protocolInfo The protocol information. + * @param unixTimestampSeconds The UNIX timestamp in seconds. + * @returns The epoch index. + */ +export function unixTimestampToEpochIndexConverter(protocolInfo: ProtocolInfo): (unixTimestampSeconds: number) => number { + return (unixTimestampSeconds: number) => { + const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; + const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(protocolInfo); + + const targetSlotIndex = unixTimestampToSlotIndex(unixTimestampSeconds); + return targetSlotIndex >> slotsPerEpochExponent; + }; +} From dae92f78ea6f097a488b43d32822d2d6cf1a5779 Mon Sep 17 00:00:00 2001 From: Bran <52735957+brancoder@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:02:11 +0100 Subject: [PATCH 3/4] feat: add basic nova search (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add basic block search * fix: add search for outputs and addresses * Refactor searchExecutor.ts to handle API response errors * fix: lint errors * fix: lint errors * fix: lint errors * fix: add slotIndex to searchQueryBuilder * doc: fix typo --------- Co-authored-by: Begoña Álvarez de la Cruz --- api/src/models/api/nova/ISearchRequest.ts | 11 + api/src/models/api/nova/ISearchResponse.ts | 42 +++ api/src/routes.ts | 1 + api/src/routes/nova/search.ts | 30 ++ api/src/services/nova/novaApiService.ts | 19 ++ api/src/utils/nova/addressHelper.ts | 146 ++++++++++ api/src/utils/nova/searchExecutor.ts | 127 +++++++++ api/src/utils/nova/searchQueryBuilder.ts | 162 +++++++++++ client/src/app/routes.tsx | 2 + client/src/app/routes/nova/Search.tsx | 260 ++++++++++++++++++ client/src/models/api/nova/ISearchRequest.ts | 11 + client/src/models/api/nova/ISearchResponse.ts | 60 ++++ client/src/services/nova/novaApiClient.ts | 11 + 13 files changed, 882 insertions(+) create mode 100644 api/src/models/api/nova/ISearchRequest.ts create mode 100644 api/src/models/api/nova/ISearchResponse.ts create mode 100644 api/src/routes/nova/search.ts create mode 100644 api/src/utils/nova/addressHelper.ts create mode 100644 api/src/utils/nova/searchExecutor.ts create mode 100644 api/src/utils/nova/searchQueryBuilder.ts create mode 100644 client/src/app/routes/nova/Search.tsx create mode 100644 client/src/models/api/nova/ISearchRequest.ts create mode 100644 client/src/models/api/nova/ISearchResponse.ts diff --git a/api/src/models/api/nova/ISearchRequest.ts b/api/src/models/api/nova/ISearchRequest.ts new file mode 100644 index 000000000..b0371bef2 --- /dev/null +++ b/api/src/models/api/nova/ISearchRequest.ts @@ -0,0 +1,11 @@ +export interface ISearchRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The query to look for. + */ + query: string; +} diff --git a/api/src/models/api/nova/ISearchResponse.ts b/api/src/models/api/nova/ISearchResponse.ts new file mode 100644 index 000000000..c4b8925b5 --- /dev/null +++ b/api/src/models/api/nova/ISearchResponse.ts @@ -0,0 +1,42 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { Block, OutputResponse } from "@iota/sdk-nova"; +import { IAddressDetails } from "./IAddressDetails"; +import { IResponse } from "../IResponse"; + +export interface ISearchResponse extends IResponse { + /** + * Block if it was found. + */ + block?: Block; + + /** + * Address details. + */ + addressDetails?: IAddressDetails; + + /** + * Output if it was found (block will also be populated). + */ + output?: OutputResponse; + + /** + * Account id if it was found. + */ + accountId?: string; + + /** + * Anchor id if it was found. + */ + anchorId?: string; + + /** + * Foundry id if it was found. + */ + foundryId?: string; + + /** + * Nft id if it was found. + */ + nftId?: string; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index d8b1ade11..3e99da481 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -202,6 +202,7 @@ export const routes: IRoute[] = [ func: "get", }, // Nova + { path: "/nova/search/:network/:query", method: "get", folder: "nova", func: "search" }, { path: "/nova/output/:network/:outputId", method: "get", folder: "nova/output", func: "get" }, { path: "/nova/output/rewards/:network/:outputId", method: "get", folder: "nova/output/rewards", func: "get" }, { path: "/nova/account/:network/:accountId", method: "get", folder: "nova/account", func: "get" }, diff --git a/api/src/routes/nova/search.ts b/api/src/routes/nova/search.ts new file mode 100644 index 000000000..ba50dbd92 --- /dev/null +++ b/api/src/routes/nova/search.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../factories/serviceFactory"; +import { ISearchRequest } from "../../models/api/nova/ISearchRequest"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { IConfiguration } from "../../models/configuration/IConfiguration"; +import { NOVA } from "../../models/db/protocolVersion"; +import { NetworkService } from "../../services/networkService"; +import { NovaApiService } from "../../services/nova/novaApiService"; +import { ValidationHelper } from "../../utils/validationHelper"; + +/** + * Find the object from the network. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function search(_: IConfiguration, request: ISearchRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.query, "query"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.search(request.query); +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index b3388a8db..f46e24d54 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -10,19 +10,28 @@ import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; +import { SearchExecutor } from "../../utils/nova/searchExecutor"; +import { SearchQueryBuilder } from "../../utils/nova/searchQueryBuilder"; /** * Class to interact with the nova API. */ export class NovaApiService { + /** + * The network in context. + */ + private readonly network: INetwork; + /** * The client to use for requests. */ private readonly client: Client; constructor(network: INetwork) { + this.network = network; this.client = ServiceFactory.get(`client-${network.network}`); } @@ -154,4 +163,14 @@ export class NovaApiService { return manaRewardsResponse ? { outputId, manaRewards: manaRewardsResponse } : { outputId, message: "Rewards data not found" }; } + + /** + * Find item on the stardust network. + * @param query The query to use for finding items. + * @returns The item found. + */ + public async search(query: string): Promise { + const searchQuery = new SearchQueryBuilder(query, this.network.bechHrp).build(); + return new SearchExecutor(this.network, searchQuery).run(); + } } diff --git a/api/src/utils/nova/addressHelper.ts b/api/src/utils/nova/addressHelper.ts new file mode 100644 index 000000000..6a0c5e023 --- /dev/null +++ b/api/src/utils/nova/addressHelper.ts @@ -0,0 +1,146 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { + Address, + AddressType, + AccountAddress, + Ed25519Address, + NftAddress, + AnchorAddress, + Utils, + ImplicitAccountCreationAddress, + RestrictedAddress, +} from "@iota/sdk-nova"; +import { plainToInstance } from "class-transformer"; +import { IAddressDetails } from "../../models/api/nova/IAddressDetails"; +import { HexHelper } from "../hexHelper"; + +export class AddressHelper { + /** + * Build the address details. + * @param hrp The human readable part of the address. + * @param address The address to source the data from. + * @param typeHint The type of the address. + * @returns The parts of the address. + */ + public static buildAddress(hrp: string, address: string | Address, typeHint?: number): IAddressDetails { + return typeof address === "string" ? this.buildAddressFromString(hrp, address, typeHint) : this.buildAddressFromTypes(address, hrp); + } + + private static buildAddressFromString(hrp: string, addressString: string, typeHint?: number): IAddressDetails { + let bech32: string; + let hex: string; + let type: AddressType; + if (Utils.isAddressValid(addressString)) { + try { + const address: Address = Utils.parseBech32Address(addressString); + + if (address) { + bech32 = addressString; + type = address.type; + hex = Utils.bech32ToHex(addressString); + } + } catch (e) { + console.error(e); + } + } + + if (!bech32) { + // We assume this is hex + hex = addressString; + if (typeHint) { + bech32 = this.computeBech32FromHexAndType(hex, type, hrp); + } + } + + return { + bech32, + hex: hex ? HexHelper.addPrefix(hex) : hex, + type, + label: AddressHelper.typeLabel(type), + restricted: false, + }; + } + + private static buildAddressFromTypes( + address: Address, + hrp: string, + restricted: boolean = false, + capabilities?: number[], + ): IAddressDetails { + let hex: string = ""; + let bech32: string = ""; + + if (address.type === AddressType.Ed25519) { + hex = (address as Ed25519Address).pubKeyHash; + } else if (address.type === AddressType.Account) { + hex = (address as AccountAddress).accountId; + } else if (address.type === AddressType.Nft) { + hex = (address as NftAddress).nftId; + } else if (address.type === AddressType.Anchor) { + hex = (address as AnchorAddress).anchorId; + } else if (address.type === AddressType.ImplicitAccountCreation) { + const implicitAccountCreationAddress = plainToInstance(ImplicitAccountCreationAddress, address); + const innerAddress = implicitAccountCreationAddress.address(); + hex = innerAddress.pubKeyHash; + } else if (address.type === AddressType.Restricted) { + const restrictedAddress = plainToInstance(RestrictedAddress, address); + const innerAddress = restrictedAddress.address; + + return this.buildAddressFromTypes( + innerAddress, + hrp, + true, + Array.from(restrictedAddress.getAllowedCapabilities() as ArrayLike), + ); + } + + bech32 = this.computeBech32FromHexAndType(hex, address.type, hrp); + + return { + bech32, + hex, + type: address.type, + label: AddressHelper.typeLabel(address.type), + restricted, + capabilities, + }; + } + + private static computeBech32FromHexAndType(hex: string, addressType: AddressType, hrp: string) { + let bech32 = ""; + + if (addressType === AddressType.Ed25519) { + bech32 = Utils.hexToBech32(hex, hrp); + } else if (addressType === AddressType.Account) { + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.Nft) { + bech32 = Utils.nftIdToBech32(hex, hrp); + } else if (addressType === AddressType.Anchor) { + // Update to Utils.anchorIdToBech32 when it gets implemented + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.ImplicitAccountCreation) { + bech32 = Utils.hexToBech32(hex, hrp); + } + + return bech32; + } + + /** + * Convert the address type number to a label. + * @param addressType The address type to get the label for. + * @returns The label. + */ + private static typeLabel(addressType?: AddressType): string | undefined { + if (addressType === AddressType.Ed25519) { + return "Ed25519"; + } else if (addressType === AddressType.Account) { + return "Account"; + } else if (addressType === AddressType.Nft) { + return "NFT"; + } else if (addressType === AddressType.Anchor) { + return "Anchor"; + } + } +} diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts new file mode 100644 index 000000000..9c276abba --- /dev/null +++ b/api/src/utils/nova/searchExecutor.ts @@ -0,0 +1,127 @@ +import { SearchQuery } from "./searchQueryBuilder"; +import { ServiceFactory } from "../../factories/serviceFactory"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { INetwork } from "../../models/db/INetwork"; +import { NovaApiService } from "../../services/nova/novaApiService"; + +export class SearchExecutor { + /** + * The search query. + */ + private readonly query: SearchQuery; + + private readonly apiService: NovaApiService; + + constructor(network: INetwork, query: SearchQuery) { + this.query = query; + this.apiService = ServiceFactory.get(`api-service-${network.network}`); + } + + public async run(): Promise { + const searchQuery = this.query; + const promises: Promise[] = []; + let promisesResult: ISearchResponse | null = null; + + if (searchQuery.blockId) { + promises.push( + this.executeQuery( + this.apiService.block(searchQuery.blockId), + (response) => { + promisesResult = { + block: response.block, + error: response.error || response.message, + }; + }, + "Block fetch failed", + ), + ); + } + + if (searchQuery.outputId) { + promises.push( + this.executeQuery( + this.apiService.outputDetails(searchQuery.outputId), + (response) => { + promisesResult = { + output: response.output, + error: response.error || response.message, + }; + }, + "Output fetch failed", + ), + ); + } + + if (searchQuery.accountId) { + promises.push( + this.executeQuery( + this.apiService.accountDetails(searchQuery.accountId), + (response) => { + promisesResult = { + accountId: response.accountOutputDetails ? searchQuery.accountId : undefined, + error: response.error || response.message, + }; + }, + "Account id fetch failed", + ), + ); + } + + if (searchQuery.nftId) { + promises.push( + this.executeQuery( + this.apiService.nftDetails(searchQuery.nftId), + (response) => { + promisesResult = { + nftId: response.nftOutputDetails ? searchQuery.nftId : undefined, + error: response.error || response.message, + }; + }, + "Nft id fetch failed", + ), + ); + } + + if (searchQuery.anchorId) { + promises.push( + this.executeQuery( + this.apiService.anchorDetails(searchQuery.anchorId), + (response) => { + promisesResult = { + anchorId: response.anchorOutputDetails ? searchQuery.anchorId : undefined, + error: response.error || response.message, + }; + }, + "Anchor id fetch failed", + ), + ); + } + + await Promise.any(promises).catch((_) => {}); + + if (promisesResult !== null) { + return promisesResult; + } + + if (searchQuery.address?.bech32) { + return { + addressDetails: searchQuery.address, + }; + } + + return {}; + } + + private async executeQuery(query: Promise, successHandler: (result: T) => void, failureMessage: string): Promise { + try { + const result = await query; + if (result) { + successHandler(result); + } else { + throw new Error(failureMessage); + } + } catch { + throw new Error(`${failureMessage}`); + } + } +} diff --git a/api/src/utils/nova/searchQueryBuilder.ts b/api/src/utils/nova/searchQueryBuilder.ts new file mode 100644 index 000000000..d977a8d8b --- /dev/null +++ b/api/src/utils/nova/searchQueryBuilder.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +/* eslint-disable import/no-unresolved */ +import { AddressType, HexEncodedString } from "@iota/sdk-nova"; +import { AddressHelper } from "./addressHelper"; +import { IAddressDetails } from "../../models/api/nova/IAddressDetails"; +import { Converter } from "../convertUtils"; +import { HexHelper } from "../hexHelper"; + +export interface SearchQuery { + /** + * The query string in lower case. + */ + queryLower: string; + /** + * The slotIndex query. + */ + slotIndex?: number; + /** + * The MaybeAddress query. + */ + address?: IAddressDetails; + /** + * The blockId query. + */ + blockId?: string; + /** + * The transactionId query. + */ + transactionId?: string; + /** + * The outputId query. + */ + outputId?: string; + /** + * The accountId query. + */ + accountId?: string; + /** + * The nftId query. + */ + nftId?: string; + /** + * The foundryId query. + */ + foundryId?: string; + /** + * The anchorId query. + */ + anchorId?: string; + /** + * The delegationId query. + */ + delegationId?: string; + /** + * The tag of an output. + */ + tag?: HexEncodedString; +} + +/** + * Builds SearchQuery object from query stirng + */ +export class SearchQueryBuilder { + /** + * The query string. + */ + private readonly query: string; + + /** + * The query string in lower case. + */ + private readonly queryLower: string; + + /** + * Thed human readable part to use for bech32. + */ + private readonly networkBechHrp: string; + + /** + * Creates a new instance of SearchQueryBuilder. + * @param query The query string. + * @param networkBechHrp The network bechHrp. + */ + constructor(query: string, networkBechHrp: string) { + this.query = query; + this.queryLower = query.toLowerCase(); + this.networkBechHrp = networkBechHrp; + } + + /** + * Builds the SearchQuery. + * @returns the SearchQuery object. + */ + public build(): SearchQuery { + let address: IAddressDetails; + let blockId: string; + let transactionId: string; + let outputId: string; + let accountId: string; + let nftId: string; + let foundryId: string; + let anchorId: string; + let delegationId: string; + let tag: string; + + const queryDetails = AddressHelper.buildAddress(this.networkBechHrp, this.queryLower); + const slotIndex = /^\d+$/.test(this.query) ? Number.parseInt(this.query, 10) : undefined; + + // if the source query was valid bech32, we should directly look for an address + if (queryDetails.bech32) { + address = queryDetails; + } else { + // if the hex has 74 characters it might be a block id or a tx id + if (queryDetails?.hex && queryDetails.hex.length === 74) { + blockId = queryDetails.hex; + transactionId = queryDetails.hex; + } else if (queryDetails?.hex && queryDetails.hex.length === 66) { + // if the hex has 66 characters it might be a accoount id or a nft id + accountId = queryDetails.hex; + nftId = queryDetails.hex; + anchorId = queryDetails.hex; + delegationId = queryDetails.hex; + } else if ( + // if the hex without prefix is 76 characters and first byte is 08, + // it might be a FoundryId (tokenId) + queryDetails.hex && + Converter.isHex(queryDetails.hex, true) && + queryDetails.hex.length === 78 && + Number.parseInt(HexHelper.stripPrefix(queryDetails.hex).slice(0, 2), 16) === AddressType.Account + ) { + foundryId = queryDetails.hex; + } else if ( + // if the hex is 70 characters it might be an outputId + queryDetails?.hex && + queryDetails.hex.length === 78 + ) { + outputId = queryDetails.hex; + } + + // also perform a tag search + const maybeTag = Converter.isHex(this.query, true) ? HexHelper.addPrefix(this.query) : Converter.utf8ToHex(this.query, true); + if (maybeTag.length < 66) { + tag = maybeTag; + } + } + + return { + queryLower: this.queryLower, + slotIndex, + address, + blockId, + transactionId, + outputId, + accountId, + nftId, + foundryId, + anchorId, + delegationId, + tag, + }; + } +} diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 446a84365..4654cdbb7 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -35,6 +35,7 @@ import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; import NovaBlockPage from "./routes/nova/Block"; import NovaOutputPage from "./routes/nova/OutputPage"; +import NovaSearch from "./routes/nova/Search"; import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; @@ -174,6 +175,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , ]; return ( diff --git a/client/src/app/routes/nova/Search.tsx b/client/src/app/routes/nova/Search.tsx new file mode 100644 index 000000000..77702e554 --- /dev/null +++ b/client/src/app/routes/nova/Search.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from "react"; +import { Redirect, RouteComponentProps, useLocation, useParams } from "react-router-dom"; +import { SearchRouteProps } from "../SearchRouteProps"; +import { NetworkService } from "~/services/networkService"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { NOVA, ProtocolVersion } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { SearchState } from "../SearchState"; +import { scrollToTop } from "~/helpers/pageUtils"; +import { AddressType, Block } from "@iota/sdk-wasm-nova/web"; +import Spinner from "~/app/components/Spinner"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; + +const Search: React.FC> = (props) => { + const { protocolInfo, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const networkService = ServiceFactory.get("network"); + const protocolVersion: ProtocolVersion = + (props.match.params.network && networkService.get(props.match.params.network)?.protocolVersion) || NOVA; + + const _apiClient = ServiceFactory.get(`api-client-${NOVA}`); + + const [state, setState] = useState({ + protocolVersion, + statusBusy: true, + status: "", + completion: "", + redirect: "", + search: "", + invalidError: "", + }); + + const location = useLocation(); + const { network, query } = useParams(); + + useEffect(() => { + scrollToTop(); + updateState(); + }, [location.pathname]); + + const updateState = () => { + const queryTerm = (query ?? "").trim(); + + let status = ""; + let statusBusy = false; + let completion = ""; + const redirect = ""; + let invalidError = ""; + + if (queryTerm.length > 0) { + status = "Detecting query type..."; + statusBusy = true; + + setTimeout(async () => { + const response = await _apiClient.search({ + network, + query: queryTerm, + }); + if (!response || response?.error) { + setState((prevState) => ({ + ...prevState, + completion: response?.error ? "invalid" : "notFound", + invalidError: response?.error ?? "", + status: "", + statusBusy: false, + })); + } else if (Object.keys(response).length > 0) { + const routeSearch = new Map(); + let route = ""; + let routeParam = query; + let redirectState = {}; + + if (response.block) { + route = "block"; + if (protocolInfo) { + routeParam = Block.id(response.block, protocolInfo.parameters); + } + } else if (response.addressDetails) { + route = "addr"; + routeParam = response.addressDetails.bech32; + redirectState = { + addressDetails: response.addressDetails, + }; + } else if (response.accountId) { + route = "addr"; + const accountAddress = buildAddressFromIdAndType(response.accountId, AddressType.Account); + redirectState = { + addressDetails: accountAddress, + }; + routeParam = accountAddress.bech32; + } else if (response.nftId) { + route = "addr"; + const nftAddress = buildAddressFromIdAndType(response.nftId, AddressType.Nft); + redirectState = { + addressDetails: nftAddress, + }; + routeParam = nftAddress.bech32; + } else if (response.anchorId) { + route = "addr"; + const anchorAddress = buildAddressFromIdAndType(response.anchorId, AddressType.Anchor); + redirectState = { + addressDetails: anchorAddress, + }; + routeParam = anchorAddress.bech32; + } else if (response.output) { + route = "output"; + routeParam = response.output.metadata.outputId; + } else if (response.transactionId) { + route = "transaction"; + routeParam = response.transactionId; + } else if (response.foundryId) { + route = "foundry"; + routeParam = response.foundryId; + } + + const getEncodedSearch = () => { + if (routeSearch.size === 0) { + return ""; + } + + const searchParams = new URLSearchParams(); + for (const [key, value] of routeSearch.entries()) { + searchParams.append(key, value); + } + + return `?${searchParams.toString()}`; + }; + + setState((prevState) => ({ + ...prevState, + status: "", + statusBusy: false, + redirect: `/${network}/${route}/${routeParam}`, + search: getEncodedSearch(), + redirectState, + })); + } + }, 0); + } else { + invalidError = "the query is empty"; + completion = "invalid"; + } + + setState((prevState) => ({ + ...prevState, + statusBusy, + status, + completion, + redirect, + invalidError, + })); + }; + + const buildAddressFromIdAndType = (id: string, type: number) => { + return AddressHelper.buildAddress(bech32Hrp, id, type); + }; + + return state.redirect ? ( + + ) : ( +
+
+
+

Search

+ {!state.completion && state.status && ( +
+
+

Searching

+
+
+ {state.statusBusy && } +

{state.status}

+
+
+ )} + {state.completion === "notFound" && ( +
+
+

Not found

+
+
+

We could not find any blocks, addresses or outputs for the query.

+
+
+
    +
  • + Query + {params.query} +
  • +
+
+
+

The following formats are supported:

+
+
    +
  • + Blocks + 74 Hex characters +
  • +
  • + Block using Transaction Id + 74 Hex characters +
  • +
  • + Addresses + Bech32 Format +
  • +
  • + Nft/Account Addresses + 66 Hex characters or Bech32 Format +
  • +
  • + Outputs + 78 Hex characters +
  • +
  • + Account Id + 66 Hex characters +
  • +
  • + Foundry Id + 78 Hex characters +
  • +
  • + Token Id + 78 Hex characters +
  • +
  • + NFT Id + 66 Hex characters +
  • +
+
+

Please perform another search with a valid hash.

+
+
+ )} + {state.completion === "invalid" && ( +
+
+

Incorrect query format

+
+
+ {state.protocolVersion === NOVA &&

{state.invalidError}.

} +
+
+ )} +
+
+
+ ); +}; + +export default Search; diff --git a/client/src/models/api/nova/ISearchRequest.ts b/client/src/models/api/nova/ISearchRequest.ts new file mode 100644 index 000000000..b0371bef2 --- /dev/null +++ b/client/src/models/api/nova/ISearchRequest.ts @@ -0,0 +1,11 @@ +export interface ISearchRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The query to look for. + */ + query: string; +} diff --git a/client/src/models/api/nova/ISearchResponse.ts b/client/src/models/api/nova/ISearchResponse.ts new file mode 100644 index 000000000..e84fda3d7 --- /dev/null +++ b/client/src/models/api/nova/ISearchResponse.ts @@ -0,0 +1,60 @@ +import { Block, OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; +import { IAddressDetails } from "./IAddressDetails"; + +export interface ISearchResponse extends IResponse { + /** + * Block if it was found. + */ + block?: Block; + + /** + * Output if it was found (block will also be populated). + */ + output?: OutputResponse; + + /** + * Address details. + */ + addressDetails?: IAddressDetails; + + /** + * Transaction id if it was found. + */ + transactionId?: string; + + /** + * Account id if it was found. + */ + accountId?: string; + + /** + * Account details. + */ + accountDetails?: OutputResponse; + + /** + * Anchor id if it was found. + */ + anchorId?: string; + + /** + * Foundry id if it was found. + */ + foundryId?: string; + + /** + * Foundry details. + */ + foundryDetails?: OutputResponse; + + /** + * Nft id if it was found. + */ + nftId?: string; + + /** + * Nft details. + */ + nftDetails?: OutputResponse; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 6635f8319..8264a7c47 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -19,6 +19,8 @@ import { INftDetailsRequest } from "~/models/api/nova/INftDetailsRequest"; import { INftDetailsResponse } from "~/models/api/nova/INftDetailsResponse"; import { IAnchorDetailsRequest } from "~/models/api/nova/IAnchorDetailsRequest"; import { IAnchorDetailsResponse } from "~/models/api/nova/IAnchorDetailsResponse"; +import { ISearchRequest } from "~/models/api/nova/ISearchRequest"; +import { ISearchResponse } from "~/models/api/nova/ISearchResponse"; /** * Class to handle api communications on nova. @@ -120,4 +122,13 @@ export class NovaApiClient extends ApiClient { "get", ); } + + /** + * Find items from the tangle. + * @param request The request to send. + * @returns The response from the request. + */ + public async search(request: ISearchRequest): Promise { + return this.callApi(`nova/search/${request.network}/${request.query}`, "get"); + } } From 788ea46cc2b3680d3e4b1ca268f2852e85db8be6 Mon Sep 17 00:00:00 2001 From: JCNoguera <88061365+VmMad@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:04:03 +0100 Subject: [PATCH 4/4] feat: update blocks metadata on paused visualizer (#1111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: update blocks metadata on paused visualizer * fix: improve event listeners --------- Co-authored-by: Begoña Álvarez de la Cruz --- .../visualizer-threejs/VisualizerInstance.tsx | 69 +++++++++---------- .../visualizer-threejs/store/tangle.ts | 14 ++-- .../visualizer-threejs/useRenderTangle.tsx | 35 +++++----- 3 files changed, 57 insertions(+), 61 deletions(-) diff --git a/client/src/features/visualizer-threejs/VisualizerInstance.tsx b/client/src/features/visualizer-threejs/VisualizerInstance.tsx index b25b7cf27..77cc0636a 100644 --- a/client/src/features/visualizer-threejs/VisualizerInstance.tsx +++ b/client/src/features/visualizer-threejs/VisualizerInstance.tsx @@ -80,13 +80,11 @@ const VisualizerInstance: React.FC> = useEffect(() => { const handleVisibilityChange = async () => { if (document.hidden) { - await feedSubscriptionStop(); setIsPlaying(false); } }; const handleBlur = async () => { - await feedSubscriptionStop(); setIsPlaying(false); }; @@ -118,13 +116,9 @@ const VisualizerInstance: React.FC> = return; } - if (isPlaying) { - feedSubscriptionStart(); - } else { - await feedSubscriptionStop(); - } + feedSubscriptionStart(); })(); - }, [feedService, isPlaying, runListeners]); + }, [feedService, runListeners]); /** * Control width and height of canvas @@ -157,18 +151,46 @@ const VisualizerInstance: React.FC> = setRunListeners(false); setIsPlaying(false); resetConfigState(); - await feedSubscriptionStop(); + await feedSubscriptionFinalize(); setFeedService(ServiceFactory.get(`feed-${network}`)); })(); }, [network]); + useEffect(() => { + if (!runListeners) { + return; + } + setIsPlaying(true); + + return () => { + bpsCounter.stop(); + }; + }, [runListeners]); + + const feedSubscriptionStart = () => { + if (!feedService) { + return; + } + feedService.subscribeBlocks(onNewBlock, onBlockMetadataUpdate); + + bpsCounter.start(); + }; + + const feedSubscriptionFinalize = async () => { + if (!feedService) { + return; + } + await feedService.unsubscribeBlocks(); + bpsCounter.reset(); + }; + /** * Subscribe to updates * @param blockData The new block data */ const onNewBlock = (blockData: IFeedBlockData) => { const emitterObj = emitterRef.current; - if (emitterObj && blockData) { + if (emitterObj && blockData && isPlaying) { const emitterBox = new Box3().setFromObject(emitterObj); const emitterCenter = new THREE.Vector3(); @@ -220,33 +242,6 @@ const VisualizerInstance: React.FC> = } } - useEffect(() => { - if (!runListeners) { - return; - } - setIsPlaying(true); - - return () => { - bpsCounter.stop(); - }; - }, [runListeners]); - - const feedSubscriptionStart = () => { - if (!feedService) { - return; - } - feedService.subscribeBlocks(onNewBlock, onBlockMetadataUpdate); - bpsCounter.start(); - }; - - const feedSubscriptionStop = async () => { - if (!feedService) { - return; - } - await feedService.unsubscribeBlocks(); - bpsCounter.reset(); - }; - return ( []; addToColorQueue: (blockId: string, color: Color) => void; - removeFromColorQueue: (blockId: string) => void; + removeFromColorQueue: (blockIds: string[]) => void; // Map of blockId to index in Tangle 'InstancedMesh' blockIdToIndex: Map; @@ -155,11 +155,13 @@ export const useTangleStore = create()( colorQueue: [...state.colorQueue, { id, color }], })); }, - removeFromColorQueue: (blockId: string) => { - set((state) => ({ - ...state, - colorQueue: state.colorQueue.filter((block) => block.id !== blockId), - })); + removeFromColorQueue: (blockIds) => { + if (blockIds.length > 0) { + set((state) => ({ + ...state, + colorQueue: state.colorQueue.filter((block) => !blockIds.includes(block.id)), + })); + } }, updateBlockIdToIndex: (blockId: string, index: number) => { set((state) => { diff --git a/client/src/features/visualizer-threejs/useRenderTangle.tsx b/client/src/features/visualizer-threejs/useRenderTangle.tsx index 092429ede..4e6e93604 100644 --- a/client/src/features/visualizer-threejs/useRenderTangle.tsx +++ b/client/src/features/visualizer-threejs/useRenderTangle.tsx @@ -28,18 +28,6 @@ export const useRenderTangle = () => { const blockIdToPosition = useTangleStore((s) => s.blockIdToPosition); const blockIdToAnimationPosition = useTangleStore((s) => s.blockIdToAnimationPosition); - const updateBlockColor = (blockId: string, color: THREE.Color): void => { - const indexToUpdate = blockIdToIndex.get(blockId); - - if (indexToUpdate) { - tangleMeshRef.current.setColorAt(indexToUpdate, color); - if (tangleMeshRef.current.instanceColor) { - tangleMeshRef.current.instanceColor.needsUpdate = true; - } - removeFromColorQueue(blockId); - } - }; - function updateInstancedMeshPosition( instancedMesh: THREE.InstancedMesh, index: number, @@ -175,12 +163,23 @@ export const useRenderTangle = () => { }, [blockQueue, blockIdToAnimationPosition]); useEffect(() => { - if (colorQueue.length === 0) { - return; - } + if (colorQueue.length > 0) { + const removeIds: string[] = []; + for (const { id, color } of colorQueue) { + const indexToUpdate = blockIdToIndex.get(id); + + if (indexToUpdate) { + tangleMeshRef.current.setColorAt(indexToUpdate, color); + + if (tangleMeshRef.current.instanceColor) { + tangleMeshRef.current.instanceColor.needsUpdate = true; + } + + removeIds.push(id); + } + } - for (const { id, color } of colorQueue) { - updateBlockColor(id, color); + removeFromColorQueue(removeIds); } - }, [colorQueue]); + }, [colorQueue, blockIdToIndex]); };