forked from Qortal/Q-Nodecontrol
Add follow and block lists from Q-Follow #1
+1
-1
@@ -6,7 +6,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
*.zip
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/png" href="/qnc.png" />
|
<link rel="icon" type="image/png" href="/qnc.png" />
|
||||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||||
<title>Qortal Nodecontrol</title>
|
<title>Q-Node & Q-Follow</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+145
-33
@@ -1,7 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import "@fontsource/lato";
|
import "@fontsource/lato";
|
||||||
import { CssBaseline } from '@mui/material';
|
import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles';
|
||||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
||||||
import { styled } from "@mui/system";
|
import { styled } from "@mui/system";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
|
CssBaseline,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -44,7 +46,9 @@ import {
|
|||||||
RemoveCircleOutline,
|
RemoveCircleOutline,
|
||||||
RestartAlt,
|
RestartAlt,
|
||||||
Storage,
|
Storage,
|
||||||
SyncLock
|
SyncLock,
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
|
import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar';
|
||||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
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 noAvatar from "./assets/noavatar.png";
|
||||||
import NagistralBold from './fonts/Magistral-Bold.woff2';
|
import NagistralBold from './fonts/Magistral-Bold.woff2';
|
||||||
import NodeWidget from './components/NodeWidget';
|
import NodeWidget from './components/NodeWidget';
|
||||||
|
import FollowBlockPanel, { FollowBlockPanelHandles } from './FollowBlockPanel';
|
||||||
import { useIframe } from './main';
|
import { useIframe } from './main';
|
||||||
import { DefaultTheme } from '@mui/private-theming';
|
import { DefaultTheme } from '@mui/private-theming';
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
function secondsToDhms(seconds: number) {
|
function secondsToDhms(seconds: number) {
|
||||||
seconds = Number(seconds);
|
seconds = Number(seconds);
|
||||||
@@ -209,8 +213,48 @@ function App() {
|
|||||||
const [mintingAccountKey, setMintingAccountKey] = React.useState('');
|
const [mintingAccountKey, setMintingAccountKey] = React.useState('');
|
||||||
const [openPeerDialog, setOpenPeerDialog] = React.useState(false);
|
const [openPeerDialog, setOpenPeerDialog] = React.useState(false);
|
||||||
const [newPeerAddress, setNewPeerAddress] = React.useState('');
|
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<FollowBlockPanelHandles>(null);
|
||||||
|
const [userAddr, setUserAddr] = React.useState<string | null>(null);
|
||||||
|
const [userNames, setUserNames] = React.useState<string[]>([]);
|
||||||
|
const [authAnchorEl, setAuthAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
const isAuthed = Boolean(userAddr);
|
||||||
const emptyRows = page > 0 ? Math.max(0, (1 + page) * rowsPerPage - connectedPeers.length) : 0;
|
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<HTMLButtonElement>) => {
|
||||||
|
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<DefaultTheme>;
|
let newTheme: Partial<DefaultTheme>;
|
||||||
|
|
||||||
@@ -696,16 +740,16 @@ function App() {
|
|||||||
|
|
||||||
const mintingAccountsHeader = () => {
|
const mintingAccountsHeader = () => {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<Box
|
||||||
width: "100%",
|
sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
display: 'flex',
|
cursor: 'pointer' }}
|
||||||
flexWrap: 'wrap',
|
>
|
||||||
alignItems: 'center',
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: .5 }}
|
||||||
justifyContent: 'space-between'
|
onClick={() => setOpenMinting(!openMinting)}
|
||||||
}}>
|
>
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">Minting Account(s)</Typography>
|
||||||
Minting Account(s)
|
{openMinting ? <ExpandLess/> : <ExpandMore/>}
|
||||||
</Typography>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
disabled={isUsingGateway}
|
disabled={isUsingGateway}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -716,22 +760,22 @@ function App() {
|
|||||||
>
|
>
|
||||||
Add Minting Account
|
Add Minting Account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectedPeersHeader = () => {
|
const connectedPeersHeader = () => {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<Box
|
||||||
width: "100%",
|
sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
display: 'flex',
|
cursor: 'pointer' }}
|
||||||
flexWrap: 'wrap',
|
>
|
||||||
alignItems: 'center',
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: .5 }}
|
||||||
justifyContent: 'space-between'
|
onClick={() => setOpenPeers(!openPeers)}
|
||||||
}}>
|
>
|
||||||
<Typography variant="h6">
|
<Typography variant="h6">Connected Peers</Typography>
|
||||||
Peers connected to this Node
|
{openPeers ? <ExpandLess/> : <ExpandMore/>}
|
||||||
</Typography>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
disabled={isUsingGateway}
|
disabled={isUsingGateway}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -742,7 +786,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
Add new peer
|
Add new peer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1073,7 +1117,7 @@ function App() {
|
|||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: '#05a2e4' }}>Qortal </span>Nodecontrol
|
<span style={{ color: '#05a2e4' }}>Q-</span>Node & <span style={{ color: '#05a2e4' }}>Q-</span>Follow
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
@@ -1162,15 +1206,83 @@ function App() {
|
|||||||
{mintingAccountsHeader()}
|
{mintingAccountsHeader()}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ marginTop: '5px' }} />
|
<Divider sx={{ marginTop: '5px' }} />
|
||||||
<Box maxWidth="xl" marginTop={2}>
|
{openMinting && (
|
||||||
{loadingMintingAccountsTable ? tableLoaderMintingAccounts() : tableMintingAccounts()}
|
<Box maxWidth="xl" marginTop={2}>
|
||||||
</Box>
|
{loadingMintingAccountsTable
|
||||||
|
? tableLoaderMintingAccounts()
|
||||||
|
: tableMintingAccounts()}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box maxWidth="xl" marginTop={4}>
|
<Box maxWidth="xl" marginTop={4}>
|
||||||
{connectedPeersHeader()}
|
{connectedPeersHeader()}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ marginTop: '5px' }} />
|
<Divider sx={{ marginTop: '5px' }} />
|
||||||
<Box maxWidth="xl" marginTop={2}>
|
{openPeers && (
|
||||||
{tableConnectedPeers()}
|
<Box maxWidth="xl" marginTop={2}>
|
||||||
|
{tableConnectedPeers()}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box maxWidth="xl" marginTop={4}>
|
||||||
|
<Box
|
||||||
|
sx={{ width: "100%", display: "flex", alignItems: "center",
|
||||||
|
justifyContent: "space-between", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}
|
||||||
|
onClick={() => setOpenFollowBlock(!openFollowBlock)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
>Follow / Block Lists</Typography>
|
||||||
|
{openFollowBlock ?
|
||||||
|
<ExpandLess /> :
|
||||||
|
<ExpandMore />}
|
||||||
|
<Tooltip title="Reload lists">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleReloadAndToggle(); }}>
|
||||||
|
<RestartAlt fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleAuthClick}
|
||||||
|
endIcon={isAuthed ? <ExpandMore fontSize="small" /> : undefined}
|
||||||
|
sx={{ borderRadius: 50 }}
|
||||||
|
>
|
||||||
|
{isAuthed ? `${userNames.length} name${userNames.length === 1 ? "" : "s"}` : "Authenticate"}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={authAnchorEl}
|
||||||
|
open={Boolean(authAnchorEl)}
|
||||||
|
onClose={handleAuthClose}
|
||||||
|
>
|
||||||
|
{userNames.map((nm) => (
|
||||||
|
<MenuItem
|
||||||
|
key={nm}
|
||||||
|
disableRipple
|
||||||
|
sx={{
|
||||||
|
"&:hover": { backgroundColor: "transparent" },
|
||||||
|
"&.Mui-focusVisible": { backgroundColor: "transparent" },
|
||||||
|
"&.Mui-selected": { backgroundColor: "transparent" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nm}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ marginTop: '5px' }} />
|
||||||
|
<Box
|
||||||
|
maxWidth="xl"
|
||||||
|
marginTop={2}
|
||||||
|
sx={{ display: openFollowBlock ? "block" : "none" }}
|
||||||
|
>
|
||||||
|
<FollowBlockPanel ref={followBlockRef} userAddr={userAddr} userNames={userNames}/>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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<FollowBlockPanelHandles, FollowBlockPanelProps>((props, ref) => {
|
||||||
|
const [followed, setFollowed] = React.useState<string[]>([]);
|
||||||
|
const [blockedNames, setBlockedNames] = React.useState<string[]>([]);
|
||||||
|
const [blockedAddresses, setBlockedAddrs] = React.useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [listsLoaded, setListsLoaded] = React.useState(false);
|
||||||
|
const { userAddr = null, userNames = [] } = props;
|
||||||
|
const [notices, setNotices] = React.useState<string[]>([]);
|
||||||
|
const [openDialog, setOpenDialog] = React.useState<Record<ListName, boolean>>({
|
||||||
|
followedNames: false,
|
||||||
|
blockedNames: false,
|
||||||
|
blockedAddresses: false,
|
||||||
|
});
|
||||||
|
const [inputVals, setInputVals] = React.useState<Record<ListName, string>>({
|
||||||
|
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<Record<string, string>>({});
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string,string> = {},
|
||||||
|
) => {
|
||||||
|
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",
|
||||||
|
) => (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: .5 }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{title} ({rows.length})
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddBoxOutlined />}
|
||||||
|
onClick={() => openAddDialog(list)}
|
||||||
|
style={{ borderRadius: 50 }}
|
||||||
|
>
|
||||||
|
{kind === "address" ? "Add Address" : "Add Name"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{!listsLoaded
|
||||||
|
? <Typography variant="body2" color="text.secondary">No lists loaded</Typography>
|
||||||
|
: rows.length === 0
|
||||||
|
? <Typography variant="body2" color="text.secondary">No list items</Typography>
|
||||||
|
: (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table stickyHeader size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<StyledTableCell>Item</StyledTableCell>
|
||||||
|
<StyledTableCell sx={{ width: 120 }}>Action</StyledTableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((val) => (
|
||||||
|
<StyledTableRow key={val}>
|
||||||
|
<StyledTableCell>{val}</StyledTableCell>
|
||||||
|
<StyledTableCell>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<RemoveCircleOutline/>}
|
||||||
|
onClick={() => remove(list, val)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</StyledTableCell>
|
||||||
|
</StyledTableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const listMeta: Record<ListName, { title: string; helper: string; label: string; kind: "name"|"address"; }> = {
|
||||||
|
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) => (
|
||||||
|
<DialogGeneral
|
||||||
|
key={list}
|
||||||
|
maxWidth="md"
|
||||||
|
open={openDialog[list]}
|
||||||
|
aria-labelledby={`add-${list}`}
|
||||||
|
keepMounted={false}
|
||||||
|
>
|
||||||
|
<DialogTitle>{listMeta[list].title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{listMeta[list].helper}</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
margin="normal"
|
||||||
|
label={listMeta[list].label}
|
||||||
|
value={inputVals[list]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInputVals({ ...inputVals, [list]: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button color="error" onClick={() => closeAddDialog(list)}>Cancel</Button>
|
||||||
|
<Button color="success" onClick={() => saveItem(list, listMeta[list].kind)}>Add</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogGeneral>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display:'flex', alignItems:'center', gap:1 }}>
|
||||||
|
<CircularProgress size={20}/> <Typography>Loading lists…</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{notices.map((msg) => (
|
||||||
|
<Alert key={msg} severity="warning" sx={{ mb: 2 }}>{msg}</Alert>
|
||||||
|
))}
|
||||||
|
{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)
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FollowBlockPanel;
|
||||||
Reference in New Issue
Block a user