diff --git a/app/(general)/integration/arweave/account/edit/page.tsx b/app/(general)/integration/arweave/account/edit/page.tsx new file mode 100644 index 00000000..9f5b9405 --- /dev/null +++ b/app/(general)/integration/arweave/account/edit/page.tsx @@ -0,0 +1,6 @@ +'use client' +import { ArweaveAccountEdit } from '@/integrations/arweave/components/arweave-account/form' + +export default function ArweaveEditAccountPage() { + return +} diff --git a/app/(general)/integration/arweave/account/page.tsx b/app/(general)/integration/arweave/account/page.tsx new file mode 100644 index 00000000..721ee5d2 --- /dev/null +++ b/app/(general)/integration/arweave/account/page.tsx @@ -0,0 +1,6 @@ +'use client' +import { ArweaveAccount } from '@/integrations/arweave/components/arweave-account' + +export default function ArweaveAccountPage() { + return +} diff --git a/app/(general)/integration/arweave/layout.tsx b/app/(general)/integration/arweave/layout.tsx new file mode 100644 index 00000000..020310e8 --- /dev/null +++ b/app/(general)/integration/arweave/layout.tsx @@ -0,0 +1,66 @@ +'use client' +import { ReactNode } from 'react' + +import { motion } from 'framer-motion' +import Image from 'next/image' +import Balancer from 'react-wrap-balancer' + +import { IsDarkTheme } from '@/components/shared/is-dark-theme' +import { IsLightTheme } from '@/components/shared/is-light-theme' +import { LinkComponent } from '@/components/shared/link-component' +import { FADE_DOWN_ANIMATION_VARIANTS } from '@/config/design' +import { turboIntegrations } from '@/data/turbo-integrations' +import { ArweaveWalletProvider } from '@/integrations/arweave/context/arweave-wallet' + +import { SideBar } from './sidebar' + +const integrationData = turboIntegrations.arweave + +export default function ArweaveLayout({ children }: { children: ReactNode }) { + return ( + + + + + Starter logo + + + Starter logo + + + {integrationData.name} + + + {integrationData.description} + + + + Documentation + + + + +
+ +
{children}
+
+
+
+
+ ) +} diff --git a/app/(general)/integration/arweave/opengraph-image.tsx b/app/(general)/integration/arweave/opengraph-image.tsx new file mode 100644 index 00000000..80d62f14 --- /dev/null +++ b/app/(general)/integration/arweave/opengraph-image.tsx @@ -0,0 +1,9 @@ +import { IntegrationOgImage } from '@/components/ui/social/og-image-integrations' + +export const runtime = 'edge' +export const size = { + width: 1200, + height: 630, +} + +export default IntegrationOgImage('arweave') diff --git a/app/(general)/integration/arweave/page.tsx b/app/(general)/integration/arweave/page.tsx new file mode 100644 index 00000000..f78fe149 --- /dev/null +++ b/app/(general)/integration/arweave/page.tsx @@ -0,0 +1,6 @@ +'use client' +import { ConnectArweaveWallet } from '@/integrations/arweave/components/connect-arweave-wallet' + +export default function ArweaveHome() { + return +} diff --git a/app/(general)/integration/arweave/posts/[txId]/page.tsx b/app/(general)/integration/arweave/posts/[txId]/page.tsx new file mode 100644 index 00000000..5418e10f --- /dev/null +++ b/app/(general)/integration/arweave/posts/[txId]/page.tsx @@ -0,0 +1,10 @@ +'use client' + +import { Post } from '@/integrations/arweave/components/post' +import { ArweaveTxId } from '@/integrations/arweave/utils/types' + +export default function ERC20({ params }: { params: { txId: ArweaveTxId } }) { + const { txId } = params + + return +} diff --git a/app/(general)/integration/arweave/posts/new/page.tsx b/app/(general)/integration/arweave/posts/new/page.tsx new file mode 100644 index 00000000..4aec2ac6 --- /dev/null +++ b/app/(general)/integration/arweave/posts/new/page.tsx @@ -0,0 +1,6 @@ +'use client' +import { FormNewPost } from '@/integrations/arweave/components/form-new-post' + +export default function FormNewPostPage() { + return +} diff --git a/app/(general)/integration/arweave/posts/page.tsx b/app/(general)/integration/arweave/posts/page.tsx new file mode 100644 index 00000000..8e7a3b2b --- /dev/null +++ b/app/(general)/integration/arweave/posts/page.tsx @@ -0,0 +1,6 @@ +'use client' +import { ListPosts } from '@/integrations/arweave/components/list-posts' + +export default function ListPostsPage() { + return +} diff --git a/app/(general)/integration/arweave/settings/page.tsx b/app/(general)/integration/arweave/settings/page.tsx new file mode 100644 index 00000000..91ed4b9c --- /dev/null +++ b/app/(general)/integration/arweave/settings/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { ArweaveSettings } from '@/integrations/arweave/components/settings' + +export default function ArweaveSettingsPage() { + return +} diff --git a/app/(general)/integration/arweave/sidebar.tsx b/app/(general)/integration/arweave/sidebar.tsx new file mode 100644 index 00000000..ea2509ad --- /dev/null +++ b/app/(general)/integration/arweave/sidebar.tsx @@ -0,0 +1,62 @@ +import { LinkComponent } from '@/components/shared/link-component' +import { turboIntegrations } from '@/data/turbo-integrations' +import { ArweaveAccountPreview } from '@/integrations/arweave/components/arweave-account/sidebar-preview' + +export const SideBar = () => { + const arweaveBaseUrl = turboIntegrations.arweave.href + return ( + + ) +} diff --git a/app/(general)/integration/arweave/twitter-image.tsx b/app/(general)/integration/arweave/twitter-image.tsx new file mode 100644 index 00000000..dbca541e --- /dev/null +++ b/app/(general)/integration/arweave/twitter-image.tsx @@ -0,0 +1,9 @@ +import Image from './opengraph-image' + +export const runtime = 'edge' +export const size = { + width: 1200, + height: 630, +} + +export default Image diff --git a/app/(general)/page.tsx b/app/(general)/page.tsx index 9fa00d11..d60128b7 100644 --- a/app/(general)/page.tsx +++ b/app/(general)/page.tsx @@ -394,6 +394,21 @@ const features = [ ), }, + { + title: turboIntegrations.arweave.name, + description: turboIntegrations.arweave.description, + href: turboIntegrations.arweave.href, + demo: ( +
+ + Arweave logo + + + Arweave logo + +
+ ), + }, { title: turboIntegrations.starter.name, description: turboIntegrations.starter.description, diff --git a/data/turbo-integrations.ts b/data/turbo-integrations.ts index 0c5c6d3d..05734496 100644 --- a/data/turbo-integrations.ts +++ b/data/turbo-integrations.ts @@ -87,7 +87,6 @@ export const turboIntegrations = { imgLight: '/integrations/connext.png', imgDark: '/integrations/connext.png', }, - gelato: { name: 'Gelato', href: '/integration/gelato', @@ -112,6 +111,7 @@ export const turboIntegrations = { imgLight: '/integrations/moralis.png', imgDark: '/integrations/moralis.png', }, + aave: { name: 'Aave', href: '/integration/aave', @@ -120,6 +120,15 @@ export const turboIntegrations = { imgLight: '/integrations/aave.png', imgDark: '/integrations/aave.png', }, + arweave: { + name: 'Arweave', + href: '/integration/arweave', + url: 'https://arwiki.arweave.dev', + description: + 'Arweave is the first protocol that enables permanent data storage. Its design allows anyone to preserve data forever with just a single, one-time fee.', + imgLight: '/integrations/arweave-light.png', + imgDark: '/integrations/arweave-dark.png', + }, starter: { name: 'Starter Template', href: '/integration/starter', diff --git a/integrations/aave/components/list-supplied-assets.tsx b/integrations/aave/components/list-supplied-assets.tsx index aceae0a2..90b34cf5 100644 --- a/integrations/aave/components/list-supplied-assets.tsx +++ b/integrations/aave/components/list-supplied-assets.tsx @@ -1,5 +1,5 @@ -import { useAave } from '../hooks/use-aave' import { SuppliedAssetsItem } from './supplied-assets-item' +import { useAave } from '../hooks/use-aave' export const ListSuppliedAssets = () => { const { usdData, balanceInUsd, collateralInUsd, averageSupplyApy } = useAave() diff --git a/integrations/arweave/README.md b/integrations/arweave/README.md new file mode 100644 index 00000000..86d925d5 --- /dev/null +++ b/integrations/arweave/README.md @@ -0,0 +1,62 @@ +# Starter TurboETH Integration + +Welcome to the Starter TurboETH Integration! This folder serves as a blueprint for creating new integrations in TurboETH. If you're looking to contribute a new integration, simply copy this directory, and also the starter page located at `app/integration/starter`, to begin your development. + +## Creating a new integration + +Below are the steps to create a new integration. + +1. Copy the integration folder template from `/integrations/starter` and add your integration code, adhering to the file structure patterns evident in this folder. + +2. Duplicate the integration page from `/app/(general)/integration/starter` and populate it with your integration pages' code. + +3. Locate any API endpoints associated with your integration in the `/api` folder within the page folder of your integration. An example API endpoint can be found at `/app/(general)/integration/starter/api/hello-world/route.ts`. These API endpoints should follow the new [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) patterns of Next.js 13. + +4. Enter the data related to your integration in `/data/turbo-integrations.ts`. Here, add a new object with the name, description, image, and URL of your integration. + +5. Update the OG image configuration of your integration page in the `opengraph-image.tsx` file. Do this by replacing the argument of the `IntegrationOgImage` function with the object key of your integration used in the previous step. + +## Understanding the Starter template + +Each component of the Starter TurboETH template is designed to help streamline your development process: + +- **abis/**: Put your contract's ABI here. Each ABI should be in its own TypeScript file. + +- **client/**: Any client initialization for your chosen module or SDK should be placed here. + +- **components/**: This is the home for your React components. 'Read' components, which display data from a contract, and 'write' components, that send transactions, should all be placed here. + +- **hooks/**: Place your custom React hooks in this folder. These hooks are intended to manage state updates and encapsulate the logic for interacting with Ethereum contracts. + +- **starter-wagmi.ts**: This is a generated file from [wagmi-cli](https://wagmi.sh/cli/getting-started). It includes hooks for your contracts . + +- **index.ts**: Consider this as the entry point for your integration. It should export all the hooks, components, and utility functions that your integration provides. + +- **wagmi.config.ts**: This file should hold the wagmi-cli configuration for your integration, which includes settings like compiler version and optimization. + +- **README.md**: Here, you should document your integration. Explain its purpose, its use, and any important information a new developer or user should know. + +Each of these elements plays a crucial role in making your integration functional and accessible. + +## File Structure + +``` +integrations/starter +├─ abis/ +│ ├─ starter-abi.ts +├─ client/ +│ ├─ index.ts +├─ components/ +│ ├─ starter-header.tsx +├─ generated/ +│ ├─ starter-wagmi.ts +├─ hooks/ +│ ├─ use-starter.ts +├─ utils/ +│ ├─ types.ts +├─ index.ts +├─ README.md +├─ wagmi.config.ts +``` + +By using this template, you'll create well-organized and understandable integrations that are easy for you and others to navigate. Happy coding! diff --git a/integrations/arweave/arweave-account.ts b/integrations/arweave/arweave-account.ts new file mode 100644 index 00000000..710d89d1 --- /dev/null +++ b/integrations/arweave/arweave-account.ts @@ -0,0 +1,58 @@ +import { JWKInterface } from 'arweave/node/lib/wallet' +import Account, { ArAccount } from 'arweave-account' +import { ArAccountEncoded, T_profile } from 'arweave-account/lib/types' + +import { createArweaveDataTx, getArweaveWalletAddress, signAndSendArweaveTx } from '.' +import { SignAndSendArweaveTxResponse } from './utils/types' + +export const ArweaveAccount = new Account() + +export const getUserAccount = async (wallet: JWKInterface): Promise => { + const acc: ArAccount = await ArweaveAccount.get(await getArweaveWalletAddress(wallet)) + return acc +} +export const getAccountByAddress = async (address: string): Promise => { + const acc: ArAccount = await ArweaveAccount.get(address) + return acc +} + +export type UpdateArweaveAccountPayload = Partial & Pick + +export const updateArweaveAccount = async (wallet: JWKInterface, payload: UpdateArweaveAccountPayload): Promise => { + const tx = await createArweaveDataTx(wallet, JSON.stringify(encode(payload))) + const tags = [ + { name: 'Protocol-Name', value: 'Account-0.3' }, + { name: 'handle', value: payload.handleName }, + ] + return await signAndSendArweaveTx(wallet, tx, tags) +} + +function encode(profile: UpdateArweaveAccountPayload): ArAccountEncoded | null { + let data: ArAccountEncoded = { handle: profile.handleName } + if (profile.avatar) data = { ...data, avatar: profile.avatar } + if (profile.banner) data = { ...data, banner: profile.banner } + if (profile.name) data = { ...data, name: profile.name } + if (profile.bio) data = { ...data, bio: profile.bio } + if (profile.email) data = { ...data, email: profile.email } + if (profile.links) data = { ...data, links: profile.links } + if (profile.wallets) data = { ...data, wallets: profile.wallets } + return data +} + +export const uploadArweaveAccountAvatar = async ( + wallet: JWKInterface, + profile: T_profile, + avatar: ArrayBuffer, + avatarFileType: string +): Promise => { + const avatarTx = await createArweaveDataTx(wallet, avatar) + const tags = [{ name: 'Content-type', value: avatarFileType }] + const { txId, response, insufficientBalance } = await signAndSendArweaveTx(wallet, avatarTx, tags) + if (insufficientBalance) return { txId, response, insufficientBalance } + if (response?.status === 200) { + const avatarUrl = `ar://${txId}` + const payload = { ...profile, avatar: avatarUrl } + return await updateArweaveAccount(wallet, payload) + } + return { txId, response, insufficientBalance: false } +} diff --git a/integrations/arweave/components/arweave-account/form/controls.ts b/integrations/arweave/components/arweave-account/form/controls.ts new file mode 100644 index 00000000..b299e4a4 --- /dev/null +++ b/integrations/arweave/components/arweave-account/form/controls.ts @@ -0,0 +1,80 @@ +export const arweaveAccountFormControls = [ + { + formfieldName: 'handleName', + component: 'input', + label: 'Handle Name', + placeholder: 'Insert your @ here', + }, + { + formfieldName: 'name', + component: 'input', + label: 'Name', + placeholder: 'Insert your name here', + }, + { + formfieldName: 'bio', + component: 'textArea', + label: 'Bio', + placeholder: 'Insert your bio here', + }, + { + formfieldName: 'email', + component: 'input', + label: 'Email Address', + placeholder: 'Insert your Email here', + }, + { + formfieldName: 'wallets.eth', + component: 'input', + label: 'Etheruem address', + placeholder: 'Insert your Etheruem address here', + }, + { + formfieldName: 'links.twitter', + component: 'input', + label: 'Twitter link', + placeholder: 'Insert your Twitter link here', + }, + { + formfieldName: 'links.github', + component: 'input', + label: 'Github link', + placeholder: 'Insert your Github link here', + }, + { + formfieldName: 'links.instagram', + component: 'input', + label: 'Instagram link', + placeholder: 'Insert your Instagram link here', + }, + { + formfieldName: 'links.discord', + component: 'input', + label: 'Discord link', + placeholder: 'Insert your Discord link here', + }, + { + formfieldName: 'links.facebook', + component: 'input', + label: 'Facebook link', + placeholder: 'Insert your Facebook link here', + }, + { + formfieldName: 'links.linkedin', + component: 'input', + label: 'LinkedIn link', + placeholder: 'Insert your LinkedIn link here', + }, + { + formfieldName: 'links.youtube', + component: 'input', + label: 'Youtube link', + placeholder: 'Insert your Youtube link here', + }, + { + formfieldName: 'links.twitch', + component: 'input', + label: 'Twitch link', + placeholder: 'Insert your Twitch link here', + }, +] diff --git a/integrations/arweave/components/arweave-account/form/hook.ts b/integrations/arweave/components/arweave-account/form/hook.ts new file mode 100644 index 00000000..f6cec4ee --- /dev/null +++ b/integrations/arweave/components/arweave-account/form/hook.ts @@ -0,0 +1,100 @@ +import { useEffect } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { JWKInterface } from 'arweave/node/lib/wallet' +import { ethers } from 'ethers' +import { useForm, useWatch } from 'react-hook-form' +import { useDebounce } from 'usehooks-ts' +import { z } from 'zod' + +import { UpdateArweaveAccountPayload, updateArweaveAccount } from '@/integrations/arweave/arweave-account' +import { useArweaveWallet } from '@/integrations/arweave/hooks/use-arweave-wallet' +import { useEstimateTxFee } from '@/integrations/arweave/hooks/use-estimate-tx-fee' + +const useEditProfileAPI = () => { + return useMutation({ + mutationFn: async ({ wallet, payload }: { wallet: JWKInterface; payload: UpdateArweaveAccountPayload }) => { + const { txId, response, insufficientBalance } = await updateArweaveAccount(wallet, payload) + if (insufficientBalance) throw { insufficientBalance: true } + if (response?.status !== 200) { + throw (response?.data as { error: string }).error + } + return txId + }, + }) +} + +export const useArweaveAccountForm = () => { + const { account, wallet } = useArweaveWallet() + const { mutate, data, isLoading, isError, error, isSuccess } = useEditProfileAPI() + const profileSchema = z.object({ + handleName: z.string().min(1), + avatar: z.string().optional(), + banner: z.string().optional(), + name: z.string().optional(), + bio: z.string().optional(), + email: z.string().email().optional().or(z.literal('')), + wallets: z.object({ + eth: z + .string() + .refine((value) => ethers.utils.isAddress(value), { + message: 'Provided address is invalid. Please insure you have typed correctly.', + }) + .optional() + .or(z.literal('')), + }), + links: z.object({ + twitter: z.string().optional(), + github: z.string().optional(), + instagram: z.string().optional(), + discord: z.string().optional(), + facebook: z.string().optional(), + linkedin: z.string().optional(), + youtube: z.string().optional(), + twitch: z.string().optional(), + }), + }) + const profile = account?.profile ?? null + const form = useForm>({ + resolver: zodResolver(profileSchema), + defaultValues: { ...(profile ?? {}) }, + }) + const formData = useWatch({ control: form.control }) + const debouncedFormData = useDebounce(formData, 1000) + const { estimatedTxFee, isEstimatingTxFee, estimationError, estimateTxFee } = useEstimateTxFee() + + useEffect(() => { + if (form.formState.isValid && !form.formState.isValidating) { + estimateTxFee(JSON.stringify(debouncedFormData)) + } + }, [debouncedFormData]) + + const onSubmit = async (values: z.infer) => { + try { + if (!wallet) { + console.error('No Arweave wallet connected.') + return + } + mutate({ wallet, payload: values }) + } catch (error) { + console.log(error) + } + } + + return { + error, + data, + isError, + isLoading, + isSuccess, + profileSchema, + form, + onSubmit, + estimation: { + estimatedTxFee, + isEstimatingTxFee, + estimationError, + }, + } +} diff --git a/integrations/arweave/components/arweave-account/form/index.tsx b/integrations/arweave/components/arweave-account/form/index.tsx new file mode 100644 index 00000000..5d2cc2ab --- /dev/null +++ b/integrations/arweave/components/arweave-account/form/index.tsx @@ -0,0 +1,78 @@ +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' + +import { arweaveAccountFormControls } from './controls' +import { useArweaveAccountForm } from './hook' +import { useArweaveWallet } from '../../../hooks/use-arweave-wallet' +import { getComponent } from '../../../utils/get-element-component' +import { ConnectArweaveWallet } from '../../connect-arweave-wallet' +import { FeeEstimation } from '../../fee-estimation' +import { InsufficientBalanceError } from '../../insufficient-balance-error' +import { PendingTx } from '../../pending-tx' +import { Spinner } from '../../spinner' + +// This wrapper exists so the form renders only if we're done getting account +export const ArweaveAccountEdit = () => { + const { wallet, address, account } = useArweaveWallet() + if (!wallet || !address) return + if (!account) return + return +} + +const ArweaveAccountForm = () => { + const { userHasAccount, getAccount } = useArweaveWallet() + const { onSubmit, form, isLoading, isError, isSuccess, error, data, estimation } = useArweaveAccountForm() + const { handleSubmit, register } = form + return ( + <> +
+

{userHasAccount ? 'Edit your Profile' : 'Create your Arweave account'}

+
+ + {arweaveAccountFormControls.map((item) => { + const Component = getComponent(item.component) + return ( + ( + + {item?.label} + + + + + + )} + /> + ) + })} + +
+ + {isError ? ( + (error as { insufficientBalance: boolean }).insufficientBalance ? ( + + ) : ( +
Error: {error instanceof Error ? error.message : String(error)}
+ ) + ) : null} +
+ + +
+
+

Arweave account

+

Arweave profile is the universal account in arweave ecosystem.

+
+
+ {isSuccess && data && } + + ) +} diff --git a/integrations/arweave/components/arweave-account/index.tsx b/integrations/arweave/components/arweave-account/index.tsx new file mode 100644 index 00000000..b67e040c --- /dev/null +++ b/integrations/arweave/components/arweave-account/index.tsx @@ -0,0 +1,139 @@ +import { useCallback, useRef, useState } from 'react' + +import { LinkComponent } from '@/components/shared/link-component' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' + +import { uploadArweaveAccountAvatar } from '../../arweave-account' +import { useArweaveWallet } from '../../hooks/use-arweave-wallet' +import { useEstimateTxFee } from '../../hooks/use-estimate-tx-fee' +import { convertBlobToBase64 } from '../../utils' +import { ConnectArweaveWallet } from '../connect-arweave-wallet' +import { FeeEstimation } from '../fee-estimation' +import { InsufficientBalanceError } from '../insufficient-balance-error' +import { PendingTx } from '../pending-tx' +import { Spinner } from '../spinner' + +export const ArweaveAccount = () => { + const fileInputRef = useRef(null) + const [picture, setPicture] = useState<{ file: ArrayBuffer; type: string; url: string } | null>(null) + const [error, setError] = useState(null) + const [txId, setTxId] = useState(null) + const [insufficientBalance, setInsufficientBalance] = useState(false) + const [uploading, setUploading] = useState(false) + const { address, account, wallet, getAccount } = useArweaveWallet() + const handleName = account?.profile?.handleName ?? null + const { estimatedTxFee, isEstimatingTxFee, estimationError, estimateTxFee } = useEstimateTxFee() + const upload = useCallback(async () => { + if (wallet && picture && account?.profile) { + setUploading(true) + const { txId, response, insufficientBalance } = await uploadArweaveAccountAvatar(wallet, account?.profile, picture.file, picture.type) + if (insufficientBalance) { + setInsufficientBalance(true) + setUploading(false) + return + } + if (response?.status !== 200) { + setError(`${response?.statusText ?? ''} - ${(response?.data as { error: string }).error}`) + setUploading(false) + return + } + setTxId(txId) + setUploading(false) + } + }, [wallet, picture]) + if (!wallet) return + if (!account) return + return ( +
+
+ + + {(handleName ?? address ?? '').substring(0, 2)} + + {!picture ? ( + + ) : ( +
+ +
+ + +
+
+ )} + {insufficientBalance && } + {error &&
Error: {String(error)}
} +
+ { + if (e.target.files) { + setError(null) + setInsufficientBalance(false) + const blobUrl = URL.createObjectURL(e.target.files[0]) + fetch(blobUrl) + .then((r) => r.blob()) + .then((blob) => { + convertBlobToBase64(blob) + .then((res) => { + setPicture({ url: blobUrl, file: res, type: e.target.files?.[0]?.type ?? '' }) + estimateTxFee(res) + }) + .catch((e) => alert(e)) + }) + .catch((e) => console.error(e)) + } + }} + /> + {txId && ( + { + getAccount() + setPicture(null) + }} + /> + )} +
+
+

