diff --git a/.gitignore b/.gitignore index a547bf3..bfe8e32 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* - +*.zip node_modules dist dist-ssr diff --git a/index.html b/index.html index 7f1f897..0b1b2ed 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - Qortal Nodecontrol + Q-Node & Q-Follow diff --git a/src/App.tsx b/src/App.tsx index 905bcf7..6876530 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import "@fontsource/lato"; -import { CssBaseline } from '@mui/material'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles'; import { styled } from "@mui/system"; import { Alert, @@ -11,6 +10,7 @@ import { Button, CircularProgress, Container, + CssBaseline, Dialog, DialogActions, DialogContent, @@ -19,6 +19,8 @@ import { Divider, Grid, IconButton, + Menu, + MenuItem, Paper, Table, TableBody, @@ -44,7 +46,9 @@ import { RemoveCircleOutline, RestartAlt, Storage, - SyncLock + SyncLock, + ExpandLess, + ExpandMore } from '@mui/icons-material'; import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; import Slide, { SlideProps } from '@mui/material/Slide'; @@ -61,9 +65,9 @@ import appLogo from './assets/q-nodecontrol.png'; import noAvatar from "./assets/noavatar.png"; import NagistralBold from './fonts/Magistral-Bold.woff2'; import NodeWidget from './components/NodeWidget'; +import FollowBlockPanel, { FollowBlockPanelHandles } from './FollowBlockPanel'; import { useIframe } from './main'; import { DefaultTheme } from '@mui/private-theming'; -import { useTheme } from '@mui/material/styles'; function secondsToDhms(seconds: number) { seconds = Number(seconds); @@ -209,8 +213,48 @@ function App() { const [mintingAccountKey, setMintingAccountKey] = React.useState(''); const [openPeerDialog, setOpenPeerDialog] = React.useState(false); const [newPeerAddress, setNewPeerAddress] = React.useState(''); - + const [openMinting, setOpenMinting] = React.useState(true); + const [openPeers, setOpenPeers] = React.useState(true); + const [openFollowBlock, setOpenFollowBlock] = React.useState(true); + const followBlockRef = React.useRef(null); + const [userAddr, setUserAddr] = React.useState(null); + const [userNames, setUserNames] = React.useState([]); + const [authAnchorEl, setAuthAnchorEl] = React.useState(null); + const isAuthed = Boolean(userAddr); const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - connectedPeers.length) : 0; + const handleAuthClose = () => setAuthAnchorEl(null); + const handleReloadAndToggle = () => { + followBlockRef.current?.reload?.(); + if (!openFollowBlock) { + setOpenFollowBlock(!openFollowBlock); + }; + }; + + const handleAuthClick = async (event: React.MouseEvent) => { + if (!isAuthed) { + await authenticate(); + } else { + setAuthAnchorEl(event.currentTarget); + } + }; + + + async function authenticate() { + try { + const account = await qortalRequest({ action: "GET_USER_ACCOUNT" }); + setUserAddr(account?.address ?? null); + if (account?.address) { + const nameRes = await qortalRequest({ + action: "GET_ACCOUNT_NAMES", + address: account.address, + limit: 50, offset: 0, reverse: true, + }); + setUserNames((nameRes || []).map((n: any) => n.name)); + } + } catch (err) { + console.error(err); + } + } let newTheme: Partial; @@ -696,16 +740,16 @@ function App() { const mintingAccountsHeader = () => { return ( -
- - Minting Account(s) - + + setOpenMinting(!openMinting)} + > + Minting Account(s) + {openMinting ? : } + -
+ ); }; const connectedPeersHeader = () => { return ( -
- - Peers connected to this Node - + + setOpenPeers(!openPeers)} + > + Connected Peers + {openPeers ? : } + -
+ ); }; @@ -1073,7 +1117,7 @@ function App() { textDecoration: 'none', }} > - Qortal Nodecontrol + Q-Node & Q-Follow - - {loadingMintingAccountsTable ? tableLoaderMintingAccounts() : tableMintingAccounts()} - + {openMinting && ( + + {loadingMintingAccountsTable + ? tableLoaderMintingAccounts() + : tableMintingAccounts()} + + )} {connectedPeersHeader()} - - {tableConnectedPeers()} + {openPeers && ( + + {tableConnectedPeers()} + + )} + + + setOpenFollowBlock(!openFollowBlock)} + > + Follow / Block Lists + {openFollowBlock ? + : + } + + { e.stopPropagation(); handleReloadAndToggle(); }}> + + + + + e.stopPropagation()}> + + + {userNames.map((nm) => ( + + {nm} + + ))} + + + + + + + diff --git a/src/FollowBlockPanel.tsx b/src/FollowBlockPanel.tsx new file mode 100644 index 0000000..6b1a20f --- /dev/null +++ b/src/FollowBlockPanel.tsx @@ -0,0 +1,359 @@ +import React, { forwardRef, useImperativeHandle, useMemo } from "react"; +import { + Alert, Box, Paper, Typography, Table, TableHead, TableRow, TableCell, + TableBody, Button, CircularProgress, TableContainer, TextField, + Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle +} from "@mui/material"; +import { tableCellClasses } from '@mui/material/TableCell'; +import { styled } from "@mui/system"; +import { RemoveCircleOutline, AddBoxOutlined } from "@mui/icons-material"; + +type ListName = "followedNames" | "blockedNames" | "blockedAddresses"; + +/** expose a `reload()` method to the parent via ref */ +export interface FollowBlockPanelHandles { reload: () => void; } + +interface FollowBlockPanelProps { + onExpand?: () => void; + userAddr?: string | null; + userNames?: string[]; +} + + + +export const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: "#02648d", + color: theme.palette.common.white, + fontSize: 14, + }, + [`&.${tableCellClasses.body}`]: { fontSize: 13 }, +})); + +export const StyledTableRow = styled(TableRow)(({ theme }) => ({ + "&:nth-of-type(odd)": { backgroundColor: theme.palette.action.hover }, + "&:last-child td, &:last-child th": { border: 0 }, +})); + +export const DialogGeneral = styled(Dialog)(({ theme }) => ({ + "& .MuiDialogContent-root": { padding: theme.spacing(3) }, + "& .MuiDialogActions-root": { padding: theme.spacing(1) }, + "& .MuiDialog-paper": { borderRadius: 15 }, + "& .MuiTextField-root": { width: "50ch" }, +})); + +const FollowBlockPanel = forwardRef((props, ref) => { + const [followed, setFollowed] = React.useState([]); + const [blockedNames, setBlockedNames] = React.useState([]); + const [blockedAddresses, setBlockedAddrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [listsLoaded, setListsLoaded] = React.useState(false); + const { userAddr = null, userNames = [] } = props; + const [notices, setNotices] = React.useState([]); + const [openDialog, setOpenDialog] = React.useState>({ + followedNames: false, + blockedNames: false, + blockedAddresses: false, + }); + const [inputVals, setInputVals] = React.useState>({ + followedNames: "", + blockedNames: "", + blockedAddresses: "", + }); + + const loadList = async (list: ListName) => { + const res = await qortalRequest({ + action: "GET_LIST_ITEMS", + list_name: list, + }); + const sorted = (res || []).sort(); + if (list === "followedNames") setFollowed(sorted); + if (list === "blockedNames") setBlockedNames(sorted); + if (list === "blockedAddresses") setBlockedAddrs(sorted); + }; + + const loadLists = async () => { + setLoading(true); + try { + await loadList("followedNames"); + await loadList("blockedNames"); + await loadList("blockedAddresses"); + setListsLoaded(true); + props.onExpand?.(); + } finally { + setLoading(false); + } + }; + + const [followOwnerMap, setFollowOwnerMap] = + React.useState>({}); + + React.useEffect(() => { + setNotices(calcNotices( + userAddr, userNames, blockedNames, blockedAddresses, followed, followOwnerMap, + )); + }, [userAddr, userNames, blockedNames, blockedAddresses, followed, followOwnerMap]); + + React.useEffect(() => { + if (!followed.length) { setFollowOwnerMap({}); return; } + (async () => { + const m: Record = {}; + await Promise.all( + followed.map(async (n) => { + const d = await qortalRequest({ action: "GET_NAME_DATA", name: n }); + if (d?.owner) m[n] = d.owner; + }) + ); + setFollowOwnerMap(m); + })(); + }, [followed]); + + const calcNotices = ( + addr: string | null, + names: string[], + blockedN: string[], + blockedA: string[], + followedN: string[], + followOwner: Record = {}, + ) => { + const msgs: string[] = []; + if (addr && blockedA.includes(addr)) + msgs.push("⚠️ Your address is blocked on this node."); + if (names.some((n) => blockedN.includes(n))) + msgs.push("⚠️ One of your names is blocked on this node."); + const dupe = followedN.filter((n) => blockedN.includes(n)); + if (dupe.length) + msgs.push(`⚠️ Conflict: ${dupe.join(", ")} in both Follow & Block lists.`); + const ownedConflict = Object.entries(followOwner) + .filter(([, owner]) => blockedA.includes(owner)) + .map(([name]) => name); + if (ownedConflict.length) + msgs.push(`⚠️ Conflict: ${ownedConflict.join(", ")} owned by a blocked address.`); + return msgs; + }; + + useImperativeHandle(ref, () => ({ reload: loadLists }), []); + + const remove = async (list: ListName, value: string) => { + if (list === "blockedNames") { + const d = await qortalRequest({ action: "GET_NAME_DATA", name: value }); + const owner = d?.owner; + if (owner && blockedAddresses.includes(owner)) { + if (window.confirm(`Also remove owner address ${owner} from Blocked Addresses?`)) { + await qortalRequest({ + action: "DELETE_LIST_ITEM", + list_name: "blockedAddresses", + item: owner, + }); + } + } + } + else if (list === "blockedAddresses") { + const nameRes = await qortalRequest({ + action: "GET_ACCOUNT_NAMES", + address: value, limit: 50, offset: 0, reverse: true, + }); + const namesToRemove = (nameRes || []) + .map((n: any) => n.name) + .filter((n: string) => blockedNames.includes(n)); + if (namesToRemove.length && + window.confirm( + `Also remove these names from Blocked Names?\n${namesToRemove.join(", ")}` + ) + ) { + for (const nm of namesToRemove) { + await qortalRequest({ + action: "DELETE_LIST_ITEM", + list_name: "blockedNames", + item: nm, + }); + } + } + } + + await qortalRequest({ + action: "DELETE_LIST_ITEM", + list_name: list, + item: value, + }); + await loadLists(); + }; + + const saveItem = async (list: ListName, kind: "name" | "address") => { + const value = inputVals[list].trim(); + if (!value) return; + await qortalRequest({ action: "ADD_LIST_ITEMS", list_name: list, items: [value] }); + + if (list === "blockedAddresses") { + const nameRes = await qortalRequest({ + action: "GET_ACCOUNT_NAMES", address: value, limit: 50, offset: 0, reverse: true, + }); + const names = (nameRes || []).map((n: any) => n.name) + .filter((n: string) => !blockedNames.includes(n)); + if (names.length && + window.confirm(`Block these names too?\n${names.join(", ")}`)) { + await qortalRequest({ action: "ADD_LIST_ITEMS", list_name: "blockedNames", items: names }); + } + } + + if (list === "blockedNames") { + const nameData = await qortalRequest({ action: "GET_NAME_DATA", name: value }); + if (nameData?.owner && + !blockedAddresses.includes(nameData.owner) && + window.confirm(`Block the owner address ${nameData.owner}?`)) { + await qortalRequest({ + action: "ADD_LIST_ITEMS", list_name: "blockedAddresses", items: [nameData.owner], + }); + } + } + + if (list === "followedNames" && blockedNames.includes(value)) + window.alert(`${value} is currently blocked – choose to unblock or unfollow.`); + if (list === "blockedNames" && followed.includes(value)) + window.alert(`${value} is currently followed – choose to block or unfollow.`); + + await loadLists(); + setInputVals({ ...inputVals, [list]: "" }); + setOpenDialog({ ...openDialog, [list]: false }); + }; + + const openAddDialog = (list: ListName) => + setOpenDialog({ ...openDialog, [list]: true }); + + const closeAddDialog = (list: ListName) => { + setOpenDialog({ ...openDialog, [list]: false }); + setInputVals({ ...inputVals, [list]: "" }); + }; + + const renderTable = ( + title: string, + rows: string[], + list: ListName, + kind: "name" | "address", + ) => ( + + + + {title} ({rows.length}) + + + + {!listsLoaded + ? No lists loaded + : rows.length === 0 + ? No list items + : ( + + + + + Item + Action + + + + {rows.map((val) => ( + + {val} + + + + + ))} + +
+
+ )} +
+ ); + + const listMeta: Record = { + followedNames: { + title: "Add name to Follow list", + helper: "Following a name downloads and rehosts QDN data published by that name – videos, shops, etc.", + label: "NAME", + kind: "name", + }, + blockedNames: { + title: "Add name to Block list", + helper: "Blocking a name blocks QDN data published by that name – videos, shops, etc.", + label: "NAME", + kind: "name", + }, + blockedAddresses: { + title: "Add address to Block list", + helper: "Blocking an address blocks ALL transactions from that address – trades, chats, etc.", + label: "ADDRESS", + kind: "address", + }, + }; + + const addDialogs = (list: ListName) => ( + + {listMeta[list].title} + + {listMeta[list].helper} + + setInputVals({ ...inputVals, [list]: e.target.value }) + } + /> + + + + + + + ); + + if (loading) { + return ( + + Loading lists… + + ); + } + + return ( + + {notices.map((msg) => ( + {msg} + ))} + {renderTable("Followed Names", followed, "followedNames", "name")} + {renderTable("Blocked Names", blockedNames, "blockedNames", "name")} + {renderTable("Blocked Addresses", blockedAddresses,"blockedAddresses","address")} + {["followedNames", "blockedNames", "blockedAddresses"].map( + (list) => addDialogs(list as ListName) + )} + + ); +}); + +export default FollowBlockPanel;