From 4d4b96bede316dfd98a493a2438ed873976c0c8b Mon Sep 17 00:00:00 2001 From: Valtteri Karesto Date: Mon, 13 Dec 2021 22:23:37 +0200 Subject: [PATCH] Extract actions to separate files --- components/Accounts.tsx | 3 +- components/DeployEditor.tsx | 2 +- components/DeployFooter.tsx | 3 +- components/EditorNavigation.tsx | 4 +- components/HooksEditor.tsx | 3 +- components/Navigation.tsx | 2 +- pages/_app.tsx | 3 +- pages/deploy/[[...slug]].tsx | 2 +- pages/develop/[[...slug]].tsx | 3 +- state.ts | 575 -------------------------- state/actions/addFaucetAccount.ts | 52 +++ state/actions/compileCode.ts | 79 ++++ state/actions/createNewFile.ts | 7 + state/actions/deployHook.ts | 93 +++++ state/actions/fetchFiles.ts | 55 +++ state/actions/importAccount.ts | 28 ++ state/actions/index.ts | 21 + state/actions/saveFile.ts | 15 + state/actions/syncToGist.ts | 97 +++++ state/actions/updateEditorSettings.ts | 12 + state/index.ts | 146 +++++++ 21 files changed, 620 insertions(+), 585 deletions(-) delete mode 100644 state.ts create mode 100644 state/actions/addFaucetAccount.ts create mode 100644 state/actions/compileCode.ts create mode 100644 state/actions/createNewFile.ts create mode 100644 state/actions/deployHook.ts create mode 100644 state/actions/fetchFiles.ts create mode 100644 state/actions/importAccount.ts create mode 100644 state/actions/index.ts create mode 100644 state/actions/saveFile.ts create mode 100644 state/actions/syncToGist.ts create mode 100644 state/actions/updateEditorSettings.ts create mode 100644 state/index.ts diff --git a/components/Accounts.tsx b/components/Accounts.tsx index 896e6ef..49c8a6d 100644 --- a/components/Accounts.tsx +++ b/components/Accounts.tsx @@ -1,6 +1,7 @@ import toast from "react-hot-toast"; import Button from "./Button"; -import { addFaucetAccount, deployHook, importAccount, state } from "../state"; +import { addFaucetAccount, deployHook, importAccount } from "../state/actions"; +import state from "../state"; import Box from "./Box"; import Container from "./Container"; import Heading from "./Heading"; diff --git a/components/DeployEditor.tsx b/components/DeployEditor.tsx index f7773b5..c658f8f 100644 --- a/components/DeployEditor.tsx +++ b/components/DeployEditor.tsx @@ -10,7 +10,7 @@ import Box from "./Box"; import Container from "./Container"; import dark from "../theme/editor/amy.json"; import light from "../theme/editor/xcode_default.json"; -import { state } from "../state"; +import state from "../state"; import EditorNavigation from "./EditorNavigation"; import Text from "./Text"; diff --git a/components/DeployFooter.tsx b/components/DeployFooter.tsx index 8b80e61..8b75abf 100644 --- a/components/DeployFooter.tsx +++ b/components/DeployFooter.tsx @@ -6,7 +6,8 @@ import useStayScrolled from "react-stay-scrolled"; import Container from "./Container"; import Box from "./Box"; import LogText from "./LogText"; -import { compileCode, state } from "../state"; +import { compileCode } from "../state/actions"; +import state from "../state"; import Button from "./Button"; import Heading from "./Heading"; diff --git a/components/EditorNavigation.tsx b/components/EditorNavigation.tsx index 62fced7..b595e66 100644 --- a/components/EditorNavigation.tsx +++ b/components/EditorNavigation.tsx @@ -28,10 +28,10 @@ import { useSnapshot } from "valtio"; import { createNewFile, - state, syncToGist, updateEditorSettings, -} from "../state"; +} from "../state/actions"; +import state from "../state"; import Box from "./Box"; import Button from "./Button"; import Container from "./Container"; diff --git a/components/HooksEditor.tsx b/components/HooksEditor.tsx index 2baeca9..5daa7b3 100644 --- a/components/HooksEditor.tsx +++ b/components/HooksEditor.tsx @@ -10,7 +10,8 @@ import Box from "./Box"; import Container from "./Container"; import dark from "../theme/editor/amy.json"; import light from "../theme/editor/xcode_default.json"; -import { saveFile, state } from "../state"; +import { saveFile } from "../state/actions"; +import state from "../state"; import EditorNavigation from "./EditorNavigation"; import Text from "./Text"; diff --git a/components/Navigation.tsx b/components/Navigation.tsx index 70bab4b..a58b4fc 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -12,7 +12,7 @@ import Flex from "./Flex"; import Container from "./Container"; import Box from "./Box"; import ThemeChanger from "./ThemeChanger"; -import { state } from "../state"; +import state from "../state"; import Heading from "./Heading"; import Text from "./Text"; import Spinner from "./Spinner"; diff --git a/pages/_app.tsx b/pages/_app.tsx index f119ccb..3ec2c76 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -10,7 +10,8 @@ import { IdProvider } from "@radix-ui/react-id"; import { darkTheme, css } from "../stitches.config"; import Navigation from "../components/Navigation"; -import { fetchFiles, state } from "../state"; +import { fetchFiles } from "../state/actions"; +import state from "../state"; function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { const router = useRouter(); diff --git a/pages/deploy/[[...slug]].tsx b/pages/deploy/[[...slug]].tsx index c26cb40..1f10571 100644 --- a/pages/deploy/[[...slug]].tsx +++ b/pages/deploy/[[...slug]].tsx @@ -2,7 +2,7 @@ import React from "react"; import dynamic from "next/dynamic"; import Flex from "../../components/Flex"; import { useSnapshot } from "valtio"; -import { state } from "../../state"; +import state from "../../state"; const DeployEditor = dynamic(() => import("../../components/DeployEditor"), { ssr: false, diff --git a/pages/develop/[[...slug]].tsx b/pages/develop/[[...slug]].tsx index bd3d1a1..2f70eb0 100644 --- a/pages/develop/[[...slug]].tsx +++ b/pages/develop/[[...slug]].tsx @@ -4,7 +4,8 @@ import Hotkeys from "react-hot-keys"; import { Play } from "phosphor-react"; import type { NextPage } from "next"; -import { compileCode, state } from "../../state"; +import { compileCode } from "../../state/actions"; +import state from "../../state"; import Button from "../../components/Button"; import Box from "../../components/Box"; diff --git a/state.ts b/state.ts deleted file mode 100644 index 4bca8da..0000000 --- a/state.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { proxy, ref, subscribe } from "valtio"; -import { devtools } from "valtio/utils"; -import { Octokit } from "@octokit/core"; -import type monaco from "monaco-editor"; -import toast from "react-hot-toast"; -import Router from "next/router"; -import type { Session } from "next-auth"; -import { decodeBinary } from "./utils/decodeBinary"; - -import { derive, sign } from "xrpl-accountlib"; -import { XrplClient } from "xrpl-client"; - -const octokit = new Octokit(); - -interface File { - name: string; - language: string; - content: string; - compiledContent?: ArrayBuffer | null; - compiledWatContent?: string | null; -} - -export interface FaucetAccountRes { - address: string; - secret: string; - xrp: number; - hash: string; - code: string; -} - -export interface IAccount { - name: string; - address: string; - secret: string; - xrp: string; - sequence: number; - hooks: string[]; - isLoading: boolean; -} - -export interface ILog { - type: "error" | "warning" | "log" | "success"; - message: string; - link?: string; - linkText?: string; -} - -interface IState { - files: File[]; - gistId?: string | null; - gistOwner?: string | null; - gistName?: string | null; - active: number; - activeWat: number; - loading: boolean; - gistLoading: boolean; - compiling: boolean; - logs: ILog[]; - deployLogs: ILog[]; - editorCtx?: typeof monaco.editor; - editorSettings: { - tabSize: number; - }; - client: XrplClient | null; - clientStatus: "offline" | "online"; - mainModalOpen: boolean; - accounts: IAccount[]; -} - -const names = [ - "Alice", - "Bob", - "Carol", - "Carlos", - "Charlie", - "Dan", - "Dave", - "David", - "Faythe", - "Frank", - "Grace", - "Heidi", - "Judy", - "Olive", - "Peggy", - "Walter", -]; - -// let localStorageState: null | string = null; -let initialState = { - files: [], - active: 0, - activeWat: 0, - loading: false, - compiling: false, - logs: [], - deployLogs: [], - editorCtx: undefined, - gistId: undefined, - gistOwner: undefined, - gistName: undefined, - gistLoading: false, - editorSettings: { - tabSize: 2, - }, - client: null, - clientStatus: "offline" as "offline", - mainModalOpen: false, - accounts: [], -}; - -let localStorageAccounts: string | null = null; -let initialAccounts: IAccount[] = []; -// Check if there's a persited accounts in localStorage -if (typeof window !== "undefined") { - try { - localStorageAccounts = localStorage.getItem("hooksIdeAccounts"); - } catch (err) { - console.log(`localStorage state broken`); - localStorage.removeItem("hooksIdeAccounts"); - } - if (localStorageAccounts) { - initialAccounts = JSON.parse(localStorageAccounts); - } -} - -// Initialize state -export const state = proxy({ - ...initialState, - accounts: initialAccounts.length > 0 ? initialAccounts : [], - logs: [], -}); - -// Initialize socket connection -const client = new XrplClient("wss://hooks-testnet.xrpl-labs.com"); - -client.on("online", () => { - state.client = ref(client); - state.clientStatus = "online"; -}); - -client.on("offline", () => { - state.clientStatus = "offline"; -}); - -// Fetch content from Githug Gists -export const fetchFiles = (gistId: string) => { - state.loading = true; - if (gistId && !state.files.length) { - state.logs.push({ - type: "log", - message: `Fetching Gist with id: ${gistId}`, - }); - - octokit - .request("GET /gists/{gist_id}", { gist_id: gistId }) - .then((res) => { - if (res.data.files && Object.keys(res.data.files).length > 0) { - const files = Object.keys(res.data.files).map((filename) => ({ - name: res.data.files?.[filename]?.filename || "noname.c", - language: res.data.files?.[filename]?.language?.toLowerCase() || "", - content: res.data.files?.[filename]?.content || "", - })); - state.loading = false; - if (files.length > 0) { - state.logs.push({ - type: "success", - message: "Fetched successfully ✅", - }); - state.files = files; - state.gistId = gistId; - state.gistName = Object.keys(res.data.files)?.[0] || "untitled"; - state.gistOwner = res.data.owner?.login; - return; - } else { - // Open main modal if now files - state.mainModalOpen = true; - } - return Router.push({ pathname: "/develop" }); - } - state.loading = false; - }) - .catch((err) => { - state.loading = false; - state.logs.push({ - type: "error", - message: `Couldn't find Gist with id: ${gistId}`, - }); - return; - }); - return; - } - state.loading = false; - // return state.files = initFiles -}; - -export const syncToGist = async ( - session?: Session | null, - createNewGist?: boolean -) => { - let files: Record = {}; - state.gistLoading = true; - - if (!session || !session.user) { - state.gistLoading = false; - return toast.error("You need to be logged in!"); - } - const toastId = toast.loading("Pushing to Gist"); - if (!state.files || !state.files.length) { - state.gistLoading = false; - return toast.error(`You need to create some files we can push to gist`, { - id: toastId, - }); - } - if ( - state.gistId && - session?.user.username === state.gistOwner && - !createNewGist - ) { - const currentFilesRes = await octokit.request("GET /gists/{gist_id}", { - gist_id: state.gistId, - }); - if (currentFilesRes.data.files) { - Object.keys(currentFilesRes?.data?.files).forEach((filename) => { - files[`${filename}`] = { filename, content: "" }; - }); - } - state.files.forEach((file) => { - files[`${file.name}`] = { filename: file.name, content: file.content }; - }); - // Update existing Gist - octokit - .request("PATCH /gists/{gist_id}", { - gist_id: state.gistId, - files, - headers: { - authorization: `token ${session?.accessToken || ""}`, - }, - }) - .then((res) => { - state.gistLoading = false; - return toast.success("Updated to gist successfully!", { id: toastId }); - }) - .catch((err) => { - console.log(err); - state.gistLoading = false; - return toast.error(`Could not update Gist, try again later!`, { - id: toastId, - }); - }); - } else { - // Not Gist of the current user or it isn't Gist yet - state.files.forEach((file) => { - files[`${file.name}`] = { filename: file.name, content: file.content }; - }); - octokit - .request("POST /gists", { - files, - public: true, - headers: { - authorization: `token ${session?.accessToken || ""}`, - }, - }) - .then((res) => { - state.gistLoading = false; - state.gistOwner = res.data.owner?.login; - state.gistId = res.data.id; - state.gistName = Array.isArray(res.data.files) - ? Object.keys(res.data?.files)?.[0] - : "Untitled"; - Router.push({ pathname: `/develop/${res.data.id}` }); - return toast.success("Created new gist successfully!", { id: toastId }); - }) - .catch((err) => { - console.log(err); - state.gistLoading = false; - return toast.error(`Could not create Gist, try again later!`, { - id: toastId, - }); - }); - } -}; - -export const updateEditorSettings = ( - editorSettings: IState["editorSettings"] -) => { - state.editorCtx?.getModels().forEach((model) => { - model.updateOptions({ - ...editorSettings, - }); - }); - return (state.editorSettings = editorSettings); -}; - -export const saveFile = (showToast: boolean = true) => { - const editorModels = state.editorCtx?.getModels(); - const currentModel = editorModels?.find((editorModel) => { - return editorModel.uri.path === `/c/${state.files[state.active].name}`; - }); - if (state.files.length > 0) { - state.files[state.active].content = currentModel?.getValue() || ""; - } - if (showToast) { - toast.success("Saved successfully", { position: "bottom-center" }); - } -}; - -export const createNewFile = (name: string) => { - const emptyFile: File = { name, language: "c", content: "" }; - state.files.push(emptyFile); - state.active = state.files.length - 1; -}; - -export const compileCode = async (activeId: number) => { - saveFile(false); - if (!process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT) { - throw Error("Missing env!"); - } - if (state.compiling) { - // if compiling is ongoing return - return; - } - state.compiling = true; - try { - const res = await fetch(process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - output: "wasm", - compress: true, - files: [ - { - type: "c", - name: state.files[activeId].name, - options: "-O0", - src: state.files[activeId].content, - }, - ], - }), - }); - const json = await res.json(); - state.compiling = false; - if (!json.success) { - state.logs.push({ type: "error", message: json.message }); - if (json.tasks && json.tasks.length > 0) { - json.tasks.forEach((task: any) => { - if (!task.success) { - state.logs.push({ type: "error", message: task?.console }); - } - }); - } - return toast.error(`Couldn't compile!`, { position: "bottom-center" }); - } - state.logs.push({ - type: "success", - message: `File ${state.files?.[activeId]?.name} compiled successfully. Ready to deploy.`, - link: Router.asPath.replace("develop", "deploy"), - linkText: "Go to deploy", - }); - const bufferData = await decodeBinary(json.output); - state.files[state.active].compiledContent = ref(bufferData); - - import("wabt").then((wabt) => { - const ww = wabt.default(); - const myModule = ww.readWasm(new Uint8Array(bufferData), { - readDebugNames: true, - }); - myModule.applyNames(); - - const wast = myModule.toText({ foldExprs: false, inlineExport: false }); - state.files[state.active].compiledWatContent = wast; - toast.success("Compiled successfully!", { position: "bottom-center" }); - }); - } catch (err) { - console.log(err); - state.logs.push({ - type: "error", - message: "Error occured while compiling!", - }); - state.compiling = false; - } -}; - -export const addFaucetAccount = async (showToast: boolean = false) => { - if (state.accounts.length > 4) { - return toast.error("You can only have maximum 5 accounts"); - } - const toastId = showToast ? toast.loading("Creating account") : ""; - const res = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/faucet`, { - method: "POST", - }); - const json: FaucetAccountRes | { error: string } = await res.json(); - if ("error" in json) { - if (showToast) { - return toast.error(json.error, { id: toastId }); - } else { - return; - } - } else { - if (showToast) { - toast.success("New account created", { id: toastId }); - } - state.accounts.push({ - name: names[state.accounts.length], - xrp: (json.xrp || 0 * 1000000).toString(), - address: json.address, - secret: json.secret, - sequence: 1, - hooks: [], - isLoading: false, - }); - } -}; - -function arrayBufferToHex(arrayBuffer?: ArrayBuffer | null) { - if (!arrayBuffer) { - return ""; - } - if ( - typeof arrayBuffer !== "object" || - arrayBuffer === null || - typeof arrayBuffer.byteLength !== "number" - ) { - throw new TypeError("Expected input to be an ArrayBuffer"); - } - - var view = new Uint8Array(arrayBuffer); - var result = ""; - var value; - - for (var i = 0; i < view.length; i++) { - value = view[i].toString(16); - result += value.length === 1 ? "0" + value : value; - } - - return result; -} -export const deployHook = async (account: IAccount & { name?: string }) => { - if ( - !state.files || - state.files.length === 0 || - !state.files?.[state.active]?.compiledContent - ) { - return; - } - - if (!state.files?.[state.active]?.compiledContent) { - return; - } - if (typeof window !== "undefined") { - const tx = { - Account: account.address, - TransactionType: "SetHook", - CreateCode: arrayBufferToHex( - state.files?.[state.active]?.compiledContent - ).toUpperCase(), - HookOn: "0000000000000000", - Sequence: account.sequence, - Fee: "1000", - }; - const keypair = derive.familySeed(account.secret); - const { signedTransaction } = sign(tx, keypair); - const currentAccount = state.accounts.find( - (acc) => acc.address === account.address - ); - if (currentAccount) { - currentAccount.isLoading = true; - } - try { - const submitRes = await client.send({ - command: "submit", - tx_blob: signedTransaction, - }); - if (submitRes.engine_result === "tesSUCCESS") { - state.deployLogs.push({ - type: "success", - message: "Hook deployed successfully ✅", - }); - state.deployLogs.push({ - type: "success", - message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`, - }); - } else { - state.deployLogs.push({ - type: "error", - message: `[${submitRes.engine_result}] ${submitRes.engine_result_message}`, - }); - } - } catch (err) { - console.log(err); - state.deployLogs.push({ - type: "error", - message: "Error occured while deploying", - }); - } - if (currentAccount) { - currentAccount.isLoading = false; - } - } -}; - -export const sendXrp = async (account: IAccount & { name?: string }) => { - const tx = { - TransactionType: "Payment", - Account: state.accounts[2].address, - Fee: "1000", - Destination: state.accounts[1].address, - Amount: "133701337", - Sequence: state.accounts[2].sequence, - }; - const keypair = derive.familySeed(state.accounts[2].secret); - const { signedTransaction } = sign(tx, keypair); - await client.send({ - command: "submit", - tx_blob: signedTransaction, - }); -}; - -//@ts-expect-errors -window.sendXrp = sendXrp; - -export const importAccount = (secret: string) => { - if (!secret) { - return toast.error("You need to add secret!"); - } - if (state.accounts.find((acc) => acc.secret === secret)) { - return toast.error("Account already added!"); - } - const account = derive.familySeed(secret); - if (!account.secret.familySeed) { - return toast.error(`Couldn't create account!`); - } - state.accounts.push({ - name: names[state.accounts.length], - address: account.address || "", - secret: account.secret.familySeed || "", - xrp: "0", - sequence: 1, - hooks: [], - isLoading: false, - }); - return toast.success("Account imported successfully!"); -}; - -// @ts-expect-error -window.importAccount = importAccount; - -// fetch initial faucets -(async function fetchFaucets() { - if (state.accounts.length < 2) { - await addFaucetAccount(); - setTimeout(() => { - addFaucetAccount(); - }, 10000); - } -})(); - -if (process.env.NODE_ENV !== "production") { - devtools(state, "Files State"); -} - -if (typeof window !== "undefined") { - subscribe(state, () => { - const { accounts, active } = state; - const accountsNoLoading = accounts.map(acc => ({ ...acc, isLoading: false })) - localStorage.setItem("hooksIdeAccounts", JSON.stringify(accountsNoLoading)); - if (!state.files[active]?.compiledWatContent) { - state.activeWat = 0; - } else { - state.activeWat = active; - } - }); -} diff --git a/state/actions/addFaucetAccount.ts b/state/actions/addFaucetAccount.ts new file mode 100644 index 0000000..7ca2f6a --- /dev/null +++ b/state/actions/addFaucetAccount.ts @@ -0,0 +1,52 @@ +import toast from "react-hot-toast"; +import state, { FaucetAccountRes } from '../index'; + +export const names = [ + "Alice", + "Bob", + "Carol", + "Carlos", + "Charlie", + "Dan", + "Dave", + "David", + "Faythe", + "Frank", + "Grace", + "Heidi", + "Judy", + "Olive", + "Peggy", + "Walter", +]; + +export const addFaucetAccount = async (showToast: boolean = false) => { + if (state.accounts.length > 4) { + return toast.error("You can only have maximum 5 accounts"); + } + const toastId = showToast ? toast.loading("Creating account") : ""; + const res = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/faucet`, { + method: "POST", + }); + const json: FaucetAccountRes | { error: string } = await res.json(); + if ("error" in json) { + if (showToast) { + return toast.error(json.error, { id: toastId }); + } else { + return; + } + } else { + if (showToast) { + toast.success("New account created", { id: toastId }); + } + state.accounts.push({ + name: names[state.accounts.length], + xrp: (json.xrp || 0 * 1000000).toString(), + address: json.address, + secret: json.secret, + sequence: 1, + hooks: [], + isLoading: false, + }); + } +}; \ No newline at end of file diff --git a/state/actions/compileCode.ts b/state/actions/compileCode.ts new file mode 100644 index 0000000..d844f74 --- /dev/null +++ b/state/actions/compileCode.ts @@ -0,0 +1,79 @@ +import toast from "react-hot-toast"; +import Router from 'next/router'; + +import state from "../index"; +import { saveFile } from "./saveFile"; +import { decodeBinary } from "../../utils/decodeBinary"; +import { ref } from "valtio"; + +export const compileCode = async (activeId: number) => { + saveFile(false); + if (!process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT) { + throw Error("Missing env!"); + } + if (state.compiling) { + // if compiling is ongoing return + return; + } + state.compiling = true; + try { + const res = await fetch(process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + output: "wasm", + compress: true, + files: [ + { + type: "c", + name: state.files[activeId].name, + options: "-O0", + src: state.files[activeId].content, + }, + ], + }), + }); + const json = await res.json(); + state.compiling = false; + if (!json.success) { + state.logs.push({ type: "error", message: json.message }); + if (json.tasks && json.tasks.length > 0) { + json.tasks.forEach((task: any) => { + if (!task.success) { + state.logs.push({ type: "error", message: task?.console }); + } + }); + } + return toast.error(`Couldn't compile!`, { position: "bottom-center" }); + } + state.logs.push({ + type: "success", + message: `File ${state.files?.[activeId]?.name} compiled successfully. Ready to deploy.`, + link: Router.asPath.replace("develop", "deploy"), + linkText: "Go to deploy", + }); + const bufferData = await decodeBinary(json.output); + state.files[state.active].compiledContent = ref(bufferData); + + import("wabt").then((wabt) => { + const ww = wabt.default(); + const myModule = ww.readWasm(new Uint8Array(bufferData), { + readDebugNames: true, + }); + myModule.applyNames(); + + const wast = myModule.toText({ foldExprs: false, inlineExport: false }); + state.files[state.active].compiledWatContent = wast; + toast.success("Compiled successfully!", { position: "bottom-center" }); + }); + } catch (err) { + console.log(err); + state.logs.push({ + type: "error", + message: "Error occured while compiling!", + }); + state.compiling = false; + } +}; \ No newline at end of file diff --git a/state/actions/createNewFile.ts b/state/actions/createNewFile.ts new file mode 100644 index 0000000..1884cf7 --- /dev/null +++ b/state/actions/createNewFile.ts @@ -0,0 +1,7 @@ +import state, { IFile } from '../index'; + +export const createNewFile = (name: string) => { + const emptyFile: IFile = { name, language: "c", content: "" }; + state.files.push(emptyFile); + state.active = state.files.length - 1; +}; \ No newline at end of file diff --git a/state/actions/deployHook.ts b/state/actions/deployHook.ts new file mode 100644 index 0000000..308f461 --- /dev/null +++ b/state/actions/deployHook.ts @@ -0,0 +1,93 @@ +import { derive, sign } from "xrpl-accountlib"; + +import state, { IAccount } from "../index"; + +function arrayBufferToHex(arrayBuffer?: ArrayBuffer | null) { + if (!arrayBuffer) { + return ""; + } + if ( + typeof arrayBuffer !== "object" || + arrayBuffer === null || + typeof arrayBuffer.byteLength !== "number" + ) { + throw new TypeError("Expected input to be an ArrayBuffer"); + } + + var view = new Uint8Array(arrayBuffer); + var result = ""; + var value; + + for (var i = 0; i < view.length; i++) { + value = view[i].toString(16); + result += value.length === 1 ? "0" + value : value; + } + + return result; +} +export const deployHook = async (account: IAccount & { name?: string }) => { + if ( + !state.files || + state.files.length === 0 || + !state.files?.[state.active]?.compiledContent + ) { + return; + } + + if (!state.files?.[state.active]?.compiledContent) { + return; + } + if (!state.client) { + return; + } + if (typeof window !== "undefined") { + const tx = { + Account: account.address, + TransactionType: "SetHook", + CreateCode: arrayBufferToHex( + state.files?.[state.active]?.compiledContent + ).toUpperCase(), + HookOn: "0000000000000000", + Sequence: account.sequence, + Fee: "1000", + }; + const keypair = derive.familySeed(account.secret); + const { signedTransaction } = sign(tx, keypair); + const currentAccount = state.accounts.find( + (acc) => acc.address === account.address + ); + if (currentAccount) { + currentAccount.isLoading = true; + } + try { + const submitRes = await state.client.send({ + command: "submit", + tx_blob: signedTransaction, + }); + if (submitRes.engine_result === "tesSUCCESS") { + state.deployLogs.push({ + type: "success", + message: "Hook deployed successfully ✅", + }); + state.deployLogs.push({ + type: "success", + message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`, + }); + } else { + state.deployLogs.push({ + type: "error", + message: `[${submitRes.engine_result}] ${submitRes.engine_result_message}`, + }); + } + } catch (err) { + console.log(err); + state.deployLogs.push({ + type: "error", + message: "Error occured while deploying", + }); + } + if (currentAccount) { + currentAccount.isLoading = false; + } + } +}; \ No newline at end of file diff --git a/state/actions/fetchFiles.ts b/state/actions/fetchFiles.ts new file mode 100644 index 0000000..e5196ad --- /dev/null +++ b/state/actions/fetchFiles.ts @@ -0,0 +1,55 @@ +import { Octokit } from "@octokit/core"; +import Router from "next/router"; +import state from '../index'; + +const octokit = new Octokit(); + +// Fetch content from Githug Gists +export const fetchFiles = (gistId: string) => { + state.loading = true; + if (gistId && !state.files.length) { + state.logs.push({ + type: "log", + message: `Fetching Gist with id: ${gistId}`, + }); + + octokit + .request("GET /gists/{gist_id}", { gist_id: gistId }) + .then((res) => { + if (res.data.files && Object.keys(res.data.files).length > 0) { + const files = Object.keys(res.data.files).map((filename) => ({ + name: res.data.files?.[filename]?.filename || "noname.c", + language: res.data.files?.[filename]?.language?.toLowerCase() || "", + content: res.data.files?.[filename]?.content || "", + })); + state.loading = false; + if (files.length > 0) { + state.logs.push({ + type: "success", + message: "Fetched successfully ✅", + }); + state.files = files; + state.gistId = gistId; + state.gistName = Object.keys(res.data.files)?.[0] || "untitled"; + state.gistOwner = res.data.owner?.login; + return; + } else { + // Open main modal if now files + state.mainModalOpen = true; + } + return Router.push({ pathname: "/develop" }); + } + state.loading = false; + }) + .catch((err) => { + state.loading = false; + state.logs.push({ + type: "error", + message: `Couldn't find Gist with id: ${gistId}`, + }); + return; + }); + return; + } + state.loading = false; +}; \ No newline at end of file diff --git a/state/actions/importAccount.ts b/state/actions/importAccount.ts new file mode 100644 index 0000000..3833b0f --- /dev/null +++ b/state/actions/importAccount.ts @@ -0,0 +1,28 @@ +import toast from "react-hot-toast"; +import { derive } from "xrpl-accountlib"; + +import state from '../index'; +import { names } from './addFaucetAccount'; + +export const importAccount = (secret: string) => { + if (!secret) { + return toast.error("You need to add secret!"); + } + if (state.accounts.find((acc) => acc.secret === secret)) { + return toast.error("Account already added!"); + } + const account = derive.familySeed(secret); + if (!account.secret.familySeed) { + return toast.error(`Couldn't create account!`); + } + state.accounts.push({ + name: names[state.accounts.length], + address: account.address || "", + secret: account.secret.familySeed || "", + xrp: "0", + sequence: 1, + hooks: [], + isLoading: false, + }); + return toast.success("Account imported successfully!"); +}; \ No newline at end of file diff --git a/state/actions/index.ts b/state/actions/index.ts new file mode 100644 index 0000000..90869db --- /dev/null +++ b/state/actions/index.ts @@ -0,0 +1,21 @@ +import { addFaucetAccount } from "./addFaucetAccount"; +import { compileCode } from "./compileCode"; +import { createNewFile } from "./createNewFile"; +import { deployHook } from "./deployHook"; +import { fetchFiles } from "./fetchFiles"; +import { importAccount } from "./importAccount"; +import { saveFile } from "./saveFile"; +import { syncToGist } from "./syncToGist"; +import { updateEditorSettings } from "./updateEditorSettings"; + +export { + addFaucetAccount, + compileCode, + createNewFile, + deployHook, + fetchFiles, + importAccount, + saveFile, + syncToGist, + updateEditorSettings +}; \ No newline at end of file diff --git a/state/actions/saveFile.ts b/state/actions/saveFile.ts new file mode 100644 index 0000000..1ee1099 --- /dev/null +++ b/state/actions/saveFile.ts @@ -0,0 +1,15 @@ +import toast from "react-hot-toast"; +import state from '../index'; + +export const saveFile = (showToast: boolean = true) => { + const editorModels = state.editorCtx?.getModels(); + const currentModel = editorModels?.find((editorModel) => { + return editorModel.uri.path === `/c/${state.files[state.active].name}`; + }); + if (state.files.length > 0) { + state.files[state.active].content = currentModel?.getValue() || ""; + } + if (showToast) { + toast.success("Saved successfully", { position: "bottom-center" }); + } +}; \ No newline at end of file diff --git a/state/actions/syncToGist.ts b/state/actions/syncToGist.ts new file mode 100644 index 0000000..0556a95 --- /dev/null +++ b/state/actions/syncToGist.ts @@ -0,0 +1,97 @@ +import type { Session } from "next-auth"; +import toast from "react-hot-toast"; +import { Octokit } from "@octokit/core"; +import Router from "next/router"; + +import state from '../index'; + +const octokit = new Octokit(); + +export const syncToGist = async ( + session?: Session | null, + createNewGist?: boolean +) => { + let files: Record = {}; + state.gistLoading = true; + + if (!session || !session.user) { + state.gistLoading = false; + return toast.error("You need to be logged in!"); + } + const toastId = toast.loading("Pushing to Gist"); + if (!state.files || !state.files.length) { + state.gistLoading = false; + return toast.error(`You need to create some files we can push to gist`, { + id: toastId, + }); + } + if ( + state.gistId && + session?.user.username === state.gistOwner && + !createNewGist + ) { + const currentFilesRes = await octokit.request("GET /gists/{gist_id}", { + gist_id: state.gistId, + }); + if (currentFilesRes.data.files) { + Object.keys(currentFilesRes?.data?.files).forEach((filename) => { + files[`${filename}`] = { filename, content: "" }; + }); + } + state.files.forEach((file) => { + files[`${file.name}`] = { filename: file.name, content: file.content }; + }); + // Update existing Gist + octokit + .request("PATCH /gists/{gist_id}", { + gist_id: state.gistId, + files, + headers: { + authorization: `token ${session?.accessToken || ""}`, + }, + }) + .then((res) => { + state.gistLoading = false; + return toast.success("Updated to gist successfully!", { id: toastId }); + }) + .catch((err) => { + console.log(err); + state.gistLoading = false; + return toast.error(`Could not update Gist, try again later!`, { + id: toastId, + }); + }); + } else { + // Not Gist of the current user or it isn't Gist yet + state.files.forEach((file) => { + files[`${file.name}`] = { filename: file.name, content: file.content }; + }); + octokit + .request("POST /gists", { + files, + public: true, + headers: { + authorization: `token ${session?.accessToken || ""}`, + }, + }) + .then((res) => { + state.gistLoading = false; + state.gistOwner = res.data.owner?.login; + state.gistId = res.data.id; + state.gistName = Array.isArray(res.data.files) + ? Object.keys(res.data?.files)?.[0] + : "Untitled"; + Router.push({ pathname: `/develop/${res.data.id}` }); + return toast.success("Created new gist successfully!", { id: toastId }); + }) + .catch((err) => { + console.log(err); + state.gistLoading = false; + return toast.error(`Could not create Gist, try again later!`, { + id: toastId, + }); + }); + } +}; + +export default syncToGist; \ No newline at end of file diff --git a/state/actions/updateEditorSettings.ts b/state/actions/updateEditorSettings.ts new file mode 100644 index 0000000..9f1d1e8 --- /dev/null +++ b/state/actions/updateEditorSettings.ts @@ -0,0 +1,12 @@ +import state, { IState } from '../index'; + +export const updateEditorSettings = ( + editorSettings: IState["editorSettings"] +) => { + state.editorCtx?.getModels().forEach((model) => { + model.updateOptions({ + ...editorSettings, + }); + }); + return (state.editorSettings = editorSettings); +}; \ No newline at end of file diff --git a/state/index.ts b/state/index.ts new file mode 100644 index 0000000..fac241f --- /dev/null +++ b/state/index.ts @@ -0,0 +1,146 @@ +import { proxy, ref, subscribe } from "valtio"; +import { devtools } from 'valtio/utils' +import type monaco from "monaco-editor"; +import { XrplClient } from "xrpl-client"; +import { addFaucetAccount } from "./actions/addFaucetAccount"; + +export interface IFile { + name: string; + language: string; + content: string; + compiledContent?: ArrayBuffer | null; + compiledWatContent?: string | null; +} + +export interface FaucetAccountRes { + address: string; + secret: string; + xrp: number; + hash: string; + code: string; +} + +export interface IAccount { + name: string; + address: string; + secret: string; + xrp: string; + sequence: number; + hooks: string[]; + isLoading: boolean; +} + +export interface ILog { + type: "error" | "warning" | "log" | "success"; + message: string; + link?: string; + linkText?: string; +} + +export interface IState { + files: IFile[]; + gistId?: string | null; + gistOwner?: string | null; + gistName?: string | null; + active: number; + activeWat: number; + loading: boolean; + gistLoading: boolean; + compiling: boolean; + logs: ILog[]; + deployLogs: ILog[]; + editorCtx?: typeof monaco.editor; + editorSettings: { + tabSize: number; + }; + client: XrplClient | null; + clientStatus: "offline" | "online"; + mainModalOpen: boolean; + accounts: IAccount[]; +} + +// let localStorageState: null | string = null; +let initialState = { + files: [], + active: 0, + activeWat: 0, + loading: false, + compiling: false, + logs: [], + deployLogs: [], + editorCtx: undefined, + gistId: undefined, + gistOwner: undefined, + gistName: undefined, + gistLoading: false, + editorSettings: { + tabSize: 2, + }, + client: null, + clientStatus: "offline" as "offline", + mainModalOpen: false, + accounts: [], +}; + +let localStorageAccounts: string | null = null; +let initialAccounts: IAccount[] = []; +// Check if there's a persited accounts in localStorage +if (typeof window !== "undefined") { + try { + localStorageAccounts = localStorage.getItem("hooksIdeAccounts"); + } catch (err) { + console.log(`localStorage state broken`); + localStorage.removeItem("hooksIdeAccounts"); + } + if (localStorageAccounts) { + initialAccounts = JSON.parse(localStorageAccounts); + } +} + +// Initialize state +const state = proxy({ + ...initialState, + accounts: initialAccounts.length > 0 ? initialAccounts : [], + logs: [], +}); + +// Initialize socket connection +const client = new XrplClient("wss://hooks-testnet.xrpl-labs.com"); + +client.on("online", () => { + state.client = ref(client); + state.clientStatus = "online"; +}); + +client.on("offline", () => { + state.clientStatus = "offline"; +}); + + +// fetch initial faucets +(async function fetchFaucets() { + if (state.accounts.length < 2) { + await addFaucetAccount(); + setTimeout(() => { + addFaucetAccount(); + }, 10000); + } +})(); + +if (process.env.NODE_ENV !== "production") { + devtools(state, "Files State"); +} + +if (typeof window !== "undefined") { + subscribe(state, () => { + const { accounts, active } = state; + const accountsNoLoading = accounts.map(acc => ({ ...acc, isLoading: false })) + localStorage.setItem("hooksIdeAccounts", JSON.stringify(accountsNoLoading)); + if (!state.files[active]?.compiledWatContent) { + state.activeWat = 0; + } else { + state.activeWat = active; + } + }); +} +export default state \ No newline at end of file