diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index 6053548..6337499 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -1,6 +1,12 @@ { "extends": "next/core-web-vitals", "rules": { - "@typescript-eslint/no-unused-vars": "warn" + "@typescript-eslint/no-unused-vars": "warn", + "prettier/prettier": [ + "error", + { + "endOfLine": "auto" + } + ] } } diff --git a/packages/web/package.json b/packages/web/package.json index 3e767f2..eb474b2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,7 +13,8 @@ "@ethfs/deploy": "workspace:*", "@ethfs/ponder": "workspace:*", "@ponder/client": "0.9.0-next.10", - "@rainbow-me/rainbowkit": "^1.3.1", + "@rainbow-me/rainbowkit": "^2.2.3", + "@tanstack/react-query": "^5.66.0", "fflate": "^0.7.4", "luxon": "^3.4.4", "mime": "^4.0.1", @@ -23,8 +24,8 @@ "react-dom": "^18", "react-toastify": "^9.1.3", "tailwind-merge": "^2.1.0", - "viem": "^1.20.0", - "wagmi": "^1.4.12", + "viem": "^2.22.17", + "wagmi": "^2.14.9", "zustand": "^4.4.7" }, "devDependencies": { diff --git a/packages/web/src/EthereumProviders.tsx b/packages/web/src/EthereumProviders.tsx index c2ce202..3fddaec 100644 --- a/packages/web/src/EthereumProviders.tsx +++ b/packages/web/src/EthereumProviders.tsx @@ -7,57 +7,55 @@ import { lightTheme, RainbowKitProvider, } from "@rainbow-me/rainbowkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactNode } from "react"; -import { configureChains, createConfig, WagmiConfig } from "wagmi"; -import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; -import { publicProvider } from "wagmi/providers/public"; +import { Chain } from "viem"; +import { createConfig, http, WagmiProvider } from "wagmi"; import { supportedChains } from "./supportedChains"; -const { chains, publicClient } = configureChains( - supportedChains.map((c) => c.chain), - [ - jsonRpcProvider({ - rpc: (chain) => { - // TODO: simplify, this feels wrong/ugly (maybe better in v2?) - const supportedChain = supportedChains.find( - (c) => c.chain.id === chain.id, - ); - if (!supportedChain) return null; - return { - http: supportedChain.rpcUrl, - }; - }, - }), - publicProvider(), - ], -); +// Prepare chains and transports as before +const chains = supportedChains.map((c) => c.chain) as unknown as [ + Chain, + ...Chain[], +]; +const transports = supportedChains.reduce< + Record> +>((acc, supportedChain) => { + acc[supportedChain.chain.id] = http(supportedChain.rpcUrl); + return acc; +}, {}); const { connectors } = getDefaultWallets({ appName: "EthFS", projectId: "bbc87dca59c4e2ac827da9083052f194", - chains, }); -const wagmiConfig = createConfig({ - autoConnect: true, +// Create Wagmi config (no need to pass the React Query client here) +export const config = createConfig({ + chains, + transports, connectors, - publicClient, }); +export const queryClient = new QueryClient(); + export function EthereumProviders({ children }: { children: ReactNode }) { return ( - - - {children} - - + // Wrap the whole provider tree in QueryClientProvider: + + + + {children} + + + ); } diff --git a/packages/web/src/WriteButton.tsx b/packages/web/src/WriteButton.tsx index 404f152..7d317ea 100644 --- a/packages/web/src/WriteButton.tsx +++ b/packages/web/src/WriteButton.tsx @@ -2,7 +2,7 @@ import { useConnectModal } from "@rainbow-me/rainbowkit"; import React, { ComponentProps, ReactNode, useState } from "react"; -import { useAccount, useNetwork, useSwitchNetwork } from "wagmi"; +import { useAccount, useSwitchChain } from "wagmi"; import { useIsMounted } from "./useIsMounted"; import { usePromise } from "./usePromise"; @@ -27,11 +27,8 @@ export const WriteButton = React.forwardRef( ) => { const isMounted = useIsMounted(); const { openConnectModal, connectModalOpen } = useConnectModal(); - const { isConnected, isConnecting } = useAccount(); - const { chain } = useNetwork(); - const { switchNetwork, isLoading: isSwitchingNetwork } = useSwitchNetwork({ - chainId, - }); + const { isConnected, isConnecting, chain } = useAccount(); + const { switchChain, isPending: isSwitchingChain } = useSwitchChain(); const [writePromise, setWritePromise] = useState | null>( null, @@ -77,19 +74,19 @@ export const WriteButton = React.forwardRef( } if (chain != null && chain.id !== chainId) { - if (switchNetwork) { + if (switchChain) { return render({ ...buttonProps, ref, type, "aria-label": "Switch network", - "aria-busy": isSwitchingNetwork, + "aria-busy": isSwitchingChain, onClick: (event) => { if (event.defaultPrevented) return; if (event.currentTarget.ariaBusy === "true") return; if (event.currentTarget.ariaDisabled === "true") return; event.preventDefault(); - switchNetwork(); + switchChain({ chainId: chainId }); }, }); } else { diff --git a/packages/web/src/app/[chain]/migrate-v1/MigrateButton.tsx b/packages/web/src/app/[chain]/migrate-v1/MigrateButton.tsx index 428874b..cb48a31 100644 --- a/packages/web/src/app/[chain]/migrate-v1/MigrateButton.tsx +++ b/packages/web/src/app/[chain]/migrate-v1/MigrateButton.tsx @@ -2,6 +2,7 @@ import { toast } from "react-toastify"; import { BaseError, Hex } from "viem"; +import { useAccount } from "wagmi"; import { useChain } from "../../../ChainContext"; import { WriteButton } from "../../../WriteButton"; @@ -14,6 +15,7 @@ type Props = { }; export function MigrateButton({ file }: Props) { + const { address } = useAccount(); const chain = useChain(); const blockExplorer = chain.blockExplorers?.default; @@ -27,7 +29,7 @@ export function MigrateButton({ file }: Props) { ).then((res) => res.json())) as Hex[]; console.log("creating file"); - await createFile(chain.id, file, pointers, (message) => { + await createFile(chain, file, pointers, address!, (message) => { toast.update(toastId, { render: message }); }).then( (receipt) => { diff --git a/packages/web/src/app/[chain]/migrate-v1/createFile.ts b/packages/web/src/app/[chain]/migrate-v1/createFile.ts index 64cdf28..b45392e 100644 --- a/packages/web/src/app/[chain]/migrate-v1/createFile.ts +++ b/packages/web/src/app/[chain]/migrate-v1/createFile.ts @@ -1,21 +1,27 @@ import IFileStoreAbi from "@ethfs/contracts/out/IFileStore.sol/IFileStore.abi.json"; import deploys from "@ethfs/deploy/deploys.json"; -import { Hex, stringToHex, TransactionReceipt } from "viem"; -import { readContract, waitForTransaction, writeContract } from "wagmi/actions"; +import { Address, Chain, Hex, stringToHex, TransactionReceipt } from "viem"; +import { + readContract, + waitForTransactionReceipt, + writeContract, +} from "wagmi/actions"; +import { config } from "../../../EthereumProviders"; import { File } from "./getFiles"; export async function createFile( - chainId: number, + chain: Chain, file: File, pointers: Hex[], + address: Address, onProgress: (message: string) => void, ): Promise { - const deploy = deploys[chainId]; + const deploy = deploys[chain.id]; onProgress("Checking filename…"); - const fileExists = await readContract({ - chainId, + const fileExists = await readContract(config, { + chainId: chain.id, address: deploy.contracts.FileStore.address, abi: IFileStoreAbi, functionName: "fileExists", @@ -30,8 +36,10 @@ export async function createFile( // TODO: add progress messages for long running requests // https://github.com/holic/a-fundamental-dispute/blob/f83ea42fa60c3b8667f6b0eb03a009d264219ba6/packages/app/src/MintButton.tsx#L118-L131 - const { hash: tx } = await writeContract({ - chainId, + const tx = await writeContract(config, { + account: address, + chainId: chain.id, + chain: chain, address: deploy.contracts.FileStore.address, abi: IFileStoreAbi, functionName: "createFileFromPointers", @@ -51,8 +59,8 @@ export async function createFile( console.log("create file tx", tx); onProgress(`Waiting for transaction…`); - const receipt = await waitForTransaction({ - chainId, + const receipt = await waitForTransactionReceipt(config, { + chainId: chain.id, hash: tx, }); console.log("create file receipt", receipt); diff --git a/packages/web/src/file-explorer/FileThumbnail.tsx b/packages/web/src/file-explorer/FileThumbnail.tsx index 545bb41..71d4c43 100644 --- a/packages/web/src/file-explorer/FileThumbnail.tsx +++ b/packages/web/src/file-explorer/FileThumbnail.tsx @@ -1,7 +1,10 @@ "use client"; +import { useCallback } from "react"; + import { OnchainFile } from "../common"; import { gunzip } from "../gunzip"; +import { DownloadIcon } from "../icons/DownloadIcon"; type Props = { file: OnchainFile; @@ -9,33 +12,72 @@ type Props = { }; export function FileThumbnail({ file, contents }: Props) { - if (file.type?.startsWith("image/")) { - return ( - // eslint-disable-next-line @next/next/no-img-element - {file.filename} - ); - } - + // Decode the contents (assuming base64 encoding if applicable). const decodedContents = file.encoding === "base64" ? Buffer.from(contents, "base64") : Buffer.from(contents); - const decompressedContents = - file.compression === "gzip" ? gunzip(decodedContents) : decodedContents; + // Set a preview size limit. + const previewMaxSize = 1024 * 1024; - return ( + // For non-image files, decompress only up to previewMaxSize bytes. + // (For images we use the original encoded content for rendering.) + const previewContents = + file.compression === "gzip" + ? gunzip(decodedContents, previewMaxSize) + : decodedContents.slice(0, previewMaxSize); + + // Create a download filename (if gzipped, remove the ".gz" extension). + const downloadFilename = + file.compression === "gzip" + ? file.filename.replace(/\.gz$/, "") + : file.filename; + + // Download handler: decompress fully (if needed) only when the user clicks "Download". + const handleDownload = useCallback(() => { + // For compressed files, decompress fully; otherwise use the decoded content. + const fullContents = + file.compression === "gzip" ? gunzip(decodedContents) : decodedContents; + + const blob = new Blob([fullContents], { + type: file.type || "application/octet-stream", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = downloadFilename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [file, decodedContents, downloadFilename]); + + // Determine the preview element based on file type. + const previewElement = file.type?.startsWith("image/") ? ( + // For images, render the using the original encoded content. + {file.filename} + ) : ( + // For non-images, render an