Account Info

+ + + +
+ {Object.entries(account.profile) + .filter(([k]) => !['avatar', 'avatarURL', 'banner', 'bannerURL'].includes(k)) + .map(([key, val]) => ( +
+ + {key} + {val instanceof Object ? ':' : ''} + + + {val instanceof Object + ? Object.entries(val).map(([key, val]) => ( +
+ {key} + {val ? val : '-'} +
+ )) + : val + ? val + : '-'} +
+
+ ))} +
+
+ ) +} diff --git a/integrations/arweave/components/arweave-account/sidebar-preview.tsx b/integrations/arweave/components/arweave-account/sidebar-preview.tsx new file mode 100644 index 00000000..f353ac57 --- /dev/null +++ b/integrations/arweave/components/arweave-account/sidebar-preview.tsx @@ -0,0 +1,46 @@ +import CopyToClipboard from 'react-copy-to-clipboard' +import { FaCopy } from 'react-icons/fa' + +import { LinkComponent } from '@/components/shared/link-component' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { useArweaveWallet } from '@/integrations/arweave/hooks/use-arweave-wallet' +import { truncateString } from '@/integrations/arweave/utils' +import { useToast } from '@/lib/hooks/use-toast' + +export const ArweaveAccountPreview = () => { + const { account, balance, address } = useArweaveWallet() + const { toast, dismiss } = useToast() + + const handleToast = () => { + toast({ + title: 'Arweave wallet address Copied', + }) + + setTimeout(() => { + dismiss() + }, 4200) + } + + const handleName = account?.profile?.handleName ?? null + if (!account || !address) return null + return ( + + + + {(handleName ?? address).substring(0, 2)} + +
+ {handleName &&
{handleName}
} +
+ {truncateString(address, 15)} + + + + + +
+ {balance !== null &&
{balance?.ar} AR
} +
+
+ ) +} diff --git a/integrations/arweave/components/connect-arweave-wallet.tsx b/integrations/arweave/components/connect-arweave-wallet.tsx new file mode 100644 index 00000000..511e2cc4 --- /dev/null +++ b/integrations/arweave/components/connect-arweave-wallet.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react' + +import { redirect } from 'next/navigation' +import { useAccount } from 'wagmi' + +import { Spinner } from './spinner' +import { useArweaveWallet } from '../hooks/use-arweave-wallet' + +export function ConnectArweaveWallet() { + const fileInputRef = useRef(null) + const [loading, setLoading] = useState(false) + const { generate, wallet, importFromFile, error, generateBasedOnEthAddress } = useArweaveWallet() + const { address: ethAccountAddress } = useAccount() + useEffect(() => { + if (wallet || error) setLoading(false) + }, [wallet, error]) + + if (loading) return + + if (!wallet) + return ( +
+
Use your Eth address
+ +
- or -
+
Generate a new Arweave Wallet
+ +
- or -
+
Import your wallet KeyFile
+ + { + if (e.target.files) { + setLoading(true) + void importFromFile(e.target.files[0]) + } + }} + /> + {error && ( +
+ {error} +
+ )} +
+ You can get a backup of your Arweave wallet by clicking your wallet address in the sidebar once connected. +
+
+ ) + + return redirect('/integration/arweave/settings') +} diff --git a/integrations/arweave/components/fee-estimation.tsx b/integrations/arweave/components/fee-estimation.tsx new file mode 100644 index 00000000..fd1b6db3 --- /dev/null +++ b/integrations/arweave/components/fee-estimation.tsx @@ -0,0 +1,27 @@ +import { Spinner } from './spinner' +import { ArweaveAmount } from '../utils/types' + +export const FeeEstimation = ({ + estimatedTxFee, + isEstimatingTxFee, + estimationError, +}: { + estimatedTxFee: ArweaveAmount | null + isEstimatingTxFee: boolean + estimationError: string | null +}) => ( +
+ Estimated Tx Fee: + {isEstimatingTxFee ? ( + + ) : estimationError ? ( + {estimationError} + ) : estimatedTxFee ? ( + + {estimatedTxFee?.ar} AR ({estimatedTxFee?.winston} winston) + + ) : ( + - + )} +
+) diff --git a/integrations/arweave/components/form-new-post/hook.ts b/integrations/arweave/components/form-new-post/hook.ts new file mode 100644 index 00000000..a71e49ad --- /dev/null +++ b/integrations/arweave/components/form-new-post/hook.ts @@ -0,0 +1,116 @@ +import { useEffect } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { JWKInterface } from 'arweave/node/lib/wallet' +import { useForm, useWatch } from 'react-hook-form' +import { useDebounce } from 'usehooks-ts' +import { z } from 'zod' + +import { createArweaveDataTx, signAndSendArweaveTx } from '@/integrations/arweave' +import { useArweaveWallet } from '@/integrations/arweave/hooks/use-arweave-wallet' + +import { useEstimateTxFee } from '../../hooks/use-estimate-tx-fee' +import { convertBlobToBase64 } from '../../utils' +import { ArweaveTxTag } from '../../utils/types' + +type ArweavePost = { tags: ArweaveTxTag[] } & ({ data: string; file?: never } | { data?: never; file: string | ArrayBuffer }) + +const useCreateArweavePostAPI = () => { + return useMutation({ + mutationFn: async ({ wallet, payload }: { wallet: JWKInterface; payload: ArweavePost }) => { + if (!wallet) throw 'No wallet connected.' + + if (!payload.data && !payload.file) { + throw 'No Data or Files selected' + } + const tx = await createArweaveDataTx(wallet, payload.data ?? payload.file) + const { txId, response, insufficientBalance } = await signAndSendArweaveTx(wallet, tx, payload.tags, !!payload.file) + if (insufficientBalance) throw { insufficientBalance: true } + if (response?.status !== 200) { + throw `${response?.statusText ?? ''} - ${(response?.data as { error: string }).error}` + } + return txId + }, + }) +} + +export const useArweavePostForm = () => { + const { wallet } = useArweaveWallet() + const { mutate, data, isLoading, isError, error, isSuccess } = useCreateArweavePostAPI() + const txSchema = z.object({ + data: z.string(), + file: z.instanceof(File).optional(), + tags: z.array( + z.object({ + name: z.string(), + value: z.string(), + }) + ), + }) + const form = useForm>({ + resolver: zodResolver(txSchema), + defaultValues: { + data: '', + tags: [], + }, + }) + + const formData = useWatch({ name: 'data', control: form.control }) + const formFile = useWatch({ name: 'file', control: form.control }) + const debouncedFormData = useDebounce(formData, 1000) + const { estimatedTxFee, isEstimatingTxFee, estimationError, estimateTxFee, setIsEstimatingTxFee, reset } = useEstimateTxFee() + + useEffect(() => { + if (form.formState.isValid && !form.formState.isValidating) { + if (debouncedFormData === '') reset() + else estimateTxFee(JSON.stringify(debouncedFormData)) + } + }, [debouncedFormData]) + + useEffect(() => { + if (form.formState.isValid && !form.formState.isValidating) { + if (formFile) { + setIsEstimatingTxFee(true) + convertBlobToBase64(formFile) + .then((base64) => { + estimateTxFee(base64) + }) + .catch(console.error) + .finally(() => setIsEstimatingTxFee(false)) + } else { + reset() + } + } + }, [formFile]) + + const onSubmit = async (values: z.infer) => { + try { + if (!wallet) { + console.error('No Arweave wallet connected.') + return + } + if (values.file) { + const base64File = await convertBlobToBase64(values.file) + mutate({ wallet, payload: { file: base64File, tags: values.tags } }) + } else { + mutate({ wallet, payload: { data: values.data, tags: values.tags } }) + } + form.reset() + } catch (error) { + console.log(error) + } + } + + return { + error, + data, + isError, + isLoading, + isSuccess, + txSchema, + form, + onSubmit, + estimation: { estimatedTxFee, isEstimatingTxFee, estimationError }, + } +} diff --git a/integrations/arweave/components/form-new-post/index.tsx b/integrations/arweave/components/form-new-post/index.tsx new file mode 100644 index 00000000..3a5beed5 --- /dev/null +++ b/integrations/arweave/components/form-new-post/index.tsx @@ -0,0 +1,136 @@ +import { useRef } from 'react' + +import { useFieldArray } from 'react-hook-form' + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' + +import { useArweavePostForm } from './hook' +import { FormListTags } from './list-tags' +import { useArweaveWallet } from '../../hooks/use-arweave-wallet' +import { truncateString } from '../../utils' +import { ConnectArweaveWallet } from '../connect-arweave-wallet' +import { FeeEstimation } from '../fee-estimation' +import { InsufficientBalanceError } from '../insufficient-balance-error' +import { PendingTx } from '../pending-tx' + +export const FormNewPost = () => { + const fileInputRef = useRef(null) + const { wallet, address } = useArweaveWallet() + const { onSubmit, form, isLoading, isError, isSuccess, error, data, estimation } = useArweavePostForm() + const { control, handleSubmit, register, getValues, setValue } = form + const values = getValues() + const { + fields: tags, + append: appendTag, + remove: removeTag, + } = useFieldArray({ + control, + name: 'tags', + }) + if (!wallet || !address) return + return ( + <> +
+

Create a new Arweave post

+
+ + {!values.file && ( + ( + + Data to be stored + +