commit
c89ee3d466
@ -0,0 +1,6 @@ |
||||
--- |
||||
'@hyperlane-xyz/utils': major |
||||
'@hyperlane-xyz/sdk': major |
||||
--- |
||||
|
||||
Upgrade CosmJS libs to 0.32.4 |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/widgets': minor |
||||
--- |
||||
|
||||
Migrate hyperlane widgets lib to monorepo |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/cli': minor |
||||
--- |
||||
|
||||
Update hyperlane core read to log the config terminal "preview", only if the number of lines is < 250 |
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/cli': patch |
||||
--- |
||||
|
||||
Update CLI verbiage to ask for vault and not token when initiating collateralVault warp route. |
@ -0,0 +1,52 @@ |
||||
use std::sync::Arc; |
||||
|
||||
use derive_new::new; |
||||
use eyre::Result; |
||||
use hyperlane_core::H512; |
||||
use tokio::sync::{ |
||||
mpsc::{Receiver as MpscReceiver, Sender as MpscSender}, |
||||
Mutex, |
||||
}; |
||||
|
||||
#[derive(Debug, Clone, new)] |
||||
/// Wrapper around a vec of mpsc senders that broadcasts messages to all of them.
|
||||
/// This is a workaround to get an async interface for `send`, so senders are blocked if any of the receiving channels is full,
|
||||
/// rather than overwriting old messages (as the `broadcast` channel ring buffer implementation does).
|
||||
pub struct BroadcastMpscSender<T> { |
||||
capacity: usize, |
||||
/// To make this safe to `Clone`, the sending end has to be in an arc-mutex.
|
||||
/// Otherwise it would be possible to call `get_receiver` and create new receiver-sender pairs, whose sender is later dropped
|
||||
/// because the other `BroadcastMpscSender`s have no reference to it. The receiver would then point to a closed
|
||||
/// channel. So all instances of `BroadcastMpscSender` have to point to the entire set of senders.
|
||||
#[new(default)] |
||||
sender: Arc<Mutex<Vec<MpscSender<T>>>>, |
||||
} |
||||
|
||||
impl BroadcastMpscSender<H512> { |
||||
/// Send a message to all the receiving channels.
|
||||
// This will block if at least one of the receiving channels is full
|
||||
pub async fn send(&self, txid: H512) -> Result<()> { |
||||
let senders = self.sender.lock().await; |
||||
for sender in &*senders { |
||||
sender.send(txid).await? |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Get a receiver channel that will receive messages broadcasted by all the senders
|
||||
pub async fn get_receiver(&self) -> MpscReceiver<H512> { |
||||
let (sender, receiver) = tokio::sync::mpsc::channel(self.capacity); |
||||
|
||||
self.sender.lock().await.push(sender); |
||||
receiver |
||||
} |
||||
|
||||
/// Utility function map an option of `BroadcastMpscSender` to an option of `MpscReceiver`
|
||||
pub async fn map_get_receiver(maybe_self: Option<&Self>) -> Option<MpscReceiver<H512>> { |
||||
if let Some(s) = maybe_self { |
||||
Some(s.get_receiver().await) |
||||
} else { |
||||
None |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,60 @@ |
||||
// TODO de-dupe this types with the Explorer by moving them to a shared lib
|
||||
// These were originally imported from the explorer package but there were two issues
|
||||
// 1. The explorer is not structured to be a lib (it's an app)
|
||||
// 2. The explorer's deps on monorepo packages created circular deps leading to transitive deps conflicts
|
||||
|
||||
type Address = string; |
||||
|
||||
export enum MessageStatus { |
||||
Unknown = 'unknown', |
||||
Pending = 'pending', |
||||
Delivered = 'delivered', |
||||
Failing = 'failing', |
||||
} |
||||
|
||||
export interface MessageTxStub { |
||||
timestamp: number; |
||||
hash: string; |
||||
from: Address; |
||||
} |
||||
|
||||
export interface MessageTx extends MessageTxStub { |
||||
to: Address; |
||||
blockHash: string; |
||||
blockNumber: number; |
||||
mailbox: Address; |
||||
nonce: number; |
||||
gasLimit: number; |
||||
gasPrice: number; |
||||
effectiveGasPrice: number; |
||||
gasUsed: number; |
||||
cumulativeGasUsed: number; |
||||
maxFeePerGas: number; |
||||
maxPriorityPerGas: number; |
||||
} |
||||
|
||||
export interface MessageStub { |
||||
status: MessageStatus; |
||||
id: string; // Database id
|
||||
msgId: string; // Message hash
|
||||
nonce: number; // formerly leafIndex
|
||||
sender: Address; |
||||
recipient: Address; |
||||
originChainId: number; |
||||
originDomainId: number; |
||||
destinationChainId: number; |
||||
destinationDomainId: number; |
||||
origin: MessageTxStub; |
||||
destination?: MessageTxStub; |
||||
isPiMsg?: boolean; |
||||
} |
||||
|
||||
export interface Message extends MessageStub { |
||||
body: string; |
||||
decodedBody?: string; |
||||
origin: MessageTx; |
||||
destination?: MessageTx; |
||||
totalGasAmount?: string; |
||||
totalPayment?: string; |
||||
numPayments?: number; |
||||
} |
@ -1,193 +0,0 @@ |
||||
# Apache License |
||||
|
||||
_Version 2.0, January 2004_ |
||||
_<<http://www.apache.org/licenses/>>_ |
||||
|
||||
### Terms and Conditions for use, reproduction, and distribution |
||||
|
||||
#### 1. Definitions |
||||
|
||||
“License” shall mean the terms and conditions for use, reproduction, and |
||||
distribution as defined by Sections 1 through 9 of this document. |
||||
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright |
||||
owner that is granting the License. |
||||
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities |
||||
that control, are controlled by, or are under common control with that entity. |
||||
For the purposes of this definition, “control” means **(i)** the power, direct or |
||||
indirect, to cause the direction or management of such entity, whether by |
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the |
||||
outstanding shares, or **(iii)** beneficial ownership of such entity. |
||||
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising |
||||
permissions granted by this License. |
||||
|
||||
“Source” form shall mean the preferred form for making modifications, including |
||||
but not limited to software source code, documentation source, and configuration |
||||
files. |
||||
|
||||
“Object” form shall mean any form resulting from mechanical transformation or |
||||
translation of a Source form, including but not limited to compiled object code, |
||||
generated documentation, and conversions to other media types. |
||||
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made |
||||
available under the License, as indicated by a copyright notice that is included |
||||
in or attached to the work (an example is provided in the Appendix below). |
||||
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that |
||||
is based on (or derived from) the Work and for which the editorial revisions, |
||||
annotations, elaborations, or other modifications represent, as a whole, an |
||||
original work of authorship. For the purposes of this License, Derivative Works |
||||
shall not include works that remain separable from, or merely link (or bind by |
||||
name) to the interfaces of, the Work and Derivative Works thereof. |
||||
|
||||
“Contribution” shall mean any work of authorship, including the original version |
||||
of the Work and any modifications or additions to that Work or Derivative Works |
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work |
||||
by the copyright owner or by an individual or Legal Entity authorized to submit |
||||
on behalf of the copyright owner. For the purposes of this definition, |
||||
“submitted” means any form of electronic, verbal, or written communication sent |
||||
to the Licensor or its representatives, including but not limited to |
||||
communication on electronic mailing lists, source code control systems, and |
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for |
||||
the purpose of discussing and improving the Work, but excluding communication |
||||
that is conspicuously marked or otherwise designated in writing by the copyright |
||||
owner as “Not a Contribution.” |
||||
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf |
||||
of whom a Contribution has been received by Licensor and subsequently |
||||
incorporated within the Work. |
||||
|
||||
#### 2. Grant of Copyright License |
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby |
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, |
||||
irrevocable copyright license to reproduce, prepare Derivative Works of, |
||||
publicly display, publicly perform, sublicense, and distribute the Work and such |
||||
Derivative Works in Source or Object form. |
||||
|
||||
#### 3. Grant of Patent License |
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby |
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, |
||||
irrevocable (except as stated in this section) patent license to make, have |
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where |
||||
such license applies only to those patent claims licensable by such Contributor |
||||
that are necessarily infringed by their Contribution(s) alone or by combination |
||||
of their Contribution(s) with the Work to which such Contribution(s) was |
||||
submitted. If You institute patent litigation against any entity (including a |
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a |
||||
Contribution incorporated within the Work constitutes direct or contributory |
||||
patent infringement, then any patent licenses granted to You under this License |
||||
for that Work shall terminate as of the date such litigation is filed. |
||||
|
||||
#### 4. Redistribution |
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof |
||||
in any medium, with or without modifications, and in Source or Object form, |
||||
provided that You meet the following conditions: |
||||
|
||||
- **(a)** You must give any other recipients of the Work or Derivative Works a copy of |
||||
this License; and |
||||
- **(b)** You must cause any modified files to carry prominent notices stating that You |
||||
changed the files; and |
||||
- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, |
||||
all copyright, patent, trademark, and attribution notices from the Source form |
||||
of the Work, excluding those notices that do not pertain to any part of the |
||||
Derivative Works; and |
||||
- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any |
||||
Derivative Works that You distribute must include a readable copy of the |
||||
attribution notices contained within such NOTICE file, excluding those notices |
||||
that do not pertain to any part of the Derivative Works, in at least one of the |
||||
following places: within a NOTICE text file distributed as part of the |
||||
Derivative Works; within the Source form or documentation, if provided along |
||||
with the Derivative Works; or, within a display generated by the Derivative |
||||
Works, if and wherever such third-party notices normally appear. The contents of |
||||
the NOTICE file are for informational purposes only and do not modify the |
||||
License. You may add Your own attribution notices within Derivative Works that |
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work, |
||||
provided that such additional attribution notices cannot be construed as |
||||
modifying the License. |
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide |
||||
additional or different license terms and conditions for use, reproduction, or |
||||
distribution of Your modifications, or for any such Derivative Works as a whole, |
||||
provided Your use, reproduction, and distribution of the Work otherwise complies |
||||
with the conditions stated in this License. |
||||
|
||||
#### 5. Submission of Contributions |
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted |
||||
for inclusion in the Work by You to the Licensor shall be under the terms and |
||||
conditions of this License, without any additional terms or conditions. |
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of |
||||
any separate license agreement you may have executed with Licensor regarding |
||||
such Contributions. |
||||
|
||||
#### 6. Trademarks |
||||
|
||||
This License does not grant permission to use the trade names, trademarks, |
||||
service marks, or product names of the Licensor, except as required for |
||||
reasonable and customary use in describing the origin of the Work and |
||||
reproducing the content of the NOTICE file. |
||||
|
||||
#### 7. Disclaimer of Warranty |
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the |
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, |
||||
including, without limitation, any warranties or conditions of TITLE, |
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are |
||||
solely responsible for determining the appropriateness of using or |
||||
redistributing the Work and assume any risks associated with Your exercise of |
||||
permissions under this License. |
||||
|
||||
#### 8. Limitation of Liability |
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence), |
||||
contract, or otherwise, unless required by applicable law (such as deliberate |
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be |
||||
liable to You for damages, including any direct, indirect, special, incidental, |
||||
or consequential damages of any character arising as a result of this License or |
||||
out of the use or inability to use the Work (including but not limited to |
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or |
||||
any and all other commercial damages or losses), even if such Contributor has |
||||
been advised of the possibility of such damages. |
||||
|
||||
#### 9. Accepting Warranty or Additional Liability |
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to |
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or |
||||
other liability obligations and/or rights consistent with this License. However, |
||||
in accepting such obligations, You may act only on Your own behalf and on Your |
||||
sole responsibility, not on behalf of any other Contributor, and only if You |
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability |
||||
incurred by, or claims asserted against, such Contributor by reason of your |
||||
accepting any such warranty or additional liability. |
||||
|
||||
_END OF TERMS AND CONDITIONS_ |
||||
|
||||
### APPENDIX: How to apply the Apache License to your work |
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate |
||||
notice, with the fields enclosed by brackets `[]` replaced with your own |
||||
identifying information. (Don't include the brackets!) The text should be |
||||
enclosed in the appropriate comment syntax for the file format. We also |
||||
recommend that a file or class name and description of purpose be included on |
||||
the same “printed page” as the copyright notice for easier identification within |
||||
third-party archives. |
||||
|
||||
Copyright [yyyy] [name of copyright owner] |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
@ -0,0 +1,6 @@ |
||||
node_modules |
||||
dist |
||||
coverage |
||||
tailwind.config.js |
||||
postcss.config.js |
||||
src/stories/**/*.stories.tsx |
@ -0,0 +1,6 @@ |
||||
{ |
||||
"rules": { |
||||
// TODO use utils rootLogger in widgets lib |
||||
"no-console": ["off"] |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
# testing |
||||
/storybook-static |
||||
|
||||
# production |
||||
/dist |
@ -0,0 +1,19 @@ |
||||
import type { StorybookConfig } from '@storybook/react-vite'; |
||||
|
||||
const config: StorybookConfig = { |
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], |
||||
addons: [ |
||||
'@storybook/addon-links', |
||||
'@storybook/addon-essentials', |
||||
'@storybook/addon-onboarding', |
||||
'@storybook/addon-interactions', |
||||
], |
||||
framework: { |
||||
name: '@storybook/react-vite', |
||||
options: {}, |
||||
}, |
||||
docs: { |
||||
autodocs: true, |
||||
}, |
||||
}; |
||||
export default config; |
@ -0,0 +1 @@ |
||||
<link href="/styles.css" rel="stylesheet" /> |
@ -0,0 +1,15 @@ |
||||
import '../src/styles.css'; |
||||
|
||||
const preview = { |
||||
parameters: { |
||||
actions: { argTypesRegex: '^on[A-Z].*' }, |
||||
controls: { |
||||
matchers: { |
||||
color: /(background|color)$/i, |
||||
date: /Date$/i, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
export default preview; |
@ -0,0 +1,35 @@ |
||||
# Hyperlane Widgets |
||||
|
||||
Common react components for projects using Hyperlane. |
||||
|
||||
## Installation |
||||
|
||||
```sh |
||||
# Install with npm |
||||
npm install @hyperlane-xyz/widgets |
||||
|
||||
# Or install with yarn |
||||
yarn add @hyperlane-xyz/widgets |
||||
``` |
||||
|
||||
### Peer dependencies |
||||
|
||||
This package requires `@hyperlane-xyz/sdk`, `react`, and `react-dom`. |
||||
|
||||
## Contents |
||||
|
||||
### Components |
||||
|
||||
- `ChainLogo`: A logo icon for a given chain ID |
||||
- `MessageTimeline`: A timeline showing stages of message delivery |
||||
- `WideChevron`: A customizable version of Hyperlane's chevron logo |
||||
|
||||
### Hooks |
||||
|
||||
- `useMessage`: Fetch data about a message from the Hyperlane Explorer |
||||
- `useMessageStage`: Fetch and compute message delivery stage and timings |
||||
- `useMessageTimeline`: Fetch message data for use with `MessageTimeline` |
||||
|
||||
## Learn more |
||||
|
||||
For more information, see the [Hyperlane documentation](https://docs.hyperlane.xyz/docs/intro). |
@ -0,0 +1,74 @@ |
||||
{ |
||||
"name": "@hyperlane-xyz/widgets", |
||||
"description": "Common react components for Hyperlane projects", |
||||
"version": "4.1.0", |
||||
"peerDependencies": { |
||||
"react": "^18", |
||||
"react-dom": "^18" |
||||
}, |
||||
"dependencies": { |
||||
"@hyperlane-xyz/registry": "2.3.0", |
||||
"@hyperlane-xyz/sdk": "4.1.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@storybook/addon-essentials": "^7.6.14", |
||||
"@storybook/addon-interactions": "^7.6.14", |
||||
"@storybook/addon-links": "^7.6.14", |
||||
"@storybook/addon-onboarding": "^1.0.11", |
||||
"@storybook/blocks": "^7.6.14", |
||||
"@storybook/react": "^7.6.14", |
||||
"@storybook/react-vite": "^7.6.14", |
||||
"@storybook/test": "^7.6.14", |
||||
"@types/node": "^18.11.18", |
||||
"@types/react": "^18.0.27", |
||||
"@types/react-dom": "^18.0.10", |
||||
"@types/ws": "^8.5.5", |
||||
"@typescript-eslint/eslint-plugin": "^7.4.0", |
||||
"@typescript-eslint/parser": "^7.4.0", |
||||
"babel-loader": "^8.3.0", |
||||
"eslint": "^8.57.0", |
||||
"eslint-config-prettier": "^9.1.0", |
||||
"eslint-plugin-storybook": "^0.6.15", |
||||
"postcss": "^8.4.21", |
||||
"prettier": "^2.8.8", |
||||
"react": "^18.2.0", |
||||
"react-dom": "^18.2.0", |
||||
"storybook": "^7.6.14", |
||||
"tailwindcss": "^3.2.4", |
||||
"ts-node": "^10.8.0", |
||||
"typescript": "5.3.3", |
||||
"vite": "^5.1.1" |
||||
}, |
||||
"files": [ |
||||
"/dist" |
||||
], |
||||
"type": "module", |
||||
"exports": { |
||||
".": "./dist/index.js", |
||||
"./styles.css": "./dist/styles.css" |
||||
}, |
||||
"types": "./dist/index.d.ts", |
||||
"homepage": "https://www.hyperlane.xyz", |
||||
"keywords": [ |
||||
"Hyperlane", |
||||
"Widgets", |
||||
"React", |
||||
"Components", |
||||
"Typescript" |
||||
], |
||||
"license": "Apache-2.0", |
||||
"repository": { |
||||
"type": "git", |
||||
"url": "https://github.com/hyperlane-xyz/hyperlane-widgets" |
||||
}, |
||||
"scripts": { |
||||
"build": "yarn build:ts && yarn build:css", |
||||
"build:ts": "tsc", |
||||
"build:css": "tailwindcss -c ./tailwind.config.cjs -i ./src/styles.css -o ./dist/styles.css --minify", |
||||
"clean": "rm -rf ./dist ./cache ./storybook-static", |
||||
"lint": "eslint ./src --ext .ts", |
||||
"prettier": "prettier --write ./src", |
||||
"storybook": "storybook dev -p 6006", |
||||
"build-storybook": "storybook build" |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
module.exports = { |
||||
plugins: { |
||||
tailwindcss: {}, |
||||
}, |
||||
} |
@ -0,0 +1,30 @@ |
||||
export enum ColorPalette { |
||||
Black = '#010101', |
||||
White = '#FFFFFF', |
||||
Blue = '#2362C0', |
||||
DarkBlue = '#162A4A', |
||||
LightBlue = '#82A8E4', |
||||
Pink = '#CF2FB3', |
||||
Gray = '#6B7280', |
||||
Beige = '#F1EDE9', |
||||
Red = '#BF1B15', |
||||
} |
||||
|
||||
export function seedToBgColor(seed?: number) { |
||||
if (!seed) return 'htw-bg-gray-100'; |
||||
const mod = seed % 5; |
||||
switch (mod) { |
||||
case 0: |
||||
return 'htw-bg-blue-100'; |
||||
case 1: |
||||
return 'htw-bg-pink-200'; |
||||
case 2: |
||||
return 'htw-bg-green-100'; |
||||
case 3: |
||||
return 'htw-bg-orange-200'; |
||||
case 4: |
||||
return 'htw-bg-violet-200'; |
||||
default: |
||||
return 'htw-bg-gray-100'; |
||||
} |
||||
} |
@ -0,0 +1 @@ |
||||
export const HYPERLANE_EXPLORER_API_URL = 'https://explorer.hyperlane.xyz/api'; |
@ -0,0 +1,30 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
// Paper airplane shape
|
||||
function _AirplaneIcon({ width, height, color, classes }: Props) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 16 16" |
||||
width={width} |
||||
height={height} |
||||
className={classes} |
||||
> |
||||
<path |
||||
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083l6-15Zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471-.47 1.178Z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const AirplaneIcon = memo(_AirplaneIcon); |
@ -0,0 +1,73 @@ |
||||
import React, { ReactElement, useEffect, useState } from 'react'; |
||||
|
||||
import type { IRegistry } from '@hyperlane-xyz/registry'; |
||||
|
||||
import { Circle } from './Circle.js'; |
||||
import { QuestionMarkIcon } from './QuestionMark.js'; |
||||
|
||||
type SvgIcon = (props: { |
||||
width: number; |
||||
height: number; |
||||
title?: string; |
||||
}) => ReactElement; |
||||
|
||||
export interface ChainLogoProps { |
||||
chainName: string; |
||||
registry: IRegistry; |
||||
size?: number; |
||||
background?: boolean; |
||||
Icon?: SvgIcon; // Optional override for the logo in the registry
|
||||
} |
||||
|
||||
export function ChainLogo({ |
||||
chainName, |
||||
registry, |
||||
size = 32, |
||||
background = false, |
||||
Icon, |
||||
}: ChainLogoProps) { |
||||
const title = chainName || 'Unknown'; |
||||
const bgColorSeed = title.charCodeAt(0); |
||||
const iconSize = Math.floor(size / 1.9); |
||||
|
||||
const [svgLogos, setSvgLogos] = useState({}); |
||||
const logoUri = svgLogos[chainName]; |
||||
|
||||
useEffect(() => { |
||||
if (!chainName || svgLogos[chainName] || Icon) return; |
||||
registry |
||||
.getChainLogoUri(chainName) |
||||
.then((uri) => uri && setSvgLogos({ ...svgLogos, [chainName]: uri })) |
||||
.catch((err) => console.error(err)); |
||||
}, [chainName, registry, svgLogos, Icon]); |
||||
|
||||
if (!logoUri && !Icon) { |
||||
return ( |
||||
<Circle size={size} title={title} bgColorSeed={bgColorSeed}> |
||||
{chainName ? ( |
||||
<div style={{ fontSize: iconSize }}>{chainName[0].toUpperCase()}</div> |
||||
) : ( |
||||
<QuestionMarkIcon width={iconSize} height={iconSize} /> |
||||
)} |
||||
</Circle> |
||||
); |
||||
} |
||||
|
||||
if (background) { |
||||
return ( |
||||
<Circle size={size} title={title} classes="htw-bg-gray-100"> |
||||
{Icon ? ( |
||||
<Icon width={iconSize} height={iconSize} title={title} /> |
||||
) : ( |
||||
<img src={logoUri} alt={title} width={iconSize} height={iconSize} /> |
||||
)} |
||||
</Circle> |
||||
); |
||||
} else { |
||||
return Icon ? ( |
||||
<Icon width={size} height={size} title={title} /> |
||||
) : ( |
||||
<img src={logoUri} alt={title} width={size} height={size} /> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,30 @@ |
||||
import React, { PropsWithChildren } from 'react'; |
||||
|
||||
import { seedToBgColor } from '../color.js'; |
||||
|
||||
export function Circle({ |
||||
size, |
||||
title, |
||||
bgColorSeed, |
||||
classes, |
||||
children, |
||||
}: PropsWithChildren<{ |
||||
size: string | number; |
||||
title?: string; |
||||
bgColorSeed?: number; |
||||
classes?: string; |
||||
}>) { |
||||
const bgColor = |
||||
bgColorSeed === null || bgColorSeed == undefined |
||||
? '' |
||||
: seedToBgColor(bgColorSeed); |
||||
return ( |
||||
<div |
||||
style={{ width: `${size}px`, height: `${size}px` }} |
||||
className={`htw-flex htw-items-center htw-justify-center htw-rounded-full htw-transition-all overflow-hidden ${bgColor} ${classes}`} |
||||
title={title} |
||||
> |
||||
{children} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,34 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
// Envelope with checkmark
|
||||
function _EnvelopeIcon({ width, height, color, classes }: Props) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 16 16" |
||||
width={width} |
||||
height={height} |
||||
className={classes} |
||||
> |
||||
<path |
||||
d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586l-1.239-.757ZM16 4.697v4.974A4.491 4.491 0 0 0 12.5 8a4.49 4.49 0 0 0-1.965.45l-.338-.207L16 4.697Z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
<path |
||||
d="M16 12.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Zm-1.993-1.679a.5.5 0 0 0-.686.172l-1.17 1.95-.547-.547a.5.5 0 0 0-.708.708l.774.773a.75.75 0 0 0 1.174-.144l1.335-2.226a.5.5 0 0 0-.172-.686Z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const EnvelopeIcon = memo(_EnvelopeIcon); |
@ -0,0 +1,29 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
function _LockIcon({ width, height, color, classes }: Props) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 15 18" |
||||
width={width} |
||||
height={height} |
||||
className={classes} |
||||
> |
||||
<path |
||||
d="M7.14 1.13c.76 0 1.49.23 2.02.65.54.43.84 1 .84 1.6v4.5H4.29v-4.5c0-.6.3-1.17.83-1.6a3.29 3.29 0 0 1 2.02-.66Zm4.29 6.75v-4.5c0-.9-.45-1.76-1.26-2.4C9.37.37 8.28 0 7.14 0 6.01 0 4.92.36 4.11.99c-.8.63-1.25 1.49-1.25 2.38v4.5c-.76 0-1.49.24-2.02.66-.54.43-.84 1-.84 1.6v5.62c0 .6.3 1.17.84 1.6.53.41 1.26.65 2.02.65h8.57c.76 0 1.48-.24 2.02-.66.53-.42.84-1 .84-1.59v-5.63c0-.6-.3-1.16-.84-1.59a3.29 3.29 0 0 0-2.02-.65Z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const LockIcon = memo(_LockIcon); |
@ -0,0 +1,29 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
function _QuestionMarkIcon({ width, height, color, classes }: Props) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width={width} |
||||
height={height} |
||||
viewBox="13.7 6 20.65 38" |
||||
className={classes} |
||||
> |
||||
<path |
||||
d="M21.55 31.5q.05-3.6.82-5.25.78-1.65 2.93-3.6 2.1-1.9 3.23-3.52t1.12-3.48q0-2.25-1.5-3.75t-4.2-1.5q-2.6 0-4 1.48t-2.05 3.07l-4.2-1.85q1.1-2.95 3.73-5.03T23.95 6q5 0 7.7 2.77t2.7 6.68q0 2.4-1.02 4.35-1.03 1.95-3.28 4.1-2.45 2.35-2.95 3.6t-.55 4Zm2.4 12.5q-1.45 0-2.48-1.02-1.02-1.03-1.02-2.48t1.02-2.48Q22.5 37 23.95 37t2.48 1.02q1.02 1.03 1.02 2.48t-1.02 2.48Q25.4 44 23.95 44Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const QuestionMarkIcon = memo(_QuestionMarkIcon); |
@ -0,0 +1,31 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
interface Props { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
color?: string; |
||||
classes?: string; |
||||
} |
||||
|
||||
// Shield with checkmark
|
||||
function _ShieldIcon({ width, height, color, classes }: Props) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 16 16" |
||||
width={width} |
||||
height={height} |
||||
className={classes} |
||||
> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.777 11.777 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7.159 7.159 0 0 0 1.048-.625 11.775 11.775 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.541 1.541 0 0 0-1.044-1.263 62.467 62.467 0 0 0-2.887-.87C9.843.266 8.69 0 8 0zm2.146 5.146a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const ShieldIcon = memo(_ShieldIcon); |
@ -0,0 +1,71 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
export interface WideChevronProps { |
||||
width?: string | number; |
||||
height?: string | number; |
||||
direction: 'n' | 'e' | 's' | 'w'; |
||||
color?: string; |
||||
rounded?: boolean; |
||||
classes?: string; |
||||
} |
||||
|
||||
function _WideChevron({ |
||||
width, |
||||
height, |
||||
direction, |
||||
color, |
||||
rounded, |
||||
classes, |
||||
}: WideChevronProps) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = 'htw--rotate-90'; |
||||
break; |
||||
case 'e': |
||||
directionClass = ''; |
||||
break; |
||||
case 's': |
||||
directionClass = 'htw-rotate-90'; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'htw-rotate-180'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid chevron direction ${direction}`); |
||||
} |
||||
|
||||
if (rounded) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 120.3 190" |
||||
width={width} |
||||
height={height} |
||||
fill={color || ColorPalette.Blue} |
||||
className={`${directionClass} ${classes}`} |
||||
> |
||||
<path d="M4.4 0h53c7.2 0 13.7 3 16.2 7.7l46.5 85.1a2 2 0 0 1 0 2l-.2.5-46.3 87c-2.5 4.6-9 7.7-16.3 7.7h-53c-3 0-5-2-4-4L48 92.9.4 4c-1-2 1-4 4-4Z" /> |
||||
</svg> |
||||
); |
||||
} else { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 28 27" |
||||
width={width} |
||||
height={height} |
||||
className={`${directionClass} ${classes}`} |
||||
> |
||||
<path |
||||
d="M13.44 13.5 0 27h14.56L28 13.5 14.56 0H0l13.44 13.5Z" |
||||
fill={color || ColorPalette.Blue} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export const WideChevron = memo(_WideChevron); |
@ -0,0 +1,15 @@ |
||||
export { ColorPalette, seedToBgColor } from './color.js'; |
||||
export * from './consts.js'; |
||||
export { ChainLogo } from './icons/ChainLogo.js'; |
||||
export { Circle } from './icons/Circle.js'; |
||||
export { WideChevron } from './icons/WideChevron.js'; |
||||
export { MessageTimeline } from './messages/MessageTimeline.js'; |
||||
export { |
||||
MessageStage, |
||||
MessageStatus, |
||||
type ApiMessage, |
||||
type StageTimings, |
||||
} from './messages/types.js'; |
||||
export { useMessage } from './messages/useMessage.js'; |
||||
export { useMessageStage } from './messages/useMessageStage.js'; |
||||
export { useMessageTimeline } from './messages/useMessageTimeline.js'; |
@ -0,0 +1,225 @@ |
||||
import React from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { AirplaneIcon } from '../icons/Airplane.js'; |
||||
import { EnvelopeIcon } from '../icons/Envelope.js'; |
||||
import { LockIcon } from '../icons/Lock.js'; |
||||
import { ShieldIcon } from '../icons/Shield.js'; |
||||
import { WideChevron } from '../icons/WideChevron.js'; |
||||
|
||||
import { MessageStatus, MessageStage as Stage, StageTimings } from './types.js'; |
||||
|
||||
interface Props { |
||||
status: MessageStatus; |
||||
stage: Stage; |
||||
timings: StageTimings; |
||||
timestampSent?: number; |
||||
hideDescriptions?: boolean; |
||||
} |
||||
|
||||
export function MessageTimeline({ |
||||
status, |
||||
stage: _stage, |
||||
timings, |
||||
timestampSent, |
||||
hideDescriptions, |
||||
}: Props) { |
||||
// Ignore stage value if status shows as delivered
|
||||
const stage = status === MessageStatus.Delivered ? Stage.Relayed : _stage; |
||||
|
||||
const timeSent = timestampSent ? new Date(timestampSent) : null; |
||||
const timeSentStr = timeSent |
||||
? `${timeSent.toLocaleDateString()} ${timeSent.toLocaleTimeString()}` |
||||
: null; |
||||
|
||||
return ( |
||||
<div className="htw-pt-14 htw-pb-1 htw-flex htw-w-full"> |
||||
<div className={styles.stageContainer}> |
||||
<div |
||||
className={`${styles.stageBar} htw-rounded-l ${getStageOpacityClass( |
||||
Stage.Sent, |
||||
stage, |
||||
status, |
||||
)}`}
|
||||
> |
||||
<div className={styles.stageHole}></div> |
||||
<div className={styles.stageIconContainer}> |
||||
<StageIcon Icon={AirplaneIcon} /> |
||||
<div className={styles.stageIconCircle}></div> |
||||
</div> |
||||
<ChevronBlue /> |
||||
</div> |
||||
<h4 className={styles.stageHeader}> |
||||
{getStageHeader(Stage.Sent, stage, timings, status)} |
||||
</h4> |
||||
{!hideDescriptions && ( |
||||
<p className={styles.stageDesc}> |
||||
{timeSentStr |
||||
? `Origin transaction sent at ${timeSentStr}` |
||||
: 'Waiting for origin transaction'} |
||||
</p> |
||||
)} |
||||
</div> |
||||
<div className={styles.stageSpacer}></div> |
||||
<div className={styles.stageContainer}> |
||||
<div |
||||
className={`${styles.stageBar} ${getStageOpacityClass( |
||||
Stage.Finalized, |
||||
stage, |
||||
status, |
||||
)}`}
|
||||
> |
||||
<div className={styles.stageHole}></div> |
||||
<div className={styles.stageIconContainer}> |
||||
<StageIcon Icon={LockIcon} size={14} /> |
||||
<div className={styles.stageIconCircle}></div> |
||||
</div> |
||||
<ChevronWhite /> |
||||
<ChevronBlue /> |
||||
</div> |
||||
<h4 className={styles.stageHeader}> |
||||
{getStageHeader(Stage.Finalized, stage, timings, status)} |
||||
</h4> |
||||
{!hideDescriptions && ( |
||||
<p className={styles.stageDesc}> |
||||
Origin transaction has sufficient confirmations |
||||
</p> |
||||
)} |
||||
</div> |
||||
<div className={styles.stageSpacer}></div> |
||||
<div className={styles.stageContainer}> |
||||
<div |
||||
className={`${styles.stageBar} ${getStageOpacityClass( |
||||
Stage.Validated, |
||||
stage, |
||||
status, |
||||
)}`}
|
||||
> |
||||
<div className={styles.stageHole}></div> |
||||
<div className={styles.stageIconContainer}> |
||||
<StageIcon Icon={ShieldIcon} /> |
||||
<div className={styles.stageIconCircle}></div> |
||||
</div> |
||||
<ChevronWhite /> |
||||
<ChevronBlue /> |
||||
</div> |
||||
<h4 className={styles.stageHeader}> |
||||
{getStageHeader(Stage.Validated, stage, timings, status)} |
||||
</h4> |
||||
{!hideDescriptions && ( |
||||
<p className={styles.stageDesc}> |
||||
Validators have signed the message bundle |
||||
</p> |
||||
)} |
||||
</div> |
||||
<div className={styles.stageSpacer}></div> |
||||
<div className={styles.stageContainer}> |
||||
<div |
||||
className={`${styles.stageBar} htw-rounded-r ${getStageOpacityClass( |
||||
Stage.Relayed, |
||||
stage, |
||||
status, |
||||
)}`}
|
||||
> |
||||
<div className={styles.stageHole}></div> |
||||
<div className={styles.stageIconContainer}> |
||||
<StageIcon Icon={EnvelopeIcon} /> |
||||
<div className={styles.stageIconCircle}></div> |
||||
</div> |
||||
<ChevronWhite /> |
||||
</div> |
||||
<h4 className={styles.stageHeader}> |
||||
{getStageHeader(Stage.Relayed, stage, timings, status)} |
||||
</h4> |
||||
{!hideDescriptions && ( |
||||
<p className={styles.stageDesc}> |
||||
Destination transaction has been confirmed |
||||
</p> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function StageIcon({ Icon, size }: { Icon: any; size?: number }) { |
||||
return ( |
||||
<div className="htw-h-9 htw-w-9 htw-flex htw-items-center htw-justify-center htw-rounded-full htw-bg-blue-500"> |
||||
<Icon |
||||
width={size ?? 14} |
||||
height={size ?? 14} |
||||
alt="" |
||||
color={ColorPalette.White} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ChevronWhite() { |
||||
return ( |
||||
<div className="htw-absolute htw--left-3 htw-top-0 htw-h-6"> |
||||
<WideChevron direction="e" height="100%" width="auto" color="#ffffff" /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ChevronBlue() { |
||||
return ( |
||||
<div className="htw-absolute htw--right-3 htw-top-0 htw-h-6"> |
||||
<WideChevron direction="e" height="100%" width="auto" /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getStageHeader( |
||||
targetStage: Stage, |
||||
currentStage: Stage, |
||||
timings: StageTimings, |
||||
status: MessageStatus, |
||||
) { |
||||
let label = ''; |
||||
if (targetStage === Stage.Finalized) { |
||||
label = currentStage >= targetStage ? 'Finalized' : 'Finalizing'; |
||||
} else if (targetStage === Stage.Validated) { |
||||
label = currentStage >= targetStage ? 'Validated' : 'Validating'; |
||||
} else if (targetStage === Stage.Relayed) { |
||||
label = currentStage >= targetStage ? 'Relayed' : 'Relaying'; |
||||
} else if (targetStage === Stage.Sent) { |
||||
label = currentStage >= targetStage ? 'Sent' : 'Sending'; |
||||
} |
||||
const timing = timings[targetStage]; |
||||
if (status === MessageStatus.Failing) { |
||||
if (targetStage === currentStage + 1) return `${label}: failed`; |
||||
if (targetStage > currentStage + 1) return label; |
||||
} |
||||
if (timing) return `${label}: ${timing} sec`; |
||||
else return label; |
||||
} |
||||
|
||||
function getStageOpacityClass( |
||||
targetStage: Stage, |
||||
currentStage: Stage, |
||||
messageStatus: MessageStatus, |
||||
) { |
||||
if (currentStage >= targetStage) return ''; |
||||
if ( |
||||
currentStage === targetStage - 1 && |
||||
messageStatus !== MessageStatus.Failing |
||||
) |
||||
return 'htw-animate-pulse-slow'; |
||||
return 'htw-opacity-50'; |
||||
} |
||||
|
||||
const styles = { |
||||
stageContainer: 'htw-flex-1 htw-flex htw-flex-col htw-items-center', |
||||
stageSpacer: 'htw-flex-0 htw-w-1 xs:htw-w-2 sm:htw-w-3', |
||||
stageBar: |
||||
'htw-w-full htw-h-6 htw-flex htw-items-center htw-justify-center htw-bg-blue-500 htw-relative', |
||||
stageHole: 'htw-w-3 htw-h-3 htw-rounded-full htw-bg-white', |
||||
stageIconContainer: |
||||
'htw-absolute htw--top-12 htw-flex htw-flex-col htw-items-center', |
||||
stageIconCircle: 'htw-w-0.5 htw-h-4 htw-bg-blue-500', |
||||
stageHeader: |
||||
'htw-mt-2.5 htw-text-gray-700 htw-text-xs xs:htw-text-sm sm:htw-text-base', |
||||
stageDesc: |
||||
'htw-mt-1 sm:htw-px-4 htw-text-xs htw-text-gray-500 htw-text-center', |
||||
}; |
@ -0,0 +1,86 @@ |
||||
// TODO DE-DUPE WITH EXPLORER
|
||||
// Mostly copied from explorer src/types.ts
|
||||
export enum MessageStatus { |
||||
Unknown = 'unknown', |
||||
Pending = 'pending', |
||||
Delivered = 'delivered', |
||||
Failing = 'failing', |
||||
} |
||||
|
||||
export interface MessageTxStub { |
||||
timestamp: number; |
||||
hash: string; |
||||
from: Address; |
||||
} |
||||
|
||||
export interface MessageTx extends MessageTxStub { |
||||
to: Address; |
||||
blockHash: string; |
||||
blockNumber: number; |
||||
mailbox: Address; |
||||
nonce: number; |
||||
gasLimit: number; |
||||
gasPrice: number; |
||||
effectiveGasPrice; |
||||
gasUsed: number; |
||||
cumulativeGasUsed: number; |
||||
maxFeePerGas: number; |
||||
maxPriorityPerGas: number; |
||||
} |
||||
|
||||
export interface MessageStub { |
||||
status: MessageStatus; |
||||
id: string; // Database id
|
||||
msgId: string; // Message hash
|
||||
nonce: number; // formerly leafIndex
|
||||
sender: Address; |
||||
recipient: Address; |
||||
originChainId: number; |
||||
originDomainId: number; |
||||
destinationChainId: number; |
||||
destinationDomainId: number; |
||||
origin: MessageTxStub; |
||||
destination?: MessageTxStub; |
||||
isPiMsg?: boolean; |
||||
} |
||||
|
||||
export interface Message extends MessageStub { |
||||
body: string; |
||||
decodedBody?: string; |
||||
origin: MessageTx; |
||||
destination?: MessageTx; |
||||
totalGasAmount?: string; |
||||
totalPayment?: string; |
||||
numPayments?: number; |
||||
} |
||||
|
||||
export type ApiMessage = Omit< |
||||
Message, |
||||
| 'msgId' // use id field for msgId
|
||||
| 'decodedBody' |
||||
>; |
||||
|
||||
export interface PartialMessage { |
||||
status: MessageStatus; |
||||
nonce: number; |
||||
originChainId: number; |
||||
originDomainId: number; |
||||
destinationChainId: number; |
||||
destinationDomainId: number; |
||||
origin: { blockNumber: number; timestamp: number }; |
||||
destination?: { blockNumber: number; timestamp: number }; |
||||
} |
||||
|
||||
export enum MessageStage { |
||||
Preparing = 0, |
||||
Sent = 1, |
||||
Finalized = 2, |
||||
Validated = 3, |
||||
Relayed = 4, |
||||
} |
||||
|
||||
export type StageTimings = { |
||||
[MessageStage.Finalized]: number | null; |
||||
[MessageStage.Validated]: number | null; |
||||
[MessageStage.Relayed]: number | null; |
||||
}; |
@ -0,0 +1,78 @@ |
||||
import { useCallback, useState } from 'react'; |
||||
|
||||
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js'; |
||||
import { executeExplorerQuery } from '../utils/explorers.js'; |
||||
import { useInterval } from '../utils/useInterval.js'; |
||||
|
||||
import { ApiMessage, MessageStatus } from './types.js'; |
||||
|
||||
interface Params { |
||||
messageId?: string; |
||||
originTxHash?: string; |
||||
explorerApiUrl?: string; |
||||
retryInterval?: number; |
||||
} |
||||
|
||||
// Queries Explorer API to get data for message
|
||||
// Requires either messageId or originTxHash
|
||||
export function useMessage({ |
||||
messageId, |
||||
originTxHash, |
||||
explorerApiUrl = HYPERLANE_EXPLORER_API_URL, |
||||
retryInterval = 2000, |
||||
}: Params) { |
||||
// Tempting to use react-query here as we did in Explorer but
|
||||
// avoiding for now to keep dependencies for this lib minimal
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [error, setError] = useState<string | null>(null); |
||||
const [data, setData] = useState<ApiMessage | null>(null); |
||||
|
||||
const fetcher = useCallback(() => { |
||||
// Skip if message is already fetched and delivered
|
||||
if (data?.status === MessageStatus.Delivered) return; |
||||
|
||||
setIsLoading(true); |
||||
fetchMessage(explorerApiUrl, messageId, originTxHash) |
||||
.then((result) => { |
||||
setData(result); |
||||
setError(null); |
||||
}) |
||||
.catch((e) => setError(e.toString())) |
||||
.finally(() => setIsLoading(false)); |
||||
}, [messageId, originTxHash, data]); |
||||
|
||||
useInterval(fetcher, retryInterval); |
||||
|
||||
return { |
||||
data, |
||||
isLoading, |
||||
error, |
||||
}; |
||||
} |
||||
|
||||
async function fetchMessage( |
||||
explorerApiUrl: string, |
||||
messageId?: string, |
||||
originTxHash?: string, |
||||
): Promise<ApiMessage | null> { |
||||
if (!explorerApiUrl) throw new Error('Explorer API URL required'); |
||||
if (!messageId && !originTxHash) |
||||
throw new Error('Either messageId or originTxHash required'); |
||||
|
||||
let url = `${explorerApiUrl}?module=message&action=get-messages`; |
||||
if (messageId) url += `&id=${messageId}`; |
||||
else if (originTxHash) url += `&origin-tx-hash=${originTxHash}`; |
||||
const result = await executeExplorerQuery<ApiMessage[]>(url, 5000); |
||||
|
||||
if (result.length > 1) { |
||||
console.warn('More than one message received, should not occur'); |
||||
return result[0]; |
||||
} else if (result.length === 1) { |
||||
console.debug('Message data found, id:', result[0].id); |
||||
return result[0]; |
||||
} else { |
||||
console.debug('Message data not found'); |
||||
return null; |
||||
} |
||||
} |
@ -0,0 +1,248 @@ |
||||
import { useCallback, useState } from 'react'; |
||||
|
||||
import type { MultiProvider } from '@hyperlane-xyz/sdk'; |
||||
|
||||
import { HYPERLANE_EXPLORER_API_URL } from '../consts.js'; |
||||
import { queryExplorerForBlock } from '../utils/explorers.js'; |
||||
import { fetchWithTimeout } from '../utils/timeout.js'; |
||||
import { useInterval } from '../utils/useInterval.js'; |
||||
|
||||
import { |
||||
MessageStatus, |
||||
PartialMessage, |
||||
MessageStage as Stage, |
||||
StageTimings, |
||||
} from './types.js'; |
||||
|
||||
const VALIDATION_TIME_EST = 5; |
||||
const DEFAULT_BLOCK_TIME_EST = 3; |
||||
const DEFAULT_FINALITY_BLOCKS = 3; |
||||
|
||||
interface Params { |
||||
message: PartialMessage | null | undefined; |
||||
multiProvider: MultiProvider; |
||||
explorerApiUrl?: string; |
||||
retryInterval?: number; |
||||
} |
||||
|
||||
const defaultTiming: StageTimings = { |
||||
[Stage.Finalized]: null, |
||||
[Stage.Validated]: null, |
||||
[Stage.Relayed]: null, |
||||
}; |
||||
|
||||
export function useMessageStage({ |
||||
message, |
||||
multiProvider, |
||||
explorerApiUrl = HYPERLANE_EXPLORER_API_URL, |
||||
retryInterval = 2000, |
||||
}: Params) { |
||||
// Tempting to use react-query here as we did in Explorer but
|
||||
// avoiding for now to keep dependencies for this lib minimal
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [error, setError] = useState<string | null>(null); |
||||
const [data, setData] = useState<{ |
||||
stage: Stage; |
||||
timings: StageTimings; |
||||
} | null>(null); |
||||
|
||||
const fetcher = useCallback(() => { |
||||
// Skip invalid or placeholder messages
|
||||
if (!isValidMessage(message)) return; |
||||
// Don't re-run for failing messages
|
||||
if (message.status === MessageStatus.Failing && data) return; |
||||
// Don't re-run for pending, validated messages
|
||||
if ( |
||||
message.status === MessageStatus.Pending && |
||||
data?.stage === Stage.Validated |
||||
) |
||||
return; |
||||
|
||||
setIsLoading(true); |
||||
fetchMessageState(message, multiProvider, explorerApiUrl) |
||||
.then((result) => { |
||||
setData(result); |
||||
setError(null); |
||||
}) |
||||
.catch((e) => setError(e.toString())) |
||||
.finally(() => setIsLoading(false)); |
||||
}, [message, data]); |
||||
|
||||
useInterval(fetcher, retryInterval); |
||||
|
||||
return { |
||||
stage: data?.stage |
||||
? data.stage |
||||
: isValidMessage(message) |
||||
? Stage.Sent |
||||
: Stage.Preparing, |
||||
timings: data?.timings ? data.timings : defaultTiming, |
||||
isLoading, |
||||
error, |
||||
}; |
||||
} |
||||
|
||||
async function fetchMessageState( |
||||
message: PartialMessage, |
||||
multiProvider: MultiProvider, |
||||
explorerApiUrl: string, |
||||
) { |
||||
const { |
||||
status, |
||||
nonce, |
||||
originDomainId, |
||||
destinationDomainId, |
||||
origin, |
||||
destination, |
||||
} = message; |
||||
const { blockNumber: originBlockNumber, timestamp: originTimestamp } = origin; |
||||
const destTimestamp = destination?.timestamp; |
||||
|
||||
const relayEstimate = Math.floor( |
||||
(await getBlockTimeEst(destinationDomainId, multiProvider)) * 1.5, |
||||
); |
||||
const finalityBlocks = await getFinalityBlocks(originDomainId, multiProvider); |
||||
const finalityEstimate = |
||||
finalityBlocks * (await getBlockTimeEst(originDomainId, multiProvider)); |
||||
|
||||
if (status === MessageStatus.Delivered && destTimestamp) { |
||||
// For delivered messages, just to rough estimates for stages
|
||||
// This saves us from making extra explorer calls. May want to revisit in future
|
||||
const totalDuration = Math.round((destTimestamp - originTimestamp) / 1000); |
||||
const finalityDuration = Math.max( |
||||
Math.min(finalityEstimate, totalDuration - VALIDATION_TIME_EST), |
||||
1, |
||||
); |
||||
const remaining = totalDuration - finalityDuration; |
||||
const validateDuration = Math.max( |
||||
Math.min(Math.round(remaining * 0.25), VALIDATION_TIME_EST), |
||||
1, |
||||
); |
||||
const relayDuration = Math.max(remaining - validateDuration, 1); |
||||
return { |
||||
stage: Stage.Relayed, |
||||
timings: { |
||||
[Stage.Finalized]: finalityDuration, |
||||
[Stage.Validated]: validateDuration, |
||||
[Stage.Relayed]: relayDuration, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
const latestNonce = await tryFetchLatestNonce( |
||||
originDomainId, |
||||
multiProvider, |
||||
explorerApiUrl, |
||||
); |
||||
if (latestNonce && latestNonce >= nonce) { |
||||
return { |
||||
stage: Stage.Validated, |
||||
timings: { |
||||
[Stage.Finalized]: finalityEstimate, |
||||
[Stage.Validated]: VALIDATION_TIME_EST, |
||||
[Stage.Relayed]: relayEstimate, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
const latestBlock = await tryFetchChainLatestBlock( |
||||
originDomainId, |
||||
multiProvider, |
||||
); |
||||
const finalizedBlock = originBlockNumber + finalityBlocks; |
||||
if (latestBlock && parseInt(latestBlock.number.toString()) > finalizedBlock) { |
||||
return { |
||||
stage: Stage.Finalized, |
||||
timings: { |
||||
[Stage.Finalized]: finalityEstimate, |
||||
[Stage.Validated]: VALIDATION_TIME_EST, |
||||
[Stage.Relayed]: relayEstimate, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
stage: Stage.Sent, |
||||
timings: { |
||||
[Stage.Finalized]: finalityEstimate, |
||||
[Stage.Validated]: VALIDATION_TIME_EST, |
||||
[Stage.Relayed]: relayEstimate, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
async function getFinalityBlocks( |
||||
domainId: number, |
||||
multiProvider: MultiProvider, |
||||
) { |
||||
const metadata = await multiProvider.getChainMetadata(domainId); |
||||
if (metadata?.blocks?.confirmations) return metadata.blocks.confirmations; |
||||
else return DEFAULT_FINALITY_BLOCKS; |
||||
} |
||||
|
||||
async function getBlockTimeEst(domainId: number, multiProvider: MultiProvider) { |
||||
const metadata = await multiProvider.getChainMetadata(domainId); |
||||
return metadata?.blocks?.estimateBlockTime || DEFAULT_BLOCK_TIME_EST; |
||||
} |
||||
|
||||
async function tryFetchChainLatestBlock( |
||||
domainId: number, |
||||
multiProvider: MultiProvider, |
||||
) { |
||||
const metadata = multiProvider.tryGetChainMetadata(domainId); |
||||
if (!metadata) return null; |
||||
console.debug(`Attempting to fetch latest block for:`, metadata.name); |
||||
try { |
||||
const block = await queryExplorerForBlock( |
||||
metadata.name, |
||||
multiProvider, |
||||
'latest', |
||||
); |
||||
return block; |
||||
} catch (error) { |
||||
console.error('Error fetching latest block', error); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
async function tryFetchLatestNonce( |
||||
domainId: number, |
||||
multiProvider: MultiProvider, |
||||
explorerApiUrl: string, |
||||
) { |
||||
const metadata = multiProvider.tryGetChainMetadata(domainId); |
||||
if (!metadata) return null; |
||||
console.debug(`Attempting to fetch nonce for:`, metadata.name); |
||||
try { |
||||
const response = await fetchWithTimeout( |
||||
`${explorerApiUrl}/latest-nonce`, |
||||
{ |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
body: JSON.stringify({ chainId: metadata.chainId }), |
||||
}, |
||||
3000, |
||||
); |
||||
const result = await response.json(); |
||||
console.debug(`Found nonce:`, result.nonce); |
||||
return result.nonce; |
||||
} catch (error) { |
||||
console.error('Error fetching nonce', error); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
function isValidMessage( |
||||
message: PartialMessage | undefined | null, |
||||
): message is PartialMessage { |
||||
return !!( |
||||
message && |
||||
message.originChainId && |
||||
message.destinationChainId && |
||||
message.originDomainId && |
||||
message.destinationDomainId |
||||
); |
||||
} |
@ -0,0 +1,37 @@ |
||||
import type { MultiProvider } from '@hyperlane-xyz/sdk'; |
||||
|
||||
import { useMessage } from './useMessage.js'; |
||||
import { useMessageStage } from './useMessageStage.js'; |
||||
|
||||
interface Params { |
||||
messageId?: string; |
||||
multiProvider: MultiProvider; |
||||
originTxHash?: string; |
||||
explorerApiUrl?: string; |
||||
retryInterval?: number; |
||||
} |
||||
|
||||
export function useMessageTimeline(params: Params) { |
||||
const { |
||||
data: message, |
||||
error: msgError, |
||||
isLoading: isMsgLoading, |
||||
} = useMessage(params); |
||||
const { |
||||
stage, |
||||
timings, |
||||
error: stageError, |
||||
isLoading: isStageLoading, |
||||
} = useMessageStage({ |
||||
message, |
||||
multiProvider: params.multiProvider, |
||||
retryInterval: params.retryInterval, |
||||
}); |
||||
return { |
||||
message, |
||||
stage, |
||||
timings, |
||||
error: msgError || stageError, |
||||
isLoading: isMsgLoading || isStageLoading, |
||||
}; |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React from 'react'; |
||||
|
||||
import { GithubRegistry } from '@hyperlane-xyz/registry'; |
||||
|
||||
import { ChainLogo } from '../icons/ChainLogo.js'; |
||||
|
||||
export default { |
||||
title: 'ChainLogo', |
||||
component: ChainLogo, |
||||
} as ComponentMeta<typeof ChainLogo>; |
||||
|
||||
const Template: ComponentStory<typeof ChainLogo> = (args) => ( |
||||
<ChainLogo {...args} /> |
||||
); |
||||
|
||||
const registry = new GithubRegistry(); |
||||
|
||||
export const ChainNoBackground = Template.bind({}); |
||||
ChainNoBackground.args = { |
||||
chainName: 'ethereum', |
||||
background: false, |
||||
registry, |
||||
}; |
||||
|
||||
export const ChainWithBackground = Template.bind({}); |
||||
ChainWithBackground.args = { |
||||
chainName: 'ethereum', |
||||
background: true, |
||||
registry, |
||||
}; |
||||
|
||||
export const ChainWithBigSize = Template.bind({}); |
||||
ChainWithBigSize.args = { |
||||
chainName: 'ethereum', |
||||
size: 100, |
||||
registry, |
||||
}; |
||||
|
||||
export const ChainWithBackgrounAndBig = Template.bind({}); |
||||
ChainWithBackgrounAndBig.args = { |
||||
chainName: 'ethereum', |
||||
size: 100, |
||||
background: true, |
||||
registry, |
||||
}; |
||||
|
||||
export const JustChainName = Template.bind({}); |
||||
JustChainName.args = { |
||||
chainName: 'ethereum', |
||||
registry, |
||||
}; |
||||
|
||||
export const FakeChainName = Template.bind({}); |
||||
FakeChainName.args = { |
||||
chainName: 'myfakechain', |
||||
registry, |
||||
}; |
@ -0,0 +1,70 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React from 'react'; |
||||
|
||||
import { MessageTimeline } from '../messages/MessageTimeline.js'; |
||||
import { MessageStage, MessageStatus } from '../messages/types.js'; |
||||
|
||||
export default { |
||||
title: 'MessageTimeline', |
||||
component: MessageTimeline, |
||||
} as ComponentMeta<typeof MessageTimeline>; |
||||
|
||||
const Template: ComponentStory<typeof MessageTimeline> = (args) => ( |
||||
<MessageTimeline {...args} /> |
||||
); |
||||
|
||||
const defaultTimings = { |
||||
[MessageStage.Finalized]: 10, |
||||
[MessageStage.Validated]: 5, |
||||
[MessageStage.Relayed]: 8, |
||||
}; |
||||
const defaultTimeSent = Date.now() - 10_000; |
||||
|
||||
export const TimelinePreparing = Template.bind({}); |
||||
TimelinePreparing.args = { |
||||
status: MessageStatus.Pending, |
||||
stage: MessageStage.Preparing, |
||||
timings: {}, |
||||
timestampSent: undefined, |
||||
}; |
||||
|
||||
export const TimelineOriginSent = Template.bind({}); |
||||
TimelineOriginSent.args = { |
||||
status: MessageStatus.Pending, |
||||
stage: MessageStage.Sent, |
||||
timings: defaultTimings, |
||||
timestampSent: defaultTimeSent, |
||||
}; |
||||
|
||||
export const TimelineOriginFinalized = Template.bind({}); |
||||
TimelineOriginFinalized.args = { |
||||
status: MessageStatus.Pending, |
||||
stage: MessageStage.Finalized, |
||||
timings: defaultTimings, |
||||
timestampSent: defaultTimeSent, |
||||
}; |
||||
|
||||
export const TimelineOriginValidated = Template.bind({}); |
||||
TimelineOriginValidated.args = { |
||||
status: MessageStatus.Pending, |
||||
stage: MessageStage.Validated, |
||||
timings: defaultTimings, |
||||
timestampSent: defaultTimeSent, |
||||
}; |
||||
|
||||
export const TimelineOriginDelivered = Template.bind({}); |
||||
TimelineOriginDelivered.args = { |
||||
status: MessageStatus.Delivered, |
||||
stage: MessageStage.Preparing, |
||||
timings: defaultTimings, |
||||
timestampSent: defaultTimeSent, |
||||
}; |
||||
|
||||
export const TimelineHideDesc = Template.bind({}); |
||||
TimelineHideDesc.args = { |
||||
status: MessageStatus.Pending, |
||||
stage: MessageStage.Sent, |
||||
timings: defaultTimings, |
||||
timestampSent: defaultTimeSent, |
||||
hideDescriptions: true, |
||||
}; |
@ -0,0 +1,32 @@ |
||||
import { ComponentMeta, ComponentStory } from '@storybook/react'; |
||||
import React from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { WideChevron } from '../icons/WideChevron.js'; |
||||
|
||||
export default { |
||||
title: 'WideChevron', |
||||
component: WideChevron, |
||||
} as ComponentMeta<typeof WideChevron>; |
||||
|
||||
const Template: ComponentStory<typeof WideChevron> = (args) => ( |
||||
<WideChevron {...args} /> |
||||
); |
||||
|
||||
export const BlueEastRounded = Template.bind({}); |
||||
BlueEastRounded.args = { |
||||
color: ColorPalette.Blue, |
||||
direction: 'e', |
||||
rounded: true, |
||||
width: 50, |
||||
height: 150, |
||||
}; |
||||
|
||||
export const BlackSouthUnrounded = Template.bind({}); |
||||
BlackSouthUnrounded.args = { |
||||
color: ColorPalette.Black, |
||||
direction: 's', |
||||
rounded: false, |
||||
width: 50, |
||||
height: 150, |
||||
}; |
@ -0,0 +1,3 @@ |
||||
@tailwind base; |
||||
@tailwind components; |
||||
@tailwind utilities; |
@ -0,0 +1,6 @@ |
||||
declare module '*.svg' { |
||||
const content: string; |
||||
export default content; |
||||
} |
||||
|
||||
declare type Address = string; |
@ -0,0 +1,84 @@ |
||||
import type { MultiProvider } from '@hyperlane-xyz/sdk'; |
||||
|
||||
import { fetchWithTimeout } from './timeout.js'; |
||||
|
||||
export interface ExplorerQueryResponse<R> { |
||||
status: string; |
||||
message: string; |
||||
result: R; |
||||
} |
||||
|
||||
export async function getExplorerApiUrl( |
||||
chainName: string, |
||||
multiProvider: MultiProvider, |
||||
) { |
||||
const metadata = await multiProvider.getChainMetadata(chainName); |
||||
const blockExplorers = metadata?.blockExplorers; |
||||
if (!blockExplorers?.length) return null; |
||||
return blockExplorers[0].apiUrl || blockExplorers[0].url; |
||||
} |
||||
|
||||
export async function queryExplorer<P>( |
||||
chainName: string, |
||||
multiProvider: MultiProvider, |
||||
path: string, |
||||
apiKey?: string, |
||||
timeout?: number, |
||||
) { |
||||
const baseUrl = getExplorerApiUrl(chainName, multiProvider); |
||||
if (!baseUrl) |
||||
throw new Error(`No URL found for explorer for chain ${chainName}`); |
||||
|
||||
let url = `${baseUrl}/${path}`; |
||||
console.debug('Querying explorer url:', url); |
||||
|
||||
if (apiKey) { |
||||
url += `&apikey=${apiKey}`; |
||||
} |
||||
|
||||
const result = await executeExplorerQuery<P>(url, timeout); |
||||
return result; |
||||
} |
||||
|
||||
export async function executeExplorerQuery<P>(url: string, timeout?: number) { |
||||
const response = await fetchWithTimeout(url, undefined, timeout); |
||||
if (!response.ok) { |
||||
throw new Error(`Fetch response not okay: ${response.status}`); |
||||
} |
||||
const json = (await response.json()) as ExplorerQueryResponse<P>; |
||||
|
||||
if (!json.result) { |
||||
const responseText = await response.text(); |
||||
throw new Error(`Invalid result format: ${responseText}`); |
||||
} |
||||
|
||||
return json.result; |
||||
} |
||||
|
||||
interface PartialBlock { |
||||
hash: string; |
||||
number: number; |
||||
timestamp: number; |
||||
nonce: string; |
||||
} |
||||
|
||||
export async function queryExplorerForBlock( |
||||
chainName: string, |
||||
multiProvider: MultiProvider, |
||||
blockNumber?: number | string, |
||||
) { |
||||
const path = `?module=proxy&action=eth_getBlockByNumber&tag=${ |
||||
blockNumber || 'latest' |
||||
}&boolean=false`;
|
||||
const block = await queryExplorer<PartialBlock>( |
||||
chainName, |
||||
multiProvider, |
||||
path, |
||||
); |
||||
if (!block?.number || parseInt(block.number.toString()) < 0) { |
||||
const msg = 'Invalid block result'; |
||||
console.error(msg, JSON.stringify(block), path); |
||||
throw new Error(msg); |
||||
} |
||||
return block; |
||||
} |
@ -0,0 +1,14 @@ |
||||
export async function fetchWithTimeout( |
||||
resource: RequestInfo, |
||||
options?: RequestInit, |
||||
timeout = 10000, |
||||
) { |
||||
const controller = new AbortController(); |
||||
const id = setTimeout(() => controller.abort(), timeout); |
||||
const response = await fetch(resource, { |
||||
...options, |
||||
signal: controller.signal, |
||||
}); |
||||
clearTimeout(id); |
||||
return response; |
||||
} |
@ -0,0 +1,27 @@ |
||||
import { useEffect, useLayoutEffect, useRef } from 'react'; |
||||
|
||||
const useIsomorphicLayoutEffect = |
||||
typeof window !== 'undefined' ? useLayoutEffect : useEffect; |
||||
|
||||
// https://usehooks-typescript.com/react-hook/use-interval
|
||||
export function useInterval(callback: () => void, delay: number | null) { |
||||
const savedCallback = useRef(callback); |
||||
|
||||
// Remember the latest callback if it changes.
|
||||
useIsomorphicLayoutEffect(() => { |
||||
savedCallback.current = callback; |
||||
}, [callback]); |
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => { |
||||
// Don't schedule if no delay is specified.
|
||||
// Note: 0 is a valid value for delay.
|
||||
if (!delay && delay !== 0) { |
||||
return; |
||||
} |
||||
|
||||
const id = setInterval(() => savedCallback.current(), delay); |
||||
|
||||
return () => clearInterval(id); |
||||
}, [delay]); |
||||
} |
@ -0,0 +1,108 @@ |
||||
/** @type {import('tailwindcss').Config} */ |
||||
|
||||
const defaultTheme = require('tailwindcss/defaultTheme') |
||||
|
||||
module.exports = { |
||||
content: ['./src/**/*.{js,ts,jsx,tsx}'], |
||||
prefix: 'htw-', |
||||
theme: { |
||||
fontFamily: { |
||||
sans: ['Helvetica', 'Arial', 'sans-serif'], |
||||
serif: ['Garamond', 'serif'], |
||||
mono: ['Courier New', 'monospace'], |
||||
}, |
||||
screens: { |
||||
xs: '480px', |
||||
...defaultTheme.screens, |
||||
}, |
||||
extend: { |
||||
colors: { |
||||
black: '#010101', |
||||
white: '#ffffff', |
||||
blue: { |
||||
50: '#E6EDF9', |
||||
100: '#CDDCF4', |
||||
200: '#A7C2EC', |
||||
300: '#82A8E4', |
||||
400: '#5385D2', |
||||
500: '#2362C0', |
||||
600: '#1D4685', |
||||
700: '#162A4A', |
||||
800: '#11213B', |
||||
900: '#0D192C', |
||||
}, |
||||
beige: { |
||||
100: '#F6F4F1', |
||||
200: '#F5F2EF', |
||||
300: '#F3F0ED', |
||||
400: '#F2EEEB', |
||||
500: '#F1EDE9', |
||||
600: '#D8D5D1', |
||||
700: '#C0BDBA', |
||||
800: '#A8A5A3', |
||||
900: '#908E8B', |
||||
}, |
||||
red: { |
||||
100: '#EBBAB8', |
||||
200: '#DF8D8A', |
||||
300: '#D25F5B', |
||||
400: '#C5312C', |
||||
500: '#BF1B15', |
||||
600: '#AB1812', |
||||
700: '#85120E', |
||||
800: '#5F0D0A', |
||||
900: '#390806', |
||||
}, |
||||
green: { |
||||
50: '#D3E3DB', |
||||
100: '#BED5C9', |
||||
200: '#93BAA6', |
||||
300: '#679F82', |
||||
400: '#3C835E', |
||||
500: '#27764d', |
||||
600: '#236A45', |
||||
700: '#1F5E3D', |
||||
800: '#17462E', |
||||
900: '#0F2F1E', |
||||
}, |
||||
pink: { |
||||
50: '#FAEAF7', |
||||
100: '#F0C0E8', |
||||
200: '#EBABE0', |
||||
300: '#E282D1', |
||||
400: '#D858C2', |
||||
500: '#CF2FB3', |
||||
600: '#BA2AA1', |
||||
700: '#A5258F', |
||||
800: '#90207D', |
||||
900: '#7C1C6B', |
||||
} |
||||
}, |
||||
fontSize: { |
||||
md: '0.95rem', |
||||
}, |
||||
spacing: { |
||||
88: '22rem', |
||||
100: '26rem', |
||||
112: '28rem', |
||||
128: '32rem', |
||||
144: '36rem', |
||||
}, |
||||
borderRadius: { |
||||
none: '0', |
||||
sm: '0.2rem', |
||||
DEFAULT: '0.3rem', |
||||
md: '0.4rem', |
||||
lg: '0.5rem', |
||||
full: '9999px', |
||||
}, |
||||
blur: { |
||||
xs: '3px', |
||||
}, |
||||
animation: { |
||||
'pulse-slow': 'pulse 3s infinite cubic-bezier(.4,0,.6,1)', |
||||
} |
||||
}, |
||||
}, |
||||
plugins: [], |
||||
}; |
@ -0,0 +1,13 @@ |
||||
{ |
||||
"extends": "../tsconfig.json", |
||||
"compilerOptions": { |
||||
"allowSyntheticDefaultImports": true, |
||||
"isolatedModules": true, |
||||
"jsx": "react", |
||||
"noImplicitAny": false, |
||||
"rootDir": "./src", |
||||
"outDir": "./dist", |
||||
}, |
||||
"include": ["./src/**/*"], |
||||
"exclude": ["node_modules", "dist", "**/*.stories.tsx"], |
||||
} |
Loading…
Reference in new issue