diff --git a/.vscode/settings.json b/.vscode/settings.json index 55712c19f1..8397362a2b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } } \ No newline at end of file diff --git a/configs/app/features/forum.ts b/configs/app/features/forum.ts new file mode 100644 index 0000000000..5b82a0da5b --- /dev/null +++ b/configs/app/features/forum.ts @@ -0,0 +1,32 @@ +import type { Feature } from './types'; + +import stripTrailingSlash from 'lib/stripTrailingSlash'; + +import { getEnvValue } from '../utils'; + +const indexerUrl = stripTrailingSlash(getEnvValue('NEXT_PUBLIC_FORUM_INDEXER_URL') || '') || null; +const walletConnectProjectId = getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID') || ''; + +const title = 'Forum'; + +const config: Feature<{ indexerUrl: string; walletConnectProjectId: string }> = (() => { + if ( + getEnvValue('NEXT_PUBLIC_IS_FORUM_SUPPORTED') === 'true' && + indexerUrl && + walletConnectProjectId + ) { + return Object.freeze({ + title, + isEnabled: true, + walletConnectProjectId, + indexerUrl, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index ad45f9a4a1..fe445a41be 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -6,6 +6,7 @@ export { default as beaconChain } from './beaconChain'; export { default as bridgedTokens } from './bridgedTokens'; export { default as blockchainInteraction } from './blockchainInteraction'; export { default as csvExport } from './csvExport'; +export { default as forum } from './forum'; export { default as googleAnalytics } from './googleAnalytics'; export { default as graphqlApiDocs } from './graphqlApiDocs'; export { default as marketplace } from './marketplace'; diff --git a/configs/app/ui/views/address.ts b/configs/app/ui/views/address.ts index 56789ffad5..c2f0f4fb52 100644 --- a/configs/app/ui/views/address.ts +++ b/configs/app/ui/views/address.ts @@ -27,6 +27,7 @@ const hiddenViews = (() => { const config = Object.freeze({ identiconType, hiddenViews, + solidityscanEnabled: getEnvValue('NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED') === 'true', }); export default config; diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 71dc3fdd4d..5493cd7c08 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -45,3 +45,8 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com #meta NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true + +# ylide +NEXT_PUBLIC_IS_FORUM_SUPPORTED=true +NEXT_PUBLIC_FORUM_INDEXER_URL=https://forum-blockscout.ylide.io +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=e9deead089b3383b2db777961e3fa244 \ No newline at end of file diff --git a/configs/envs/.env.eth_goerli b/configs/envs/.env.eth_goerli index bea2e6654e..da1fac4d97 100644 --- a/configs/envs/.env.eth_goerli +++ b/configs/envs/.env.eth_goerli @@ -47,6 +47,7 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask'] +NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true' #meta NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true diff --git a/deploy/helmfile.yaml b/deploy/helmfile.yaml index 0e401dce3d..ac44d659af 100644 --- a/deploy/helmfile.yaml +++ b/deploy/helmfile.yaml @@ -19,7 +19,7 @@ releases: # Deploy front-main - name: bs-stack chart: blockscout/blockscout-stack - version: 1.2.* + version: 1.3.* namespace: front-main labels: app: front @@ -30,7 +30,7 @@ releases: # Deploy l2-optimism-goerli - name: bs-stack chart: blockscout/blockscout-stack - version: 1.2.* + version: 1.3.* namespace: l2-optimism-goerli labels: app: l2-optimism-goerli @@ -55,7 +55,7 @@ releases: type: kubernetes.io/dockerconfigjson - name: bs-stack chart: blockscout/blockscout-stack - version: 1.2.* + version: 1.3.* namespace: review-l2-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }} labels: app: review-l2-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }} @@ -80,7 +80,7 @@ releases: type: kubernetes.io/dockerconfigjson - name: bs-stack chart: blockscout/blockscout-stack - version: 1.2.* + version: 1.3.* namespace: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }} labels: app: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }} diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 7940f55a48..b280436fd3 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -182,6 +182,19 @@ const sentrySchema = yup }), }); +const ylideSchema = yup + .object() + .shape({ + NEXT_PUBLIC_IS_FORUM_SUPPORTED: yup.boolean(), + NEXT_PUBLIC_FORUM_INDEXER_URL: yup + .string() + .when('NEXT_PUBLIC_IS_FORUM_SUPPORTED', { + is: (value: boolean) => value, + then: (schema) => schema.test(urlTest).required(), + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_FORUM_INDEXER_URL cannot not be used if NEXT_PUBLIC_IS_FORUM_SUPPORTED is not set to "true"'), + }), + }); + const accountSchema = yup .object() .shape({ @@ -392,6 +405,7 @@ const schema = yup .transform(replaceQuotes) .json() .of(yup.string().oneOf(ADDRESS_VIEWS_IDS)), + NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED: yup.boolean(), NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: yup .array() .transform(replaceQuotes) @@ -453,6 +467,7 @@ const schema = yup NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(), }) .concat(accountSchema) + .concat(ylideSchema) .concat(adsBannerSchema) .concat(marketplaceSchema) .concat(rollupSchema) diff --git a/deploy/tools/envs-validator/test/.env.base b/deploy/tools/envs-validator/test/.env.base index 578d5f91f6..d9f5c79c49 100644 --- a/deploy/tools/envs-validator/test/.env.base +++ b/deploy/tools/envs-validator/test/.env.base @@ -50,4 +50,7 @@ NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas'] NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','tx_fee','gas_fees','burnt_fees'] NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false -NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket'] \ No newline at end of file +NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket'] +NEXT_PUBLIC_IS_FORUM_SUPPORTED=true +NEXT_PUBLIC_FORUM_INDEXER_URL=https://forum-blockscout.ylide.io +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=e9deead089b3383b2db777961e3fa244 \ No newline at end of file diff --git a/deploy/values/l2-optimism-goerli/values.yaml b/deploy/values/l2-optimism-goerli/values.yaml index 1d695580d4..0cc8861bc0 100644 --- a/deploy/values/l2-optimism-goerli/values.yaml +++ b/deploy/values/l2-optimism-goerli/values.yaml @@ -194,6 +194,8 @@ frontend: NEXT_PUBLIC_L1_BASE_URL: https://eth-goerli.blockscout.com/ NEXT_PUBLIC_OPTIMISTIC_L2_WITHDRAWAL_URL: https://app.optimism.io/bridge/withdraw NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62 + NEXT_PUBLIC_IS_FORUM_SUPPORTED: true + NEXT_PUBLIC_FORUM_INDEXER_URL: https://forum-blockscout.ylide.io envFromSecret: NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/front-main?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/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID diff --git a/deploy/values/main/values.yaml b/deploy/values/main/values.yaml index 65f36f4dcd..7baede3e7f 100644 --- a/deploy/values/main/values.yaml +++ b/deploy/values/main/values.yaml @@ -81,10 +81,10 @@ blockscout: INDEXER_COIN_BALANCES_BATCH_SIZE: 50 DISABLE_EXCHANGE_RATES: 'true' DISABLE_INDEXER: 'false' - FIRST_BLOCK: '8739119' - LAST_BLOCK: '8739119' - TRACE_FIRST_BLOCK: '8739119' - TRACE_LAST_BLOCK: '8739119' + FIRST_BLOCK: '10200150' + LAST_BLOCK: '10200150' + TRACE_FIRST_BLOCK: '10200150' + TRACE_LAST_BLOCK: '10200150' envFromSecret: ETHEREUM_JSONRPC_TRACE_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ETHEREUM_JSONRPC_TRACE_URL ETHEREUM_JSONRPC_HTTP_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ETHEREUM_JSONRPC_HTTP_URL diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index e55c460edf..df954e173a 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -69,6 +69,8 @@ frontend: NEXT_PUBLIC_L1_BASE_URL: https://blockscout-main.k8s-dev.blockscout.com NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62 NEXT_PUBLIC_USE_NEXT_JS_PROXY: true + NEXT_PUBLIC_IS_FORUM_SUPPORTED: true + NEXT_PUBLIC_FORUM_INDEXER_URL: https://forum-blockscout.ylide.io envFromSecret: NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index b720f05d2b..6172834852 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -34,6 +34,8 @@ frontend: nginx.ingress.kubernetes.io/proxy-connect-timeout: "15m" nginx.ingress.kubernetes.io/proxy-send-timeout: "15m" nginx.ingress.kubernetes.io/proxy-read-timeout: "15m" + nginx.ingress.kubernetes.io/proxy-buffer-size: "128k" + nginx.ingress.kubernetes.io/proxy-buffers-number: "8" cert-manager.io/cluster-issuer: "zerossl-prod" hostname: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com @@ -51,8 +53,8 @@ frontend: NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg - NEXT_PUBLIC_API_HOST: etc.blockscout.com - NEXT_PUBLIC_STATS_API_HOST: https://stats-etc.k8s.blockscout.com/ + NEXT_PUBLIC_API_HOST: eth-goerli.blockscout.com + NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com @@ -67,13 +69,13 @@ frontend: NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']" NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS: "['top_accounts']" + NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED: true NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS: "['value','fee_currency','gas_price','gas_fees','burnt_fees']" NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']" NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" - OTEL_SDK_ENABLED: true - OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger-collector.jaeger.svc.cluster.local:4318 - NEXT_OTEL_VERBOSE: 1 + NEXT_PUBLIC_IS_FORUM_SUPPORTED: true + NEXT_PUBLIC_FORUM_INDEXER_URL: https://forum-blockscout.ylide.io envFromSecret: NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI diff --git a/docs/ENVS.md b/docs/ENVS.md index b405ff8178..ffe0e9f56c 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -50,6 +50,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will - [SUAVE chain](ENVS.md#suave-chain) - [Sentry error monitoring](ENVS.md#sentry-error-monitoring) - [OpenTelemetry](ENVS.md#opentelemetry) + - [Ylide Forum](ENVS.md#ylide) - [3rd party services configuration](ENVS.md#external-services-configuration)   @@ -197,6 +198,7 @@ Settings for meta tags and OG tags | --- | --- | --- | --- | --- | --- | | NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` | | NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS | `Array` | Address views that should not be displayed. See below the list of the possible id values. | - | - | `'["top_accounts"]'` | +| NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED | `boolean` | Set to `true` if SolidityScan reports are supperted | - | - | `true` | ##### Address views list | Id | Description | @@ -547,6 +549,18 @@ OpenTelemetry SDK for Node.js app could be enabled by passing `OTEL_SDK_ENABLED=   +### Ylide Forum + +Decenrtalized forum feature could be enabled by passing `NEXT_PUBLIC_IS_FORUM_SUPPORTED=true` variable. After deploying the forum indexer Docker image, you should pass the public url of backend API to the variable `NEXT_PUBLIC_FORUM_INDEXER_URL` (without trailing slash). If you want to support WalletConnect protocol for authorization, please, kindly sign up in the [Wallet Connect Cloud](https://cloud.walletconnect.com/sign-in) and provide the WalletConnect project ID to the `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` variable. + +| Variable | Type| Description | Compulsoriness | Default value | Example value | +| --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_IS_FORUM_SUPPORTED | `boolean` | Flag to enable the feature | Required | `false` | `true` | +| NEXT_PUBLIC_FORUM_INDEXER_URL | `string` | URL of the forum public indexer | Optional | `` | `https://forum-blockscout.ylide.io` | +| NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID | `string` | Flag to enable the feature | Optional | `` | `e9deead089b3383b2db777961e3fa244` | + +  + ## External services configuration ### Google ReCaptcha diff --git a/icons/account.svg b/icons/account.svg new file mode 100644 index 0000000000..489c88bd1b --- /dev/null +++ b/icons/account.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/icons/arrows/arrow-back.svg b/icons/arrows/arrow-back.svg new file mode 100644 index 0000000000..594cf996fe --- /dev/null +++ b/icons/arrows/arrow-back.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/arrows/arrow-down.svg b/icons/arrows/arrow-down.svg new file mode 100644 index 0000000000..fe7dd0df74 --- /dev/null +++ b/icons/arrows/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/arrows/arrow-up.svg b/icons/arrows/arrow-up.svg new file mode 100644 index 0000000000..4f07a7a86b --- /dev/null +++ b/icons/arrows/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/arrows/caret-down.svg b/icons/arrows/caret-down.svg new file mode 100644 index 0000000000..d5818e3a75 --- /dev/null +++ b/icons/arrows/caret-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/arrows/caret-right.svg b/icons/arrows/caret-right.svg new file mode 100644 index 0000000000..08a42e8f4a --- /dev/null +++ b/icons/arrows/caret-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/attachment.svg b/icons/attachment.svg new file mode 100644 index 0000000000..7c91577f4d --- /dev/null +++ b/icons/attachment.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/bookmark.svg b/icons/bookmark.svg new file mode 100644 index 0000000000..81078f42a8 --- /dev/null +++ b/icons/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/bookmark_filled.svg b/icons/bookmark_filled.svg new file mode 100644 index 0000000000..6f3a0716ff --- /dev/null +++ b/icons/bookmark_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/chat.svg b/icons/chat.svg new file mode 100644 index 0000000000..ecaebea99d --- /dev/null +++ b/icons/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/chat_list.svg b/icons/chat_list.svg new file mode 100644 index 0000000000..dcca43382f --- /dev/null +++ b/icons/chat_list.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/eye.svg b/icons/eye.svg new file mode 100644 index 0000000000..61c7295694 --- /dev/null +++ b/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/eye_filled.svg b/icons/eye_filled.svg new file mode 100644 index 0000000000..87b0d7bee3 --- /dev/null +++ b/icons/eye_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/forum.svg b/icons/forum.svg new file mode 100644 index 0000000000..7e0ecd7df6 --- /dev/null +++ b/icons/forum.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/horizontal_dots.svg b/icons/horizontal_dots.svg new file mode 100644 index 0000000000..ab24132b9a --- /dev/null +++ b/icons/horizontal_dots.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/logout.svg b/icons/logout.svg new file mode 100644 index 0000000000..fb76af6369 --- /dev/null +++ b/icons/logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/proceedTOWalletArrow.svg b/icons/proceedTOWalletArrow.svg new file mode 100644 index 0000000000..85debd96b0 --- /dev/null +++ b/icons/proceedTOWalletArrow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/icons/reply.svg b/icons/reply.svg new file mode 100644 index 0000000000..7cb24d4dd7 --- /dev/null +++ b/icons/reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/score/score-not-ok.svg b/icons/score/score-not-ok.svg new file mode 100644 index 0000000000..9b4533f6ac --- /dev/null +++ b/icons/score/score-not-ok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/score/score-ok.svg b/icons/score/score-ok.svg new file mode 100644 index 0000000000..dbe11836fd --- /dev/null +++ b/icons/score/score-ok.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/send.svg b/icons/send.svg new file mode 100644 index 0000000000..5fe7ec2d0e --- /dev/null +++ b/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/jest/lib.tsx b/jest/lib.tsx index 2c590932cf..f08f9b335c 100644 --- a/jest/lib.tsx +++ b/jest/lib.tsx @@ -6,9 +6,9 @@ import React from 'react'; import { AppContextProvider } from 'lib/contexts/app'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; +import { YlideProvider } from 'lib/contexts/ylide'; import { SocketProvider } from 'lib/socket/context'; import theme from 'theme'; - import 'lib/setLocale'; const PAGE_PROPS = { @@ -19,6 +19,8 @@ const PAGE_PROPS = { hash: '', number: '', q: '', + topic: '', + thread: '', }; const TestApp = ({ children }: {children: React.ReactNode}) => { @@ -37,7 +39,9 @@ const TestApp = ({ children }: {children: React.ReactNode}) => { - { children } + + { children } + diff --git a/lib/ago.ts b/lib/ago.ts new file mode 100644 index 0000000000..bffba6ca3c --- /dev/null +++ b/lib/ago.ts @@ -0,0 +1,20 @@ +export default function ago(timestamp: number, now = Date.now()) { + const seconds = Math.floor((now - timestamp) / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) { + return `${ seconds } second${ seconds === 1 ? '' : 's' } ago`; + } + + if (minutes < 60) { + return `${ minutes } minute${ minutes === 1 ? '' : 's' } ago`; + } + + if (hours < 24) { + return `${ hours } hour${ hours === 1 ? '' : 's' } ago`; + } + + return `${ days } day${ days === 1 ? '' : 's' } ago`; +} diff --git a/lib/api/resources.ts b/lib/api/resources.ts index bf1fc70c56..d317d6d5ac 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -31,7 +31,7 @@ import type { AddressesResponse } from 'types/api/addresses'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts'; import type { BackendVersionConfig } from 'types/api/configs'; -import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig } from 'types/api/contract'; +import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod, SmartContractVerificationConfig, SolidityscanReport } from 'types/api/contract'; import type { VerifiedContractsResponse, VerifiedContractsFilters, VerifiedContractsCounters } from 'types/api/contracts'; import type { IndexingStatus } from 'types/api/indexingStatus'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; @@ -343,6 +343,10 @@ export const RESOURCES = { path: '/api/v2/smart-contracts/:hash/verification/via/:method', pathParams: [ 'hash' as const, 'method' as const ], }, + contract_solidityscan_report: { + path: '/api/v2/smart-contracts/:hash/solidityscan-report', + pathParams: [ 'hash' as const ], + }, verified_contracts: { path: '/api/v2/smart-contracts', @@ -659,6 +663,7 @@ Q extends 'contract_methods_read' ? Array : Q extends 'contract_methods_read_proxy' ? Array : Q extends 'contract_methods_write' ? Array : Q extends 'contract_methods_write_proxy' ? Array : +Q extends 'contract_solidityscan_report' ? SolidityscanReport : Q extends 'verified_contracts' ? VerifiedContractsResponse : Q extends 'verified_contracts_counters' ? VerifiedContractsCounters : Q extends 'visualize_sol2uml' ? VisualizedContract : diff --git a/lib/api/ylideApi/ChatsPersonalApi.ts b/lib/api/ylideApi/ChatsPersonalApi.ts new file mode 100644 index 0000000000..2742c789d0 --- /dev/null +++ b/lib/api/ylideApi/ChatsPersonalApi.ts @@ -0,0 +1,110 @@ +import type { IMessage } from '@ylide/sdk'; +import React from 'react'; + +import { DEFAULT_CHAINS } from 'ui/shared/forum/SelectBlockchainDropdown'; + +import useChatsApiFetch from './useChatsApiFetch'; + +const useChatsGetList = () => { + const fetch = useChatsApiFetch(); + + return React.useCallback((myAddress: string, offset = 0, limit = 100) => { + return fetch<{ + result: true; + data: { + totalCount: string; + entries: Array<{ + address: string; + msgId: string; + metadata: Omit & { key: Array }; + isSentByYou: boolean; + lastMessageTimestamp: number; + }>; + }; + }>({ + url: '/v2/chats', + fetchParams: { + method: 'POST', + body: { + myAddress, + offset, + limit, + blockchain: DEFAULT_CHAINS, + }, + }, + }).then(response => ({ + ...response, + data: { + ...response.data, + entries: response.data.entries.map(e => ({ + ...e, + metadata: { + ...e.metadata, + key: new Uint8Array(e.metadata.key), + }, + })), + }, + })); + }, [ fetch ]); +}; + +const useChatsGetMessages = () => { + const fetch = useChatsApiFetch(); + + return React.useCallback(( + myAddress: string, + recipientAddress: string, + offset = 0, + limit = 100, + ) => { + return fetch<{ + result: true; + data: { + totalCount: string; + entries: Array<{ + id: string; + type: 'message'; + isIncoming: boolean; + msg: IMessage; + }>; + }; + }>({ + url: '/v2/thread', + fetchParams: { + method: 'POST', + body: { + myAddress, + recipientAddress, + offset, + limit, + blockchain: DEFAULT_CHAINS, + }, + }, + }).then(result => { + if (result.result) { + return { + ...result, + data: { + ...result.data, + entries: result.data.entries.map(m => ({ + ...m, + msg: { + ...m.msg, + key: new Uint8Array(m.msg.key), + }, + })), + }, + }; + } else { + return result; + } + }); + }, [ fetch ]); +}; + +const publicApi = { + useGetChats: useChatsGetList, + useGetMessages: useChatsGetMessages, +}; + +export default publicApi; diff --git a/lib/api/ylideApi/ForumPersonalApi.ts b/lib/api/ylideApi/ForumPersonalApi.ts new file mode 100644 index 0000000000..37216d2e1f --- /dev/null +++ b/lib/api/ylideApi/ForumPersonalApi.ts @@ -0,0 +1,167 @@ +import React from 'react'; + +import type { ForumThread, ForumTopic } from './types'; + +import useYlideApiFetch from './useForumApiFetch'; + +const useForumBackendAcquireAuthKey = () => { + const fetch = useYlideApiFetch(); + + return React.useCallback((body: { + messageEncrypted: string; + publicKey: string; + address: string; + }) => { + return fetch<{ token: string }>({ + url: '/auth/', + fetchParams: { + method: 'POST', + body, + }, + }).then(({ token }: { token: string }) => token); + }, [ fetch ]); +}; + +const useForumBackendCreateTopic = (token: string) => { + const fetch = useYlideApiFetch(token); + + return React.useCallback((body: { + title: string; + description: string; + adminOnly: boolean; + }) => { + return fetch({ + url: '/topic/', + fetchParams: { + method: 'POST', + body, + }, + }); + }, [ fetch ]); +}; + +const useForumBackendBookmarkTopic = () => { + const fetch = useYlideApiFetch(); + + return React.useCallback((body: { + token: string; + id: string; + enable: boolean; + }) => { + return fetch({ + url: '/topic/bookmark/', + fetchParams: { + method: 'POST', + body: { + id: body.id, + enable: body.enable, + }, + }, + tokens: body.token, + }); + }, [ fetch ]); +}; + +const useForumBackendWatchTopic = () => { + const fetch = useYlideApiFetch(); + + return React.useCallback((body: { + token: string; + id: string; + enable: boolean; + }) => { + return fetch({ + url: '/topic/watch/', + fetchParams: { + method: 'POST', + body: { + id: body.id, + enable: body.enable, + }, + }, + tokens: body.token, + }); + }, [ fetch ]); +}; + +const useForumBackendBookmarkThread = () => { + const fetch = useYlideApiFetch(); + + return React.useCallback((body: { + token: string; + id: string; + enable: boolean; + }) => { + return fetch({ + url: '/thread/bookmark/', + fetchParams: { + method: 'POST', + body: { + id: body.id, + enable: body.enable, + }, + }, + tokens: body.token, + }); + }, [ fetch ]); +}; + +const useForumBackendWatchThread = () => { + const fetch = useYlideApiFetch(); + + return React.useCallback((body: { + token: string; + id: string; + enable: boolean; + }) => { + return fetch({ + url: '/thread/watch/', + fetchParams: { + method: 'POST', + body: { + id: body.id, + enable: body.enable, + }, + }, + tokens: body.token, + }); + }, [ fetch ]); +}; + +export interface ThreadCreateParams { + topic: string; + title: string; + description: string; + + parentFeedId?: string; + tags: Array; + blockchainAddress?: string; + blockchainTx?: string; + comissions?: string; +} + +const useForumBackendCreateThread = (token: string) => { + const fetch = useYlideApiFetch(token); + + return React.useCallback((body: ThreadCreateParams) => { + return fetch({ + url: '/thread/', + fetchParams: { + method: 'POST', + body, + }, + }); + }, [ fetch ]); +}; + +const personalApi = { + useAcquireAuthKey: useForumBackendAcquireAuthKey, + useCreateTopic: useForumBackendCreateTopic, + useCreateThread: useForumBackendCreateThread, + useBookmarkTopic: useForumBackendBookmarkTopic, + useBookmarkThread: useForumBackendBookmarkThread, + useWatchTopic: useForumBackendWatchTopic, + useWatchThread: useForumBackendWatchThread, +}; + +export default personalApi; diff --git a/lib/api/ylideApi/ForumPublicApi.ts b/lib/api/ylideApi/ForumPublicApi.ts new file mode 100644 index 0000000000..506551d0eb --- /dev/null +++ b/lib/api/ylideApi/ForumPublicApi.ts @@ -0,0 +1,227 @@ +import type { Uint256 } from '@ylide/sdk'; +import React from 'react'; + +import type { PaginatedArray, ForumTopic, ForumThread, ForumReply } from './types'; + +import { useYlide } from 'lib/contexts/ylide'; + +import useForumApiFetch from './useForumApiFetch'; + +const useForumBackendGetMe = (tokens: Array) => { + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch>({ + url: '/auth/me', + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch ]); +}; + +const useForumBackendGetTopics = (tokens?: Array, criteria: 'all' | 'bookmarked' | 'watched' = 'all') => { + const fetch = useForumApiFetch(tokens); + + return React.useCallback((query: string, sort: [string, 'ASC' | 'DESC'], range: [number, number]) => { + const queryParams: Record = query ? { + sort: JSON.stringify(sort), + search: query, + } : { + sort: JSON.stringify(sort), + }; + if (criteria === 'bookmarked') { + queryParams.onlyBookmarked = 'true'; + } else + if (criteria === 'watched') { + queryParams.onlyWatched = 'true'; + } + queryParams.range = JSON.stringify(range); + return fetch>({ + url: '/topic/', + queryParams, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, criteria ]); +}; + +const useForumBackendGetTopic = (id: string) => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch({ + url: `/topic/${ id }`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, id ]); +}; + +const useForumBackendGetThreads = ( + tokens?: Array, + topicSlug?: string, + criteria: 'all' | 'bookmarked' | 'watched' = 'all', + tag?: string, +) => { + const fetch = useForumApiFetch(tokens); + + return React.useCallback((query: string, sort: [string, 'ASC' | 'DESC'], range: [number, number]) => { + const queryParams: Record = query ? { + sort: JSON.stringify(sort), + search: query, + } : { + sort: JSON.stringify(sort), + }; + if (topicSlug) { + queryParams.topicSlug = topicSlug; + } + if (criteria === 'bookmarked') { + queryParams.onlyBookmarked = 'true'; + } else + if (criteria === 'watched') { + queryParams.onlyWatched = 'true'; + } + if (tag) { + queryParams.tags = tag; + } + queryParams.range = JSON.stringify(range); + return fetch>({ + url: `/thread/`, + queryParams, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, topicSlug, criteria, tag ]); +}; + +const useForumBackendGetThreadsMeta = (topicSlug: string) => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch<{ + pinnedThreads: Array; + topTags: Array<{ name: string; count: string }>; + }>({ + url: `/topic/${ topicSlug }/meta`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, topicSlug ]); +}; + +const useForumBackendGetBestThreads = () => { + const fetch = useForumApiFetch(); + + return React.useCallback(() => { + return fetch<{ + latest: Array; + newest: Array; + popular: Array; + }>({ + url: `/thread/best`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch ]); +}; + +const useForumBackendGetThread = (id: string) => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch({ + url: `/thread/${ id }`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, id ]); +}; + +const useForumBackendGetThreadByTx = (txHash: string) => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch({ + url: `/thread//blockchain/transaction/${ txHash }`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, txHash ]); +}; + +const useForumBackendGetThreadByAddress = (address: string) => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback(() => { + return fetch({ + url: `/thread//blockchain/address/${ address }`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch, address ]); +}; + +const useForumBackendGetReplies = () => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback((feedId: Uint256, sort: [string, 'ASC' | 'DESC']) => { + return fetch>({ + url: `/reply/`, + queryParams: { + feedId, + sort: JSON.stringify(sort), + }, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch ]); +}; + +const useForumBackendGetReply = () => { + const { accounts: { tokens } } = useYlide(); + const fetch = useForumApiFetch(tokens); + + return React.useCallback((id: string) => { + return fetch({ + url: `/reply/${ id }`, + fetchParams: { + method: 'GET', + }, + }); + }, [ fetch ]); +}; + +const publicApi = { + useGetMe: useForumBackendGetMe, + + useGetTopics: useForumBackendGetTopics, + useGetTopic: useForumBackendGetTopic, + + useGetThreads: useForumBackendGetThreads, + useGetThreadsMeta: useForumBackendGetThreadsMeta, + useGetBestThreads: useForumBackendGetBestThreads, + useGetThread: useForumBackendGetThread, + useGetThreadByTx: useForumBackendGetThreadByTx, + useGetThreadByAddress: useForumBackendGetThreadByAddress, + + useGetReplies: useForumBackendGetReplies, + useGetReply: useForumBackendGetReply, +}; + +export default publicApi; diff --git a/lib/api/ylideApi/ForumTelegramApi.ts b/lib/api/ylideApi/ForumTelegramApi.ts new file mode 100644 index 0000000000..03604cb64b --- /dev/null +++ b/lib/api/ylideApi/ForumTelegramApi.ts @@ -0,0 +1,27 @@ +import React from 'react'; + +import useTelegramApiFetch from './useTelegramApiFetch'; + +const useForumTelegramGetLink = () => { + const fetch = useTelegramApiFetch(); + + return React.useCallback(( + addresses: Array, + ) => { + return fetch<{ + result: true; + data: string; + }>({ + url: `/get-link?${ addresses.map(address => `addresses=${ encodeURIComponent(address) }`).join('&') }`, + fetchParams: { + method: 'GET', + }, + }).then(res => res.data); + }, [ fetch ]); +}; + +const forumTelegramApi = { + useGetLink: useForumTelegramGetLink, +}; + +export default forumTelegramApi; diff --git a/lib/api/ylideApi/types.ts b/lib/api/ylideApi/types.ts new file mode 100644 index 0000000000..d959aa61c5 --- /dev/null +++ b/lib/api/ylideApi/types.ts @@ -0,0 +1,78 @@ +import type { Uint256 } from '@ylide/sdk'; + +export interface PaginatedState { + loading: boolean; + error: NonNullable | null; + data: PaginatedArray; +} + +export interface PaginatedArray { + count: number; + items: Array; +} + +export interface ForumTopic { + id: string; + title: string; + description: string; + adminOnly: boolean; + creatorAddress: string; + slug: string; + threadsCount: string; + bookmarked: Array | null; + watched: Array | null; +} + +export interface ForumThreadCompact { + slug: string; + feedId: Uint256; + createTimestamp: number; + updateTimestamp: number | null; + + topic: string; + title: string; + description: string; + tags: Array; + + blockchainAddress: string | null; + blockchainTx: string | null; + comissions: Record; + creatorAddress: string; + messageFeedId: Uint256; + parentFeedId: Uint256 | null; + + bookmarked: Array | null; + watched: Array | null; +} + +export interface ForumThread extends ForumThreadCompact { + activated: boolean; + replyCount: string; + tags: Array; + topicId: string; + topicSlug: string; +} + +export interface ForumReply { + id: string; + + createTimestamp: number; + + blockchain: string; + feedId: Uint256; + sender: string; + + contentText: string; + + banned: boolean; + isAdmin: boolean; +} + +export const defaultPaginatedState: () => PaginatedState = () => ({ + loading: true, + error: null, + data: { + count: 0, + items: [], + }, +}); diff --git a/lib/api/ylideApi/useChatsApiFetch.ts b/lib/api/ylideApi/useChatsApiFetch.ts new file mode 100644 index 0000000000..f9d19ba81b --- /dev/null +++ b/lib/api/ylideApi/useChatsApiFetch.ts @@ -0,0 +1,43 @@ +// const { entries: enteriesRaw } = await indexerRequest<{ +// totalCount: number +// entries: { type: 'message' | string; id: string; isIncoming: boolean; msg: IMessage }[] +// }>(CHAT_ENDPOINT, { +// myAddress: account, +// recipientAddress: recipientAddress || recipientName, +// offset: 0, +// limit: 1000, +// }) + +import _pickBy from 'lodash/pickBy'; +import React from 'react'; + +import useFetch from 'lib/hooks/useFetch'; +import type { Params as FetchParams } from 'lib/hooks/useFetch'; + +export interface Params { + url?: string; + queryParams?: Record; + fetchParams?: Pick; +} + +const baseUrl = `https://idx1.ylide.io`; + +export default function useChatsApiFetch() { + const fetch = useFetch(); + + return React.useCallback(( + { url, queryParams, fetchParams }: Params = {}, + ) => { + const headers = _pickBy({ + 'Content-type': fetchParams?.body ? 'text/plain' : undefined, + }, Boolean) as HeadersInit; + + return fetch( + `${ baseUrl }${ url }?${ new URLSearchParams(queryParams || {}).toString() }`, + { + headers, + ...fetchParams, + }, + ); + }, [ fetch ]); +} diff --git a/lib/api/ylideApi/useForumApiFetch.ts b/lib/api/ylideApi/useForumApiFetch.ts new file mode 100644 index 0000000000..8ef1a4c7ae --- /dev/null +++ b/lib/api/ylideApi/useForumApiFetch.ts @@ -0,0 +1,41 @@ +import _pickBy from 'lodash/pickBy'; +import React from 'react'; + +import type { Params as FetchParams } from 'lib/hooks/useFetch'; +import useFetch from 'lib/hooks/useFetch'; + +export interface Params { + url?: string; + queryParams?: Record | Array>; + fetchParams?: Pick; + tokens?: string | Array; +} + +const baseUrl = `https://blockscout-feed1.ylide.io`; + +const mergeTokens = (tokens?: string | Array, overrideTokens?: string | Array) => { + const firstTokens = typeof tokens === 'string' ? [ tokens ] : tokens || []; + const secondTokens = typeof overrideTokens === 'string' ? [ overrideTokens ] : overrideTokens || []; + return [ ...new Set(firstTokens.concat(secondTokens)).values() ]; +}; + +export default function useForumApiFetch(tokens?: string | Array) { + const fetch = useFetch(); + + return React.useCallback(( + { url, queryParams, fetchParams, tokens: overrideTokens }: Params = {}, + ) => { + const mergedTokens = mergeTokens(tokens, overrideTokens); + const headers = _pickBy({ + Authorization: mergedTokens ? `Bearer ${ typeof mergedTokens === 'string' ? mergedTokens : mergedTokens.join(' ') }` : undefined, + }, Boolean) as HeadersInit; + + return fetch( + `${ baseUrl }${ url }?${ new URLSearchParams(queryParams || {}).toString() }`, + { + headers, + ...fetchParams, + }, + ); + }, [ fetch, tokens ]); +} diff --git a/lib/api/ylideApi/useTelegramApiFetch.ts b/lib/api/ylideApi/useTelegramApiFetch.ts new file mode 100644 index 0000000000..834c17a8b0 --- /dev/null +++ b/lib/api/ylideApi/useTelegramApiFetch.ts @@ -0,0 +1,27 @@ +import React from 'react'; + +import useFetch from 'lib/hooks/useFetch'; +import type { Params as FetchParams } from 'lib/hooks/useFetch'; + +export interface Params { + url?: string; + queryParams?: Record; + fetchParams?: Pick; +} + +const baseUrl = `https://bss-tg.ylide.io`; + +export default function useTelegramApiFetch() { + const fetch = useFetch(); + + return React.useCallback(( + { url, queryParams, fetchParams }: Params = {}, + ) => { + return fetch( + `${ baseUrl }${ url }${ queryParams ? `?${ new URLSearchParams(queryParams || {}).toString() }` : '' }`, + { + ...fetchParams, + }, + ); + }, [ fetch ]); +} diff --git a/lib/api/ylideApi/utils.ts b/lib/api/ylideApi/utils.ts new file mode 100644 index 0000000000..d9e37fb074 --- /dev/null +++ b/lib/api/ylideApi/utils.ts @@ -0,0 +1,21 @@ +import type { PaginatedState } from './types'; +import type { PaginationParams } from 'ui/shared/pagination/types'; + +export const calcForumPagination = ( + pageSize: number, + page: number, + setPage: (_page: number) => void, + state: PaginatedState, +): PaginationParams => { + return { + page, + onNextPageClick: () => setPage(page + 1), + onPrevPageClick: () => setPage(page - 1), + resetPage: () => setPage(1), + hasPages: state.data.count > pageSize, + hasNextPage: state.data.count > pageSize * page, + canGoBackwards: page > 1, + isLoading: state.loading, + isVisible: state.data.count > pageSize, + }; +}; diff --git a/lib/contexts/app.tsx b/lib/contexts/app.tsx index f0a1eec14e..4c21e6c3a2 100644 --- a/lib/contexts/app.tsx +++ b/lib/contexts/app.tsx @@ -15,6 +15,8 @@ const AppContext = createContext({ hash: '', number: '', q: '', + topic: '', + thread: '', }); export function AppContextProvider({ children, pageProps }: Props) { diff --git a/lib/contexts/ylide/constants.tsx b/lib/contexts/ylide/constants.tsx new file mode 100644 index 0000000000..9d64411a08 --- /dev/null +++ b/lib/contexts/ylide/constants.tsx @@ -0,0 +1,515 @@ +import { EVM_NAMES, EVMNetwork } from '@ylide/ethereum'; +import React from 'react'; + +import { ArbitrumLogo } from './logos/network/ArbitrumLogo'; +import { AuroraLogo } from './logos/network/AuroraLogo'; +import { AvalancheLogo } from './logos/network/AvalancheLogo'; +import { BaseLogo } from './logos/network/BaseLogo'; +import { BNBChainLogo } from './logos/network/BNBChainLogo'; +import { CeloLogo } from './logos/network/CeloLogo'; +import { CronosLogo } from './logos/network/CronosLogo'; +import { EthereumLogo } from './logos/network/EthereumLogo'; +import { FantomLogo } from './logos/network/FantomLogo'; +import { GnosisLogo } from './logos/network/GnosisLogo'; +import { KlaytnLogo } from './logos/network/KlaytnLogo'; +import { LineaLogo } from './logos/network/LineaLogo'; +import { MetisLogo } from './logos/network/MetisLogo'; +import { MoonbeamLogo } from './logos/network/MoonbeamLogo'; +import { MoonriverLogo } from './logos/network/MoonriverLogo'; +import { OptimismLogo } from './logos/network/OptimismLogo'; +import { PolygonLogo } from './logos/network/PolygonLogo'; +import { ZetaLogo } from './logos/network/ZetaLogo'; +import { BinanceWalletLogo } from './logos/wallets/BinanceWalletLogo'; +import { CoinbaseWalletLogo } from './logos/wallets/CoinbaseWalletLogo'; +import { FrontierLogo } from './logos/wallets/FrontierLogo'; +import { MetaMaskLogo } from './logos/wallets/MetaMaskLogo'; +import { TrustWalletLogo } from './logos/wallets/TrustWalletLogo'; +import { WalletConnectLogo } from './logos/wallets/WalletConnectLogo'; + +export const BlockchainName = { + LOCAL_HARDHAT: EVM_NAMES[EVMNetwork.LOCAL_HARDHAT], + CRONOS: EVM_NAMES[EVMNetwork.CRONOS], + ETHEREUM: EVM_NAMES[EVMNetwork.ETHEREUM], + BNBCHAIN: EVM_NAMES[EVMNetwork.BNBCHAIN], + ARBITRUM: EVM_NAMES[EVMNetwork.ARBITRUM], + AVALANCHE: EVM_NAMES[EVMNetwork.AVALANCHE], + OPTIMISM: EVM_NAMES[EVMNetwork.OPTIMISM], + POLYGON: EVM_NAMES[EVMNetwork.POLYGON], + FANTOM: EVM_NAMES[EVMNetwork.FANTOM], + KLAYTN: EVM_NAMES[EVMNetwork.KLAYTN], + GNOSIS: EVM_NAMES[EVMNetwork.GNOSIS], + AURORA: EVM_NAMES[EVMNetwork.AURORA], + CELO: EVM_NAMES[EVMNetwork.CELO], + MOONBEAM: EVM_NAMES[EVMNetwork.MOONBEAM], + MOONRIVER: EVM_NAMES[EVMNetwork.MOONRIVER], + METIS: EVM_NAMES[EVMNetwork.METIS], + ASTAR: EVM_NAMES[EVMNetwork.ASTAR], + BASE: EVM_NAMES[EVMNetwork.BASE], + ZETA: EVM_NAMES[EVMNetwork.ZETA], + LINEA: EVM_NAMES[EVMNetwork.LINEA], +}; + +export interface IEthereumNetworkDescriptor { + chainId: string; + chainName: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: 18; + }; + rpcUrls: Array; + blockExplorerUrls: Array; +} + +export const blockchainMeta: Record< +string, +{ + title: string; + logo: (s?: number) => JSX.Element; + ethNetwork: IEthereumNetworkDescriptor; +} +> = { + [BlockchainName.LINEA]: { + title: 'Linea', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xe708', + chainName: 'Linea', + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ 'https://linea.blockpi.network/v1/rpc/public' ], + blockExplorerUrls: [ 'https://lineascan.build' ], + }, + }, + [BlockchainName.BASE]: { + title: 'Base', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x2105', + chainName: 'Base', + nativeCurrency: { + name: 'ETH', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ 'https://base.blockpi.network/v1/rpc/public' ], + blockExplorerUrls: [ 'https://basescan.org' ], + }, + }, + [BlockchainName.ZETA]: { + title: 'ZetaChain', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x1B59', + chainName: 'Zeta Chain', + nativeCurrency: { + name: 'aZETA', + symbol: 'aZETA', + decimals: 18, + }, + rpcUrls: [ 'https://zetachain-athens-evm.blockpi.network/v1/rpc/public' ], + blockExplorerUrls: [ 'https://zetachain-athens-3.blockscout.com' ], + }, + }, + [BlockchainName.LOCAL_HARDHAT]: { + title: 'LocalNet', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x7A69', + chainName: 'Hardhat Local', + nativeCurrency: { + name: 'GoEther', + symbol: 'GO', + decimals: 18, + }, + rpcUrls: [], + blockExplorerUrls: [], + }, + }, + [BlockchainName.CRONOS]: { + title: 'Cronos', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x19', + chainName: 'Cronos Mainnet Beta', + nativeCurrency: { + name: 'Cronos', + symbol: 'CRO', + decimals: 18, + }, + rpcUrls: [ + 'https://evm.cronos.org', + 'https://cronos-rpc.heavenswail.one', + 'https://cronosrpc-1.xstaking.sg', + 'https://cronos-rpc.elk.finance', + ], + blockExplorerUrls: [ 'https://cronoscan.com' ], + }, + }, + [BlockchainName.ETHEREUM]: { + title: 'Ethereum', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x1', + chainName: 'Ethereum Mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ + 'https://api.mycryptoapi.com/eth', + 'https://cloudflare-eth.com', + 'https://rpc.flashbots.net', + 'https://eth-mainnet.gateway.pokt.network/v1/5f3453978e354ab992c4da79', + 'https://mainnet-nethermind.blockscout.com', + 'https://nodes.mewapi.io/rpc/eth', + 'https://main-rpc.linkpool.io', + 'https://mainnet.eth.cloud.ava.do', + 'https://ethereumnodelight.app.runonflux.io', + 'https://rpc.ankr.com/eth', + 'https://eth-rpc.gateway.pokt.network', + 'https://main-light.eth.linkpool.io', + 'https://eth-mainnet.public.blastapi.io', + 'http://18.211.207.34:8545', + 'https://eth-mainnet.nodereal.io/v1/1659dfb40aa24bbb8153a677b98064d7', + 'https://api.bitstack.com/v1/wNFxbiJyQsSeLrX8RRCHi7NpRxrlErZk/DjShIqLishPCTB9HiMkPHXjUM9CNM9Na/ETH/mainnet', + 'https://eth-mainnet.unifra.io/v1/d157f0245608423091f5b4b9c8e2103e', + 'https://1rpc.io/eth', + 'https://eth-mainnet.rpcfast.com', + 'https://eth-mainnet.rpcfast.com?api_key=xbhWBI1Wkguk8SNMu1bvvLurPGLXmgwYeC4S6g2H7WdwFigZSmPWVZRxrskEQwIf', + 'https://api.securerpc.com/v1', + ], + blockExplorerUrls: [ 'https://etherscan.io' ], + }, + }, + [BlockchainName.BNBCHAIN]: { + title: 'BNB Chain', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x38', + chainName: 'Binance Smart Chain Mainnet', + nativeCurrency: { + name: 'Binance Chain Native Token', + symbol: 'BNB', + decimals: 18, + }, + rpcUrls: [ + 'https://bsc-dataseed1.binance.org', + 'https://bsc-dataseed2.binance.org', + 'https://bsc-dataseed3.binance.org', + 'https://bsc-dataseed4.binance.org', + 'https://bsc-dataseed1.defibit.io', + 'https://bsc-dataseed2.defibit.io', + 'https://bsc-dataseed3.defibit.io', + 'https://bsc-dataseed4.defibit.io', + 'https://bsc-dataseed1.ninicoin.io', + 'https://bsc-dataseed2.ninicoin.io', + 'https://bsc-dataseed3.ninicoin.io', + 'https://bsc-dataseed4.ninicoin.io', + 'wss://bsc-ws-node.nariox.org', + 'https://bsc-dataseed.binance.org', + 'https://bsc-mainnet.nodereal.io/v1/64a9df0874fb4a93b9d0a3849de012d3', + 'https://rpc.ankr.com/bsc', + 'https://bscrpc.com', + 'https://bsc.mytokenpocket.vip', + 'https://binance.nodereal.io', + 'https://rpc-bsc.bnb48.club', + 'https://bscapi.terminet.io/rpc', + 'https://1rpc.io/bnb', + 'https://bsc-mainnet.rpcfast.com', + 'https://bsc-mainnet.rpcfast.com?api_key=S3X5aFCCW9MobqVatVZX93fMtWCzff0MfRj9pvjGKSiX5Nas7hz33HwwlrT5tXRM', + ], + blockExplorerUrls: [ 'https://bscscan.com' ], + }, + }, + [BlockchainName.ARBITRUM]: { + title: 'Arbitrum', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xa4b1', + chainName: 'Arbitrum One', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ 'https://arb1.arbitrum.io/rpc', 'https://rpc.ankr.com/arbitrum', 'https://1rpc.io/arb' ], + blockExplorerUrls: [ 'https://arbiscan.io' ], + }, + }, + [BlockchainName.AVALANCHE]: { + title: 'Avalanche', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xa86a', + chainName: 'Avalanche C-Chain', + nativeCurrency: { + name: 'Avalanche', + symbol: 'AVAX', + decimals: 18, + }, + rpcUrls: [ + 'https://api.avax.network/ext/bc/C/rpc', + 'https://rpc.ankr.com/avalanche', + 'https://ava-mainnet.public.blastapi.io/ext/bc/C/rpc', + 'https://avalancheapi.terminet.io/ext/bc/C/rpc', + 'https://1rpc.io/avax/c', + ], + blockExplorerUrls: [ 'https://snowtrace.io' ], + }, + }, + [BlockchainName.OPTIMISM]: { + title: 'Optimism', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xa', + chainName: 'Optimism', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ + 'https://mainnet.optimism.io', + 'https://optimism-mainnet.public.blastapi.io', + 'https://rpc.ankr.com/optimism', + 'https://1rpc.io/op', + ], + blockExplorerUrls: [ 'https://optimistic.etherscan.io' ], + }, + }, + [BlockchainName.POLYGON]: { + title: 'Polygon', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x89', + chainName: 'Polygon Mainnet', + nativeCurrency: { + name: 'MATIC', + symbol: 'MATIC', + decimals: 18, + }, + rpcUrls: [ + 'https://polygon-rpc.com', + 'https://rpc-mainnet.matic.network', + 'https://matic-mainnet.chainstacklabs.com', + 'https://rpc-mainnet.maticvigil.com', + 'https://rpc-mainnet.matic.quiknode.pro', + 'https://matic-mainnet-full-rpc.bwarelabs.com', + 'https://matic-mainnet-archive-rpc.bwarelabs.com', + 'https://poly-rpc.gateway.pokt.network', + 'https://rpc.ankr.com/polygon', + 'https://polygon-mainnet.public.blastapi.io', + 'https://polygonapi.terminet.io/rpc', + 'https://1rpc.io/matic', + 'https://polygon-mainnet.rpcfast.com', + 'https://polygon-mainnet.rpcfast.com?api_key=eQhI7SkwYXeQJyOLWrKNvpRnW9fTNoqkX0CErPfEsZjBBtYmn2e2uLKZtQkHkZdT', + 'https://polygon-bor.publicnode.com', + 'https://matic.slingshot.finance', + ], + blockExplorerUrls: [ 'https://polygonscan.com' ], + }, + }, + [BlockchainName.FANTOM]: { + title: 'Fantom', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xfa', + chainName: 'Fantom Opera', + nativeCurrency: { + name: 'Fantom', + symbol: 'FTM', + decimals: 18, + }, + rpcUrls: [ + 'https://rpc.ftm.tools', + 'https://fantom-mainnet.gateway.pokt.network/v1/lb/62759259ea1b320039c9e7ac', + 'https://rpc.ankr.com/fantom', + 'https://rpc.fantom.network', + 'https://rpc2.fantom.network', + 'https://rpc3.fantom.network', + 'https://rpcapi.fantom.network', + 'https://fantom-mainnet.public.blastapi.io', + ], + blockExplorerUrls: [ 'https://ftmscan.com' ], + }, + }, + [BlockchainName.KLAYTN]: { + title: 'Klaytn', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x2019', + chainName: 'Klaytn Mainnet Cypress', + nativeCurrency: { + name: 'KLAY', + symbol: 'KLAY', + decimals: 18, + }, + rpcUrls: [ + 'https://public-node-api.klaytnapi.com/v1/cypress', + 'https://klaytn01.fandom.finance', + 'https://klaytn02.fandom.finance', + 'https://klaytn03.fandom.finance', + 'https://klaytn04.fandom.finance', + 'https://klaytn05.fandom.finance', + 'https://cypress.fandom.finance/archive', + ], + blockExplorerUrls: [ 'https://scope.klaytn.com' ], + }, + }, + [BlockchainName.GNOSIS]: { + title: 'Gnosis', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x64', + chainName: 'Gnosis', + nativeCurrency: { + name: 'xDAI', + symbol: 'xDAI', + decimals: 18, + }, + rpcUrls: [ + 'https://rpc.gnosischain.com', + 'https://rpc.ankr.com/gnosis', + 'https://gnosischain-rpc.gateway.pokt.network', + 'https://gnosis-mainnet.public.blastapi.io', + 'wss://rpc.gnosischain.com/wss', + 'https://xdai-rpc.gateway.pokt.network', + 'https://xdai-archive.blockscout.com', + 'https://rpc.ap-southeast-1.gateway.fm/v1/gnosis/non-archival/mainnet', + ], + blockExplorerUrls: [ 'https://gnosisscan.io' ], + }, + }, + [BlockchainName.AURORA]: { + title: 'Aurora', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x4e454152', + chainName: 'Aurora Mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: [ 'https://mainnet.aurora.dev' ], + blockExplorerUrls: [ 'https://aurorascan.dev' ], + }, + }, + [BlockchainName.CELO]: { + title: 'Celo', + logo: (s = 16) => , + ethNetwork: { + chainId: '0xa4ec', + chainName: 'Celo Mainnet', + nativeCurrency: { + name: 'CELO', + symbol: 'CELO', + decimals: 18, + }, + rpcUrls: [ 'https://forno.celo.org', 'wss://forno.celo.org/ws', 'https://rpc.ankr.com/celo' ], + blockExplorerUrls: [ 'https://celoscan.io' ], + }, + }, + [BlockchainName.MOONBEAM]: { + title: 'Moonbeam', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x504', + chainName: 'Moonbeam', + nativeCurrency: { + name: 'Glimmer', + symbol: 'GLMR', + decimals: 18, + }, + rpcUrls: [ + 'https://rpc.api.moonbeam.network', + 'wss://wss.api.moonbeam.network', + 'https://moonbeam.public.blastapi.io', + 'https://rpc.ankr.com/moonbeam', + 'https://1rpc.io/glmr', + ], + blockExplorerUrls: [ 'https://moonbeam.moonscan.io' ], + }, + }, + [BlockchainName.MOONRIVER]: { + title: 'Moonriver', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x505', + chainName: 'Moonriver', + nativeCurrency: { + name: 'Moonriver', + symbol: 'MOVR', + decimals: 18, + }, + rpcUrls: [ + 'https://rpc.api.moonriver.moonbeam.network', + 'wss://wss.api.moonriver.moonbeam.network', + 'https://moonriver.api.onfinality.io/rpc?apikey=673e1fae-c9c9-4c7f-a3d5-2121e8274366', + 'https://moonriver.api.onfinality.io/public', + 'https://moonriver.public.blastapi.io', + ], + blockExplorerUrls: [ 'https://moonriver.moonscan.io' ], + }, + }, + [BlockchainName.METIS]: { + title: 'Metis', + logo: (s = 16) => , + ethNetwork: { + chainId: '0x440', + chainName: 'Metis Andromeda Mainnet', + nativeCurrency: { + name: 'Metis', + symbol: 'METIS', + decimals: 18, + }, + rpcUrls: [ 'https://andromeda.metis.io/?owner=1088' ], + blockExplorerUrls: [ 'https://andromeda-explorer.metis.io' ], + }, + }, +}; + +export interface WalletMeta { + title: string; + link: string; + logo: (size?: number) => JSX.Element; +} + +export const walletsMeta: Record = { + // EVM + metamask: { + title: 'MetaMask', + logo: (s = 30) => , + link: 'https://metamask.io/', + }, + frontier: { + title: 'Frontier', + logo: (s = 30) => , + link: 'https://www.frontier.xyz/', + }, + walletconnect: { + title: 'WalletConnect', + logo: (s = 30) => , + link: 'https://walletconnect.com/', + }, + coinbase: { + title: 'Coinbase', + logo: (s = 30) => , + link: 'https://www.coinbase.com/wallet', + }, + trustwallet: { + title: 'TrustWallet', + logo: (s = 30) => , + link: 'https://trustwallet.com/', + }, + binance: { + title: 'BinanceWallet', + logo: (s = 30) => , + link: 'https://chrome.google.com/webstore/detail/binance-wallet/fhbohimaelbohpjbbldcngcnapndodjp', + }, +}; diff --git a/lib/contexts/ylide/editor-utils.ts b/lib/contexts/ylide/editor-utils.ts new file mode 100644 index 0000000000..8a55b5121a --- /dev/null +++ b/lib/contexts/ylide/editor-utils.ts @@ -0,0 +1,220 @@ +import type { OutputData } from '@editorjs/editorjs'; +import type { IYMFTagNode } from '@ylide/sdk'; +import { YMF, randomBytes } from '@ylide/sdk'; +import { SmartBuffer } from '@ylide/smart-buffer'; + +export function htmlSelfClosingTagsToXHtml(html: string) { + return html.replace( + // eslint-disable-next-line regexp/no-super-linear-backtracking + /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr|command|keygen|menuitem|frame)\b(.*?)[ /]*>/, + '<$1$2 />', + ); +} + +const EMPTY_OUTPUT_DATA: OutputData = { + time: 1676587472156, + version: '2.26.5', + blocks: [], +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parseEditorJsJson(json: any) { + try { + json = typeof json === 'string' ? JSON.parse(json) : json; + } catch (e) { + return typeof json === 'string' ? json : JSON.stringify(json); + } + let result = ''; + + for (const block of json.blocks) { + if (block.type === 'paragraph') { + result += block.data.text + '\n'; + } else if (block.type === 'header') { + result += '#'.repeat(block.data.level) + ' ' + block.data.text + '\n'; + } else if (block.type === 'list') { + let i = 1; + for (const item of block.data.items) { + result += (block.data.style === 'ordered' ? `${ i }. ` : '- ') + item + '\n'; + i++; + } + } else if (block.type === 'delimiter') { + result += '\n'; + } else if (block.type === 'image') { + result += block.data.caption + '\n'; + } else if (block.type === 'embed') { + result += block.data.caption + '\n'; + } else if (block.type === 'table') { + result += block.data.caption + '\n'; + } else if (block.type === 'quote') { + result += block.data.caption + '\n'; + } else if (block.type === 'code') { + result += block.data.caption + '\n'; + } else if (block.type === 'raw') { + result += block.data.caption + '\n'; + } else if (block.type === 'warning') { + result += block.data.caption + '\n'; + } else if (block.type === 'linkTool') { + result += block.data.caption + '\n'; + } else if (block.type === 'marker') { + result += block.data.caption + '\n'; + } else if (block.type === 'checklist') { + result += block.data.caption + '\n'; + } else if (block.type === 'inlineCode') { + result += block.data.caption + '\n'; + } else if (block.type === 'simpleImage') { + result += block.data.caption + '\n'; + } else if (block.type === 'underline') { + result += block.data.caption + '\n'; + } else if (block.type === 'strikethrough') { + result += block.data.caption + '\n'; + } else if (block.type === 'superscript') { + result += block.data.caption + '\n'; + } else if (block.type === 'subscript') { + result += block.data.caption + '\n'; + } else if (block.type === 'link') { + result += block.data.caption + '\n'; + } else if (block.type === 'alignment') { + result += block.data.caption + '\n'; + } else if (block.type === 'rawTool') { + result += block.data.caption + '\n'; + } else if (block.type === 'del') { + result += block.data.caption + '\n'; + } else if (block.type === 'inlineLink') { + result += block.data.caption + '\n'; + } else if (block.type === 'mention') { + result += block.data.caption + '\n'; + } + } + + return result.replaceAll('
', '\n'); +} + +export function editorJsToYMF(data: OutputData | undefined) { + if (!data) { + return YMF.fromPlainText(''); + } + + const prepareText = (text: string) => htmlSelfClosingTagsToXHtml(text); + + const nodes: Array = []; + for (const block of data.blocks) { + if (block.type === 'paragraph') { + nodes.push(`

