mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-21 05:53:25 +02:00
Finish Event console
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Added simple query events tool
|
||||
Added Event Console tool
|
||||
|
@@ -24,7 +24,9 @@
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/shared-utils": "^2.0.4",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@codemirror/autocomplete": "^6.12.0",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@getalby/bitcoin-connect": "^3.2.1",
|
||||
|
@@ -3,7 +3,7 @@ import { useAsync } from "react-use";
|
||||
|
||||
import db from "../../services/db";
|
||||
|
||||
export type UserDirectory = { pubkey: string; names: [] }[];
|
||||
export type UserDirectory = { pubkey: string; names: string[] }[];
|
||||
export type GetDirectoryFn = () => UserDirectory;
|
||||
const UserSearchDirectoryContext = createContext<GetDirectoryFn>(() => []);
|
||||
|
||||
|
@@ -23,7 +23,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
|
||||
{!isSpecial && <DeleteEventMenuItem event={list} label="Delete List" />}
|
||||
{hasPeople && (
|
||||
<MenuItem
|
||||
icon={<Image w="4" h="4" src="https://www.makeprisms.com/favicon.ico" />}
|
||||
icon={<Image w="4" h="4" src="https://framerusercontent.com/images/3S3Pyvkh2tEvvKyX47QrUq7XQLk.png" />}
|
||||
onClick={() => window.open(`https://www.makeprisms.com/create/${naddr}`, "_blank")}
|
||||
>
|
||||
Create $prism
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { Code, Flex, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { Box, Code, Flex, Heading, Text, useDisclosure } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import ExpandButton from "./expand-button";
|
||||
import UserName from "../../../components/user-name";
|
||||
import { CopyIconButton } from "../../../components/copy-icon-button";
|
||||
import Timestamp from "../../../components/timestamp";
|
||||
|
||||
export default function EventRow({ event }: { event: NostrEvent }) {
|
||||
const expanded = useDisclosure();
|
||||
@@ -31,9 +33,38 @@ export default function EventRow({ event }: { event: NostrEvent }) {
|
||||
|
||||
{expanded.isOpen && (
|
||||
<Flex gap="2" direction="column" px="2" pb="2" alignItems="flex-start">
|
||||
<Code whiteSpace="pre-wrap" noOfLines={4} w="full">
|
||||
<Flex gap="2">
|
||||
<Text>ID: </Text>
|
||||
<Code>{event.id}</Code>
|
||||
<CopyIconButton text={event.id} aria-label="Copy ID" title="Copy ID" size="xs" />
|
||||
</Flex>
|
||||
<Flex gap="2">
|
||||
<Text>Pubkey: </Text>
|
||||
<Code>{event.pubkey}</Code>
|
||||
<CopyIconButton text={event.pubkey} aria-label="Copy Pubkey" title="Copy Pubkey" size="xs" />
|
||||
</Flex>
|
||||
<Flex gap="2">
|
||||
<Text>Created: </Text>
|
||||
<Code>{event.created_at}</Code>
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
</Flex>
|
||||
<Code whiteSpace="pre-wrap" w="full">
|
||||
{event.content}
|
||||
</Code>
|
||||
<Box>
|
||||
<Text>Tags:</Text>
|
||||
<Flex gap="2" direction="column">
|
||||
{event.tags.map((t, i) => (
|
||||
<Flex key={t.join(",") + i} gap="1" wrap="wrap">
|
||||
{t.map((v, ii) => (
|
||||
<Code key={v} minW="1rem" fontFamily="monospace" fontWeight={ii === 0 ? "bold" : "normal"}>
|
||||
{v}
|
||||
</Code>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
|
51
src/views/tools/event-console/help-modal.tsx
Normal file
51
src/views/tools/event-console/help-modal.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Code,
|
||||
Flex,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
ModalProps,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export default function HelpModal({ isOpen, onClose, ...props }: Omit<ModalProps, "children">) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg" {...props}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">Help</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px="4" pt="0" pb="4">
|
||||
<Heading size="sm">Keyboard shortcuts</Heading>
|
||||
<Text>
|
||||
<Code>Ctrl+Shift+Enter</Code>: Run Filter
|
||||
</Text>
|
||||
|
||||
<Heading size="sm" mt="4">
|
||||
Pubkeys
|
||||
</Heading>
|
||||
<Text>
|
||||
Typing <Code>@</Code> inside any string will autocomplete with a list of users the app has seen
|
||||
</Text>
|
||||
|
||||
<Heading size="sm" mt="4">
|
||||
Dates
|
||||
</Heading>
|
||||
<Text>
|
||||
<Code>since</Code> and <Code>until</Code> fields can both take relative times in the form of strings
|
||||
</Text>
|
||||
<Text>Examples:</Text>
|
||||
<Flex gap="1">
|
||||
{["now", "n-3h", "n-5", "n+4m", "n-7d", "n-30s", "n-4w"].map((t) => (
|
||||
<Code key={t}>{t}</Code>
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -11,6 +11,7 @@ import {
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Switch,
|
||||
Text,
|
||||
useColorMode,
|
||||
useDisclosure,
|
||||
@@ -18,24 +19,65 @@ import {
|
||||
import ReactCodeMirror from "@uiw/react-codemirror";
|
||||
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
|
||||
import { jsonSchema } from "codemirror-json-schema";
|
||||
import { Filter, NostrEvent } from "nostr-tools";
|
||||
import { NostrEvent, Relay, Subscription } from "nostr-tools";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useInterval, useLocalStorage } from "react-use";
|
||||
import { Subscription as IDBSubscription, CacheRelay } from "nostr-idb";
|
||||
import _throttle from "lodash.throttle";
|
||||
import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import { jsonLanguage } from "@codemirror/lang-json";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
|
||||
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { NostrFilterSchema } from "./schema";
|
||||
import { relayRequest } from "../../../helpers/relay";
|
||||
import { localRelay } from "../../../services/local-relay";
|
||||
import Play from "../../../components/icons/play";
|
||||
import ClockRewind from "../../../components/icons/clock-rewind";
|
||||
import HistoryDrawer from "./history-drawer";
|
||||
import EventRow from "./event-row";
|
||||
import { processFilter } from "./process";
|
||||
import HelpModal from "./help-modal";
|
||||
import HelpCircle from "../../../components/icons/help-circle";
|
||||
import stringify from "json-stringify-deterministic";
|
||||
import { DownloadIcon } from "../../../components/icons";
|
||||
import { RelayUrlInput } from "../../../components/relay-url-input";
|
||||
import { validateRelayURL } from "../../../helpers/relay";
|
||||
import { UserDirectory, useUserSearchDirectoryContext } from "../../../providers/global/user-directory-provider";
|
||||
|
||||
let users: UserDirectory = [];
|
||||
function userAutocomplete(context: CompletionContext): CompletionResult | null {
|
||||
let nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
if (nodeBefore.name !== "String") return null;
|
||||
|
||||
let textBefore = context.state.sliceDoc(nodeBefore.from, context.pos);
|
||||
let tagBefore = /@\w*$/.exec(textBefore);
|
||||
if (!tagBefore && !context.explicit) return null;
|
||||
|
||||
return {
|
||||
from: tagBefore ? nodeBefore.from + tagBefore.index : context.pos,
|
||||
validFor: /^(@\w*)?$/,
|
||||
// options: tagOptions,
|
||||
options: users
|
||||
.filter((u) => !!u.names[0])
|
||||
.map((user) => ({
|
||||
label: "@" + user.names[0]!,
|
||||
type: "keyword",
|
||||
apply: user.pubkey,
|
||||
detail: "pubkey",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const FilterEditor = memo(
|
||||
({ value, onChange, onRun }: { value: string; onChange: (v: string) => void; onRun: () => void }) => {
|
||||
const getDirectory = useUserSearchDirectoryContext();
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
useInterval(() => {
|
||||
users = getDirectory();
|
||||
}, 1000);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
keymap.of([
|
||||
@@ -55,6 +97,9 @@ const FilterEditor = memo(
|
||||
},
|
||||
]),
|
||||
jsonSchema(NostrFilterSchema),
|
||||
jsonLanguage.data.of({
|
||||
autocomplete: userAutocomplete,
|
||||
}),
|
||||
],
|
||||
[onRun],
|
||||
);
|
||||
@@ -84,33 +129,98 @@ const EventTimeline = memo(({ events }: { events: NostrEvent[] }) => {
|
||||
export default function EventConsoleView() {
|
||||
const historyDrawer = useDisclosure();
|
||||
const [history, setHistory] = useLocalStorage<string[]>("console-history", []);
|
||||
const helpModal = useDisclosure();
|
||||
const queryRelay = useDisclosure();
|
||||
const [relayURL, setRelayURL] = useState("");
|
||||
const [relay, setRelay] = useState<Relay | null>(null);
|
||||
|
||||
const [sub, setSub] = useState<Subscription | IDBSubscription | null>(null);
|
||||
|
||||
const [query, setQuery] = useState(() => history?.[0] || JSON.stringify({ kinds: [1], limit: 20 }, null, 2));
|
||||
const queryRef = useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
const filter = await processFilter(JSON.parse(queryRef.current));
|
||||
if (queryRelay.isOpen && !relayURL) throw new Error("Must set relay");
|
||||
|
||||
const filter = await processFilter(JSON.parse(query));
|
||||
setLoading(true);
|
||||
setHistory((arr) => (arr || []).concat(queryRef.current));
|
||||
const e = await relayRequest(localRelay, [filter]);
|
||||
setEvents(e);
|
||||
setHistory((arr) => (arr ? (!arr.includes(query) ? [query, ...arr] : arr) : [query]));
|
||||
setEvents([]);
|
||||
|
||||
if (sub) sub.close();
|
||||
|
||||
let r: Relay | CacheRelay = localRelay;
|
||||
if (queryRelay.isOpen) {
|
||||
const url = validateRelayURL(relayURL);
|
||||
if (!relay || relay.url !== url.toString()) {
|
||||
if (relay) relay.close();
|
||||
r = new Relay(url.toString());
|
||||
await r.connect();
|
||||
setRelay(r);
|
||||
} else r = relay;
|
||||
} else {
|
||||
if (relay) {
|
||||
relay.close();
|
||||
setRelay(null);
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((res) => {
|
||||
let buffer: NostrEvent[] = [];
|
||||
const flush = _throttle(() => setEvents([...buffer]), 1000 / 10, { trailing: true });
|
||||
|
||||
const s = r.subscribe([filter], {
|
||||
onevent: (e) => {
|
||||
buffer.push(e);
|
||||
flush();
|
||||
},
|
||||
oneose: () => {
|
||||
setEvents([...buffer]);
|
||||
res();
|
||||
},
|
||||
});
|
||||
setSub(s);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error) setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [queryRelay.isOpen, query, relayURL, relay, sub]);
|
||||
|
||||
const submitRef = useRef(loadEvents);
|
||||
submitRef.current = loadEvents;
|
||||
|
||||
const submitCode = useCallback(() => submitRef.current(), []);
|
||||
|
||||
const downloadEvents = () => {
|
||||
const lines = events.map((e) => stringify(e)).join("\n");
|
||||
const file = new File([lines], "events.json", { type: "application/jsonl" });
|
||||
const url = URL.createObjectURL(file);
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||
<BackButton size="sm" />
|
||||
<Heading size="md">Event Console</Heading>
|
||||
<Switch size="sm" checked={queryRelay.isOpen} onChange={queryRelay.onToggle}>
|
||||
Query Relay
|
||||
</Switch>
|
||||
{queryRelay.isOpen && (
|
||||
<RelayUrlInput
|
||||
size="sm"
|
||||
borderRadius="md"
|
||||
w="xs"
|
||||
value={relayURL}
|
||||
onChange={(e) => setRelayURL(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<ButtonGroup ml="auto">
|
||||
<IconButton icon={<HelpCircle />} aria-label="Help" title="Help" size="sm" onClick={helpModal.onOpen} />
|
||||
<IconButton
|
||||
icon={<ClockRewind />}
|
||||
aria-label="History"
|
||||
@@ -124,7 +234,7 @@ export default function EventConsoleView() {
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
<FilterEditor value={query} onChange={setQuery} onRun={loadEvents} />
|
||||
<FilterEditor value={query} onChange={setQuery} onRun={submitCode} />
|
||||
|
||||
{error && (
|
||||
<Alert status="error">
|
||||
@@ -139,6 +249,20 @@ export default function EventConsoleView() {
|
||||
|
||||
<Flex gap="2">
|
||||
<Text>{events.length} events</Text>
|
||||
{sub && (
|
||||
<Text color="green.500" ml="auto">
|
||||
Subscribed
|
||||
</Text>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<IconButton
|
||||
aria-label="Download Events"
|
||||
title="Download Events"
|
||||
icon={<DownloadIcon />}
|
||||
onClick={downloadEvents}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Box>
|
||||
<EventTimeline events={events} />
|
||||
@@ -154,6 +278,8 @@ export default function EventConsoleView() {
|
||||
historyDrawer.onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
<HelpModal isOpen={helpModal.isOpen} onClose={helpModal.onClose} />
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,33 @@
|
||||
import dayjs from "dayjs";
|
||||
import { Filter, nip19 } from "nostr-tools";
|
||||
|
||||
function processDateString(date: string) {
|
||||
if (date.startsWith("n")) {
|
||||
const match = date.match(/n([+-])(\d+)([hwmsd])?/i);
|
||||
if (match === null) throw new Error(`Cant parse relative date string ${date}`);
|
||||
|
||||
if (match[1] === "-") {
|
||||
return (
|
||||
dayjs()
|
||||
// @ts-expect-error
|
||||
.subtract(parseInt(match[2]), match[3] || "h")
|
||||
.unix()
|
||||
);
|
||||
} else if (match[1] === "+") {
|
||||
return (
|
||||
dayjs()
|
||||
// @ts-expect-error
|
||||
.add(parseInt(match[2]), match[3] || "h")
|
||||
.unix()
|
||||
);
|
||||
} else throw Error(`Unknown operation ${match[1]}`);
|
||||
} else if (date.toLowerCase() === "now") {
|
||||
return dayjs().unix();
|
||||
}
|
||||
|
||||
throw new Error(`Unknown date string ${date}`);
|
||||
}
|
||||
|
||||
export async function processFilter(f: Filter): Promise<Filter> {
|
||||
const filter = JSON.parse(JSON.stringify(f)) as Filter;
|
||||
|
||||
@@ -9,5 +37,12 @@ export async function processFilter(f: Filter): Promise<Filter> {
|
||||
return p;
|
||||
});
|
||||
|
||||
if (typeof filter.since === "string") {
|
||||
filter.since = processDateString(filter.since);
|
||||
}
|
||||
if (typeof filter.until === "string") {
|
||||
filter.until = processDateString(filter.until);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
@@ -1,4 +1,7 @@
|
||||
import { type JSONSchema7 } from "json-schema";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
const kindNumbers = Object.values(kinds).filter((t) => typeof t === "number") as number[];
|
||||
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
export const NostrFilterSchema: JSONSchema7 = {
|
||||
@@ -19,6 +22,8 @@ export const NostrFilterSchema: JSONSchema7 = {
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
examples: kindNumbers,
|
||||
},
|
||||
},
|
||||
authors: {
|
||||
@@ -34,14 +39,27 @@ export const NostrFilterSchema: JSONSchema7 = {
|
||||
type: "integer",
|
||||
description: "max number of events to return",
|
||||
default: 20,
|
||||
minimum: 0,
|
||||
},
|
||||
until: {
|
||||
type: "integer",
|
||||
description: "Return events before or on this date",
|
||||
oneOf: [
|
||||
{
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
},
|
||||
{ type: "string" },
|
||||
],
|
||||
},
|
||||
since: {
|
||||
type: "integer",
|
||||
description: "Return events after or on this date",
|
||||
oneOf: [
|
||||
{
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
},
|
||||
{ type: "string" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -2026,7 +2026,7 @@
|
||||
human-id "^1.0.2"
|
||||
prettier "^2.7.1"
|
||||
|
||||
"@codemirror/autocomplete@^6.0.0":
|
||||
"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.12.0":
|
||||
version "6.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz#3fa620a8a3f42ded7751749916e8375f6bbbb333"
|
||||
integrity sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==
|
||||
@@ -2054,7 +2054,7 @@
|
||||
"@codemirror/language" "^6.0.0"
|
||||
"@lezer/json" "^1.0.0"
|
||||
|
||||
"@codemirror/language@^6.0.0":
|
||||
"@codemirror/language@^6.0.0", "@codemirror/language@^6.10.1":
|
||||
version "6.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.1.tgz#428c932a158cb75942387acfe513c1ece1090b05"
|
||||
integrity sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==
|
||||
|
Reference in New Issue
Block a user