Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/web/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
7 changes: 4 additions & 3 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
72 changes: 35 additions & 37 deletions packages/web/src/EthereumProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, ReturnType<typeof http>>
>((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 (
<WagmiConfig config={wagmiConfig}>
<RainbowKitProvider
chains={chains}
theme={lightTheme({
borderRadius: "none",
accentColor: "#57534e",
fontStack: "system",
})}
>
{children}
</RainbowKitProvider>
</WagmiConfig>
// Wrap the whole provider tree in QueryClientProvider:
<QueryClientProvider client={queryClient}>
<WagmiProvider config={config}>
<RainbowKitProvider
initialChain={chains[0]}
theme={lightTheme({
borderRadius: "none",
accentColor: "#57534e",
fontStack: "system",
})}
>
{children}
</RainbowKitProvider>
</WagmiProvider>
</QueryClientProvider>
);
}
15 changes: 6 additions & 9 deletions packages/web/src/WriteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,11 +27,8 @@ export const WriteButton = React.forwardRef<HTMLButtonElement, Props>(
) => {
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<Promise<void> | null>(
null,
Expand Down Expand Up @@ -77,19 +74,19 @@ export const WriteButton = React.forwardRef<HTMLButtonElement, Props>(
}

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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/app/[chain]/migrate-v1/MigrateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +15,7 @@ type Props = {
};

export function MigrateButton({ file }: Props) {
const { address } = useAccount();
const chain = useChain();
const blockExplorer = chain.blockExplorers?.default;

Expand All @@ -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) => {
Expand Down
28 changes: 18 additions & 10 deletions packages/web/src/app/[chain]/migrate-v1/createFile.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionReceipt> {
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",
Expand All @@ -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",
Expand All @@ -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);
Expand Down
80 changes: 61 additions & 19 deletions packages/web/src/file-explorer/FileThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
"use client";

import { useCallback } from "react";

import { OnchainFile } from "../common";
import { gunzip } from "../gunzip";
import { DownloadIcon } from "../icons/DownloadIcon";

type Props = {
file: OnchainFile;
contents: string;
};

export function FileThumbnail({ file, contents }: Props) {
if (file.type?.startsWith("image/")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:${file.type}${
file.encoding === "base64" ? ";base64" : ""
},${contents}`}
className="max-w-64 max-h-64 object-contain bg-white border-2 border-stone-400 shadow-hard"
alt={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 <img> using the original encoded content.
<img
src={`data:${file.type}${file.encoding === "base64" ? ";base64" : ""},${contents}`}
className="max-w-64 max-h-64 object-contain bg-white border-2 border-stone-400 shadow-hard"
alt={file.filename}
/>
) : (
// For non-images, render an <iframe> showing the preview (up to previewMaxSize bytes).
<iframe
className="w-64 h-64 bg-white border-2 border-stone-400 shadow-hard"
src={`data:text/plain;charset=utf-8;base64,${decompressedContents
.slice(0, 1024 * 64)
.toString("base64")}`}
src={`data:text/plain;charset=utf-8;base64,${previewContents.toString("base64")}`}
/>
);

return (
<div className="flex flex-col gap-6">
{previewElement}
<div className="flex flex-row items-center justify-center gap-4 w-64">
<p>{file.filename}</p>
<p onClick={handleDownload} className="text-stone-400 cursor-pointer">
<DownloadIcon />
</p>
</div>
</div>
);
}
Loading
Loading