Finish Event console

This commit is contained in:
hzrd149
2024-02-15 12:11:12 +00:00
parent dd561308e3
commit c1017ae25b
10 changed files with 284 additions and 21 deletions

View File

@@ -2,4 +2,4 @@
"nostrudel": minor
---
Added simple query events tool
Added Event Console tool

View File

@@ -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",

View File

@@ -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>(() => []);

View File

@@ -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

View File

@@ -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>
)}
</>

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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" },
],
},
},
};

View File

@@ -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==