diff --git a/configs/app/apis.ts b/configs/app/apis.ts index 60144e5a81..027f77a31a 100644 --- a/configs/app/apis.ts +++ b/configs/app/apis.ts @@ -100,26 +100,6 @@ const rewardsApi = (() => { endpoint: apiHost, }); })(); - -const multichainApi = (() => { - const apiHost = getEnvValue('NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST'); - if (!apiHost) { - return; - } - - try { - const url = new URL(apiHost); - - return Object.freeze({ - endpoint: apiHost, - socketEndpoint: `wss://${ url.host }`, - }); - } catch (error) { - return; - } - -})(); - const statsApi = (() => { const apiHost = getEnvValue('NEXT_PUBLIC_STATS_API_HOST'); if (!apiHost) { @@ -166,6 +146,35 @@ const visualizeApi = (() => { }); })(); +const clustersApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_CLUSTERS_API_HOST'); + if (!apiHost) { + return; + } + + return Object.freeze({ + endpoint: apiHost, + }); +})(); + +const multichainApi = (() => { + const apiHost = getEnvValue('NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST'); + if (!apiHost) { + return; + } + + try { + const url = new URL(apiHost); + + return Object.freeze({ + endpoint: apiHost, + socketEndpoint: `wss://${ url.host }`, + }); + } catch (error) { + return; + } +})(); + export type Apis = { general: ApiPropsFull; } & Partial, ApiPropsBase>>; @@ -174,6 +183,7 @@ const apis: Apis = Object.freeze({ general: generalApi, admin: adminApi, bens: bensApi, + clusters: clustersApi, contractInfo: contractInfoApi, metadata: metadataApi, multichain: multichainApi, diff --git a/configs/app/features/clusters.ts b/configs/app/features/clusters.ts new file mode 100644 index 0000000000..1e588860fa --- /dev/null +++ b/configs/app/features/clusters.ts @@ -0,0 +1,26 @@ +import type { Feature } from './types'; + +import apis from '../apis'; +import { getEnvValue } from '../utils'; + +const title = 'Clusters Universal Name Service'; + +const config: Feature<{ cdnUrl: string }> = (() => { + const cdnUrl = getEnvValue('NEXT_PUBLIC_CLUSTERS_CDN_URL') || 'https://cdn.clusters.xyz'; + + if (apis.clusters) { + return Object.freeze({ + title, + isEnabled: true, + cdnUrl, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + cdnUrl, + }); +})(); + +export default config; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index 9f3d936821..fb966796f0 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -10,6 +10,7 @@ export { default as beaconChain } from './beaconChain'; export { default as bridgedTokens } from './bridgedTokens'; export { default as blockchainInteraction } from './blockchainInteraction'; export { default as celo } from './celo'; +export { default as clusters } from './clusters'; export { default as csvExport } from './csvExport'; export { default as dataAvailability } from './dataAvailability'; export { default as deFiDropdown } from './deFiDropdown'; diff --git a/configs/envs/.env.jest b/configs/envs/.env.jest index abe2107a80..d59bd3d1a8 100644 --- a/configs/envs/.env.jest +++ b/configs/envs/.env.jest @@ -50,3 +50,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004 NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005 NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006 NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx + +# clusters feature +NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz +NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz \ No newline at end of file diff --git a/configs/envs/.env.localhost b/configs/envs/.env.localhost index 3956c0d11c..b1c60c2f34 100644 --- a/configs/envs/.env.localhost +++ b/configs/envs/.env.localhost @@ -37,3 +37,7 @@ NEXT_PUBLIC_APP_ENV=development NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true NEXT_PUBLIC_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout + +# clusters feature +NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz +NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz \ No newline at end of file diff --git a/configs/envs/.env.pw b/configs/envs/.env.pw index 9e327fa31b..78785ffcea 100644 --- a/configs/envs/.env.pw +++ b/configs/envs/.env.pw @@ -60,4 +60,6 @@ NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32'] NEXT_PUBLIC_VIEWS_ADDRESS_BECH_32_PREFIX=tom NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED=false -NEXT_PUBLIC_GAS_TRACKER_UNITS=['usd','gwei'] \ No newline at end of file +NEXT_PUBLIC_GAS_TRACKER_UNITS=['usd','gwei'] +NEXT_PUBLIC_CLUSTERS_API_HOST=https://api.clusters.xyz +NEXT_PUBLIC_CLUSTERS_CDN_URL=https://cdn.clusters.xyz \ No newline at end of file diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 2d359ef044..184f3686b7 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -445,7 +445,7 @@ const rollupSchema = yup }), }); -const celoSchema = yup + const celoSchema = yup .object() .shape({ NEXT_PUBLIC_CELO_ENABLED: yup.boolean(), @@ -1050,6 +1050,8 @@ const schema = yup NEXT_PUBLIC_VISUALIZE_API_BASE_PATH: yup.string(), NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest), + NEXT_PUBLIC_CLUSTERS_API_HOST: yup.string().test(urlTest), + NEXT_PUBLIC_CLUSTERS_CDN_URL: yup.string().test(urlTest), NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_WEB3_WALLETS: yup .mixed() diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index ecc6d34aca..a66a716704 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -79,6 +79,8 @@ frontend: PROMETHEUS_METRICS_ENABLED: true NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED: true + NEXT_PUBLIC_CLUSTERS_API_HOST: https://api.clusters.xyz + NEXT_PUBLIC_CLUSTERS_CDN_URL: https://cdn.clusters.xyz envFromSecret: NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID diff --git a/docs/ENVS.md b/docs/ENVS.md index 448a64594d..cc29640a5d 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -56,6 +56,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d - [Verified tokens info](#verified-tokens-info) - [Name service integration](#name-service-integration) - [Metadata service integration](#metadata-service-integration) + - [Clusters Universal Name Service](#clusters-universal-name-service) - [Public tag submission](#public-tag-submission) - [Data availability](#data-availability) - [Bridged tokens](#bridged-tokens) @@ -672,6 +673,17 @@ This feature allows name tags and other public tags for addresses.   +### Clusters Universal Name Service + +This feature integrates Clusters.xyz universal naming service, enabling users to look up and track cross-chain identities through human-readable names like "vitalik/" or "uniswap/". Unlike traditional domain services that work on single chains, clusters span multiple blockchains - one cluster name can represent addresses on Ethereum, Base, Optimism, and other networks. This integration adds cluster lookup pages (/clusters/[name]), a clusters directory (/clusters), search functionality in the main search bar, and displays cluster profile information and images throughout the explorer. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | +| --- | --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_CLUSTERS_API_HOST | `string` | Clusters.xyz API endpoint for fetching cluster data, directory listings, and cross-chain address mappings | Required | - | `https://example.com/clusters-api` | v2.2.0+ | +| NEXT_PUBLIC_CLUSTERS_CDN_URL | `string` | CDN base URL for serving cluster profile images and avatars displayed in search results and cluster pages | - | `https://cdn.clusters.xyz` | `https://your-cdn.example.com` | v2.2.0+ | + +  + ### Public tag submission This feature allows you to submit an application with a public address tag. diff --git a/icons/clusters.svg b/icons/clusters.svg new file mode 100644 index 0000000000..b82f717b18 --- /dev/null +++ b/icons/clusters.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/jest.config.ts b/jest.config.ts index d718d56a7b..5450f44495 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,6 +15,9 @@ const config: JestConfigWithTsJest = { ], moduleNameMapper: { '^jest/(.*)': '/jest/$1', + '^nextjs-routes$': '/jest/mocks/nextjs-routes.js', + '\\.svg$': '/jest/mocks/svg.js', + '^@uidotdev/usehooks$': '/jest/mocks/usehooks.js', }, modulePathIgnorePatterns: [ 'node_modules_linux', diff --git a/jest/mocks/nextjs-routes.js b/jest/mocks/nextjs-routes.js new file mode 100644 index 0000000000..fe73627563 --- /dev/null +++ b/jest/mocks/nextjs-routes.js @@ -0,0 +1,18 @@ +module.exports = { + route: jest.fn((opts) => { + const pathname = opts?.pathname; + const query = opts?.query || {}; + + if (pathname === '/address/[hash]') { + return `/address/${ query.hash || 'test-hash' }`; + } + if (pathname === '/tx/[hash]') { + return `/tx/${ query.hash || 'test-hash' }`; + } + if (pathname === '/clusters/[name]') { + return `/clusters/${ query.name || 'test-cluster' }`; + } + + return pathname || '/'; + }), +}; diff --git a/jest/mocks/svg.js b/jest/mocks/svg.js new file mode 100644 index 0000000000..86059f3629 --- /dev/null +++ b/jest/mocks/svg.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/jest/mocks/usehooks.js b/jest/mocks/usehooks.js new file mode 100644 index 0000000000..016c3c47bf --- /dev/null +++ b/jest/mocks/usehooks.js @@ -0,0 +1,12 @@ +module.exports = { + useClickAway: jest.fn(() => jest.fn()), + useEventListener: jest.fn(), + useLocalStorage: jest.fn(() => [ '', jest.fn() ]), + useSessionStorage: jest.fn(() => [ '', jest.fn() ]), + useToggle: jest.fn(() => [ false, jest.fn() ]), + useDebounce: jest.fn((value) => value), + useThrottle: jest.fn((value) => value), + usePrevious: jest.fn(), + useCounter: jest.fn(() => ({ count: 0, increment: jest.fn(), decrement: jest.fn(), reset: jest.fn() })), + useCopyToClipboard: jest.fn(() => [ '', jest.fn() ]), +}; diff --git a/lib/address/isEvmAddress.ts b/lib/address/isEvmAddress.ts new file mode 100644 index 0000000000..83db807b0b --- /dev/null +++ b/lib/address/isEvmAddress.ts @@ -0,0 +1,6 @@ +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; + +export function isEvmAddress(address: string): boolean { + if (!address) return false; + return ADDRESS_REGEXP.test(address.trim()); +} diff --git a/lib/api/resources.ts b/lib/api/resources.ts index e8071de1a7..c728eebbc8 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -4,6 +4,8 @@ import type { AdminApiResourceName, AdminApiResourcePayload } from './services/a import { ADMIN_API_RESOURCES } from './services/admin'; import { BENS_API_RESOURCES } from './services/bens'; import type { BensApiResourceName, BensApiResourcePayload, BensApiPaginationFilters, BensApiPaginationSorting } from './services/bens'; +import { CLUSTERS_API_RESOURCES } from './services/clusters'; +import type { ClustersApiResourceName, ClustersApiResourcePayload, ClustersApiPaginationFilters, ClustersApiPaginationSorting } from './services/clusters'; import { CONTRACT_INFO_API_RESOURCES } from './services/contractInfo'; import type { ContractInfoApiPaginationFilters, ContractInfoApiResourceName, ContractInfoApiResourcePayload } from './services/contractInfo'; import { GENERAL_API_RESOURCES } from './services/general'; @@ -30,6 +32,7 @@ import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './se export const RESOURCES = { admin: ADMIN_API_RESOURCES, bens: BENS_API_RESOURCES, + clusters: CLUSTERS_API_RESOURCES, contractInfo: CONTRACT_INFO_API_RESOURCES, general: GENERAL_API_RESOURCES, metadata: METADATA_API_RESOURCES, @@ -53,6 +56,7 @@ export type ResourcePath = string; export type ResourcePayload = R extends AdminApiResourceName ? AdminApiResourcePayload : R extends BensApiResourceName ? BensApiResourcePayload : +R extends ClustersApiResourceName ? ClustersApiResourcePayload : R extends ContractInfoApiResourceName ? ContractInfoApiResourcePayload : R extends GeneralApiResourceName ? GeneralApiResourcePayload : R extends MetadataApiResourceName ? MetadataApiResourcePayload : @@ -89,6 +93,7 @@ export type ResourceErrorAccount = ResourceError<{ errors: T }>; /* eslint-disable @stylistic/indent */ export type PaginationFilters = R extends BensApiResourceName ? BensApiPaginationFilters : +R extends ClustersApiResourceName ? ClustersApiPaginationFilters : R extends GeneralApiResourceName ? GeneralApiPaginationFilters : R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters : R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters : @@ -100,6 +105,7 @@ export const SORTING_FIELDS = [ 'sort', 'order' ]; /* eslint-disable @stylistic/indent */ export type PaginationSorting = R extends BensApiResourceName ? BensApiPaginationSorting : +R extends ClustersApiResourceName ? ClustersApiPaginationSorting : R extends GeneralApiResourceName ? GeneralApiPaginationSorting : never; /* eslint-enable @stylistic/indent */ diff --git a/lib/api/services/clusters.ts b/lib/api/services/clusters.ts new file mode 100644 index 0000000000..738018dade --- /dev/null +++ b/lib/api/services/clusters.ts @@ -0,0 +1,57 @@ +import type { ApiResource } from '../types'; +import type { + ClustersByAddressResponse, + ClusterByNameResponse, + ClustersLeaderboardResponse, + ClustersDirectoryResponse, + ClustersByAddressQueryParams, + ClusterByNameQueryParams, + ClustersLeaderboardQueryParams, + ClustersDirectoryQueryParams, + ClusterByIdQueryParams, + ClusterByIdResponse, +} from 'types/api/clusters'; + +export const CLUSTERS_API_RESOURCES = { + get_clusters_by_address: { + path: '/v1/trpc/names.getNamesByOwnerAddress', + pathParams: [], + }, + get_cluster_by_name: { + path: '/v1/trpc/names.get', + pathParams: [], + }, + get_cluster_by_id: { + path: '/v1/trpc/clusters.getClusterById', + pathParams: [], + }, + get_leaderboard: { + path: '/v1/trpc/names.leaderboard', + pathParams: [], + }, + get_directory: { + path: '/v1/trpc/names.search', + pathParams: [], + }, +} satisfies Record; + +export type ClustersApiResourceName = `clusters:${ keyof typeof CLUSTERS_API_RESOURCES }`; + +export type ClustersApiResourcePayload = + R extends 'clusters:get_clusters_by_address' ? ClustersByAddressResponse : + R extends 'clusters:get_cluster_by_name' ? ClusterByNameResponse : + R extends 'clusters:get_cluster_by_id' ? ClusterByIdResponse : + R extends 'clusters:get_leaderboard' ? ClustersLeaderboardResponse : + R extends 'clusters:get_directory' ? ClustersDirectoryResponse : + never; + +export type ClustersApiQueryParams = + R extends 'clusters:get_clusters_by_address' ? ClustersByAddressQueryParams : + R extends 'clusters:get_cluster_by_name' ? ClusterByNameQueryParams : + R extends 'clusters:get_cluster_by_id' ? ClusterByIdQueryParams : + R extends 'clusters:get_leaderboard' ? ClustersLeaderboardQueryParams : + R extends 'clusters:get_directory' ? ClustersDirectoryQueryParams : + never; + +export type ClustersApiPaginationFilters = never; +export type ClustersApiPaginationSorting = never; diff --git a/lib/api/types.ts b/lib/api/types.ts index dcee835b85..f6f6e5d3b4 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -1,4 +1,7 @@ -export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | 'userOps' | 'visualize'; +export type ApiName = +'general' | 'admin' | 'bens' | 'contractInfo' | 'clusters' | +'metadata' | 'multichain' | 'rewards' | 'stats' | 'tac' | +'userOps' | 'visualize'; export interface ApiResource { path: string; diff --git a/lib/clusters/actionBarUtils.test.ts b/lib/clusters/actionBarUtils.test.ts new file mode 100644 index 0000000000..ebe25693d0 --- /dev/null +++ b/lib/clusters/actionBarUtils.test.ts @@ -0,0 +1,63 @@ +import { + shouldShowClearButton, + shouldDisableViewToggle, + getSearchPlaceholder, + shouldShowActionBar, +} from './actionBarUtils'; + +describe('actionBarUtils', () => { + describe('shouldShowClearButton', () => { + it('should return true for non-empty search values', () => { + expect(shouldShowClearButton('test')).toBe(true); + expect(shouldShowClearButton('a')).toBe(true); + expect(shouldShowClearButton('cluster-name')).toBe(true); + }); + + it('should return false for empty search values', () => { + expect(shouldShowClearButton('')).toBe(false); + }); + + it('should return true for whitespace (button should be visible)', () => { + expect(shouldShowClearButton(' ')).toBe(true); + expect(shouldShowClearButton(' ')).toBe(true); + }); + }); + + describe('shouldDisableViewToggle', () => { + it('should return true when loading', () => { + expect(shouldDisableViewToggle(true)).toBe(true); + }); + + it('should return false when not loading', () => { + expect(shouldDisableViewToggle(false)).toBe(false); + }); + }); + + describe('getSearchPlaceholder', () => { + it('should return consistent placeholder text', () => { + const placeholder = getSearchPlaceholder(); + expect(placeholder).toBe('Search clusters by name or EVM address'); + }); + + it('should return same result on multiple calls', () => { + const first = getSearchPlaceholder(); + const second = getSearchPlaceholder(); + expect(first).toBe(second); + }); + }); + + describe('shouldShowActionBar', () => { + it('should return true on desktop regardless of pagination', () => { + expect(shouldShowActionBar(false, true)).toBe(true); + expect(shouldShowActionBar(true, true)).toBe(true); + }); + + it('should return true on mobile when pagination is visible', () => { + expect(shouldShowActionBar(true, false)).toBe(true); + }); + + it('should return false on mobile when pagination is not visible', () => { + expect(shouldShowActionBar(false, false)).toBe(false); + }); + }); +}); diff --git a/lib/clusters/actionBarUtils.ts b/lib/clusters/actionBarUtils.ts new file mode 100644 index 0000000000..f5cf63771d --- /dev/null +++ b/lib/clusters/actionBarUtils.ts @@ -0,0 +1,15 @@ +export function shouldShowClearButton(searchValue: string): boolean { + return searchValue.length > 0; +} + +export function shouldDisableViewToggle(isLoading: boolean): boolean { + return isLoading; +} + +export function getSearchPlaceholder(): string { + return 'Search clusters by name or EVM address'; +} + +export function shouldShowActionBar(paginationVisible: boolean, isDesktop: boolean): boolean { + return isDesktop || paginationVisible; +} diff --git a/lib/clusters/clustersUtils.test.ts b/lib/clusters/clustersUtils.test.ts new file mode 100644 index 0000000000..59c02c50e0 --- /dev/null +++ b/lib/clusters/clustersUtils.test.ts @@ -0,0 +1,204 @@ +import { + filterOwnedClusters, + getTotalRecordsDisplay, + getClusterLabel, + getClustersToShow, + getGridRows, + hasMoreClusters, + type ClusterData, +} from './clustersUtils'; + +describe('clustersUtils', () => { + const mockClusters: Array = [ + { + name: 'cluster1', + owner: '0x1234567890123456789012345678901234567890', + totalWeiAmount: '1000000000000000000', + createdAt: '2023-01-01', + updatedAt: '2023-01-01', + updatedBy: 'user1', + isTestnet: false, + clusterId: 'id1', + expiresAt: null, + }, + { + name: 'cluster2', + owner: '0xABCDEF1234567890123456789012345678901234', + totalWeiAmount: '2000000000000000000', + createdAt: '2023-01-02', + updatedAt: '2023-01-02', + updatedBy: 'user2', + isTestnet: false, + clusterId: 'id2', + expiresAt: null, + }, + { + name: 'cluster3', + owner: '0x1234567890123456789012345678901234567890', + totalWeiAmount: '3000000000000000000', + createdAt: '2023-01-03', + updatedAt: '2023-01-03', + updatedBy: 'user3', + isTestnet: false, + clusterId: 'id3', + expiresAt: null, + }, + ]; + + describe('filterOwnedClusters', () => { + it('should filter clusters by owner address', () => { + const result = filterOwnedClusters(mockClusters, '0x1234567890123456789012345678901234567890'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('cluster1'); + expect(result[1].name).toBe('cluster3'); + }); + + it('should handle case insensitive address matching', () => { + const result = filterOwnedClusters(mockClusters, '0x1234567890123456789012345678901234567890'.toUpperCase()); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('cluster1'); + expect(result[1].name).toBe('cluster3'); + }); + + it('should return empty array for non-matching address', () => { + const result = filterOwnedClusters(mockClusters, '0x9999999999999999999999999999999999999999'); + + expect(result).toHaveLength(0); + }); + + it('should filter out clusters without owner', () => { + const clustersWithoutOwner = [ + ...mockClusters, + { + name: 'cluster4', + owner: null as unknown as string, + totalWeiAmount: '4000000000000000000', + createdAt: '2023-01-04', + updatedAt: '2023-01-04', + updatedBy: 'user4', + isTestnet: false, + clusterId: 'id4', + expiresAt: null, + }, + ]; + + const result = filterOwnedClusters(clustersWithoutOwner, '0x1234567890123456789012345678901234567890'); + + expect(result).toHaveLength(2); + }); + }); + + describe('getTotalRecordsDisplay', () => { + it('should return exact count for numbers 40 and below', () => { + expect(getTotalRecordsDisplay(1)).toBe('1'); + expect(getTotalRecordsDisplay(10)).toBe('10'); + expect(getTotalRecordsDisplay(40)).toBe('40'); + }); + + it('should return "40+" for numbers above 40', () => { + expect(getTotalRecordsDisplay(41)).toBe('40+'); + expect(getTotalRecordsDisplay(100)).toBe('40+'); + expect(getTotalRecordsDisplay(999)).toBe('40+'); + }); + + it('should handle edge case of 0', () => { + expect(getTotalRecordsDisplay(0)).toBe('0'); + }); + }); + + describe('getClusterLabel', () => { + it('should return singular for count of 1', () => { + expect(getClusterLabel(1)).toBe('Cluster'); + }); + + it('should return plural for count greater than 1', () => { + expect(getClusterLabel(2)).toBe('Clusters'); + expect(getClusterLabel(10)).toBe('Clusters'); + expect(getClusterLabel(100)).toBe('Clusters'); + }); + + it('should return singular for count of 0', () => { + expect(getClusterLabel(0)).toBe('Cluster'); + }); + }); + + describe('getClustersToShow', () => { + it('should return first 10 items by default', () => { + const manyClusters = Array(15).fill(null).map((_, i) => ({ + ...mockClusters[0], + name: `cluster${ i }`, + clusterId: `id${ i }`, + })); + + const result = getClustersToShow(manyClusters); + + expect(result).toHaveLength(10); + expect(result[0].name).toBe('cluster0'); + expect(result[9].name).toBe('cluster9'); + }); + + it('should respect custom maxItems parameter', () => { + const result = getClustersToShow(mockClusters, 2); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('cluster1'); + expect(result[1].name).toBe('cluster2'); + }); + + it('should return all items if fewer than maxItems', () => { + const result = getClustersToShow(mockClusters, 10); + + expect(result).toHaveLength(3); + expect(result).toEqual(mockClusters); + }); + + it('should handle empty array', () => { + const result = getClustersToShow([]); + + expect(result).toHaveLength(0); + }); + }); + + describe('getGridRows', () => { + it('should return the item count if less than maxRows', () => { + expect(getGridRows(3)).toBe(3); + expect(getGridRows(1)).toBe(1); + }); + + it('should return maxRows if item count exceeds it', () => { + expect(getGridRows(10)).toBe(5); + expect(getGridRows(20)).toBe(5); + }); + + it('should respect custom maxRows parameter', () => { + expect(getGridRows(10, 3)).toBe(3); + expect(getGridRows(2, 3)).toBe(2); + }); + + it('should handle edge case of 0 items', () => { + expect(getGridRows(0)).toBe(0); + }); + }); + + describe('hasMoreClusters', () => { + it('should return true when total count exceeds display count', () => { + expect(hasMoreClusters(15, 10)).toBe(true); + expect(hasMoreClusters(11, 10)).toBe(true); + }); + + it('should return false when total count equals display count', () => { + expect(hasMoreClusters(10, 10)).toBe(false); + }); + + it('should return false when total count is less than display count', () => { + expect(hasMoreClusters(5, 10)).toBe(false); + }); + + it('should handle edge cases', () => { + expect(hasMoreClusters(0, 0)).toBe(false); + expect(hasMoreClusters(1, 0)).toBe(true); + }); + }); +}); diff --git a/lib/clusters/clustersUtils.ts b/lib/clusters/clustersUtils.ts new file mode 100644 index 0000000000..d750724655 --- /dev/null +++ b/lib/clusters/clustersUtils.ts @@ -0,0 +1,29 @@ +import type { ClustersByAddressResponse } from 'types/api/clusters'; + +export type ClusterData = ClustersByAddressResponse['result']['data'][0]; + +export function filterOwnedClusters(clusters: Array, ownerAddress: string): Array { + return clusters.filter((cluster) => + cluster.owner && cluster.owner.toLowerCase() === ownerAddress.toLowerCase(), + ); +} + +export function getTotalRecordsDisplay(count: number): string { + return count > 40 ? '40+' : count.toString(); +} + +export function getClusterLabel(count: number): string { + return count > 1 ? 'Clusters' : 'Cluster'; +} + +export function getClustersToShow(clusters: Array, maxItems: number = 10): Array { + return clusters.slice(0, maxItems); +} + +export function getGridRows(itemCount: number, maxRows: number = 5): number { + return Math.min(itemCount, maxRows); +} + +export function hasMoreClusters(totalCount: number, displayCount: number): boolean { + return totalCount > displayCount; +} diff --git a/lib/clusters/detectInputType.test.ts b/lib/clusters/detectInputType.test.ts new file mode 100644 index 0000000000..a9716ed8dd --- /dev/null +++ b/lib/clusters/detectInputType.test.ts @@ -0,0 +1,39 @@ +import { isEvmAddress } from 'lib/address/isEvmAddress'; + +import { detectInputType } from './detectInputType'; + +describe('detectInputType', () => { + it('should detect EVM address format', () => { + expect(detectInputType('0x1234567890123456789012345678901234567890')).toBe('address'); + }); + + it('should detect cluster name format', () => { + expect(detectInputType('test-cluster')).toBe('cluster_name'); + }); +}); + +describe('isEvmAddress', () => { + it('should return true for valid EVM address', () => { + expect(isEvmAddress('0x1234567890123456789012345678901234567890')).toBe(true); + expect(isEvmAddress('0xabcdef1234567890123456789012345678901234')).toBe(true); + expect(isEvmAddress('0xABCDEF1234567890123456789012345678901234')).toBe(true); + }); + + it('should return false for invalid EVM address', () => { + expect(isEvmAddress('0x123')).toBe(false); + expect(isEvmAddress('123456789012345678901234567890123456789')).toBe(false); + expect(isEvmAddress('0xGGGGGG1234567890123456789012345678901234')).toBe(false); + expect(isEvmAddress('0x12345678901234567890123456789012345678901')).toBe(false); + }); + + it('should return false for empty or null input', () => { + expect(isEvmAddress('')).toBe(false); + expect(isEvmAddress(null as unknown as string)).toBe(false); + expect(isEvmAddress(undefined as unknown as string)).toBe(false); + }); + + it('should handle addresses with extra whitespace', () => { + expect(isEvmAddress(' 0x1234567890123456789012345678901234567890 ')).toBe(true); + expect(isEvmAddress(' 0x123 ')).toBe(false); + }); +}); diff --git a/lib/clusters/detectInputType.ts b/lib/clusters/detectInputType.ts new file mode 100644 index 0000000000..49bfae862a --- /dev/null +++ b/lib/clusters/detectInputType.ts @@ -0,0 +1,17 @@ +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; + +export type InputType = 'address' | 'cluster_name'; + +export function detectInputType(input: string): InputType { + if (!input || input.trim().length === 0) { + return 'cluster_name'; + } + + const trimmedInput = input.trim(); + + if (ADDRESS_REGEXP.test(trimmedInput)) { + return 'address'; + } + + return 'cluster_name'; +} diff --git a/lib/clusters/pageUtils.test.ts b/lib/clusters/pageUtils.test.ts new file mode 100644 index 0000000000..46a8489e3b --- /dev/null +++ b/lib/clusters/pageUtils.test.ts @@ -0,0 +1,331 @@ +import { ClustersOrderBy } from 'types/api/clusters'; +import type { ClustersDirectoryObject } from 'types/api/clusters'; + +import { + getViewModeOrderBy, + shouldShowDirectoryView, + transformLeaderboardData, + transformAddressDataToDirectory, + transformFullDirectoryData, + applyDirectoryPagination, + calculateHasNextPage, + isValidViewMode, + getDefaultViewMode, + getCurrentDataLength, +} from './pageUtils'; + +describe('pageUtils', () => { + describe('getViewModeOrderBy', () => { + it('should return RANK_ASC for leaderboard view regardless of search', () => { + expect(getViewModeOrderBy('leaderboard', false)).toBe(ClustersOrderBy.RANK_ASC); + expect(getViewModeOrderBy('leaderboard', true)).toBe(ClustersOrderBy.RANK_ASC); + }); + + it('should return NAME_ASC for directory view with search term', () => { + expect(getViewModeOrderBy('directory', true)).toBe(ClustersOrderBy.NAME_ASC); + }); + + it('should return CREATED_AT_DESC for directory view without search term', () => { + expect(getViewModeOrderBy('directory', false)).toBe(ClustersOrderBy.CREATED_AT_DESC); + }); + }); + + describe('shouldShowDirectoryView', () => { + it('should return true for directory view mode', () => { + expect(shouldShowDirectoryView('directory', false)).toBe(true); + expect(shouldShowDirectoryView('directory', true)).toBe(true); + }); + + it('should return true for leaderboard mode with search term', () => { + expect(shouldShowDirectoryView('leaderboard', true)).toBe(true); + }); + + it('should return false for leaderboard mode without search term', () => { + expect(shouldShowDirectoryView('leaderboard', false)).toBe(false); + }); + }); + + describe('transformLeaderboardData', () => { + const mockLeaderboardData = { + result: { + data: [ + { name: 'cluster1', rank: 1 }, + { name: 'cluster2', rank: 2 }, + ], + }, + }; + + it('should return empty array when showDirectoryView is true', () => { + expect(transformLeaderboardData(mockLeaderboardData, true)).toEqual([]); + }); + + it('should return empty array when data is null', () => { + expect(transformLeaderboardData(null, false)).toEqual([]); + }); + + it('should return transformed data when valid', () => { + const result = transformLeaderboardData(mockLeaderboardData, false); + expect(result).toEqual([ + { name: 'cluster1', rank: 1 }, + { name: 'cluster2', rank: 2 }, + ]); + }); + + it('should return empty array for invalid data structure', () => { + expect(transformLeaderboardData({ invalid: 'data' }, false)).toEqual([]); + expect(transformLeaderboardData({ result: { data: 'not-array' } }, false)).toEqual([]); + }); + }); + + describe('transformAddressDataToDirectory', () => { + const mockAddressData = [ + { + name: 'test-cluster', + isTestnet: false, + createdAt: '2023-01-01', + owner: '0x123', + totalWeiAmount: '1000', + updatedAt: '2023-01-02', + updatedBy: '0x456', + clusterId: 'test-cluster-id', + expiresAt: '2024-01-01', + }, + ]; + + const mockClusterDetails = { + result: { + data: { + wallets: [ + { chainIds: [ '1', '137' ] }, + { chainIds: [ '1', '56' ] }, + ], + }, + }, + }; + + it('should transform address data with unique chain IDs', () => { + const result = transformAddressDataToDirectory(mockAddressData, mockClusterDetails); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'test-cluster', + isTestnet: false, + createdAt: '2023-01-01', + owner: '0x123', + totalWeiAmount: '1000', + updatedAt: '2023-01-02', + updatedBy: '0x456', + chainIds: [ '1', '137', '56' ], + }); + }); + + it('should handle missing cluster details', () => { + const result = transformAddressDataToDirectory(mockAddressData, null); + + expect(result[0].chainIds).toEqual([]); + }); + + it('should handle empty wallets array', () => { + const emptyDetails = { result: { data: { wallets: [] } } }; + const result = transformAddressDataToDirectory(mockAddressData, emptyDetails); + + expect(result[0].chainIds).toEqual([]); + }); + }); + + describe('transformFullDirectoryData', () => { + it('should return empty array when showDirectoryView is false', () => { + const result = transformFullDirectoryData({}, {}, 'address', false); + expect(result).toEqual([]); + }); + + it('should return empty array when data is null', () => { + const result = transformFullDirectoryData(null, {}, 'address', true); + expect(result).toEqual([]); + }); + + it('should transform address-type data', () => { + const mockData = { + result: { + data: [ { name: 'cluster1', owner: '0x123' } ], + }, + }; + const mockDetails = { + result: { data: { wallets: [ { chainIds: [ '1' ] } ] } }, + }; + + const result = transformFullDirectoryData(mockData, mockDetails, 'address', true); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('cluster1'); + }); + + it('should transform cluster_name-type data', () => { + const mockData = { + result: { + data: { + items: [ { name: 'cluster1' }, { name: 'cluster2' } ], + }, + }, + }; + + const result = transformFullDirectoryData(mockData, {}, 'cluster_name', true); + expect(result).toEqual([ { name: 'cluster1' }, { name: 'cluster2' } ]); + }); + }); + + describe('applyDirectoryPagination', () => { + const mockData = Array.from({ length: 100 }, (_, i) => ({ name: `cluster${ i }` })) as Array; + + it('should apply pagination for address input type', () => { + const result = applyDirectoryPagination(mockData, 'address', 2, 20); + expect(result).toHaveLength(20); + expect(result[0].name).toBe('cluster20'); + expect(result[19].name).toBe('cluster39'); + }); + + it('should return all data for cluster_name input type', () => { + const result = applyDirectoryPagination(mockData, 'cluster_name', 2, 20); + expect(result).toHaveLength(100); + expect(result[0].name).toBe('cluster0'); + }); + + it('should handle last page correctly', () => { + const result = applyDirectoryPagination(mockData, 'address', 5, 20); + expect(result).toHaveLength(20); + expect(result[0].name).toBe('cluster80'); + }); + + it('should handle page beyond data length', () => { + const result = applyDirectoryPagination(mockData, 'address', 10, 20); + expect(result).toHaveLength(0); + }); + }); + + describe('calculateHasNextPage', () => { + const mockDirectoryData = { + result: { + data: { + total: 100, + }, + }, + }; + + it('should return true for address type with more data', () => { + const result = calculateHasNextPage( + {}, + 0, + 200, + true, + 'address', + 2, + false, + 50, + ); + expect(result).toBe(true); + }); + + it('should return false for address type at end', () => { + const result = calculateHasNextPage( + {}, + 0, + 100, + true, + 'address', + 2, + false, + 50, + ); + expect(result).toBe(false); + }); + + it('should return false for cluster_name type with search term', () => { + const result = calculateHasNextPage( + mockDirectoryData, + 0, + 0, + true, + 'cluster_name', + 1, + true, + 50, + ); + expect(result).toBe(false); + }); + + it('should return true for cluster_name type without search and more pages', () => { + const result = calculateHasNextPage( + mockDirectoryData, + 0, + 0, + true, + 'cluster_name', + 1, + false, + 50, + ); + expect(result).toBe(true); + }); + + it('should return true for leaderboard with full page', () => { + const result = calculateHasNextPage( + {}, + 50, + 0, + false, + 'cluster_name', + 1, + false, + 50, + ); + expect(result).toBe(true); + }); + + it('should return false for leaderboard with partial page', () => { + const result = calculateHasNextPage( + {}, + 25, + 0, + false, + 'cluster_name', + 1, + false, + 50, + ); + expect(result).toBe(false); + }); + }); + + describe('isValidViewMode', () => { + it('should return true for valid view modes', () => { + expect(isValidViewMode('leaderboard')).toBe(true); + expect(isValidViewMode('directory')).toBe(true); + }); + + it('should return false for invalid view modes', () => { + expect(isValidViewMode('invalid')).toBe(false); + expect(isValidViewMode('')).toBe(false); + expect(isValidViewMode('grid')).toBe(false); + }); + }); + + describe('getDefaultViewMode', () => { + it('should return directory as default', () => { + expect(getDefaultViewMode()).toBe('directory'); + }); + }); + + describe('getCurrentDataLength', () => { + it('should return directory data length when showing directory view', () => { + expect(getCurrentDataLength(true, 25, 50)).toBe(25); + }); + + it('should return leaderboard data length when showing leaderboard view', () => { + expect(getCurrentDataLength(false, 25, 50)).toBe(50); + }); + + it('should handle zero lengths', () => { + expect(getCurrentDataLength(true, 0, 10)).toBe(0); + expect(getCurrentDataLength(false, 10, 0)).toBe(0); + }); + }); +}); diff --git a/lib/clusters/pageUtils.ts b/lib/clusters/pageUtils.ts new file mode 100644 index 0000000000..bb22e74c4f --- /dev/null +++ b/lib/clusters/pageUtils.ts @@ -0,0 +1,161 @@ +import { ClustersOrderBy } from 'types/api/clusters'; +import type { ClustersLeaderboardObject, ClustersDirectoryObject, ClustersByAddressObject } from 'types/api/clusters'; + +export type ViewMode = 'leaderboard' | 'directory'; +export type InputType = 'address' | 'cluster_name'; + +export function getViewModeOrderBy(viewMode: ViewMode, hasSearchTerm: boolean): ClustersOrderBy { + if (viewMode === 'leaderboard') return ClustersOrderBy.RANK_ASC; + if (hasSearchTerm) return ClustersOrderBy.NAME_ASC; + return ClustersOrderBy.CREATED_AT_DESC; +} + +export function shouldShowDirectoryView(viewMode: ViewMode, hasSearchTerm: boolean): boolean { + return viewMode === 'directory' || hasSearchTerm; +} + +export function transformLeaderboardData( + data: unknown, + showDirectoryView: boolean, +): Array { + if (!data || showDirectoryView) return []; + + if (data && typeof data === 'object' && 'result' in data) { + const result = (data as Record).result; + if (result && typeof result === 'object' && 'data' in result && Array.isArray(result.data)) { + return result.data as Array; + } + } + + return []; +} + +export function transformAddressDataToDirectory( + addressData: Array, + clusterDetails: unknown, +): Array { + const clusterDetailsData = clusterDetails && + typeof clusterDetails === 'object' && + 'result' in clusterDetails && + clusterDetails.result && + typeof clusterDetails.result === 'object' && + 'data' in clusterDetails.result ? clusterDetails.result.data : null; + + const allChainIds = clusterDetailsData && + typeof clusterDetailsData === 'object' && + 'wallets' in clusterDetailsData && + Array.isArray(clusterDetailsData.wallets) ? + clusterDetailsData.wallets.flatMap( + (wallet: unknown) => { + if (wallet && typeof wallet === 'object' && 'chainIds' in wallet && Array.isArray(wallet.chainIds)) { + return wallet.chainIds as Array; + } + return []; + }, + ) : []; + const uniqueChainIds = [ ...new Set(allChainIds) ] as Array; + + return addressData.map((item) => ({ + name: item.name, + isTestnet: item.isTestnet, + createdAt: item.createdAt, + owner: item.owner, + totalWeiAmount: item.totalWeiAmount, + updatedAt: item.updatedAt, + updatedBy: item.updatedBy, + chainIds: uniqueChainIds, + })); +} + +export function transformFullDirectoryData( + data: unknown, + clusterDetails: unknown, + inputType: InputType, + showDirectoryView: boolean, +): Array { + if (!showDirectoryView || !data) return []; + + if (inputType === 'address') { + const addressData = data && + typeof data === 'object' && + 'result' in data && + data.result && + typeof data.result === 'object' && + 'data' in data.result ? data.result.data as Array : null; + if (addressData && Array.isArray(addressData)) { + return transformAddressDataToDirectory(addressData, clusterDetails); + } + } else { + const apiData = data && + typeof data === 'object' && + 'result' in data && + data.result && + typeof data.result === 'object' && + 'data' in data.result ? data.result.data : null; + if (apiData && typeof apiData === 'object' && 'items' in apiData && Array.isArray(apiData.items)) { + return apiData.items as Array; + } + } + + return []; +} + +export function applyDirectoryPagination( + fullDirectoryData: Array, + inputType: InputType, + page: number, + limit = 50, +): Array { + if (inputType === 'address') { + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + return fullDirectoryData.slice(startIndex, endIndex); + } + return fullDirectoryData; +} + +export function calculateHasNextPage( + data: unknown, + leaderboardDataLength: number, + fullDirectoryDataLength: number, + showDirectoryView: boolean, + inputType: InputType, + page: number, + hasSearchTerm: boolean, + limit = 50, +): boolean { + if (showDirectoryView) { + if (inputType === 'address') { + return page * limit < fullDirectoryDataLength; + } else { + if (hasSearchTerm) return false; + const apiData = data && + typeof data === 'object' && + 'result' in data && + data.result && + typeof data.result === 'object' && + 'data' in data.result ? data.result.data : null; + if (apiData && typeof apiData === 'object' && 'total' in apiData && typeof apiData.total === 'number') { + return (page * limit) < apiData.total; + } + return false; + } + } + return leaderboardDataLength === limit; +} + +export function isValidViewMode(value: string): value is ViewMode { + return value === 'leaderboard' || value === 'directory'; +} + +export function getDefaultViewMode(): ViewMode { + return 'directory'; +} + +export function getCurrentDataLength( + showDirectoryView: boolean, + directoryDataLength: number, + leaderboardDataLength: number, +): number { + return showDirectoryView ? directoryDataLength : leaderboardDataLength; +} diff --git a/lib/clusters/useAddressClusters.test.ts b/lib/clusters/useAddressClusters.test.ts new file mode 100644 index 0000000000..4c292b4645 --- /dev/null +++ b/lib/clusters/useAddressClusters.test.ts @@ -0,0 +1,72 @@ +import { useAddressClusters } from './useAddressClusters'; + +jest.mock('lib/api/useApiQuery', () => ({ + __esModule: true, + 'default': jest.fn(), +})); + +jest.mock('configs/app', () => ({ + features: { + clusters: { + isEnabled: true, + }, + }, +})); + +const mockUseApiQuery = require('lib/api/useApiQuery').default; + +describe('useAddressClusters', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseApiQuery.mockReturnValue({ data: null, isLoading: false }); + }); + + it('should call API with correct parameters', () => { + const addressHash = '0x1234567890123456789012345678901234567890'; + + useAddressClusters(addressHash); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', { + queryParams: { + input: JSON.stringify({ + address: addressHash, + }), + }, + queryOptions: { + enabled: true, + }, + }); + }); + + it('should be disabled when addressHash is empty', () => { + useAddressClusters(''); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', { + queryParams: { + input: JSON.stringify({ + address: '', + }), + }, + queryOptions: { + enabled: false, + }, + }); + }); + + it('should handle isEnabled parameter', () => { + const addressHash = '0x1234567890123456789012345678901234567890'; + + useAddressClusters(addressHash, false); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_clusters_by_address', { + queryParams: { + input: JSON.stringify({ + address: addressHash, + }), + }, + queryOptions: { + enabled: false, + }, + }); + }); +}); diff --git a/lib/clusters/useAddressClusters.ts b/lib/clusters/useAddressClusters.ts new file mode 100644 index 0000000000..914ba0cdb3 --- /dev/null +++ b/lib/clusters/useAddressClusters.ts @@ -0,0 +1,15 @@ +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; + +export function useAddressClusters(addressHash: string, isEnabled: boolean = true) { + return useApiQuery('clusters:get_clusters_by_address', { + queryParams: { + input: JSON.stringify({ + address: addressHash, + }), + }, + queryOptions: { + enabled: Boolean(addressHash) && config.features.clusters.isEnabled && isEnabled, + }, + }); +} diff --git a/lib/clusters/useClusterPagination.test.ts b/lib/clusters/useClusterPagination.test.ts new file mode 100644 index 0000000000..24dc11f9e8 --- /dev/null +++ b/lib/clusters/useClusterPagination.test.ts @@ -0,0 +1,199 @@ +import { renderHook, act } from 'jest/lib'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { useQueryParams } from 'lib/router/useQueryParams'; + +import { useClusterPagination } from '../clusters/useClusterPagination'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); +jest.mock('lib/router/useQueryParams'); +jest.mock('lib/router/getQueryParamString'); + +const { useRouter } = require('next/router'); +const mockUseRouter = useRouter as jest.MockedFunction; +const mockUseQueryParams = useQueryParams as jest.MockedFunction; +const mockGetQueryParamString = getQueryParamString as jest.MockedFunction; + +describe('useClusterPagination', () => { + const mockUpdateQuery = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseRouter.mockReturnValue({ + query: {}, + } as unknown as ReturnType); + mockUseQueryParams.mockReturnValue({ + updateQuery: mockUpdateQuery, + }); + }); + + describe('page calculation', () => { + it('should default to page 1 when no page param', () => { + mockGetQueryParamString.mockReturnValue(''); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.page).toBe(1); + expect(result.current.pagination.page).toBe(1); + }); + + it('should parse page from query param', () => { + mockGetQueryParamString.mockReturnValue('3'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.page).toBe(3); + expect(result.current.pagination.page).toBe(3); + }); + + it('should handle invalid page param gracefully', () => { + mockGetQueryParamString.mockReturnValue('invalid'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.page).toBeNaN(); + }); + }); + + describe('navigation functions', () => { + beforeEach(() => { + mockGetQueryParamString.mockReturnValue('2'); + }); + + it('should increment page on next click', () => { + const { result } = renderHook(() => useClusterPagination(true, false)); + + act(() => { + result.current.pagination.onNextPageClick(); + }); + + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: '3' }); + }); + + it('should decrement page on prev click', () => { + const { result } = renderHook(() => useClusterPagination(true, false)); + + act(() => { + result.current.pagination.onPrevPageClick(); + }); + + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: undefined }); + }); + + it('should handle prev click from page 3', () => { + mockGetQueryParamString.mockReturnValue('3'); + const { result } = renderHook(() => useClusterPagination(true, false)); + + act(() => { + result.current.pagination.onPrevPageClick(); + }); + + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: '2' }); + }); + + it('should reset page to undefined', () => { + const { result } = renderHook(() => useClusterPagination(true, false)); + + act(() => { + result.current.pagination.resetPage(); + }); + + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: undefined }); + }); + }); + + describe('pagination state', () => { + it('should set hasPages true when page > 1', () => { + mockGetQueryParamString.mockReturnValue('2'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.pagination.hasPages).toBe(true); + }); + + it('should set hasPages false when page = 1', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.pagination.hasPages).toBe(false); + }); + + it('should set canGoBackwards true when page > 1', () => { + mockGetQueryParamString.mockReturnValue('2'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.pagination.canGoBackwards).toBe(true); + }); + + it('should set canGoBackwards false when page = 1', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.pagination.canGoBackwards).toBe(false); + }); + + it('should pass through hasNextPage prop', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(false, false)); + + expect(result.current.pagination.hasNextPage).toBe(false); + }); + + it('should pass through isLoading prop', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(true, true)); + + expect(result.current.pagination.isLoading).toBe(true); + }); + }); + + describe('pagination visibility', () => { + it('should be visible when page > 1', () => { + mockGetQueryParamString.mockReturnValue('2'); + + const { result } = renderHook(() => useClusterPagination(false, false)); + + expect(result.current.pagination.isVisible).toBe(true); + }); + + it('should be visible when hasNextPage is true', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(true, false)); + + expect(result.current.pagination.isVisible).toBe(true); + }); + + it('should not be visible when page = 1 and no next page', () => { + mockGetQueryParamString.mockReturnValue('1'); + + const { result } = renderHook(() => useClusterPagination(false, false)); + + expect(result.current.pagination.isVisible).toBe(false); + }); + }); + + describe('function stability', () => { + it('should not recreate functions when dependencies do not change', () => { + mockGetQueryParamString.mockReturnValue('2'); + + const { result, rerender } = renderHook(() => useClusterPagination(true, false)); + + const firstOnNext = result.current.pagination.onNextPageClick; + const firstOnPrev = result.current.pagination.onPrevPageClick; + const firstReset = result.current.pagination.resetPage; + + rerender(); + + expect(result.current.pagination.onNextPageClick).toBe(firstOnNext); + expect(result.current.pagination.onPrevPageClick).toBe(firstOnPrev); + expect(result.current.pagination.resetPage).toBe(firstReset); + }); + }); +}); diff --git a/lib/clusters/useClusterPagination.ts b/lib/clusters/useClusterPagination.ts new file mode 100644 index 0000000000..ee7fe5c057 --- /dev/null +++ b/lib/clusters/useClusterPagination.ts @@ -0,0 +1,42 @@ +import { useRouter } from 'next/router'; +import { useCallback, useMemo } from 'react'; + +import type { PaginationParams } from 'ui/shared/pagination/types'; + +import getQueryParamString from 'lib/router/getQueryParamString'; +import { useQueryParams } from 'lib/router/useQueryParams'; + +export function useClusterPagination(hasNextPage: boolean, isLoading: boolean) { + const router = useRouter(); + const { updateQuery } = useQueryParams(); + const page = parseInt(getQueryParamString(router.query.page) || '1', 10); + + const onNextPageClick = useCallback(() => { + updateQuery({ page: (page + 1).toString() }); + }, [ updateQuery, page ]); + + const onPrevPageClick = useCallback(() => { + updateQuery({ page: page === 2 ? undefined : (page - 1).toString() }); + }, [ updateQuery, page ]); + + const resetPage = useCallback(() => { + updateQuery({ page: undefined }); + }, [ updateQuery ]); + + const pagination: PaginationParams = useMemo(() => ({ + page, + onNextPageClick, + onPrevPageClick, + resetPage, + hasPages: page > 1, + hasNextPage, + canGoBackwards: page > 1, + isLoading, + isVisible: page > 1 || hasNextPage, + }), [ page, onNextPageClick, onPrevPageClick, resetPage, hasNextPage, isLoading ]); + + return { + page, + pagination, + }; +} diff --git a/lib/clusters/useClusterSearch.test.ts b/lib/clusters/useClusterSearch.test.ts new file mode 100644 index 0000000000..aaf83e0904 --- /dev/null +++ b/lib/clusters/useClusterSearch.test.ts @@ -0,0 +1,48 @@ +import { useRouter } from 'next/router'; + +import { renderHook } from 'jest/lib'; + +import { useClusterSearch } from './useClusterSearch'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +const mockUseRouter = useRouter as jest.MockedFunction; + +describe('useClusterSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return search term from router query', () => { + mockUseRouter.mockReturnValue({ + query: { q: 'test-search' }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useClusterSearch()); + + expect(result.current.searchTerm).toBe('test-search'); + }); + + it('should debounce search term', () => { + mockUseRouter.mockReturnValue({ + query: { q: 'test' }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useClusterSearch()); + + expect(result.current.debouncedSearchTerm).toBe('test'); + }); + + it('should handle empty query', () => { + mockUseRouter.mockReturnValue({ + query: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useClusterSearch()); + + expect(result.current.searchTerm).toBe(''); + expect(result.current.debouncedSearchTerm).toBe(''); + }); +}); diff --git a/lib/clusters/useClusterSearch.ts b/lib/clusters/useClusterSearch.ts new file mode 100644 index 0000000000..01848c2370 --- /dev/null +++ b/lib/clusters/useClusterSearch.ts @@ -0,0 +1,15 @@ +import { useRouter } from 'next/router'; + +import useDebounce from 'lib/hooks/useDebounce'; +import getQueryParamString from 'lib/router/getQueryParamString'; + +export function useClusterSearch() { + const router = useRouter(); + const searchTerm = getQueryParamString(router.query.q); + const debouncedSearchTerm = useDebounce(searchTerm || '', 300); + + return { + searchTerm, + debouncedSearchTerm, + }; +} diff --git a/lib/clusters/useClustersData.test.ts b/lib/clusters/useClustersData.test.ts new file mode 100644 index 0000000000..81e0050249 --- /dev/null +++ b/lib/clusters/useClustersData.test.ts @@ -0,0 +1,421 @@ +import { renderHook } from '@testing-library/react'; + +import useApiQuery from 'lib/api/useApiQuery'; + +import { useClustersData } from './useClustersData'; + +jest.mock('lib/api/useApiQuery'); +const mockUseApiQuery = useApiQuery as jest.MockedFunction; + +type MockQueryResult = ReturnType; + +jest.mock('lib/clusters/detectInputType', () => ({ + detectInputType: jest.fn(), +})); + +const mockDetectInputType = require('lib/clusters/detectInputType').detectInputType; + +describe('useClustersData', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseApiQuery.mockReturnValue({ + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult); + }); + + describe('input type detection logic', () => { + it('should default to cluster_name when no search term provided', () => { + renderHook(() => useClustersData('', 'leaderboard', 1)); + + expect(mockDetectInputType).not.toHaveBeenCalled(); + }); + + it('should call detectInputType when search term exists', () => { + mockDetectInputType.mockReturnValue('address'); + + renderHook(() => useClustersData('0x123...', 'directory', 1)); + + expect(mockDetectInputType).toHaveBeenCalledWith('0x123...'); + }); + + it('should memoize input type calculation', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + + const { rerender } = renderHook( + ({ searchTerm }) => useClustersData(searchTerm, 'directory', 1), + { initialProps: { searchTerm: 'example.cluster' } }, + ); + + rerender({ searchTerm: 'example.cluster' }); + + expect(mockDetectInputType).toHaveBeenCalledTimes(1); + }); + }); + + describe('view mode determination', () => { + it('should show directory view when viewMode is directory', () => { + renderHook(() => useClustersData('', 'directory', 1)); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_directory', expect.any(Object)); + }); + + it('should show directory view when search term exists regardless of viewMode', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + + renderHook(() => useClustersData('search', 'leaderboard', 1)); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_directory', expect.any(Object)); + }); + + it('should show leaderboard view when no search term and viewMode is leaderboard', () => { + renderHook(() => useClustersData('', 'leaderboard', 1)); + + expect(mockUseApiQuery).toHaveBeenCalledWith('clusters:get_leaderboard', expect.any(Object)); + }); + }); + + describe('API query configuration', () => { + it('should configure leaderboard query with correct pagination', () => { + renderHook(() => useClustersData('', 'leaderboard', 3)); + + const leaderboardCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_leaderboard', + ); + + expect(leaderboardCall).toBeDefined(); + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":100'); + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"limit":50'); + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"orderBy":"rank-asc"'); + }); + + it('should configure directory query with search term', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + + renderHook(() => useClustersData('example', 'directory', 2)); + + const directoryCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_directory', + ); + + expect(directoryCall).toBeDefined(); + expect(directoryCall?.[1]?.queryParams?.input).toContain('"offset":50'); + expect(directoryCall?.[1]?.queryParams?.input).toContain('"query":"example"'); + }); + + it('should configure address query when input type is address', () => { + mockDetectInputType.mockReturnValue('address'); + + renderHook(() => useClustersData('0x123...', 'directory', 1)); + + const addressCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_clusters_by_address', + ); + + expect(addressCall).toBeDefined(); + expect(addressCall?.[1]?.queryParams?.input).toContain('"address":"0x123..."'); + }); + + it('should call cluster details query when cluster ID is available', () => { + mockDetectInputType.mockReturnValue('address'); + + mockUseApiQuery.mockImplementation((resource) => { + if (resource === 'clusters:get_clusters_by_address') { + return { + data: { + result: { + data: [ { clusterId: 'cluster-123' } ], + }, + }, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + } + return { + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + }); + + renderHook(() => useClustersData('0x123...', 'directory', 1)); + + const clusterDetailsCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_cluster_by_id', + ); + + expect(clusterDetailsCall).toBeDefined(); + expect(clusterDetailsCall?.[1]?.queryParams?.input).toContain('"id":"cluster-123"'); + }); + }); + + describe('dynamic ordering business logic', () => { + it('should use NAME_ASC ordering when search term exists', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + + renderHook(() => useClustersData('search', 'directory', 1)); + + const directoryCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_directory', + ); + + expect(directoryCall?.[1]?.queryParams?.input).toContain('"orderBy":"name-asc"'); + }); + + it('should use CREATED_AT_DESC ordering when no search term', () => { + renderHook(() => useClustersData('', 'directory', 1)); + + const directoryCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_directory', + ); + + expect(directoryCall?.[1]?.queryParams?.input).toContain('"orderBy":"createdAt-desc"'); + }); + + it('should memoize directory order by logic', () => { + const { rerender } = renderHook( + ({ searchTerm }) => useClustersData(searchTerm, 'directory', 1), + { initialProps: { searchTerm: 'search' } }, + ); + + jest.clearAllMocks(); + + rerender({ searchTerm: 'search' }); + + const expectedCallsPerRender = 4; + expect(mockUseApiQuery.mock.calls.length).toBe(expectedCallsPerRender); + }); + }); + + describe('query selection logic', () => { + it('should return data from leaderboard query in leaderboard mode', () => { + const mockLeaderboardData = { result: { data: [] } }; + + mockUseApiQuery.mockImplementation((resource) => { + if (resource === 'clusters:get_leaderboard') { + return { + data: mockLeaderboardData, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + } + return { + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + }); + + const { result } = renderHook(() => + useClustersData('', 'leaderboard', 1), + ); + + expect(result.current.data).toBe(mockLeaderboardData); + }); + + it('should return data from address query when input type is address', () => { + mockDetectInputType.mockReturnValue('address'); + const mockAddressData = { result: { data: [] } }; + + mockUseApiQuery.mockImplementation((resource) => { + if (resource === 'clusters:get_clusters_by_address') { + return { + data: mockAddressData, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + } + return { + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + }); + + const { result } = renderHook(() => + useClustersData('0x123...', 'directory', 1), + ); + + expect(result.current.data).toBe(mockAddressData); + }); + + it('should return data from directory query when input type is cluster_name', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + const mockDirectoryData = { result: { data: { items: [] } } }; + + mockUseApiQuery.mockImplementation((resource) => { + if (resource === 'clusters:get_directory') { + return { + data: mockDirectoryData, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + } + return { + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + }); + + const { result } = renderHook(() => + useClustersData('example', 'directory', 1), + ); + + expect(result.current.data).toBe(mockDirectoryData); + }); + }); + + describe('return value structure', () => { + it('should return correct data structure with all expected properties', () => { + const mockData = { result: { data: [] } }; + const mockClusterDetails = { result: { data: { id: 'cluster-123' } } }; + + mockUseApiQuery.mockImplementation((resource) => { + if (resource === 'clusters:get_leaderboard') { + return { + data: mockData, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + } + if (resource === 'clusters:get_cluster_by_id') { + return { + data: mockClusterDetails, + isError: false, + isPlaceholderData: false, + isLoading: true, + } as unknown as MockQueryResult; + } + return { + data: null, + isError: false, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult; + }); + + const { result } = renderHook(() => + useClustersData('', 'leaderboard', 1), + ); + + expect(result.current).toEqual({ + data: mockData, + clusterDetails: mockClusterDetails, + isError: false, + isLoading: false, + isClusterDetailsLoading: true, + }); + }); + + it('should handle error states correctly', () => { + mockUseApiQuery.mockReturnValue({ + data: null, + isError: true, + isPlaceholderData: false, + isLoading: false, + } as unknown as MockQueryResult); + + const { result } = renderHook(() => + useClustersData('', 'leaderboard', 1), + ); + + expect(result.current.isError).toBe(true); + }); + + it('should handle loading states correctly', () => { + mockUseApiQuery.mockReturnValue({ + data: null, + isError: false, + isPlaceholderData: true, + isLoading: false, + } as unknown as MockQueryResult); + + const { result } = renderHook(() => + useClustersData('', 'leaderboard', 1), + ); + + expect(result.current.isLoading).toBe(true); + }); + }); + + describe('pagination calculations', () => { + it('should calculate correct offset for page 1', () => { + renderHook(() => useClustersData('', 'leaderboard', 1)); + + const leaderboardCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_leaderboard', + ); + + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":0'); + }); + + it('should calculate correct offset for page 5', () => { + renderHook(() => useClustersData('', 'leaderboard', 5)); + + const leaderboardCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_leaderboard', + ); + + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"offset":200'); + }); + + it('should consistently use 50 items per page', () => { + renderHook(() => useClustersData('', 'leaderboard', 1)); + + const leaderboardCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_leaderboard', + ); + + expect(leaderboardCall?.[1]?.queryParams?.input).toContain('"limit":50'); + }); + }); + + describe('query enabling/disabling logic', () => { + it('should disable leaderboard query when in directory view', () => { + renderHook(() => useClustersData('search', 'directory', 1)); + + const leaderboardCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_leaderboard', + ); + + expect(leaderboardCall?.[1]?.queryOptions?.enabled).toBe(false); + }); + + it('should disable directory query when input type is address', () => { + mockDetectInputType.mockReturnValue('address'); + + renderHook(() => useClustersData('0x123...', 'directory', 1)); + + const directoryCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_directory', + ); + + expect(directoryCall?.[1]?.queryOptions?.enabled).toBe(false); + }); + + it('should disable address query when input type is not address', () => { + mockDetectInputType.mockReturnValue('cluster_name'); + + renderHook(() => useClustersData('example', 'directory', 1)); + + const addressCall = mockUseApiQuery.mock.calls.find(call => + call[0] === 'clusters:get_clusters_by_address', + ); + + expect(addressCall?.[1]?.queryOptions?.enabled).toBe(false); + }); + }); +}); diff --git a/lib/clusters/useClustersData.ts b/lib/clusters/useClustersData.ts new file mode 100644 index 0000000000..ba46da8cf8 --- /dev/null +++ b/lib/clusters/useClustersData.ts @@ -0,0 +1,120 @@ +import React from 'react'; + +import type { ClustersByAddressObject } from 'types/api/clusters'; +import { ClustersOrderBy } from 'types/api/clusters'; + +import useApiQuery from 'lib/api/useApiQuery'; +import { detectInputType } from 'lib/clusters/detectInputType'; +import { CLUSTER_ITEM } from 'stubs/clusters'; + +export function useClustersData(debouncedSearchTerm: string, viewMode: string, page: number) { + const ITEMS_PER_PAGE = 50; + + const inputType = React.useMemo(() => { + if (!debouncedSearchTerm) return 'cluster_name'; + return detectInputType(debouncedSearchTerm); + }, [ debouncedSearchTerm ]); + + const showDirectoryView = viewMode === 'directory' || Boolean(debouncedSearchTerm); + + const leaderboardQuery = useApiQuery('clusters:get_leaderboard', { + queryParams: { + input: JSON.stringify({ + offset: (page - 1) * ITEMS_PER_PAGE, + limit: ITEMS_PER_PAGE, + orderBy: ClustersOrderBy.RANK_ASC, + }), + }, + queryOptions: { + enabled: !showDirectoryView, + placeholderData: (previousData) => { + if (previousData) return previousData; + return { + result: { + data: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM), + }, + }; + }, + }, + }); + + const getDirectoryOrderBy = React.useMemo(() => { + if (debouncedSearchTerm) { + return ClustersOrderBy.NAME_ASC; + } + return ClustersOrderBy.CREATED_AT_DESC; + }, [ debouncedSearchTerm ]); + + const directoryQuery = useApiQuery('clusters:get_directory', { + queryParams: { + input: JSON.stringify({ + offset: (page - 1) * ITEMS_PER_PAGE, + limit: ITEMS_PER_PAGE, + orderBy: getDirectoryOrderBy, + query: debouncedSearchTerm || '', + }), + }, + queryOptions: { + enabled: showDirectoryView && inputType === 'cluster_name', + placeholderData: (previousData) => { + if (previousData) return previousData; + return { + result: { + data: { + total: 1000, + items: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM), + }, + }, + }; + }, + }, + }); + + const addressQuery = useApiQuery('clusters:get_clusters_by_address', { + queryParams: { + input: JSON.stringify({ + address: debouncedSearchTerm, + }), + }, + queryOptions: { + enabled: showDirectoryView && inputType === 'address', + placeholderData: (previousData) => { + if (previousData) return previousData; + return { + result: { + data: Array(ITEMS_PER_PAGE).fill(CLUSTER_ITEM), + }, + }; + }, + }, + }); + + const clusterDetailsQuery = useApiQuery('clusters:get_cluster_by_id', { + queryParams: { + input: JSON.stringify({ + id: addressQuery.data?.result?.data?.[0]?.clusterId || '', + }), + }, + queryOptions: { + enabled: ( + showDirectoryView && + inputType === 'address' && + Boolean((addressQuery.data?.result?.data?.[0] as ClustersByAddressObject & { clusterId?: string })?.clusterId) + ), + }, + }); + + const { data, isError, isPlaceholderData: isLoading } = (() => { + if (!showDirectoryView) return leaderboardQuery; + if (inputType === 'address') return addressQuery; + return directoryQuery; + })(); + + return { + data, + clusterDetails: clusterDetailsQuery.data, + isError, + isLoading, + isClusterDetailsLoading: clusterDetailsQuery.isLoading, + }; +} diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 092866f818..fbfdacd8cb 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -118,6 +118,12 @@ export default function useNavItems(): ReturnType { icon: 'MUD_menu', isActive: pathname === '/mud-worlds', } : null; + const clustersLookup: NavItem | null = config.features.clusters.isEnabled ? { + text: 'Clusters lookup', + nextRoute: { pathname: '/clusters' as const }, + icon: 'clusters', + isActive: pathname === '/clusters' || pathname === '/clusters/[name]', + } : null; const epochs = config.features.celo.isEnabled ? { text: 'Epochs', nextRoute: { pathname: '/epochs' as const }, @@ -161,6 +167,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else if (rollupFeature.isEnabled && rollupFeature.type === 'shibarium') { @@ -177,6 +184,7 @@ export default function useNavItems(): ReturnType { topAccounts, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else if (rollupFeature.isEnabled && rollupFeature.type === 'zkSync') { @@ -193,6 +201,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, ].filter(Boolean), ]; } else { @@ -207,6 +216,7 @@ export default function useNavItems(): ReturnType { validators, verifiedContracts, ensLookup, + clustersLookup, config.features.beaconChain.isEnabled && { text: 'Withdrawals', nextRoute: { pathname: '/withdrawals' as const }, diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 6273f7a497..45d1edff47 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -23,6 +23,8 @@ const OG_TYPE_DICT: Record = { '/token/[hash]/instance/[id]': 'Regular page', '/apps': 'Root page', '/apps/[id]': 'Regular page', + '/clusters': 'Root page', + '/clusters/[name]': 'Regular page', '/stats': 'Root page', '/stats/[id]': 'Regular page', '/api-docs': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index b2f8538ad9..e4d21510f3 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -26,6 +26,8 @@ const TEMPLATE_MAP: Record = { '/token/[hash]/instance/[id]': '%hash%, balances and analytics on the %network_title%', '/apps': DEFAULT_TEMPLATE, '/apps/[id]': DEFAULT_TEMPLATE, + '/clusters': '%network_name% clusters | %app_name%', + '/clusters/[name]': '%cluster_name% cluster | %app_name%', '/stats': DEFAULT_TEMPLATE, '/stats/[id]': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 30487ce539..d0f026cc50 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -68,6 +68,8 @@ const TEMPLATE_MAP: Record = { '/chain/[chain-slug]/tx/[hash]': '%network_name% transaction %hash% details', '/operations': '%network_name% operations', '/operation/[id]': '%network_name% operation %id%', + '/clusters': 'Clusters universal name service', + '/clusters/[name]': 'Clusters details for %name%', // service routes, added only to make typescript happy '/login': '%network_name% login', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 3a7312e768..4371c542fc 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -21,6 +21,8 @@ export const PAGE_TYPE_DICT: Record = { '/token/[hash]/instance/[id]': 'Token Instance', '/apps': 'DApps', '/apps/[id]': 'DApp', + '/clusters': 'Clusters', + '/clusters/[name]': 'Cluster details', '/stats': 'Stats', '/stats/[id]': 'Stats chart', '/api-docs': 'REST API', diff --git a/lib/router/useQueryParams.ts b/lib/router/useQueryParams.ts new file mode 100644 index 0000000000..c4eead5e77 --- /dev/null +++ b/lib/router/useQueryParams.ts @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router'; +import { useCallback } from 'react'; + +export function useQueryParams() { + const router = useRouter(); + + const updateQuery = useCallback((updates: Record) => { + const newQuery = { ...router.query }; + + Object.entries(updates).forEach(([ key, value ]) => { + if (value === undefined) { + delete newQuery[key]; + } else { + newQuery[key] = value; + } + }); + + router.push({ pathname: router.pathname, query: newQuery }, undefined, { shallow: true }); + }, [ router ]); + + return { updateQuery }; +} diff --git a/mocks/clusters/cluster.ts b/mocks/clusters/cluster.ts new file mode 100644 index 0000000000..0979b85023 --- /dev/null +++ b/mocks/clusters/cluster.ts @@ -0,0 +1,77 @@ +import type { ClusterByNameResponse, ClusterByIdResponse } from 'types/api/clusters'; + +export const campNetworkClusterByName: ClusterByNameResponse = { + result: { + data: { + name: 'campnetwork/lol', + owner: '0x1234567890123456789012345678901234567890', + clusterId: 'clstr_1a2b3c4d5e6f7g8h9i0j', + backingWei: '5000000000000000000', + expiresAt: '2025-01-15T10:30:00Z', + createdAt: '2024-01-15T10:30:00Z', + updatedAt: '2024-01-20T14:22:00Z', + updatedBy: '0x1234567890123456789012345678901234567890', + isTestnet: false, + }, + }, +}; + +export const duckClusterByName: ClusterByNameResponse = { + result: { + data: { + name: 'duck/quack', + owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + clusterId: 'clstr_9z8y7x6w5v4u3t2s1r0q', + backingWei: '12000000000000000000', + expiresAt: null, + createdAt: '2024-02-01T08:15:00Z', + updatedAt: '2024-02-05T16:45:00Z', + updatedBy: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + isTestnet: false, + }, + }, +}; + +export const campNetworkClusterById: ClusterByIdResponse = { + result: { + data: { + id: 'clstr_1a2b3c4d5e6f7g8h9i0j', + createdBy: '0x1234567890123456789012345678901234567890', + createdAt: '2024-01-15T10:30:00Z', + wallets: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'main.campnetwork', + chainIds: [ '1', '137' ], + }, + { + address: '0x0987654321098765432109876543210987654321', + name: 'treasury.campnetwork', + chainIds: [ '1' ], + }, + { + address: '0x1111222233334444555566667777888899990000', + name: 'staking.campnetwork', + chainIds: [ '137', '56' ], + }, + ], + isTestnet: false, + }, + }, +}; + +export const testnetClusterByName: ClusterByNameResponse = { + result: { + data: { + name: 'test/cluster', + owner: '0x9876543210987654321098765432109876543210', + clusterId: 'clstr_test123456789', + backingWei: '1000000000000000000', + expiresAt: '2024-12-31T23:59:59Z', + createdAt: '2024-03-01T12:00:00Z', + updatedAt: '2024-03-01T12:00:00Z', + updatedBy: '0x9876543210987654321098765432109876543210', + isTestnet: true, + }, + }, +}; diff --git a/mocks/clusters/directory.ts b/mocks/clusters/directory.ts new file mode 100644 index 0000000000..5e39b6dd36 --- /dev/null +++ b/mocks/clusters/directory.ts @@ -0,0 +1,77 @@ +import type { ClustersDirectoryResponse, ClustersDirectoryObject } from 'types/api/clusters'; + +export const campNetworkCluster: ClustersDirectoryObject = { + name: 'campnetwork/lol', + isTestnet: false, + createdAt: '2024-01-15T10:30:00Z', + owner: '0x1234567890123456789012345678901234567890', + totalWeiAmount: '5000000000000000000', + updatedAt: '2024-01-20T14:22:00Z', + updatedBy: '0x1234567890123456789012345678901234567890', + chainIds: [ '1', '137', '56' ], +}; + +export const duckCluster: ClustersDirectoryObject = { + name: 'duck/quack', + isTestnet: false, + createdAt: '2024-02-01T08:15:00Z', + owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + totalWeiAmount: '12000000000000000000', + updatedAt: '2024-02-05T16:45:00Z', + updatedBy: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + chainIds: [ '1', '42161' ], +}; + +export const testnetCluster: ClustersDirectoryObject = { + name: 'test/cluster', + isTestnet: true, + createdAt: '2024-03-01T12:00:00Z', + owner: '0x9876543210987654321098765432109876543210', + totalWeiAmount: '1000000000000000000', + updatedAt: '2024-03-01T12:00:00Z', + updatedBy: '0x9876543210987654321098765432109876543210', + chainIds: [ '11155111' ], +}; + +export const longNameCluster: ClustersDirectoryObject = { + name: 'this-is-a-very-long-cluster-name-that-should-test-truncation/subdomain', + isTestnet: false, + createdAt: '2024-01-10T09:20:00Z', + owner: '0x1111222233334444555566667777888899990000', + totalWeiAmount: '750000000000000000', + updatedAt: '2024-01-25T11:30:00Z', + updatedBy: '0x1111222233334444555566667777888899990000', + chainIds: [ '1' ], +}; + +export const clustersDirectoryMock: ClustersDirectoryResponse = { + result: { + data: { + total: 4, + items: [ + campNetworkCluster, + duckCluster, + testnetCluster, + longNameCluster, + ], + }, + }, +}; + +export const clustersDirectoryEmptyMock: ClustersDirectoryResponse = { + result: { + data: { + total: 0, + items: [], + }, + }, +}; + +export const clustersDirectoryLoadingMock: ClustersDirectoryResponse = { + result: { + data: { + total: 0, + items: [], + }, + }, +}; diff --git a/mocks/clusters/leaderboard.ts b/mocks/clusters/leaderboard.ts new file mode 100644 index 0000000000..437b9ce499 --- /dev/null +++ b/mocks/clusters/leaderboard.ts @@ -0,0 +1,74 @@ +import type { ClustersLeaderboardResponse, ClustersLeaderboardObject } from 'types/api/clusters'; + +export const leaderboardFirst: ClustersLeaderboardObject = { + name: 'ethereum/foundation', + clusterId: 'clstr_eth_foundation_001', + isTestnet: false, + totalWeiAmount: '50000000000000000000', + totalReferralAmount: '5000000000000000000', + chainIds: [ '1', '137', '56', '42161' ], + nameCount: '127', + rank: 1, +}; + +export const leaderboardSecond: ClustersLeaderboardObject = { + name: 'campnetwork/lol', + clusterId: 'clstr_1a2b3c4d5e6f7g8h9i0j', + isTestnet: false, + totalWeiAmount: '25000000000000000000', + totalReferralAmount: '2500000000000000000', + chainIds: [ '1', '137', '56' ], + nameCount: '89', + rank: 2, +}; + +export const leaderboardThird: ClustersLeaderboardObject = { + name: 'duck/quack', + clusterId: 'clstr_9z8y7x6w5v4u3t2s1r0q', + isTestnet: false, + totalWeiAmount: '18000000000000000000', + totalReferralAmount: '1800000000000000000', + chainIds: [ '1', '42161' ], + nameCount: '56', + rank: 3, +}; + +export const leaderboardFourth: ClustersLeaderboardObject = { + name: 'defi/protocol', + clusterId: 'clstr_defi_protocol_xyz', + isTestnet: false, + totalWeiAmount: '12000000000000000000', + totalReferralAmount: '1200000000000000000', + chainIds: [ '1' ], + nameCount: '34', + rank: 4, +}; + +export const leaderboardFifth: ClustersLeaderboardObject = { + name: 'gaming/world', + clusterId: 'clstr_gaming_world_abc', + isTestnet: false, + totalWeiAmount: '8000000000000000000', + totalReferralAmount: '800000000000000000', + chainIds: [ '137', '56' ], + nameCount: '23', + rank: 5, +}; + +export const clustersLeaderboardMock: ClustersLeaderboardResponse = { + result: { + data: [ + leaderboardFirst, + leaderboardSecond, + leaderboardThird, + leaderboardFourth, + leaderboardFifth, + ], + }, +}; + +export const clustersLeaderboardEmptyMock: ClustersLeaderboardResponse = { + result: { + data: [], + }, +}; diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index f663c3528a..1b4737ef49 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -242,6 +242,16 @@ export const nameService: GetServerSideProps = async(context) => { return base(context); }; +export const clusters: GetServerSideProps = async(context) => { + if (!config.features.clusters.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; + export const accounts: GetServerSideProps = async(context) => { if (config.UI.views.address.hiddenViews?.top_accounts) { return { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 36a777555e..cf615e4875 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -43,6 +43,8 @@ declare module "nextjs-routes" { | DynamicRoute<"/chain/[chain-slug]/block/[height_or_hash]", { "chain-slug": string; "height_or_hash": string }> | DynamicRoute<"/chain/[chain-slug]/tx/[hash]", { "chain-slug": string; "hash": string }> | StaticRoute<"/chakra"> + | DynamicRoute<"/clusters/[name]", { "name": string }> + | StaticRoute<"/clusters"> | StaticRoute<"/contract-verification"> | StaticRoute<"/csv-export"> | StaticRoute<"/deposits"> diff --git a/package.json b/package.json index 390016bea9..1b2ba0ec41 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "dayjs": "^1.11.5", "dom-to-image": "^2.6.0", "es-toolkit": "1.31.0", + "esbuild": "0.21.5", "focus-visible": "^5.2.0", "gradient-avatar": "git+https://github.com/blockscout/gradient-avatar.git", "graphiql": "^4.1.2", diff --git a/pages/clusters/[name].tsx b/pages/clusters/[name].tsx new file mode 100644 index 0000000000..5f368e9581 --- /dev/null +++ b/pages/clusters/[name].tsx @@ -0,0 +1,20 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Props } from 'nextjs/getServerSideProps'; +import PageNextJs from 'nextjs/PageNextJs'; + +const Cluster = dynamic(() => import('ui/pages/Cluster'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { clusters as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/clusters/index.tsx b/pages/clusters/index.tsx new file mode 100644 index 0000000000..582b3ce487 --- /dev/null +++ b/pages/clusters/index.tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import PageNextJs from 'nextjs/PageNextJs'; + +const Clusters = dynamic(() => import('ui/pages/Clusters'), { ssr: false }); + +const Page: NextPage = () => { + return ( + + + + ); +}; + +export default Page; + +export { clusters as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/playwright-ct.config.ts b/playwright-ct.config.ts index 3167c09c59..1e66df7ac5 100644 --- a/playwright-ct.config.ts +++ b/playwright-ct.config.ts @@ -88,6 +88,12 @@ const config: PlaywrightTestConfig = defineConfig({ { find: '/playwright/index.ts', replacement: './playwright/index.ts' }, { find: '/playwright/envs.js', replacement: './playwright/envs.js' }, + + // Fix for @libp2p/utils missing merge-options export + { find: '@libp2p/utils/merge-options', replacement: 'merge-options' }, + + // Mock for @helia/verified-fetch to avoid build issues in tests + { find: '@helia/verified-fetch', replacement: './playwright/mocks/modules/@helia/verified-fetch.js' }, ], }, define: { diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index 6192526d1f..1202c985fc 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -113,6 +113,9 @@ export const ENVS_MAP: Record> = { celo: [ [ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ], ], + clusters: [ + [ 'NEXT_PUBLIC_CLUSTERS_API_HOST', 'https://api.clusters.xyz' ], + ], navigationPromoBannerText: [ [ 'NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG', '{"img_url": "http://localhost:3000/image.svg", "text": "Try the DUCK!", "bg_color": {"light": "rgb(150, 211, 255)", "dark": "rgb(68, 51, 122)"}, "text_color": {"light": "rgb(69, 69, 69)", "dark": "rgb(233, 216, 253)"}, "link_url": "https://example.com"}' ], ], diff --git a/playwright/mocks/modules/@helia/verified-fetch.js b/playwright/mocks/modules/@helia/verified-fetch.js new file mode 100644 index 0000000000..b48b48999e --- /dev/null +++ b/playwright/mocks/modules/@helia/verified-fetch.js @@ -0,0 +1 @@ +export const verifiedFetch = () => Promise.resolve(new Response()); diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index da0700a74e..03bc4ad5d9 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -38,6 +38,7 @@ | "clock-light" | "clock" | "close" + | "clusters" | "coins/bitcoin" | "collection" | "columns" diff --git a/stubs/clusters.ts b/stubs/clusters.ts new file mode 100644 index 0000000000..99a896ba06 --- /dev/null +++ b/stubs/clusters.ts @@ -0,0 +1,12 @@ +import type { ClustersLeaderboardObject } from 'types/api/clusters'; + +export const CLUSTER_ITEM: ClustersLeaderboardObject = { + name: 'example.cluster', + clusterId: '0x1234567890123456789012345678901234567890123456789012345678901234', + isTestnet: false, + totalWeiAmount: '1000000000000000000', + totalReferralAmount: '100000000000000000', + chainIds: [ '1', '137', '42161' ], + nameCount: '10', + rank: 1, +}; diff --git a/toolkit/components/forms/validators/address.ts b/toolkit/components/forms/validators/address.ts index d85d1858ae..28da0a75fd 100644 --- a/toolkit/components/forms/validators/address.ts +++ b/toolkit/components/forms/validators/address.ts @@ -1,4 +1,4 @@ -export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; export const ADDRESS_LENGTH = 42; diff --git a/toolkit/theme/foundations/colors.ts b/toolkit/theme/foundations/colors.ts index a957d1c37c..e0c6736d4b 100644 --- a/toolkit/theme/foundations/colors.ts +++ b/toolkit/theme/foundations/colors.ts @@ -157,6 +157,7 @@ const colors = { medium: { value: '#231F20' }, reddit: { value: '#FF4500' }, celo: { value: '#FCFF52' }, + clusters: { value: '#DE6061' }, }; export default colors; diff --git a/toolkit/utils/regexp.ts b/toolkit/utils/regexp.ts index 6667a6e064..13f0d37e6b 100644 --- a/toolkit/utils/regexp.ts +++ b/toolkit/utils/regexp.ts @@ -8,3 +8,5 @@ export const HEX_REGEXP_WITH_0X = /^0x[\da-fA-F]+$/; export const FILE_EXTENSION = /\.([\da-z]+)$/i; export const BLOCK_HEIGHT = /^\d+$/; + +export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/; diff --git a/tools/scripts/pw.docker.deps.sh b/tools/scripts/pw.docker.deps.sh index 719930361f..f17da531c7 100755 --- a/tools/scripts/pw.docker.deps.sh +++ b/tools/scripts/pw.docker.deps.sh @@ -1,3 +1,17 @@ #!/bin/bash + +# Install build tools required for native dependencies +apt-get update && apt-get install -y \ + build-essential \ + python3 \ + cmake \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* -yarn install --modules-folder node_modules_linux +# Set environment variables to help with native compilation +export npm_config_build_from_source=false +export npm_config_prefer_offline=true +export NODE_PATH=$(pwd)/node_modules_linux + +yarn install --modules-folder node_modules_linux \ No newline at end of file diff --git a/types/api/clusters.ts b/types/api/clusters.ts new file mode 100644 index 0000000000..275213b7ba --- /dev/null +++ b/types/api/clusters.ts @@ -0,0 +1,119 @@ +export interface ClustersByAddressObject { + name: string; + owner: string; + totalWeiAmount: string; + createdAt: string; + updatedAt: string; + updatedBy: string; + isTestnet: boolean; + clusterId: string; + expiresAt: string | null; +} + +export interface ClustersByAddressResponse { + result: { + data: Array; + }; +} + +export interface ClusterByNameResponse { + result: { + data: { + name: string; + owner: string; + clusterId: string; + backingWei: string; + expiresAt: string | null; + createdAt: string; + updatedAt: string; + updatedBy: string; + isTestnet: boolean; + }; + }; +} + +export interface ClusterByIdQueryParams { + id: string; +} + +export interface ClusterByIdResponse { + result: { + data: { + id: string; + createdBy: string; + createdAt: string; + wallets: Array<{ + address: string; + name: string; + chainIds: Array; + }>; + isTestnet: boolean; + }; + }; +} + +export interface ClustersLeaderboardObject { + name: string; + clusterId: string; + isTestnet: boolean; + totalWeiAmount: string; + totalReferralAmount: string; + chainIds: Array; + nameCount: string; + rank: number; +} + +export interface ClustersLeaderboardResponse { + result: { + data: Array; + }; +} + +export interface ClustersDirectoryObject { + name: string; + isTestnet: boolean; + createdAt: string; + owner: string; + totalWeiAmount: string; + updatedAt: string; + updatedBy: string; + chainIds: Array; +} + +export interface ClustersDirectoryResponse { + result: { + data: { + total: number; + items: Array; + }; + }; +} + +export interface ClustersByAddressQueryParams { + address: string; +} + +export interface ClusterByNameQueryParams { + name: string; +} + +export enum ClustersOrderBy { + RANK_ASC = 'rank-asc', + CREATED_AT_DESC = 'createdAt-desc', + NAME_ASC = 'name-asc', +} + +export interface ClustersLeaderboardQueryParams { + offset?: number; + limit?: number; + orderBy?: ClustersOrderBy | string; + query?: string | null; + isExact?: boolean; +} + +export interface ClustersDirectoryQueryParams { + offset?: number; + limit?: number; + orderBy?: ClustersOrderBy | string; + query?: string | null; +} diff --git a/types/api/search.ts b/types/api/search.ts index da2952b899..21a787561d 100644 --- a/types/api/search.ts +++ b/types/api/search.ts @@ -11,6 +11,7 @@ export const SEARCH_RESULT_TYPES = { transaction: 'transaction', contract: 'contract', ens_domain: 'ens_domain', + cluster: 'cluster', label: 'label', user_operation: 'user_operation', blob: 'blob', @@ -80,6 +81,19 @@ export interface SearchResultDomain extends SearchResultAddressData { }; } +export interface SearchResultCluster extends SearchResultAddressData { + type: 'cluster'; + cluster_info: { + cluster_id: string; + name: string; + owner: string; + created_at?: string; + expires_at?: string | null; + total_wei_amount?: string; + is_testnet?: boolean; + }; +} + export interface SearchResultLabel { type: 'label'; address_hash: string; @@ -127,6 +141,7 @@ export type SearchResultItem = SearchResultUserOp | SearchResultBlob | SearchResultDomain | + SearchResultCluster | SearchResultMetadataTag | SearchResultTacOperation; diff --git a/ui/address/clusters/AddressClusters.tsx b/ui/address/clusters/AddressClusters.tsx new file mode 100644 index 0000000000..4984f43a14 --- /dev/null +++ b/ui/address/clusters/AddressClusters.tsx @@ -0,0 +1,114 @@ +import { Grid, chakra } from '@chakra-ui/react'; +import type { UseQueryResult } from '@tanstack/react-query'; +import React from 'react'; + +import type { ClustersByAddressResponse } from 'types/api/clusters'; + +import { route } from 'nextjs-routes'; + +import type { ResourceError } from 'lib/api/resources'; +import { + filterOwnedClusters, + getTotalRecordsDisplay, + getClusterLabel, + getClustersToShow, + getGridRows, + hasMoreClusters, +} from 'lib/clusters/clustersUtils'; +import { Button } from 'toolkit/chakra/button'; +import { Link } from 'toolkit/chakra/link'; +import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; +import IconSvg from 'ui/shared/IconSvg'; + +interface Props { + query: UseQueryResult>; + addressHash: string; +} + +interface ClustersGridProps { + data: ClustersByAddressResponse['result']['data']; +} + +const ClustersGrid = ({ data }: ClustersGridProps) => { + const itemsToShow = getClustersToShow(data, 10); + const numberOfRows = getGridRows(itemsToShow.length, 5); + + return ( + + { itemsToShow.map((cluster) => ( + + )) } + + ); +}; + +const AddressClusters = ({ query, addressHash }: Props) => { + const { data, isPending, isError } = query; + + if (isError) { + return null; + } + + if (isPending) { + return ; + } + + if (!data?.result?.data || data.result.data.length === 0) { + return null; + } + + const ownedClusters = filterOwnedClusters(data.result.data, addressHash); + + if (ownedClusters.length === 0) { + return null; + } + + const totalRecords = getTotalRecordsDisplay(ownedClusters.length); + const clusterLabel = getClusterLabel(ownedClusters.length); + const showMoreLink = hasMoreClusters(ownedClusters.length, 10); + + return ( + + +
+ + + +
+
+ + +
+ Attached to this address + +
+ { showMoreLink && ( + + More results + ({ totalRecords }) + + ) } +
+
+
+ ); +}; + +export default AddressClusters; diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index 19542f413c..232d39e1cd 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png index d3c5e18b05..d128dfc389 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index 381c1aef0b..5317b163e3 100644 Binary files a/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/ui/address/contract/methods/form/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/cluster/ClusterDetails.tsx b/ui/cluster/ClusterDetails.tsx new file mode 100644 index 0000000000..9e3b7ebe4e --- /dev/null +++ b/ui/cluster/ClusterDetails.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import type { ClusterByNameResponse } from 'types/api/clusters'; + +import { isEvmAddress } from 'lib/address/isEvmAddress'; +import { currencyUnits } from 'lib/units'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import CurrencyValue from 'ui/shared/CurrencyValue'; +import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo'; +import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; + +interface Props { + clusterData?: ClusterByNameResponse['result']['data']; + clusterName: string; + isLoading: boolean; +} + +const ClusterDetails = ({ clusterData, clusterName, isLoading }: Props) => { + if (!clusterData && !isLoading) { + throw new Error('Cluster not found', { cause: { status: 404 } }); + } + + const ownerIsEvm = clusterData?.owner ? isEvmAddress(clusterData.owner) : false; + const addressType = ownerIsEvm ? 'EVM' : 'NON-EVM'; + + return ( + + + Cluster Name + + + + + + + Owner address + + + + + + + Type + + + + { addressType } + + + + + Backing + + + + + + + Created + + + { clusterData?.createdAt ? ( + + ) : ( + N/A + ) } + + + ); +}; + +export default ClusterDetails; diff --git a/ui/clusters/ClustersActionBar.tsx b/ui/clusters/ClustersActionBar.tsx new file mode 100644 index 0000000000..1e2076b3a7 --- /dev/null +++ b/ui/clusters/ClustersActionBar.tsx @@ -0,0 +1,90 @@ +import { Flex, VStack, Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { PaginationParams } from 'ui/shared/pagination/types'; + +import { + getSearchPlaceholder, + shouldShowActionBar, +} from 'lib/clusters/actionBarUtils'; +import useIsInitialLoading from 'lib/hooks/useIsInitialLoading'; +import { Button, ButtonGroupRadio } from 'toolkit/chakra/button'; +import { FilterInput } from 'toolkit/components/filters/FilterInput'; +import ActionBar from 'ui/shared/ActionBar'; +import Pagination from 'ui/shared/pagination/Pagination'; + +type ViewMode = 'leaderboard' | 'directory'; + +interface Props { + pagination: PaginationParams; + searchTerm: string | undefined; + onSearchChange: (value: string) => void; + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; + isLoading: boolean; +} + +const ClustersActionBar = ({ + searchTerm, + onSearchChange, + viewMode, + onViewModeChange, + isLoading, + pagination, +}: Props) => { + const isInitialLoading = useIsInitialLoading(isLoading); + + const handleViewModeChange = React.useCallback((value: string) => { + onViewModeChange(value as ViewMode); + }, [ onViewModeChange ]); + + const placeholder = getSearchPlaceholder(); + const showActionBarOnMobile = shouldShowActionBar(pagination.isVisible, false); + const showActionBarOnDesktop = shouldShowActionBar(pagination.isVisible, true); + + const filters = ( + + + + + + + + ); + + return ( + <> + + { filters } + + + + { filters } + + + + + ); +}; + +export default React.memo(ClustersActionBar); diff --git a/ui/clusters/ClustersDirectoryListItem.tsx b/ui/clusters/ClustersDirectoryListItem.tsx new file mode 100644 index 0000000000..3a5be5047f --- /dev/null +++ b/ui/clusters/ClustersDirectoryListItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import type { ClustersDirectoryObject } from 'types/api/clusters'; + +import { isEvmAddress } from 'lib/address/isEvmAddress'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; +import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; + +interface Props { + item: ClustersDirectoryObject; + isLoading?: boolean; + isClusterDetailsLoading?: boolean; +} + +const ClustersDirectoryListItem = ({ item, isLoading, isClusterDetailsLoading }: Props) => { + return ( + + + Cluster Name + + + + + + + Address + + + { item.owner && ( + + ) } + { !item.owner && — } + + + + Joined + + + + + + + Active Chains + + + + { (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersDirectoryListItem); diff --git a/ui/clusters/ClustersDirectoryTable.tsx b/ui/clusters/ClustersDirectoryTable.tsx new file mode 100644 index 0000000000..ab643f6260 --- /dev/null +++ b/ui/clusters/ClustersDirectoryTable.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { ClustersDirectoryObject } from 'types/api/clusters'; + +import { TableBody, TableHeaderSticky, TableRow, TableColumnHeader, TableRoot } from 'toolkit/chakra/table'; +import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle'; + +import ClustersDirectoryTableItem from './ClustersDirectoryTableItem'; + +interface Props { + data: Array; + isLoading?: boolean; + top?: number; + isClusterDetailsLoading?: boolean; +} + +const ClustersDirectoryTable = ({ data, isLoading, top, isClusterDetailsLoading }: Props) => { + return ( + + + + Cluster Name + Address + + Joined + + + Active Chains + + + + { data.map((item, index) => ( + + )) } + + + ); +}; + +export default React.memo(ClustersDirectoryTable); diff --git a/ui/clusters/ClustersDirectoryTableItem.tsx b/ui/clusters/ClustersDirectoryTableItem.tsx new file mode 100644 index 0000000000..b4a43ccd0e --- /dev/null +++ b/ui/clusters/ClustersDirectoryTableItem.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import type { ClustersDirectoryObject } from 'types/api/clusters'; + +import { isEvmAddress } from 'lib/address/isEvmAddress'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { TableCell, TableRow } from 'toolkit/chakra/table'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; +import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip'; + +interface Props { + item: ClustersDirectoryObject; + isLoading?: boolean; + isClusterDetailsLoading?: boolean; +} + +const ClustersDirectoryTableItem = ({ item, isLoading, isClusterDetailsLoading }: Props) => { + return ( + + + + + + { item.owner && ( + + ) } + { !item.owner && — } + + + + + + + { (item.chainIds?.length || 1) } { (item.chainIds?.length || 1) === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersDirectoryTableItem); diff --git a/ui/clusters/ClustersLeaderboardListItem.tsx b/ui/clusters/ClustersLeaderboardListItem.tsx new file mode 100644 index 0000000000..518e13734b --- /dev/null +++ b/ui/clusters/ClustersLeaderboardListItem.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import type { ClustersLeaderboardObject } from 'types/api/clusters'; + +import { Skeleton } from 'toolkit/chakra/skeleton'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; +import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; + +interface Props { + item: ClustersLeaderboardObject; + isLoading?: boolean; +} + +const ClustersLeaderboardListItem = ({ item, isLoading }: Props) => { + return ( + + + Rank + + + + #{ item.rank } + + + + + Cluster Name + + + + + + + Names + + + + { item.nameCount } + + + + + Backing + + + + { (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH + + + + + Network Presence + + + + { item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersLeaderboardListItem); diff --git a/ui/clusters/ClustersLeaderboardTable.tsx b/ui/clusters/ClustersLeaderboardTable.tsx new file mode 100644 index 0000000000..f667a5cab6 --- /dev/null +++ b/ui/clusters/ClustersLeaderboardTable.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import type { ClustersLeaderboardObject } from 'types/api/clusters'; + +import { TableBody, TableHeaderSticky, TableRow, TableColumnHeader, TableRoot } from 'toolkit/chakra/table'; + +import ClustersLeaderboardTableItem from './ClustersLeaderboardTableItem'; + +interface Props { + data: Array; + isLoading?: boolean; + top?: number; +} + +const ClustersLeaderboardTable = ({ data, isLoading, top }: Props) => { + return ( + + + + Rank + Cluster Name + Names + Total Backing + Active Chains + + + + { data.map((item, index) => ( + + )) } + + + ); +}; + +export default React.memo(ClustersLeaderboardTable); diff --git a/ui/clusters/ClustersLeaderboardTableItem.tsx b/ui/clusters/ClustersLeaderboardTableItem.tsx new file mode 100644 index 0000000000..b75c57d6a3 --- /dev/null +++ b/ui/clusters/ClustersLeaderboardTableItem.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import type { ClustersLeaderboardObject } from 'types/api/clusters'; + +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { TableCell, TableRow } from 'toolkit/chakra/table'; +import ClustersEntity from 'ui/shared/entities/clusters/ClustersEntity'; + +interface Props { + item: ClustersLeaderboardObject; + isLoading?: boolean; +} + +const ClustersLeaderboardTableItem = ({ item, isLoading }: Props) => { + return ( + + + + #{ item.rank } + + + + + + + + { item.nameCount } + + + + + { (parseFloat(item.totalWeiAmount) / 1e18).toFixed(2) } ETH + + + + + { item.chainIds.length } { item.chainIds.length === 1 ? 'chain' : 'chains' } + + + + ); +}; + +export default React.memo(ClustersLeaderboardTableItem); diff --git a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png index 7b1decf963..a33ded8c18 100644 Binary files a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png and b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png differ diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 6dee5cca94..24e3af08ee 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -10,6 +10,7 @@ import getCheckedSummedAddress from 'lib/address/getCheckedSummedAddress'; import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import useAddressMetadataInitUpdate from 'lib/address/useAddressMetadataInitUpdate'; import useApiQuery from 'lib/api/useApiQuery'; +import { useAddressClusters } from 'lib/clusters/useAddressClusters'; import { useMultichainContext } from 'lib/contexts/multichain'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; @@ -38,6 +39,7 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTxs from 'ui/address/AddressTxs'; import AddressUserOps from 'ui/address/AddressUserOps'; import AddressWithdrawals from 'ui/address/AddressWithdrawals'; +import AddressClusters from 'ui/address/clusters/AddressClusters'; import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert'; @@ -131,6 +133,8 @@ const AddressPageContent = () => { addressEnsDomainsQuery.data?.items.find((domain) => domain.name === addressQuery.data?.ens_domain_name) : undefined; + const addressClustersQuery = useAddressClusters(hash, areQueriesEnabled); + const address3rdPartyWidgets = useAddress3rdPartyWidgets( addressQuery.data?.is_contract ? 'contract' : 'eoa', addressQuery.isPlaceholderData, @@ -431,6 +435,8 @@ const AddressPageContent = () => { } { !isLoading && addressEnsDomainsQuery.data && config.features.nameService.isEnabled && } + { !isLoading && addressClustersQuery.data && config.features.clusters.isEnabled && + } ); diff --git a/ui/pages/Cluster.pw.tsx b/ui/pages/Cluster.pw.tsx new file mode 100644 index 0000000000..4562d4f57b --- /dev/null +++ b/ui/pages/Cluster.pw.tsx @@ -0,0 +1,83 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { campNetworkClusterByName, testnetClusterByName } from 'mocks/clusters/cluster'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import Cluster from './Cluster'; + +test.beforeEach(async({ mockEnvs, mockTextAd }) => { + await mockEnvs(ENVS_MAP.clusters); + await mockTextAd(); +}); + +test.describe('Cluster Details Page', () => { + test('mainnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/profile-image/campnetwork/lol', + './playwright/mocks/image_s.jpg', + ); + await mockApiResponse('clusters:get_cluster_by_name', campNetworkClusterByName, { + queryParams: { + input: JSON.stringify({ name: 'campnetwork/lol' }), + }, + }); + + const component = await render( +
+ + +
, + { + hooksConfig: { + router: { + query: { name: 'campnetwork/lol' }, + isReady: true, + }, + }, + }, + ); + + await expect(component.getByText('campnetwork/lol').first()).toBeVisible(); + await expect(component.getByText('Cluster Name')).toBeVisible(); + await expect(component.getByText('Owner address')).toBeVisible(); + await expect(component.getByText('Backing')).toBeVisible(); + + await expect(component).toHaveScreenshot(); + }); + + test('testnet cluster details +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/profile-image/test/cluster', + './playwright/mocks/image_s.jpg', + ); + await mockApiResponse('clusters:get_cluster_by_name', testnetClusterByName, { + queryParams: { + input: JSON.stringify({ name: 'test/cluster' }), + }, + }); + + const component = await render( +
+ + +
, + { + hooksConfig: { + router: { + query: { name: 'test/cluster' }, + isReady: true, + }, + }, + }, + ); + + await expect(component.getByText('test/cluster').first()).toBeVisible(); + await expect(component.getByText('Cluster Name')).toBeVisible(); + await expect(component.getByText('Owner address')).toBeVisible(); + await expect(component.getByText('Backing')).toBeVisible(); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/pages/Cluster.tsx b/ui/pages/Cluster.tsx new file mode 100644 index 0000000000..c8576d1f27 --- /dev/null +++ b/ui/pages/Cluster.tsx @@ -0,0 +1,38 @@ +import { useRouter } from 'next/router'; +import React from 'react'; + +import useApiQuery from 'lib/api/useApiQuery'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import ClusterDetails from 'ui/cluster/ClusterDetails'; +import TextAd from 'ui/shared/ad/TextAd'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +const Cluster = () => { + const router = useRouter(); + const encodedClusterName = getQueryParamString(router.query.name); + const clusterName = decodeURIComponent(encodedClusterName || ''); + + const clusterQuery = useApiQuery('clusters:get_cluster_by_name', { + queryParams: { + input: JSON.stringify({ name: clusterName }), + }, + }); + + const clusterData = clusterQuery.data?.result?.data; + + const isLoading = clusterQuery.isLoading; + + return ( + <> + + + + + ); +}; + +export default Cluster; diff --git a/ui/pages/Clusters.pw.tsx b/ui/pages/Clusters.pw.tsx new file mode 100644 index 0000000000..d0dde27ab4 --- /dev/null +++ b/ui/pages/Clusters.pw.tsx @@ -0,0 +1,61 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { clustersDirectoryMock } from 'mocks/clusters/directory'; +import { clustersLeaderboardMock } from 'mocks/clusters/leaderboard'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; +import { test, expect } from 'playwright/lib'; + +import Clusters from './Clusters'; + +test.beforeEach(async({ mockEnvs, mockTextAd }) => { + await mockEnvs(ENVS_MAP.clusters); + await mockTextAd(); +}); + +test.describe('Clusters Directory Page', () => { + test('clusters directory with data +@mobile', async({ render, mockApiResponse, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/campnetwork/lol', './playwright/mocks/image_s.jpg'); + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/duck/quack', './playwright/mocks/image_s.jpg'); + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/test/cluster', './playwright/mocks/image_s.jpg'); + await mockApiResponse('clusters:get_directory', clustersDirectoryMock, { + queryParams: { + input: JSON.stringify({ + offset: 0, + limit: 50, + orderBy: 'createdAt-desc', + query: '', + }), + }, + }); + await mockApiResponse('clusters:get_leaderboard', clustersLeaderboardMock, { + queryParams: { + input: JSON.stringify({ + offset: 0, + limit: 50, + orderBy: 'rank-asc', + }), + }, + }); + + const component = await render( +
+ + +
, + { + hooksConfig: { + router: { + isReady: true, + }, + }, + }, + ); + + await expect(component.getByRole('link', { name: 'campnetwork/lol' })).toBeVisible({ timeout: 10000 }); + await expect(component.getByRole('link', { name: 'duck/quack' })).toBeVisible({ timeout: 10000 }); + await expect(component.getByRole('link', { name: 'test/cluster' })).toBeVisible({ timeout: 10000 }); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/pages/Clusters.tsx b/ui/pages/Clusters.tsx new file mode 100644 index 0000000000..82c601349b --- /dev/null +++ b/ui/pages/Clusters.tsx @@ -0,0 +1,156 @@ +import { Box } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React, { useCallback } from 'react'; + +import { detectInputType } from 'lib/clusters/detectInputType'; +import { + shouldShowDirectoryView, + transformLeaderboardData, + transformFullDirectoryData, + applyDirectoryPagination, + calculateHasNextPage, + getCurrentDataLength, +} from 'lib/clusters/pageUtils'; +import type { ViewMode } from 'lib/clusters/pageUtils'; +import { useClusterPagination } from 'lib/clusters/useClusterPagination'; +import { useClustersData } from 'lib/clusters/useClustersData'; +import { useClusterSearch } from 'lib/clusters/useClusterSearch'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { useQueryParams } from 'lib/router/useQueryParams'; +import { apos } from 'toolkit/utils/htmlEntities'; +import ClustersActionBar from 'ui/clusters/ClustersActionBar'; +import ClustersDirectoryListItem from 'ui/clusters/ClustersDirectoryListItem'; +import ClustersDirectoryTable from 'ui/clusters/ClustersDirectoryTable'; +import ClustersLeaderboardListItem from 'ui/clusters/ClustersLeaderboardListItem'; +import ClustersLeaderboardTable from 'ui/clusters/ClustersLeaderboardTable'; +import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +const Clusters = () => { + const router = useRouter(); + const { updateQuery } = useQueryParams(); + + const { searchTerm, debouncedSearchTerm } = useClusterSearch(); + const viewMode = (getQueryParamString(router.query.view) as ViewMode) || 'directory'; + const page = parseInt(getQueryParamString(router.query.page) || '1', 10); + + const inputType = React.useMemo(() => { + if (!debouncedSearchTerm) return 'cluster_name'; + return detectInputType(debouncedSearchTerm); + }, [ debouncedSearchTerm ]); + + const { data, clusterDetails, isError, isLoading, isClusterDetailsLoading } = useClustersData(debouncedSearchTerm, viewMode, page); + + const showDirectoryView = shouldShowDirectoryView(viewMode, Boolean(debouncedSearchTerm)); + + const leaderboardData = transformLeaderboardData(data, showDirectoryView); + + const fullDirectoryData = transformFullDirectoryData(data, clusterDetails, inputType, showDirectoryView); + + const limit = 50; + + const directoryData = applyDirectoryPagination(fullDirectoryData, inputType, page, limit); + + const currentDataLength = getCurrentDataLength(showDirectoryView, directoryData.length, leaderboardData.length); + + const hasNextPage = calculateHasNextPage( + data, + leaderboardData.length, + fullDirectoryData.length, + showDirectoryView, + inputType, + page, + Boolean(debouncedSearchTerm), + limit, + ); + + const { pagination } = useClusterPagination(hasNextPage, isLoading); + + const handleViewModeChange = useCallback((newViewMode: ViewMode) => { + updateQuery({ + view: newViewMode === 'directory' ? undefined : newViewMode, + page: undefined, + }); + }, [ updateQuery ]); + + const handleSearchChange = useCallback((value: string) => { + updateQuery({ + q: value || undefined, + page: undefined, + }); + }, [ updateQuery ]); + + const hasActiveFilters = Boolean(debouncedSearchTerm); + + const content = ( + <> + + { showDirectoryView ? ( + directoryData.map((item, index) => ( + + )) + ) : ( + leaderboardData.map((item, index) => ( + + )) + ) } + + + { showDirectoryView ? ( + + ) : ( + + ) } + + + ); + + const actionBar = ( + + ); + + return ( + <> + + + { content } + + + ); +}; + +export default Clusters; diff --git a/ui/pages/NameDomains.tsx b/ui/pages/NameDomains.tsx index 68f496fba0..e256d03038 100644 --- a/ui/pages/NameDomains.tsx +++ b/ui/pages/NameDomains.tsx @@ -10,8 +10,8 @@ import useDebounce from 'lib/hooks/useDebounce'; import getQueryParamString from 'lib/router/getQueryParamString'; import { ENS_DOMAIN } from 'stubs/ENS'; import { generateListStub } from 'stubs/utils'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; import { apos } from 'toolkit/utils/htmlEntities'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import NameDomainsActionBar from 'ui/nameDomains/NameDomainsActionBar'; import NameDomainsListItem from 'ui/nameDomains/NameDomainsListItem'; import NameDomainsTable from 'ui/nameDomains/NameDomainsTable'; diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..d3f17df558 Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..45b3fc4520 Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_default_Cluster-Details-Page-testnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..3f11f96abd Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-mainnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png new file mode 100644 index 0000000000..e055c8c04f Binary files /dev/null and b/ui/pages/__screenshots__/Cluster.pw.tsx_mobile_Cluster-Details-Page-testnet-cluster-details-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png b/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png new file mode 100644 index 0000000000..02389ef87b Binary files /dev/null and b/ui/pages/__screenshots__/Clusters.pw.tsx_default_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png b/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png new file mode 100644 index 0000000000..887d0ecca1 Binary files /dev/null and b/ui/pages/__screenshots__/Clusters.pw.tsx_mobile_Clusters-Directory-Page-clusters-directory-with-data-mobile-1.png differ diff --git a/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png b/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png index 52d7a4ffbe..d95a2e2b9b 100644 Binary files a/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png and b/ui/pages/__screenshots__/Home.pw.tsx_default_default-view-screen-xl-base-view-1.png differ diff --git a/ui/searchResults/SearchResultListItem.tsx b/ui/searchResults/SearchResultListItem.tsx index 8f34864104..2e3684ade8 100644 --- a/ui/searchResults/SearchResultListItem.tsx +++ b/ui/searchResults/SearchResultListItem.tsx @@ -17,7 +17,7 @@ import { Image } from 'toolkit/chakra/image'; import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { Tag } from 'toolkit/chakra/tag'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; diff --git a/ui/searchResults/SearchResultTableItem.tsx b/ui/searchResults/SearchResultTableItem.tsx index df8362985f..287c0e1f16 100644 --- a/ui/searchResults/SearchResultTableItem.tsx +++ b/ui/searchResults/SearchResultTableItem.tsx @@ -18,7 +18,7 @@ import { Link } from 'toolkit/chakra/link'; import { Skeleton } from 'toolkit/chakra/skeleton'; import { TableCell, TableRow } from 'toolkit/chakra/table'; import { Tag } from 'toolkit/chakra/tag'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity'; diff --git a/ui/shared/ClusterIcon.tsx b/ui/shared/ClusterIcon.tsx new file mode 100644 index 0000000000..474f896dcd --- /dev/null +++ b/ui/shared/ClusterIcon.tsx @@ -0,0 +1,63 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { getFeaturePayload } from 'configs/app/features/types'; + +import config from 'configs/app'; +import { Image } from 'toolkit/chakra/image'; +import type { ImageProps } from 'toolkit/chakra/image'; +import IconSvg from 'ui/shared/IconSvg'; + +interface ClusterIconProps extends Omit { + clusterName: string; +} + +const ClusterIcon = ({ + clusterName, + boxSize = 5, + borderRadius = 'base', + mr = 2, + flexShrink = 0, + ...imageProps +}: ClusterIconProps) => { + const clustersFeature = getFeaturePayload(config.features.clusters); + + const fallbackElement = ( + + + + ); + + if (!clustersFeature) { + return fallbackElement; + } + + return ( + { + ); +}; + +export default React.memo(ClusterIcon); diff --git a/ui/shared/entities/clusters/ClustersEntity.pw.tsx b/ui/shared/entities/clusters/ClustersEntity.pw.tsx new file mode 100644 index 0000000000..f036d68b41 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.pw.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { longNameCluster } from 'mocks/clusters/directory'; +import { test, expect } from 'playwright/lib'; + +import ClustersEntity from './ClustersEntity'; + +test.use({ viewport: { width: 300, height: 200 } }); + +test.describe('basic display', () => { + test('basic cluster entity', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); + + test('cluster with subdomain', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/test/subdomain', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('test/subdomain')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); +}); + +test.describe('variants', () => { + test('heading variant', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); + }); + + test('subheading variant', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); + }); +}); + +test.describe('customization', () => { + test('no link', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); + + test('no icon', async({ render }) => { + const component = await render( + , + ); + + await expect(component.getByText('example.cluster/')).toBeVisible(); + await expect(component).toHaveScreenshot(); + }); +}); + +test('long cluster name truncation', async({ render, mockAssetResponse }) => { + await mockAssetResponse( + 'https://cdn.clusters.xyz/profile-image/this-is-a-very-long-cluster-name-that-should-test-truncation/subdomain', './playwright/mocks/image_s.jpg', + ); + const component = await render( + , + ); + + await expect(component.getByText(/this-is-a-very-long/)).toBeVisible(); + await expect(component).toHaveScreenshot(); +}); + +test('hover interaction', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await component.hover(); + await expect(component).toHaveScreenshot(); +}); + +test('dark mode +@dark-mode', async({ render, mockAssetResponse }) => { + await mockAssetResponse('https://cdn.clusters.xyz/profile-image/example.cluster', './playwright/mocks/image_s.jpg'); + const component = await render( + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/entities/clusters/ClustersEntity.test.tsx b/ui/shared/entities/clusters/ClustersEntity.test.tsx new file mode 100644 index 0000000000..bfa2fbba57 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { render, screen } from 'jest/lib'; + +import ClustersEntity from './ClustersEntity'; + +describe('ClustersEntity', () => { + const mockClusterName = 'test-cluster'; + + it('should render cluster name with slash', () => { + render(); + + expect(screen.getByText('test-cluster/')).toBeTruthy(); + }); + + it('should render cluster icon', () => { + render(); + + const icon = screen.getByAltText('test-cluster profile'); + expect(icon).toBeTruthy(); + }); + + it('should link to cluster details page', () => { + render(); + + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('/clusters/test-cluster'); + }); + + it('should render without link when noLink is true', () => { + render(); + + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.getByText('test-cluster/')).toBeTruthy(); + }); + + it('should show loading skeleton when loading', () => { + render(); + + const skeletons = document.querySelectorAll('.chakra-skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); diff --git a/ui/shared/entities/clusters/ClustersEntity.tsx b/ui/shared/entities/clusters/ClustersEntity.tsx new file mode 100644 index 0000000000..db040e78d0 --- /dev/null +++ b/ui/shared/entities/clusters/ClustersEntity.tsx @@ -0,0 +1,192 @@ +import { Box, chakra, Flex, Text } from '@chakra-ui/react'; +import React from 'react'; + +import { getFeaturePayload } from 'configs/app/features/types'; + +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import { Image } from 'toolkit/chakra/image'; +import { Link as LinkToolkit } from 'toolkit/chakra/link'; +import { Skeleton } from 'toolkit/chakra/skeleton'; +import { Tooltip } from 'toolkit/chakra/tooltip'; +import * as EntityBase from 'ui/shared/entities/base/components'; +import IconSvg from 'ui/shared/IconSvg'; + +import { distributeEntityProps, getIconProps } from '../base/utils'; + +type LinkProps = EntityBase.LinkBaseProps & Pick; + +const Link = chakra((props: LinkProps) => { + const defaultHref = route({ pathname: '/clusters/[name]', query: { name: encodeURIComponent(props.clusterName) } }); + + return ( + + { props.children } + + ); +}); + +type IconProps = EntityBase.IconBaseProps & Pick; + +const Icon = (props: IconProps) => { + if (props.noIcon) { + return null; + } + + const styles = getIconProps(props.variant); + + if (props.isLoading) { + return ; + } + + const fallbackElement = ( + + + + ); + + const profileImageElement = ( + { + ); + + const tooltipContent = ( + <> + + + + +
+ Clusters + - Universal name service +
+
+ + Clusters provides unified naming across multiple blockchains including EVM, Solana, Bitcoin, and more. + Manage all your wallet addresses under one human-readable name. + + + + Learn more about Clusters + + + ); + + return ( + + { profileImageElement } + + ); +}; + +type ContentProps = Omit & Pick; + +const Content = chakra((props: ContentProps) => { + const shouldShowTrailingSlash = !props.clusterName.includes('/'); + const displayName = shouldShowTrailingSlash ? `${ props.clusterName }/` : props.clusterName; + + return ( + + ); +}); + +type CopyProps = Omit & Pick; + +const Copy = (props: CopyProps) => { + return ( + + ); +}; + +const Container = EntityBase.Container; + +export interface EntityProps extends EntityBase.EntityBaseProps { + clusterName: string; +} + +const ClustersEntity = (props: EntityProps) => { + const partsProps = distributeEntityProps(props); + const content = ; + + return ( + + + { props.noLink ? content : { content } } + + + ); +}; + +export default React.memo(chakra(ClustersEntity)); + +export { + Container, + Link, + Icon, + Content, + Copy, +}; diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png new file mode 100644 index 0000000000..8648ebafb9 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_dark-color-mode_dark-mode-dark-mode-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-1.png new file mode 100644 index 0000000000..7c4efdb5b4 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-basic-cluster-entity-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-1.png new file mode 100644 index 0000000000..9bb4d70402 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_basic-display-cluster-with-subdomain-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-1.png new file mode 100644 index 0000000000..14ced05750 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-icon-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-1.png new file mode 100644 index 0000000000..46fe088691 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_customization-no-link-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png new file mode 100644 index 0000000000..7c4efdb5b4 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_dark-mode-dark-mode-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-1.png new file mode 100644 index 0000000000..7c4efdb5b4 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_hover-interaction-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-1.png new file mode 100644 index 0000000000..4e9803beeb Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_long-cluster-name-truncation-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-1.png new file mode 100644 index 0000000000..984f5e6d07 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-heading-variant-1.png differ diff --git a/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-1.png b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-1.png new file mode 100644 index 0000000000..db01670561 Binary files /dev/null and b/ui/shared/entities/clusters/__screenshots__/ClustersEntity.pw.tsx_default_variants-subheading-variant-1.png differ diff --git a/ui/shared/search/utils.ts b/ui/shared/search/utils.ts index 7c387b5ad3..1d07877b04 100644 --- a/ui/shared/search/utils.ts +++ b/ui/shared/search/utils.ts @@ -3,7 +3,18 @@ import type { SearchResultItem } from 'types/client/search'; import config from 'configs/app'; -export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain' | 'tac_operation'; +export type ApiCategory = + | 'token' + | 'nft' + | 'address' + | 'public_tag' + | 'transaction' + | 'block' + | 'user_operation' + | 'blob' + | 'domain' + | 'cluster' + | 'tac_operation'; export type Category = ApiCategory | 'app'; export type ItemsCategoriesMap = @@ -38,9 +49,14 @@ if (config.features.nameService.isEnabled) { searchCategories.unshift({ id: 'domain', title: 'Names' }); } +if (config.features.clusters.isEnabled) { + searchCategories.unshift({ id: 'cluster', title: 'Cluster Name' }); +} + export const searchItemTitles: Record = { app: { itemTitle: 'DApp', itemTitleShort: 'App' }, domain: { itemTitle: 'Name', itemTitleShort: 'Name' }, + cluster: { itemTitle: 'Cluster', itemTitleShort: 'Cluster' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' }, nft: { itemTitle: 'NFT', itemTitleShort: 'NFT' }, address: { itemTitle: 'Address', itemTitleShort: 'Address' }, @@ -86,6 +102,9 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C case 'ens_domain': { return 'domain'; } + case 'cluster': { + return 'cluster'; + } case 'tac_operation': { return 'tac_operation'; } diff --git a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png index 4d4e18085d..a5f148b260 100644 Binary files a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png differ diff --git a/ui/snippets/navigation/NavLinkIcon.tsx b/ui/snippets/navigation/NavLinkIcon.tsx index 21ba3c7c50..7ee0deade2 100644 --- a/ui/snippets/navigation/NavLinkIcon.tsx +++ b/ui/snippets/navigation/NavLinkIcon.tsx @@ -12,7 +12,7 @@ interface Props { const NavLinkIcon = ({ item, className }: Props) => { if ('icon' in item && item.icon) { - return ; + return ; } if ('iconComponent' in item && item.iconComponent) { const IconComponent = item.iconComponent; diff --git a/ui/snippets/searchBar/SearchBar.tsx b/ui/snippets/searchBar/SearchBar.tsx index 418b675fbc..bc342668f7 100644 --- a/ui/snippets/searchBar/SearchBar.tsx +++ b/ui/snippets/searchBar/SearchBar.tsx @@ -19,7 +19,7 @@ import SearchBarBackdrop from './SearchBarBackdrop'; import SearchBarInput from './SearchBarInput'; import SearchBarRecentKeywords from './SearchBarRecentKeywords'; import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest'; -import useQuickSearchQuery from './useQuickSearchQuery'; +import useSearchWithClusters from './useSearchWithClusters'; type Props = { isHomepage?: boolean; @@ -38,7 +38,7 @@ const SearchBar = ({ isHomepage }: Props) => { const recentSearchKeywords = getRecentSearchKeywords(); - const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query } = useQuickSearchQuery(); + const { searchTerm, debouncedSearchTerm, handleSearchTermChange, query } = useSearchWithClusters(); const handleSubmit = React.useCallback((event: FormEvent) => { event.preventDefault(); diff --git a/ui/snippets/searchBar/SearchBarInput.tsx b/ui/snippets/searchBar/SearchBarInput.tsx index 1def4ef784..3908d5f750 100644 --- a/ui/snippets/searchBar/SearchBarInput.tsx +++ b/ui/snippets/searchBar/SearchBarInput.tsx @@ -4,6 +4,7 @@ import { throttle } from 'es-toolkit'; import React from 'react'; import type { ChangeEvent, FormEvent, FocusEvent } from 'react'; +import config from 'configs/app'; import { useScrollDirection } from 'lib/contexts/scrollDirection'; import useIsMobile from 'lib/hooks/useIsMobile'; import { Input } from 'toolkit/chakra/input'; @@ -101,6 +102,15 @@ const SearchBarInput = ( const transformMobile = scrollDirection !== 'down' ? 'translateY(0)' : 'translateY(-100%)'; + const getPlaceholder = () => { + if (isMobile) { + return 'Search by address / ... '; + } + + const clusterText = config.features.clusters.isEnabled ? ' / cluster ' : ''; + return `Search by address / txn hash / block / token${ clusterText }/... `; + }; + const startElement = ( topLimit) { + if (categoriesRefs.current[i]?.getBoundingClientRect().y <= topLimit && categoriesRefs.current[i + 1]?.getBoundingClientRect().y > topLimit) { const currentCategory = categoriesRefs.current[i]; const currentCategoryId = currentCategory.getAttribute('data-id'); if (currentCategoryId) { diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx index 7050386370..189e9ed52a 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestAddress.tsx @@ -7,7 +7,7 @@ import type { SearchResultAddressOrContract, SearchResultMetadataTag } from 'typ import { toBech32Address } from 'lib/address/bech32'; import dayjs from 'lib/date/dayjs'; import highlightText from 'lib/highlightText'; -import { ADDRESS_REGEXP } from 'toolkit/components/forms/validators/address'; +import { ADDRESS_REGEXP } from 'toolkit/utils/regexp'; import SearchResultEntityTag from 'ui/searchResults/SearchResultEntityTag'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx new file mode 100644 index 0000000000..09ca35a3b3 --- /dev/null +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestCluster.tsx @@ -0,0 +1,72 @@ +import { Grid, Text, Flex, Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { ItemsProps } from './types'; +import type { SearchResultCluster } from 'types/api/search'; + +import { toBech32Address } from 'lib/address/bech32'; +import { isEvmAddress } from 'lib/address/isEvmAddress'; +import highlightText from 'lib/highlightText'; +import ClusterIcon from 'ui/shared/ClusterIcon'; +import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; + +const SearchBarSuggestCluster = ({ data, searchTerm, addressFormat }: ItemsProps) => { + const hash = data.filecoin_robust_address || (addressFormat === 'bech32' ? toBech32Address(data.address_hash) : data.address_hash); + const isClickable = isEvmAddress(data.address_hash); + + const shouldShowTrailingSlash = searchTerm.trim().endsWith('/'); + const displayName = shouldShowTrailingSlash ? data.cluster_info.name + '/' : data.cluster_info.name; + const searchTermForHighlight = searchTerm.replace(/\/$/, ''); + + const containerProps = { + opacity: isClickable ? 1 : 0.6, + }; + + const icon = ; + + const name = ( + + + + ); + + const address = ( + + + + ); + + return ( + + + + { icon } + { name } + + + { address } + + + + ); +}; + +export default React.memo(SearchBarSuggestCluster); diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx index 2ae22ae287..11031e2533 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx @@ -5,9 +5,12 @@ import type { AddressFormat } from 'types/views/address'; import { route } from 'nextjs-routes'; +import { isEvmAddress } from 'lib/address/isEvmAddress'; + import SearchBarSuggestAddress from './SearchBarSuggestAddress'; import SearchBarSuggestBlob from './SearchBarSuggestBlob'; import SearchBarSuggestBlock from './SearchBarSuggestBlock'; +import SearchBarSuggestCluster from './SearchBarSuggestCluster'; import SearchBarSuggestDomain from './SearchBarSuggestDomain'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestLabel from './SearchBarSuggestLabel'; @@ -25,7 +28,6 @@ interface Props { } const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressFormat }: Props) => { - const url = (() => { switch (data.type) { case 'token': { @@ -57,6 +59,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm case 'ens_domain': { return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } }); } + case 'cluster': { + return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } }); + } case 'tac_operation': { return route({ pathname: '/operation/[id]', query: { id: data.tac_operation.operation_id } }); } @@ -112,12 +117,21 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm case 'ens_domain': { return ; } + case 'cluster': { + return ; + } case 'tac_operation': { return ; } } })(); + const hasLink = data.type === 'cluster' ? isEvmAddress(data.address_hash) : true; + + if (!hasLink) { + return content; + } + return ( { content } diff --git a/ui/snippets/searchBar/useSearchWithClusters.test.tsx b/ui/snippets/searchBar/useSearchWithClusters.test.tsx new file mode 100644 index 0000000000..b7042b54f8 --- /dev/null +++ b/ui/snippets/searchBar/useSearchWithClusters.test.tsx @@ -0,0 +1,655 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import { renderHook } from 'jest/lib'; +import useApiFetch from 'lib/api/useApiFetch'; + +import useQuickSearchQuery from './useQuickSearchQuery'; +import useSearchWithClusters from './useSearchWithClusters'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), +})); + +const mockUseQuery = useQuery as jest.MockedFunction; + +type MockQuickSearchQuery = ReturnType; +type MockApiQuery = ReturnType; + +jest.mock('lib/api/useApiFetch', () => jest.fn()); +const mockUseApiFetch = useApiFetch as jest.MockedFunction; +jest.mock('./useQuickSearchQuery'); +jest.mock('lib/hooks/useDebounce', () => (value: unknown) => value); + +const mockUseQuickSearchQuery = useQuickSearchQuery as jest.MockedFunction; + +const defaultUseQueryResult: Partial = { + data: [], + isError: false, + isLoading: false, + isFetching: false, + error: null, + isPending: false, + isLoadingError: false, + isRefetchError: false, + isSuccess: true, + isStale: false, + status: 'success', + fetchStatus: 'idle', + refetch: jest.fn(), + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isPlaceholderData: false, + isRefetching: false, + isInitialLoading: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + isPaused: false, +}; + +jest.mock('configs/app', () => ({ + features: { + clusters: { isEnabled: true }, + rollbar: { isEnabled: false }, + }, + UI: { + homepage: { + heroBanner: null, + charts: [], + stats: [], + }, + fonts: { + heading: null, + body: null, + }, + navigation: { + logo: { 'default': null, dark: null }, + icon: { 'default': null, dark: null }, + highlightedRoutes: [], + otherLinks: [], + featuredNetworks: null, + layout: 'vertical', + }, + footer: { + links: null, + frontendVersion: null, + frontendCommit: null, + }, + views: {}, + indexingAlert: { blocks: { isHidden: false }, intTxs: { isHidden: false } }, + maintenanceAlert: { message: null }, + explorers: { items: [] }, + ides: { items: [] }, + hasContractAuditReports: false, + colorTheme: { 'default': null }, + maxContentWidth: true, + }, + app: {}, + chain: {}, + apis: {}, + services: {}, + meta: {}, +})); + +describe('useSearchWithClusters', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: false, + isLoading: false, + }, + redirectCheckQuery: { + data: null, + isError: false, + isLoading: false, + }, + } as unknown as MockQuickSearchQuery); + + mockUseApiFetch.mockReturnValue({ + data: null, + isError: false, + isLoading: false, + } as unknown as MockApiQuery); + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + } as UseQueryResult); + }); + + describe('cluster search pattern matching', () => { + it('should detect cluster search with trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster/', + debouncedSearchTerm: 'test-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'test-cluster' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should detect cluster search with slash in middle (no trailing slash)', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol', + debouncedSearchTerm: 'campnetwork/lol', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'campnetwork/lol' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should not detect cluster search without any slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster', + debouncedSearchTerm: 'test-cluster', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: '' } ], + queryFn: expect.any(Function), + enabled: false, + select: expect.any(Function), + }); + }); + + it('should handle cluster search with whitespace and trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: ' my-cluster/ ', + debouncedSearchTerm: ' my-cluster/ ', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'my-cluster' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should handle complex cluster names with hyphens and numbers', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster-123/', + debouncedSearchTerm: 'test-cluster-123/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'test-cluster-123' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should extract cluster name correctly from various formats', () => { + const testCases = [ + { input: 'simple/', expected: 'simple' }, + { input: 'cluster-name/', expected: 'cluster-name' }, + { input: 'my_cluster_123/', expected: 'my_cluster_123' }, + { input: 'ClusterWithCaps/', expected: 'ClusterWithCaps' }, + { input: 'campnetwork/lol', expected: 'campnetwork/lol' }, + { input: 'path/to/cluster/', expected: 'path/to/cluster' }, + { input: ' spaced/cluster/ ', expected: 'spaced/cluster' }, + ]; + + testCases.forEach(({ input, expected }) => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: input, + debouncedSearchTerm: input, + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: expected } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + + jest.clearAllMocks(); + }); + }); + + it('should detect cluster search with multiple slashes', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'org/team/project', + debouncedSearchTerm: 'org/team/project', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'org/team/project' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should handle the reported issue: campnetwork/lol with and without trailing slash', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol/', + debouncedSearchTerm: 'campnetwork/lol/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'campnetwork/lol' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + + jest.clearAllMocks(); + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'campnetwork/lol', + debouncedSearchTerm: 'campnetwork/lol', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'campnetwork/lol' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + }); + + describe('data transformation', () => { + it('should transform cluster API response to search result format', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test-cluster/', + debouncedSearchTerm: 'test-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'test-cluster', + address_hash: '0x1234567890123456789012345678901234567890', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'cluster-123', + name: 'test-cluster', + owner: '0x1234567890123456789012345678901234567890', + created_at: '2024-01-01T00:00:00Z', + expires_at: '2025-01-01T00:00:00Z', + total_wei_amount: '1000000000000000000', + is_testnet: false, + }, + }, + ]; + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + data: transformedData, + } as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(transformedData); + }); + + it('should handle cluster data without optional fields', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'simple-cluster/', + debouncedSearchTerm: 'simple-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'simple-cluster', + address_hash: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'simple-cluster', + name: 'simple-cluster', + owner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + created_at: undefined, + expires_at: undefined, + total_wei_amount: undefined, + is_testnet: undefined, + }, + }, + ]; + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + data: transformedData, + } as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(transformedData); + }); + + it('should use clusterId as fallback when present', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test/', + debouncedSearchTerm: 'test/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const transformedData = [ + { + type: 'cluster', + name: 'test', + address_hash: '0x123', + is_smart_contract_verified: false, + cluster_info: { + cluster_id: 'test', + name: 'test', + owner: '0x123', + created_at: undefined, + expires_at: undefined, + total_wei_amount: undefined, + is_testnet: undefined, + }, + }, + ]; + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + data: transformedData, + } as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toBeDefined(); + expect(result.current.query.data).toHaveLength(1); + const clusterResult = result.current.query.data![0] as unknown as Record; + expect((clusterResult.cluster_info as Record).cluster_id).toBe('test'); + }); + + it('should return empty results when cluster API returns error', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'nonexistent-cluster/', + debouncedSearchTerm: 'nonexistent-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + data: [], + isError: true, + error: new Error('API Error'), + isSuccess: false, + status: 'error', + failureCount: 1, + } as unknown as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual([]); + expect(result.current.query.isError).toBe(true); + }); + + it('should return empty results when cluster API returns no data', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'empty-cluster/', + debouncedSearchTerm: 'empty-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + data: [], + } as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual([]); + }); + }); + + describe('fallback to regular search', () => { + it('should return regular search results for non-cluster queries', () => { + const regularSearchData = [ + { type: 'address', address_hash: '0x123', is_smart_contract_verified: true }, + ]; + + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '0x123456', + debouncedSearchTerm: '0x123456', + handleSearchTermChange: jest.fn(), + query: { + data: regularSearchData, + isError: false, + isLoading: false, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.data).toEqual(regularSearchData); + }); + + it('should preserve regular search query properties', () => { + const mockQuickSearchQuery = { + searchTerm: 'regular search', + debouncedSearchTerm: 'regular search', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: false, + isLoading: true, + isFetching: true, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + }; + + mockUseQuickSearchQuery.mockReturnValue(mockQuickSearchQuery as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isLoading).toBe(true); + expect(result.current.query.isFetching).toBe(true); + expect(result.current.searchTerm).toBe('regular search'); + expect(result.current.debouncedSearchTerm).toBe('regular search'); + }); + + it('should preserve error states from regular search', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'error-search', + debouncedSearchTerm: 'error-search', + handleSearchTermChange: jest.fn(), + query: { + data: [], + isError: true, + error: 'Network error', + isLoading: false, + }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isError).toBe(true); + expect(result.current.query.error).toBe('Network error'); + }); + }); + + describe('integration behavior', () => { + it('should enable cluster API query only for valid cluster searches', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: '' } ], + queryFn: expect.any(Function), + enabled: false, + select: expect.any(Function), + }); + }); + + it('should not query cluster API when cluster name is empty', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: '/', + debouncedSearchTerm: '/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: '' } ], + queryFn: expect.any(Function), + enabled: false, + select: expect.any(Function), + }); + }); + + it('should return proper hook interface', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'test', + debouncedSearchTerm: 'test-debounced', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: 'redirect-data', isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current).toHaveProperty('searchTerm', 'test'); + expect(result.current).toHaveProperty('debouncedSearchTerm', 'test-debounced'); + expect(result.current).toHaveProperty('handleSearchTermChange'); + expect(result.current).toHaveProperty('query'); + expect(result.current.redirectCheckQuery).toEqual({ data: 'redirect-data', isError: false, isLoading: false }); + }); + + it('should handle loading states correctly', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'loading-cluster/', + debouncedSearchTerm: 'loading-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + mockUseQuery.mockReturnValue({ + ...defaultUseQueryResult, + isLoading: true, + } as unknown as UseQueryResult); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.query.isLoading).toBe(true); + }); + }); + + describe('debouncing integration', () => { + it('should use debounced search term for cluster detection', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'original-term/', + debouncedSearchTerm: 'final-cluster/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + renderHook(() => useSearchWithClusters()); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: [ 'clusters:get_cluster_by_name', { input: 'final-cluster' } ], + queryFn: expect.any(Function), + enabled: true, + select: expect.any(Function), + }); + }); + + it('should pass through original search term for display', () => { + mockUseQuickSearchQuery.mockReturnValue({ + searchTerm: 'original-term', + debouncedSearchTerm: 'debounced/', + handleSearchTermChange: jest.fn(), + query: { data: [], isError: false, isLoading: false }, + redirectCheckQuery: { data: null, isError: false, isLoading: false }, + } as unknown as MockQuickSearchQuery); + + const { result } = renderHook(() => useSearchWithClusters()); + + expect(result.current.searchTerm).toBe('original-term'); + expect(result.current.debouncedSearchTerm).toBe('debounced/'); + }); + }); +}); diff --git a/ui/snippets/searchBar/useSearchWithClusters.tsx b/ui/snippets/searchBar/useSearchWithClusters.tsx new file mode 100644 index 0000000000..0a063c651c --- /dev/null +++ b/ui/snippets/searchBar/useSearchWithClusters.tsx @@ -0,0 +1,105 @@ +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import type { SearchResultCluster } from 'types/api/search'; + +import config from 'configs/app'; +import type { ResourcePayload, ResourceError } from 'lib/api/resources'; +import useApiFetch from 'lib/api/useApiFetch'; +import { getResourceKey } from 'lib/api/useApiQuery'; + +import useQuickSearchQuery from './useQuickSearchQuery'; + +function isClusterSearch(term: string): boolean { + const trimmed = term.trim(); + const hasTrailingSlash = trimmed.endsWith('/'); + const looksLikeCluster = trimmed.includes('/') || hasTrailingSlash; + + return looksLikeCluster; +} + +function extractClusterName(term: string): string { + const trimmed = term.trim(); + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; +} + +function transformClusterToSearchResult(cluster: { + name: string; + clusterId?: string; + owner: string; + createdAt?: string; + expiresAt?: string | null; + backingWei?: string; + isTestnet?: boolean; +}, ownerAddress: string): SearchResultCluster { + return { + type: 'cluster', + name: cluster.name, + address_hash: ownerAddress, + is_smart_contract_verified: false, + cluster_info: { + cluster_id: cluster.clusterId || cluster.name, + name: cluster.name, + owner: cluster.owner, + created_at: cluster.createdAt, + expires_at: cluster.expiresAt, + total_wei_amount: cluster.backingWei, + is_testnet: cluster.isTestnet, + }, + }; +} + +export default function useSearchWithClusters() { + const quickSearch = useQuickSearchQuery(); + + const isClusterQuery = config.features.clusters.isEnabled ? + isClusterSearch(quickSearch.debouncedSearchTerm) : false; + + const clusterName = isClusterQuery ? extractClusterName(quickSearch.debouncedSearchTerm) : ''; + + const RESOURCE_NAME = 'clusters:get_cluster_by_name'; + type ClusterQueryResult = ResourcePayload; + + const apiFetch = useApiFetch(); + + const clusterQuery = useQuery, Array>({ + queryKey: getResourceKey(RESOURCE_NAME, { queryParams: { input: clusterName } }), + queryFn: async({ signal }) => { + try { + const result = await apiFetch(RESOURCE_NAME, { + queryParams: { input: JSON.stringify({ name: clusterName }) }, + fetchParams: { signal }, + }) as ClusterQueryResult; + return result; + } catch (error) { + return null; + } + }, + enabled: config.features.clusters.isEnabled && isClusterQuery && clusterName.length > 0, + select: (data) => { + if (!data?.result?.data) return []; + return [ transformClusterToSearchResult(data.result.data, data.result.data.owner) ]; + }, + }); + + const combinedQuery = React.useMemo(() => { + if (!config.features.clusters.isEnabled || !isClusterQuery) { + return quickSearch.query; + } + + return clusterQuery; + }, [ isClusterQuery, quickSearch, clusterQuery ]); + + const result = React.useMemo(() => ({ + searchTerm: quickSearch.searchTerm, + debouncedSearchTerm: quickSearch.debouncedSearchTerm, + handleSearchTermChange: quickSearch.handleSearchTermChange, + query: combinedQuery, + redirectCheckQuery: quickSearch.redirectCheckQuery, + }), [ + quickSearch, + combinedQuery, + ]); + + return config.features.clusters.isEnabled ? result : quickSearch; +} diff --git a/yarn.lock b/yarn.lock index 17aa9078b9..fe135b572e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1608,6 +1608,11 @@ resolved "https://registry.yarnpkg.com/@chainsafe/is-ip/-/is-ip-2.0.2.tgz#7311e7403f11d8c5cfa48111f56fcecaac37c9f6" integrity sha512-ndGqEMG1W5WkGagaqOZHpPU172AGdxr+LD15sv3WIUvT5oCFUrG1Y0CW/v2Egwj4JXEvSibaIIIqImsm98y1nA== +"@chainsafe/is-ip@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@chainsafe/is-ip/-/is-ip-2.1.0.tgz#ba9ac32acd9027698e0b56b91c7af069d28d7931" + integrity sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w== + "@chainsafe/libp2p-noise@^16.0.0", "@chainsafe/libp2p-noise@^16.0.1": version "16.1.3" resolved "https://registry.yarnpkg.com/@chainsafe/libp2p-noise/-/libp2p-noise-16.1.3.tgz#ef3733ed43ad2d2e54de2f88f796a8d409660983" @@ -1631,6 +1636,29 @@ uint8arrays "^5.0.0" wherearewe "^2.0.1" +"@chainsafe/libp2p-noise@^16.1.3": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@chainsafe/libp2p-noise/-/libp2p-noise-16.1.4.tgz#6788c1ad3e2567f37189e552fc77e67c57f88505" + integrity sha512-f4FlyRVndcs4PoioOIZWrFc6wfO/mrAj7H63o0+eA0O2xhcoRkxHh6zna4W+WtScaF/Ua/UULgiNGuKNpLvLlQ== + dependencies: + "@chainsafe/as-chacha20poly1305" "^0.1.0" + "@chainsafe/as-sha256" "^1.0.0" + "@libp2p/crypto" "^5.0.0" + "@libp2p/interface" "^2.9.0" + "@libp2p/peer-id" "^5.0.0" + "@noble/ciphers" "^1.1.3" + "@noble/curves" "^1.1.0" + "@noble/hashes" "^1.3.1" + it-length-prefixed "^10.0.1" + it-length-prefixed-stream "^2.0.1" + it-pair "^2.0.6" + it-pipe "^3.0.1" + it-stream-types "^2.0.1" + protons-runtime "^5.5.0" + uint8arraylist "^2.4.3" + uint8arrays "^5.0.0" + wherearewe "^2.0.1" + "@chainsafe/libp2p-yamux@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@chainsafe/libp2p-yamux/-/libp2p-yamux-7.0.1.tgz#ce938a9bcec90813f8c3e2f6b05287fc5e63204b" @@ -2630,13 +2658,13 @@ "@tanstack/react-virtual" "^3.13.9" use-sync-external-store "^1.5.0" -"@helia/bitswap@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@helia/bitswap/-/bitswap-2.1.1.tgz#a251d36a3f204044aa4e291aa363db07cc94b8f8" - integrity sha512-hqWEV+fQ6ko1QT7Q+yZGY8vsQ9kleB2X9HP5Ho+HKnP3ZX97OhOtSXzRBpkbqvY1GSk2kgqOnh7QIDTcRqkTug== +"@helia/bitswap@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@helia/bitswap/-/bitswap-2.2.0.tgz#e60334e390fcefa33f39547a8a14296534d51a4d" + integrity sha512-fYHjXM8SZpgRkGRM7qq8VVpBsrSLHlRGTLUEesfKZ+wYBYHZaqpn0mT9KbdLZowLrooz0qaUUiF6OIZBNmArcw== dependencies: - "@helia/interface" "^5.3.1" - "@helia/utils" "^1.3.1" + "@helia/interface" "^5.4.0" + "@helia/utils" "^1.4.0" "@libp2p/interface" "^2.2.1" "@libp2p/logger" "^5.1.4" "@libp2p/peer-collections" "^6.0.12" @@ -2659,30 +2687,31 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" -"@helia/block-brokers@^4.1.0", "@helia/block-brokers@^4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@helia/block-brokers/-/block-brokers-4.2.1.tgz#890dc63362c4b7fa97013b8eb0dde40af2617d0c" - integrity sha512-Ndu728nzNoZJEYiIFbfDKhCviFnnBII20HueCn3R6cmUlPsSkpZ1NBkno6HRzewWGqBAvLV1rVd7ccx3b/zHIw== +"@helia/block-brokers@^4.1.0", "@helia/block-brokers@^4.2.4": + version "4.2.4" + resolved "https://registry.yarnpkg.com/@helia/block-brokers/-/block-brokers-4.2.4.tgz#247fd8911171b007e5172b1a79b3ccab591c516f" + integrity sha512-Xz7JyRIprbldi5bxfvuj5xTDijkfndgLPKoK/AUVeFOBD0n04Cb6RzqYGVQN6of8a6hfL6tD0fO4RVUFh/Jl6A== dependencies: - "@helia/bitswap" "^2.1.1" - "@helia/interface" "^5.3.1" - "@helia/utils" "^1.3.1" + "@helia/bitswap" "^2.2.0" + "@helia/interface" "^5.4.0" + "@helia/utils" "^1.4.0" "@libp2p/interface" "^2.2.1" "@libp2p/utils" "^6.2.1" "@multiformats/multiaddr" "^12.3.3" - "@multiformats/multiaddr-matcher" "^1.6.0" + "@multiformats/multiaddr-matcher" "^2.0.1" "@multiformats/multiaddr-to-uri" "^11.0.0" interface-blockstore "^5.3.1" interface-store "^6.0.2" multiformats "^13.3.1" progress-events "^1.0.1" + uint8arraylist "^2.4.8" "@helia/car@^4.0.4": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@helia/car/-/car-4.1.1.tgz#2036fceb4634259e1a5bfe47c59709df94034d99" - integrity sha512-FSfuqh/og/v6ikd/NmcSmeIqeEtCfboVxrBbyfH+zbjYy/6b6mXjIjvudOz+QIXCEEE3luudtknmcLjtBcOCUA== + version "4.2.0" + resolved "https://registry.yarnpkg.com/@helia/car/-/car-4.2.0.tgz#8f8aed3f863aa785093ebc70d2595f767ddc5f38" + integrity sha512-COX0l9mjqjl4Cybq0b5HKW/K1z77LOiiJfEZBF4MziVUUPfD/ivX6B6bqe889+YotUWO/vEv/HBY1m+BF2HeTQ== dependencies: - "@helia/interface" "^5.3.1" + "@helia/interface" "^5.4.0" "@ipld/car" "^5.3.3" "@ipld/dag-pb" "^4.1.3" "@libp2p/interface" "^2.2.1" @@ -2716,10 +2745,10 @@ p-queue "^8.0.1" uint8arrays "^5.1.0" -"@helia/interface@^5.2.1", "@helia/interface@^5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@helia/interface/-/interface-5.3.1.tgz#2c632bcab50a68a91be827b057c5b5f03fa14f3d" - integrity sha512-MDUIVUHwH2eMDy5msA/cnU+nonpWDgSdWK76lN6st8YFmRjxoDNW7E4L0hOajVzYavZDZWPeUbbgpTKjlK+2CQ== +"@helia/interface@^5.2.1", "@helia/interface@^5.4.0": + version "5.4.0" + resolved "https://registry.yarnpkg.com/@helia/interface/-/interface-5.4.0.tgz#c3684c15a7082e0c8b079b2c993e75a0c34ee4e0" + integrity sha512-UfY/Xa2qYJ6Bh4XUUSgwU3s2VY69cKwRznkS9Ab0C3ciU+dUeyz3IPIn5qqRGVfNVM9SD2wISXmwA7FJvhdRAQ== dependencies: "@libp2p/interface" "^2.2.1" "@multiformats/dns" "^1.0.6" @@ -2731,11 +2760,11 @@ progress-events "^1.0.1" "@helia/ipns@^8.2.0": - version "8.2.2" - resolved "https://registry.yarnpkg.com/@helia/ipns/-/ipns-8.2.2.tgz#191099e57b0658834dbb0b248f79d34e8ae749b0" - integrity sha512-oZnbhKK40FAw9BHcJYs6+jlhdAwZJARQEMhRsbDekGw6oefB9ARpBjTlr+xq6bG+FknEsTFBxw8H/1QZgfBMgg== + version "8.2.4" + resolved "https://registry.yarnpkg.com/@helia/ipns/-/ipns-8.2.4.tgz#290fded2be3aa60a0db2d9758deda4e96b2d514d" + integrity sha512-Jdx6yQvSDpV2IRQR06TlBjW5yOPxj820eoNK44P+kU/xU+dwREs8JJAj455SsDboj2XV9YnA9OQJVhu7SmtpBw== dependencies: - "@helia/interface" "^5.3.1" + "@helia/interface" "^5.4.0" "@libp2p/interface" "^2.2.1" "@libp2p/kad-dht" "^15.0.2" "@libp2p/logger" "^5.1.4" @@ -2747,13 +2776,13 @@ progress-events "^1.0.1" uint8arrays "^5.1.0" -"@helia/routers@^3.0.1", "@helia/routers@^3.1.1": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@helia/routers/-/routers-3.1.1.tgz#c841dc471e5e234a61b6f5e7e4482c378e5b4714" - integrity sha512-7yLsaKBoTbfdwNVBM5Ys1w71ikADEYRbqXoGCaVDgIzVRrolT5eGq0H/mPT9SE6aJxM+MFHUrP2qWx2a0rAV/Q== +"@helia/routers@^3.0.1", "@helia/routers@^3.1.3": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@helia/routers/-/routers-3.1.3.tgz#076e5a49226e82a927bbd8548225c71c0a9918e8" + integrity sha512-BPbzN3WCe6EF3yXjeORJbbb5hZjv0PKaJri1sSPHRZX6sKUCvRzwAc0AkQKUMHeMzTLrrAcl0yAz5x1r8z3Ynw== dependencies: "@helia/delegated-routing-v1-http-api-client" "^4.2.1" - "@helia/interface" "^5.3.1" + "@helia/interface" "^5.4.0" "@libp2p/interface" "^2.2.1" "@libp2p/peer-id" "^5.0.8" "@multiformats/uri-to-multiaddr" "^9.0.1" @@ -2764,11 +2793,11 @@ uint8arrays "^5.1.0" "@helia/unixfs@^5.0.0": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@helia/unixfs/-/unixfs-5.0.2.tgz#43f90535242e79ba23470b1e08d04fce171cea7f" - integrity sha512-daCC5UqF0gwKqW+uMONkgdhOXm4PttPdivyens4/ukgVBxLPUhuNrEq4vPHLbRn2BqLy1LbRIB3DTS57sAKVdQ== + version "5.1.0" + resolved "https://registry.yarnpkg.com/@helia/unixfs/-/unixfs-5.1.0.tgz#b5b2bc9f47fd22d99910a5edf5a43f75e21d4474" + integrity sha512-QGY6atWRAGAkx2/iPAioTKli4OduCxm6pdnII9HnsYAw0O5To+uERFpEm06J8gawxQuwD05Ux3wVfH/C0Wo87Q== dependencies: - "@helia/interface" "^5.3.1" + "@helia/interface" "^5.4.0" "@ipld/dag-pb" "^4.1.3" "@libp2p/interface" "^2.2.1" "@libp2p/logger" "^5.1.4" @@ -2776,30 +2805,30 @@ "@multiformats/murmur3" "^2.1.8" hamt-sharding "^3.0.6" interface-blockstore "^5.3.1" - ipfs-unixfs "^11.2.0" - ipfs-unixfs-exporter "^13.6.1" - ipfs-unixfs-importer "^15.3.1" + ipfs-unixfs "^11.2.5" + ipfs-unixfs-exporter "^13.7.2" + ipfs-unixfs-importer "^15.4.0" it-all "^3.0.6" it-first "^3.0.6" it-glob "^3.0.1" it-last "^3.0.6" it-pipe "^3.0.1" - merge-options "^3.0.4" multiformats "^13.3.1" progress-events "^1.0.1" sparse-array "^1.3.2" uint8arrays "^5.1.0" -"@helia/utils@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@helia/utils/-/utils-1.3.1.tgz#4768db5156339828f4f4e7da7798e2b29c96cf94" - integrity sha512-EXkxXqUCsGD4ASZf8IAcxd5JBm5GDBGfarxmZhp1K6PYx1aU3vItuMpXLV7hDmFCBzLJsji8UBOZg1bjJC1ZkA== +"@helia/utils@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@helia/utils/-/utils-1.4.0.tgz#bbcab48b7f5f68ed77c37bcb6d1bf8744e8c65f8" + integrity sha512-xXwhQbfSQEC5uHd6HVwdGXksZayy5xnYB5aOk0TU1fDnwbaeJz46o/GdWcxkTxnbfGyxqs9hyK5NqHw1B58yAw== dependencies: - "@helia/interface" "^5.3.1" + "@helia/interface" "^5.4.0" "@ipld/dag-cbor" "^9.2.2" "@ipld/dag-json" "^10.2.3" "@ipld/dag-pb" "^4.1.3" "@libp2p/interface" "^2.5.0" + "@libp2p/keychain" "^5.2.8" "@libp2p/logger" "^5.1.8" "@libp2p/utils" "^6.5.1" "@multiformats/dns" "^1.0.6" @@ -2814,6 +2843,7 @@ it-filter "^3.1.1" it-foreach "^2.1.1" it-merge "^3.0.5" + libp2p "^2.9.0" mortice "^3.0.6" multiformats "^13.3.1" p-defer "^4.0.1" @@ -3052,6 +3082,14 @@ cborg "^4.0.0" multiformats "^13.1.0" +"@ipld/dag-cbor@^9.2.4": + version "9.2.4" + resolved "https://registry.yarnpkg.com/@ipld/dag-cbor/-/dag-cbor-9.2.4.tgz#64aba7060836af081debe87610cd1c915997ba6a" + integrity sha512-GbDWYl2fdJgkYtIJN0HY9oO0o50d1nB4EQb7uYWKUd2ztxCjxiEW3PjwGG0nqUpN1G4Cug6LX8NzbA7fKT+zfA== + dependencies: + cborg "^4.0.0" + multiformats "^13.1.0" + "@ipld/dag-json@^10.2.2": version "10.2.3" resolved "https://registry.yarnpkg.com/@ipld/dag-json/-/dag-json-10.2.3.tgz#bb9de2e869f1c523104c52adc89e1e8bb0db7253" @@ -3068,6 +3106,14 @@ cborg "^4.0.0" multiformats "^13.1.0" +"@ipld/dag-json@^10.2.5": + version "10.2.5" + resolved "https://registry.yarnpkg.com/@ipld/dag-json/-/dag-json-10.2.5.tgz#a17e1c10ba58ea5bf43b6c7de8b73bb6461f6654" + integrity sha512-Q4Fr3IBDEN8gkpgNefynJ4U/ZO5Kwr7WSUMBDbZx0c37t0+IwQCTM9yJh8l5L4SRFjm31MuHwniZ/kM+P7GQ3Q== + dependencies: + cborg "^4.0.0" + multiformats "^13.1.0" + "@ipld/dag-pb@^4.1.2": version "4.1.3" resolved "https://registry.yarnpkg.com/@ipld/dag-pb/-/dag-pb-4.1.3.tgz#b572d7978fa548a3a9219f566a80884189261858" @@ -3082,6 +3128,13 @@ dependencies: multiformats "^13.1.0" +"@ipld/dag-pb@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@ipld/dag-pb/-/dag-pb-4.1.5.tgz#e3bdf11995e038877a737e3684d2382e481b60df" + integrity sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA== + dependencies: + multiformats "^13.1.0" + "@ipshipyard/libp2p-auto-tls@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@ipshipyard/libp2p-auto-tls/-/libp2p-auto-tls-1.0.0.tgz#8762caa615194722f7587a5d28d561792106ce6b" @@ -3104,7 +3157,7 @@ multiformats "^13.3.1" uint8arrays "^5.1.0" -"@ipshipyard/node-datachannel@^0.26.4": +"@ipshipyard/node-datachannel@^0.26.4", "@ipshipyard/node-datachannel@^0.26.6": version "0.26.6" resolved "https://registry.yarnpkg.com/@ipshipyard/node-datachannel/-/node-datachannel-0.26.6.tgz#d0e8a2787592e637a0c769a5bb817e0c8bf0efd9" integrity sha512-70HdhYMyAGXEMuCUq9ATO1Rx/JmiENM5LrGN94KT/q/Et2VsMjJpOWbyFzgodtkQJjDG5saNXTOiQpYZ1AnvEg== @@ -3520,6 +3573,19 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" +"@libp2p/crypto@^5.1.7": + version "5.1.7" + resolved "https://registry.yarnpkg.com/@libp2p/crypto/-/crypto-5.1.7.tgz#5a27652c1801873194e19891578242251ccbc6d8" + integrity sha512-7DO0piidLEKfCuNfS420BlHG0e2tH7W/zugdsPSiC/1Apa/s1B1dBkaIEgfDkGjrRP4S/8Or86Rtq7zXeEu67g== + dependencies: + "@libp2p/interface" "^2.10.5" + "@noble/curves" "^1.9.1" + "@noble/hashes" "^1.8.0" + multiformats "^13.3.6" + protons-runtime "^5.5.0" + uint8arraylist "^2.4.8" + uint8arrays "^5.1.0" + "@libp2p/dcutr@^2.0.18": version "2.0.29" resolved "https://registry.yarnpkg.com/@libp2p/dcutr/-/dcutr-2.0.29.tgz#63d24e3fd46e625d0ee0bed929c3291164ed5b0d" @@ -3596,6 +3662,16 @@ "@multiformats/multiaddr" "^12.3.3" progress-events "^1.0.1" +"@libp2p/interface-internal@^2.3.18": + version "2.3.18" + resolved "https://registry.yarnpkg.com/@libp2p/interface-internal/-/interface-internal-2.3.18.tgz#0a163b52c63317483138283aa0576f42a2a9b077" + integrity sha512-tnZ20IFASXLbDc2JxeUPZNIXDuN5Ge7be6BU458WLvmquf93NlSqZkWs6xFdi+0yXUrw7GGTgzIP5v+1LnDUmA== + dependencies: + "@libp2p/interface" "^2.10.5" + "@libp2p/peer-collections" "^6.0.34" + "@multiformats/multiaddr" "^12.4.4" + progress-events "^1.0.1" + "@libp2p/interface@^2.0.0", "@libp2p/interface@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@libp2p/interface/-/interface-2.2.0.tgz#8718c29a0cf8c82b00d2ff9b140bcec9185578a2" @@ -3608,6 +3684,20 @@ progress-events "^1.0.0" uint8arraylist "^2.4.8" +"@libp2p/interface@^2.10.5": + version "2.10.5" + resolved "https://registry.yarnpkg.com/@libp2p/interface/-/interface-2.10.5.tgz#c8f7990a19071488831ede202f37ae6c840f3b6b" + integrity sha512-Z52n04Mph/myGdwyExbFi5S/HqrmZ9JOmfLc2v4r2Cik3GRdw98vrGH19PFvvwjLwAjaqsweCtlGaBzAz09YDw== + dependencies: + "@multiformats/dns" "^1.0.6" + "@multiformats/multiaddr" "^12.4.4" + it-pushable "^3.2.3" + it-stream-types "^2.0.2" + main-event "^1.0.1" + multiformats "^13.3.6" + progress-events "^1.0.1" + uint8arraylist "^2.4.8" + "@libp2p/interface@^2.2.1", "@libp2p/interface@^2.4.0", "@libp2p/interface@^2.5.0", "@libp2p/interface@^2.9.0": version "2.9.0" resolved "https://registry.yarnpkg.com/@libp2p/interface/-/interface-2.9.0.tgz#c1276c6788436451aec7236482bc227817476048" @@ -3672,6 +3762,21 @@ sanitize-filename "^1.6.3" uint8arrays "^5.1.0" +"@libp2p/keychain@^5.2.8": + version "5.2.8" + resolved "https://registry.yarnpkg.com/@libp2p/keychain/-/keychain-5.2.8.tgz#ef269ff62a1569f22f036af26afdc5c7f511b3e9" + integrity sha512-NIM4mNVO8dlgKEIMKn/p3Yens+qdIr4dRWFRunGD3Y1FouTyyQtkRKzYjS+ek4uAyvZovO7eDYa+2BVW7xSgrQ== + dependencies: + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/utils" "^6.7.1" + "@noble/hashes" "^1.8.0" + asn1js "^3.0.6" + interface-datastore "^8.3.1" + multiformats "^13.3.6" + sanitize-filename "^1.6.3" + uint8arrays "^5.1.0" + "@libp2p/logger@^5.0.0", "@libp2p/logger@^5.0.1", "@libp2p/logger@^5.1.3": version "5.1.3" resolved "https://registry.yarnpkg.com/@libp2p/logger/-/logger-5.1.3.tgz#fca69a5de0b3a80cfc1ec039bb76f30e9e26eab7" @@ -3694,6 +3799,17 @@ multiformats "^13.3.1" weald "^1.0.4" +"@libp2p/logger@^5.1.21": + version "5.1.21" + resolved "https://registry.yarnpkg.com/@libp2p/logger/-/logger-5.1.21.tgz#40ce0b9bfbc7d17b6cece0b63547711621ec5ff8" + integrity sha512-V1TWlZM5BuKkiGQ7En4qOnseVP82JwDIpIfNjceUZz1ArL32A5HXJjLQnJchkZ3VW8PVciJzUos/vP6slhPY6Q== + dependencies: + "@libp2p/interface" "^2.10.5" + "@multiformats/multiaddr" "^12.4.4" + interface-datastore "^8.3.1" + multiformats "^13.3.6" + weald "^1.0.4" + "@libp2p/mdns@^11.0.20": version "11.0.35" resolved "https://registry.yarnpkg.com/@libp2p/mdns/-/mdns-11.0.35.tgz#23022481518fc8b8f2b634c8f342f9a95a170c88" @@ -3722,17 +3838,17 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" -"@libp2p/multistream-select@^6.0.22": - version "6.0.22" - resolved "https://registry.yarnpkg.com/@libp2p/multistream-select/-/multistream-select-6.0.22.tgz#07e6d344f762b0a7365a7af72ff3d6e3a4c9507d" - integrity sha512-SCSnLKNvqulYYN52mG/b5INGlmj3rMAxtH9zVb1e9rq5WflJu7CGaV8CJsxOjRoJ7YqPgx1meywkeG989OdwDA== +"@libp2p/multistream-select@^6.0.28": + version "6.0.28" + resolved "https://registry.yarnpkg.com/@libp2p/multistream-select/-/multistream-select-6.0.28.tgz#52ff7c37de3e806b4fbe4bcfa8b29692fa39a5fb" + integrity sha512-ILu65FAX2Hak7x40DXb0gYptF6BmlGGW2kNgGeKIcNeseuvsAkBPO8k0CHwr8MU5mnHamTiweLJh5jD0iVZJ1A== dependencies: - "@libp2p/interface" "^2.9.0" + "@libp2p/interface" "^2.10.5" it-length-prefixed "^10.0.1" - it-length-prefixed-stream "^2.0.1" + it-length-prefixed-stream "^2.0.2" it-stream-types "^2.0.2" p-defer "^4.0.1" - race-signal "^1.1.2" + race-signal "^1.1.3" uint8-varint "^2.0.4" uint8arraylist "^2.4.8" uint8arrays "^5.1.0" @@ -3757,6 +3873,16 @@ "@libp2p/utils" "^6.6.2" multiformats "^13.3.1" +"@libp2p/peer-collections@^6.0.34": + version "6.0.34" + resolved "https://registry.yarnpkg.com/@libp2p/peer-collections/-/peer-collections-6.0.34.tgz#a56ef2f2d0bb4acd42ada0b790a1e9550b319ff9" + integrity sha512-rw8gDGhou4sF6W6i9ntmRARFePX19Dw9MMVpZHr6Kx9q2kvBJq91IXUzsXP06roexEOu1CUlZwxtUAqOBy+Eww== + dependencies: + "@libp2p/interface" "^2.10.5" + "@libp2p/peer-id" "^5.1.8" + "@libp2p/utils" "^6.7.1" + multiformats "^13.3.6" + "@libp2p/peer-id@^5.0.0", "@libp2p/peer-id@^5.0.1", "@libp2p/peer-id@^5.0.7": version "5.0.7" resolved "https://registry.yarnpkg.com/@libp2p/peer-id/-/peer-id-5.0.7.tgz#bcde5224ec3bc97b826efadebd52489f518bb326" @@ -3777,6 +3903,16 @@ multiformats "^13.3.1" uint8arrays "^5.1.0" +"@libp2p/peer-id@^5.1.8": + version "5.1.8" + resolved "https://registry.yarnpkg.com/@libp2p/peer-id/-/peer-id-5.1.8.tgz#646fdda7d3346ce072adee6727c792ddcf1631ea" + integrity sha512-pGaM4BwjnXdGtAtd84L4/wuABpsnFYE+AQ+h3GxNFme0IsTaTVKWd1jBBE5YFeKHBHGUOhF3TlHsdjFfjQA7TA== + dependencies: + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + multiformats "^13.3.6" + uint8arrays "^5.1.0" + "@libp2p/peer-record@^8.0.27": version "8.0.27" resolved "https://registry.yarnpkg.com/@libp2p/peer-record/-/peer-record-8.0.27.tgz#f7fbd26a393314993d3d4141b9d3d513e97cc76d" @@ -3793,20 +3929,38 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" -"@libp2p/peer-store@^11.1.4": - version "11.1.4" - resolved "https://registry.yarnpkg.com/@libp2p/peer-store/-/peer-store-11.1.4.tgz#62a86707051549e510f970f8b836e2e728ed5753" - integrity sha512-KUfY0GJLUUYrPGLsiGRWliNNFPGlC0bY4BE25jhp1MEsjrimkTl6TcksqCQ8SzR0Cn4HMRRPJs4H2AzdaQexZA== - dependencies: - "@libp2p/crypto" "^5.1.1" - "@libp2p/interface" "^2.9.0" - "@libp2p/peer-id" "^5.1.2" - "@libp2p/peer-record" "^8.0.27" - "@multiformats/multiaddr" "^12.3.3" +"@libp2p/peer-record@^8.0.34": + version "8.0.34" + resolved "https://registry.yarnpkg.com/@libp2p/peer-record/-/peer-record-8.0.34.tgz#6f90f40e01771d70a2f1fdcec12ab582a37b667f" + integrity sha512-GqvRBpvclscoKuF0JUfLyZTv+BwzICBBe50LFiAKio8LijZMBr43b+AcEaSEwFWDwlWmaKU73q8EQLrCb/e67Q== + dependencies: + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/peer-id" "^5.1.8" + "@libp2p/utils" "^6.7.1" + "@multiformats/multiaddr" "^12.4.4" + multiformats "^13.3.6" + protons-runtime "^5.5.0" + uint8-varint "^2.0.4" + uint8arraylist "^2.4.8" + uint8arrays "^5.1.0" + +"@libp2p/peer-store@^11.2.6": + version "11.2.6" + resolved "https://registry.yarnpkg.com/@libp2p/peer-store/-/peer-store-11.2.6.tgz#6df8d9bae22507b5bab5c9781733a3a7aa9f409c" + integrity sha512-3Lc982/7drqlXa51s9l1/DFHD48zzIjMMYajxFM2KbobyStH+lztYnFc3kNGB9sZijULaW1480PvbTMm9WaJ0g== + dependencies: + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/peer-collections" "^6.0.34" + "@libp2p/peer-id" "^5.1.8" + "@libp2p/peer-record" "^8.0.34" + "@multiformats/multiaddr" "^12.4.4" interface-datastore "^8.3.1" - it-all "^3.0.6" - mortice "^3.0.6" - multiformats "^13.3.1" + it-all "^3.0.8" + main-event "^1.0.1" + mortice "^3.2.1" + multiformats "^13.3.6" protons-runtime "^5.5.0" uint8arraylist "^2.4.8" uint8arrays "^5.1.0" @@ -3937,7 +4091,36 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" -"@libp2p/webrtc@^5.1.0", "@libp2p/webrtc@^5.2.12": +"@libp2p/utils@^6.7.1": + version "6.7.1" + resolved "https://registry.yarnpkg.com/@libp2p/utils/-/utils-6.7.1.tgz#0f6dbe587474f62b2c2a32b7d5ede0be57ebebda" + integrity sha512-x3WImvw4unmx1ZeAedj8AkRe4UImUlkw0ZItYAiKiekElMNUXwv+Yt48dI/LmB38JIof8sng29XvUeCVU3F6OA== + dependencies: + "@chainsafe/is-ip" "^2.1.0" + "@chainsafe/netmask" "^2.0.0" + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/logger" "^5.1.21" + "@multiformats/multiaddr" "^12.4.4" + "@sindresorhus/fnv1a" "^3.1.0" + any-signal "^4.1.1" + delay "^6.0.0" + get-iterator "^2.0.1" + is-loopback-addr "^2.0.2" + is-plain-obj "^4.1.0" + it-foreach "^2.1.3" + it-pipe "^3.0.1" + it-pushable "^3.2.3" + it-stream-types "^2.0.2" + main-event "^1.0.1" + netmask "^2.0.2" + p-defer "^4.0.1" + race-event "^1.3.0" + race-signal "^1.1.3" + uint8arraylist "^2.4.8" + uint8arrays "^5.1.0" + +"@libp2p/webrtc@^5.1.0": version "5.2.12" resolved "https://registry.yarnpkg.com/@libp2p/webrtc/-/webrtc-5.2.12.tgz#1cce271cfffa4d41e1eb1e09678291eab9e64e41" integrity sha512-ObmCeK28PmN1vqgJ52YPNHfMFrTWBlXEGAXzJlgMuuvJ4Y94of7Ej1kZWhsfGWyfrhELEcFtioPCzeaSdbeB7A== @@ -3976,7 +4159,47 @@ uint8arraylist "^2.4.8" uint8arrays "^5.1.0" -"@libp2p/websockets@^9.1.5", "@libp2p/websockets@^9.2.10": +"@libp2p/webrtc@^5.2.12": + version "5.2.23" + resolved "https://registry.yarnpkg.com/@libp2p/webrtc/-/webrtc-5.2.23.tgz#dba9f1523729e4bdb2961114def3a0eb52d4610e" + integrity sha512-2iNEkiqNBlRXbdpIyP96tp+dvpXEnVJJk91WUPryS9ULrsyceTraKjEnlQUmSyKcxZRChfdLXax7LtpPJqjZAA== + dependencies: + "@chainsafe/is-ip" "^2.1.0" + "@chainsafe/libp2p-noise" "^16.1.3" + "@ipshipyard/node-datachannel" "^0.26.6" + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/interface-internal" "^2.3.18" + "@libp2p/keychain" "^5.2.8" + "@libp2p/peer-id" "^5.1.8" + "@libp2p/utils" "^6.7.1" + "@multiformats/multiaddr" "^12.4.4" + "@multiformats/multiaddr-matcher" "^2.0.0" + "@peculiar/webcrypto" "^1.5.0" + "@peculiar/x509" "^1.12.3" + any-signal "^4.1.1" + detect-browser "^5.3.0" + get-port "^7.1.0" + interface-datastore "^8.3.1" + it-length-prefixed "^10.0.1" + it-protobuf-stream "^2.0.2" + it-pushable "^3.2.3" + it-stream-types "^2.0.2" + main-event "^1.0.1" + multiformats "^13.3.6" + p-defer "^4.0.1" + p-timeout "^6.1.4" + p-wait-for "^5.0.2" + progress-events "^1.0.1" + protons-runtime "^5.5.0" + race-event "^1.3.0" + race-signal "^1.1.3" + react-native-webrtc "^124.0.5" + uint8-varint "^2.0.4" + uint8arraylist "^2.4.8" + uint8arrays "^5.1.0" + +"@libp2p/websockets@^9.1.5": version "9.2.10" resolved "https://registry.yarnpkg.com/@libp2p/websockets/-/websockets-9.2.10.tgz#2b498b10d41925d832ef0e4b645be85e53b0fee2" integrity sha512-3UUG8SdTr2pe5Jpv86zu0R8AKXVTtIgBWjIISghjeMDhEEz8lakYGtlUYndWiGe7sICG2EaAQcr/SFwMmzItIg== @@ -3994,6 +4217,25 @@ race-signal "^1.1.2" ws "^8.18.0" +"@libp2p/websockets@^9.2.10": + version "9.2.18" + resolved "https://registry.yarnpkg.com/@libp2p/websockets/-/websockets-9.2.18.tgz#5e7f41276e9ec156f708d3f191f52d6a9dcae8b6" + integrity sha512-P27ZMv5TuKZDr8nfvVOExaDxBMCwvGLaPxbK3qMC8WiaFBuR5Z38X/HpnTFI5wog8LcKCRD+gFzRZWd/9nEVZw== + dependencies: + "@libp2p/interface" "^2.10.5" + "@libp2p/utils" "^6.7.1" + "@multiformats/multiaddr" "^12.4.4" + "@multiformats/multiaddr-matcher" "^2.0.0" + "@multiformats/multiaddr-to-uri" "^11.0.0" + "@types/ws" "^8.18.1" + it-ws "^6.1.5" + main-event "^1.0.1" + p-defer "^4.0.1" + p-event "^6.0.1" + progress-events "^1.0.1" + race-signal "^1.1.3" + ws "^8.18.2" + "@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9" @@ -4322,7 +4564,7 @@ dependencies: "@multiformats/multiaddr" "^12.0.0" -"@multiformats/multiaddr-matcher@^1.6.0", "@multiformats/multiaddr-matcher@^1.7.0": +"@multiformats/multiaddr-matcher@^1.6.0": version "1.7.2" resolved "https://registry.yarnpkg.com/@multiformats/multiaddr-matcher/-/multiaddr-matcher-1.7.2.tgz#9b4e535676ab217b41b99794ea8ab38c659d7478" integrity sha512-BJzHOBAAxGZKw+FY/MzeIKGKERAW/1XOrpj61wgzZVvR/iksyGTQhliyTgmuakpBJPSsCxlrk3eLemVhZuJIFQ== @@ -4331,6 +4573,13 @@ "@multiformats/multiaddr" "^12.0.0" multiformats "^13.0.0" +"@multiformats/multiaddr-matcher@^2.0.0", "@multiformats/multiaddr-matcher@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@multiformats/multiaddr-matcher/-/multiaddr-matcher-2.0.2.tgz#6db95e8d2a54b9cb711234964f3811562f7c6b40" + integrity sha512-si7EZCI93mfBJKKRkh+u2bB9W6W5APVN3XfdwuseEJ0OS7ysg0Jno9SuAi0bRzsl5OEFESoF71SjsRqgp8PXAA== + dependencies: + "@multiformats/multiaddr" "^12.0.0" + "@multiformats/multiaddr-to-uri@^11.0.0": version "11.0.0" resolved "https://registry.yarnpkg.com/@multiformats/multiaddr-to-uri/-/multiaddr-to-uri-11.0.0.tgz#ec0ee9494f1cfc6ccd5173e61bbb0b6722029e97" @@ -4350,7 +4599,7 @@ uint8-varint "^2.0.1" uint8arrays "^5.0.0" -"@multiformats/multiaddr@^12.3.3", "@multiformats/multiaddr@^12.3.5", "@multiformats/multiaddr@^12.4.0": +"@multiformats/multiaddr@^12.3.3", "@multiformats/multiaddr@^12.4.0": version "12.4.0" resolved "https://registry.yarnpkg.com/@multiformats/multiaddr/-/multiaddr-12.4.0.tgz#13fca8d68805fe0d0569bdd7d4dce41497503d31" integrity sha512-FL7yBTLijJ5JkO044BGb2msf+uJLrwpD6jD6TkXlbjA9N12+18HT40jvd4o5vL4LOJMc86dPX6tGtk/uI9kYKg== @@ -4362,6 +4611,19 @@ uint8-varint "^2.0.1" uint8arrays "^5.0.0" +"@multiformats/multiaddr@^12.4.4": + version "12.5.1" + resolved "https://registry.yarnpkg.com/@multiformats/multiaddr/-/multiaddr-12.5.1.tgz#45d64456eddbf8cbe179366d7cb7b72efabe049f" + integrity sha512-+DDlr9LIRUS8KncI1TX/FfUn8F2dl6BIxJgshS/yFQCNB5IAF0OGzcwB39g5NLE22s4qqDePv0Qof6HdpJ/4aQ== + dependencies: + "@chainsafe/is-ip" "^2.0.1" + "@chainsafe/netmask" "^2.0.0" + "@multiformats/dns" "^1.0.3" + abort-error "^1.0.1" + multiformats "^13.0.0" + uint8-varint "^2.0.1" + uint8arrays "^5.0.0" + "@multiformats/murmur3@^2.1.8": version "2.1.8" resolved "https://registry.yarnpkg.com/@multiformats/murmur3/-/murmur3-2.1.8.tgz#81c1c15b6391109f3febfca4b3205196615a04e9" @@ -4506,6 +4768,13 @@ dependencies: "@noble/hashes" "1.5.0" +"@noble/curves@^1.9.1": + version "1.9.5" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.5.tgz#d3e15de15ff1a37e4bf629170ada84395e4cf6b5" + integrity sha512-IHiC8xU74NLKg7gNmwMbUVtqqZy9OWKphTAChESCgsXI5NTK6n3ewOFXrj4Dxal/Ml8D3msbPIHfpHLwv50Q2w== + dependencies: + "@noble/hashes" "1.8.0" + "@noble/curves@~1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" @@ -4543,7 +4812,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.1.tgz#5738f6d765710921e7a751e00c20ae091ed8db0f" integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== -"@noble/hashes@1.8.0", "@noble/hashes@^1.6.1": +"@noble/hashes@1.8.0", "@noble/hashes@^1.6.1", "@noble/hashes@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== @@ -8089,7 +8358,7 @@ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== -"@types/ws@^8.2.2", "@types/ws@^8.5.13": +"@types/ws@^8.18.1", "@types/ws@^8.2.2", "@types/ws@^8.5.13": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== @@ -9836,6 +10105,15 @@ asn1js@^3.0.5: pvutils "^1.1.3" tslib "^2.4.0" +asn1js@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.6.tgz#53e002ebe00c5f7fd77c1c047c3557d7c04dce25" + integrity sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" @@ -10283,7 +10561,12 @@ cborg@^4.0.0, cborg@^4.0.5, cborg@^4.2.3: resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.2.6.tgz#7491c29986a87c647d6e2c232e64c82214ca660e" integrity sha512-77vo4KlSwfjCIXcyZUVei4l2gdjesSCeYSx4U/Upwix7pcWZq8uw21sVRpjwn7mjEi//ieJPTj1MRWDHmud1Rg== -cborg@^4.2.10, cborg@^4.2.6: +cborg@^4.2.10: + version "4.2.12" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.2.12.tgz#2f9826773f559e436cb85a49c88fc1bb08bdc373" + integrity sha512-z126yLoavS75cdTuiKu61RC3Y3trqtDAgQRa5Q0dpHn1RmqhIedptWXKnk0lQ5yo/GmcV9myvIkzFgZ8GnqSog== + +cborg@^4.2.6: version "4.2.10" resolved "https://registry.yarnpkg.com/cborg/-/cborg-4.2.10.tgz#d0272aed02f471c90f1576ee8d078f15de1ca69a" integrity sha512-ZVA0xrVn8uBfDJYgfKKZzB/93z/Uiz7YtRdBPsZi/gyHNyqFdHMLHURVEk9dejOHepaX0zhcMyNva2/vF972SA== @@ -11961,7 +12244,7 @@ es-toolkit@1.33.0: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.33.0.tgz#bcc9d92ef2e1ed4618c00dd30dfda9faddf4a0b7" integrity sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg== -esbuild@^0.21.3: +esbuild@0.21.5, esbuild@^0.21.3: version "0.21.5" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== @@ -13327,17 +13610,17 @@ he@1.2.0: integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== helia@^5.3.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/helia/-/helia-5.4.1.tgz#f979ded5970a46c07f1d9e3611639deb8a830849" - integrity sha512-doKPI/xD71xdN3RI3OTJaWBoKVz9/2oqdbYbCoI1L+4fY4JQn5+eeVnbqa5ft4U/ri5GibWh791ZMYkTrLxg7Q== + version "5.5.1" + resolved "https://registry.yarnpkg.com/helia/-/helia-5.5.1.tgz#34a97a83e9cd58c167395f3e5099392f46005249" + integrity sha512-UrTI19VZsNrYc4efwTSIWZeCkJeoxTM4pa/2Zofj5mYLNb6wB09XMJLHBgX1q0dJC7CETVjXeSXcjZgL2s9+AA== dependencies: "@chainsafe/libp2p-noise" "^16.0.1" "@chainsafe/libp2p-yamux" "^7.0.1" - "@helia/block-brokers" "^4.2.1" + "@helia/block-brokers" "^4.2.4" "@helia/delegated-routing-v1-http-api-client" "^4.2.1" - "@helia/interface" "^5.3.1" - "@helia/routers" "^3.1.1" - "@helia/utils" "^1.3.1" + "@helia/interface" "^5.4.0" + "@helia/routers" "^3.1.3" + "@helia/utils" "^1.4.0" "@ipshipyard/libp2p-auto-tls" "^1.0.0" "@libp2p/autonat" "^2.0.19" "@libp2p/bootstrap" "^11.0.20" @@ -13359,10 +13642,9 @@ helia@^5.3.0: "@multiformats/dns" "^1.0.6" blockstore-core "^5.0.2" datastore-core "^10.0.2" - interface-blockstore "^5.3.1" interface-datastore "^8.3.1" ipns "^10.0.0" - libp2p "^2.3.1" + libp2p "^2.9.0" multiformats "^13.3.1" help-me@^4.0.1: @@ -13595,6 +13877,14 @@ interface-blockstore@^5.0.0, interface-blockstore@^5.3.0, interface-blockstore@^ interface-store "^6.0.0" multiformats "^13.2.3" +interface-blockstore@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/interface-blockstore/-/interface-blockstore-5.3.2.tgz#0fb32bb94c12ba5211d417b83abd1f34ed785779" + integrity sha512-oA9Pjkxun/JHAsZrYEyKX+EoPjLciTzidE7wipLc/3YoHDjzsnXRJzAzFJXNUvogtY4g7hIwxArx8+WKJs2RIg== + dependencies: + interface-store "^6.0.0" + multiformats "^13.3.6" + interface-datastore@^8.0.0, interface-datastore@^8.3.0, interface-datastore@^8.3.1: version "8.3.1" resolved "https://registry.yarnpkg.com/interface-datastore/-/interface-datastore-8.3.1.tgz#c793f990c5cf078a24a8a2ded13f7e2099a2a282" @@ -13608,6 +13898,11 @@ interface-store@^6.0.0, interface-store@^6.0.2: resolved "https://registry.yarnpkg.com/interface-store/-/interface-store-6.0.2.tgz#1746a1ee07634f7678b3aa778738b79e3f75c909" integrity sha512-KSFCXtBlNoG0hzwNa0RmhHtrdhzexp+S+UY2s0rWTBJyfdEIgn6i6Zl9otVqrcFYbYrneBT7hbmHQ8gE0C3umA== +interface-store@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/interface-store/-/interface-store-6.0.3.tgz#a4443490976f52e1b40ff99ddfbc690dfbb00863" + integrity sha512-+WvfEZnFUhRwFxgz+QCQi7UC6o9AM0EHM9bpIe2Nhqb100NHCsTvNAn4eJgvgV2/tmLo1MP9nGxQKEcZTAueLA== + internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -13667,10 +13962,10 @@ ip-regex@^5.0.0: resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632" integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw== -ipfs-unixfs-exporter@^13.6.1: - version "13.6.1" - resolved "https://registry.yarnpkg.com/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.6.1.tgz#0e66908c7dcc80c8b4c0b97fc0432c8ac09bcade" - integrity sha512-pYPI4oBTWao2//sFzAL0pURyojn79q/u5BuK6L5/nVbVUQVw6DcVP5uB1ySdWlTM2H+0Zlhp9+OL9aJBRIICpg== +ipfs-unixfs-exporter@^13.6.2: + version "13.6.2" + resolved "https://registry.yarnpkg.com/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.6.2.tgz#a517ba336d239bf63ac59ec08ac20a8a8bb76a17" + integrity sha512-U3NkQHvQn5XzxtjSo1/GfoFIoXYY4hPgOlZG5RUrV5ScBI222b3jAHbHksXZuMy7sqPkA9ieeWdOmnG1+0nxyw== dependencies: "@ipld/dag-cbor" "^9.2.1" "@ipld/dag-json" "^10.2.2" @@ -13689,44 +13984,44 @@ ipfs-unixfs-exporter@^13.6.1: p-queue "^8.0.1" progress-events "^1.0.1" -ipfs-unixfs-exporter@^13.6.2: - version "13.6.2" - resolved "https://registry.yarnpkg.com/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.6.2.tgz#a517ba336d239bf63ac59ec08ac20a8a8bb76a17" - integrity sha512-U3NkQHvQn5XzxtjSo1/GfoFIoXYY4hPgOlZG5RUrV5ScBI222b3jAHbHksXZuMy7sqPkA9ieeWdOmnG1+0nxyw== +ipfs-unixfs-exporter@^13.7.2: + version "13.7.2" + resolved "https://registry.yarnpkg.com/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.7.2.tgz#1374e4365bac62e7960260242fac2d0ab737c38a" + integrity sha512-6SLjuqgytYzdYBGoYktWEek0cZZnaN5fdUs82wXmOX816YWTI588m1AYukN+EVgd+yUkloh6WZEeWZjGbXTsyA== dependencies: - "@ipld/dag-cbor" "^9.2.1" - "@ipld/dag-json" "^10.2.2" - "@ipld/dag-pb" "^4.1.2" + "@ipld/dag-cbor" "^9.2.4" + "@ipld/dag-json" "^10.2.5" + "@ipld/dag-pb" "^4.1.5" "@multiformats/murmur3" "^2.1.8" hamt-sharding "^3.0.6" - interface-blockstore "^5.3.0" + interface-blockstore "^5.3.2" ipfs-unixfs "^11.0.0" - it-filter "^3.1.1" - it-last "^3.0.6" - it-map "^3.1.1" - it-parallel "^3.0.8" + it-filter "^3.1.4" + it-last "^3.0.9" + it-map "^3.1.4" + it-parallel "^3.0.13" it-pipe "^3.0.1" it-pushable "^3.2.3" - multiformats "^13.2.3" - p-queue "^8.0.1" + multiformats "^13.3.7" + p-queue "^8.1.0" progress-events "^1.0.1" -ipfs-unixfs-importer@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/ipfs-unixfs-importer/-/ipfs-unixfs-importer-15.3.2.tgz#8bb3ec00d62019f795e2258da4af18f5f3be830d" - integrity sha512-12FqAAAE3YC6AHtYxZ944nDCabmvbNLdhNCVIN5RJIOri82ss62XdX4lsLpex9VvPzDIJyTAsrKJPcwM6hXGdQ== +ipfs-unixfs-importer@^15.4.0: + version "15.4.0" + resolved "https://registry.yarnpkg.com/ipfs-unixfs-importer/-/ipfs-unixfs-importer-15.4.0.tgz#d58777708476ea5a09782b60694f6ea4cfe391cb" + integrity sha512-laypY07Q0uGLMSxx5YsfVAEPVzdbV2p9cEC1/HNxqoWoz83LA2nX45usr8dcehalKLHm38u0FwUiRHq5wCHngQ== dependencies: - "@ipld/dag-pb" "^4.1.2" + "@ipld/dag-pb" "^4.1.5" "@multiformats/murmur3" "^2.1.8" hamt-sharding "^3.0.6" - interface-blockstore "^5.3.0" - interface-store "^6.0.0" + interface-blockstore "^5.3.2" + interface-store "^6.0.3" ipfs-unixfs "^11.0.0" - it-all "^3.0.6" - it-batch "^3.0.6" - it-first "^3.0.6" - it-parallel-batch "^3.0.6" - multiformats "^13.2.3" + it-all "^3.0.9" + it-batch "^3.0.9" + it-first "^3.0.9" + it-parallel-batch "^3.0.9" + multiformats "^13.3.7" progress-events "^1.0.1" rabin-wasm "^0.1.5" uint8arraylist "^2.4.8" @@ -13740,7 +14035,7 @@ ipfs-unixfs@^11.0.0: protons-runtime "^5.5.0" uint8arraylist "^2.4.8" -ipfs-unixfs@^11.2.0, ipfs-unixfs@^11.2.1: +ipfs-unixfs@^11.2.1: version "11.2.1" resolved "https://registry.yarnpkg.com/ipfs-unixfs/-/ipfs-unixfs-11.2.1.tgz#679adc00cdfd37b55ce5318715efa19051a300b4" integrity sha512-gUeeX63EFgiaMgcs0cUs2ZUPvlOeEZ38okjK8twdWGZX2jYd2rCk8k/TJ3DSRIDZ2t/aZMv6I23guxHaofZE3w== @@ -13748,6 +14043,14 @@ ipfs-unixfs@^11.2.0, ipfs-unixfs@^11.2.1: protons-runtime "^5.5.0" uint8arraylist "^2.4.8" +ipfs-unixfs@^11.2.5: + version "11.2.5" + resolved "https://registry.yarnpkg.com/ipfs-unixfs/-/ipfs-unixfs-11.2.5.tgz#6b684bb2058689b1fe562715e8f69bbe94cfb86a" + integrity sha512-uasYJ0GLPbViaTFsOLnL9YPjX5VmhnqtWRriogAHOe4ApmIi9VAOFBzgDHsUW2ub4pEa/EysbtWk126g2vkU/g== + dependencies: + protons-runtime "^5.5.0" + uint8arraylist "^2.4.8" + ipns@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/ipns/-/ipns-10.0.0.tgz#639b6b2d939a5eac2f01e25670dd5952211f0a20" @@ -14020,6 +14323,11 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -14230,11 +14538,21 @@ it-all@^3.0.0, it-all@^3.0.6: resolved "https://registry.yarnpkg.com/it-all/-/it-all-3.0.6.tgz#30a4f922ae9ca0945b0f720d3478ae6f5b6707ab" integrity sha512-HXZWbxCgQZJfrv5rXvaVeaayXED8nTKx9tj9fpBhmcUJcedVZshMMMqTj0RG2+scGypb9Ut1zd1ifbf3lA8L+Q== -it-batch@^3.0.0, it-batch@^3.0.6: +it-all@^3.0.8, it-all@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/it-all/-/it-all-3.0.9.tgz#9b9b54ddb42786260c3d9e25feeaa02e667be1cc" + integrity sha512-fz1oJJ36ciGnu2LntAlE6SA97bFZpW7Rnt0uEc1yazzR2nKokZLr8lIRtgnpex4NsmaBcvHF+Z9krljWFy/mmg== + +it-batch@^3.0.0: version "3.0.6" resolved "https://registry.yarnpkg.com/it-batch/-/it-batch-3.0.6.tgz#0bcda35bf1c600e821c6d5f4d2446fe85a26ab1d" integrity sha512-pQAAlSvJ4aV6xM/6LRvkPdKSKXxS4my2fGzNUxJyAQ8ccFdxPmK1bUuF5OoeUDkcdrbs8jtsmc4DypCMrGY6sg== +it-batch@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/it-batch/-/it-batch-3.0.9.tgz#d0e3b57403908d3ea14a44ec2b40026b0c44e709" + integrity sha512-z6p89Q8gm2urBtF3JcpnbJogacijWk3m1uc3xZYI3x0eJUoYLUbgF8IxJ2fnuVObV7yRv3SixfwGCufaZY1NCg== + it-byte-stream@^2.0.0, it-byte-stream@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/it-byte-stream/-/it-byte-stream-2.0.2.tgz#fb87ef7f853daffbd38c23029cfdc480d285b3bf" @@ -14246,6 +14564,17 @@ it-byte-stream@^2.0.0, it-byte-stream@^2.0.1: race-signal "^1.1.3" uint8arraylist "^2.4.8" +it-byte-stream@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/it-byte-stream/-/it-byte-stream-2.0.3.tgz#42bfac48d918d865113aa5838d7463b250752405" + integrity sha512-h7FFcn4DWiWsJw1dCJhuPdiY8cGi1z8g4aLAfFspTaJbwQxvEMlEBFG/f8lIVGwM8YK26ClM4/9lxLVhF33b8g== + dependencies: + abort-error "^1.0.1" + it-queueless-pushable "^2.0.0" + it-stream-types "^2.0.2" + race-signal "^1.1.3" + uint8arraylist "^2.4.8" + it-drain@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/it-drain/-/it-drain-3.0.7.tgz#671a5d0220802c5bce9e68fc2b07088540fbc674" @@ -14258,11 +14587,23 @@ it-filter@^3.1.1: dependencies: it-peekable "^3.0.0" +it-filter@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/it-filter/-/it-filter-3.1.4.tgz#bcbeb74edd45c6b8d522e6581edf8a4c0bbb02af" + integrity sha512-80kWEKgiFEa4fEYD3mwf2uygo1dTQ5Y5midKtL89iXyjinruA/sNXl6iFkTcdNedydjvIsFhWLiqRPQP4fAwWQ== + dependencies: + it-peekable "^3.0.0" + it-first@^3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/it-first/-/it-first-3.0.6.tgz#f532f0f36fe9bf0c291e0162b9d3375d59fe8f05" integrity sha512-ExIewyK9kXKNAplg2GMeWfgjUcfC1FnUXz/RPfAvIXby+w7U4b3//5Lic0NV03gXT8O/isj5Nmp6KiY0d45pIQ== +it-first@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/it-first/-/it-first-3.0.9.tgz#cee48427aaa36ec34df6644042c7f22349832070" + integrity sha512-ZWYun273Gbl7CwiF6kK5xBtIKR56H1NoRaiJek2QzDirgen24u8XZ0Nk+jdnJSuCTPxC2ul1TuXKxu/7eK6NuA== + it-foreach@^2.0.6, it-foreach@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/it-foreach/-/it-foreach-2.1.1.tgz#93e311a1057dd0ff7631f914dc9c2c963f27a4b8" @@ -14270,6 +14611,13 @@ it-foreach@^2.0.6, it-foreach@^2.1.1: dependencies: it-peekable "^3.0.0" +it-foreach@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/it-foreach/-/it-foreach-2.1.4.tgz#f7295feefe40b47569863b34271efc3682f62708" + integrity sha512-gFntBbNLpVK9uDmaHusugICD8/Pp+OCqbF5q1Z8K+B8WaG20YgMePWbMxI1I25+JmNWWr3hk0ecKyiI9pOLgeA== + dependencies: + it-peekable "^3.0.0" + it-glob@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/it-glob/-/it-glob-3.0.3.tgz#b1f2ec8083bcec17d19c9410a3b79022dd642ad1" @@ -14282,6 +14630,11 @@ it-last@^3.0.6: resolved "https://registry.yarnpkg.com/it-last/-/it-last-3.0.6.tgz#53b1463e47fcaa950375968002598686101de6ab" integrity sha512-M4/get95O85u2vWvWQinF8SJUc/RPC5bWTveBTYXvlP2q5TF9Y+QhT3nz+CRCyS2YEc66VJkyl/da6WrJ0wKhw== +it-last@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/it-last/-/it-last-3.0.9.tgz#c9e54b7bc16c2c19da10b4658ec465f38d463683" + integrity sha512-AtfUEnGDBHBEwa1LjrpGHsJMzJAWDipD6zilvhakzJcm+BCvNX8zlX2BsHClHJLLTrsY4lY9JUjc+TQV4W7m1w== + it-length-prefixed-stream@^2.0.0, it-length-prefixed-stream@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/it-length-prefixed-stream/-/it-length-prefixed-stream-2.0.2.tgz#c71bd3080efbcaeffa2a9e6e9277a3d516302965" @@ -14293,6 +14646,17 @@ it-length-prefixed-stream@^2.0.0, it-length-prefixed-stream@^2.0.1: uint8-varint "^2.0.4" uint8arraylist "^2.4.8" +it-length-prefixed-stream@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/it-length-prefixed-stream/-/it-length-prefixed-stream-2.0.3.tgz#8020d64e2dc7fea0999f9f4eb95455e43749f49b" + integrity sha512-Ns3jNFy2mcFnV59llCYitJnFHapg8wIcOsWkEaAwOkG9v4HBCk24nze/zGDQjiJdDTyFXTT5GOY3M/uaksot3w== + dependencies: + abort-error "^1.0.1" + it-byte-stream "^2.0.0" + it-stream-types "^2.0.2" + uint8-varint "^2.0.4" + uint8arraylist "^2.4.8" + it-length-prefixed@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/it-length-prefixed/-/it-length-prefixed-10.0.1.tgz#a20fb5ca37d27f85dc8ac3f8aea05e20e849d989" @@ -14316,10 +14680,10 @@ it-map@^3.1.1: dependencies: it-peekable "^3.0.0" -it-map@^3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/it-map/-/it-map-3.1.3.tgz#bccbeb1971e01f2ebe2da9b0e47d68f22cc7d609" - integrity sha512-BAdTuPN/Ie5K4pKLShqyLGBvkLSPtraYXBrX8h+Ki1CZQI8o0dOcaLewISLTXmEJsOHcAjkwxJsVwxND4/Rkpg== +it-map@^3.1.2, it-map@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/it-map/-/it-map-3.1.4.tgz#e27641c3eb2f195e1bf62f63b6d932fceee851e5" + integrity sha512-QB9PYQdE9fUfpVFYfSxBIyvKynUCgblb143c+ktTK6ZuKSKkp7iH58uYFzagqcJ5HcqIfn1xbfaralHWam+3fg== dependencies: it-peekable "^3.0.0" @@ -14330,6 +14694,13 @@ it-merge@^3.0.0, it-merge@^3.0.5: dependencies: it-pushable "^3.2.3" +it-merge@^3.0.11: + version "3.0.12" + resolved "https://registry.yarnpkg.com/it-merge/-/it-merge-3.0.12.tgz#3534be25161e7af024bcf867093077c3d7ea290c" + integrity sha512-nnnFSUxKlkZVZD7c0jYw6rDxCcAQYcMsFj27thf7KkDhpj0EA0g9KHPxbFzHuDoc6US2EPS/MtplkNj8sbCx4Q== + dependencies: + it-queueless-pushable "^2.0.0" + it-ndjson@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/it-ndjson/-/it-ndjson-1.0.7.tgz#017e5e3e1b5fa8c10b8d9a0771bcc9b55baaa6b3" @@ -14343,13 +14714,20 @@ it-pair@^2.0.6: it-stream-types "^2.0.1" p-defer "^4.0.0" -it-parallel-batch@^3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/it-parallel-batch/-/it-parallel-batch-3.0.6.tgz#61487fdaca03cc34c648b3432f59e82e0c805172" - integrity sha512-3wgiQGvMMHy65OXScrtrtmY+bJSF7P6St1AP+BU+SK83fEr8NNk/MrmJKrtB1+MahYX2a8I+pOGKDj8qVtuV0Q== +it-parallel-batch@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/it-parallel-batch/-/it-parallel-batch-3.0.9.tgz#4fcaa8328e2b534471d8dceec907fb6325673b01" + integrity sha512-TszXWqqLG8IG5DUEnC4cgH9aZI6CsGS7sdkXTiiacMIj913bFy7+ohU3IqsFURCcZkpnXtNLNzrYnXISsKBhbQ== dependencies: it-batch "^3.0.0" +it-parallel@^3.0.11, it-parallel@^3.0.13: + version "3.0.13" + resolved "https://registry.yarnpkg.com/it-parallel/-/it-parallel-3.0.13.tgz#bc08878b76d384e7099e3ab7058127261449def5" + integrity sha512-85PPJ/O8q97Vj9wmDTSBBXEkattwfQGruXitIzrh0RLPso6RHfiVqkuTqBNufYYtB1x6PSkh0cwvjmMIkFEPHA== + dependencies: + p-defer "^4.0.1" + it-parallel@^3.0.8: version "3.0.8" resolved "https://registry.yarnpkg.com/it-parallel/-/it-parallel-3.0.8.tgz#fb4a5344732ddae9eff7c7b21908aa1f223638d4" @@ -14381,6 +14759,16 @@ it-protobuf-stream@^2.0.1: it-stream-types "^2.0.2" uint8arraylist "^2.4.8" +it-protobuf-stream@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/it-protobuf-stream/-/it-protobuf-stream-2.0.3.tgz#9106cd2a6cd419a25f964ca0d6f9ad29ae674c76" + integrity sha512-Dus9qyylOSnC7l75/3qs6j3Fe9MCM2K5luXi9o175DYijFRne5FPucdOGIYdwaDBDQ4Oy34dNCuFobOpcusvEQ== + dependencies: + abort-error "^1.0.1" + it-length-prefixed-stream "^2.0.0" + it-stream-types "^2.0.2" + uint8arraylist "^2.4.8" + it-pushable@^3.1.2, it-pushable@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/it-pushable/-/it-pushable-3.2.3.tgz#e2b80aed90cfbcd54b620c0a0785e546d4e5f334" @@ -14388,6 +14776,17 @@ it-pushable@^3.1.2, it-pushable@^3.2.3: dependencies: p-defer "^4.0.0" +it-queue@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/it-queue/-/it-queue-1.1.0.tgz#a26c32b0e0b02e2d30b3d2623f85d2af8fd56a73" + integrity sha512-aK9unJRIaJc9qiv53LByhF7/I2AuD7Ro4oLfLieVLL9QXNvRx++ANMpv8yCp2UO0KAtBuf70GOxSYb6ElFVRpQ== + dependencies: + abort-error "^1.0.1" + it-pushable "^3.2.3" + main-event "^1.0.0" + race-event "^1.3.0" + race-signal "^1.1.3" + it-queueless-pushable@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/it-queueless-pushable/-/it-queueless-pushable-1.0.2.tgz#9ec8e7012f2a1bf3d5604135fa08e2388f2d86cd" @@ -14444,9 +14843,9 @@ it-tar@^6.0.5: uint8arrays "^5.0.2" it-to-browser-readablestream@^2.0.10: - version "2.0.11" - resolved "https://registry.yarnpkg.com/it-to-browser-readablestream/-/it-to-browser-readablestream-2.0.11.tgz#b652b5f0b92492b1b3fc466cdf325c3052325278" - integrity sha512-nDnU2hE5PihjFMsT3/oPph+kIqSMbq+UqsFh94+zJXZF9tpOm6XQfgy6jh4l0VARSDQdtNp17Ul2F1/0o93/gg== + version "2.0.12" + resolved "https://registry.yarnpkg.com/it-to-browser-readablestream/-/it-to-browser-readablestream-2.0.12.tgz#5d0dbd6058794d727a5de840b7cc0ed7eb0c2c06" + integrity sha512-9pcVGxY8jrfMUgCqPrxjVN0bl6fQXCK1NEbUq5Bi+APlr3q0s2AsQINBPcWYgJbMnSHAfoRDthsi4GHqtkvHgw== dependencies: get-iterator "^2.0.1" @@ -15137,38 +15536,38 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -libp2p@^2.3.1, libp2p@^2.8.5: - version "2.8.5" - resolved "https://registry.yarnpkg.com/libp2p/-/libp2p-2.8.5.tgz#b51d4ae1aae12b8e6b6d61afbd83b74acd1e153f" - integrity sha512-K2jqFmNp3LsTeuJ15t6jG0Z9WoydLs+AfSDvhSYQa7lRTu9IANt84SxNg+PsmGxMMiTOtIoMmo27DHzF3+ON8Q== +libp2p@^2.8.5, libp2p@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/libp2p/-/libp2p-2.9.0.tgz#390ab280b9716910819f421ba8adaa4c72e6d140" + integrity sha512-gzRnhLY+k9KjYifWQCYbdEfmWqCFdM0TZ5Q7qqdY13sAUKXixK0MF5+Z9LMrm5ELGDPWX7pRVLGK8BOSv5/v3Q== dependencies: - "@chainsafe/is-ip" "^2.0.2" + "@chainsafe/is-ip" "^2.1.0" "@chainsafe/netmask" "^2.0.0" - "@libp2p/crypto" "^5.1.1" - "@libp2p/interface" "^2.9.0" - "@libp2p/interface-internal" "^2.3.11" - "@libp2p/logger" "^5.1.15" - "@libp2p/multistream-select" "^6.0.22" - "@libp2p/peer-collections" "^6.0.27" - "@libp2p/peer-id" "^5.1.2" - "@libp2p/peer-store" "^11.1.4" - "@libp2p/utils" "^6.6.2" + "@libp2p/crypto" "^5.1.7" + "@libp2p/interface" "^2.10.5" + "@libp2p/interface-internal" "^2.3.18" + "@libp2p/logger" "^5.1.21" + "@libp2p/multistream-select" "^6.0.28" + "@libp2p/peer-collections" "^6.0.34" + "@libp2p/peer-id" "^5.1.8" + "@libp2p/peer-store" "^11.2.6" + "@libp2p/utils" "^6.7.1" "@multiformats/dns" "^1.0.6" - "@multiformats/multiaddr" "^12.3.5" - "@multiformats/multiaddr-matcher" "^1.7.0" + "@multiformats/multiaddr" "^12.4.4" + "@multiformats/multiaddr-matcher" "^2.0.0" any-signal "^4.1.1" datastore-core "^10.0.2" interface-datastore "^8.3.1" - it-byte-stream "^2.0.1" - it-merge "^3.0.5" - it-parallel "^3.0.8" - merge-options "^3.0.4" - multiformats "^13.3.1" + it-byte-stream "^2.0.2" + it-merge "^3.0.11" + it-parallel "^3.0.11" + main-event "^1.0.1" + multiformats "^13.3.6" p-defer "^4.0.1" p-retry "^6.2.1" progress-events "^1.0.1" race-event "^1.3.0" - race-signal "^1.1.2" + race-signal "^1.1.3" uint8arrays "^5.1.0" lilconfig@2.0.5: @@ -15439,6 +15838,11 @@ magic-bytes.js@1.8.0: resolved "https://registry.yarnpkg.com/magic-bytes.js/-/magic-bytes.js-1.8.0.tgz#8362793c60cd77c2dd77db6420be727192df68e2" integrity sha512-lyWpfvNGVb5lu8YUAbER0+UMBTdR63w2mcSUlhhBTyVbxJvjgqwyAf3AZD6MprgK0uHuBoWXSDAMWLupX83o3Q== +main-event@^1.0.0, main-event@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/main-event/-/main-event-1.0.1.tgz#f7eceac5787088d6f943b03286d0964d7e893b3a" + integrity sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw== + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -15688,6 +16092,15 @@ mortice@^3.0.6: p-queue "^8.0.1" p-timeout "^6.0.0" +mortice@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/mortice/-/mortice-3.3.1.tgz#ff52db518da2f5f389abf987c01fc190638c6a2f" + integrity sha512-t3oESfijIPGsmsdLEKjF+grHfrbnKSXflJtgb1wY14cjxZpS6GnhHRXTxxzCAoCCnq1YYfpEPwY3gjiCPhOufQ== + dependencies: + abort-error "^1.0.0" + it-queue "^1.1.0" + main-event "^1.0.0" + motion-dom@^12.23.2: version "12.23.2" resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.23.2.tgz#4a808afda4cb657d4e05794ce1aec9b7bb294d32" @@ -15750,11 +16163,21 @@ multiformats@^13.0.0, multiformats@^13.1.0, multiformats@^13.2.2, multiformats@^ resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.3.1.tgz#ea30d134b5697dcf2036ac819a17948f8a1775be" integrity sha512-QxowxTNwJ3r5RMctoGA5p13w5RbRT2QDkoM+yFlqfLiioBp78nhDjnRLvmSBI9+KAqN4VdgOVWM9c0CHd86m3g== -multiformats@^13.3.1, multiformats@^13.3.2: +multiformats@^13.3.1: version "13.3.3" resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.3.3.tgz#c731180bcb0d03e3c06b4cf48f89fd0cc9e3273f" integrity sha512-TlaFCzs3NHNzMpwiGwRYehnnhHlZcWfptygFekshlb9xCyO09GfN+9881+VBENCdRnKOeqmMxDCbupNecV8xRQ== +multiformats@^13.3.2, multiformats@^13.3.7: + version "13.4.0" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.4.0.tgz#eada9b939650b69e3e9ac553c8cbffe6b3a57596" + integrity sha512-Mkb/QcclrJxKC+vrcIFl297h52QcKh2Az/9A5vbWytbQt4225UWWWmIuSsKksdww9NkIeYcA7DkfftyLuC/JSg== + +multiformats@^13.3.6: + version "13.3.7" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-13.3.7.tgz#9313edd1b152d9997ab6830c4f702783e724d62f" + integrity sha512-meL9DERHj+fFVWoOX9fXqfcYcSpUfSYJPcFvDPKrxitICbwAoWR+Ut4j5NO9zAT917HUHLQmqzQbAsGNHlDcxQ== + multiformats@^9.4.2: version "9.9.0" resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" @@ -16336,6 +16759,14 @@ p-queue@^8.0.1: eventemitter3 "^5.0.1" p-timeout "^6.1.2" +p-queue@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-8.1.0.tgz#d71929249868b10b16f885d8a82beeaf35d32279" + integrity sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw== + dependencies: + eventemitter3 "^5.0.1" + p-timeout "^6.1.2" + p-retry@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-6.2.1.tgz#81828f8dc61c6ef5a800585491572cc9892703af" @@ -16350,7 +16781,7 @@ p-timeout@^6.0.0, p-timeout@^6.1.2: resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.3.tgz#9635160c4e10c7b4c3db45b7d5d26f911d9fd853" integrity sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw== -p-timeout@^6.1.3: +p-timeout@^6.1.3, p-timeout@^6.1.4: version "6.1.4" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.1.4.tgz#418e1f4dd833fa96a2e3f532547dd2abdb08dbc2" integrity sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg== @@ -17292,6 +17723,15 @@ react-native-webrtc@^124.0.4: debug "4.3.4" event-target-shim "6.0.2" +react-native-webrtc@^124.0.5: + version "124.0.6" + resolved "https://registry.yarnpkg.com/react-native-webrtc/-/react-native-webrtc-124.0.6.tgz#340b61a7c35031f3281bc886af8a01e0200b7769" + integrity sha512-5GviOGK19vujT7sGvSYdZE+bBlh0KC9g1JLharzajpCDVrNdCSpYxveOJUINSRevLsmL12FgNJJgnTjFKn7Aqw== + dependencies: + base64-js "1.5.1" + debug "4.3.4" + event-target-shim "6.0.2" + react-number-format@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.1.tgz#840c257da9cb4b248990d8db46e4d23e8bac67ff" @@ -19887,6 +20327,11 @@ ws@^8.18.0, ws@^8.4.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a" integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ== +ws@^8.18.2: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + ws@~8.11.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"