Add templates to event publisher

added edit button to event debug modal
This commit is contained in:
hzrd149
2024-09-20 13:07:29 -05:00
parent 1c9d12d851
commit 6e6baa7a98
16 changed files with 815 additions and 131 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add edit button to event debug modal

View File

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

View File

@@ -55,6 +55,7 @@
"emoji-regex": "^10.3.0",
"emojilib": "^3",
"framer-motion": "^10.16.0",
"handlebars": "^4.7.8",
"hls.js": "^1.4.14",
"i18n-iso-countries": "^7.12.0",
"idb": "^8.0.0",

View File

@@ -237,6 +237,14 @@ const router = createHashRouter([
</RouteProviders>
),
},
{
path: "/tools/publisher",
element: (
<RouteProviders>
<EventPublisherView />
</RouteProviders>
),
},
{
path: "/",
element: <RootPage />,
@@ -392,7 +400,6 @@ const router = createHashRouter([
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
{ path: "unknown", element: <UnknownTimelineView /> },
{ path: "console", element: <EventConsoleView /> },
{ path: "publisher", element: <EventPublisherView /> },
{ path: "corrections", element: <CorrectionsFeedView /> },
],
},

View File

@@ -17,6 +17,7 @@ import {
AccordionPanelProps,
Button,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { ModalProps } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
@@ -27,6 +28,7 @@ import { CopyIconButton } from "../copy-icon-button";
import DebugEventTags from "./event-tags";
import relayHintService from "../../services/event-relay-hint";
import { usePublishEvent } from "../../providers/global/publish-provider";
import { EditIcon } from "../icons";
function Section({
label,
@@ -112,7 +114,22 @@ export default function EventDebugModal({ event, ...props }: { event: NostrEvent
<Section
label="JSON"
p="0"
actions={<CopyIconButton aria-label="copy json" value={JSON.stringify(event, null, 2)} size="sm" />}
actions={
<>
<Button
leftIcon={<EditIcon />}
as={RouterLink}
to="/tools/publisher"
size="sm"
state={{ draft: event }}
colorScheme="primary"
mr="2"
>
Edit
</Button>
<CopyIconButton aria-label="copy json" value={JSON.stringify(event, null, 2)} size="sm" />
</>
}
>
<JsonCode data={event} />
</Section>

View File

@@ -1,20 +1,28 @@
import { Input, InputProps } from "@chakra-ui/react";
import { forwardRef } from "react";
import { useAsync } from "react-use";
import { forwardRef, useEffect, useState } from "react";
import { nip19 } from "nostr-tools";
import { useUserSearchDirectoryContext } from "../providers/global/user-directory-provider";
import userMetadataService from "../services/user-metadata";
import { getDisplayName } from "../helpers/nostr/user-metadata";
import { getDisplayName, Kind0ParsedContent } from "../helpers/nostr/user-metadata";
import useAppSettings from "../hooks/use-app-settings";
const UserAutocomplete = forwardRef<HTMLInputElement, InputProps>(({ value, ...props }, ref) => {
type UserAutocompleteProps = InputProps & {
hex?: boolean;
};
const UserAutocomplete = forwardRef<HTMLInputElement, UserAutocompleteProps>(({ value, hex, ...props }, ref) => {
const getDirectory = useUserSearchDirectoryContext();
const { removeEmojisInUsernames } = useAppSettings();
const { value: users } = useAsync(async () => {
const dir = await getDirectory();
return dir.map(({ pubkey }) => ({ pubkey, metadata: userMetadataService.getSubject(pubkey).value }));
const [users, setUsers] = useState<{ pubkey: string; names: string[]; metadata?: Kind0ParsedContent }[]>([]);
useEffect(() => {
const dir = getDirectory();
setUsers(
dir.map(({ pubkey, names }) => ({ pubkey, names, metadata: userMetadataService.getSubject(pubkey).value })),
);
}, [getDirectory]);
return (
@@ -22,13 +30,11 @@ const UserAutocomplete = forwardRef<HTMLInputElement, InputProps>(({ value, ...p
<Input placeholder="Users" list="users" value={value} {...props} ref={ref} />
{users && (
<datalist id="users">
{users
.filter((p) => !!p.metadata)
.map(({ metadata, pubkey }) => (
<option key={pubkey} value={nip19.npubEncode(pubkey)}>
{getDisplayName(metadata, pubkey, removeEmojisInUsernames)}
</option>
))}
{users.map(({ metadata, pubkey, names }) => (
<option key={pubkey} value={hex ? pubkey : nip19.npubEncode(pubkey)}>
{names[0] || getDisplayName(metadata, pubkey, removeEmojisInUsernames)}
</option>
))}
</datalist>
)}
</>

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import { Flex, IconButton, Input, InputProps } from "@chakra-ui/react";
import ClockStopwatch from "../../../../components/icons/clock-stopwatch";
export default function TimestampInput({
timestamp,
onChange,
...props
}: { timestamp?: number; onChange: (ts: number) => void } & Omit<InputProps, "type" | "value" | "onChange">) {
const [value, setValue] = useState(() => (timestamp ? dayjs.unix(timestamp) : dayjs()).format("YYYY-MM-DDTHH:mm"));
useEffect(() => {
if (timestamp) {
setValue(dayjs.unix(timestamp).format("YYYY-MM-DDTHH:mm"));
}
}, [timestamp]);
const handleDateChange = (value: string) => {
setValue(value);
const date = dayjs(value, "YYYY-MM-DDTHH:mm");
if (date.isValid()) {
onChange(date.unix());
}
};
return (
<Flex gap="2" flex={1}>
<Input type="datetime-local" value={value} onChange={(e) => handleDateChange(e.target.value)} {...props} />
<IconButton
icon={<ClockStopwatch boxSize={5} />}
aria-label="Set to now"
onClick={() => onChange(dayjs().unix())}
size={props.size}
/>
</Flex>
);
}

View File

@@ -0,0 +1,147 @@
import {
ButtonGroup,
CloseButton,
Flex,
FormControl,
FormLabel,
Heading,
IconButton,
Input,
Select,
} from "@chakra-ui/react";
import { kinds } from "nostr-tools";
import { LooseEventTemplate } from "../../process";
import MagicTextArea from "../../../../../components/magic-textarea";
import TimestampInput from "../datetime-input";
import Plus from "../../../../../components/icons/plus";
import Minus from "../../../../../components/icons/minus";
const KindOptions = (
Object.entries(kinds).filter(([name, value]) => typeof value === "number") as [string, number][]
).sort((a, b) => a[1] - b[1]);
export default function EventTemplateEditor({
draft,
onChange,
}: {
draft: LooseEventTemplate;
onChange: (draft: LooseEventTemplate) => void;
}) {
const setTagValue = (index: number, tagIndex: number, value: string) => {
onChange({
...draft,
tags: draft.tags.map((t, i) => {
if (i === index) {
const newTag = Array.from(t);
newTag[tagIndex] = value;
return newTag;
}
return t;
}),
});
};
const removeTag = (index: number) => {
const tags = Array.from(draft.tags);
tags.splice(index, 1);
onChange({ ...draft, tags });
};
const addTagValue = (index: number) => {
onChange({
...draft,
tags: draft.tags.map((tag, i) => {
if (i === index) return [...tag, ""];
return tag;
}),
});
};
const removeTagValue = (index: number) => {
onChange({
...draft,
tags: draft.tags.map((tag, i) => {
if (i === index) return tag.slice(0, -1);
return tag;
}),
});
};
return (
<Flex direction="column" gap="2">
<FormControl>
<FormLabel htmlFor="content">Kind</FormLabel>
<Select value={draft.kind} onChange={(e) => onChange({ ...draft, kind: parseInt(e.target.value) })}>
{KindOptions.map(([name, kind]) => (
<option value={kind} key={name + kind}>
{name} ({kind})
</option>
))}
</Select>
</FormControl>
<FormControl>
<FormLabel htmlFor="content">Content</FormLabel>
<MagicTextArea
id="content"
name="content"
value={draft.content}
onChange={(e) => onChange({ ...draft, content: e.target.value })}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="content">Created At</FormLabel>
<TimestampInput
timestamp={typeof draft.created_at === "number" ? draft.created_at : undefined}
onChange={(ts) => onChange({ ...draft, created_at: ts })}
/>
</FormControl>
<Flex gap="2" alignItems="center">
<Heading size="sm">Tags</Heading>
<IconButton
size="xs"
ml="auto"
colorScheme="primary"
icon={<Plus boxSize={5} />}
aria-label="Add Tag"
onClick={() => onChange({ ...draft, tags: [...draft.tags, ["tag", "value"]] })}
/>
</Flex>
{draft.tags.map((tag, index) => (
<Flex key={index} gap="2" alignItems="center" wrap="wrap">
{tag.map((v, i) => (
<Input
key={i}
value={v}
onChange={(e) => setTagValue(index, i, e.target.value)}
minW={i === 0 ? undefined : "xs"}
maxW={i === 0 ? "40" : undefined}
fontWeight={i === 0 ? "bold" : undefined}
w="auto"
flex={1}
/>
))}
<ButtonGroup isAttached ml="auto">
<IconButton
size="sm"
ml="auto"
icon={<Plus boxSize={5} />}
aria-label="Add Tag"
onClick={() => addTagValue(index)}
/>
<IconButton
size="sm"
ml="auto"
icon={<Minus boxSize={5} />}
aria-label="Add Tag"
onClick={() => removeTagValue(index)}
isDisabled={tag.length === 1}
/>
<CloseButton onClick={() => removeTag(index)} />
</ButtonGroup>
</Flex>
))}
</Flex>
);
}

View File

@@ -0,0 +1,98 @@
import { memo, useMemo, useState } from "react";
import { Text, useColorMode } from "@chakra-ui/react";
import ReactCodeMirror from "@uiw/react-codemirror";
import { EventTemplate } from "nostr-tools";
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";
import { LooseEventTemplate } from "../../process";
const EventJsonEditor = memo(
({
draft,
onChange,
onRun,
}: {
draft?: LooseEventTemplate;
onChange: (v: EventTemplate) => void;
onRun?: () => void;
}) => {
const getDirectory = useUserSearchDirectoryContext();
const { colorMode } = useColorMode();
const [value, setValue] = useState(JSON.stringify(draft, null, 2));
const [error, setError] = useState<Error>();
const handleChange = (v: string) => {
try {
setError(undefined);
setValue(v);
const json = JSON.parse(v);
if (json.content === undefined) throw new Error("Missing content");
if (json.created_at === undefined) throw new Error("Missing created_at");
if (json.kind === undefined) throw new Error("Missing kind");
if (!Array.isArray(json.tags)) throw new Error("Missing tags");
onChange(json);
} catch (error) {
if (error instanceof Error) setError(error);
}
};
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={handleChange}
height="100%"
lang="json"
extensions={extensions}
theme={colorMode === "light" ? githubLight : githubDark}
/>
{error && (
<Text fontSize="sm" color="red.500">
{error.message}
</Text>
)}
</>
);
},
);
export default EventJsonEditor;

View File

@@ -0,0 +1,178 @@
import { memo, ReactNode } from "react";
import {
Flex,
FlexProps,
IconButton,
Input,
Menu,
MenuButton,
MenuItem,
MenuList,
Select,
Text,
} from "@chakra-ui/react";
import { EventTemplate, NostrEvent } from "nostr-tools";
import { EnumVar, PubkeyVar, StringVar, TimestampVar, Variable } from "../../process";
import TimestampInput from "../datetime-input";
import PuzzlePiece01 from "../../../../../components/icons/puzzle-piece-01";
import UserAutocomplete from "../../../../../components/user-autocomplete";
function EnumVariableRow({
variable,
onChange,
}: {
variable: EnumVar;
onChange: (name: string, value: string) => void;
}) {
return (
<Select value={variable.value} size="sm" rounded="md" onChange={(e) => onChange(variable.name, e.target.value)}>
{variable.options?.map((opts) => (
<option value={opts} key={opts}>
{opts}
</option>
))}
</Select>
);
}
function StringVariableRow({
variable,
onChange,
}: {
variable: StringVar;
onChange: (name: string, value: string) => void;
}) {
return (
<Input
type={variable.input || "text"}
value={variable.value}
placeholder={variable.placeholder}
onChange={(e) => onChange(variable.name, e.target.value)}
size="sm"
rounded="md"
/>
);
}
function PubkeyVariableRow({
variable,
onChange,
}: {
variable: PubkeyVar;
onChange: (name: string, value: string) => void;
}) {
return (
<UserAutocomplete
value={variable.value}
onChange={(e) => onChange(variable.name, e.target.value)}
size="sm"
rounded="md"
hex
/>
);
}
function TimestampVariableRow({
variable,
onChange,
}: {
variable: TimestampVar;
onChange: (name: string, value: string) => void;
}) {
const ts = parseInt(variable.value);
return (
<TimestampInput
timestamp={Number.isFinite(ts) ? ts : undefined}
onChange={(ts) => onChange(variable.name, String(ts))}
size="sm"
rounded="md"
/>
);
}
const VariableRow = memo(
({
variable,
onValueChange,
onTypeChange,
}: {
variable: Variable;
onValueChange: (name: string, value: string) => void;
onTypeChange: (name: string, type: string) => void;
}) => {
let content: ReactNode = null;
switch (variable.type) {
case "enum":
content = <EnumVariableRow variable={variable} onChange={onValueChange} />;
break;
case "string":
content = <StringVariableRow variable={variable} onChange={onValueChange} />;
break;
case "pubkey":
content = <PubkeyVariableRow variable={variable} onChange={onValueChange} />;
break;
case "timestamp":
content = <TimestampVariableRow variable={variable} onChange={onValueChange} />;
break;
}
return (
<Flex gap="2" borderWidth="1px" rounded="md" p="2" alignItems="center">
<Text fontWeight="bold" flexShrink={0} mr="2">
{variable.name}
</Text>
{content}
<Menu>
<MenuButton as={IconButton} size="sm" aria-label="Options" icon={<PuzzlePiece01 />} variant="outline" />
<MenuList>
<MenuItem onClick={() => onTypeChange(variable.name, "string")}>String</MenuItem>
<MenuItem onClick={() => onTypeChange(variable.name, "pubkey")}>Pubkey</MenuItem>
<MenuItem onClick={() => onTypeChange(variable.name, "timestamp")}>Timestamp</MenuItem>
</MenuList>
</Menu>
</Flex>
);
},
);
export default function VariableEditor({
variables,
onChange,
...props
}: {
draft?: NostrEvent | EventTemplate;
variables: Variable[];
onChange: (variables: Variable[]) => void;
} & Omit<FlexProps, "children" | "onChange">) {
const setVariableValue = (name: string, value: string) => {
onChange(
variables.map((v) => {
if (v.name === name) return { ...v, value };
return v;
}),
);
};
const setVariableType = (name: string, type: string) => {
onChange(
variables.map((v) => {
if (v.name === name) return { ...v, type } as Variable;
return v;
}),
);
};
return (
<Flex direction="column" gap="2" {...props}>
{variables.map((variable) => (
<VariableRow
key={variable.name}
variable={variable}
onValueChange={setVariableValue}
onTypeChange={setVariableType}
/>
))}
</Flex>
);
}

View File

@@ -1,62 +0,0 @@
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

@@ -1,8 +1,9 @@
import { useMemo, useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
ButtonGroup,
Code,
Divider,
Flex,
Heading,
Modal,
@@ -12,78 +13,101 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Spacer,
Switch,
Text,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { EventTemplate, NostrEvent, UnsignedEvent, getEventHash, verifyEvent } from "nostr-tools";
import dayjs from "dayjs";
import { NostrEvent, UnsignedEvent, verifyEvent } from "nostr-tools";
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 { processEvent } from "./process";
import { WritingIcon } from "../../../components/icons";
import EventJsonEditor from "./components/json-editor";
import { getVariables, LooseEventTemplate, processEvent, Variable } from "./process";
import { EditIcon, 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";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { TEMPLATES } from "./templates";
import RequireCurrentAccount from "../../../providers/route/require-current-account";
import VariableEditor from "./components/variable-editor";
import EventTemplateEditor from "./components/event-template-editor";
import useRouteStateValue from "../../../hooks/use-route-state-value";
import { cloneEvent } from "../../../helpers/nostr/event";
export default function EventPublisherView() {
function EventPublisherPage({ initDraft }: { initDraft?: LooseEventTemplate }) {
const toast = useToast();
const [loading, setLoading] = useState(false);
const { requestSignature } = useSigningContext();
const publish = usePublishEvent();
const account = useCurrentAccount();
const account = useCurrentAccount()!;
const customRelay = useDisclosure();
const editor = useDisclosure({ defaultIsOpen: true });
const editRaw = useDisclosure();
const [customRelayURL, setCustomRelayURL] = useState("");
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 [variables, setVariables] = useState<Variable[]>(initDraft ? [] : TEMPLATES[0].variables);
const [draft, setDraft] = useState<LooseEventTemplate>(initDraft || TEMPLATES[0].template());
const [finalized, setFinalized] = useState<UnsignedEvent>();
// update the variables based on the template
useEffect(() => {
if (draft) {
const variableNames = getVariables(draft);
setVariables((current) => {
return variableNames.map((name) => {
return current.find((v) => v.name === name) || { type: "string", value: "", name };
});
});
}
}, [draft]);
const [processed, setProcessed] = useState<UnsignedEvent>();
useEffect(() => {
try {
if (!draft) return;
setProcessed(processEvent(draft, variables, account));
} catch (error) {}
}, [draft, account, variables]);
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);
const event = processEvent(draft, variables, account);
setFinalized(event);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const sign = async () => {
if (!draft) return;
if (!finalized) return;
try {
setDraft(await requestSignature(draft));
setFinalized(await requestSignature(finalized));
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const publishDraft = async () => {
if (!draft || !(draft as NostrEvent).sig) return;
if (!finalized || !(finalized as NostrEvent).sig) return;
try {
setLoading(true);
const valid = verifyEvent(draft as NostrEvent);
if (!valid) throw new Error("Invalid event");
if (customRelayURL) {
await publish("Custom Event", draft, [customRelayURL], true, true);
await publish("Custom Event", finalized, [customRelayURL], true, true);
} else {
await publish("Custom Event", draft);
await publish("Custom Event", finalized);
}
setDraft(undefined);
setFinalized(undefined);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
@@ -92,37 +116,46 @@ export default function EventPublisherView() {
const yolo = async () => {
try {
if (!account) return;
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 event = await requestSignature(processEvent(draft, variables, account));
const valid = verifyEvent(event);
if (!valid) throw new Error("Invalid event");
if (customRelayURL) {
await publish("Custom Event", draft, [customRelayURL], true, true);
await publish("Custom Event", event, [customRelayURL], true, true);
} else {
await publish("Custom Event", draft);
await publish("Custom Event", event);
}
setDraft(undefined);
setFinalized(undefined);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};
const selectTemplate = (name: string) => {
const template = TEMPLATES.find((t) => t.name === name);
if (template) {
setVariables(template.variables);
setDraft(template.template());
if (template.variables.length > 0) editor.onClose();
else editor.onOpen();
}
};
return (
<>
<VerticalPageLayout>
<Flex gap="2" alignItems="center" wrap="wrap">
<BackButton size="sm" />
<BackButton />
<Heading size="md">Event Publisher</Heading>
<Switch size="sm" isChecked={customRelay.isOpen} onChange={customRelay.onToggle}>
Publish to Relay
</Switch>
{customRelay.isOpen && (
<RelayUrlInput
size="sm"
borderRadius="md"
w="xs"
value={customRelayURL}
@@ -130,17 +163,64 @@ export default function EventPublisherView() {
/>
)}
<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">
<Button colorScheme="primary" onClick={submitEvent} leftIcon={<Play />}>
Publish
</Button>
</ButtonGroup>
</Flex>
<EventEditor value={value} onChange={(v) => setValue(v)} onRun={submitEvent} />
<Flex direction={{ base: "column", xl: "row" }} gap="2">
<Flex direction="column" gap="2" flex={1} overflow="hidden">
<Flex gap="2" alignItems="center">
<Text fontWeight="bold">Template</Text>
<Select onChange={(e) => selectTemplate(e.target.value)} w="auto">
{TEMPLATES.map((template) => (
<option value={template.name}>{template.name}</option>
))}
</Select>
<Spacer />
<ButtonGroup size="sm">
{editor.isOpen && (
<Button onClick={editRaw.onToggle} colorScheme={editRaw.isOpen ? "primary" : undefined}>
Raw
</Button>
)}
<Button
onClick={editor.onToggle}
colorScheme={editor.isOpen ? "primary" : undefined}
leftIcon={<EditIcon />}
>
Edit
</Button>
</ButtonGroup>
</Flex>
{editor.isOpen &&
(editRaw.isOpen ? (
<EventJsonEditor draft={draft} onChange={setDraft} onRun={submitEvent} />
) : (
<EventTemplateEditor draft={draft} onChange={setDraft} />
))}
<Flex gap="2">
<Heading size="md" mt="4">
Variables
</Heading>
</Flex>
<VariableEditor variables={variables} onChange={(v) => setVariables(v)} />
</Flex>
<Divider hideFrom="xl" />
<Flex flex={1} direction="column" gap="2" overflow="hidden">
<Code whiteSpace="pre" overflow="auto" p="2">
{JSON.stringify(processed, null, 2)}
</Code>
</Flex>
</Flex>
</VerticalPageLayout>
{draft && (
<Modal isOpen onClose={() => setDraft(undefined)} size="2xl">
{finalized && (
<Modal isOpen onClose={() => setFinalized(undefined)} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Publish Event</ModalHeader>
@@ -150,30 +230,30 @@ export default function EventPublisherView() {
1. Event ID
</Heading>
<Code w="full" p="2" overflow="auto">
{(draft as NostrEvent).id}
{(finalized 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}
{(finalized as NostrEvent).pubkey}
</Code>
<UserAvatar pubkey={(draft as NostrEvent).pubkey} />
<UserAvatar pubkey={(finalized 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}
{(finalized as NostrEvent).sig}
</Code>
<Button leftIcon={<WritingIcon boxSize={5} />} flexShrink={0} onClick={sign} ml="auto">
Sign
</Button>
</Flex>
{(draft as NostrEvent).sig && (
{(finalized as NostrEvent).sig && (
<Button w="full" colorScheme="primary" mt="2" isLoading={loading} onClick={publishDraft}>
Publish
</Button>
@@ -181,7 +261,7 @@ export default function EventPublisherView() {
</ModalBody>
<ModalFooter>
<Button mr={2} onClick={() => setDraft(undefined)}>
<Button mr={2} onClick={() => setFinalized(undefined)}>
Cancel
</Button>
<Button colorScheme="primary" onClick={yolo} isLoading={loading}>
@@ -194,3 +274,19 @@ export default function EventPublisherView() {
</>
);
}
export default function EventPublisherView() {
let { value: draft } = useRouteStateValue<NostrEvent>("draft");
if (draft && draft.sig) {
draft = { ...draft };
// @ts-ignore
delete draft.sig;
}
return (
<RequireCurrentAccount>
<EventPublisherPage initDraft={draft} />
</RequireCurrentAccount>
);
}

View File

@@ -1,10 +1,87 @@
import { EventTemplate, UnsignedEvent } from "nostr-tools";
import { processDateString } from "../event-console/process";
import { EventTemplate, getEventHash, NostrEvent, UnsignedEvent } from "nostr-tools";
import Handlebars from "handlebars";
import { processDateString } from "../event-console/process";
import { Account } from "../../../classes/accounts/account";
import { InputProps } from "@chakra-ui/react";
type BaseVariable<T, V> = {
type: T;
name: string;
value: string;
} & Partial<V>;
export type StringVar = BaseVariable<
"string",
{
input: InputProps["type"];
placeholder: string;
}
>;
export type EnumVar = BaseVariable<
"enum",
{
type: "enum";
options: string[];
}
>;
export type TimestampVar = BaseVariable<"timestamp", {}>;
export type PubkeyVar = BaseVariable<"pubkey", {}>;
export type Variable = StringVar | EnumVar | TimestampVar | PubkeyVar;
export type LooseEventTemplate = Omit<UnsignedEvent | NostrEvent | EventTemplate, "created_at"> & {
created_at: number | string | undefined;
};
function getVariablesFromString(str: string) {
if (str.length === 0) return;
const matches = str.matchAll(/{{([^{}]+)}}/g);
return Array.from(matches).map((match) => match[1].trim());
}
function setVariablesInString(str: string, variables: Record<string, any>) {
if (!str.includes("{{")) return str;
return Handlebars.compile(str)(variables);
}
export function getVariables(draft?: LooseEventTemplate) {
if (!draft) return [];
const variables: string[] = [];
const add = (vars?: string[]) => {
if (!vars) return;
for (const v of vars) {
if (!variables.includes(v)) variables.push(v);
}
};
add(getVariablesFromString(draft.content));
for (const tag of draft.tags) {
for (const v of tag) {
add(getVariablesFromString(v));
}
}
return variables;
}
export function processEvent(draft: LooseEventTemplate, variables: Variable[], account: Account): UnsignedEvent {
const event = { ...draft } as UnsignedEvent;
const vars: Record<string, string> = variables.reduce((dir, v) => ({ ...dir, [v.name]: v.value }), {});
event.content = setVariablesInString(event.content, vars);
event.tags = event.tags.map((tag) => tag.map((v) => setVariablesInString(v, vars)));
export function processEvent(event: UnsignedEvent): UnsignedEvent {
if (typeof event.created_at === "string") {
event.created_at = processDateString(event.created_at);
}
return event;
event.pubkey = account.pubkey;
// @ts-expect-error
event.id = getEventHash(event);
return event as UnsignedEvent;
}

View File

@@ -0,0 +1,38 @@
import { kinds } from "nostr-tools";
import { LooseEventTemplate, Variable } from "./process";
export const TEMPLATES: { name: string; variables: Variable[]; template: () => LooseEventTemplate }[] = [
{
name: "Short Text Note",
variables: [],
template: () => ({
kind: kinds.ShortTextNote,
content: "Hello World",
created_at: "now",
tags: [],
}),
},
{
name: "Live Stream",
variables: [
{ type: "enum", name: "status", value: "live", options: ["live", "ended"] },
{ type: "timestamp", name: "starts", value: "" },
{ type: "string", name: "streaming", input: "url", placeholder: "https://example.com/stream.m3u8", value: "" },
{ type: "string", name: "image", input: "url", placeholder: "https://example.com/stream-image.png", value: "" },
],
template: () => ({
content: "",
created_at: "now",
kind: kinds.LiveEvent,
tags: [
["d", "{{id}}"],
["title", "{{title}}"],
["summary", "{{summary}}"],
["streaming", "{{streaming}}"],
["status", "{{status}}"],
["starts", "{{starts}}"],
["image", "{{image}}"],
],
}),
},
];

View File

@@ -4825,6 +4825,18 @@ grapheme-splitter@^1.0.4:
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
handlebars@^4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
hard-rejection@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
@@ -6150,6 +6162,11 @@ minimist-options@^4.0.2:
is-plain-obj "^1.1.0"
kind-of "^6.0.3"
minimist@^1.2.5:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
mixme@^0.5.1:
version "0.5.10"
resolved "https://registry.yarnpkg.com/mixme/-/mixme-0.5.10.tgz#d653b2984b75d9018828f1ea333e51717ead5f51"
@@ -6204,6 +6221,11 @@ nearley@^2.20.1:
railroad-diagrams "^1.0.0"
randexp "0.4.6"
neo-async@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
ngeohash@^0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/ngeohash/-/ngeohash-0.6.3.tgz#10b1e80be5488262ec95c56cf2dbb6c45fbdf245"
@@ -7835,6 +7857,11 @@ typo-js@*:
resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.2.4.tgz#0e009c289a966dd51dc80a75580289a381cc607f"
integrity sha512-Oy/k+tFle5NAA3J/yrrYGfvEnPVrDZ8s8/WCwjUE75k331QyKIsFss7byQ/PzBmXLY6h1moRnZbnaxWBe3I3CA==
uglify-js@^3.1.4:
version "3.19.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
@@ -8134,6 +8161,11 @@ which@^1.2.9:
dependencies:
isexe "^2.0.0"
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
workbox-background-sync@7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-7.0.0.tgz#2b84b96ca35fec976e3bd2794b70e4acec46b3a5"