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()}>
+ : undefined}
+ sx={{ borderRadius: 50 }}
+ >
+ {isAuthed ? `${userNames.length} name${userNames.length === 1 ? "" : "s"}` : "Authenticate"}
+
+
+
+
+
+
+
+
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})
+
+ }
+ onClick={() => openAddDialog(list)}
+ style={{ borderRadius: 50 }}
+ >
+ {kind === "address" ? "Add Address" : "Add Name"}
+
+
+ {!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;