Add follow and block lists from Q-Follow #1

Merged
crowetic merged 2 commits from :master into master 2025-09-10 19:33:23 +00:00
4 changed files with 506 additions and 35 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.zip
node_modules
dist
dist-ssr
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="/qnc.png" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<title>Qortal Nodecontrol</title>
<title>Q-Node & Q-Follow</title>
</head>
<body>
+145 -33
View File
@@ -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<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 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>;
@@ -696,16 +740,16 @@ function App() {
const mintingAccountsHeader = () => {
return (
<div style={{
width: "100%",
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Typography variant="h6">
Minting Account(s)
</Typography>
<Box
sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: .5 }}
onClick={() => setOpenMinting(!openMinting)}
>
<Typography variant="h6">Minting Account(s)</Typography>
{openMinting ? <ExpandLess/> : <ExpandMore/>}
</Box>
<Button
disabled={isUsingGateway}
size="small"
@@ -716,22 +760,22 @@ function App() {
>
Add Minting Account
</Button>
</div>
</Box>
);
};
const connectedPeersHeader = () => {
return (
<div style={{
width: "100%",
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Typography variant="h6">
Peers connected to this Node
</Typography>
<Box
sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: .5 }}
onClick={() => setOpenPeers(!openPeers)}
>
<Typography variant="h6">Connected Peers</Typography>
{openPeers ? <ExpandLess/> : <ExpandMore/>}
</Box>
<Button
disabled={isUsingGateway}
size="small"
@@ -742,7 +786,7 @@ function App() {
>
Add new peer
</Button>
</div>
</Box>
);
};
@@ -1073,7 +1117,7 @@ function App() {
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
variant="h6"
@@ -1162,15 +1206,83 @@ function App() {
{mintingAccountsHeader()}
</Box>
<Divider sx={{ marginTop: '5px' }} />
<Box maxWidth="xl" marginTop={2}>
{loadingMintingAccountsTable ? tableLoaderMintingAccounts() : tableMintingAccounts()}
</Box>
{openMinting && (
<Box maxWidth="xl" marginTop={2}>
{loadingMintingAccountsTable
? tableLoaderMintingAccounts()
: tableMintingAccounts()}
</Box>
)}
<Box maxWidth="xl" marginTop={4}>
{connectedPeersHeader()}
</Box>
<Divider sx={{ marginTop: '5px' }} />
<Box maxWidth="xl" marginTop={2}>
{tableConnectedPeers()}
{openPeers && (
<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>
</Container>
</ThemeProvider>
+359
View File
@@ -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;