From c1017ae25ba410808a3d40824ea7675505897c8a Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Thu, 15 Feb 2024 12:11:12 +0000 Subject: [PATCH] Finish Event console --- .changeset/popular-pandas-wave.md | 2 +- package.json | 2 + .../global/user-directory-provider.tsx | 2 +- src/views/lists/components/list-menu.tsx | 2 +- src/views/tools/event-console/event-row.tsx | 35 +++- src/views/tools/event-console/help-modal.tsx | 51 ++++++ src/views/tools/event-console/index.tsx | 150 ++++++++++++++++-- src/views/tools/event-console/process.ts | 35 ++++ src/views/tools/event-console/schema.ts | 22 ++- yarn.lock | 4 +- 10 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 src/views/tools/event-console/help-modal.tsx diff --git a/.changeset/popular-pandas-wave.md b/.changeset/popular-pandas-wave.md index 6c09ce651..906f5ceb4 100644 --- a/.changeset/popular-pandas-wave.md +++ b/.changeset/popular-pandas-wave.md @@ -2,4 +2,4 @@ "nostrudel": minor --- -Added simple query events tool +Added Event Console tool diff --git a/package.json b/package.json index 6e9b3e22d..338364ac2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/providers/global/user-directory-provider.tsx b/src/providers/global/user-directory-provider.tsx index 37f1ea495..8b2bb2df5 100644 --- a/src/providers/global/user-directory-provider.tsx +++ b/src/providers/global/user-directory-provider.tsx @@ -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(() => []); diff --git a/src/views/lists/components/list-menu.tsx b/src/views/lists/components/list-menu.tsx index 8074bc40f..ba9f04c9a 100644 --- a/src/views/lists/components/list-menu.tsx +++ b/src/views/lists/components/list-menu.tsx @@ -23,7 +23,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit {!isSpecial && } {hasPeople && ( } + icon={} onClick={() => window.open(`https://www.makeprisms.com/create/${naddr}`, "_blank")} > Create $prism diff --git a/src/views/tools/event-console/event-row.tsx b/src/views/tools/event-console/event-row.tsx index 6b177ac97..04706e91d 100644 --- a/src/views/tools/event-console/event-row.tsx +++ b/src/views/tools/event-console/event-row.tsx @@ -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 && ( - + + ID: + {event.id} + + + + Pubkey: + {event.pubkey} + + + + Created: + {event.created_at} + + + {event.content} + + Tags: + + {event.tags.map((t, i) => ( + + {t.map((v, ii) => ( + + {v} + + ))} + + ))} + + )} diff --git a/src/views/tools/event-console/help-modal.tsx b/src/views/tools/event-console/help-modal.tsx new file mode 100644 index 000000000..dac1d85d5 --- /dev/null +++ b/src/views/tools/event-console/help-modal.tsx @@ -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) { + return ( + + + + Help + + + Keyboard shortcuts + + Ctrl+Shift+Enter: Run Filter + + + + Pubkeys + + + Typing @ inside any string will autocomplete with a list of users the app has seen + + + + Dates + + + since and until fields can both take relative times in the form of strings + + Examples: + + {["now", "n-3h", "n-5", "n+4m", "n-7d", "n-30s", "n-4w"].map((t) => ( + {t} + ))} + + + + + ); +} diff --git a/src/views/tools/event-console/index.tsx b/src/views/tools/event-console/index.tsx index fd8b3825f..d23cd59ad 100644 --- a/src/views/tools/event-console/index.tsx +++ b/src/views/tools/event-console/index.tsx @@ -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("console-history", []); + const helpModal = useDisclosure(); + const queryRelay = useDisclosure(); + const [relayURL, setRelayURL] = useState(""); + const [relay, setRelay] = useState(null); + + const [sub, setSub] = useState(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([]); 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((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 ( - + Event Console + + Query Relay + + {queryRelay.isOpen && ( + setRelayURL(e.target.value)} + /> + )} + } aria-label="Help" title="Help" size="sm" onClick={helpModal.onOpen} /> } aria-label="History" @@ -124,7 +234,7 @@ export default function EventConsoleView() { - + {error && ( @@ -139,6 +249,20 @@ export default function EventConsoleView() { {events.length} events + {sub && ( + + Subscribed + + )} + {events.length > 0 && ( + } + onClick={downloadEvents} + size="xs" + /> + )} @@ -154,6 +278,8 @@ export default function EventConsoleView() { historyDrawer.onClose(); }} /> + + ); } diff --git a/src/views/tools/event-console/process.ts b/src/views/tools/event-console/process.ts index 9c2600b53..3f56372a8 100644 --- a/src/views/tools/event-console/process.ts +++ b/src/views/tools/event-console/process.ts @@ -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 { const filter = JSON.parse(JSON.stringify(f)) as Filter; @@ -9,5 +37,12 @@ export async function processFilter(f: Filter): Promise { return p; }); + if (typeof filter.since === "string") { + filter.since = processDateString(filter.since); + } + if (typeof filter.until === "string") { + filter.until = processDateString(filter.until); + } + return filter; } diff --git a/src/views/tools/event-console/schema.ts b/src/views/tools/event-console/schema.ts index 8d4340b72..6dfb636b9 100644 --- a/src/views/tools/event-console/schema.ts +++ b/src/views/tools/event-console/schema.ts @@ -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" }, + ], }, }, }; diff --git a/yarn.lock b/yarn.lock index 80844cb21..8af1869ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==