From 673986651a541af42ba506915fddc43152f39cb9 Mon Sep 17 00:00:00 2001 From: greenflame089 Date: Thu, 7 Aug 2025 03:58:02 -0400 Subject: [PATCH 1/2] Add follow and block lists from Q-Follow --- .gitignore | 2 +- src/App.tsx | 167 ++++++++++++++++----- src/FollowBlockPanel.tsx | 304 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 33 deletions(-) create mode 100644 src/FollowBlockPanel.tsx 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/src/App.tsx b/src/App.tsx index 905bcf7..e3226b9 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,43 @@ 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?.(); + + 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 +735,15 @@ 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 ? : } + -
+ ); }; @@ -1162,15 +1199,81 @@ function App() { {mintingAccountsHeader()} - - {loadingMintingAccountsTable ? tableLoaderMintingAccounts() : tableMintingAccounts()} - + {openMinting && ( + + {loadingMintingAccountsTable + ? tableLoaderMintingAccounts() + : tableMintingAccounts()} + + )} {connectedPeersHeader()} - - {tableConnectedPeers()} + {openPeers && ( + + {tableConnectedPeers()} + + )} + + + + setOpenFollowBlock(!openFollowBlock)} + >Follow / Block Lists + {openFollowBlock ? + setOpenFollowBlock(!openFollowBlock)}/> : + setOpenFollowBlock(!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..a5b57e9 --- /dev/null +++ b/src/FollowBlockPanel.tsx @@ -0,0 +1,304 @@ +import React, { forwardRef, useImperativeHandle, useMemo } from "react"; +import { + Box, Paper, Typography, Table, TableHead, TableRow, TableCell, + TableBody, Button, CircularProgress, TableContainer, TextField, + Alert, InputAdornment, IconButton +} from "@mui/material"; +import { RemoveCircleOutline, AddBoxOutlined } from "@mui/icons-material"; +import CloseIcon from "@mui/icons-material/Close"; + +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[]; +} +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 [showInputs, setShowInputs] = 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 handleAddClick = async (list: ListName, kind: "name" | "address") => { + if (!showInputs[list]) { + setShowInputs({ ...showInputs, [list]: true }); + return; + } + 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]: "" }); + setShowInputs({ ...showInputs, [list]: false }); + }; + + const renderTable = ( + title: string, + rows: string[], + list: ListName, + kind: "name" | "address", + ) => ( + + + + {title} ({rows.length}) + + {showInputs[list] && ( + + setInputVals({ ...inputVals, [list]: e.target.value }) + } + placeholder={kind === "address" ? "Address" : "Name"} + sx={{ mr: 1, width: 200 }} + InputProps={{ + endAdornment: ( + + { + setInputVals({ ...inputVals, [list]: "" }); + setShowInputs({ ...showInputs, [list]: false }); + }} + > + + + + ), + }} + /> + )} + + + {!listsLoaded + ? No lists loaded + : rows.length === 0 + ? No list items + : ( + + + + + Item + Action + + + + {rows.map((val) => ( + + {val} + + + + + ))} + +
+
+ )} +
+ ); + + 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")} + + ); +}); + +export default FollowBlockPanel; -- 2.43.0 From dcb094f2a5f6820dc421a1b36a309286d6e14963 Mon Sep 17 00:00:00 2001 From: greenflame089 Date: Thu, 7 Aug 2025 07:14:43 -0400 Subject: [PATCH 2/2] Use dialog for Add Name/Address buttons --- index.html | 2 +- src/App.tsx | 29 +++++--- src/FollowBlockPanel.tsx | 149 +++++++++++++++++++++++++++------------ 3 files changed, 122 insertions(+), 58 deletions(-) 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 e3226b9..6876530 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -223,7 +223,12 @@ function App() { 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?.(); + const handleReloadAndToggle = () => { + followBlockRef.current?.reload?.(); + if (!openFollowBlock) { + setOpenFollowBlock(!openFollowBlock); + }; + }; const handleAuthClick = async (event: React.MouseEvent) => { if (!isAuthed) { @@ -738,9 +743,10 @@ function App() { setOpenMinting(!openMinting)} > - + setOpenMinting(!openMinting)} + > Minting Account(s) {openMinting ? : } @@ -763,9 +769,10 @@ function App() { setOpenPeers(!openPeers)} > - + setOpenPeers(!openPeers)} + > Connected Peers {openPeers ? : } @@ -1110,7 +1117,7 @@ function App() { textDecoration: 'none', }} > - Qortal Nodecontrol + Q-Node & Q-Follow - + setOpenFollowBlock(!openFollowBlock)} + > setOpenFollowBlock(!openFollowBlock)} + variant="h6" >Follow / Block Lists {openFollowBlock ? - setOpenFollowBlock(!openFollowBlock)}/> : - setOpenFollowBlock(!openFollowBlock)}/>} + : + } ({ + [`&.${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([]); @@ -25,7 +50,7 @@ const FollowBlockPanel = forwardRef([]); - const [showInputs, setShowInputs] = React.useState>({ + const [openDialog, setOpenDialog] = React.useState>({ followedNames: false, blockedNames: false, blockedAddresses: false, @@ -154,11 +179,7 @@ const FollowBlockPanel = forwardRef { - if (!showInputs[list]) { - setShowInputs({ ...showInputs, [list]: true }); - return; - } + 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] }); @@ -190,9 +211,18 @@ const FollowBlockPanel = forwardRef + setOpenDialog({ ...openDialog, [list]: true }); + + const closeAddDialog = (list: ListName) => { + setOpenDialog({ ...openDialog, [list]: false }); + setInputVals({ ...inputVals, [list]: "" }); }; const renderTable = ( @@ -208,41 +238,14 @@ const FollowBlockPanel = forwardRef {title} ({rows.length}) - {showInputs[list] && ( - - setInputVals({ ...inputVals, [list]: e.target.value }) - } - placeholder={kind === "address" ? "Address" : "Name"} - sx={{ mr: 1, width: 200 }} - InputProps={{ - endAdornment: ( - - { - setInputVals({ ...inputVals, [list]: "" }); - setShowInputs({ ...showInputs, [list]: false }); - }} - > - - - - ), - }} - /> - )} {!listsLoaded @@ -251,18 +254,18 @@ const FollowBlockPanel = forwardRefNo list items : ( - +
- Item - Action + Item + Action {rows.map((val) => ( - - {val} - + + {val} + - - + + ))}
@@ -281,6 +284,55 @@ const FollowBlockPanel = forwardRef ); + 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 ( @@ -297,6 +349,9 @@ const FollowBlockPanel = forwardRef addDialogs(list as ListName) + )} ); }); -- 2.43.0