add event publisher tool

This commit is contained in:
hzrd149 2024-02-29 12:38:26 +00:00
parent 3359064415
commit 16ae69c3a6
12 changed files with 461 additions and 118 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add event publisher tool

View File

@ -101,6 +101,7 @@ const NetworkMuteGraphView = lazy(() => import("./views/tools/network-mute-graph
const NetworkDMGraphView = lazy(() => import("./views/tools/network-dm-graph"));
const UnknownTimelineView = lazy(() => import("./views/tools/unknown-event-feed"));
const EventConsoleView = lazy(() => import("./views/tools/event-console"));
const EventPublisherView = lazy(() => import("./views/tools/event-publisher"));
const UserStreamsTab = lazy(() => import("./views/user/streams"));
const StreamsView = lazy(() => import("./views/streams"));
@ -323,6 +324,7 @@ const router = createHashRouter([
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
{ path: "unknown", element: <UnknownTimelineView /> },
{ path: "console", element: <EventConsoleView /> },
{ path: "publisher", element: <EventPublisherView /> },
],
},
{

View File

@ -19,6 +19,7 @@ import ShieldOff from "../../components/icons/shield-off";
import Users01 from "../../components/icons/users-01";
import Film02 from "../../components/icons/film-02";
import MessageQuestionSquare from "../../components/icons/message-question-square";
import UploadCloud01 from "../../components/icons/upload-cloud-01";
export const internalApps: App[] = [
{
@ -96,6 +97,13 @@ export const internalTools: App[] = [
id: "console",
to: "/tools/console",
},
{
title: "Event Publisher",
description: "Write and publish events",
icon: UploadCloud01,
id: "publisher",
to: "/tools/publisher ",
},
{ title: "WoT Test", description: "Just a test for now", icon: Users01, id: "wot-test", to: "/tools/wot-test" },
];

View File

@ -1,12 +1,14 @@
import { Box, Code, Flex, Heading, Text, useDisclosure } from "@chakra-ui/react";
import { Box, Code, Flex, Heading, Switch, Text, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import ExpandButton from "./expand-button";
import UserName from "../../../components/user/user-name";
import { CopyIconButton } from "../../../components/copy-icon-button";
import Timestamp from "../../../components/timestamp";
import stringify from "json-stringify-deterministic";
export default function EventRow({ event }: { event: NostrEvent }) {
const expanded = useDisclosure();
const raw = useDisclosure();
return (
<>
@ -32,39 +34,62 @@ export default function EventRow({ event }: { event: NostrEvent }) {
</Flex>
{expanded.isOpen && (
<Flex gap="2" direction="column" px="2" pb="2" alignItems="flex-start">
<Flex gap="2">
<Text>ID: </Text>
<Code>{event.id}</Code>
<CopyIconButton text={event.id} aria-label="Copy ID" title="Copy ID" size="xs" />
<Flex gap="2" direction="column" px="2" pb="2" alignItems="flex-start" position="relative">
<Flex
top="2"
right="4"
position="absolute"
gap="2"
alignItems="center"
p="2"
bg="var(--chakra-colors-chakra-body-bg)"
borderRadius="md"
>
{raw.isOpen && <CopyIconButton text={stringify(event, { space: " " })} aria-label="Copy json" size="sm" />}
<Switch size="sm" checked={!raw.isOpen} onChange={raw.onToggle}>
Raw
</Switch>
</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>
{raw.isOpen ? (
<Code whiteSpace="pre" w="full" overflow="auto">
{stringify(event, { space: " " })}
</Code>
) : (
<>
<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>
))}
</Flex>
</Box>
</Box>
</>
)}
</Flex>
)}
</>

View File

@ -0,0 +1,64 @@
import { memo, useMemo } from "react";
import { useColorMode } from "@chakra-ui/react";
import ReactCodeMirror from "@uiw/react-codemirror";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { jsonSchema } from "codemirror-json-schema";
import { keymap } from "@codemirror/view";
import { useInterval } from "react-use";
import _throttle from "lodash.throttle";
import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { jsonLanguage } from "@codemirror/lang-json";
import { syntaxTree } from "@codemirror/language";
import { NostrFilterSchema } from "./schema";
import { UserDirectory, useUserSearchDirectoryContext } from "../../../providers/global/user-directory-provider";
import { codeMirrorUserAutocomplete, updateCodeMirrorUserAutocomplete } from "./user-autocomplete";
const FilterEditor = memo(
({ value, onChange, onRun }: { value: string; onChange: (v: string) => void; onRun: () => void }) => {
const getDirectory = useUserSearchDirectoryContext();
const { colorMode } = useColorMode();
useInterval(() => {
updateCodeMirrorUserAutocomplete(getDirectory());
}, 1000);
const extensions = useMemo(
() => [
keymap.of([
{
win: "Ctrl-Enter",
linux: "Ctrl-Enter",
mac: "Cmd-Enter",
preventDefault: true,
run: () => {
onRun();
return true;
},
shift: () => {
onRun();
return true;
},
},
]),
jsonSchema(NostrFilterSchema),
jsonLanguage.data.of({
autocomplete: codeMirrorUserAutocomplete,
}),
],
[onRun],
);
return (
<ReactCodeMirror
value={value}
onChange={onChange}
height="200px"
lang="json"
extensions={extensions}
theme={colorMode === "light" ? githubLight : githubDark}
/>
);
},
);
export default FilterEditor;

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useRef, useState } from "react";
import {
Alert,
AlertDescription,
@ -13,24 +13,15 @@ import {
IconButton,
Switch,
Text,
useColorMode,
useDisclosure,
} from "@chakra-ui/react";
import ReactCodeMirror from "@uiw/react-codemirror";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { jsonSchema } from "codemirror-json-schema";
import { NostrEvent, Relay, Subscription } from "nostr-tools";
import { keymap } from "@codemirror/view";
import { useInterval, useLocalStorage } from "react-use";
import { 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/router/back-button";
import { NostrFilterSchema } from "./schema";
import { localRelay } from "../../../services/local-relay";
import Play from "../../../components/icons/play";
import ClockRewind from "../../../components/icons/clock-rewind";
@ -43,78 +34,7 @@ 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([
{
win: "Ctrl-Enter",
linux: "Ctrl-Enter",
mac: "Cmd-Enter",
preventDefault: true,
run: () => {
onRun();
return true;
},
shift: () => {
onRun();
return true;
},
},
]),
jsonSchema(NostrFilterSchema),
jsonLanguage.data.of({
autocomplete: userAutocomplete,
}),
],
[onRun],
);
return (
<ReactCodeMirror
value={value}
onChange={onChange}
height="200px"
lang="json"
extensions={extensions}
theme={colorMode === "light" ? githubLight : githubDark}
/>
);
},
);
import FilterEditor from "./filter-editor";
const EventTimeline = memo(({ events }: { events: NostrEvent[] }) => {
return (

View File

@ -1,8 +1,10 @@
import dayjs from "dayjs";
import { Filter, nip19 } from "nostr-tools";
function processDateString(date: string) {
if (date.startsWith("n")) {
export function processDateString(date: string) {
if (date.toLowerCase() === "now" || date.toLowerCase() === "n") {
return dayjs().unix();
} else if (date.startsWith("n")) {
const match = date.match(/n([+-])(\d+)([hwmsd])?/i);
if (match === null) throw new Error(`Cant parse relative date string ${date}`);
@ -21,8 +23,6 @@ function processDateString(date: string) {
.unix()
);
} else throw Error(`Unknown operation ${match[1]}`);
} else if (date.toLowerCase() === "now") {
return dayjs().unix();
}
throw new Error(`Unknown date string ${date}`);

View File

@ -0,0 +1,33 @@
import _throttle from "lodash.throttle";
import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { syntaxTree } from "@codemirror/language";
import { UserDirectory } from "../../../providers/global/user-directory-provider";
let users: UserDirectory = [];
export function codeMirrorUserAutocomplete(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",
})),
};
}
export function updateCodeMirrorUserAutocomplete(newUsers: UserDirectory) {
users = newUsers;
}

View File

@ -0,0 +1,62 @@
import { memo, useMemo } from "react";
import { useColorMode } from "@chakra-ui/react";
import ReactCodeMirror from "@uiw/react-codemirror";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
import { jsonSchema } from "codemirror-json-schema";
import { keymap } from "@codemirror/view";
import { useInterval } from "react-use";
import _throttle from "lodash.throttle";
import { jsonLanguage } from "@codemirror/lang-json";
import { NostrEventSchema } from "./schema";
import { useUserSearchDirectoryContext } from "../../../providers/global/user-directory-provider";
import { updateCodeMirrorUserAutocomplete, codeMirrorUserAutocomplete } from "../event-console/user-autocomplete";
const EventEditor = memo(
({ value, onChange, onRun }: { value: string; onChange: (v: string) => void; onRun?: () => void }) => {
const getDirectory = useUserSearchDirectoryContext();
const { colorMode } = useColorMode();
useInterval(() => {
updateCodeMirrorUserAutocomplete(getDirectory());
}, 1000);
const extensions = useMemo(
() => [
keymap.of([
{
win: "Ctrl-Enter",
linux: "Ctrl-Enter",
mac: "Cmd-Enter",
preventDefault: true,
run: () => {
if (onRun) onRun();
return true;
},
shift: () => {
if (onRun) onRun();
return true;
},
},
]),
jsonSchema(NostrEventSchema),
jsonLanguage.data.of({
autocomplete: codeMirrorUserAutocomplete,
}),
],
[onRun],
);
return (
<ReactCodeMirror
value={value}
onChange={onChange}
height="200px"
lang="json"
extensions={extensions}
theme={colorMode === "light" ? githubLight : githubDark}
/>
);
},
);
export default EventEditor;

View File

@ -0,0 +1,170 @@
import { useMemo, useState } from "react";
import {
Button,
ButtonGroup,
Code,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useToast,
} from "@chakra-ui/react";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import BackButton from "../../../components/router/back-button";
import Play from "../../../components/icons/play";
import EventEditor from "./event-editor";
import { EventTemplate, NostrEvent, UnsignedEvent, getEventHash, verifyEvent } from "nostr-tools";
import dayjs from "dayjs";
import { processEvent } from "./process";
import { WritingIcon } from "../../../components/icons";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import useCurrentAccount from "../../../hooks/use-current-account";
import UserAvatar from "../../../components/user/user-avatar";
export default function EventPublisherView() {
const toast = useToast();
const [loading, setLoading] = useState(false);
const { requestSignature } = useSigningContext();
const publish = usePublishEvent();
const account = useCurrentAccount();
const defaultEvent = useMemo(
() =>
JSON.stringify(
{ kind: 1234, content: "", tags: [], created_at: dayjs().unix() } satisfies EventTemplate,
null,
2,
),
[],
);
const [value, setValue] = useState(defaultEvent);
const [draft, setDraft] = useState<NostrEvent | EventTemplate>();
const submitEvent = () => {
try {
const draft = processEvent(JSON.parse(value) as UnsignedEvent);
if (account) draft.pubkey = account.pubkey;
(draft as NostrEvent).id = getEventHash(draft);
setDraft(draft);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const sign = async () => {
if (!draft) return;
try {
setDraft(await requestSignature(draft));
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const publishDraft = async () => {
if (!draft || !(draft as NostrEvent).sig) return;
try {
setLoading(true);
const valid = verifyEvent(draft as NostrEvent);
if (!valid) throw new Error("Invalid event");
await publish("Custom Event", draft);
setDraft(undefined);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};
const yolo = async () => {
try {
setLoading(true);
const draft = processEvent(JSON.parse(value) as UnsignedEvent);
if (account) draft.pubkey = account.pubkey;
(draft as NostrEvent).id = getEventHash(draft);
const event = await requestSignature(draft);
const valid = verifyEvent(event);
if (!valid) throw new Error("Invalid event");
await publish("Custom Event", event);
setDraft(undefined);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};
return (
<>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<BackButton size="sm" />
<Heading size="md">Event Publisher</Heading>
<ButtonGroup ml="auto">
{/* <IconButton icon={<HelpCircle />} aria-label="Help" title="Help" size="sm" onClick={helpModal.onOpen} /> */}
<Button colorScheme="primary" onClick={submitEvent} leftIcon={<Play />} size="sm">
Publish
</Button>
</ButtonGroup>
</Flex>
<EventEditor value={value} onChange={(v) => setValue(v)} onRun={submitEvent} />
</VerticalPageLayout>
{draft && (
<Modal isOpen onClose={() => setDraft(undefined)} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Publish Event</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" pb="2" pt="0">
<Heading size="md" mt="2">
1. Event ID
</Heading>
<Code w="full" p="2" overflow="auto">
{(draft as NostrEvent).id}
</Code>
<Heading size="md" mt="2">
2. Pubkey
</Heading>
<Flex gap="2" alignItems="center">
<Code w="full" p="2" overflow="auto">
{(draft as NostrEvent).pubkey}
</Code>
<UserAvatar pubkey={(draft as NostrEvent).pubkey} />
</Flex>
<Heading size="md" whiteSpace="pre" mt="2">
3. Signature
</Heading>
<Flex gap="2" alignItems="center">
<Code overflow="auto" whiteSpace="pre" w="full" p="2">
{(draft as NostrEvent).sig}
</Code>
<Button leftIcon={<WritingIcon boxSize={5} />} flexShrink={0} onClick={sign} ml="auto">
Sign
</Button>
</Flex>
{(draft as NostrEvent).sig && (
<Button w="full" colorScheme="primary" mt="2" isLoading={loading} onClick={publishDraft}>
Publish
</Button>
)}
</ModalBody>
<ModalFooter>
<Button mr={2} onClick={() => setDraft(undefined)}>
Cancel
</Button>
<Button colorScheme="primary" onClick={yolo} isLoading={loading}>
Yolo
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
}

View File

@ -0,0 +1,10 @@
import { EventTemplate, UnsignedEvent } from "nostr-tools";
import { processDateString } from "../event-console/process";
export function processEvent(event: UnsignedEvent): UnsignedEvent {
if (typeof event.created_at === "string") {
event.created_at = processDateString(event.created_at);
}
return event;
}

View File

@ -0,0 +1,44 @@
import dayjs from "dayjs";
import { type JSONSchema7 } from "json-schema";
export const NostrEventSchema: JSONSchema7 = {
type: "object",
required: ["kind", "created_at", "tags", "content"],
properties: {
id: {
type: "string",
description: "The id of the event",
},
kind: {
type: "integer",
description: "The kind of event",
minimum: 0,
},
pubkey: {
type: "string",
description: "The owner of the event",
},
created_at: {
description: "The unix timestamp the event was created at",
oneOf: [
{
type: "integer",
minimum: 0,
default: dayjs().unix(),
},
{ type: "string" },
],
},
tags: {
type: "array",
description: "Event metadata tags",
items: {
type: "array",
minItems: 1,
items: {
type: "string",
},
},
},
},
};