mirror of
https://github.com/Qortal/qortal-mobile.git
synced 2025-04-21 10:37:50 +00:00
587 lines
16 KiB
TypeScript
587 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import List from "@mui/material/List";
|
|
import ListItem from "@mui/material/ListItem";
|
|
import Divider from "@mui/material/Divider";
|
|
import ListItemText from "@mui/material/ListItemText";
|
|
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
|
import Avatar from "@mui/material/Avatar";
|
|
import Typography from "@mui/material/Typography";
|
|
import {
|
|
Box,
|
|
Button,
|
|
ButtonBase,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
Input,
|
|
} from "@mui/material";
|
|
import { CustomButton } from "./App-styles";
|
|
import { useDropzone } from "react-dropzone";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import { Label } from "./components/Group/AddGroup";
|
|
import { Spacer } from "./common/Spacer";
|
|
import { getWallets, storeWallets, walletVersion } from "./background";
|
|
import { useModal } from "./common/useModal";
|
|
import PhraseWallet from "./utils/generateWallet/phrase-wallet";
|
|
import { decryptStoredWalletFromSeedPhrase } from "./utils/decryptWallet";
|
|
import { crypto } from "./constants/decryptWallet";
|
|
import { LoadingButton } from "@mui/lab";
|
|
import { PasswordField } from "./components";
|
|
import { FilePicker } from '@capawesome/capacitor-file-picker';
|
|
|
|
const parsefilenameQortal = (filename) => {
|
|
return filename.startsWith("qortal_backup_") ? filename.slice(14) : filename;
|
|
};
|
|
|
|
export const Wallets = ({ setExtState, setRawWallet, rawWallet }) => {
|
|
const [wallets, setWallets] = useState([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [seedValue, setSeedValue] = useState("");
|
|
const [seedName, setSeedName] = useState("");
|
|
const [seedError, setSeedError] = useState("");
|
|
|
|
const [password, setPassword] = useState("");
|
|
const [isOpenSeedModal, setIsOpenSeedModal] = useState(false);
|
|
const [isLoadingEncryptSeed, setIsLoadingEncryptSeed] = useState(false);
|
|
|
|
const { isShow, onCancel, onOk, show } = useModal();
|
|
|
|
const handleFilePick = async () => {
|
|
try {
|
|
const resultPermission = await FilePicker.checkPermissions();
|
|
// Open the file picker to select a JSON file
|
|
const result = await FilePicker.pickFiles({
|
|
types: ['application/json'], // Restrict to JSON files
|
|
multiple: true, // Allow only one file
|
|
readData: true,
|
|
|
|
});
|
|
|
|
if (result.files.length > 0) {
|
|
let importedWallets: any = [];
|
|
|
|
for (const file of result.files) {
|
|
try {
|
|
const decodedData = atob(file.data); // `atob` decodes Base64 to a string
|
|
const parsedFile = JSON.parse(decodedData);
|
|
|
|
|
|
// Validate required fields
|
|
const requiredFields = [
|
|
"address0",
|
|
"salt",
|
|
"iv",
|
|
"version",
|
|
"encryptedSeed",
|
|
"mac",
|
|
"kdfThreads",
|
|
];
|
|
for (const field of requiredFields) {
|
|
if (!(field in parsedFile)) throw new Error(`${field} not found in JSON`);
|
|
}
|
|
|
|
// Set the state with parsed wallet data
|
|
importedWallets.push({ ...parsedFile, filename: file?.name });
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
let uniqueInitialMap = new Map();
|
|
|
|
// Only add a message if it doesn't already exist in the Map
|
|
importedWallets.forEach((wallet) => {
|
|
if (!wallet?.address0) return;
|
|
if (!uniqueInitialMap.has(wallet?.address0)) {
|
|
uniqueInitialMap.set(wallet?.address0, wallet);
|
|
}
|
|
});
|
|
const data = Array.from(uniqueInitialMap.values());
|
|
if (data && data?.length > 0) {
|
|
const uniqueNewWallets = data.filter(
|
|
(newWallet) =>
|
|
!wallets.some(
|
|
(existingWallet) =>
|
|
existingWallet?.address0 === newWallet?.address0
|
|
)
|
|
);
|
|
setWallets([...wallets, ...uniqueNewWallets]);
|
|
}
|
|
|
|
|
|
} else {
|
|
console.log("No file selected.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error picking JSON file:", error);
|
|
}
|
|
};
|
|
|
|
const { getRootProps, getInputProps } = useDropzone({
|
|
accept: {
|
|
"application/json": [".json"], // Only accept JSON files
|
|
},
|
|
onDrop: async (acceptedFiles) => {
|
|
const files: any = acceptedFiles;
|
|
let importedWallets: any = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const fileContents = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.onabort = () => reject("File reading was aborted");
|
|
reader.onerror = () => reject("File reading has failed");
|
|
reader.onload = () => {
|
|
// Resolve the promise with the reader result when reading completes
|
|
resolve(reader.result);
|
|
};
|
|
|
|
// Read the file as text
|
|
reader.readAsText(file);
|
|
});
|
|
if (typeof fileContents !== "string") continue;
|
|
const parsedData = JSON.parse(fileContents);
|
|
importedWallets.push({ ...parsedData, filename: file?.name });
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
let uniqueInitialMap = new Map();
|
|
|
|
// Only add a message if it doesn't already exist in the Map
|
|
importedWallets.forEach((wallet) => {
|
|
if (!wallet?.address0) return;
|
|
if (!uniqueInitialMap.has(wallet?.address0)) {
|
|
uniqueInitialMap.set(wallet?.address0, wallet);
|
|
}
|
|
});
|
|
const data = Array.from(uniqueInitialMap.values());
|
|
if (data && data?.length > 0) {
|
|
const uniqueNewWallets = data.filter(
|
|
(newWallet) =>
|
|
!wallets.some(
|
|
(existingWallet) =>
|
|
existingWallet?.address0 === newWallet?.address0
|
|
)
|
|
);
|
|
setWallets([...wallets, ...uniqueNewWallets]);
|
|
}
|
|
},
|
|
});
|
|
|
|
const updateWalletItem = (idx, wallet) => {
|
|
setWallets((prev) => {
|
|
let copyPrev = [...prev];
|
|
if (wallet === null) {
|
|
copyPrev.splice(idx, 1); // Use splice to remove the item
|
|
return copyPrev;
|
|
} else {
|
|
copyPrev[idx] = wallet; // Update the wallet at the specified index
|
|
return copyPrev;
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleSetSeedValue = async () => {
|
|
try {
|
|
setIsOpenSeedModal(true);
|
|
const { seedValue, seedName, password } = await show({
|
|
message: "",
|
|
publishFee: "",
|
|
});
|
|
setIsLoadingEncryptSeed(true);
|
|
const res = await decryptStoredWalletFromSeedPhrase(seedValue);
|
|
const wallet2 = new PhraseWallet(res, walletVersion);
|
|
const wallet = await wallet2.generateSaveWalletData(
|
|
password,
|
|
crypto.kdfThreads,
|
|
() => {}
|
|
);
|
|
if (wallet?.address0) {
|
|
setWallets([
|
|
...wallets,
|
|
{
|
|
...wallet,
|
|
name: seedName,
|
|
},
|
|
]);
|
|
setIsOpenSeedModal(false);
|
|
setSeedValue("");
|
|
setSeedName("");
|
|
setPassword("");
|
|
setSeedError("");
|
|
} else {
|
|
setSeedError("Could not create wallet.");
|
|
}
|
|
} catch (error) {
|
|
setSeedError(error?.message || "Could not create wallet.");
|
|
} finally {
|
|
setIsLoadingEncryptSeed(false);
|
|
}
|
|
};
|
|
|
|
const selectedWalletFunc = (wallet) => {
|
|
setRawWallet(wallet);
|
|
setExtState("wallet-dropped");
|
|
};
|
|
|
|
useEffect(() => {
|
|
setIsLoading(true);
|
|
getWallets()
|
|
.then((res) => {
|
|
if (res && Array.isArray(res)) {
|
|
setWallets(res);
|
|
}
|
|
setIsLoading(false);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
setIsLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && wallets && Array.isArray(wallets)) {
|
|
storeWallets(wallets);
|
|
}
|
|
}, [wallets, isLoading]);
|
|
|
|
if (isLoading) return null;
|
|
|
|
return (
|
|
<div>
|
|
<Box
|
|
sx={{
|
|
width: "100%",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{wallets?.length === 0 || !wallets ? (
|
|
<>
|
|
<Typography>No wallets saved</Typography>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Typography>Your saved wallets</Typography>
|
|
</>
|
|
)}
|
|
</Box>
|
|
<Spacer height="10px" />
|
|
{rawWallet && (
|
|
<Box>
|
|
<Typography>Selected Wallet:</Typography>
|
|
{rawWallet?.name && <Typography>{rawWallet.name}</Typography>}
|
|
{rawWallet?.address0 && (
|
|
<Typography>{rawWallet?.address0}</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
{wallets?.length > 0 && (
|
|
<List
|
|
sx={{
|
|
width: "100%",
|
|
maxWidth: "500px",
|
|
bgcolor: "background.paper",
|
|
maxHeight: "60vh",
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{wallets?.map((wallet, idx) => {
|
|
return (
|
|
<>
|
|
<WalletItem
|
|
setSelectedWallet={selectedWalletFunc}
|
|
key={wallet?.address0}
|
|
wallet={wallet}
|
|
idx={idx}
|
|
updateWalletItem={updateWalletItem}
|
|
/>
|
|
<Divider variant="inset" component="li" />
|
|
</>
|
|
);
|
|
})}
|
|
</List>
|
|
)}
|
|
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
gap: "10px",
|
|
alignItems: "center",
|
|
position: wallets?.length === 0 ? "relative" : "fixed",
|
|
bottom: wallets?.length === 0 ? 'unset' : "20px",
|
|
right: wallets?.length === 0 ? 'unset' : "20px",
|
|
flexDirection: wallets?.length === 0 ? "column" : "row",
|
|
}}
|
|
>
|
|
<CustomButton
|
|
onClick={handleSetSeedValue}
|
|
sx={{
|
|
padding: "10px",
|
|
}}
|
|
>
|
|
Add seed-phrase
|
|
</CustomButton>
|
|
<CustomButton
|
|
sx={{
|
|
padding: "10px",
|
|
}}
|
|
onClick={handleFilePick}
|
|
>
|
|
|
|
Add wallets
|
|
</CustomButton>
|
|
</Box>
|
|
|
|
<Dialog
|
|
open={isOpenSeedModal}
|
|
aria-labelledby="alert-dialog-title"
|
|
aria-describedby="alert-dialog-description"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && seedValue && seedName && password) {
|
|
onOk({ seedValue, seedName, password });
|
|
}
|
|
}}
|
|
>
|
|
<DialogTitle id="alert-dialog-title">
|
|
Type or paste in your seed-phrase
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Label>Name</Label>
|
|
<Input
|
|
placeholder="Name"
|
|
value={seedName}
|
|
onChange={(e) => setSeedName(e.target.value)}
|
|
/>
|
|
<Spacer height="7px" />
|
|
<Label>Seed-phrase</Label>
|
|
<Input
|
|
placeholder="Seed-phrase"
|
|
value={seedValue}
|
|
onChange={(e) => setSeedValue(e.target.value)}
|
|
/>
|
|
<Spacer height="7px" />
|
|
|
|
<Label>Choose new password</Label>
|
|
<PasswordField
|
|
id="standard-adornment-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
disabled={isLoadingEncryptSeed}
|
|
variant="contained"
|
|
onClick={() => {
|
|
setIsOpenSeedModal(false);
|
|
setSeedValue("");
|
|
setSeedName("");
|
|
setPassword("");
|
|
setSeedError("");
|
|
}}
|
|
>
|
|
Close
|
|
</Button>
|
|
<LoadingButton
|
|
loading={isLoadingEncryptSeed}
|
|
disabled={!seedValue || !seedName || !password}
|
|
variant="contained"
|
|
onClick={() => {
|
|
if (!seedValue || !seedName || !password) return;
|
|
onOk({ seedValue, seedName, password });
|
|
}}
|
|
autoFocus
|
|
>
|
|
Add
|
|
</LoadingButton>
|
|
<Typography
|
|
sx={{
|
|
fontSize: "14px",
|
|
visibility: seedError ? "visible" : "hidden",
|
|
}}
|
|
>
|
|
{seedError}
|
|
</Typography>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const WalletItem = ({ wallet, updateWalletItem, idx, setSelectedWallet }) => {
|
|
const [name, setName] = useState("");
|
|
const [note, setNote] = useState("");
|
|
const [isEdit, setIsEdit] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (wallet?.name) {
|
|
setName(wallet.name);
|
|
}
|
|
if (wallet?.note) {
|
|
setNote(wallet.note);
|
|
}
|
|
}, [wallet]);
|
|
return (
|
|
<>
|
|
<ButtonBase
|
|
onClick={() => {
|
|
setSelectedWallet(wallet);
|
|
}}
|
|
sx={{
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<ListItem
|
|
secondaryAction={
|
|
<IconButton
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsEdit(true);
|
|
}}
|
|
edge="end"
|
|
aria-label="edit"
|
|
>
|
|
<EditIcon
|
|
sx={{
|
|
color: "white",
|
|
}}
|
|
/>
|
|
</IconButton>
|
|
}
|
|
alignItems="flex-start"
|
|
>
|
|
<ListItemAvatar>
|
|
<Avatar alt="" src="/static/images/avatar/1.jpg" />
|
|
</ListItemAvatar>
|
|
<ListItemText
|
|
primary={
|
|
wallet?.name
|
|
? wallet.name
|
|
: wallet?.filename
|
|
? parsefilenameQortal(wallet?.filename)
|
|
: "No name"
|
|
}
|
|
secondary={
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Typography
|
|
component="span"
|
|
variant="body2"
|
|
sx={{ color: "text.primary", display: "inline" }}
|
|
>
|
|
{wallet?.address0}
|
|
</Typography>
|
|
{wallet?.note}
|
|
</Box>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
</ButtonBase>
|
|
{isEdit && (
|
|
<Box
|
|
sx={{
|
|
padding: "8px",
|
|
}}
|
|
>
|
|
<Label>Name</Label>
|
|
<Input
|
|
placeholder="Name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
sx={{
|
|
width: "100%",
|
|
}}
|
|
/>
|
|
<Spacer height="10px" />
|
|
<Label>Note</Label>
|
|
<Input
|
|
placeholder="Note"
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
inputProps={{
|
|
maxLength: 100,
|
|
}}
|
|
sx={{
|
|
width: "100%",
|
|
}}
|
|
/>
|
|
<Spacer height="10px" />
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
gap: "20px",
|
|
justifyContent: "flex-end",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
onClick={() => setIsEdit(false)}
|
|
>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
sx={{
|
|
backgroundColor: "var(--danger)",
|
|
"&:hover": {
|
|
backgroundColor: "var(--danger)",
|
|
},
|
|
"&:focus": {
|
|
backgroundColor: "var(--danger)",
|
|
},
|
|
}}
|
|
size="small"
|
|
variant="contained"
|
|
onClick={() => updateWalletItem(idx, null)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
<Button
|
|
sx={{
|
|
backgroundColor: "#5EB049",
|
|
"&:hover": {
|
|
backgroundColor: "#5EB049",
|
|
},
|
|
"&:focus": {
|
|
backgroundColor: "#5EB049",
|
|
},
|
|
}}
|
|
size="small"
|
|
variant="contained"
|
|
onClick={() => {
|
|
updateWalletItem(idx, {
|
|
...wallet,
|
|
name,
|
|
note,
|
|
});
|
|
setIsEdit(false);
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</>
|
|
);
|
|
};
|