diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 2508da903..7b718a706 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "8f0ff5e1e899a0d960ddfea09237739a88c3bcf1" + default: "fc9f0f56bb5cfc146993e53aa9656ded220734e1" environment: type: choice description: "Select the environment to deploy to" diff --git a/api/package-lock.json b/api/package-lock.json index f3fea6386..2994d3fd7 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "explorer-api", - "version": "3.3.4-rc.1", + "version": "3.3.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "explorer-api", - "version": "3.3.4-rc.1", + "version": "3.3.4", "license": "Apache-2.0", "dependencies": { "@google-cloud/logging-winston": "^5.3.0", diff --git a/api/package.json b/api/package.json index 836eaf8f2..7b12cdc13 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "explorer-api", "description": "API for Tangle Explorer", - "version": "3.3.4-rc.1", + "version": "3.3.4", "author": "Martyn Janes ", "repository": { "type": "git", diff --git a/api/src/models/api/nova/ICongestionRequest.ts b/api/src/models/api/nova/ICongestionRequest.ts new file mode 100644 index 000000000..00e845db6 --- /dev/null +++ b/api/src/models/api/nova/ICongestionRequest.ts @@ -0,0 +1,11 @@ +export interface ICongestionRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the congestion for. + */ + accountId: string; +} diff --git a/api/src/models/api/nova/ICongestionResponse.ts b/api/src/models/api/nova/ICongestionResponse.ts new file mode 100644 index 000000000..9e7fff964 --- /dev/null +++ b/api/src/models/api/nova/ICongestionResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { CongestionResponse } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ICongestionResponse extends IResponse { + /** + * The Account Congestion. + */ + congestion?: CongestionResponse; +} diff --git a/api/src/models/api/nova/ISearchResponse.ts b/api/src/models/api/nova/ISearchResponse.ts index c4b8925b5..f9762dd30 100644 --- a/api/src/models/api/nova/ISearchResponse.ts +++ b/api/src/models/api/nova/ISearchResponse.ts @@ -39,4 +39,9 @@ export interface ISearchResponse extends IResponse { * Nft id if it was found. */ nftId?: string; + + /** + * Transaction included block. + */ + transactionBlock?: Block; } diff --git a/api/src/models/api/nova/ISlotRequest.ts b/api/src/models/api/nova/ISlotRequest.ts new file mode 100644 index 000000000..557fb3337 --- /dev/null +++ b/api/src/models/api/nova/ISlotRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the details for. + */ + slotIndex: string; +} diff --git a/api/src/models/api/nova/ISlotResponse.ts b/api/src/models/api/nova/ISlotResponse.ts new file mode 100644 index 000000000..342e6c65f --- /dev/null +++ b/api/src/models/api/nova/ISlotResponse.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/no-unresolved +import { SlotCommitment } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ISlotResponse extends IResponse { + /** + * The deserialized slot. + */ + slot?: SlotCommitment; +} diff --git a/api/src/models/api/nova/ITransactionDetailsRequest.ts b/api/src/models/api/nova/ITransactionDetailsRequest.ts new file mode 100644 index 000000000..d5b298039 --- /dev/null +++ b/api/src/models/api/nova/ITransactionDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface ITransactionDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The transaction id to get the details for. + */ + transactionId: string; +} diff --git a/api/src/models/api/nova/ITransactionDetailsResponse.ts b/api/src/models/api/nova/ITransactionDetailsResponse.ts new file mode 100644 index 000000000..b9aef5eea --- /dev/null +++ b/api/src/models/api/nova/ITransactionDetailsResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +import { Block } from "@iota/sdk-nova"; +import { IResponse } from "./IResponse"; + +export interface ITransactionDetailsResponse extends IResponse { + /** + * Transaction included block. + */ + block?: Block; +} diff --git a/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts b/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts new file mode 100644 index 000000000..c2818376d --- /dev/null +++ b/api/src/models/api/nova/commitment/ILatestSlotCommitmentsResponse.ts @@ -0,0 +1,18 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { SlotCommitment } from "@iota/sdk-nova"; +import { IResponse } from "../../IResponse"; + +export enum SlotCommitmentStatus { + Committed = "committed", + Finalized = "finalized", +} + +export interface ISlotCommitmentWrapper { + status: SlotCommitmentStatus; + slotCommitment: SlotCommitment; +} + +export interface ILatestSlotCommitmentResponse extends IResponse { + slotCommitments: ISlotCommitmentWrapper[]; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 3a3296c84..13ac5518a 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -248,6 +248,25 @@ export const routes: IRoute[] = [ folder: "nova/transactionhistory", func: "get", }, + { + path: "/nova/transaction/:network/:transactionId", + method: "get", + folder: "nova/transaction", + func: "get", + }, + { + path: "/nova/account/congestion/:network/:accountId", + method: "get", + folder: "nova/account/congestion", + func: "get", + }, { path: "/nova/block/:network/:blockId", method: "get", folder: "nova/block", func: "get" }, { path: "/nova/block/metadata/:network/:blockId", method: "get", folder: "nova/block/metadata", func: "get" }, + { + path: "/nova/commitment/latest/:network", + method: "get", + folder: "nova/commitment/latest", + func: "get", + }, + { path: "/nova/slot/:network/:slotIndex", method: "get", folder: "nova/slot", func: "get" }, ]; diff --git a/api/src/routes/nova/account/congestion/get.ts b/api/src/routes/nova/account/congestion/get.ts new file mode 100644 index 000000000..4ab680766 --- /dev/null +++ b/api/src/routes/nova/account/congestion/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { ICongestionRequest } from "../../../../models/api/nova/ICongestionRequest"; +import { ICongestionResponse } from "../../../../models/api/nova/ICongestionResponse"; +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"; + +/** + * Get Congestion for Account address + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: ICongestionRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.accountId, "accountId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getAccountCongestion(request.accountId); +} diff --git a/api/src/routes/nova/commitment/latest/get.ts b/api/src/routes/nova/commitment/latest/get.ts new file mode 100644 index 000000000..784fdaff6 --- /dev/null +++ b/api/src/routes/nova/commitment/latest/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { ILatestSlotCommitmentResponse } from "../../../../models/api/nova/commitment/ILatestSlotCommitmentsResponse"; +import { IConfiguration } from "../../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../services/networkService"; +import { NovaFeed } from "../../../../services/nova/feed/novaFeed"; +import { ValidationHelper } from "../../../../utils/validationHelper"; + +/** + * Get the latest slot commitments. + * @param _ The configuration. + * @param request The request. + * @param request.network The network in context. + * @returns The response. + */ +export async function get(_: IConfiguration, request: { network: string }): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return { error: "Endpoint available only on Nova networks.", slotCommitments: [] }; + } + + const feedService = ServiceFactory.get(`feed-${request.network}`); + const slotCommitments = feedService.getLatestSlotCommitments; + + return { slotCommitments }; +} diff --git a/api/src/routes/nova/slot/get.ts b/api/src/routes/nova/slot/get.ts new file mode 100644 index 000000000..40339920f --- /dev/null +++ b/api/src/routes/nova/slot/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { ISlotRequest } from "../../../models/api/nova/ISlotRequest"; +import { ISlotResponse } from "../../../models/api/nova/ISlotResponse"; +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"; + +/** + * Fetch the block from the network. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(_: IConfiguration, request: ISlotRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.numberFromString(request.slotIndex, "slotIndex"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.getSlotCommitment(Number(request.slotIndex)); +} diff --git a/api/src/routes/nova/transaction/get.ts b/api/src/routes/nova/transaction/get.ts new file mode 100644 index 000000000..01a7cbbc4 --- /dev/null +++ b/api/src/routes/nova/transaction/get.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { ITransactionDetailsRequest } from "../../../models/api/nova/ITransactionDetailsRequest"; +import { ITransactionDetailsResponse } from "../../../models/api/nova/ITransactionDetailsResponse"; +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 config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: ITransactionDetailsRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.transactionId, "transactionId"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.transactionIncludedBlock(request.transactionId); +} diff --git a/api/src/services/nova/feed/novaFeed.ts b/api/src/services/nova/feed/novaFeed.ts index 1fc1a2447..e7b7a8a44 100644 --- a/api/src/services/nova/feed/novaFeed.ts +++ b/api/src/services/nova/feed/novaFeed.ts @@ -1,13 +1,17 @@ /* eslint-disable import/no-unresolved */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { Block, Client, IBlockMetadata, SlotCommitment } from "@iota/sdk-nova"; import { ClassConstructor, plainToInstance } from "class-transformer"; import { ServiceFactory } from "../../../factories/serviceFactory"; import logger from "../../../logger"; +import { ISlotCommitmentWrapper, SlotCommitmentStatus } from "../../../models/api/nova/commitment/ILatestSlotCommitmentsResponse"; import { IFeedUpdate } from "../../../models/api/nova/feed/IFeedUpdate"; import { INetwork } from "../../../models/db/INetwork"; import { NodeInfoService } from "../nodeInfoService"; +const LATEST_SLOT_COMMITMENT_LIMIT = 30; + /** * Wrapper class around Nova MqttClient. * Streaming blocks from mqtt (upstream) to explorer-client connections (downstream). @@ -25,6 +29,11 @@ export class NovaFeed { */ private _mqttClient: Client; + /** + * The latest slot commitments cache. + */ + private readonly latestSlotCommitmentCache: ISlotCommitmentWrapper[] = []; + /** * The network in context. */ @@ -54,6 +63,14 @@ export class NovaFeed { }); } + /** + * Get the latest slot commitment cache state. + * @returns The latest slot commitments. + */ + public get getLatestSlotCommitments() { + return this.latestSlotCommitmentCache; + } + /** * Subscribe to the blocks nova feed. * @param id The id of the subscriber. @@ -124,10 +141,26 @@ export class NovaFeed { // eslint-disable-next-line no-void void this.broadcastBlock(update); + + // eslint-disable-next-line no-void + void this.updateLatestSlotCommitmentCache(slotCommitment, true); } catch { logger.error("[NovaFeed]: Failed broadcasting finalized slot downstream."); } }); + + // eslint-disable-next-line no-void + void this._mqttClient.listenMqtt(["commitments/latest"], async (_, message) => { + try { + const deserializedMessage: { topic: string; payload: string } = JSON.parse(message); + const slotCommitment: SlotCommitment = JSON.parse(deserializedMessage.payload); + + // eslint-disable-next-line no-void + void this.updateLatestSlotCommitmentCache(slotCommitment, false); + } catch { + logger.error("[NovaFeed]: Failed broadcasting commited slot downstream."); + } + }); } private parseMqttPayloadMessage(cls: ClassConstructor, serializedMessage: string): T { @@ -159,4 +192,30 @@ export class NovaFeed { } } } + + /** + * Updates the slot commitment cache. + * @param newSlotCommitment The new slot commitment. + * @param isFinalized Did the SlotCommitment get emitted from the 'commitments/finalized' topic or not ('commitments/latest'). + */ + private async updateLatestSlotCommitmentCache(newSlotCommitment: SlotCommitment, isFinalized: boolean): Promise { + if (!this.latestSlotCommitmentCache.map((commitment) => commitment.slotCommitment.slot).includes(newSlotCommitment.slot)) { + this.latestSlotCommitmentCache.unshift({ + slotCommitment: newSlotCommitment, + status: isFinalized ? SlotCommitmentStatus.Finalized : SlotCommitmentStatus.Committed, + }); + + if (this.latestSlotCommitmentCache.length > LATEST_SLOT_COMMITMENT_LIMIT) { + this.latestSlotCommitmentCache.pop(); + } + } else if (isFinalized) { + const commitmentToUpdate = this.latestSlotCommitmentCache.find( + (commitment) => commitment.slotCommitment.slot === newSlotCommitment.slot, + ); + + if (commitmentToUpdate) { + commitmentToUpdate.status = SlotCommitmentStatus.Finalized; + } + } + } } diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index 09d70ff27..441c1b520 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -11,10 +11,13 @@ import { IAddressDetailsResponse } from "../../models/api/nova/IAddressDetailsRe import { IAnchorDetailsResponse } from "../../models/api/nova/IAnchorDetailsResponse"; import { IBlockDetailsResponse } from "../../models/api/nova/IBlockDetailsResponse"; import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; +import { ICongestionResponse } from "../../models/api/nova/ICongestionResponse"; 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 { ISlotResponse } from "../../models/api/nova/ISlotResponse"; +import { ITransactionDetailsResponse } from "../../models/api/nova/ITransactionDetailsResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; import { SearchExecutor } from "../../utils/nova/searchExecutor"; @@ -85,6 +88,30 @@ export class NovaApiService { } } + /** + * Get the transaction included block. + * @param transactionId The transaction id to get the details. + * @returns The item details. + */ + public async transactionIncludedBlock(transactionId: string): Promise { + transactionId = HexHelper.addPrefix(transactionId); + try { + const block = await this.client.getIncludedBlock(transactionId); + + if (!block) { + return { error: `Couldn't find block from transaction id ${transactionId}` }; + } + if (block && Object.keys(block).length > 0) { + return { + block, + }; + } + } catch (e) { + logger.error(`Failed fetching block with transaction id ${transactionId}. Cause: ${e}`); + return { error: "Block fetch failed." }; + } + } + /** * Get the output details. * @param outputId The output id to get the details. @@ -157,6 +184,25 @@ export class NovaApiService { } } + /** + * Get the delegation output details. + * @param delegationId The delegationId to get the output details for. + * @returns The delegation output details. + */ + public async delegationDetails(delegationId: string): Promise { + try { + const delegationOutputId = await this.client.delegationOutputId(delegationId); + + if (delegationOutputId) { + const outputResponse = await this.outputDetails(delegationOutputId); + + return outputResponse.error ? { error: outputResponse.error } : { output: outputResponse.output }; + } + } catch { + return { message: "Delegation output not found" }; + } + } + /** * Get controlled Foundry output id by controller Account address * @param accountAddress The bech32 account address to get the controlled Foundries for. @@ -250,6 +296,25 @@ export class NovaApiService { }; } + /** + * Get Congestion for Account + * @param accountId The account address to get the congestion for. + * @returns The Congestion. + */ + public async getAccountCongestion(accountId: string): Promise { + try { + const response = await this.client.getAccountCongestion(accountId); + + if (response) { + return { + congestion: response, + }; + } + } catch { + return { message: "Account congestion not found" }; + } + } + /** * Get the output mana rewards. * @param outputId The outputId to get the rewards for. @@ -261,6 +326,21 @@ export class NovaApiService { return manaRewardsResponse ? { outputId, manaRewards: manaRewardsResponse } : { outputId, message: "Rewards data not found" }; } + /** + * Get the slot commitment. + * @param slotIndex The slot index to get the commitment for. + * @returns The slot commitment. + */ + public async getSlotCommitment(slotIndex: number): Promise { + try { + const slot = await this.client.getCommitmentByIndex(slotIndex); + + return { slot }; + } catch (e) { + logger.error(`Failed fetching slot with slot index ${slotIndex}. Cause: ${e}`); + } + } + /** * Find item on the stardust network. * @param query The query to use for finding items. diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts index 9c276abba..e2dad915e 100644 --- a/api/src/utils/nova/searchExecutor.ts +++ b/api/src/utils/nova/searchExecutor.ts @@ -97,6 +97,36 @@ export class SearchExecutor { ); } + if (searchQuery.delegationId) { + promises.push( + this.executeQuery( + this.apiService.delegationDetails(searchQuery.delegationId), + (response) => { + promisesResult = { + output: response.output, + error: response.error || response.message, + }; + }, + "Delegation id fetch failed", + ), + ); + } + + if (searchQuery.transactionId) { + promises.push( + this.executeQuery( + this.apiService.transactionIncludedBlock(searchQuery.transactionId), + (response) => { + promisesResult = { + transactionBlock: response.block, + error: response.error || response.message, + }; + }, + "Transaction included block fetch failed", + ), + ); + } + await Promise.any(promises).catch((_) => {}); if (promisesResult !== null) { diff --git a/client/package-lock.json b/client/package-lock.json index 5792475e5..548214678 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "explorer-client", - "version": "3.3.4-rc.1", + "version": "3.3.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "explorer-client", - "version": "3.3.4-rc.1", + "version": "3.3.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/client/package.json b/client/package.json index 0cf510b3b..7e1f82705 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "explorer-client", "description": "Tangle Explorer UI", - "version": "3.3.4-rc.1", + "version": "3.3.4", "author": "Martyn Janes ", "type": "module", "repository": { diff --git a/client/src/app/components/nova/KeyValueEntries.tsx b/client/src/app/components/nova/KeyValueEntries.tsx new file mode 100644 index 000000000..156402ea2 --- /dev/null +++ b/client/src/app/components/nova/KeyValueEntries.tsx @@ -0,0 +1,49 @@ +import classNames from "classnames"; +import React, { useState } from "react"; +import { IKeyValue, IKeyValueEntries } from "~/app/lib/interfaces"; +import DropdownIcon from "~assets/dropdown-arrow.svg?react"; + +function KeyValuePair({ label, value, orientation }: IKeyValue): React.JSX.Element { + return ( + <> + {value !== undefined && value !== null && ( + <> +
{label}
+
+ {value} +
+ + )} + + ); +} + +export default function KeyValueEntries({ isPreExpanded, label, value, entries }: IKeyValueEntries): React.JSX.Element { + const [isExpanded, setIsExpanded] = useState(isPreExpanded ?? false); + + return ( +
+
setIsExpanded(!isExpanded)}> +
+ +
+ +
+ + {entries && entries.length > 0 && isExpanded && ( +
+ {entries.map((entry, idx) => ( + + ))} +
+ )} +
+ ); +} diff --git a/client/src/app/components/nova/OutputView.scss b/client/src/app/components/nova/OutputView.scss index 244e76f56..0d418af48 100644 --- a/client/src/app/components/nova/OutputView.scss +++ b/client/src/app/components/nova/OutputView.scss @@ -1,5 +1,7 @@ @import "../../../scss/media-queries"; @import "../../../scss/variables"; +@import "../../../scss/mixins"; +@import "../../../scss/fonts"; .card--content__output { padding: 0 30px; diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index 5fe292f08..544a5daaf 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -4,7 +4,6 @@ import classNames from "classnames"; import { Output, OutputType, - BasicOutput, CommonOutput, AccountOutput, AnchorOutput, @@ -23,6 +22,8 @@ import FeatureView from "./FeaturesView"; import TruncatedId from "../stardust/TruncatedId"; import { HexHelper } from "~/helpers/stardust/hexHelper"; import bigInt from "big-integer"; +import { OutputManaDetails, getManaKeyValueEntries } from "~/helpers/nova/manaUtils"; +import KeyValueEntries from "./KeyValueEntries"; import "./OutputView.scss"; interface OutputViewProps { @@ -31,9 +32,10 @@ interface OutputViewProps { showCopyAmount: boolean; isPreExpanded?: boolean; isLinksDisabled?: boolean; + manaDetails: OutputManaDetails | null; } -const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled }) => { +const OutputView: React.FC = ({ outputId, output, showCopyAmount, isPreExpanded, isLinksDisabled, manaDetails }) => { const [isExpanded, setIsExpanded] = useState(isPreExpanded ?? false); const [isFormattedBalance, setIsFormattedBalance] = useState(true); const { bech32Hrp, name: network } = useNetworkInfoNova((s) => s.networkInfo); @@ -41,6 +43,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun const aliasOrNftBech32 = buildAddressForAliasOrNft(outputId, output, bech32Hrp); const outputIdTransactionPart = `${outputId.slice(0, 8)}....${outputId.slice(-8, -4)}`; const outputIdIndexPart = outputId.slice(-4); + const manaEntries = getManaKeyValueEntries(manaDetails); const header = (
setIsExpanded(!isExpanded)} className="card--value card-header--wrapper"> @@ -158,12 +161,8 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun {(output.type === OutputType.Basic || output.type === OutputType.Account || output.type === OutputType.Anchor || - output.type === OutputType.Nft) && ( - -
Stored mana:
-
{(output as BasicOutput).mana?.toString()}
-
- )} + output.type === OutputType.Nft) && + manaDetails?.totalMana && } {output.type === OutputType.Delegation && (
Delegated amount:
diff --git a/client/src/app/components/nova/PageDataRow.tsx b/client/src/app/components/nova/PageDataRow.tsx new file mode 100644 index 000000000..57658ed01 --- /dev/null +++ b/client/src/app/components/nova/PageDataRow.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classNames from "classnames"; +import TruncatedId from "../stardust/TruncatedId"; + +export interface IPageDataRow { + label: string; + value?: string | number; + highlight?: boolean; + truncatedId?: { + id: string; + link?: string; + showCopyButton?: boolean; + }; +} +const PageDataRow = ({ label, value, truncatedId, highlight }: IPageDataRow): React.JSX.Element => { + return ( +
+
{label}
+
+ {truncatedId ? ( + + ) : ( + value + )} +
+
+ ); +}; + +export default PageDataRow; diff --git a/client/src/app/components/nova/StatusPill.scss b/client/src/app/components/nova/StatusPill.scss new file mode 100644 index 000000000..6467cdc5b --- /dev/null +++ b/client/src/app/components/nova/StatusPill.scss @@ -0,0 +1,41 @@ +@import "./../../../scss/fonts"; +@import "./../../../scss/mixins"; +@import "./../../../scss/media-queries"; +@import "./../../../scss/variables"; + +.status-pill { + @include font-size(12px); + + display: flex; + align-items: center; + margin-right: 8px; + padding: 6px 12px; + border: 0; + border-radius: 6px; + outline: none; + color: $gray-midnight; + font-family: $inter; + font-weight: 500; + letter-spacing: 0.5px; + white-space: nowrap; + + @include phone-down { + height: 32px; + } + + &.status__ { + &success { + background-color: var(--message-confirmed-bg); + color: $mint-green-7; + } + + &error { + background-color: var(--message-conflicting-bg); + } + + &pending { + background-color: var(--light-bg); + color: #8493ad; + } + } +} diff --git a/client/src/app/components/nova/StatusPill.tsx b/client/src/app/components/nova/StatusPill.tsx new file mode 100644 index 000000000..289ee81f2 --- /dev/null +++ b/client/src/app/components/nova/StatusPill.tsx @@ -0,0 +1,42 @@ +import classNames from "classnames"; +import React from "react"; +import { PillStatus } from "~/app/lib/ui/enums"; +import Tooltip from "../Tooltip"; +import "./StatusPill.scss"; + +interface IStatusPill { + /** + * Label for the status. + */ + label: string; + /** + * The status of the pill. + */ + status: PillStatus; + /** + * Tooltip explaining further for the label. + */ + tooltip?: string; +} + +const StatusPill: React.FC = ({ label, status, tooltip }): React.JSX.Element => ( + <> +
+ {tooltip ? ( + + {status} + + ) : ( + {label} + )} +
+ +); + +export default StatusPill; diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index c54a413ca..3c7577e03 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -1,6 +1,8 @@ import React, { useState } from "react"; import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; import foundriesMessage from "~assets/modals/stardust/alias/foundries.json"; +import stateMessage from "~assets/modals/stardust/alias/state.json"; +import bicMessage from "~assets/modals/nova/account/bic.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; @@ -15,6 +17,8 @@ import { AddressType } from "@iota/sdk-wasm-nova/web"; import AccountFoundriesSection from "./account/AccountFoundriesSection"; import TransactionHistory from "../../history/TransactionHistoryView"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import AccountBlockIssuanceSection from "./account/AccountBlockIssuanceSection"; +import AnchorStateSection from "./anchor/AnchorStateSection"; enum DEFAULT_TABS { Transactions = "Transactions", @@ -23,9 +27,14 @@ enum DEFAULT_TABS { } enum ACCOUNT_TABS { + BlockIssuance = "Block Issuance", Foundries = "Foundries", } +enum ANCHOR_TABS { + State = "State", +} + const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number, isAddressHistoryLoading: boolean) => ({ [DEFAULT_TABS.Transactions]: { disabled: false, @@ -47,13 +56,33 @@ const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: num }, }); -const buildAccountAddressTabsOptions = (foundriesCount: number, isAccountFoundriesLoading: boolean) => ({ +const buildAccountAddressTabsOptions = ( + isBlockIssuer: boolean, + isCongestionLoading: boolean, + foundriesCount: number, + isAccountFoundriesLoading: boolean, +) => ({ [ACCOUNT_TABS.Foundries]: { disabled: foundriesCount === 0, hidden: foundriesCount === 0, isLoading: isAccountFoundriesLoading, infoContent: foundriesMessage, }, + [ACCOUNT_TABS.BlockIssuance]: { + disabled: !isBlockIssuer, + hidden: !isBlockIssuer, + isLoading: isCongestionLoading, + infoContent: bicMessage, + }, +}); + +const buildAnchorAddressTabsOptions = (isAnchorStateTabDisabled: boolean, isAnchorDetailsLoading: boolean) => ({ + [ANCHOR_TABS.State]: { + disabled: isAnchorStateTabDisabled, + hidden: isAnchorStateTabDisabled, + isLoading: isAnchorDetailsLoading, + infoContent: stateMessage, + }, }); interface IAddressPageTabbedSectionsProps { @@ -105,6 +134,11 @@ export const AddressPageTabbedSections: React.FC, , + ] + : null; + let tabEnums = DEFAULT_TABS; const defaultTabsOptions = buildDefaultTabsOptions(tokensCount, outputCount, isAddressHistoryLoading); let tabOptions = defaultTabsOptions; @@ -119,17 +163,30 @@ export const AddressPageTabbedSections: React.FC = ({ blockIssuerFeature, congestion }) => { + return ( +
+
+
+
Current Slot
+
{congestion?.slot}
+
+
+
Block Issuance Credit
+
{congestion?.blockIssuanceCredits.toString()}
+
+
+
Referenced Mana Cost
+
{congestion?.referenceManaCost.toString()}
+
+
+
Expiry Slot
+
{blockIssuerFeature?.expirySlot}
+
+
+ {blockIssuerFeature?.blockIssuerKeys.map((key) => ( + + Public Key: +
+ +
+
+ ))} +
+
+
+ ); +}; + +export default AccountBlockIssuanceSection; diff --git a/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx b/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx new file mode 100644 index 000000000..ffc910a9f --- /dev/null +++ b/client/src/app/components/nova/address/section/anchor/AnchorStateSection.tsx @@ -0,0 +1,37 @@ +import { AnchorOutput, FeatureType, StateMetadataFeature } from "@iota/sdk-wasm-nova/web"; +import React from "react"; +import DataToggle from "~/app/components/DataToggle"; + +interface AnchorStateSectionProps { + /** + * The Anchor Output + */ + readonly output: AnchorOutput | null; +} + +const AnchorStateSection: React.FC = ({ output }) => { + const stateMetadata = output?.features?.find((feature) => feature.type === FeatureType.StateMetadata) as StateMetadataFeature; + + return ( +
+
+
+
State Index
+
+ {output?.stateIndex} +
+
+ {Object.entries(stateMetadata.entries).map(([key, value], index) => ( +
+
{key}
+
+ +
+
+ ))} +
+
+ ); +}; + +export default AnchorStateSection; diff --git a/client/src/app/components/nova/block/BlockTangleState.scss b/client/src/app/components/nova/block/BlockTangleState.scss index c69f8c0ab..66652ad31 100644 --- a/client/src/app/components/nova/block/BlockTangleState.scss +++ b/client/src/app/components/nova/block/BlockTangleState.scss @@ -40,41 +40,4 @@ } } } - - .block-tangle-state { - @include font-size(12px); - - display: flex; - align-items: center; - height: 24px; - margin-right: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - outline: none; - background-color: $gray-light; - color: $gray-midnight; - font-family: $inter; - font-weight: 500; - letter-spacing: 0.5px; - white-space: nowrap; - - @include phone-down { - height: 32px; - } - - &.block-tangle-state__confirmed { - background-color: var(--message-confirmed-bg); - color: $mint-green-7; - } - - &.block-tangle-state__conflicting { - background-color: var(--message-conflicting-bg); - } - - &.block-tangle-state__pending { - background-color: var(--light-bg); - color: #8493ad; - } - } } diff --git a/client/src/app/components/nova/block/BlockTangleState.tsx b/client/src/app/components/nova/block/BlockTangleState.tsx index e8a4619e6..1d8630c6c 100644 --- a/client/src/app/components/nova/block/BlockTangleState.tsx +++ b/client/src/app/components/nova/block/BlockTangleState.tsx @@ -1,9 +1,9 @@ -import classNames from "classnames"; import React from "react"; -import Tooltip from "../../Tooltip"; -import { BlockState, u64 } from "@iota/sdk-wasm-nova/web"; -import { BlockFailureReason, BLOCK_FAILURE_REASON_STRINGS } from "@iota/sdk-wasm-nova/web/lib/types/models/block-failure-reason"; import moment from "moment"; +import { BlockState, u64 } from "@iota/sdk-wasm-nova/web"; +import { BLOCK_FAILURE_REASON_STRINGS, BlockFailureReason } from "@iota/sdk-wasm-nova/web/lib/types/models/block-failure-reason"; +import StatusPill from "~/app/components/nova/StatusPill"; +import { PillStatus } from "~/app/lib/ui/enums"; import "./BlockTangleState.scss"; export interface BlockTangleStateProps { @@ -23,38 +23,29 @@ export interface BlockTangleStateProps { failureReason?: BlockFailureReason; } +const BLOCK_STATE_TO_PILL_STATUS: Record = { + pending: PillStatus.Pending, + accepted: PillStatus.Success, + confirmed: PillStatus.Success, + finalized: PillStatus.Success, + failed: PillStatus.Error, + rejected: PillStatus.Error, +}; + const BlockTangleState: React.FC = ({ status, issuingTime, failureReason }) => { const blockIssueMoment = moment(Number(issuingTime) / 1000000); const timeReference = blockIssueMoment.fromNow(); const longTimestamp = blockIssueMoment.format("LLLL"); + const pillStatus: PillStatus = BLOCK_STATE_TO_PILL_STATUS[status]; + const failureReasonString: string | undefined = failureReason ? BLOCK_FAILURE_REASON_STRINGS[failureReason] : undefined; + return ( <>
{status && ( -
- {failureReason ? ( - - - {status} - - - ) : ( - {status} - )} -
+
{timeReference} diff --git a/client/src/app/components/nova/block/section/TransactionMetadataSection.scss b/client/src/app/components/nova/block/section/TransactionMetadataSection.scss deleted file mode 100644 index c87daebc3..000000000 --- a/client/src/app/components/nova/block/section/TransactionMetadataSection.scss +++ /dev/null @@ -1,42 +0,0 @@ -@import "../../../../../scss/fonts"; -@import "../../../../../scss/mixins"; -@import "../../../../../scss/media-queries"; -@import "../../../../../scss/variables"; -@import "../../../../../scss/themes"; - -.transaction-tangle-state { - @include font-size(12px); - - display: flex; - align-items: center; - height: 24px; - margin-right: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - outline: none; - background-color: $gray-light; - color: $gray-midnight; - font-family: $inter; - font-weight: 500; - letter-spacing: 0.5px; - white-space: nowrap; - - @include phone-down { - height: 32px; - } - - &.transaction-tangle-state__confirmed { - background-color: var(--message-confirmed-bg); - color: $mint-green-7; - } - - &.transaction-tangle-state__conflicting { - background-color: var(--message-conflicting-bg); - } - - &.transaction-tangle-state__pending { - background-color: var(--light-bg); - color: #8493ad; - } -} diff --git a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx index b1d8e2e19..79ea81e1c 100644 --- a/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx +++ b/client/src/app/components/nova/block/section/TransactionMetadataSection.tsx @@ -1,11 +1,11 @@ -import classNames from "classnames"; -import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata } from "@iota/sdk-wasm-nova/web"; +import { TRANSACTION_FAILURE_REASON_STRINGS, Transaction, TransactionMetadata, TransactionState, Utils } from "@iota/sdk-wasm-nova/web"; import React from "react"; -import "./TransactionMetadataSection.scss"; import Spinner from "../../../Spinner"; import TruncatedId from "~/app/components/stardust/TruncatedId"; import ContextInputView from "../../ContextInputView"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { PillStatus } from "~/app/lib/ui/enums"; +import StatusPill from "~/app/components/nova/StatusPill"; interface TransactionMetadataSectionProps { readonly transaction?: Transaction; @@ -13,8 +13,17 @@ interface TransactionMetadataSectionProps { readonly metadataError?: string; } +const TRANSACTION_STATE_TO_PILL_STATUS: Record = { + pending: PillStatus.Pending, + accepted: PillStatus.Success, + confirmed: PillStatus.Success, + finalized: PillStatus.Success, + failed: PillStatus.Error, +}; + const TransactionMetadataSection: React.FC = ({ transaction, transactionMetadata, metadataError }) => { - const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const pillStatus: PillStatus | undefined = TRANSACTION_STATE_TO_PILL_STATUS[transactionMetadata?.transactionState ?? "pending"]; return (
@@ -28,23 +37,8 @@ const TransactionMetadataSection: React.FC = ({ <>
Transaction Status
-
-
- {transactionMetadata.transactionState} -
+
+
{transactionMetadata.transactionFailureReason && ( @@ -73,7 +67,7 @@ const TransactionMetadataSection: React.FC = ({
diff --git a/client/src/app/components/nova/landing/LandingEpochSection.scss b/client/src/app/components/nova/landing/LandingEpochSection.scss index 50d57c36c..8f6bd5ebc 100644 --- a/client/src/app/components/nova/landing/LandingEpochSection.scss +++ b/client/src/app/components/nova/landing/LandingEpochSection.scss @@ -7,18 +7,18 @@ background-color: $gray-1; border-radius: 8px; + .epoch-section__header { + width: fit-content; + margin: 0 auto; + padding: 20px; + } + .epoch-progress__wrapper { display: flow-root; background-color: $gray-3; - margin: 20px; + margin: 0 20px 20px; border-radius: 8px; - .epoch-progress__header { - width: fit-content; - margin: 0 auto; - padding: 20px; - } - .epoch-progress__stats-wrapper { display: flex; padding: 12px; @@ -33,37 +33,6 @@ text-align: center; } } - - .progress-bar__wrapper { - $bar-height: 32px; - - .progress-bar { - position: relative; - background-color: $gray-5; - margin: 20px 12px; - height: $bar-height; - border-radius: 4px; - text-align: center; - overflow: hidden; - - .progress-bar__label { - position: absolute; - left: 0; - right: 0; - line-height: $bar-height; - margin: 0 auto; - font-weight: 600; - } - - .progress-bar__fill { - position: absolute; - width: 100%; - height: 100%; - background-color: #36c636; - transform: translateX(-100%); - } - } - } } .epoch-section__controls { diff --git a/client/src/app/components/nova/landing/LandingEpochSection.tsx b/client/src/app/components/nova/landing/LandingEpochSection.tsx index 249d2f422..7cc45e0e9 100644 --- a/client/src/app/components/nova/landing/LandingEpochSection.tsx +++ b/client/src/app/components/nova/landing/LandingEpochSection.tsx @@ -1,6 +1,7 @@ import moment from "moment"; import React from "react"; import { useCurrentEpochProgress } from "~/helpers/nova/hooks/useCurrentEpochProgress"; +import ProgressBar from "./ProgressBar"; import "./LandingEpochSection.scss"; const LandingEpochSection: React.FC = () => { @@ -29,8 +30,8 @@ const LandingEpochSection: React.FC = () => { return (
+

Epoch {epochIndex} Progress

-

Epoch {epochIndex} Progress

Registration end: {registrationTimeRemaining}
Time remaining: {epochTimeRemaining}
@@ -40,7 +41,7 @@ const LandingEpochSection: React.FC = () => { {epochTo}
- +
previous
@@ -51,13 +52,4 @@ const LandingEpochSection: React.FC = () => { ); }; -const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => ( -
-
-
-
{progress}%
-
-
-); - export default LandingEpochSection; diff --git a/client/src/app/components/nova/landing/LandingSlotSection.scss b/client/src/app/components/nova/landing/LandingSlotSection.scss new file mode 100644 index 000000000..8e2b963c4 --- /dev/null +++ b/client/src/app/components/nova/landing/LandingSlotSection.scss @@ -0,0 +1,55 @@ +@import "../../../../scss/variables"; +@import "../../../../scss/fonts"; + +.slots-section { + font-family: $metropolis; + margin-top: 40px; + background-color: $gray-1; + border-radius: 8px; + + .slots-section__header { + width: fit-content; + margin: 0 auto; + padding: 20px; + } + + .slots-feed__wrapper { + margin: 0 20px 20px; + + .slots-feed__item { + display: grid; + grid-template-columns: 1fr 3fr 1fr 1fr; + margin: 0px 12px; + align-items: center; + line-height: 32px; + justify-content: center; + background-color: $gray-5; + border-radius: 4px; + + &.basic { + grid-template-columns: none; + } + + &.transparent { + background-color: transparent; + } + + &:not(:last-child) { + margin-bottom: 20px; + } + + .slot__index, + .slot__commitment-id, + .slot__rmc, + .slot__status { + display: flex; + margin: 0 auto; + justify-content: center; + } + + .slot__commitment-id { + width: 220px; + } + } + } +} diff --git a/client/src/app/components/nova/landing/LandingSlotSection.tsx b/client/src/app/components/nova/landing/LandingSlotSection.tsx new file mode 100644 index 000000000..6da6b6847 --- /dev/null +++ b/client/src/app/components/nova/landing/LandingSlotSection.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import useSlotsFeed from "~/helpers/nova/hooks/useSlotsFeed"; +import ProgressBar from "./ProgressBar"; +import { Utils } from "@iota/sdk-wasm-nova/web"; +import Spinner from "../../Spinner"; +import TruncatedId from "../../stardust/TruncatedId"; +import "./LandingSlotSection.scss"; + +const LandingSlotSection: React.FC = () => { + const { currentSlotIndex, currentSlotProgressPercent, latestSlotIndexes, latestSlotCommitments } = useSlotsFeed(); + + if (currentSlotIndex === null || currentSlotProgressPercent === null) { + return null; + } + + return ( +
+

Latest Slots

+
+ +
+
{currentSlotIndex}
+
+
+ {latestSlotIndexes?.map((slot) => { + const commitmentWrapper = latestSlotCommitments?.find((commitment) => commitment.slotCommitment.slot === slot) ?? null; + const commitmentId = !commitmentWrapper ? ( + + ) : ( + + ); + const referenceManaCost = !commitmentWrapper ? ( + + ) : ( + commitmentWrapper.slotCommitment.referenceManaCost.toString() + ); + const slotStatus = !commitmentWrapper ? "pending" : commitmentWrapper.status; + + return ( +
+
{slot}
+
{commitmentId}
+
{referenceManaCost}
+
{slotStatus}
+
+ ); + })} +
+
+ ); +}; + +export default LandingSlotSection; diff --git a/client/src/app/components/nova/landing/ProgressBar.scss b/client/src/app/components/nova/landing/ProgressBar.scss new file mode 100644 index 000000000..97200025e --- /dev/null +++ b/client/src/app/components/nova/landing/ProgressBar.scss @@ -0,0 +1,40 @@ +@import "../../../../scss/variables"; + +.progress-bar__wrapper { + $bar-height: 32px; + + .progress-bar { + position: relative; + background-color: $gray-5; + margin: 20px 12px; + height: $bar-height; + border-radius: 4px; + text-align: center; + overflow: hidden; + + .progress-bar__label { + position: absolute; + left: 0; + right: 0; + line-height: $bar-height; + margin: 0 auto; + font-weight: 600; + } + + .progress-bar__children { + position: absolute; + left: 0; + right: 0; + line-height: $bar-height; + margin: 0 auto; + } + + .progress-bar__fill { + position: absolute; + width: 100%; + height: 100%; + background-color: #36c636; + transform: translateX(-100%); + } + } +} diff --git a/client/src/app/components/nova/landing/ProgressBar.tsx b/client/src/app/components/nova/landing/ProgressBar.tsx new file mode 100644 index 000000000..b67f95955 --- /dev/null +++ b/client/src/app/components/nova/landing/ProgressBar.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import "./ProgressBar.scss"; + +interface ProgressBarProps { + progress: number; + showLabel: boolean; + children?: React.ReactNode | React.ReactElement; +} + +const ProgressBar: React.FC = ({ progress, showLabel, children }) => ( +
+
+
+ {showLabel &&
{progress}%
} + {children &&
{children}
} +
+
+); + +export default ProgressBar; diff --git a/client/src/app/lib/interfaces/index.ts b/client/src/app/lib/interfaces/index.ts index 204986b69..9d55bc6d7 100644 --- a/client/src/app/lib/interfaces/index.ts +++ b/client/src/app/lib/interfaces/index.ts @@ -1 +1,2 @@ export * from "./routes.interfaces"; +export * from "./key-value.interfaces"; diff --git a/client/src/app/lib/interfaces/key-value.interfaces.ts b/client/src/app/lib/interfaces/key-value.interfaces.ts new file mode 100644 index 000000000..38f212608 --- /dev/null +++ b/client/src/app/lib/interfaces/key-value.interfaces.ts @@ -0,0 +1,10 @@ +export interface IKeyValue { + orientation?: "row" | "column"; + label: string; + value: string | number | null | undefined; +} + +export interface IKeyValueEntries extends IKeyValue { + isPreExpanded?: boolean; + entries?: IKeyValue[]; +} diff --git a/client/src/app/lib/ui/enums/index.ts b/client/src/app/lib/ui/enums/index.ts new file mode 100644 index 000000000..eeda1a054 --- /dev/null +++ b/client/src/app/lib/ui/enums/index.ts @@ -0,0 +1 @@ +export * from "./pill-status.enum"; diff --git a/client/src/app/lib/ui/enums/pill-status.enum.ts b/client/src/app/lib/ui/enums/pill-status.enum.ts new file mode 100644 index 000000000..5a3e05e32 --- /dev/null +++ b/client/src/app/lib/ui/enums/pill-status.enum.ts @@ -0,0 +1,5 @@ +export enum PillStatus { + Pending = "pending", + Success = "success", + Error = "error", +} diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index cd52d8ed5..62ac887e8 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -35,8 +35,10 @@ import NftRedirectRoute from "./routes/stardust/NftRedirectRoute"; import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; import NovaBlockPage from "./routes/nova/Block"; +import NovaTransactionPage from "./routes/nova/TransactionPage"; import NovaOutputPage from "./routes/nova/OutputPage"; import NovaSearch from "./routes/nova/Search"; +import NovaSlotPage from "./routes/nova/SlotPage"; import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; @@ -178,6 +180,8 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , + , ]; return ( diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 1abdfaf8d..1fd3769f6 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -81,7 +81,13 @@ const OutputPage: React.FC> = ({
- +
@@ -106,7 +112,7 @@ const OutputPage: React.FC> = ({
Transaction ID
- +
)} @@ -140,43 +146,6 @@ const OutputPage: React.FC> = ({
)} - - {outputManaDetails && ( - <> -
-
Stored mana
-
- {outputManaDetails.storedMana} -
-
-
-
Stored mana (decayed)
-
- {outputManaDetails.storedManaDecayed} -
-
-
-
Potential mana
-
- {outputManaDetails.potentialMana} -
-
- {outputManaDetails.delegationRewards && ( -
-
Mana rewards
-
- {outputManaDetails.delegationRewards} -
-
- )} -
-
Total mana
-
- {outputManaDetails.totalMana} -
-
- - )}
diff --git a/client/src/app/routes/nova/Search.tsx b/client/src/app/routes/nova/Search.tsx index 6701c8f6c..9c30eab28 100644 --- a/client/src/app/routes/nova/Search.tsx +++ b/client/src/app/routes/nova/Search.tsx @@ -105,9 +105,8 @@ const Search: React.FC> = (props) => { } else if (response.output) { route = "output"; routeParam = response.output.metadata.outputId; - } else if (response.transactionId) { + } else if (response.transactionBlock) { route = "transaction"; - routeParam = response.transactionId; } else if (response.foundryId) { route = "foundry"; routeParam = response.foundryId; diff --git a/client/src/app/routes/nova/SlotPage.scss b/client/src/app/routes/nova/SlotPage.scss new file mode 100644 index 000000000..2c802392c --- /dev/null +++ b/client/src/app/routes/nova/SlotPage.scss @@ -0,0 +1,65 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.slot-page { + display: flex; + flex-direction: column; + + .wrapper { + display: flex; + justify-content: center; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: $desktop-width; + margin: 40px 25px; + + @include desktop-down { + flex: unset; + width: 100%; + max-width: 100%; + margin: 40px 24px; + padding-right: 24px; + padding-left: 24px; + + > .row { + flex-direction: column; + } + } + + @include tablet-down { + margin: 28px 0; + } + + .slot-page--header { + display: flex; + flex-direction: column; + align-items: flex-start; + + .header--title { + margin-bottom: 8px; + } + + .header--status { + display: flex; + } + } + + .section { + padding-top: 44px; + + .section--header { + margin-top: 44px; + } + + .card--content__output { + margin-top: 20px; + } + } + } + } +} diff --git a/client/src/app/routes/nova/SlotPage.tsx b/client/src/app/routes/nova/SlotPage.tsx new file mode 100644 index 000000000..56d538e1e --- /dev/null +++ b/client/src/app/routes/nova/SlotPage.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import useuseSlotDetails from "~/helpers/nova/hooks/useSlotDetails"; +import PageDataRow, { IPageDataRow } from "~/app/components/nova/PageDataRow"; +import Modal from "~/app/components/Modal"; +import mainHeaderMessage from "~assets/modals/nova/slot/main-header.json"; +import NotFound from "~/app/components/NotFound"; +import { RouteComponentProps } from "react-router-dom"; +import "./SlotPage.scss"; + +export default function SlotPage({ + match: { + params: { network, slotIndex }, + }, +}: RouteComponentProps<{ + network: string; + slotIndex: string; +}>): React.JSX.Element { + const { slotCommitment } = useuseSlotDetails(network, slotIndex); + + const parsedSlotIndex = parseSlotIndex(slotIndex); + + const dataRows: IPageDataRow[] = [ + { + label: "Slot Index", + value: parsedSlotIndex ?? "-", + }, + { + label: "RMC", + value: slotCommitment?.referenceManaCost?.toString() ?? "-", + }, + ]; + + function parseSlotIndex(slotIndex: string): number | undefined { + const slotIndexNum = parseInt(slotIndex, 10); + if (isNaN(slotIndexNum)) { + return; + } + return slotIndexNum; + } + + return ( +
+
+
+
+
+

Slot

+ +
+
+ {parsedSlotIndex ? ( +
+
+
+

General

+
+
+ {dataRows.map((dataRow, index) => { + if (dataRow.value || dataRow.truncatedId) { + return ; + } + })} +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/client/src/app/routes/nova/TransactionPage.scss b/client/src/app/routes/nova/TransactionPage.scss new file mode 100644 index 000000000..74c212e45 --- /dev/null +++ b/client/src/app/routes/nova/TransactionPage.scss @@ -0,0 +1,71 @@ +@import "../../../scss/fonts"; +@import "../../../scss/mixins"; +@import "../../../scss/media-queries"; +@import "../../../scss/variables"; + +.transaction-page { + display: flex; + flex-direction: column; + + .wrapper { + display: flex; + justify-content: center; + + .inner { + display: flex; + flex: 1; + flex-direction: column; + max-width: $desktop-width; + margin: 40px 25px; + + @include desktop-down { + flex: unset; + width: 100%; + max-width: 100%; + margin: 40px 24px; + padding-right: 24px; + padding-left: 24px; + + > .row { + flex-direction: column; + } + } + + @include tablet-down { + margin: 28px 0; + } + + .transation-page--header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .section { + padding-top: 44px; + + .section--header { + margin-top: 44px; + } + } + + .link { + @include font-size(14px); + + max-width: 100%; + color: var(--link-color); + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + } + } + + .section--data { + .amount-transacted { + @include font-size(15px); + font-weight: 700; + } + } + } +} diff --git a/client/src/app/routes/nova/TransactionPage.tsx b/client/src/app/routes/nova/TransactionPage.tsx new file mode 100644 index 000000000..79aebb339 --- /dev/null +++ b/client/src/app/routes/nova/TransactionPage.tsx @@ -0,0 +1,198 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { BasicBlockBody, SignedTransactionPayload, Utils } from "@iota/sdk-wasm-nova/web"; +import React, { useEffect, useState } from "react"; +import { RouteComponentProps } from "react-router-dom"; +import metadataInfoMessage from "~assets/modals/stardust/block/metadata.json"; +import transactionPayloadMessage from "~assets/modals/stardust/transaction/main-header.json"; +import { useBlockMetadata } from "~helpers/nova/hooks/useBlockMetadata"; +import { useInputsAndOutputs } from "~helpers/nova/hooks/useInputsAndOutputs"; +import { useTransactionIncludedBlock } from "~helpers/nova/hooks/useTransactionIncludedBlock"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import TabbedSection from "~/app/components/hoc/TabbedSection"; +import Modal from "~/app/components/Modal"; +import Spinner from "~/app/components/Spinner"; +import TruncatedId from "~/app/components/stardust/TruncatedId"; +import NotFound from "~/app/components/NotFound"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { DateHelper } from "~/helpers/dateHelper"; +import BlockTangleState from "~/app/components/nova/block/BlockTangleState"; +import BlockPayloadSection from "~/app/components/nova/block/section/BlockPayloadSection"; +import TransactionMetadataSection from "~/app/components/nova/block/section/TransactionMetadataSection"; +import "./TransactionPage.scss"; + +export interface TransactionPageProps { + /** + * The network to lookup. + */ + network: string; + + /** + * The transaction to lookup. + */ + transactionId: string; +} + +enum TRANSACTION_PAGE_TABS { + Payload = "Payload", + Metadata = "Metadata", +} + +const TransactionPage: React.FC> = ({ + history, + match: { + params: { network, transactionId }, + }, +}) => { + const { tokenInfo, protocolInfo, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [block, isIncludedBlockLoading, blockError] = useTransactionIncludedBlock(network, transactionId); + const [inputs, outputs, transferTotal, isInputsAndOutputsLoading] = useInputsAndOutputs(network, block); + const [blockId, setBlockId] = useState(null); + const [blockMetadata] = useBlockMetadata(network, blockId); + const [isFormattedBalance, setIsFormattedBalance] = useState(true); + + useEffect(() => { + if (block && protocolInfo) { + setBlockId(Utils.blockId(block, protocolInfo?.parameters)); + } + }, [block]); + + const tabbedSections: JSX.Element[] = []; + let idx = 0; + if (block) { + tabbedSections.push( + , + ); + } + + if (blockMetadata.metadata?.transactionMetadata) { + tabbedSections.push( + , + ); + } + + if (blockError) { + return ( +
+
+
+
+
+

Transaction

+ + {isIncludedBlockLoading && } +
+ +
+
+
+
+ ); + } + + const transactionContent = block ? ( + +
+
+

General

+
+
+
+
Transaction ID
+
+ +
+
+ {blockId && ( +
+
Included in block
+
+ +
+
+ )} +
+
Issuing Time
+
{DateHelper.formatShort(Number(block.header.issuingTime) / 1000000)}
+
+
+
Slot commitment
+
+ +
+
+
+
Issuer
+
+ +
+
+ {transferTotal !== null && ( +
+
Amount transacted
+
+ setIsFormattedBalance(!isFormattedBalance)} className="pointer margin-r-5"> + {formatAmount(transferTotal, tokenInfo, !isFormattedBalance)} + +
+
+ )} + + {tabbedSections} + +
+ ) : null; + + return ( +
+
+
+
+
+
+

Transaction

+ + {isIncludedBlockLoading && } +
+ {blockMetadata.metadata && block?.header && ( + + )} +
+
+
{transactionContent}
+
+
+
+ ); +}; + +export default TransactionPage; diff --git a/client/src/app/routes/nova/landing/Landing.tsx b/client/src/app/routes/nova/landing/Landing.tsx index 68cc9158a..573a78a0b 100644 --- a/client/src/app/routes/nova/landing/Landing.tsx +++ b/client/src/app/routes/nova/landing/Landing.tsx @@ -1,6 +1,7 @@ import React from "react"; import { RouteComponentProps } from "react-router-dom"; import LandingEpochSection from "~/app/components/nova/landing/LandingEpochSection"; +import LandingSlotSection from "~/app/components/nova/landing/LandingSlotSection"; import { useNetworkConfig } from "~helpers/hooks/useNetworkConfig"; import { LandingRouteProps } from "../../LandingRouteProps"; import "./Landing.scss"; @@ -29,6 +30,7 @@ const Landing: React.FC> = ({
+
diff --git a/client/src/assets/modals/nova/account/bic.json b/client/src/assets/modals/nova/account/bic.json new file mode 100644 index 000000000..3b875279d --- /dev/null +++ b/client/src/assets/modals/nova/account/bic.json @@ -0,0 +1,11 @@ +{ + "title": "Block Issuance Credit", + "description": "

(BIC) is the form of Mana used as an anti-spam mechanism to the block issuance process.

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/mana/#block-issuance-credits-bic", + "isExternal": true + } + ] +} diff --git a/client/src/assets/modals/nova/slot/main-header.json b/client/src/assets/modals/nova/slot/main-header.json new file mode 100644 index 000000000..c9c5f0a80 --- /dev/null +++ b/client/src/assets/modals/nova/slot/main-header.json @@ -0,0 +1,11 @@ +{ + "title": "Slot", + "description": "

Each block in IOTA 2.0 contains a commitment to the content of a certain slot in the past. A slot commitment is a hash value that encapsulates all the crucial information about a slot (such as accepted blocks and transactions, the index of the slot, etc.).

", + "links": [ + { + "label": "Read more", + "href": "https://wiki.iota.org/learn/protocols/iota2.0/core-concepts/consensus/preliminaries/#slot-commitment-chain", + "isExternal": true + } + ] +} diff --git a/client/src/features/visualizer-threejs/CameraControls.tsx b/client/src/features/visualizer-threejs/CameraControls.tsx index 2419d39dc..a8353cac9 100644 --- a/client/src/features/visualizer-threejs/CameraControls.tsx +++ b/client/src/features/visualizer-threejs/CameraControls.tsx @@ -1,38 +1,75 @@ import { CameraControls as DreiCameraControls } from "@react-three/drei"; import { getCameraAngles } from "./utils"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useThree } from "@react-three/fiber"; import { CanvasElement } from "./enums"; -import { useTangleStore } from "./store"; +import { useTangleStore, useConfigStore } from "./store"; import { VISUALIZER_PADDINGS } from "./constants"; +const CAMERA_ANGLES = getCameraAngles(); + const CameraControls = () => { - const [shouldLockZoom, setShouldLockZoom] = React.useState(false); const controls = React.useRef(null); + const [shouldLockZoom, setShouldLockZoom] = useState(false); - const CAMERA_ANGLES = getCameraAngles(); - - const zoom = useTangleStore((s) => s.zoom); - const get = useThree((state) => state.get); - const mesh = get().scene.getObjectByName(CanvasElement.TangleWrapperMesh); + const scene = useThree((state) => state.scene); + const zoom = useTangleStore((state) => state.zoom); + const mesh = scene.getObjectByName(CanvasElement.TangleWrapperMesh) as THREE.Mesh | undefined; + const canvasDimensions = useConfigStore((state) => state.dimensions); - // Set fixed zoom - useEffect(() => { - if (controls.current && shouldLockZoom) { - controls.current.maxZoom = zoom; - controls.current.minZoom = zoom; + /** + * Fits the camera to the TangleMesh. + */ + function fitCameraToTangle(controls: DreiCameraControls | null, mesh?: THREE.Mesh) { + if (controls && mesh) { + const previousZoom = controls.camera.zoom; + controls.minZoom = 0.01; + controls.maxZoom = Infinity; + controls.fitToBox(mesh, false, { ...VISUALIZER_PADDINGS }); + controls.minZoom = previousZoom; + controls.maxZoom = previousZoom; } - }, [controls, zoom, shouldLockZoom]); + } - // Fix to TangleMesh + /** + * Sets the scene to be vertical or horizontal + * depending on the canvas dimensions. + */ useEffect(() => { - if (controls.current && mesh) { - controls.current.fitToBox(mesh, false, { ...VISUALIZER_PADDINGS }); - controls.current.setOrbitPoint(0, 0, 0); + const cameraControls = controls.current; + if (cameraControls && canvasDimensions.width && canvasDimensions.height) { + const camera = controls.current?.camera; + const renderVerticalScene = canvasDimensions.width < canvasDimensions.height; + const cameraUp: [number, number, number] = renderVerticalScene ? [1, 0, 0] : [0, 1, 0]; + setShouldLockZoom(false); + camera.up.set(...cameraUp); setShouldLockZoom(true); } + }, [canvasDimensions, controls, mesh]); + + /** + * Fit camera to TangleMesh on mount and on window resize. + */ + useEffect(() => { + const adjustCamera = () => fitCameraToTangle(controls.current, mesh); + adjustCamera(); + + window.addEventListener("resize", adjustCamera); + return () => { + window.removeEventListener("resize", adjustCamera); + }; }, [controls, mesh]); + /** + * Locks the camera zoom to the current zoom value. + */ + useEffect(() => { + if (controls.current) { + controls.current.maxZoom = shouldLockZoom ? zoom : Infinity; + controls.current.minZoom = shouldLockZoom ? zoom : 0.01; + } + }, [controls.current, shouldLockZoom, zoom]); + return ; }; diff --git a/client/src/features/visualizer-threejs/Emitter.tsx b/client/src/features/visualizer-threejs/Emitter.tsx index 4c5925565..b19a07765 100644 --- a/client/src/features/visualizer-threejs/Emitter.tsx +++ b/client/src/features/visualizer-threejs/Emitter.tsx @@ -1,43 +1,62 @@ /* eslint-disable react/no-unknown-property */ import { useFrame, useThree } from "@react-three/fiber"; -import React, { RefObject, Dispatch, SetStateAction, useEffect, useRef } from "react"; +import React, { RefObject, Dispatch, SetStateAction, useEffect, useRef, useLayoutEffect } from "react"; import * as THREE from "three"; import { useConfigStore, useTangleStore } from "./store"; import { useRenderTangle } from "./useRenderTangle"; -import { getTangleDistances, getSinusoidalPosition } from "./utils"; -import { CanvasElement } from "./enums"; import { - EMITTER_SPEED_MULTIPLIER, - EMITTER_DEPTH, - EMITTER_HEIGHT, - EMITTER_WIDTH, - MAX_SINUSOIDAL_AMPLITUDE, - SINUSOIDAL_AMPLITUDE_ACCUMULATOR, - HALF_WAVE_PERIOD_SECONDS, - INITIAL_SINUSOIDAL_AMPLITUDE, -} from "./constants"; + getTangleDistances, + getEmitterPositions, + generateRandomPeriods, + generateRandomAmplitudes, + generateRandomTiltings, + getCurrentTiltValue, +} from "./utils"; +import { CanvasElement } from "./enums"; +import useVisualizerTimer from "~/helpers/nova/hooks/useVisualizerTimer"; +import { EMITTER_DEPTH, EMITTER_HEIGHT, EMITTER_WIDTH } from "./constants"; interface EmitterProps { readonly setRunListeners: Dispatch>; readonly emitterRef: RefObject; } +const { xTangleDistance, yTangleDistance } = getTangleDistances(); + const Emitter: React.FC = ({ setRunListeners, emitterRef }: EmitterProps) => { + const getVisualizerTimeDiff = useVisualizerTimer(); + const setZoom = useTangleStore((s) => s.setZoom); const get = useThree((state) => state.get); const currentZoom = useThree((state) => state.camera.zoom); - const groupRef = useRef(null); const camera = get().camera; - const { xTangleDistance, yTangleDistance } = getTangleDistances(); const isPlaying = useConfigStore((state) => state.isPlaying); const setIsPlaying = useConfigStore((state) => state.setIsPlaying); + const setInitialTime = useConfigStore((state) => state.setInitialTime); + + const sinusoidPeriodsSum = useConfigStore((state) => state.sinusoidPeriodsSum); + const setSinusoidPeriodsSum = useConfigStore((state) => state.setSinusoidPeriodsSum); + const sinusoidRandomPeriods = useConfigStore((state) => state.sinusoidRandomPeriods); + const setSinusoidRandomPeriods = useConfigStore((state) => state.setSinusoidRandomPeriods); + + const randomSinusoidAmplitudes = useConfigStore((state) => state.randomSinusoidAmplitudes); + const setRandomSinusoidAmplitudes = useConfigStore((state) => state.setRandomSinusoidAmplitudes); - const animationTime = useRef(0); - const currentAmplitude = useRef(INITIAL_SINUSOIDAL_AMPLITUDE); + const randomTilts = useConfigStore((state) => state.randomTilts); + const setRandomTilts = useConfigStore((state) => state.setRandomTilts); - const previousRealTime = useRef(0); - const previousPeakTime = useRef(0); + const tangleWrapperRef = useRef(null); + + useLayoutEffect(() => { + const { periods, sum: periodsSum } = generateRandomPeriods(); + const amplitudes = generateRandomAmplitudes(); + const tiltings = generateRandomTiltings(); + setSinusoidRandomPeriods(periods); + setSinusoidPeriodsSum(periodsSum); + setRandomSinusoidAmplitudes(amplitudes); + setRandomTilts(tiltings); + }, []); useEffect(() => { setZoom(currentZoom); @@ -47,6 +66,7 @@ const Emitter: React.FC = ({ setRunListeners, emitterRef }: Emitte if (emitterRef?.current) { setIsPlaying(true); setRunListeners(true); + setInitialTime(Date.now()); } return () => { @@ -55,68 +75,53 @@ const Emitter: React.FC = ({ setRunListeners, emitterRef }: Emitte }; }, [emitterRef?.current]); - useFrame(() => { - if (camera && groupRef.current) { - camera.position.x = groupRef.current.position.x; - } - }); - - function updateAnimationTime(realTimeDelta: number): void { - animationTime.current += realTimeDelta; - } - - function checkAndHandleNewPeak(): void { - const currentHalfWaveCount = Math.floor(animationTime.current / HALF_WAVE_PERIOD_SECONDS); - const lastPeakHalfWaveCount = Math.floor(previousPeakTime.current / HALF_WAVE_PERIOD_SECONDS); - - if (currentHalfWaveCount > lastPeakHalfWaveCount) { - currentAmplitude.current = Math.min(currentAmplitude.current + SINUSOIDAL_AMPLITUDE_ACCUMULATOR, MAX_SINUSOIDAL_AMPLITUDE); - previousPeakTime.current = animationTime.current; - } - } - /** * Emitter shift */ - useFrame(({ clock }, delta) => { - const currentRealTime = clock.getElapsedTime(); - const realTimeDelta = currentRealTime - previousRealTime.current; - previousRealTime.current = currentRealTime; + useFrame(() => { + const currentAnimationTime = getVisualizerTimeDiff(); + const currentTilt = getCurrentTiltValue(currentAnimationTime, randomTilts); + const { x, y } = getEmitterPositions({ + currentAnimationTime, + periods: sinusoidRandomPeriods, + periodsSum: sinusoidPeriodsSum, + sinusoidAmplitudes: randomSinusoidAmplitudes, + }); if (isPlaying) { - updateAnimationTime(realTimeDelta); - checkAndHandleNewPeak(); - - if (groupRef.current) { - const { x } = groupRef.current.position; - const newXPos = x + delta * EMITTER_SPEED_MULTIPLIER; - groupRef.current.position.x = newXPos; + if (emitterRef.current) { + emitterRef.current.position.x = x; + emitterRef.current.position.y = y; + emitterRef.current.rotation.z = THREE.MathUtils.degToRad(currentTilt); } - if (emitterRef.current) { - const newYPos = getSinusoidalPosition(animationTime.current, currentAmplitude.current); - emitterRef.current.position.y = newYPos; + if (tangleWrapperRef.current) { + tangleWrapperRef.current.position.x = x - xTangleDistance / 2; } } + + if (tangleWrapperRef.current && camera) { + camera.position.x = tangleWrapperRef.current.position.x + xTangleDistance / 2; + } }); // The Tangle rendering hook useRenderTangle(); return ( - + <> {/* TangleWrapper Mesh */} - + - + {/* Emitter Mesh */} - + - + ); }; export default Emitter; diff --git a/client/src/features/visualizer-threejs/Visualizer.scss b/client/src/features/visualizer-threejs/Visualizer.scss index f5eb1b5c5..59c4e2d5f 100644 --- a/client/src/features/visualizer-threejs/Visualizer.scss +++ b/client/src/features/visualizer-threejs/Visualizer.scss @@ -83,74 +83,6 @@ } } - .stats-panel-container { - display: flex; - position: absolute; - z-index: 1; - top: 90px !important; - left: 20px !important; - bottom: auto !important; - justify-content: left !important; - align-items: center; - pointer-events: none; - - .stats-panel { - background: var(--body-background); - - .card--value, - .card--label { - text-align: left; - } - .card--label { - justify-content: flex-start; - } - .card--content { - padding: 0; - } - .stats-panel__info { - padding: 0 10px; - display: inline-block; - } - } - - @include tablet-down { - top: 60px; - left: 20px; - bottom: auto; - justify-content: left; - - .stats-panel { - .card--value, - .card--label { - text-align: left; - } - .card--label { - justify-content: flex-start; - } - .card--content { - padding: 0; - } - .stats-panel__info { - padding: 0 10px; - display: inline-block; - } - } - } - - @include phone-down { - left: 10px; - .stats-panel { - .card--value, - .card--label { - font-size: 12px; - } - .stats-panel__info:last-of-type { - display: block; - } - } - } - } - .info-panel { background: var(--body-background); display: flex; diff --git a/client/src/features/visualizer-threejs/VisualizerInstance.tsx b/client/src/features/visualizer-threejs/VisualizerInstance.tsx index 64a86fc0d..ff59a791d 100644 --- a/client/src/features/visualizer-threejs/VisualizerInstance.tsx +++ b/client/src/features/visualizer-threejs/VisualizerInstance.tsx @@ -5,19 +5,16 @@ import { Perf } from "r3f-perf"; import React, { useEffect, useRef } from "react"; import { RouteComponentProps } from "react-router-dom"; import * as THREE from "three"; -import { Box3 } from "three"; import { FAR_PLANE, NEAR_PLANE, DIRECTIONAL_LIGHT_INTENSITY, PENDING_BLOCK_COLOR, VISUALIZER_BACKGROUND, - EMITTER_X_POSITION_MULTIPLIER, BLOCK_STATE_TO_COLOR, } from "./constants"; import Emitter from "./Emitter"; import { useTangleStore, useConfigStore } from "./store"; -import { getGenerateDynamicYZPosition, randomIntFromInterval } from "./utils"; import { BPSCounter } from "./BPSCounter"; import { VisualizerRouteProps } from "../../app/routes/VisualizerRouteProps"; import { ServiceFactory } from "../../factories/serviceFactory"; @@ -27,14 +24,16 @@ import { Wrapper } from "./wrapper/Wrapper"; import { CanvasElement } from "./enums"; import { useGetThemeMode } from "~/helpers/hooks/useGetThemeMode"; import { TSelectFeedItemNova } from "~/app/types/visualizer.types"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { BasicBlockBody, Utils, type IBlockMetadata, type BlockState, type SlotIndex, type BlockId } from "@iota/sdk-wasm-nova/web"; +import { BasicBlockBody, Utils, type IBlockMetadata, type BlockState, type SlotIndex } from "@iota/sdk-wasm-nova/web"; import { IFeedBlockData } from "~/models/api/nova/feed/IFeedBlockData"; import CameraControls from "./CameraControls"; +import useVisualizerTimer from "~/helpers/nova/hooks/useVisualizerTimer"; +import { getBlockInitPosition, getBlockTargetPosition } from "./blockPositions"; +import { getCurrentTiltValue } from "./utils"; import "./Visualizer.scss"; const features = { - statsEnabled: true, + statsEnabled: false, cameraControls: true, }; @@ -44,8 +43,8 @@ const VisualizerInstance: React.FC> = }, }) => { const [networkConfig] = useNetworkConfig(network); - const generateYZPositions = getGenerateDynamicYZPosition(); const themeMode = useGetThemeMode(); + const getCurrentAnimationTime = useVisualizerTimer(); const [runListeners, setRunListeners] = React.useState(false); @@ -74,6 +73,11 @@ const VisualizerInstance: React.FC> = const addToConfirmedBlocksSlot = useTangleStore((s) => s.addToConfirmedBlocksBySlot); const removeConfirmedBlocksSlot = useTangleStore((s) => s.removeConfirmedBlocksSlot); + const sinusoidPeriodsSum = useConfigStore((s) => s.sinusoidPeriodsSum); + const sinusoidRandomPeriods = useConfigStore((s) => s.sinusoidRandomPeriods); + const sinusoidRandomAmplitudes = useConfigStore((s) => s.randomSinusoidAmplitudes); + const randomTilts = useConfigStore((state) => state.randomTilts); + const selectedFeedItem: TSelectFeedItemNova = clickedInstanceId ? blockMetadata.get(clickedInstanceId) ?? null : null; const resetConfigState = useTangleStore((s) => s.resetConfigState); @@ -166,6 +170,7 @@ const VisualizerInstance: React.FC> = if (!runListeners) { return; } + setIsPlaying(true); return () => { @@ -195,21 +200,20 @@ const VisualizerInstance: React.FC> = * @param blockData The new block data */ const onNewBlock = (blockData: IFeedBlockData) => { - const emitterObj = emitterRef.current; - if (emitterObj && blockData && isPlaying) { - const emitterBox = new Box3().setFromObject(emitterObj); - - const emitterCenter = new THREE.Vector3(); - emitterBox.getCenter(emitterCenter); - - const { y, z } = generateYZPositions(bpsCounter.getBPS(), emitterCenter); - const minX = emitterBox.min.x - (emitterBox.max.x - emitterBox.min.x) * EMITTER_X_POSITION_MULTIPLIER; - const maxX = emitterBox.max.x + (emitterBox.max.x - emitterBox.min.x) * EMITTER_X_POSITION_MULTIPLIER; - - const x = randomIntFromInterval(minX, maxX); - const targetPosition = { x, y, z }; + if (blockData) { + const currentAnimationTime = getCurrentAnimationTime(); + const bps = bpsCounter.getBPS(); + const initPosition = getBlockInitPosition({ + currentAnimationTime, + periods: sinusoidRandomPeriods, + periodsSum: sinusoidPeriodsSum, + sinusoidAmplitudes: sinusoidRandomAmplitudes, + }); + const blockTiltFactor = getCurrentTiltValue(currentAnimationTime, randomTilts); + const targetPosition = getBlockTargetPosition(initPosition, bps, blockTiltFactor); bpsCounter.addBlock(); + if (!bpsCounter.getBPS()) { bpsCounter.start(); } @@ -225,16 +229,12 @@ const VisualizerInstance: React.FC> = if (blockWeakParents.length > 0) { addToEdgeQueue(blockData.blockId, blockWeakParents); } - addBlock({ id: blockData.blockId, color: PENDING_BLOCK_COLOR, + blockAddedTimestamp: currentAnimationTime, targetPosition, - initPosition: { - x: emitterCenter.x, - y: emitterCenter.y, - z: emitterCenter.z, - }, + initPosition, }); } }; diff --git a/client/src/features/visualizer-threejs/blockPositions.ts b/client/src/features/visualizer-threejs/blockPositions.ts new file mode 100644 index 000000000..7f398e038 --- /dev/null +++ b/client/src/features/visualizer-threejs/blockPositions.ts @@ -0,0 +1,25 @@ +import { ISinusoidalPositionParams, IThreeDimensionalPosition } from "./interfaces"; +import { getEmitterPositions, getTangleDistances, getBlockPositionGenerator } from "./utils"; + +const generateBlockTargetPosition = getBlockPositionGenerator(); + +export function getBlockTargetPosition( + initPosition: IThreeDimensionalPosition, + bps: number, + tiltDegress: number, +): IThreeDimensionalPosition { + return generateBlockTargetPosition(bps, initPosition, tiltDegress); +} + +export function getBlockInitPosition({ + currentAnimationTime, + periods, + periodsSum, + sinusoidAmplitudes, +}: ISinusoidalPositionParams): IThreeDimensionalPosition { + const { xTangleDistance } = getTangleDistances(); + const { x: xEmitterPos, y, z } = getEmitterPositions({ currentAnimationTime, periods, periodsSum, sinusoidAmplitudes }); + const x = xEmitterPos + xTangleDistance / 2; + + return { x, y, z }; +} diff --git a/client/src/features/visualizer-threejs/constants.ts b/client/src/features/visualizer-threejs/constants.ts index 37b399ec5..6e78c4962 100644 --- a/client/src/features/visualizer-threejs/constants.ts +++ b/client/src/features/visualizer-threejs/constants.ts @@ -19,15 +19,14 @@ export const ZOOM_DEFAULT = 2; export const TIME_DIFF_COUNTER = 250; export const SECOND = 1000; export const DATA_SENDER_TIME_INTERVAL = 500; -export const ANIMATION_TIME_SECONDS = 3; // colors export const PENDING_BLOCK_COLOR = new Color("#A6C3FC"); export const ACCEPTED_BLOCK_COLOR = new Color("#0101AB"); export const CONFIRMED_BLOCK_COLOR = new Color("#0000DB"); export const FINALIZED_BLOCK_COLOR = new Color("#0101FF"); -// TODO Remove accepted state once is added to the SDK (missing) -export const BLOCK_STATE_TO_COLOR = new Map([ + +export const BLOCK_STATE_TO_COLOR = new Map([ ["pending", PENDING_BLOCK_COLOR], ["accepted", ACCEPTED_BLOCK_COLOR], ["confirmed", CONFIRMED_BLOCK_COLOR], @@ -35,7 +34,7 @@ export const BLOCK_STATE_TO_COLOR = new Map([ ]); // emitter -export const EMITTER_SPEED_MULTIPLIER = 80; +export const EMITTER_SPEED_MULTIPLIER = 150; export const EMITTER_PADDING_RIGHT = 150; export const VISUALIZER_SAFE_ZONE = 150; @@ -72,11 +71,10 @@ export const EMITTER_HEIGHT = 250; export const EMITTER_DEPTH = 250; // conic emitter +export const MIN_TANGLE_RADIUS = 200; +export const MAX_TANGLE_RADIUS = 600; -export const MIN_TANGLE_RADIUS = 100; -export const MAX_TANGLE_RADIUS = 300; - -export const MIN_BLOCKS_PER_SECOND = 100; +export const MIN_BLOCKS_PER_SECOND = 150; export const MAX_BLOCKS_PER_SECOND = 250; export const MIN_BLOCK_NEAR_RADIUS = 20; @@ -84,9 +82,19 @@ export const MIN_BLOCK_NEAR_RADIUS = 20; export const MAX_POINT_RETRIES = 10; export const MAX_PREV_POINTS = 20; -export const EMITTER_X_POSITION_MULTIPLIER = 3; +export const SPRAY_DISTANCE = 400; +export const SPRAY_ANIMATION_DURATION = SPRAY_DISTANCE / EMITTER_SPEED_MULTIPLIER; + +/* Values for randomizing the tangle */ +export const NUMBER_OF_RANDOM_PERIODS = 100; +export const MIN_SINUSOID_PERIOD = 5; +export const MAX_SINUSOID_PERIOD = 8; + +export const NUMBER_OF_RANDOM_AMPLITUDES = 100; +export const MIN_SINUSOID_AMPLITUDE = 100; +export const MAX_SINUSOID_AMPLITUDE = 200; -export const MAX_SINUSOIDAL_AMPLITUDE = 200; -export const SINUSOIDAL_AMPLITUDE_ACCUMULATOR = 30; -export const INITIAL_SINUSOIDAL_AMPLITUDE = 80; -export const HALF_WAVE_PERIOD_SECONDS = 5; +export const NUMBER_OF_RANDOM_TILTINGS = 100; +export const TILT_DURATION_SECONDS = 4; +export const MAX_TILT_FACTOR_DEGREES = 16; +export const MIN_TILT_FACTOR_DEGREES = 1; diff --git a/client/src/features/visualizer-threejs/interfaces.ts b/client/src/features/visualizer-threejs/interfaces.ts index fc7faf8d0..732fce9b1 100644 --- a/client/src/features/visualizer-threejs/interfaces.ts +++ b/client/src/features/visualizer-threejs/interfaces.ts @@ -4,3 +4,28 @@ export interface ICameraAngles { maxPolarAngle: number; maxAzimuthAngle: number; } + +export interface ITwoDimensionalPosition { + x: number; + y: number; +} + +export interface IThreeDimensionalPosition { + x: number; + y: number; + z: number; +} + +export interface IThreeDimensionalPositionWithTilt extends IThreeDimensionalPosition { + tiltFactor: number; +} + +export interface ITimeBasedPositionParams { + currentAnimationTime: number; +} + +export interface ISinusoidalPositionParams extends ITimeBasedPositionParams { + periods: number[]; + periodsSum: number; + sinusoidAmplitudes: number[]; +} diff --git a/client/src/features/visualizer-threejs/store/config.ts b/client/src/features/visualizer-threejs/store/config.ts index 36d4fb1da..8c5d9ce9d 100644 --- a/client/src/features/visualizer-threejs/store/config.ts +++ b/client/src/features/visualizer-threejs/store/config.ts @@ -7,8 +7,25 @@ interface ConfigState { isPlaying: boolean; setIsPlaying: (isPlaying: boolean) => void; + inView: boolean; + setInView: (inView: boolean) => void; + isEdgeRenderingEnabled: boolean; setEdgeRenderingEnabled: (isEdgeRenderingEnabled: boolean) => void; + + initialTime: number | null; + setInitialTime: (initialTime: number) => void; + + sinusoidPeriodsSum: number; + setSinusoidPeriodsSum: (totalPeriodsSum: number) => void; + sinusoidRandomPeriods: number[]; + setSinusoidRandomPeriods: (randomizedPeriods: number[]) => void; + + randomSinusoidAmplitudes: number[]; + setRandomSinusoidAmplitudes: (randomizedAmplitudes: number[]) => void; + + randomTilts: number[]; + setRandomTilts: (randomTilts: number[]) => void; } export const useConfigStore = create((set) => ({ @@ -34,6 +51,17 @@ export const useConfigStore = create((set) => ({ })); }, + /** + * Is canvas in view + */ + inView: false, + setInView: (inView) => { + set((state) => ({ + ...state, + inView, + })); + }, + /** * Is edge rendering enabled */ @@ -44,4 +72,56 @@ export const useConfigStore = create((set) => ({ isEdgeRenderingEnabled, })); }, + + /** + * The initial time when the emitter was mounted. + * Used for all animations based on time. + */ + initialTime: null, + setInitialTime: (initialTime) => { + set((state) => ({ + ...state, + initialTime, + })); + }, + + /** + * Randomized periods for the tangle. + */ + sinusoidPeriodsSum: 0, + setSinusoidPeriodsSum: (totalPeriodsSum) => { + set((state) => ({ + ...state, + sinusoidPeriodsSum: totalPeriodsSum, + })); + }, + sinusoidRandomPeriods: [], + setSinusoidRandomPeriods: (randomizedPeriods) => { + set((state) => ({ + ...state, + sinusoidRandomPeriods: randomizedPeriods, + })); + }, + + /** + * Randomized amplitudes for the tangle. + */ + randomSinusoidAmplitudes: [], + setRandomSinusoidAmplitudes: (randomizedAmplitudes) => { + set((state) => ({ + ...state, + randomSinusoidAmplitudes: randomizedAmplitudes, + })); + }, + + /** + * Randomized tilts for the tangle. + */ + randomTilts: [], + setRandomTilts: (randomTilts) => { + set((state) => ({ + ...state, + randomTilts, + })); + }, })); diff --git a/client/src/features/visualizer-threejs/store/tangle.ts b/client/src/features/visualizer-threejs/store/tangle.ts index 9633cb195..5044b9914 100644 --- a/client/src/features/visualizer-threejs/store/tangle.ts +++ b/client/src/features/visualizer-threejs/store/tangle.ts @@ -1,21 +1,19 @@ import { Color } from "three"; import { create } from "zustand"; import { devtools } from "zustand/middleware"; -import { ZOOM_DEFAULT, ANIMATION_TIME_SECONDS } from "../constants"; +import { ZOOM_DEFAULT, EMITTER_SPEED_MULTIPLIER, SPRAY_DISTANCE } from "../constants"; import { IFeedBlockData } from "~models/api/nova/feed/IFeedBlockData"; +import { IThreeDimensionalPosition } from "../interfaces"; import { BlockId, SlotIndex } from "@iota/sdk-wasm-nova/web"; -interface IPosition { - x: number; - y: number; - z: number; +export interface IBlockAnimationPosition { + initPosition: IThreeDimensionalPosition; + targetPosition: IThreeDimensionalPosition; + blockAddedTimestamp: number; + elapsedTime: number; } -export interface IBlockInitPosition extends IPosition { - duration: number; -} - -export interface BlockState { +export interface IBlockState extends Omit { id: string; color: Color; } @@ -32,15 +30,21 @@ interface EdgeEntry { interface TangleState { // Queue for "add block" operation to the canvas - blockQueue: BlockState[]; - addToBlockQueue: (newBlock: BlockState & { initPosition: IPosition; targetPosition: IPosition }) => void; + blockQueue: IBlockState[]; + addToBlockQueue: ( + newBlock: IBlockState & { + initPosition: IThreeDimensionalPosition; + targetPosition: IThreeDimensionalPosition; + blockAddedTimestamp: number; + }, + ) => void; removeFromBlockQueue: (blockIds: string[]) => void; edgeQueue: Edge[]; addToEdgeQueue: (blockId: string, parents: string[]) => void; removeFromEdgeQueue: (edges: Edge[]) => void; - colorQueue: Pick[]; + colorQueue: Pick[]; addToColorQueue: (blockId: string, color: Color) => void; removeFromColorQueue: (blockIds: string[]) => void; @@ -49,7 +53,6 @@ interface TangleState { blockIdToEdges: Map; blockIdToPosition: Map; blockMetadata: Map; - blockIdToAnimationPosition: Map; indexToBlockId: string[]; updateBlockIdToIndex: (blockId: string, index: number) => void; @@ -63,7 +66,9 @@ interface TangleState { clickedInstanceId: string | null; setClickedInstanceId: (instanceId: string | null) => void; - updateBlockIdToAnimationPosition: (updatedPositions: Map) => void; + blockIdToAnimationPosition: Map; + updateBlockIdToAnimationPosition: (updatedPositions: Map) => void; + resetConfigState: () => void; // Confirmed/accepted blocks by slot @@ -99,7 +104,8 @@ export const useTangleStore = create()( }); for (const [key, value] of state.blockIdToAnimationPosition) { - if (value.duration > ANIMATION_TIME_SECONDS) { + const animationTime = SPRAY_DISTANCE / EMITTER_SPEED_MULTIPLIER; + if (value.elapsedTime > animationTime) { state.blockIdToAnimationPosition.delete(key); } } @@ -110,16 +116,18 @@ export const useTangleStore = create()( }, addToBlockQueue: (block) => { set((state) => { - const { initPosition, targetPosition, ...blockRest } = block; + const { initPosition, targetPosition, blockAddedTimestamp, ...blockRest } = block; state.blockIdToPosition.set(block.id, [targetPosition.x, targetPosition.y, targetPosition.z]); state.blockIdToAnimationPosition.set(block.id, { - ...initPosition, - duration: 0, + initPosition, + blockAddedTimestamp, + targetPosition, + elapsedTime: 0, }); return { ...state, - blockQueue: [...state.blockQueue, blockRest], + blockQueue: [...state.blockQueue, { initPosition, targetPosition, blockAddedTimestamp, ...blockRest }], }; }); }, diff --git a/client/src/features/visualizer-threejs/useRenderTangle.tsx b/client/src/features/visualizer-threejs/useRenderTangle.tsx index 4e6e93604..4da8ff476 100644 --- a/client/src/features/visualizer-threejs/useRenderTangle.tsx +++ b/client/src/features/visualizer-threejs/useRenderTangle.tsx @@ -1,10 +1,12 @@ import { useThree } from "@react-three/fiber"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import * as THREE from "three"; -import { MAX_BLOCK_INSTANCES, NODE_SIZE_DEFAULT, ANIMATION_TIME_SECONDS } from "./constants"; +import { SPRAY_ANIMATION_DURATION, MAX_BLOCK_INSTANCES, NODE_SIZE_DEFAULT } from "./constants"; import { useMouseMove } from "./hooks/useMouseMove"; -import { BlockState, IBlockInitPosition, useConfigStore, useTangleStore } from "./store"; +import { IBlockState, IBlockAnimationPosition, useConfigStore, useTangleStore } from "./store"; import { useRenderEdges } from "./useRenderEdges"; +import useVisualizerTimer from "~/helpers/nova/hooks/useVisualizerTimer"; +import { positionToVector } from "./utils"; const SPHERE_GEOMETRY = new THREE.SphereGeometry(NODE_SIZE_DEFAULT, 32, 16); const SPHERE_MATERIAL = new THREE.MeshPhongMaterial(); @@ -13,9 +15,10 @@ const INITIAL_SPHERE_SCALE = 0.7; export const useRenderTangle = () => { const tangleMeshRef = useRef(new THREE.InstancedMesh(SPHERE_GEOMETRY, SPHERE_MATERIAL, MAX_BLOCK_INSTANCES)); - const objectIndexRef = useRef(0); - const clearBlocksRef = useRef<() => void>(); + const [updateAnimationPositionQueue, setUpdateAnimationPositionQueue] = useState>(new Map()); + const objectIndexRef = useRef(1); const { scene } = useThree(); + const isPlaying = useConfigStore((s) => s.isPlaying); const blockQueue = useTangleStore((s) => s.blockQueue); const removeFromBlockQueue = useTangleStore((s) => s.removeFromBlockQueue); @@ -25,31 +28,13 @@ export const useRenderTangle = () => { const blockIdToIndex = useTangleStore((s) => s.blockIdToIndex); const updateBlockIdToIndex = useTangleStore((s) => s.updateBlockIdToIndex); - const blockIdToPosition = useTangleStore((s) => s.blockIdToPosition); const blockIdToAnimationPosition = useTangleStore((s) => s.blockIdToAnimationPosition); + const updateBlockIdToAnimationPosition = useTangleStore((s) => s.updateBlockIdToAnimationPosition); - function updateInstancedMeshPosition( - instancedMesh: THREE.InstancedMesh, - index: number, - nextPosition: THREE.Vector3, - ) { - const matrix = new THREE.Matrix4(); - const position = new THREE.Vector3(); - const quaternion = new THREE.Quaternion(); - const scale = new THREE.Vector3(); - instancedMesh.getMatrixAt(index, matrix); - matrix.decompose(position, quaternion, scale); - matrix.compose(nextPosition, quaternion, scale); - instancedMesh.setMatrixAt(index, matrix); - instancedMesh.instanceMatrix.needsUpdate = true; - } - - const assignBlockToMesh = (block: BlockState) => { - const initPosition = blockIdToAnimationPosition.get(block.id); - - if (!initPosition) return; + const getVisualizerTimeDiff = useVisualizerTimer(); - SPHERE_TEMP_OBJECT.position.set(initPosition.x, initPosition.y, initPosition.z); + const assignBlockToMesh = (block: IBlockState) => { + SPHERE_TEMP_OBJECT.position.copy(positionToVector(block.initPosition)); SPHERE_TEMP_OBJECT.scale.setScalar(INITIAL_SPHERE_SCALE); SPHERE_TEMP_OBJECT.updateMatrix(); @@ -60,10 +45,10 @@ export const useRenderTangle = () => { // Reuses old indexes when MAX_INSTANCES is reached // This also makes it so that old nodes are removed - if (objectIndexRef.current < MAX_BLOCK_INSTANCES - 1) { + if (objectIndexRef.current < MAX_BLOCK_INSTANCES) { objectIndexRef.current += 1; } else { - objectIndexRef.current = 0; + objectIndexRef.current = 1; } return block.id; @@ -72,55 +57,25 @@ export const useRenderTangle = () => { useRenderEdges(); useMouseMove({ tangleMeshRef }); - /** Spray animation */ - useEffect(() => { - const PERIOD = 24; // ms - - const int = setInterval(() => { - const isPlaying = useConfigStore.getState().isPlaying; - if (!isPlaying) { - return; - } - const blockIdToAnimationPosition = useTangleStore.getState().blockIdToAnimationPosition; - const updateBlockIdToAnimationPosition = useTangleStore.getState().updateBlockIdToAnimationPosition; - const delta = PERIOD / 1000; - - const updatedAnimationPositions: Map = new Map(); - blockIdToAnimationPosition.forEach(({ x, y, z, duration: currentTime }, blockId) => { - const nextTime = currentTime + delta; - const startPositionVector = new THREE.Vector3(x, y, z); - const endPositionVector = new THREE.Vector3(...(blockIdToPosition.get(blockId) as [number, number, number])); - const interpolationFactor = Math.min(nextTime / ANIMATION_TIME_SECONDS, 1); // set 1 as max value - - const targetPositionVector = new THREE.Vector3(); - targetPositionVector.lerpVectors(startPositionVector, endPositionVector, interpolationFactor); - updatedAnimationPositions.set(blockId, { x, y, z, duration: nextTime }); - const index = blockIdToIndex.get(blockId); - if (index) { - updateInstancedMeshPosition(tangleMeshRef.current, index, targetPositionVector); - } - }); - updateBlockIdToAnimationPosition(updatedAnimationPositions); - }, PERIOD); - - return () => { - clearInterval(int); - blockIdToAnimationPosition.clear(); - blockIdToPosition.clear(); - }; - }, []); - - useEffect(() => { - const intervalCallback = () => { - if (clearBlocksRef.current) { - clearBlocksRef.current(); - } - }; - const timer = setInterval(intervalCallback, 500); - - return () => clearInterval(timer); - }, []); + function updateInstancedMeshPosition( + instancedMesh: THREE.InstancedMesh, + index: number, + nextPosition: THREE.Vector3, + ) { + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + instancedMesh.getMatrixAt(index, matrix); + matrix.decompose(position, quaternion, scale); + matrix.compose(nextPosition, quaternion, scale); + instancedMesh.setMatrixAt(index, matrix); + instancedMesh.instanceMatrix.needsUpdate = true; + } + /** + * Setup and add the tangle mesh to the scene + */ useEffect(() => { if (tangleMeshRef?.current) { tangleMeshRef.current.instanceMatrix.setUsage(THREE.DynamicDrawUsage); @@ -137,6 +92,9 @@ export const useRenderTangle = () => { } }, [tangleMeshRef]); + /** + * Add blocks to the tangle + */ useEffect(() => { if (blockQueue.length === 0) { return; @@ -152,16 +110,21 @@ export const useRenderTangle = () => { } } - if (tangleMeshRef.current.instanceColor) { - tangleMeshRef.current.instanceColor.needsUpdate = true; - } + if (isPlaying) { + if (tangleMeshRef.current.instanceColor) { + tangleMeshRef.current.instanceColor.needsUpdate = true; + } - tangleMeshRef.current.instanceMatrix.needsUpdate = true; - tangleMeshRef.current.computeBoundingSphere(); + tangleMeshRef.current.instanceMatrix.needsUpdate = true; + tangleMeshRef.current.computeBoundingSphere(); + } removeFromBlockQueue(addedIds); - }, [blockQueue, blockIdToAnimationPosition]); + }, [blockQueue, blockIdToAnimationPosition, isPlaying]); + /** + * Update block colors + */ useEffect(() => { if (colorQueue.length > 0) { const removeIds: string[] = []; @@ -182,4 +145,53 @@ export const useRenderTangle = () => { removeFromColorQueue(removeIds); } }, [colorQueue, blockIdToIndex]); + + /** + * Spray animation + */ + useEffect(() => { + const updatedAnimationPositions: Map = new Map(); + const updateAnimationPositionQueue: Map = new Map(); + const SPRAY_FRAMES_PER_SECOND = 24; + + const interval = setInterval(() => { + blockIdToAnimationPosition.forEach((properties, blockId) => { + const { initPosition, targetPosition, blockAddedTimestamp } = properties; + const currentAnimationTime = getVisualizerTimeDiff(); + const elapsedTime = currentAnimationTime - blockAddedTimestamp; + const animationAlpha = Math.min(elapsedTime / SPRAY_ANIMATION_DURATION, 1); + const targetPositionVector = new THREE.Vector3(); + + targetPositionVector.lerpVectors(positionToVector(initPosition), positionToVector(targetPosition), animationAlpha); + updatedAnimationPositions.set(blockId, { initPosition, elapsedTime, targetPosition, blockAddedTimestamp }); + + const index = blockIdToIndex.get(blockId); + if (index) { + if (isPlaying) { + updateInstancedMeshPosition(tangleMeshRef.current, index, targetPositionVector); + } else { + updateAnimationPositionQueue.set(index, targetPositionVector); + } + } + }); + }, 1000 / SPRAY_FRAMES_PER_SECOND); + + updateBlockIdToAnimationPosition(updatedAnimationPositions); + setUpdateAnimationPositionQueue(updateAnimationPositionQueue); + + return () => clearInterval(interval); + }, [blockIdToAnimationPosition, isPlaying]); + + /** + * Update animation positions after unpausing + */ + useEffect(() => { + if (isPlaying) { + updateAnimationPositionQueue.forEach((position, index) => { + updateInstancedMeshPosition(tangleMeshRef.current, index, position); + }); + updateAnimationPositionQueue.clear(); + setUpdateAnimationPositionQueue(updateAnimationPositionQueue); + } + }, [isPlaying, updateAnimationPositionQueue]); }; diff --git a/client/src/features/visualizer-threejs/utils.ts b/client/src/features/visualizer-threejs/utils.ts index 575b7d53d..a77a28edd 100644 --- a/client/src/features/visualizer-threejs/utils.ts +++ b/client/src/features/visualizer-threejs/utils.ts @@ -1,23 +1,31 @@ +import { Vector3, MathUtils } from "three"; import { - BLOCK_STEP_PX, MIN_BLOCKS_PER_SECOND, MAX_BLOCKS_PER_SECOND, MIN_TANGLE_RADIUS, MAX_TANGLE_RADIUS, - MIN_BLOCK_NEAR_RADIUS, - MAX_PREV_POINTS, - MAX_POINT_RETRIES, - HALF_WAVE_PERIOD_SECONDS, MAX_BLOCK_INSTANCES, EMITTER_SPEED_MULTIPLIER, - MAX_SINUSOIDAL_AMPLITUDE, CAMERA_X_AXIS_MOVEMENT, CAMERA_Y_AXIS_MOVEMENT, CAMERA_X_OFFSET, CAMERA_Y_OFFSET, + NUMBER_OF_RANDOM_PERIODS, + MIN_SINUSOID_PERIOD, + MAX_SINUSOID_PERIOD, + NUMBER_OF_RANDOM_AMPLITUDES, + MIN_SINUSOID_AMPLITUDE, + MAX_SINUSOID_AMPLITUDE, + NUMBER_OF_RANDOM_TILTINGS, + TILT_DURATION_SECONDS, + SPRAY_DISTANCE, + MAX_PREV_POINTS, + MAX_POINT_RETRIES, + MIN_BLOCK_NEAR_RADIUS, + MIN_TILT_FACTOR_DEGREES, + MAX_TILT_FACTOR_DEGREES, } from "./constants"; -import { Vector3 } from "three"; -import { ICameraAngles } from "./interfaces"; +import type { ICameraAngles, ISinusoidalPositionParams, IThreeDimensionalPosition, ITwoDimensionalPosition } from "./interfaces"; /** * Generates a random number within a specified range. @@ -65,16 +73,6 @@ interface IBlockTanglePosition { z: number; } -/** - * Calculates the distance between two points. - * @returns the distance between two points. - */ -function distanceBetweenPoints(point1: IBlockTanglePosition, point2: IBlockTanglePosition): number { - const { z: z1, y: y1 } = point1; - const { z: z2, y: y2 } = point2; - return Math.sqrt((y2 - y1) ** 2 + (z2 - z1) ** 2); -} - /** * Calculates the radius of the circle based on the blocks per second. * @returns the radius of the circle. @@ -90,21 +88,8 @@ function getLinearRadius(bps: number): number { return radius; } -/** - * Generates a random point on a circle. - * @returns the random point on a circle. - */ -function getDynamicRandomYZPoints(bps: number, initialPosition: Vector3 = new Vector3(0, 0, 0)): IBlockTanglePosition { - const theta = Math.random() * (2 * Math.PI); - - const maxRadius = getLinearRadius(bps); - const randomFactor = Math.random(); - const radius = randomFactor * maxRadius; - - const y = radius * Math.cos(theta) + initialPosition.y; - const z = radius * Math.sin(theta) + initialPosition.z; - - return { y, z }; +function distanceBetweenPoints(point1: IBlockTanglePosition, point2: IBlockTanglePosition): number { + return Math.sqrt(Math.pow(point1.y - point2.y, 2) + Math.pow(point1.z - point2.z, 2)); } /** @@ -119,22 +104,42 @@ function pointPassesAllChecks(point: IBlockTanglePosition, prevPoints: IBlockTan return prevPoints.some((prevPoint) => distanceBetweenPoints(point, prevPoint) > MIN_BLOCK_NEAR_RADIUS); } +export function getBlockPositionGenerator(): ( + bps: number, + initialPosition: IThreeDimensionalPosition, + tiltDegress: number, +) => IThreeDimensionalPosition { + const prevPoints: IBlockTanglePosition[] = []; + + return (bps: number, initialPosition: IThreeDimensionalPosition, tiltDegress: number) => { + const point = generateAValidRandomPoint(bps, initialPosition, prevPoints, tiltDegress); + prevPoints.push({ y: point.y, z: point.z }); + return point; + }; +} + /** * Retries to generate a point until it passes all the checks. * @returns the point that passes all the checks. */ -function generateAValidRandomPoint(bps: number, initialPosition: Vector3, prevPoints: IBlockTanglePosition[]): IBlockTanglePosition { - let trialPoint: IBlockTanglePosition; +function generateAValidRandomPoint( + bps: number, + initialPosition: IThreeDimensionalPosition, + prevPoints: IBlockTanglePosition[], + tiltDegress: number, +): IThreeDimensionalPosition { + let trialPoint: IThreeDimensionalPosition; let passAllChecks = false; let retries = 0; do { - trialPoint = getDynamicRandomYZPoints(bps, initialPosition); + trialPoint = generateRandomXYZPoints(bps, initialPosition, tiltDegress); passAllChecks = pointPassesAllChecks(trialPoint, prevPoints); retries++; } while (!passAllChecks && retries < MAX_POINT_RETRIES); prevPoints.push(trialPoint); + if (prevPoints.length > MAX_PREV_POINTS) { prevPoints.shift(); } @@ -143,23 +148,30 @@ function generateAValidRandomPoint(bps: number, initialPosition: Vector3, prevPo } /** - * Gets a function to generate a random point on a circle. - * @returns the function to generate the random point on a circle. + * Generates a random point on a circle. + * @returns the random point on a circle. */ -export function getGenerateDynamicYZPosition(): typeof getDynamicRandomYZPoints { - const prevPoints: IBlockTanglePosition[] = []; - - return (bps: number, initialPosition: Vector3 = new Vector3(0, 0, 0)): IBlockTanglePosition => { - const validPoint = generateAValidRandomPoint(bps, initialPosition, prevPoints); +export function generateRandomXYZPoints( + bps: number, + initialPosition: IThreeDimensionalPosition, + tiltDegrees: number, +): IThreeDimensionalPosition { + const tiltRad = MathUtils.degToRad(-tiltDegrees); + const opposite = SPRAY_DISTANCE * Math.sin(tiltRad); + const adjacent = SPRAY_DISTANCE * Math.cos(tiltRad); + const circumferenceCenter: ITwoDimensionalPosition = { + x: initialPosition.x - adjacent, + y: initialPosition.y + opposite, + }; - const randomYNumber = randomNumberFromInterval(0, BLOCK_STEP_PX / 20); - const randomZNumber = randomNumberFromInterval(0, BLOCK_STEP_PX / 20); + const _radius = getLinearRadius(bps); + const randomFactor = Math.random(); + const radius = _radius * randomFactor; - validPoint.y += randomYNumber; - validPoint.z += randomZNumber; + const y = circumferenceCenter.y + radius * Math.cos(radius); + const z = initialPosition.z + radius * Math.sin(radius); - return validPoint; - }; + return { x: circumferenceCenter.x, y, z }; } /** @@ -178,7 +190,7 @@ export function getTangleDistances(): { const maxXDistance = MAX_BLOCK_DISTANCE; /* Max Y Distance will be multiplied by 2 to position blocks in the negative and positive Y axis */ - const maxYDistance = MAX_TANGLE_RADIUS * 2 + MAX_SINUSOIDAL_AMPLITUDE * 2; + const maxYDistance = MAX_TANGLE_RADIUS * 2 + MAX_SINUSOID_AMPLITUDE * 2; /* TODO: add sinusoidal distances */ @@ -218,15 +230,157 @@ export function getCameraAngles(): ICameraAngles { } /** - * Calculates the sinusoidal position for the emitter + * Calculates the sinusoidal position for the emitter based on the current animation time, + * considering random periods. * @returns the sinusoidal position */ -export function getSinusoidalPosition(time: number, amplitude: number): number { - const period = HALF_WAVE_PERIOD_SECONDS * 2; - const frequency = 1 / period; - const phase = (time % period) * frequency; +export function calculateSinusoidalAmplitude({ + currentAnimationTime, + periods, + periodsSum, + sinusoidAmplitudes, +}: ISinusoidalPositionParams): number { + const elapsedTime = currentAnimationTime % periodsSum; + const { index, period, accumulatedTime } = getCurrentPeriodValues(currentAnimationTime, periods, periodsSum); + + const startTimeOfCurrentPeriod = accumulatedTime - period; + const timeInCurrentPeriod = elapsedTime - startTimeOfCurrentPeriod; + const currentAmplitude = sinusoidAmplitudes[index]; + + const yPosition = currentAmplitude * Math.sin((2 * Math.PI * timeInCurrentPeriod) / period); + + return yPosition; +} + +/** + * Calculates the emitter position based on the current animation time. + * @returns the emitter position + */ +export function calculateEmitterPositionX(currentAnimationTime: number): number { + return currentAnimationTime * EMITTER_SPEED_MULTIPLIER; +} + +/** + * Calculates the emitter position based on the current animation time. + * @returns the emitter X,Y,Z positions + */ +export function getEmitterPositions({ + currentAnimationTime, + periods, + periodsSum, + sinusoidAmplitudes, +}: ISinusoidalPositionParams): IThreeDimensionalPosition { + const x = calculateEmitterPositionX(currentAnimationTime); + const y = calculateSinusoidalAmplitude({ currentAnimationTime, periods, periodsSum, sinusoidAmplitudes }); + return { x, y, z: 0 }; +} + +/** + * Converts a position object to a Vector3 object. + * @param position - The position object to convert. + * @returns A Vector3 object representing the position. + */ +export function positionToVector(position: IThreeDimensionalPosition) { + return new Vector3(position.x, position.y, position.z); +} + +export function generateRandomPeriods(): { periods: number[]; sum: number } { + let sum = 0; + const periods = Array.from({ length: NUMBER_OF_RANDOM_PERIODS }, () => { + const period = Number(randomNumberFromInterval(MIN_SINUSOID_PERIOD, MAX_SINUSOID_PERIOD).toFixed(4)); + sum += period; + return period; + }); + return { periods, sum }; +} + +type PeriodResult = { + period: number; + accumulatedTime: number; + index: number; +}; + +function getCurrentPeriodValues(animationTime: number, periods: number[], totalSum: number): PeriodResult { + const effectiveTime = animationTime % totalSum; + + let accumulatedTime = 0; + + for (let i = 0; i < periods.length; i++) { + const period = periods[i]; + accumulatedTime += period; + if (effectiveTime < accumulatedTime) { + return { index: i, period, accumulatedTime }; + } + } - const newY = amplitude * Math.sin(phase * 2 * Math.PI); + return { index: 0, period: periods[0], accumulatedTime: 0 }; +} + +function getNextAmplitudeWithVariation(currentAmplitude: number = 0): number { + const variation = (2 * MIN_SINUSOID_AMPLITUDE) / 3; + const randomAmplitudeVariation = randomNumberFromInterval(-variation, variation); + + let newAmplitude = currentAmplitude + randomAmplitudeVariation; + + if (newAmplitude > MAX_SINUSOID_AMPLITUDE) { + newAmplitude = currentAmplitude - Math.abs(randomAmplitudeVariation); + } else if (newAmplitude < MIN_SINUSOID_AMPLITUDE) { + newAmplitude = currentAmplitude + Math.abs(randomAmplitudeVariation); + } + + newAmplitude = Math.max(MIN_SINUSOID_AMPLITUDE, Math.min(newAmplitude, MAX_SINUSOID_AMPLITUDE)); + + return newAmplitude; +} + +export function generateRandomAmplitudes(): number[] { + const amplitudes: number[] = []; + let currentAmplitude: number = 0; + for (let i = 0; i < NUMBER_OF_RANDOM_AMPLITUDES; i++) { + currentAmplitude = getNextAmplitudeWithVariation(currentAmplitude); + amplitudes.push(currentAmplitude); + } + + return amplitudes; +} + +export function generateRandomTiltings(): number[] { + let previousValue: number; + + const tilts: number[] = Array.from({ length: NUMBER_OF_RANDOM_TILTINGS }, () => { + let randomTilt = randomIntFromInterval(MIN_TILT_FACTOR_DEGREES, MAX_TILT_FACTOR_DEGREES); + + if ((previousValue < 0 && randomTilt < 0) || (previousValue > 0 && randomTilt > 0)) { + randomTilt *= -1; + } + + previousValue = randomTilt; + + return randomTilt; + }); + return tilts; +} + +export function getCurrentTiltValue(animationTime: number, tilts: number[]): number { + const tiltAnimationDuration = TILT_DURATION_SECONDS * 2; // Multiplied by 2 so it goes back to the initial position + const totalIntervalDuration = tilts.length * tiltAnimationDuration; // The total duration of the random tilts + + const currentTiltAnimationSeconds = animationTime % tiltAnimationDuration; + const currentAnimationSecondsInInterval = animationTime % totalIntervalDuration; + + const currentTiltIndex = Math.floor(currentAnimationSecondsInInterval / tiltAnimationDuration); + const tilt = tilts[currentTiltIndex]; + + // Calculate the proportion of the current animation time within the half-duration + const proportionOfHalfDuration = currentTiltAnimationSeconds / (tiltAnimationDuration / 2); + let currentTilt; + + if (currentTiltAnimationSeconds <= tiltAnimationDuration / 2) { + currentTilt = tilt * proportionOfHalfDuration; + } else { + // We subtract from 2 to reverse the effect after the peak + currentTilt = tilt * (2 - proportionOfHalfDuration); + } - return newY; + return currentTilt; } diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss index 354e27b6a..5e4521164 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss @@ -4,7 +4,7 @@ @import "../../../scss/variables"; @import "../../../scss/themes"; -.key-panel-container { +.info-container { display: flex; position: absolute; z-index: 1; @@ -13,19 +13,23 @@ left: 30px; justify-content: center; pointer-events: none; + gap: 20px; - .key-panel { + .card { background: var(--body-background); + padding: 16px 32px; + } + + .key-panel-list { display: flex; flex-direction: row; flex-wrap: wrap; - padding: 16px; + gap: 32px; .key-panel-item { display: flex; flex-direction: row; align-items: center; - margin: 0 16px; @include desktop-down { width: 110px; @@ -48,4 +52,61 @@ } } } + + .stats-panel-container { + display: flex; + z-index: 1; + align-items: center; + pointer-events: none; + + .card--label { + justify-content: flex-start; + } + .card--content { + padding: 0; + } + .stats-panel__info { + justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + } + + @include tablet-down { + top: 60px; + left: 20px; + bottom: auto; + justify-content: left; + + .stats-panel { + .card--value, + .card--label { + text-align: left; + } + .card--label { + justify-content: flex-start; + } + .card--content { + padding: 0; + } + .stats-panel__info { + padding: 0 10px; + display: inline-block; + } + } + } + + @include phone-down { + left: 10px; + .stats-panel { + .card--value, + .card--label { + font-size: 12px; + } + .stats-panel__info:last-of-type { + display: block; + } + } + } + } } diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx index f2affbbea..416743eb9 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx @@ -1,9 +1,10 @@ -import React from "react"; +import React, { memo } from "react"; import { BlockState } from "@iota/sdk-wasm-nova/web"; import "./KeyPanel.scss"; +import StatsPanel from "~features/visualizer-threejs/wrapper/StatsPanel"; -export const KeyPanel: React.FC = () => { +export const KeyPanel = ({ network }: { network: string }) => { const statuses: { label: string; state: BlockState; @@ -42,8 +43,8 @@ export const KeyPanel: React.FC = () => { ]; return ( -
-
+
+
{statuses.map((s) => { return (
@@ -58,6 +59,9 @@ export const KeyPanel: React.FC = () => { ); })}
+
); }; + +export default memo(KeyPanel); diff --git a/client/src/features/visualizer-threejs/wrapper/StatsPanel.tsx b/client/src/features/visualizer-threejs/wrapper/StatsPanel.tsx index 00e45b823..f6ac0bc01 100644 --- a/client/src/features/visualizer-threejs/wrapper/StatsPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/StatsPanel.tsx @@ -1,29 +1,18 @@ -import React from "react"; -import { useNetworkStats } from "~helpers/stardust/hooks/useNetworkStats"; - -export const StatsPanel: React.FC<{ readonly blocksCount: number; readonly network: string }> = ({ blocksCount, network }) => { - const [blocksPerSecond, confirmedBlocksPerSecond, confirmedBlocksPerSecondPercent] = useNetworkStats(network); +import React, { memo } from "react"; +import { useNetworkStats } from "~helpers/nova/hooks/useNetworkStats"; +export const StatsPanel = ({ network }: { network: string }) => { + const [blocksPerSecond] = useNetworkStats(network); return (
-
-
-
-
Blocks
-
{blocksCount}
-
-
-
BPS / CBPS
-
- {blocksPerSecond} / {confirmedBlocksPerSecond} -
-
-
-
Referenced Rate
-
{confirmedBlocksPerSecondPercent}
-
+
+
+
BPS
+
{blocksPerSecond}
); }; + +export default memo(StatsPanel); diff --git a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx index 84c8d0b31..e2796f4d7 100644 --- a/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx +++ b/client/src/features/visualizer-threejs/wrapper/Wrapper.tsx @@ -2,10 +2,9 @@ import React from "react"; import Modal from "~/app/components/Modal"; import { TSelectFeedItemNova, TSelectNode } from "~/app/types/visualizer.types"; import { INetwork } from "~/models/config/INetwork"; -import { KeyPanel } from "./KeyPanel"; +import KeyPanel from "./KeyPanel"; import mainHeader from "~assets/modals/visualizer/main-header.json"; import { SelectedFeedInfo } from "./SelectedFeedInfo"; -import { StatsPanel } from "./StatsPanel"; export const Wrapper = ({ blocksCount, @@ -68,9 +67,8 @@ export const Wrapper = ({ )}
- {selectedFeedItem && } - +
); diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index c765689d4..b360cd231 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -1,5 +1,12 @@ import { Reducer, useEffect, useReducer } from "react"; -import { AccountAddress, AccountOutput, OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { + AccountAddress, + AccountOutput, + BlockIssuerFeature, + CongestionResponse, + FeatureType, + OutputResponse, +} from "@iota/sdk-wasm-nova/web"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAccountDetails } from "./useAccountDetails"; import { useLocation, useParams } from "react-router-dom"; @@ -9,20 +16,24 @@ import { AddressHelper } from "~/helpers/nova/addressHelper"; import { useAddressBalance } from "./useAddressBalance"; import { useAddressBasicOutputs } from "~/helpers/nova/hooks/useAddressBasicOutputs"; import { useAccountControlledFoundries } from "./useAccountControlledFoundries"; +import { useAccountCongestion } from "./useAccountCongestion"; export interface IAccountAddressState { addressDetails: IAddressDetails | null; accountOutput: AccountOutput | null; totalBalance: number | null; availableBalance: number | null; + blockIssuerFeature: BlockIssuerFeature | null; addressBasicOutputs: OutputResponse[] | null; foundries: string[] | null; + congestion: CongestionResponse | null; isAccountDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; isFoundriesLoading: boolean; isAddressHistoryLoading: boolean; isAddressHistoryDisabled: boolean; + isCongestionLoading: boolean; } const initialState = { @@ -30,14 +41,17 @@ const initialState = { accountOutput: null, totalBalance: null, availableBalance: null, + blockIssuerFeature: null, addressBasicOutputs: null, foundries: null, + congestion: null, isAccountDetailsLoading: true, isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, isFoundriesLoading: false, isAddressHistoryLoading: true, isAddressHistoryDisabled: false, + isCongestionLoading: false, }; /** @@ -60,6 +74,7 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres const { totalBalance, availableBalance } = useAddressBalance(network, state.addressDetails, accountOutput); const [addressBasicOutputs, isBasicOutputsLoading] = useAddressBasicOutputs(network, state.addressDetails?.bech32 ?? null); const [foundries, isFoundriesLoading] = useAccountControlledFoundries(network, state.addressDetails); + const { congestion, isLoading: isCongestionLoading } = useAccountCongestion(network, state.addressDetails?.hex ?? null); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -74,17 +89,42 @@ export const useAccountAddressState = (address: AccountAddress): [IAccountAddres }, []); useEffect(() => { - setState({ + let updatedState: Partial = { accountOutput, isAccountDetailsLoading, totalBalance, availableBalance, foundries, + congestion, addressBasicOutputs, isBasicOutputsLoading, isFoundriesLoading, - }); - }, [accountOutput, totalBalance, availableBalance, addressBasicOutputs, isAccountDetailsLoading, isBasicOutputsLoading]); + isCongestionLoading, + }; + + if (accountOutput && !state.blockIssuerFeature) { + const blockIssuerFeature = accountOutput?.features?.find( + (feature) => feature.type === FeatureType.BlockIssuer, + ) as BlockIssuerFeature; + if (blockIssuerFeature) { + updatedState = { + ...updatedState, + blockIssuerFeature, + }; + } + } + + setState(updatedState); + }, [ + accountOutput, + totalBalance, + availableBalance, + addressBasicOutputs, + congestion, + isAccountDetailsLoading, + isBasicOutputsLoading, + isCongestionLoading, + ]); return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAccountCongestion.ts b/client/src/helpers/nova/hooks/useAccountCongestion.ts new file mode 100644 index 000000000..7ca8eafd9 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAccountCongestion.ts @@ -0,0 +1,48 @@ +import { CongestionResponse } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +/** + * Fetch account congestion + * @param network The Network in context + * @param accountId The account id + * @returns The output response and loading bool. + */ +export function useAccountCongestion( + network: string, + accountId: string | null, +): { congestion: CongestionResponse | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [congestion, setAccountCongestion] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (accountId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getAccountCongestion({ + network, + accountId, + }) + .then((response) => { + if (!response?.error && isMounted) { + setAccountCongestion(response.congestion ?? null); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, accountId]); + + return { congestion, isLoading }; +} diff --git a/client/src/helpers/nova/hooks/useNetworkStats.ts b/client/src/helpers/nova/hooks/useNetworkStats.ts new file mode 100644 index 000000000..a56a9a788 --- /dev/null +++ b/client/src/helpers/nova/hooks/useNetworkStats.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { NOVA } from "~models/config/protocolVersion"; +import { NovaApiClient } from "~services/nova/novaApiClient"; + +/** + * Periodicaly refresh network stats. + * @param network The network in context. + * @returns The network stats. + */ +export function useNetworkStats(network: string): [string] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [updateTimerId, setUpdateTimerId] = useState(null); + const [blocksPerSecond, setBlocksPerSecond] = useState("--"); + + useEffect(() => { + if (network) { + updateNetworkStats(); + } + + return () => { + if (updateTimerId) { + clearTimeout(updateTimerId); + setUpdateTimerId(null); + } + }; + }, [network]); + + const updateNetworkStats = () => { + if (isMounted && apiClient && network) { + apiClient + .stats({ + network, + includeHistory: true, + }) + .then((ips) => { + const itemsPerSecond = ips.itemsPerSecond ?? 0; + setBlocksPerSecond(itemsPerSecond >= 0 ? itemsPerSecond.toFixed(2) : "--"); + }) + .catch((err) => { + console.error(err); + }) + .finally(() => { + setUpdateTimerId(setTimeout(async () => updateNetworkStats(), 4000)); + }); + } + }; + + return [blocksPerSecond]; +} diff --git a/client/src/helpers/nova/hooks/useSlotDetails.ts b/client/src/helpers/nova/hooks/useSlotDetails.ts new file mode 100644 index 000000000..45da0620f --- /dev/null +++ b/client/src/helpers/nova/hooks/useSlotDetails.ts @@ -0,0 +1,54 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { plainToInstance } from "class-transformer"; +import { useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; + +interface IUseSlotDetails { + slotCommitment: SlotCommitment | null; + error: string | undefined; + isLoading: boolean; +} + +export default function useSlotDetails(network: string, slotIndex: string): IUseSlotDetails { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [slotCommitment, setSlotCommitment] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + setSlotCommitment(null); + if (!slotCommitment) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .getSlotCommitment({ + network, + slotIndex, + }) + .then((response) => { + if (isMounted) { + const slot = plainToInstance(SlotCommitment, response.slot) as unknown as SlotCommitment; + setSlotCommitment(slot); + setError(response.error); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, slotIndex]); + + return { + slotCommitment, + error, + isLoading, + }; +} diff --git a/client/src/helpers/nova/hooks/useSlotsFeed.ts b/client/src/helpers/nova/hooks/useSlotsFeed.ts new file mode 100644 index 000000000..fc39aa8c4 --- /dev/null +++ b/client/src/helpers/nova/hooks/useSlotsFeed.ts @@ -0,0 +1,98 @@ +import moment from "moment"; +import { useCallback, useEffect, useState } from "react"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { useIsMounted } from "~/helpers/hooks/useIsMounted"; +import { ISlotCommitmentWrapper } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { useNetworkInfoNova } from "../networkInfo"; +import { useNovaTimeConvert } from "./useNovaTimeConvert"; + +const DEFAULT_SLOT_LIMIT = 10; +const MAX_LATEST_SLOT_COMMITMENTS = 20; + +const CHECK_SLOT_INDEX_INTERVAL = 950; +const CHECK_SLOT_COMMITMENTS_INTERVAL = 3500; + +export default function useSlotsFeed(slotsLimit: number = DEFAULT_SLOT_LIMIT): { + currentSlotIndex: number | null; + currentSlotProgressPercent: number | null; + latestSlotIndexes: number[] | null; + latestSlotCommitments: ISlotCommitmentWrapper[]; +} { + const isMounted = useIsMounted(); + const { name: network } = useNetworkInfoNova((s) => s.networkInfo); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const { unixTimestampToSlotIndex, slotIndexToUnixTimeRange } = useNovaTimeConvert(); + const [currentSlotIndex, setCurrentSlotIndex] = useState(null); + const [latestSlotIndexes, setLatestSlotIndexes] = useState(null); + + const [latestSlotCommitments, setLatestSlotCommitments] = useState([]); + + const [currentSlotProgressPercent, setCurrentSlotProgressPercent] = useState(null); + + const [slotIndexCheckerHandle, setSlotIndexCheckerHandle] = useState(null); + const [slotCommitmentsCheckerHandle, setSlotCommitmentsCheckerHandle] = useState(null); + + const checkCurrentSlotIndex = () => { + if (unixTimestampToSlotIndex && slotIndexToUnixTimeRange) { + const now = moment().unix(); + const currentSlotIndex = unixTimestampToSlotIndex(now); + const slotTimeRange = slotIndexToUnixTimeRange(currentSlotIndex); + + const slotProgressPercent = Math.trunc(((now - slotTimeRange.from) / (slotTimeRange.to - 1 - slotTimeRange.from)) * 100); + + if (isMounted) { + setCurrentSlotIndex(currentSlotIndex); + setCurrentSlotProgressPercent(slotProgressPercent); + setLatestSlotIndexes(Array.from({ length: slotsLimit - 1 }, (_, i) => currentSlotIndex - 1 - i)); + } + } + }; + + const getLatestSlotCommitments = useCallback(async () => { + if (apiClient) { + const latestSlotCommitments = await apiClient.latestSlotCommitments(network); + if (isMounted && latestSlotCommitments.slotCommitments && latestSlotCommitments.slotCommitments.length > 0) { + setLatestSlotCommitments(latestSlotCommitments.slotCommitments.slice(0, MAX_LATEST_SLOT_COMMITMENTS)); + } + } + }, [network]); + + useEffect(() => { + if (slotIndexCheckerHandle === null) { + getLatestSlotCommitments(); + checkCurrentSlotIndex(); + + const slotCommitmentCheckerHandle = setInterval(() => { + getLatestSlotCommitments(); + }, CHECK_SLOT_COMMITMENTS_INTERVAL); + + const slotIndexIntervalHandle = setInterval(() => { + checkCurrentSlotIndex(); + }, CHECK_SLOT_INDEX_INTERVAL); + + setSlotCommitmentsCheckerHandle(slotCommitmentCheckerHandle); + setSlotIndexCheckerHandle(slotIndexIntervalHandle); + } + + return () => { + if (slotCommitmentsCheckerHandle) { + clearInterval(slotCommitmentsCheckerHandle); + } + + if (slotIndexCheckerHandle) { + clearInterval(slotIndexCheckerHandle); + } + + setSlotCommitmentsCheckerHandle(null); + setSlotIndexCheckerHandle(null); + setCurrentSlotIndex(null); + setCurrentSlotProgressPercent(null); + setLatestSlotIndexes(null); + setLatestSlotCommitments([]); + }; + }, []); + + return { currentSlotIndex, currentSlotProgressPercent, latestSlotIndexes, latestSlotCommitments }; +} diff --git a/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts b/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts new file mode 100644 index 000000000..646377327 --- /dev/null +++ b/client/src/helpers/nova/hooks/useTransactionIncludedBlock.ts @@ -0,0 +1,48 @@ +import { Block } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { NOVA } from "~models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { HexHelper } from "~/helpers/stardust/hexHelper"; + +/** + * Fetch transaction included block details + * @param network The Network in context + * @param transactionId The transaction id + * @returns The block, loading bool and an error string. + */ +export function useTransactionIncludedBlock(network: string, transactionId: string | null): [Block | null, boolean, string?] { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [block, setBlock] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + if (transactionId) { + // eslint-disable-next-line no-void + void (async () => { + apiClient + .transactionIncludedBlockDetails({ + network, + transactionId: HexHelper.addPrefix(transactionId), + }) + .then((response) => { + if (isMounted) { + setBlock(response.block ?? null); + setError(response.error); + } + }) + .finally(() => { + setIsLoading(false); + }); + })(); + } else { + setIsLoading(false); + } + }, [network, transactionId]); + + return [block, isLoading, error]; +} diff --git a/client/src/helpers/nova/hooks/useVisualizerTimer.ts b/client/src/helpers/nova/hooks/useVisualizerTimer.ts new file mode 100644 index 000000000..1153ba831 --- /dev/null +++ b/client/src/helpers/nova/hooks/useVisualizerTimer.ts @@ -0,0 +1,16 @@ +import { useConfigStore } from "~/features/visualizer-threejs/store"; + +export default function useVisualizerTimer() { + const initialTime = useConfigStore((state) => state.initialTime); + + return () => { + if (!initialTime) { + return 0; + } + + const currentTime = Date.now(); + const diff = (currentTime - initialTime) / 1_000; + + return diff; + }; +} diff --git a/client/src/helpers/nova/manaUtils.ts b/client/src/helpers/nova/manaUtils.ts index 255c013ea..06f29cd44 100644 --- a/client/src/helpers/nova/manaUtils.ts +++ b/client/src/helpers/nova/manaUtils.ts @@ -1,4 +1,5 @@ import { BasicOutput, ManaRewardsResponse, Output, ProtocolParameters, Utils } from "@iota/sdk-wasm-nova/web"; +import { IKeyValueEntries } from "~/app/lib/interfaces"; export interface OutputManaDetails { storedMana: string; @@ -33,3 +34,31 @@ export function buildManaDetailsForOutput( totalMana: totalMana.toString(), }; } + +export function getManaKeyValueEntries(manaDetails: OutputManaDetails | null): IKeyValueEntries { + const showDecayMana = manaDetails?.storedMana && manaDetails?.storedManaDecayed; + const decay = showDecayMana ? Number(manaDetails?.storedMana ?? 0) - Number(manaDetails?.storedManaDecayed ?? 0) : undefined; + + return { + label: "Mana:", + value: manaDetails?.totalMana, + entries: [ + { + label: "Stored:", + value: manaDetails?.storedMana, + }, + { + label: "Decay:", + value: decay, + }, + { + label: "Potential:", + value: manaDetails?.potentialMana, + }, + { + label: "Delegation Rewards:", + value: manaDetails?.delegationRewards, + }, + ], + }; +} diff --git a/client/src/helpers/stardust/hooks/useAddressHistory.ts b/client/src/helpers/stardust/hooks/useAddressHistory.ts index 79325e559..1a8601932 100644 --- a/client/src/helpers/stardust/hooks/useAddressHistory.ts +++ b/client/src/helpers/stardust/hooks/useAddressHistory.ts @@ -101,9 +101,13 @@ export function useAddressHistory( const { outputs, cursor: newCursor } = await requestOutputsList(cursor); if (!newCursor) { - setDisabled?.(true); + // Note: newCursor can be null if there are no more pages, and undefined if there are no results searchMore = false; } + if (newCursor === undefined) { + // hide the tab only if there are no results + setDisabled?.(true); + } const fulfilledOutputs: OutputWithDetails[] = await Promise.all( outputs.map(async (output) => { diff --git a/client/src/models/api/nova/ICongestionRequest.ts b/client/src/models/api/nova/ICongestionRequest.ts new file mode 100644 index 000000000..00e845db6 --- /dev/null +++ b/client/src/models/api/nova/ICongestionRequest.ts @@ -0,0 +1,11 @@ +export interface ICongestionRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The account id to get the congestion for. + */ + accountId: string; +} diff --git a/client/src/models/api/nova/ICongestionResponse.ts b/client/src/models/api/nova/ICongestionResponse.ts new file mode 100644 index 000000000..cb176a6f3 --- /dev/null +++ b/client/src/models/api/nova/ICongestionResponse.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { CongestionResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface ICongestionResponse extends IResponse { + /** + * The Account Congestion. + */ + congestion?: CongestionResponse; +} diff --git a/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts b/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts new file mode 100644 index 000000000..68f04a973 --- /dev/null +++ b/client/src/models/api/nova/ILatestSlotCommitmentsResponse.ts @@ -0,0 +1,16 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export enum SlotCommitmentStatus { + Committed = "committed", + Finalized = "finalized", +} + +export interface ISlotCommitmentWrapper { + status: SlotCommitmentStatus; + slotCommitment: SlotCommitment; +} + +export interface ILatestSlotCommitmentResponse extends IResponse { + slotCommitments: ISlotCommitmentWrapper[]; +} diff --git a/client/src/models/api/nova/ISearchResponse.ts b/client/src/models/api/nova/ISearchResponse.ts index e84fda3d7..220d64bf9 100644 --- a/client/src/models/api/nova/ISearchResponse.ts +++ b/client/src/models/api/nova/ISearchResponse.ts @@ -57,4 +57,9 @@ export interface ISearchResponse extends IResponse { * Nft details. */ nftDetails?: OutputResponse; + + /** + * Transaction included block. + */ + transactionBlock?: Block; } diff --git a/client/src/models/api/nova/ISlotRequest.ts b/client/src/models/api/nova/ISlotRequest.ts new file mode 100644 index 000000000..b00482593 --- /dev/null +++ b/client/src/models/api/nova/ISlotRequest.ts @@ -0,0 +1,11 @@ +export interface ISlotRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The slot index to get the commitment for. + */ + slotIndex: string; +} diff --git a/client/src/models/api/nova/ISlotResponse.ts b/client/src/models/api/nova/ISlotResponse.ts new file mode 100644 index 000000000..dc7e83cb4 --- /dev/null +++ b/client/src/models/api/nova/ISlotResponse.ts @@ -0,0 +1,6 @@ +import { SlotCommitment } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "../IResponse"; + +export interface ISlotResponse extends IResponse { + slot: SlotCommitment; +} diff --git a/client/src/models/api/nova/ITransactionDetailsRequest.ts b/client/src/models/api/nova/ITransactionDetailsRequest.ts new file mode 100644 index 000000000..d5b298039 --- /dev/null +++ b/client/src/models/api/nova/ITransactionDetailsRequest.ts @@ -0,0 +1,11 @@ +export interface ITransactionDetailsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The transaction id to get the details for. + */ + transactionId: string; +} diff --git a/client/src/models/api/nova/ITransactionDetailsResponse.ts b/client/src/models/api/nova/ITransactionDetailsResponse.ts new file mode 100644 index 000000000..6e672eaeb --- /dev/null +++ b/client/src/models/api/nova/ITransactionDetailsResponse.ts @@ -0,0 +1,9 @@ +import { Block } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; + +export interface ITransactionDetailsResponse extends IResponse { + /** + * The transaction included block. + */ + block?: Block; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index d430ccd0b..14c4c699b 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -32,6 +32,13 @@ import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; import { ITransactionHistoryRequest } from "~/models/api/nova/ITransactionHistoryRequest"; import { ITransactionHistoryResponse } from "~/models/api/nova/ITransactionHistoryResponse"; import { FetchHelper } from "~/helpers/fetchHelper"; +import { ISlotRequest } from "~/models/api/nova/ISlotRequest"; +import { ISlotResponse } from "~/models/api/nova/ISlotResponse"; +import { ITransactionDetailsRequest } from "~/models/api/nova/ITransactionDetailsRequest"; +import { ITransactionDetailsResponse } from "~/models/api/nova/ITransactionDetailsResponse"; +import { ICongestionRequest } from "~/models/api/nova/ICongestionRequest"; +import { ICongestionResponse } from "~/models/api/nova/ICongestionResponse"; +import { ILatestSlotCommitmentResponse } from "~/models/api/nova/ILatestSlotCommitmentsResponse"; /** * Class to handle api communications on nova. @@ -73,6 +80,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/block/metadata/${request.network}/${request.blockId}`, "get"); } + /** + * Get the transaction included block. + * @param request The request to send. + * @returns The response from the request. + */ + public async transactionIncludedBlockDetails(request: ITransactionDetailsRequest): Promise { + return this.callApi(`nova/transaction/${request.network}/${request.transactionId}`, "get"); + } + /** * Get the output details. * @param request The request to send. @@ -149,6 +165,24 @@ export class NovaApiClient extends ApiClient { ); } + /** + * Get the latest slot commitments. + * @param network The network in context. + * @returns The latest slot commitments response. + */ + public async latestSlotCommitments(network: string): Promise { + return this.callApi(`nova/commitment/latest/${network}`, "get"); + } + + /** + * Get the account congestion. + * @param request The request to send. + * @returns The response from the request. + */ + public async getAccountCongestion(request: ICongestionRequest): Promise { + return this.callApi(`nova/account/congestion/${request.network}/${request.accountId}`, "get"); + } + /** * Get the output mana rewards. * @param request The request to send. @@ -158,6 +192,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`nova/output/rewards/${request.network}/${request.outputId}`, "get"); } + /** + * Get the slot commitment. + * @param request The request to send. + * @returns The response from the request. + */ + public async getSlotCommitment(request: ISlotRequest): Promise { + return this.callApi(`nova/slot/${request.network}/${request.slotIndex}`, "get"); + } + /** * Get the stats. * @param request The request to send. diff --git a/package.json b/package.json index 01a2f33d8..8b923b782 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "explorer", "description": "Tangle Explorer", - "version": "3.3.4-rc.1", + "version": "3.3.4", "scripts": { "setup:client": "cd client && npm install && npm run postinstall", "setup:api": "cd api && npm install && npm run build-compile && npm run build-config", diff --git a/setup_nova.sh b/setup_nova.sh index 149b19584..b69fcd007 100755 --- a/setup_nova.sh +++ b/setup_nova.sh @@ -1,6 +1,6 @@ #!/bin/bash SDK_DIR="iota-sdk" -TARGET_COMMIT="257bcff80bf0336f571f9a226ebde1acd8974104" +TARGET_COMMIT="fc9f0f56bb5cfc146993e53aa9656ded220734e1" if [ ! -d "$SDK_DIR" ]; then git clone -b 2.0 git@github.com:iotaledger/iota-sdk.git @@ -35,5 +35,3 @@ rm package.json.bak echo "Building wasm bindings" yarn yarn build - -