${ prepareText(block.data.text) }

`); // data.text + } else if (block.type === 'header') { + // data.level -- number + // data.text -- string + nodes.push( + `${ prepareText(block.data.text) }`, + ); + } else if (block.type === 'list') { + // data.style: 'ordered' | 'unordered' + // data.items: string[] + if (block.data.style === 'ordered') { + const innerNodes: Array = []; + for (let i = 0; i < block.data.items.length; i++) { + innerNodes.push(`
  • ${ i + 1 }. ${ prepareText(block.data.items[i]) }
  • `); + } + nodes.push(`
      ${ innerNodes.join('\n') }
    `); + } else if (block.data.style === 'unordered') { + const innerNodes: Array = []; + for (let i = 0; i < block.data.items.length; i++) { + innerNodes.push(`
  • ${ prepareText(block.data.items[i]) }
  • `); + } + nodes.push(`
      ${ innerNodes.join('\n') }
    `); + } + } else { + // nothing + } + } + + return YMF.fromYMFText(`${ nodes.join('\n') }`); +} + +export function ymfToEditorJs(ymf: YMF) { + if ( + ymf.root.children.length === 1 && + ymf.root.children[0].type === 'tag' && + ymf.root.children[0].tag === 'editorjs' + ) { + const root: IYMFTagNode = ymf.root.children[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const blocks: Array = []; + for (const child of root.children) { + if (child.type === 'text') { + // do nothing, skip line breaks + } else if (child.type === 'tag') { + if (child.tag === 'p') { + blocks.push({ + id: child.attributes['ejs-id'], + type: 'paragraph', + data: { + text: child.children.map(c => YMF.nodeToYMFText(c)).join(''), + }, + }); + } else if (child.tag.startsWith('h')) { + const level = parseInt(child.tag[1], 10); + blocks.push({ + id: child.attributes['ejs-id'], + type: 'header', + data: { + text: child.children.map(c => YMF.nodeToYMFText(c)).join(''), + level, + }, + }); + } else if (child.tag === 'ol') { + blocks.push({ + id: child.attributes['ejs-id'], + type: 'list', + data: { + style: 'ordered', + items: child.children + .filter(c => c.type === 'tag') + .map(c => + (c as IYMFTagNode).children + .slice(1) + .map(c => YMF.nodeToYMFText(c)) + .join(''), + ), + }, + }); + } else if (child.tag === 'ul') { + blocks.push({ + id: child.attributes['ejs-id'], + type: 'list', + data: { + style: 'unordered', + items: child.children + .filter(c => c.type === 'tag') + .map(c => + (c as IYMFTagNode).children + .slice(1) + .map(c => YMF.nodeToYMFText(c)) + .join(''), + ), + }, + }); + } else { + // do nothing + } + } + } + + return { + ...EMPTY_OUTPUT_DATA, + blocks, + }; + } else { + return { + ...EMPTY_OUTPUT_DATA, + blocks: [ { id: generateEditorJsId(), type: 'paragraph', data: { text: ymf.toPlainText() } } ], + }; + } +} + +function generateEditorJsId() { + return new SmartBuffer(randomBytes(8)).toHexString(); +} + +export function isEmptyYMF(ymf: YMF) { + return !ymf.toString(); +} diff --git a/lib/contexts/ylide/index.tsx b/lib/contexts/ylide/index.tsx new file mode 100644 index 0000000000..e0cc80e987 --- /dev/null +++ b/lib/contexts/ylide/index.tsx @@ -0,0 +1,678 @@ +import { EthereumProvider } from '@walletconnect/ethereum-provider'; +import type { EVMWalletController } from '@ylide/ethereum'; +import { evm, EVM_CHAINS, EVM_NAMES, EVM_RPCS, EVMNetwork, evmWalletFactories } from '@ylide/ethereum'; +import type { AbstractWalletController, IMessage, MessageAttachment, RecipientInfo, Uint256, WalletAccount, WalletControllerFactory } from '@ylide/sdk'; +import { + BrowserLocalStorage, MessageContentV4, MessageContentV5, PrivateKeyAvailabilityState, stringToSemver, Ylide, YlideKeysRegistry, YMF, +} from '@ylide/sdk'; +import { SmartBuffer } from '@ylide/smart-buffer'; +import type { ReactNode } from 'react'; +import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react'; + +import type { DomainAccount, IAppRegistry } from './types'; + +import ForumPersonalApi from 'lib/api/ylideApi/ForumPersonalApi'; +import ForumPublicApi from 'lib/api/ylideApi/ForumPublicApi'; +import { ensurePageLoaded } from 'lib/ensurePageLoaded'; + +import { blockchainMeta } from './constants'; +import { useYlideAccountModal, useYlideSelectWalletModal } from './modals'; +import { useYlideAccounts } from './useYlideAccounts'; +import { useYlideFaucet } from './useYlideFaucet'; +import { useYlidePushes } from './useYlidePushes'; + +export enum MessageDecodedTextDataType { + PLAIN = 'plain', + YMF = 'YMF', +} + +export type IMessageDecodedTextData = + | { type: MessageDecodedTextDataType.PLAIN; value: string } + | { type: MessageDecodedTextDataType.YMF; value: YMF }; + +export interface IMessageDecodedContent { + msgId: string; + decodedTextData: IMessageDecodedTextData; + decodedSubject: string; + attachments: Array; + recipientInfos: Array; +} + +const YlideContext = createContext(undefined as unknown as ReturnType); + +export interface WalletConnectConnection { + readonly walletName: string; + readonly provider: InstanceType; +} + +const useWalletConnectState = ( + ylide: Ylide, + ylideIsInitialized: boolean, + wallets: Array, + updateWallets: (newWallets: Array) => void, +) => { + const [ initialized, setInitialized ] = useState(false); + const [ connection, setConnection ] = useState(undefined); + const [ url, setUrl ] = useState(''); + + const disconnectWalletConnect = useCallback(async() => { + if (!initialized || !connection) { + return; + } + + const wc = wallets.find(w => w.wallet() === 'walletconnect'); + + if (wc) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await (wc as EVMWalletController).signer.provider.provider.disconnect(); + // TODO: pizdec + document.location.reload(); + } + }, [ connection, initialized, wallets ]); + + const initWallet = useCallback(async(factory: WalletControllerFactory) => { + if (!initialized || !connection) { + return false; + } + await ylide.controllers.addWallet( + factory.wallet, + { + dev: false, + faucet: { + registrar: 1, + apiKey: { type: 'client', key: 'clfaf6c3e695452c2a' }, + }, + walletConnectProvider: initialized && connection ? connection.provider : null, + }, + factory.blockchainGroup, + ); + updateWallets([ ...ylide.controllers.wallets ]); + return true; + }, [ ylide, initialized, connection, updateWallets ]); + + const init = useCallback(async() => { + const rpcMap = { + // Metamask only supports ethereum chain :( + [EVM_CHAINS[EVMNetwork.ETHEREUM]]: (EVM_RPCS[EVMNetwork.ETHEREUM].find( + r => !r.rpc.startsWith('ws'), + ) as { + rpc: string; + blockLimit?: number; + lastestNotSupported?: boolean; + batchNotSupported?: boolean; + }).rpc, + }; + const chains = Object.keys(rpcMap).map(Number); + let isAvailable = true; + const projectId = 'e9deead089b3383b2db777961e3fa244'; + const wcTest = await EthereumProvider.init({ + projectId, + chains, + // TODO: remove after fix by WalletConnect - https://github.com/WalletConnect/walletconnect-monorepo/issues/2641 + // WalletConnect couldn't reproduce the issue, but we had it. + // Need further to debug, but currently it does not break anything. Propose to leave it. + optionalChains: [ 100500 ], + rpcMap, + showQrModal: true, + }); + wcTest.modal?.subscribeModal(({ open }: { open: boolean }) => { + if (open) { + wcTest.modal?.closeModal(); + isAvailable = false; + } + }); + try { + await wcTest.enable(); + } catch (err) { + isAvailable = false; + } + + if (isAvailable) { + setInitialized(true); + setConnection({ + walletName: wcTest.session?.peer.metadata.name || '', + provider: wcTest, + }); + } else { + const wcReal = await EthereumProvider.init({ + projectId, + chains, + // TODO: remove after fix by WalletConnect - https://github.com/WalletConnect/walletconnect-monorepo/issues/2641 + // WalletConnect couldn't reproduce the issue, but we had it. + // Need further to debug, but currently it does not break anything. Propose to leave it. + optionalChains: [ 100500 ], + rpcMap, + showQrModal: false, + }); + wcReal.on('display_uri', url => { + setInitialized(true); + setConnection(undefined); + setUrl(url); + }); + wcReal.on('connect', async() => { + setInitialized(true); + setConnection({ + walletName: wcReal.session?.peer.metadata.name || '', + provider: wcReal, + }); + }); + + wcReal.enable(); + } + }, []); + + useEffect(() => { + if (ylideIsInitialized) { + init(); + } + }, [ ylideIsInitialized, init ]); + + useEffect(() => { + if (ylideIsInitialized && initialized && connection) { + const wcFactory = ylide.walletsList.find(w => w.wallet === 'walletconnect'); + if (wcFactory) { + initWallet(wcFactory.factory); + } + } + }, [ ylideIsInitialized, initialized, connection, ylide, initWallet ]); + + return { + initialized, + connection, + url, + disconnectWalletConnect, + }; +}; + +const useWalletConnectRegistry = () => { + const [ registry, setRegistry ] = useState({}); + const [ loading, setLoading ] = useState(true); + + const refetch = useCallback(async() => { + try { + setLoading(true); + + const response = await fetch('https://registry.walletconnect.com/api/v2/wallets'); + const data = await response.json() as { listings: IAppRegistry }; + + setRegistry(data.listings); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [ refetch ]); + + return { registry, loading, refetch }; +}; + +const REACT_APP_FEED_PUBLIC_KEY = '9b939eaca8b685f46d8311698669e25ec50015d96644bf671ed35264d754a977'; + +const useAccountController = ( + ylide: Ylide, + keysRegistry: YlideKeysRegistry, + wallets: Array, + ylideInitialized: boolean, + disconnectWalletConnect: () => void, +) => { + const { savedAccounts, addAccount, deleteAccount, setAccountAuthKey } = useYlideAccounts(); + const selectWalletModal = useYlideSelectWalletModal(); + const accountModal = useYlideAccountModal(); + const acquireBackendAuthKey = ForumPersonalApi.useAcquireAuthKey(); + const [ backendAccountsData, setBackendAccountsData ] = useState>({}); + const [ initialized, setInitialized ] = useState(false); + + const domainAccounts = useMemo(() => { + return savedAccounts.map(acc => ({ + name: acc.name, + wallet: wallets.find(w => w.wallet() === acc.wallet), + account: acc.account, + backendAuthKey: acc.backendAuthKey, + })).filter(acc => Boolean(acc.wallet)) as Array; + }, [ wallets, savedAccounts ]); + + const tokens = useMemo(() => { + return domainAccounts.map(acc => acc.backendAuthKey).filter(Boolean) as Array; + }, [ domainAccounts ]); + + const getMe = ForumPublicApi.useGetMe(tokens); + + const isAdmin = useMemo(() => { + return domainAccounts.reduce((acc, cur) => ({ + ...acc, + [cur.account.address]: backendAccountsData[cur.account.address]?.isAdmin ?? false, + }), {} as Record); + }, [ domainAccounts, backendAccountsData ]); + + const admins = useMemo(() => { + return domainAccounts.filter(acc => isAdmin[acc.account.address]); + }, [ domainAccounts, isAdmin ]); + + useEffect(() => { + if (!ylideInitialized) { + return; + } + (async() => { + try { + const data = await getMe(); + setBackendAccountsData(data); + } catch (err) { + // console.error('getting ylide identities: ', err); + } + setInitialized(true); + })(); + }, [ getMe, ylideInitialized ]); + + const constructBackendAuthKeySignature = useCallback(async(account: DomainAccount) => { + const localPrivateKeys = keysRegistry.getLocalPrivateKeys(account.account.address); + const availableKeys = localPrivateKeys.filter(key => key.availabilityState === PrivateKeyAvailabilityState.AVAILABLE); + if (availableKeys[0]) { + const mvPublicKey = SmartBuffer.ofHexString(REACT_APP_FEED_PUBLIC_KEY).bytes; + + const messageBytes = SmartBuffer.ofUTF8String( + JSON.stringify({ address: account.account.address, timestamp: Date.now() }), + ).bytes; + + return availableKeys[0].execute( + async privateKey => ({ + messageEncrypted: new SmartBuffer(privateKey.encrypt(messageBytes, mvPublicKey)).toHexString(), + publicKey: new SmartBuffer(privateKey.publicKey).toHexString(), + address: account.account.address, + }), + // TODO: handle users with password + // { + // onPrivateKeyRequest: async (address: string, magicString: string) => + // await this.wallet.controller.signMagicString(this.account, magicString), + // onYlidePasswordRequest: async _ => password, + // }, + ); + } + return null; + }, [ keysRegistry ]); + + const connectAccount = useCallback(async() => { + const wallet = await selectWalletModal.openWithPromise(); + if (!wallet) { + return; + } + + const account = await wallet.requestAuthentication(); + + if (!account) { + return; + } + + if (savedAccounts.find((a) => a.account.address === account.address)) { + return; + } + + const remoteKeys = await ylide.core.getAddressKeys(account.address); + + const domainAccount = await accountModal.openWithPromise({ + wallet, + account, + remoteKeys: remoteKeys.remoteKeys, + }); + + if (!domainAccount) { + return; + } + + addAccount('New Account', domainAccount.account, domainAccount.wallet.wallet(), null); + + (async() => { + const signature = await constructBackendAuthKeySignature(domainAccount); + + if (signature) { + const key = await acquireBackendAuthKey(signature); + if (key) { + setAccountAuthKey(domainAccount.account, key); + } + } + })(); + + return domainAccount; + }, [ + selectWalletModal, savedAccounts, ylide.core, accountModal, addAccount, constructBackendAuthKeySignature, + acquireBackendAuthKey, setAccountAuthKey, + ]); + + const disconnectAccount = useCallback(async(account: DomainAccount) => { + const privateKeys = keysRegistry.getLocalPrivateKeys(account.account.address); + for (const key of privateKeys) { + await keysRegistry.removeLocalPrivateKey(key); + } + const currentAccount = await account.wallet.getAuthenticatedAccount(); + if (currentAccount && currentAccount.address === account.account.address) { + await account.wallet.disconnectAccount(account.account); + } + if (account.wallet.wallet() === 'walletconnect') { + disconnectWalletConnect(); + } + deleteAccount(account.account); + }, [ deleteAccount, keysRegistry, disconnectWalletConnect ]); + + // export async function activateAccount(params: { account: DomainAccount }) { + // const account = params.account; + // const wallet = account.wallet; + // const remoteKeys = await domain.ylide.core.getAddressKeys(account.account.address); + // const qqs = getQueryString(); + + // await showStaticComponent(resolve => ( + // + // )); + // } + + return { + initialized, + selectWalletModal, + accountModal, + savedAccounts, + domainAccounts, + tokens, + isAdmin, + admins, + disconnectAccount, + connectAccount, + }; +}; + +const useBalances = (ylide: Ylide, accounts: Array) => { + const [ balances, setBalances ] = useState>>({}); + + const getBalanceOf = useCallback(async(address: string) => { + const chains = ylide.controllers.blockchains; + const balances: Array<{ original: string; numeric: number; e18: string }> = await Promise.all( + chains.map(async chain => { + try { + return await new Promise((resolve, reject) => { + chain.getBalance(address).then(resolve).catch(reject); + setTimeout(reject, 3000); + }); + } catch (err) { + return { + original: '0', + numeric: 0, + e18: '0', + }; + } + }), + ); + return chains.reduce( + (p, c, i) => ({ + ...p, + [c.blockchain()]: balances[i], + }), + {} as Record, + ); + }, [ ylide ]); + + useEffect(() => { + (async() => { + let isNew = false; + const newBalances = await Promise.all(accounts.map(async(account) => { + if (balances[account.account.address]) { + return [ account.account.address, balances[account.account.address] ]; + } else { + isNew = true; + return [ account.account.address, await getBalanceOf(account.account.address) ]; + } + })); + if (isNew) { + setBalances(Object.fromEntries(newBalances)); + } + })(); + }, [ accounts, balances, getBalanceOf ]); + + return balances; +}; + +const useYlideService = () => { + const keysRegistry = useMemo(() => new YlideKeysRegistry(new BrowserLocalStorage()), []); + const ylide = useMemo(() => { + const ylide = new Ylide(keysRegistry); + ylide.add(evm); + return ylide; + }, [ keysRegistry ]); + const faucet = useYlideFaucet(ylide, keysRegistry); + + const [ initialized, setInitialized ] = useState(false); + const [ wallets, setWallets ] = useState>([]); + const walletConnectRegistry = useWalletConnectRegistry(); + const walletConnectState = useWalletConnectState(ylide, initialized, wallets, setWallets); + const accounts = useAccountController(ylide, keysRegistry, wallets, initialized, walletConnectState.disconnectWalletConnect); + const balances = useBalances(ylide, accounts.domainAccounts); + const { addressesWithPushes, setAccountPushState } = useYlidePushes(); + + const switchEVMChain = useCallback(async(wallet: AbstractWalletController, needNetwork: EVMNetwork) => { + try { + const bData = blockchainMeta[EVM_NAMES[needNetwork]]; + if (!walletConnectState.connection) { + await (wallet as EVMWalletController).providerObject.request({ + method: 'wallet_addEthereumChain', + params: [ bData.ethNetwork ], + }); + } + } catch (error) { + // console.error('error: ', error); + } + if (walletConnectState.connection) { + const wc = wallets.find(w => w.wallet() === 'walletconnect'); + if (wc) { + await (wallet as EVMWalletController).signer.provider.send('wallet_switchEthereumChain', [ + { chainId: '0x' + Number(EVM_CHAINS[needNetwork]).toString(16) }, + ]); + } + } else { + await (wallet as EVMWalletController).providerObject.request({ + method: 'wallet_switchEthereumChain', + params: [ { chainId: '0x' + Number(EVM_CHAINS[needNetwork]).toString(16) } ], // chainId must be in hexadecimal numbers + }); + } + }, [ walletConnectState.connection, wallets ]); + + const switchEVMChainRef = React.useRef(switchEVMChain); + + useEffect(() => { + switchEVMChainRef.current = switchEVMChain; + }, [ switchEVMChain ]); + + const broadcastMessage = useCallback(async(account: DomainAccount, feedId: string, subject: string, content: YMF, blockchain?: string) => { + const foundNetwork = Object.keys(EVM_NAMES).find(n => EVM_NAMES[Number(n) as EVMNetwork] === blockchain); + let network: undefined | EVMNetwork = undefined; + if (typeof foundNetwork !== 'undefined') { + network = Number(foundNetwork) as EVMNetwork; + } + return await ylide.core.broadcastMessage({ + feedId: feedId as Uint256, + wallet: account.wallet, + sender: account.account, + content: new MessageContentV5({ + subject: subject, + content: content, + attachments: [], + extraBytes: new Uint8Array(0), + extraJson: {}, + recipientInfos: [], + sendingAgentName: 'Blockscout', + sendingAgentVersion: stringToSemver('0.0.1'), + }), + serviceCode: 7, + }, { + isPersonal: false, + isGenericFeed: true, + network, + }); + }, [ ylide ]); + + const sendMessage = useCallback(async( + account: DomainAccount, + recipients: Array, + feedId: string, + subject: string, + content: string, + blockchain?: string, + ) => { + const foundNetwork = Object.keys(EVM_NAMES).find(n => EVM_NAMES[Number(n) as EVMNetwork] === blockchain); + let network: undefined | EVMNetwork = undefined; + if (typeof foundNetwork !== 'undefined') { + network = Number(foundNetwork) as EVMNetwork; + } + return await ylide.core.sendMessage({ + feedId: feedId as Uint256, + wallet: account.wallet, + sender: account.account, + content: MessageContentV5.simple(subject, content), + recipients: recipients, + serviceCode: 7, + }, { + isPersonal: false, + isGenericFeed: true, + network, + }); + }, [ ylide ]); + + const decodeDirectMessage = useCallback(async( + msgId: string, + msg: IMessage, + recipient: WalletAccount, + ): Promise => { + const content = await ylide.core.getMessageContent(msg); + + if (!content || content.corrupted) { + return null; + } + + try { + const result = await ylide.core.decryptMessageContent(recipient, msg, content); + + return { + msgId, + decodedSubject: result.content.subject, + decodedTextData: + result.content.content instanceof YMF ? + { + type: MessageDecodedTextDataType.YMF, + value: result.content.content, + } : + { + type: MessageDecodedTextDataType.PLAIN, + value: result.content.content, + }, + attachments: + result.content instanceof MessageContentV4 || result.content instanceof MessageContentV5 ? + result.content.attachments : + [], + recipientInfos: result.content instanceof MessageContentV5 ? result.content.recipientInfos : [], + }; + } catch (err) { + return null; + } + }, [ ylide ]); + + const decodeBroadcastMessage = useCallback(async( + msgId: string, + msg: IMessage, + ): Promise => { + const content = await ylide.core.getMessageContent(msg); + + if (!content || content.corrupted) { + return null; + } + + const result = ylide.core.decryptBroadcastContent(msg, content); + + return { + msgId, + decodedSubject: result.content.subject, + decodedTextData: + result.content.content instanceof YMF ? + { + type: MessageDecodedTextDataType.YMF, + value: result.content.content, + } : + { + type: MessageDecodedTextDataType.PLAIN, + value: result.content.content, + }, + attachments: + result.content instanceof MessageContentV4 || result.content instanceof MessageContentV5 ? + result.content.attachments : + [], + recipientInfos: result.content instanceof MessageContentV5 ? result.content.recipientInfos : [], + }; + }, [ ylide ]); + + useEffect(() => { + const f = async() => { + await ensurePageLoaded; + await ylide.init(); + ylide.registerWalletFactory(evmWalletFactories.walletconnect); + setWallets(ylide.controllers.wallets); + setInitialized(true); + }; + f(); + }, [ ylide, keysRegistry ]); + + useEffect(() => { + wallets.forEach(w => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + w.onNetworkSwitchRequest = async( + reason: string, + currentNetwork: EVMNetwork | undefined, + needNetwork: EVMNetwork, + ) => { + switchEVMChainRef.current(w, needNetwork); + }; + }); + }, [ wallets ]); + + return { + ylide, + keysRegistry, + wallets, + initialized, + walletConnectRegistry, + walletConnectState, + accounts, + faucet, + balances, + addressesWithPushes, + setAccountPushState, + broadcastMessage, + sendMessage, + decodeBroadcastMessage, + decodeDirectMessage, + }; +}; + +export function YlideProvider({ children }: { children?: ReactNode | undefined }) { + return ( + + { children } + + ); +} + +export function useYlide() { + return useContext(YlideContext); +} diff --git a/lib/contexts/ylide/logos/network/ArbitrumLogo.tsx b/lib/contexts/ylide/logos/network/ArbitrumLogo.tsx new file mode 100644 index 0000000000..a3b855672e --- /dev/null +++ b/lib/contexts/ylide/logos/network/ArbitrumLogo.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function ArbitrumLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/AuroraLogo.tsx b/lib/contexts/ylide/logos/network/AuroraLogo.tsx new file mode 100644 index 0000000000..a4c0ac4395 --- /dev/null +++ b/lib/contexts/ylide/logos/network/AuroraLogo.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function AuroraLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/AvalancheLogo.tsx b/lib/contexts/ylide/logos/network/AvalancheLogo.tsx new file mode 100644 index 0000000000..59d403d5bb --- /dev/null +++ b/lib/contexts/ylide/logos/network/AvalancheLogo.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function AvalancheLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/BNBChainLogo.tsx b/lib/contexts/ylide/logos/network/BNBChainLogo.tsx new file mode 100644 index 0000000000..1997315856 --- /dev/null +++ b/lib/contexts/ylide/logos/network/BNBChainLogo.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function BNBChainLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/BaseLogo.tsx b/lib/contexts/ylide/logos/network/BaseLogo.tsx new file mode 100644 index 0000000000..3ffd7953f7 --- /dev/null +++ b/lib/contexts/ylide/logos/network/BaseLogo.tsx @@ -0,0 +1,1576 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function BaseLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/CeloLogo.tsx b/lib/contexts/ylide/logos/network/CeloLogo.tsx new file mode 100644 index 0000000000..b6e0df4e89 --- /dev/null +++ b/lib/contexts/ylide/logos/network/CeloLogo.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function CeloLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/CronosLogo.tsx b/lib/contexts/ylide/logos/network/CronosLogo.tsx new file mode 100644 index 0000000000..5c031da09a --- /dev/null +++ b/lib/contexts/ylide/logos/network/CronosLogo.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function CronosLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/EthereumLogo.tsx b/lib/contexts/ylide/logos/network/EthereumLogo.tsx new file mode 100644 index 0000000000..68f49a8a5d --- /dev/null +++ b/lib/contexts/ylide/logos/network/EthereumLogo.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function EthereumLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/FantomLogo.tsx b/lib/contexts/ylide/logos/network/FantomLogo.tsx new file mode 100644 index 0000000000..995d2e801a --- /dev/null +++ b/lib/contexts/ylide/logos/network/FantomLogo.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function FantomLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + fa + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/GnosisLogo.tsx b/lib/contexts/ylide/logos/network/GnosisLogo.tsx new file mode 100644 index 0000000000..eeb36356a1 --- /dev/null +++ b/lib/contexts/ylide/logos/network/GnosisLogo.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function GnosisLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/KlaytnLogo.tsx b/lib/contexts/ylide/logos/network/KlaytnLogo.tsx new file mode 100644 index 0000000000..35cae22d06 --- /dev/null +++ b/lib/contexts/ylide/logos/network/KlaytnLogo.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function KlaytnLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/LineaLogo.tsx b/lib/contexts/ylide/logos/network/LineaLogo.tsx new file mode 100644 index 0000000000..c11ae8a492 --- /dev/null +++ b/lib/contexts/ylide/logos/network/LineaLogo.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function LineaLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/MetisLogo.tsx b/lib/contexts/ylide/logos/network/MetisLogo.tsx new file mode 100644 index 0000000000..1f33b5a248 --- /dev/null +++ b/lib/contexts/ylide/logos/network/MetisLogo.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function MetisLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/MoonbeamLogo.tsx b/lib/contexts/ylide/logos/network/MoonbeamLogo.tsx new file mode 100644 index 0000000000..6afa6d0b6b --- /dev/null +++ b/lib/contexts/ylide/logos/network/MoonbeamLogo.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function MoonbeamLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/MoonriverLogo.tsx b/lib/contexts/ylide/logos/network/MoonriverLogo.tsx new file mode 100644 index 0000000000..41b4581893 --- /dev/null +++ b/lib/contexts/ylide/logos/network/MoonriverLogo.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function MoonriverLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/OptimismLogo.tsx b/lib/contexts/ylide/logos/network/OptimismLogo.tsx new file mode 100644 index 0000000000..d8e2d26dbc --- /dev/null +++ b/lib/contexts/ylide/logos/network/OptimismLogo.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function OptimismLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/PolygonLogo.tsx b/lib/contexts/ylide/logos/network/PolygonLogo.tsx new file mode 100644 index 0000000000..301c85661e --- /dev/null +++ b/lib/contexts/ylide/logos/network/PolygonLogo.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function PolygonLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/network/ZetaLogo.tsx b/lib/contexts/ylide/logos/network/ZetaLogo.tsx new file mode 100644 index 0000000000..9795f8042d --- /dev/null +++ b/lib/contexts/ylide/logos/network/ZetaLogo.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function ZetaLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/BinanceWalletLogo.tsx b/lib/contexts/ylide/logos/wallets/BinanceWalletLogo.tsx new file mode 100644 index 0000000000..451585e87b --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/BinanceWalletLogo.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function BinanceWalletLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/CoinbaseWalletLogo.tsx b/lib/contexts/ylide/logos/wallets/CoinbaseWalletLogo.tsx new file mode 100644 index 0000000000..9aecf522a5 --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/CoinbaseWalletLogo.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function CoinbaseWalletLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/FrontierLogo.tsx b/lib/contexts/ylide/logos/wallets/FrontierLogo.tsx new file mode 100644 index 0000000000..34088b2e89 --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/FrontierLogo.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function FrontierLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/MetaMaskLogo.tsx b/lib/contexts/ylide/logos/wallets/MetaMaskLogo.tsx new file mode 100644 index 0000000000..8787d1359d --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/MetaMaskLogo.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function MetaMaskLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/TrustWalletLogo.tsx b/lib/contexts/ylide/logos/wallets/TrustWalletLogo.tsx new file mode 100644 index 0000000000..d5544d1ba2 --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/TrustWalletLogo.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function TrustWalletLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + ); +} diff --git a/lib/contexts/ylide/logos/wallets/WalletConnectLogo.tsx b/lib/contexts/ylide/logos/wallets/WalletConnectLogo.tsx new file mode 100644 index 0000000000..a35eb42b9f --- /dev/null +++ b/lib/contexts/ylide/logos/wallets/WalletConnectLogo.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; + +export function WalletConnectLogo({ size = 16, style }: { size?: number; style?: CSSProperties }) { + return ( + + + + + + + + + + + ); +} diff --git a/lib/contexts/ylide/modals.ts b/lib/contexts/ylide/modals.ts new file mode 100644 index 0000000000..f45aeabaf9 --- /dev/null +++ b/lib/contexts/ylide/modals.ts @@ -0,0 +1,70 @@ +import { useDisclosure } from '@chakra-ui/react'; +import type { AbstractWalletController, WalletAccount } from '@ylide/sdk'; +import { useCallback, useState } from 'react'; + +import type { DomainAccount } from './types'; + +import type { YlideConnectAccountModalProps } from 'ui/connectAccountModal'; +import type { SelectWalletModalProps } from 'ui/selectWalletModal'; + +export const useYlideSelectWalletModal = () => { + const { onOpen, onClose, isOpen } = useDisclosure(); + const [ props, setProps ] = useState({}); + + const triggerOpen = useCallback((props: SelectWalletModalProps) => { + setProps({ + onClose: (w?: AbstractWalletController) => { + onClose(); + props.onClose?.(w); + }, + }); + onOpen(); + }, [ onOpen, onClose ]); + + const triggerClose = useCallback(() => { + props.onClose?.(); + onClose(); + }, [ onClose, props ]); + + return { + props, + open: triggerOpen, + openWithPromise: () => new Promise(resolve => triggerOpen({ onClose: resolve })), + close: triggerClose, + isOpen, + }; +}; + +export const useYlideAccountModal = () => { + const { onOpen, onClose, isOpen } = useDisclosure(); + const [ props, setProps ] = useState({ + wallet: null as unknown as AbstractWalletController, + account: null as unknown as WalletAccount, + remoteKeys: {}, + }); + + const triggerOpen = useCallback((props: YlideConnectAccountModalProps) => { + setProps({ + ...props, + onClose: (w?: DomainAccount) => { + onClose(); + props.onClose?.(w); + }, + }); + onOpen(); + }, [ onOpen, onClose ]); + + const triggerClose = useCallback(() => { + props.onClose?.(); + onClose(); + }, [ onClose, props ]); + + return { + open: triggerOpen, + openWithPromise: (props: Omit) => + new Promise(resolve => triggerOpen({ ...props, onClose: resolve })), + close: triggerClose, + props, + isOpen, + }; +}; diff --git a/lib/contexts/ylide/types.ts b/lib/contexts/ylide/types.ts new file mode 100644 index 0000000000..a9747f7ba5 --- /dev/null +++ b/lib/contexts/ylide/types.ts @@ -0,0 +1,61 @@ +import type { AbstractWalletController, WalletAccount } from '@ylide/sdk'; + +export interface IAppEntry { + id: string; + name: string; + homepage: string; + chains: Array; + image_id: string; + image_url: { + sm: string; + md: string; + lg: string; + }; + app: { + browser: string; + ios: string; + android: string; + mac: string; + windows: string; + linux: string; + }; + mobile: { + native: string; + universal: string; + }; + desktop: { + native: string; + universal: string; + }; + metadata: { + shortName: string; + colors: { + primary: string; + secondary: string; + }; + }; +} + +export interface IAppRegistry { + [id: string]: IAppEntry; +} + +export interface DomainAccount { + name: string; + backendAuthKey: string | null; + wallet: AbstractWalletController; + account: WalletAccount; + reloadKeys: () => Promise; +} + +export interface YlideSavedAccount { + name: string; + account: WalletAccount; + wallet: string; + backendAuthKey: string | null; +} + +export interface YlideAccountPushes { + lowercaseAddress: string; + isEnabled: boolean; +} diff --git a/lib/contexts/ylide/useYlideAccounts.ts b/lib/contexts/ylide/useYlideAccounts.ts new file mode 100644 index 0000000000..995c9e42a6 --- /dev/null +++ b/lib/contexts/ylide/useYlideAccounts.ts @@ -0,0 +1,71 @@ +import { WalletAccount } from '@ylide/sdk'; +import { useState, useCallback } from 'react'; + +import type { YlideSavedAccount } from './types'; + +const serializeAccounts = (accounts: Array) => { + return JSON.stringify(accounts.map(acc => ({ + name: acc.name, + account: acc.account.toBase64(), + wallet: acc.wallet, + backendAuthKey: acc.backendAuthKey, + }))); +}; + +const deserializeAccounts = (serialized: string) => { + const parsed = JSON.parse(serialized) as Array<{ name: string; account: string; wallet: string; backendAuthKey: string | null }>; + if (!Array.isArray(parsed)) { + return []; + } else { + return parsed.map((acc) => ({ + ...acc, + backendAuthKey: acc.backendAuthKey || null, + account: WalletAccount.fromBase64(acc.account), + })); + } +}; + +export const useYlideAccounts = () => { + const localStorage = typeof window === 'undefined' ? undefined : window?.localStorage; + + const loadAccountsFromBrowserStorage = useCallback(() => { + const savedAccounts = localStorage?.getItem('ylide-accounts'); + if (savedAccounts) { + return deserializeAccounts(savedAccounts); + } + return []; + }, [ localStorage ]); + + const [ savedAccounts, setAccounts ] = useState>(loadAccountsFromBrowserStorage()); + + const addAccount = useCallback((name: string, account: WalletAccount, wallet: string, backendAuthKey: string | null) => { + const newSavedAccount = { name, account, wallet, backendAuthKey }; + const newAccounts = [ ...savedAccounts, newSavedAccount ]; + localStorage?.setItem('ylide-accounts', serializeAccounts(newAccounts)); + setAccounts(newAccounts); + return newSavedAccount; + }, [ savedAccounts, localStorage ]); + + const deleteAccount = useCallback((account: WalletAccount) => { + const newAccounts = savedAccounts.filter((acc) => acc.account.address !== account.address); + localStorage?.setItem('ylide-accounts', serializeAccounts(newAccounts)); + setAccounts(newAccounts); + }, [ savedAccounts, localStorage ]); + + const setAccountAuthKey = useCallback((account: WalletAccount, authKey: string | null) => { + const actualAccounts = loadAccountsFromBrowserStorage(); + const newAccounts = actualAccounts.map((acc) => { + if (acc.account.address === account.address) { + return { + ...acc, + backendAuthKey: authKey, + }; + } + return acc; + }); + localStorage?.setItem('ylide-accounts', serializeAccounts(newAccounts)); + setAccounts(newAccounts); + }, [ loadAccountsFromBrowserStorage, localStorage ]); + + return { savedAccounts, addAccount, deleteAccount, setAccountAuthKey }; +}; diff --git a/lib/contexts/ylide/useYlideFaucet.ts b/lib/contexts/ylide/useYlideFaucet.ts new file mode 100644 index 0000000000..16abb6d66d --- /dev/null +++ b/lib/contexts/ylide/useYlideFaucet.ts @@ -0,0 +1,74 @@ +import { EVM_NAMES, type EVMNetwork } from '@ylide/ethereum'; +import type { PublicKey, Ylide, YlideKeysRegistry } from '@ylide/sdk'; +import { useCallback, useState } from 'react'; + +import type { DomainAccount } from './types'; + +export const useYlideFaucet = (ylide: Ylide, keysRegistry: YlideKeysRegistry) => { + const [ publishingTxHash, setPublishingTxHash ] = useState(''); + const [ isTxPublishing, setIsTxPublishing ] = useState(false); + const [ txPlateVisible, setTxPlateVisible ] = useState(false); + + const getFaucetSignature = useCallback(async( + account: DomainAccount, + publicKey: PublicKey, + faucetType: EVMNetwork.GNOSIS, + ) => { + const faucet = await account.wallet.getFaucet({ faucetType }); + + const registrar = 7; + + const data = await faucet.authorizePublishing(account.account, publicKey, registrar, { + type: 'client', + key: 'clfaf6c3e695452c2a', + }); + + return { + faucet, + data, + blockchain: EVM_NAMES[faucetType], + account, + publicKey, + faucetType, + }; + }, []); + + const publishThroughFaucet = useCallback(async(faucetData: Awaited>) => { + try { + try { + const result = await faucetData.faucet.attachPublicKey(faucetData.data); + + const key = await ylide.core.waitForPublicKey( + faucetData.blockchain, + faucetData.account.account.address, + faucetData.publicKey.keyBytes, + ); + + if (key) { + await keysRegistry.addRemotePublicKey(key); + + faucetData.account.reloadKeys(); + + setPublishingTxHash(result.txHash); + setIsTxPublishing(false); + } else { + setIsTxPublishing(false); + // console.error( + // 'Something went wrong with key publishing :(\n\n' + JSON.stringify(result, null, '\t'), + // ); + } + } catch (err) { + // console.error(`Something went wrong with key publishing: ${ err.message }`, err.stack); + // toast('Something went wrong with key publishing :( Please, try again'); + setIsTxPublishing(false); + setTxPlateVisible(false); + } + } catch (err) { + // console.error('faucet publication error: ', err); + setIsTxPublishing(false); + setTxPlateVisible(false); + } + }, [ ylide, keysRegistry ]); + + return { getFaucetSignature, publishThroughFaucet, publishingTxHash, isTxPublishing, txPlateVisible, setTxPlateVisible }; +}; diff --git a/lib/contexts/ylide/useYlidePushes.ts b/lib/contexts/ylide/useYlidePushes.ts new file mode 100644 index 0000000000..b6fa3bac9b --- /dev/null +++ b/lib/contexts/ylide/useYlidePushes.ts @@ -0,0 +1,43 @@ +import { useState, useCallback, useMemo } from 'react'; + +import type { YlideAccountPushes } from './types'; + +const serializePushes = (accounts: Array) => { + return JSON.stringify(accounts); +}; + +const deserializePushes = (serialized: string) => { + return JSON.parse(serialized) as Array; +}; + +export const useYlidePushes = () => { + const localStorage = typeof window === 'undefined' ? undefined : window?.localStorage; + + const loadPushesFromBrowserStorage = useCallback(() => { + const savedPushes = localStorage?.getItem('ylide-pushes'); + if (savedPushes) { + return deserializePushes(savedPushes); + } + return []; + }, [ localStorage ]); + + const [ pushes, setPushes ] = useState>(loadPushesFromBrowserStorage()); + + const addressesWithPushes = useMemo(() => pushes.map(p => p.lowercaseAddress), [ pushes ]); + + const setAccountPushState = useCallback((address: string, isEnabled: boolean) => { + const actualPushes = loadPushesFromBrowserStorage(); + const newPushes = [ ...actualPushes ]; + address = address.toLowerCase(); + const acc = newPushes.find(a => a.lowercaseAddress === address); + if (acc) { + acc.isEnabled = isEnabled; + } else { + newPushes.push({ lowercaseAddress: address, isEnabled }); + } + localStorage?.setItem('ylide-pushes', serializePushes(newPushes)); + setPushes(newPushes); + }, [ loadPushesFromBrowserStorage, localStorage ]); + + return { addressesWithPushes, setAccountPushState }; +}; diff --git a/lib/ensurePageLoaded.ts b/lib/ensurePageLoaded.ts new file mode 100644 index 0000000000..1c53de1df5 --- /dev/null +++ b/lib/ensurePageLoaded.ts @@ -0,0 +1,13 @@ +let ensurePageLoaded: Promise; + +if (typeof document === 'undefined' || document.readyState === 'complete') { + ensurePageLoaded = Promise.resolve(); +} else { + ensurePageLoaded = new Promise(resolve => { + window.addEventListener('load', () => { + resolve(); + }); + }); +} + +export { ensurePageLoaded }; diff --git a/lib/formatDateTime.ts b/lib/formatDateTime.ts new file mode 100644 index 0000000000..f729169500 --- /dev/null +++ b/lib/formatDateTime.ts @@ -0,0 +1,29 @@ +export default function formatDateTime(t: Date, options?: { + showTime?: boolean; + showDate?: boolean; + timeWithoutSeconds?: boolean; + dateWithoutYear?: boolean; +}) { + const seconds = t.getSeconds().toString().padStart(2, '0'); + const minutes = t.getMinutes().toString().padStart(2, '0'); + const hours = t.getHours().toString().padStart(2, '0'); + + const day = t.getDate().toString().padStart(2, '0'); + const month = (t.getMonth() + 1).toString().padStart(2, '0'); + const year = (t.getFullYear() % 100).toString().padStart(2, '0'); + + const date = `${ day }/${ month }${ options?.dateWithoutYear ? '' : `/${ year }` }`; + const time = `${ hours }:${ minutes }${ options?.timeWithoutSeconds ? '' : `:${ seconds }` }`; + + if (options?.showDate && options?.showTime) { + return `${ date } ${ time }`; + } else + if (options?.showDate) { + return date; + } else + if (options?.showTime) { + return time; + } + + return `${ date } ${ time }`; +} diff --git a/lib/hooks/useFetch.tsx b/lib/hooks/useFetch.tsx index 06eea3fcae..4cffe3b6d9 100644 --- a/lib/hooks/useFetch.tsx +++ b/lib/hooks/useFetch.tsx @@ -8,7 +8,7 @@ export interface Params { method?: RequestInit['method']; headers?: RequestInit['headers']; signal?: RequestInit['signal']; - body?: Record | FormData; + body?: NonNullable | FormData; credentials?: RequestCredentials; } @@ -18,7 +18,7 @@ interface Meta { } export default function useFetch() { - return React.useCallback((path: string, params?: Params, meta?: Meta): Promise> => { + return React.useCallback((path: string, params?: Params, meta?: Meta): Promise => { const _body = params?.body; const isFormData = _body instanceof FormData; const withBody = isBodyAllowed(params?.method); @@ -65,14 +65,18 @@ export default function useFetch() { payload: jsonError as Error, status: response.status, statusText: response.statusText, - }), + } as ResourceError), () => { return Promise.reject(error); }, ); } else { - return response.json() as Promise; + if (response.status === 204) { + return Promise.resolve({} as Success); + } else { + return response.json() as Promise; + } } }); }, [ ]); diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 4a6e2552ae..9a16717e17 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -10,6 +10,7 @@ import appsIcon from 'icons/apps.svg'; import withdrawalsIcon from 'icons/arrows/north-east.svg'; import depositsIcon from 'icons/arrows/south-east.svg'; import blocksIcon from 'icons/block.svg'; +import forumIcon from 'icons/forum.svg'; import gearIcon from 'icons/gear.svg'; import globeIcon from 'icons/globe-b.svg'; import graphQLIcon from 'icons/graphQL.svg'; @@ -176,6 +177,12 @@ export default function useNavItems(): ReturnType { isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive), subItems: apiNavItems, }, + config.features.forum.isEnabled ? { + text: 'Forum', + icon: forumIcon, + nextRoute: { pathname: '/forum' as const }, + isActive: pathname.startsWith('/forum/') || pathname === '/forum', + } : null, config.UI.sidebar.otherLinks.length > 0 ? { text: 'Other', icon: gearIcon, diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 5da92870b1..1a58c2d7bb 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -48,6 +48,16 @@ const OG_TYPE_DICT: Record = { '/api/healthz': 'Regular page', '/auth/auth0': 'Regular page', '/auth/unverified-email': 'Regular page', + + // forum routes: + '/forum': 'Root page', + '/forum/[topic]': 'Regular page', + '/forum/[topic]/create-thread': 'Regular page', + '/forum/[topic]/[thread]': 'Regular page', + '/forum/chats': 'Root page', + '/forum/chats/[hash]': 'Regular page', + '/forum/bookmarks/[hash]': 'Regular page', + '/forum/watches/[hash]': 'Regular page', }; export default function getPageOgType(pathname: Route['pathname']) { diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 25ced418dd..33ad91edeb 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -51,6 +51,15 @@ const TEMPLATE_MAP: Record = { '/api/healthz': DEFAULT_TEMPLATE, '/auth/auth0': DEFAULT_TEMPLATE, '/auth/unverified-email': DEFAULT_TEMPLATE, + + '/forum': DEFAULT_TEMPLATE, + '/forum/[topic]': DEFAULT_TEMPLATE, + '/forum/[topic]/create-thread': DEFAULT_TEMPLATE, + '/forum/[topic]/[thread]': DEFAULT_TEMPLATE, + '/forum/chats': DEFAULT_TEMPLATE, + '/forum/chats/[hash]': DEFAULT_TEMPLATE, + '/forum/bookmarks/[hash]': DEFAULT_TEMPLATE, + '/forum/watches/[hash]': DEFAULT_TEMPLATE, }; export function make(pathname: Route['pathname']) { diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index a47e94cf5e..2c718389dd 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -46,6 +46,15 @@ const TEMPLATE_MAP: Record = { '/api/healthz': 'node API health check', '/auth/auth0': 'authentication', '/auth/unverified-email': 'unverified email', + + '/forum': 'forum', + '/forum/[topic]': 'topic', + '/forum/[topic]/create-thread': 'create thread', + '/forum/[topic]/[thread]': 'thread', + '/forum/chats': 'chats', + '/forum/chats/[hash]': 'chat', + '/forum/bookmarks/[hash]': 'bookmarks', + '/forum/watches/[hash]': 'watches', }; export function make(pathname: Route['pathname']) { diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index dec0e8dc30..4fb399f977 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -46,6 +46,15 @@ export const PAGE_TYPE_DICT: Record = { '/api/healthz': 'Node API: Health check', '/auth/auth0': 'Auth', '/auth/unverified-email': 'Unverified email', + + '/forum': 'Forum', + '/forum/[topic]': 'Forum ttopic', + '/forum/[topic]/create-thread': 'Forum: Create thread', + '/forum/[topic]/[thread]': 'Forum thread', + '/forum/chats': 'Chats', + '/forum/chats/[hash]': 'Chat', + '/forum/bookmarks/[hash]': 'Bookmarks', + '/forum/watches/[hash]': 'Watches', }; export default function getPageType(pathname: Route['pathname']) { diff --git a/lib/sentry/config.ts b/lib/sentry/config.ts index 9a6055d773..e0402fc384 100644 --- a/lib/sentry/config.ts +++ b/lib/sentry/config.ts @@ -27,7 +27,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => { release: feature.release, enableTracing: feature.enableTracing, tracesSampleRate, - integrations: [ new BrowserTracing() ], + integrations: feature.enableTracing ? [ new BrowserTracing() ] : undefined, // error filtering settings // were taken from here - https://docs.sentry.io/platforms/node/guides/azure-functions/configuration/filtering/#decluttering-sentry diff --git a/lib/shortDate.ts b/lib/shortDate.ts new file mode 100644 index 0000000000..2b6fc3d612 --- /dev/null +++ b/lib/shortDate.ts @@ -0,0 +1,16 @@ +export default function shortDate(timestamp: number) { + const now = new Date(); + const currentYear = now.getFullYear(); + const date = new Date(timestamp); + const year = date.getFullYear(); + + const zp = (n: number) => n.toString().padStart(2, '0'); + + const time = `${ zp(date.getHours()) }:${ zp(date.getMinutes()) }`; + + if (year === currentYear) { + return `${ zp(date.getDate()) }/${ zp(date.getMonth() + 1) } ${ time }`; + } else { + return `${ zp(date.getDate()) }/${ zp(date.getMonth() + 1) }/${ zp(year % 100) } ${ time }`; + } +} diff --git a/mocks/contract/solidityscanReport.ts b/mocks/contract/solidityscanReport.ts new file mode 100644 index 0000000000..000adabf7f --- /dev/null +++ b/mocks/contract/solidityscanReport.ts @@ -0,0 +1,65 @@ +export const solidityscanReportAverage = { + scan_report: { + scan_status: 'scan_done', + scan_summary: { + issue_severity_distribution: { + critical: 0, + gas: 1, + high: 0, + informational: 0, + low: 2, + medium: 0, + }, + lines_analyzed_count: 18, + scan_time_taken: 1, + score: '3.61', + score_v2: '72.22', + threat_score: '94.74', + }, + scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout', + }, +}; + +export const solidityscanReportGreat = { + scan_report: { + scan_status: 'scan_done', + scan_summary: { + issue_severity_distribution: { + critical: 0, + gas: 0, + high: 0, + informational: 0, + low: 0, + medium: 0, + }, + lines_analyzed_count: 18, + scan_time_taken: 1, + score: '3.61', + score_v2: '100', + threat_score: '94.74', + }, + scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout', + }, +}; + +export const solidityscanReportLow = { + scan_report: { + scan_status: 'scan_done', + scan_summary: { + issue_severity_distribution: { + critical: 2, + gas: 1, + high: 3, + informational: 0, + low: 2, + medium: 10, + }, + lines_analyzed_count: 18, + scan_time_taken: 1, + score: '3.61', + score_v2: '22.22', + threat_score: '94.74', + }, + scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout', + }, +}; diff --git a/mocks/tokens/tokenInstance.ts b/mocks/tokens/tokenInstance.ts index b28361868f..4173c0a609 100644 --- a/mocks/tokens/tokenInstance.ts +++ b/mocks/tokens/tokenInstance.ts @@ -2,7 +2,6 @@ import type { TokenInstance } from 'types/api/token'; import * as addressMock from '../address/address'; -import { tokenInfoERC721a } from './tokenInfo'; export const base: TokenInstance = { animation_url: null, @@ -74,7 +73,6 @@ export const base: TokenInstance = { name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God', }, owner: addressMock.withName, - token: tokenInfoERC721a, }; export const withRichMetadata: TokenInstance = { diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index 4b626625a6..98b08986a9 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -14,6 +14,7 @@ function generateCspPolicy() { descriptors.safe(), descriptors.sentry(), descriptors.walletConnect(), + descriptors.ylideRpcs(), ); return makePolicyString(policyDescriptor); diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index e8377eb9c3..71073c9175 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -9,3 +9,4 @@ export { monaco } from './monaco'; export { safe } from './safe'; export { sentry } from './sentry'; export { walletConnect } from './walletConnect'; +export { ylideRpcs } from './ylideRpcs'; diff --git a/nextjs/csp/policies/ylideRpcs.ts b/nextjs/csp/policies/ylideRpcs.ts new file mode 100644 index 0000000000..3c5d495be0 --- /dev/null +++ b/nextjs/csp/policies/ylideRpcs.ts @@ -0,0 +1,90 @@ +import type CspDev from 'csp-dev'; + +import config from 'configs/app'; + +export function ylideRpcs(): CspDev.DirectiveDescriptor { + if (!config.features.forum.isEnabled) { + return {}; + } + + return { + 'connect-src': [ + '*.ylide.io', + '*.terminet.io', + '*.blockpi.network', + '*.blockscout.com', + '*.gateway.pokt.network', + '*.linkpool.io', + '*.rpcfast.com', + '*.public.blastapi.io', + '*.binance.org', + '*.defibit.io', + '*.ninicoin.io', + '*.nodereal.io', + '*.bwarelabs.com', + '*.bwarelabs.com', + '*.fandom.finance', + '*.api.onfinality.io', + '*.moonbeam.network', + '*.moonscan.io', + '*.fantom.network', + '*.mytokenpocket.vip', + 'evm.cronos.org', + 'cronos-rpc.heavenswail.one', + 'cronosrpc-1.xstaking.sg', + 'cronos-rpc.elk.finance', + 'api.mycryptoapi.com', + 'rpc.flashbots.net', + 'rpc.ankr.com', + '1rpc.io', + 'lineascan.build', + 'basescan.org', + 'cronoscan.com', + 'cloudflare-eth.com', + 'nodes.mewapi.io', + 'mainnet.eth.cloud.ava.do', + 'ethereumnodelight.app.runonflux.io', + 'api.bitstack.com', + 'eth-mainnet.unifra.io', + 'api.securerpc.com', + 'etherscan.io', + 'bscrpc.com', + 'rpc-bsc.bnb48.club', + 'bscscan.com', + 'arb1.arbitrum.io', + 'arbiscan.io', + 'api.avax.network', + 'snowtrace.io', + 'mainnet.optimism.io', + 'optimistic.etherscan.io', + 'polygon-rpc.com', + 'rpc-mainnet.matic.network', + 'matic-mainnet.chainstacklabs.com', + 'rpc-mainnet.maticvigil.com', + 'rpc-mainnet.matic.quiknode.pro', + 'polygon-bor.publicnode.com', + 'matic.slingshot.finance', + 'polygonscan.com', + 'rpc.ftm.tools', + 'ftmscan.com', + 'public-node-api.klaytnapi.com', + 'scope.klaytn.com', + 'rpc.gnosischain.com', + 'rpc.ap-southeast-1.gateway.fm', + 'gnosisscan.io', + 'mainnet.aurora.dev', + 'aurorascan.dev', + 'forno.celo.org', + 'celoscan.io', + 'andromeda.metis.io', + 'andromeda-explorer.metis.io', + 'wss://*.ylide.io', + 'wss://*.alchemy.com', + 'wss://*.infura.io', + 'wss://*.nodereal.io', + 'wss://rpc.gnosischain.com', + 'wss://forno.celo.org', + 'wss://wss.api.moonbeam.network', + ], + }; +} diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index 2f62492617..70e02d132e 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -10,6 +10,8 @@ export type Props = { hash: string; number: string; q: string; + topic: string; + thread: string; } export const base: GetServerSideProps = async({ req, query }) => { @@ -22,6 +24,8 @@ export const base: GetServerSideProps = async({ req, query }) => { height_or_hash: query.height_or_hash?.toString() || '', number: query.number?.toString() || '', q: query.q?.toString() || '', + topic: query.topic?.toString() || '', + thread: query.thread?.toString() || '', }, }; }; diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index a39e9e3884..f5297f3472 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -29,6 +29,14 @@ declare module "nextjs-routes" { | DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }> | StaticRoute<"/blocks"> | StaticRoute<"/csv-export"> + | DynamicRoute<"/forum/[topic]/[thread]", { "topic": string; "thread": string }> + | DynamicRoute<"/forum/[topic]/create-thread", { "topic": string }> + | DynamicRoute<"/forum/[topic]", { "topic": string }> + | DynamicRoute<"/forum/bookmarks/[hash]", { "hash": string }> + | DynamicRoute<"/forum/chats/[hash]", { "hash": string }> + | StaticRoute<"/forum/chats"> + | StaticRoute<"/forum"> + | DynamicRoute<"/forum/watches/[hash]", { "hash": string }> | StaticRoute<"/graphiql"> | StaticRoute<"/"> | StaticRoute<"/l2-deposits"> diff --git a/package.json b/package.json index 918c4419f3..afbea6711e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "dependencies": { "@chakra-ui/react": "2.7.1", "@chakra-ui/theme-tools": "^2.0.18", + "@editorjs/editorjs": "2.27.2", + "@editorjs/header": "2.7.0", + "@editorjs/list": "1.8.0", + "@editorjs/paragraph": "2.9.0", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@metamask/post-message-stream": "^7.0.0", @@ -53,8 +57,12 @@ "@tanstack/react-query-devtools": "^5.4.3", "@types/papaparse": "^5.3.5", "@types/react-scroll": "^1.8.4", + "@walletconnect/browser-utils": "^1.8.0", "@web3modal/ethereum": "^2.6.2", "@web3modal/react": "^2.6.2", + "@ylide/ethereum": "0.9.0-beta.11", + "@ylide/sdk": "0.9.0-beta.8", + "@ylide/smart-buffer": "0.0.17", "bignumber.js": "^9.1.0", "blo": "^1.1.1", "chakra-react-select": "^4.4.3", @@ -83,11 +91,14 @@ "react": "18.2.0", "react-device-detect": "^2.2.3", "react-dom": "18.2.0", + "react-draft-wysiwyg": "^1.15.0", + "react-editor-js": "2.1.0", "react-google-recaptcha": "^2.1.0", "react-hook-form": "^7.33.1", "react-identicons": "^1.2.5", "react-intersection-observer": "^9.5.2", "react-jazzicon": "^1.0.4", + "react-qr-code": "^2.0.12", "react-scroll": "^1.8.7", "swagger-ui-react": "^5.9.0", "use-font-face-observer": "^1.2.1", @@ -106,6 +117,7 @@ "@types/csp-dev": "^1.0.0", "@types/d3": "^7.4.0", "@types/dom-to-image": "^2.6.4", + "@types/editorjs__header": "^2.6.1", "@types/jest": "^29.2.0", "@types/js-cookie": "^3.0.2", "@types/mixpanel-browser": "^2.38.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index b9d77d5c53..8bfa92a786 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -12,6 +12,7 @@ import useQueryClientConfig from 'lib/api/useQueryClientConfig'; import { AppContextProvider } from 'lib/contexts/app'; import { ChakraProvider } from 'lib/contexts/chakra'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; +import { YlideProvider } from 'lib/contexts/ylide'; import { SocketProvider } from 'lib/socket/context'; import theme from 'theme'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; @@ -56,7 +57,9 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - { getLayout() } + + { getLayout() } + diff --git a/pages/forum/[topic]/[thread]/index.tsx b/pages/forum/[topic]/[thread]/index.tsx new file mode 100644 index 0000000000..1ed4c6e733 --- /dev/null +++ b/pages/forum/[topic]/[thread]/index.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 Thread = dynamic(() => import('ui/pages/Thread'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/[topic]/create-thread.tsx b/pages/forum/[topic]/create-thread.tsx new file mode 100644 index 0000000000..cee47297ea --- /dev/null +++ b/pages/forum/[topic]/create-thread.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 CreateThread = dynamic(() => import('ui/pages/CreateThread'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/[topic]/index.tsx b/pages/forum/[topic]/index.tsx new file mode 100644 index 0000000000..e2e1bfea66 --- /dev/null +++ b/pages/forum/[topic]/index.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 Threads = dynamic(() => import('ui/pages/Threads'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/bookmarks/[hash].tsx b/pages/forum/bookmarks/[hash].tsx new file mode 100644 index 0000000000..4db121b376 --- /dev/null +++ b/pages/forum/bookmarks/[hash].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 Bookmarks = dynamic(() => import('ui/pages/Bookmarks'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/chats/[hash].tsx b/pages/forum/chats/[hash].tsx new file mode 100644 index 0000000000..4a0cd41c81 --- /dev/null +++ b/pages/forum/chats/[hash].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 Chat = dynamic(() => import('ui/pages/Chat'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/chats/index.tsx b/pages/forum/chats/index.tsx new file mode 100644 index 0000000000..d21b9806bd --- /dev/null +++ b/pages/forum/chats/index.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 Chats = dynamic(() => import('ui/pages/Chats'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/index.tsx b/pages/forum/index.tsx new file mode 100644 index 0000000000..b7f63546ed --- /dev/null +++ b/pages/forum/index.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 Topics = dynamic(() => import('ui/pages/Topics'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/forum/watches/[hash].tsx b/pages/forum/watches/[hash].tsx new file mode 100644 index 0000000000..20e7d04fdf --- /dev/null +++ b/pages/forum/watches/[hash].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 Watches = dynamic(() => import('ui/pages/Watches'), { ssr: false }); + +const Page: NextPage = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/playwright/TestApp.tsx b/playwright/TestApp.tsx index fc644a9ad8..8fe9e1fd28 100644 --- a/playwright/TestApp.tsx +++ b/playwright/TestApp.tsx @@ -8,6 +8,7 @@ import { mainnet } from 'wagmi/chains'; import type { Props as PageProps } from 'nextjs/getServerSideProps'; import { AppContextProvider } from 'lib/contexts/app'; +import { YlideProvider } from 'lib/contexts/ylide'; import { SocketProvider } from 'lib/socket/context'; import * as app from 'playwright/utils/app'; import theme from 'theme'; @@ -29,6 +30,8 @@ const defaultAppContext = { hash: '', number: '', q: '', + topic: '', + thread: '', }, }; @@ -63,7 +66,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props - { children } + + { children } + diff --git a/stubs/contract.ts b/stubs/contract.ts index 12bba6c130..36d7b13ed2 100644 --- a/stubs/contract.ts +++ b/stubs/contract.ts @@ -1,4 +1,4 @@ -import type { SmartContract } from 'types/api/contract'; +import type { SmartContract, SolidityscanReport } from 'types/api/contract'; import type { VerifiedContract } from 'types/api/contracts'; import { ADDRESS_PARAMS } from './addressParams'; @@ -53,3 +53,25 @@ export const VERIFIED_CONTRACT_INFO: VerifiedContract = { tx_count: 565058, verified_at: '2023-04-10T13:16:33.884921Z', }; + +export const SOLIDITYSCAN_REPORT: SolidityscanReport = { + scan_report: { + scan_status: 'scan_done', + scan_summary: { + issue_severity_distribution: { + critical: 0, + gas: 1, + high: 0, + informational: 0, + low: 2, + medium: 0, + }, + lines_analyzed_count: 18, + scan_time_taken: 1, + score: '3.61', + score_v2: '72.22', + threat_score: '94.74', + }, + scanner_reference_url: 'https://solidityscan.com/quickscan/0xc1EF7811FF2ebFB74F80ed7423f2AdAA37454be2/blockscout/eth-goerli?ref=blockscout', + }, +}; diff --git a/stubs/token.ts b/stubs/token.ts index 30b8b487bf..e60e004bad 100644 --- a/stubs/token.ts +++ b/stubs/token.ts @@ -108,6 +108,5 @@ export const TOKEN_INSTANCE: TokenInstance = { name: 'GENESIS #188882, 8a77ca1bcaa4036f. Blockchain pixel PFP NFT + "on music video" trait inspired by God', }, owner: ADDRESS_PARAMS, - token: TOKEN_INFO_ERC_1155, holder_address_hash: ADDRESS_HASH, }; diff --git a/tools/scripts/dev.preset.sh b/tools/scripts/dev.preset.sh index 1110089f72..73900812e9 100755 --- a/tools/scripts/dev.preset.sh +++ b/tools/scripts/dev.preset.sh @@ -30,5 +30,5 @@ dotenv \ -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ -e $config_file \ -e $secrets_file \ - -- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_PORT' | + -- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_LISTEN_PORT' | pino-pretty \ No newline at end of file diff --git a/types/api/contract.ts b/types/api/contract.ts index d8ff3a0b3c..d8451ce974 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -156,3 +156,25 @@ export interface SmartContractVerificationError { constructor_arguments?: Array; name?: Array; } + +export type SolidityscanReport = { + scan_report: { + scan_status: string; + scan_summary: { + issue_severity_distribution: { + critical: number; + gas: number; + high: number; + informational: number; + low: number; + medium: number; + }; + lines_analyzed_count: number; + scan_time_taken: number; + score: string; + score_v2: string; + threat_score: string; + }; + scanner_reference_url: string; + }; +} diff --git a/types/api/forum.ts b/types/api/forum.ts new file mode 100644 index 0000000000..529b0dd249 --- /dev/null +++ b/types/api/forum.ts @@ -0,0 +1,18 @@ +export type TopicsFilters = { q: string }; + +export interface TopicsSorting { + sort: 'popular' | 'name' | 'updated'; + order: 'asc' | 'desc'; +} + +export type ThreadsFilters = { q: string; tag?: string }; + +export interface ThreadsSorting { + sort: 'popular' | 'name' | 'updated'; + order: 'asc' | 'desc'; +} + +export interface RepliesSorting { + sort: 'time'; + order: 'asc' | 'desc'; +} diff --git a/types/api/token.ts b/types/api/token.ts index ebc5198d92..681dde2075 100644 --- a/types/api/token.ts +++ b/types/api/token.ts @@ -61,7 +61,6 @@ export interface TokenInstance { external_app_url: string | null; metadata: Record | null; owner: AddressParam | null; - token: TokenInfo; } export interface TokenInstanceTransfersCount { diff --git a/ui/Forum.png b/ui/Forum.png new file mode 100644 index 0000000000..e7df51e2b1 Binary files /dev/null and b/ui/Forum.png differ diff --git a/ui/address/AddressDiscuss.tsx b/ui/address/AddressDiscuss.tsx new file mode 100644 index 0000000000..885088e192 --- /dev/null +++ b/ui/address/AddressDiscuss.tsx @@ -0,0 +1,69 @@ +import { Flex, useColorModeValue, Text, Button, useToast } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React, { useEffect } from 'react'; + +import type { ForumThread } from 'lib/api/ylideApi/types'; + +import ForumPersonalApi from 'lib/api/ylideApi/ForumPersonalApi'; +import ForumPublicApi from 'lib/api/ylideApi/ForumPublicApi'; +import { useYlide } from 'lib/contexts/ylide'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import ThreadReplies from 'ui/shared/forum/ThreadReplies'; + +const AddressDiscuss = () => { + const router = useRouter(); + const hash = getQueryParamString(router.query.hash).toLowerCase(); + const { accounts: { initialized, tokens } } = useYlide(); + const [ thread, setThread ] = React.useState(); + const getThread = ForumPublicApi.useGetThreadByAddress(hash); + const createThread = ForumPersonalApi.useCreateThread(tokens[0]); + const toast = useToast(); + + useEffect(() => { + if (!initialized) { + return; + } + getThread().then(setThread); + }, [ getThread, initialized ]); + + const handleCreateDiscussion = React.useCallback(async() => { + // blockchainAddress + // blockchainTx + try { + const thread = await createThread({ + title: `Let's discuss address "${ hash }"`, + description: `Address "${ hash }" discussion`, + blockchainTx: hash, + topic: '27', + tags: [], + }); + setThread(thread); + } catch (err) { + toast({ + position: 'top-right', + title: 'Error', + description: (err as ({ payload: string } | undefined))?.payload || 'There was an error while creating thread.', + status: 'error', + variant: 'subtle', + isClosable: true, + }); + } + }, [ hash, createThread, toast ]); + + const mutedColor = useColorModeValue('gray.500', 'gray.500'); + + if (!thread) { + return ( + + There’s no discussions about this address yet. + + + ); + } + + return ( + + ); +}; + +export default AddressDiscuss; diff --git a/ui/address/SolidityscanReport.pw.tsx b/ui/address/SolidityscanReport.pw.tsx new file mode 100644 index 0000000000..fd41c7a860 --- /dev/null +++ b/ui/address/SolidityscanReport.pw.tsx @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import React from 'react'; + +import * as solidityscanReportMock from 'mocks/contract/solidityscanReport'; +import TestApp from 'playwright/TestApp'; +import buildApiUrl from 'playwright/utils/buildApiUrl'; + +import SolidityscanReport from './SolidityscanReport'; + +const addressHash = 'hash'; +const REPORT_API_URL = buildApiUrl('contract_solidityscan_report', { hash: addressHash }); + +test('average report +@dark-mode +@mobile', async({ mount, page }) => { + await page.route(REPORT_API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(solidityscanReportMock.solidityscanReportAverage), + })); + + const component = await mount( + + + , + ); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } }); + + await component.getByLabel('SolidityScan score').click(); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } }); +}); + +test('great report', async({ mount, page }) => { + await page.route(REPORT_API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(solidityscanReportMock.solidityscanReportGreat), + })); + + const component = await mount( + + + , + ); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } }); + + await component.getByLabel('SolidityScan score').click(); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } }); +}); + +test('low report', async({ mount, page }) => { + await page.route(REPORT_API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(solidityscanReportMock.solidityscanReportLow), + })); + + const component = await mount( + + + , + ); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 100, height: 50 } }); + + await component.getByLabel('SolidityScan score').click(); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 500 } }); +}); diff --git a/ui/address/SolidityscanReport.tsx b/ui/address/SolidityscanReport.tsx new file mode 100644 index 0000000000..5cdb32e30a --- /dev/null +++ b/ui/address/SolidityscanReport.tsx @@ -0,0 +1,160 @@ +import { + Box, + Flex, + Text, + Grid, + Button, + Icon, + chakra, + Popover, + PopoverTrigger, + PopoverBody, + PopoverContent, + useDisclosure, + Skeleton, + Center, + useColorModeValue, +} from '@chakra-ui/react'; +import React from 'react'; + +import { SolidityscanReport } from 'types/api/contract'; + +import scoreNotOkIcon from 'icons/score/score-not-ok.svg'; +import scoreOkIcon from 'icons/score/score-ok.svg'; +import useApiQuery from 'lib/api/useApiQuery'; +import { SOLIDITYSCAN_REPORT } from 'stubs/contract'; +import LinkExternal from 'ui/shared/LinkExternal'; + +type DistributionItem = { + id: keyof SolidityscanReport['scan_report']['scan_summary']['issue_severity_distribution']; + name: string; + color: string; +} + +const DISTRIBUTION_ITEMS: Array = [ + { id: 'critical', name: 'Critical', color: '#891F11' }, + { id: 'high', name: 'High', color: '#EC672C' }, + { id: 'medium', name: 'Medium', color: '#FBE74D' }, + { id: 'low', name: 'Low', color: '#68C88E' }, + { id: 'informational', name: 'Informational', color: '#A3AEBE' }, + { id: 'gas', name: 'Gas', color: '#A47585' }, +]; + +interface Props { + className?: string; + hash: string; +} + +const SolidityscanReport = ({ className, hash }: Props) => { + const { isOpen, onToggle, onClose } = useDisclosure(); + + const { data, isPlaceholderData, isError } = useApiQuery('contract_solidityscan_report', { + pathParams: { hash }, + queryOptions: { + enabled: Boolean(hash), + placeholderData: SOLIDITYSCAN_REPORT, + }, + }); + + const score = Number(data?.scan_report.scan_summary.score_v2); + + const bgBar = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); + const chartGrayColor = useColorModeValue('gray.100', 'gray.700'); + const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500'); + const popoverBgColor = useColorModeValue('white', 'gray.900'); + + if (isError || !score) { + return null; + } + + let scoreColor; + let scoreLevel; + if (score >= 80) { + scoreColor = 'green.600'; + scoreLevel = 'GREAT'; + } else if (score >= 30) { + scoreColor = 'orange.600'; + scoreLevel = 'AVERAGE'; + } else { + scoreColor = 'red.600'; + scoreLevel = 'LOW'; + } + + const vulnerabilities = data?.scan_report.scan_summary.issue_severity_distribution; + const vulnerabilitiesCounts = vulnerabilities ? Object.values(vulnerabilities) : []; + const vulnerabilitiesCount = vulnerabilitiesCounts.reduce((acc, val) => acc + val, 0); + + return ( + + + + + + + + + Contract analyzed for 140+ vulnerability patterns by SolidityScan + + +
    + +
    +
    + + + { score } + / 100 + + Security score is { scoreLevel } + +
    + { vulnerabilities && vulnerabilitiesCount > 0 && ( + + Vulnerabilities distribution + + { DISTRIBUTION_ITEMS.map(item => ( + <> + + + { item.name } + 0 ? 'text' : yetAnotherGrayColor }>{ vulnerabilities[item.id] } + + + + + + )) } + + + ) } + View full report +
    +
    +
    + ); +}; + +export default chakra(SolidityscanReport); diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-1.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-1.png new file mode 100644 index 0000000000..3b0f8bb82a Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-1.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-2.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-2.png new file mode 100644 index 0000000000..6c5e8d74a4 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_dark-color-mode_average-report-dark-mode-mobile-2.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-1.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-1.png new file mode 100644 index 0000000000..23881b2747 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-1.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-2.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-2.png new file mode 100644 index 0000000000..e952b3dae3 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_average-report-dark-mode-mobile-2.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-1.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-1.png new file mode 100644 index 0000000000..aed915a991 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-1.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-2.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-2.png new file mode 100644 index 0000000000..fef25757f9 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_great-report-2.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-1.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-1.png new file mode 100644 index 0000000000..8a84cb624a Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-1.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-2.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-2.png new file mode 100644 index 0000000000..e36ceae452 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_default_low-report-2.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-1.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-1.png new file mode 100644 index 0000000000..b3a2f2a630 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-1.png differ diff --git a/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-2.png b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-2.png new file mode 100644 index 0000000000..91d6689386 Binary files /dev/null and b/ui/address/__screenshots__/SolidityscanReport.pw.tsx_mobile_average-report-dark-mode-mobile-2.png differ diff --git a/ui/addressVerification/types.ts b/ui/addressVerification/types.ts index e2bc66180d..1340b5ad38 100644 --- a/ui/addressVerification/types.ts +++ b/ui/addressVerification/types.ts @@ -23,10 +23,10 @@ export type AddressCheckResponseSuccess = { status: 'SUCCESS'; result: AddressCheckStatusSuccess; } | -{ status: 'IS_OWNER_ERROR' } | -{ status: 'OWNERSHIP_VERIFIED_ERROR' } | -{ status: 'SOURCE_CODE_NOT_VERIFIED_ERROR' } | -{ status: 'INVALID_ADDRESS_ERROR' }; +{ status: 'IS_OWNER_ERROR'; payload?: { message?: string } } | +{ status: 'OWNERSHIP_VERIFIED_ERROR'; payload?: { message?: string } } | +{ status: 'SOURCE_CODE_NOT_VERIFIED_ERROR'; payload?: { message?: string } } | +{ status: 'INVALID_ADDRESS_ERROR'; payload?: { message?: string } }; export interface AddressVerificationResponseError { code: number; diff --git a/ui/connectAccountModal/BlockChainLabel.tsx b/ui/connectAccountModal/BlockChainLabel.tsx new file mode 100644 index 0000000000..a4ac20bf71 --- /dev/null +++ b/ui/connectAccountModal/BlockChainLabel.tsx @@ -0,0 +1,50 @@ +import { Box, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import { blockchainMeta } from 'lib/contexts/ylide/constants'; + +export interface BlockChainLabelProps { + blockchain: string; +} + +export function BlockChainLabel({ blockchain }: BlockChainLabelProps) { + const backgroundColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100'); + return ( + + + { blockchainMeta[blockchain].logo(12) } + { blockchain.toUpperCase() } + + + ); +} diff --git a/ui/connectAccountModal/WalletTag.tsx b/ui/connectAccountModal/WalletTag.tsx new file mode 100644 index 0000000000..7e4bcfdc3c --- /dev/null +++ b/ui/connectAccountModal/WalletTag.tsx @@ -0,0 +1,56 @@ +import { Flex, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import { walletsMeta } from 'lib/contexts/ylide/constants'; +import shortenString from 'lib/shortenString'; + +interface WalletTagProps { + wallet: string; + address?: string; +} + +export function WalletTag({ wallet, address }: WalletTagProps) { + const borderColor = useColorModeValue('blackAlpha.400', 'whiteAlpha.400'); + return ( + + { walletsMeta[wallet].logo(20) } + { walletsMeta[wallet].title } + { Boolean(address) && ( + + { shortenString(address || null) } + + ) } + + ); +} diff --git a/ui/connectAccountModal/index.tsx b/ui/connectAccountModal/index.tsx new file mode 100644 index 0000000000..552f51dc5a --- /dev/null +++ b/ui/connectAccountModal/index.tsx @@ -0,0 +1,486 @@ +import { Box, Button, Flex, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner } from '@chakra-ui/react'; +import { EVMNetwork } from '@ylide/ethereum'; +import type { AbstractWalletController, RemotePublicKey, WalletAccount, YlideKeysRegistry, YlidePrivateKey } from '@ylide/sdk'; +import { asyncDelay, PrivateKeyAvailabilityState, YlideKeyVersion } from '@ylide/sdk'; +import type { PropsWithChildren, ReactNode } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; + +import type { DomainAccount } from 'lib/contexts/ylide/types'; + +import ProceedToWalletArrowSvg from 'icons/proceedTOWalletArrow.svg'; +import { useYlide } from 'lib/contexts/ylide'; + +import { BlockChainLabel } from './BlockChainLabel'; +import { WalletTag } from './WalletTag'; + +enum Step { + LOADING, + ENTER_PASSWORD, + GENERATE_KEY, + PUBLISH_KEY, + PUBLISHING_KEY, +} + +interface ActionModalProps extends PropsWithChildren> { + title?: ReactNode; + buttons?: ReactNode; + onClose: () => void; +} + +function ActionModal({ children, title, buttons, onClose }: ActionModalProps) { + return ( + + + + { title != null && { title } } + + { children != null && { children } } + { buttons != null && { buttons } } + + + ); +} + +interface LoadingModalProps { + reason?: ReactNode; +} + +export function LoadingModal({ reason }: LoadingModalProps) { + const handleClose = useCallback(() => {}, []); + return ( + + + + + { reason && { reason } } + + + ); +} + +export interface YlideConnectAccountModalProps { + wallet: AbstractWalletController; + account: WalletAccount; + remoteKeys: Record; + onClose?: (account?: DomainAccount) => void; +} + +async function constructLocalKeyV3(keysRegistry: YlideKeysRegistry, controller: AbstractWalletController, account: WalletAccount) { + return await keysRegistry.instantiateNewPrivateKey( + account.blockchainGroup, + account.address, + YlideKeyVersion.KEY_V3, + PrivateKeyAvailabilityState.AVAILABLE, + { + onPrivateKeyRequest: async(address, magicString) => + await controller.signMagicString(account, magicString), + }, + ); +} + +async function constructLocalKeyV2(keysRegistry: YlideKeysRegistry, controller: AbstractWalletController, account: WalletAccount, password: string) { + return await keysRegistry.instantiateNewPrivateKey( + account.blockchainGroup, + account.address, + YlideKeyVersion.KEY_V2, + PrivateKeyAvailabilityState.AVAILABLE, + { + onPrivateKeyRequest: async(address, magicString) => + await controller.signMagicString(account, magicString), + onYlidePasswordRequest: async() => password, + }, + ); +} + +async function constructLocalKeyV1(keysRegistry: YlideKeysRegistry, controller: AbstractWalletController, account: WalletAccount, password: string) { + return await keysRegistry.instantiateNewPrivateKey( + account.blockchainGroup, + account.address, + YlideKeyVersion.INSECURE_KEY_V1, + PrivateKeyAvailabilityState.AVAILABLE, + { + onPrivateKeyRequest: async(address, magicString) => + await controller.signMagicString(account, magicString), + onYlidePasswordRequest: async() => password, + }, + ); +} + +export function YlideConnectAccountModal({ + wallet, + account, + remoteKeys, + onClose, +}: YlideConnectAccountModalProps): JSX.Element { + const freshestKey: { key: RemotePublicKey; blockchain: string } | undefined = useMemo( + () => + Object.keys(remoteKeys) + .filter(t => Boolean(remoteKeys[t])) + .map(t => ({ + key: remoteKeys[t] as RemotePublicKey, + blockchain: t, + })) + .sort((a, b) => b.key.timestamp - a.key.timestamp)[0], + [ remoteKeys ], + ); + const keyVersion = freshestKey?.key.publicKey.keyVersion || 0; + const isPasswordNeeded = keyVersion === 1 || keyVersion === 2; + + const { faucet, keysRegistry } = useYlide(); + + const [ step, setStep ] = useState(Step.ENTER_PASSWORD); + + const [ password, setPassword ] = useState(''); + + const waitTxPublishing = false; + + // const [ network, setNetwork ] = useState(); + // useEffect(() => { + // if (wallet.factory.blockchainGroup === 'evm') { + // getEvmWalletNetwork(wallet).then(setNetwork); + // } + // }, [ wallet ]); + + // const domainAccountRef = useRef(); + + const createDomainAccount = useCallback(async( + wallet: AbstractWalletController, + account: WalletAccount, + privateKey: YlidePrivateKey, + ): Promise => { + // const acc = await wallet.createNewDomainAccount(account); + // await acc.addNewLocalPrivateKey(privateKey); + // domainAccountRef.current = acc; + await keysRegistry.addLocalPrivateKey(privateKey); + return { + wallet, + account, + name: 'New Account', + backendAuthKey: null, + reloadKeys: async() => { + // await keysRegistry.reloadLocalPrivateKeys(); + await asyncDelay(1000); + }, + }; + // return acc; + }, [ keysRegistry ]); + + // function exitUnsuccessfully(error?: { message: string; e?: any }) { + // // if (error) { + // // console.error(error.message, error.e); + // // toast(error.message); + // // } + + // // if (domainAccountRef.current) { + // // disconnectAccount({ account: domainAccountRef.current }).catch(); + // // } + + // onClose?.(); + // } + + const createLocalKey = useCallback(async({ + password, + forceNew, + withoutPassword, + }: { + password: string; + forceNew?: boolean; + withoutPassword?: boolean; + }) => { + setStep(Step.GENERATE_KEY); + + let tempLocalKey: YlidePrivateKey; + const needToRepublishKey = false; + try { + const forceSecond = false; + if (withoutPassword) { + // console.warn('createLocalKey', 'withoutPassword'); + tempLocalKey = await constructLocalKeyV3(keysRegistry, wallet, account); + } else if (forceNew) { + // console.warn('createLocalKey', 'forceNew'); + tempLocalKey = await constructLocalKeyV2(keysRegistry, wallet, account, password); + } else if (freshestKey?.key.publicKey.keyVersion === YlideKeyVersion.INSECURE_KEY_V1) { + if (freshestKey.blockchain === 'venom-testnet') { + // strange... I'm not sure Qamon keys work here + if (forceSecond) { + // console.warn('createLocalKey', 'INSECURE_KEY_V1 venom-testnet'); + tempLocalKey = await constructLocalKeyV1(keysRegistry, wallet, account, password); + } else { + // console.warn('createLocalKey', 'INSECURE_KEY_V1 venom-testnet'); + tempLocalKey = await constructLocalKeyV2(keysRegistry, wallet, account, password); + } + } else { + // strange... I'm not sure Qamon keys work here + if (forceSecond) { + // console.warn('createLocalKey', 'INSECURE_KEY_V2 non-venom'); + tempLocalKey = await constructLocalKeyV2(keysRegistry, wallet, account, password); + } else { + // console.warn('createLocalKey', 'INSECURE_KEY_V1 non-venom'); + tempLocalKey = await constructLocalKeyV1(keysRegistry, wallet, account, password); + } + } + } else if (freshestKey?.key.publicKey.keyVersion === YlideKeyVersion.KEY_V2) { + // if user already using password - we should use it too + // console.warn('createLocalKey', 'KEY_V2'); + tempLocalKey = await constructLocalKeyV2(keysRegistry, wallet, account, password); + } else if (freshestKey?.key.publicKey.keyVersion === YlideKeyVersion.KEY_V3) { + // if user is not using password - we should not use it too + // console.warn('createLocalKey', 'KEY_V3'); + tempLocalKey = await constructLocalKeyV3(keysRegistry, wallet, account); + } else { + // user have no key at all - use passwordless version + // console.warn('createLocalKey', 'no key'); + tempLocalKey = await constructLocalKeyV3(keysRegistry, wallet, account); + } + } catch (e) { + // exitUnsuccessfully({ message: 'Failed to create local key 😒', e }); + return; + } + + setStep(Step.LOADING); + + if (!freshestKey || needToRepublishKey) { + const domainAccount = await createDomainAccount(wallet, account, tempLocalKey); + const actualFaucetType = EVMNetwork.GNOSIS; + + setStep(Step.GENERATE_KEY); + + const faucetData = await faucet.getFaucetSignature( + domainAccount, + tempLocalKey.publicKey, + actualFaucetType, + ); + + setStep(Step.LOADING); + + const promise = faucet.publishThroughFaucet(faucetData); + + if (waitTxPublishing) { + await promise; + } + + onClose?.(domainAccount); + } else if (freshestKey.key.publicKey.equals(tempLocalKey.publicKey)) { + await keysRegistry.addRemotePublicKeys( + Object.values(remoteKeys).filter(it => Boolean(it)) as Array, + ); + const domainAccount = await createDomainAccount(wallet, account, tempLocalKey); + onClose?.(domainAccount); + } else if (forceNew || withoutPassword) { + + // await createDomainAccount(wallet, account, tempLocalKey); + // await publishLocalKey(tempLocalKey, network); + } else { + alert('Password is wrong. Please try again ❤'); + setStep(Step.ENTER_PASSWORD); + } + }, [ account, createDomainAccount, faucet, freshestKey, keysRegistry, onClose, remoteKeys, waitTxPublishing, wallet ]); + + const handleClose = useCallback(() => { + onClose?.(); + }, [ onClose ]); + + const handleCreateLocalKey = useCallback(() => { + createLocalKey({ password }); + }, [ password, createLocalKey ]); + + const handlePasswordChange = useCallback((e: React.ChangeEvent) => { + setPassword(e.target.value); + }, []); + + const handlePasswordKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + createLocalKey({ password }); + } + }, [ password, createLocalKey ]); + + const handleForgotPassword = useCallback(() => { + // showStaticComponent(resolve => ( + // { + // if (result?.withoutPassword) { + // createLocalKey({ + // password: '', + // withoutPassword: true, + // }); + // } else if (result?.password) { + // createLocalKey({ + // password: result.password, + // forceNew: true, + // }); + // } + + // resolve(); + // } } + // /> + // )) + // .catch(console.error); + }, []); + + const handleCancelKeyGeneration = useCallback(() => { + if (isPasswordNeeded) { + setStep(Step.ENTER_PASSWORD); + } else { + handleClose(); + } + }, [ handleClose, isPasswordNeeded ]); + + const handleCancelKeyPublishing = useCallback(() => { + setStep( + isPasswordNeeded ? + Step.ENTER_PASSWORD : + Step.GENERATE_KEY, + ); + }, [ isPasswordNeeded ]); + + const steps: Record JSX.Element> = { + [Step.LOADING]: () => , + [Step.ENTER_PASSWORD]: () => ( + + + + + ) } + onClose={ handleClose } + > + + + { freshestKey ? ( + <> +
    +We found your key in{ ' ' } + blockchain.{ ' ' } + { isPasswordNeeded ? + 'Please, enter your Ylide Password to access it.' : + 'Please, sign authroization message to access it.' } +
    + + { isPasswordNeeded && ( +
    + + +
    + +
    +
    + ) } + + ) : ( +
    +To get your private Ylide communication key, please, press "Sign" button below and sign the +authorization message in your wallet. +
    + ) } +
    + ), + [Step.GENERATE_KEY]: () => ( + +Cancel + + ) } + onClose={ handleClose } + > + + +
    + +
    + +
    Confirm the message
    + +
    + { isPasswordNeeded ? + 'We need you to sign your password so we can generate you a unique communication key.' : + 'We need you to sign authorization message so we can generate you a unique communication key.' } +
    +
    + ), + [Step.PUBLISH_KEY]: () => ( + Cancel + ) } + onClose={ handleClose }// () => exitUnsuccessfully() } + > + + +
    + +
    + +
    Confirm the transaction
    + +
    Please sign the transaction in your wallet to publish your unique communication key.
    +
    + ), + [Step.PUBLISHING_KEY]: () => ( + // () => exitUnsuccessfully() } + + + + + + + + Publishing the key + + Please, wait for the transaction to be completed + + ), + }; + + const result: JSX.Element = steps[step](); + + return result; +} diff --git a/ui/createTopicModal/index.tsx b/ui/createTopicModal/index.tsx new file mode 100644 index 0000000000..713900bd27 --- /dev/null +++ b/ui/createTopicModal/index.tsx @@ -0,0 +1,82 @@ +import { Button, Checkbox, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Textarea } from '@chakra-ui/react'; +import type { PropsWithChildren, ReactNode } from 'react'; +import React, { useCallback, useState } from 'react'; + +import ForumPersonalApi from 'lib/api/ylideApi/ForumPersonalApi'; +import { useYlide } from 'lib/contexts/ylide'; + +interface ActionModalProps extends PropsWithChildren> { + title?: ReactNode; + buttons?: ReactNode; + onClose: () => void; +} + +function ActionModal({ children, title, buttons, onClose }: ActionModalProps) { + return ( + + + + { title != null && { title } } + + { children != null && { children } } + { buttons != null && { buttons } } + + + ); +} + +export interface CreateTopicModalProps { + onClose: () => void; +} + +export function CreateTopicModal({ + onClose, +}: CreateTopicModalProps): JSX.Element { + const { accounts: { admins } } = useYlide(); + const [ topic, setTopic ] = useState(''); + const [ description, setDescription ] = useState(''); + const [ adminOnly, setAdminOnly ] = useState(false); + const createTopic = ForumPersonalApi.useCreateTopic((admins.length && admins[0].backendAuthKey) || ''); + + const handleTopicChange = useCallback((e: React.ChangeEvent) => { + setTopic(e.target.value); + }, []); + + const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { + setDescription(e.target.value); + }, []); + + const handleAdminOnlyChange = useCallback((e: React.ChangeEvent) => { + setAdminOnly(e.target.checked); + }, []); + + const handleCreate = useCallback(async() => { + await createTopic({ + title: topic, + description, + adminOnly, + }); + onClose(); + }, [ onClose, createTopic, topic, description, adminOnly ]); + + const handleClose = useCallback(() => { + onClose(); + }, [ onClose ]); + + return ( + + + + + ) } + onClose={ handleClose } + > + +