add simple relay set form

This commit is contained in:
hzrd149 2024-01-26 23:16:37 +00:00
parent 7d3c4ea3b0
commit 9d3384a7d6
9 changed files with 244 additions and 42 deletions

View File

@ -0,0 +1,29 @@
import { Button, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { safeRelayUrl } from "../../helpers/relay";
import { RelayUrlInput } from "../relay-url-input";
export default function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
const { register, handleSubmit, reset } = useForm({
defaultValues: {
url: "",
},
});
const submit = handleSubmit((values) => {
const url = safeRelayUrl(values.url);
if (!url) return;
onSubmit(url);
reset();
});
return (
<Flex as="form" display="flex" gap="2" onSubmit={submit}>
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" size="sm" borderRadius="md" />
<Button type="submit" size="sm">
Add
</Button>
</Flex>
);
}

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Button,
Drawer,
@ -12,22 +12,27 @@ import {
Heading,
IconButton,
Link,
Text,
Select,
useDisclosure,
} from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { CloseIcon } from "@chakra-ui/icons";
import { Link as RouterLink } from "react-router-dom";
import { NostrEvent } from "nostr-tools";
import { useReadRelays, useWriteRelays } from "../../hooks/use-client-relays";
import relayPoolService from "../../services/relay-pool";
import useSubject from "../../hooks/use-subject";
import { RelayUrlInput } from "../relay-url-input";
import clientRelaysService from "../../services/client-relays";
import { RelayMode } from "../../classes/relay";
import RelaySet from "../../classes/relay-set";
import { safeRelayUrl } from "../../helpers/relay";
import UploadCloud01 from "../icons/upload-cloud-01";
import { RelayFavicon } from "../relay-favicon";
import useUserRelaySets from "../../hooks/use-user-relay-sets";
import useCurrentAccount from "../../hooks/use-current-account";
import { getListName } from "../../helpers/nostr/lists";
import { getEventCoordinate } from "../../helpers/nostr/events";
import AddRelayForm from "./add-relay-form";
import { SaveRelaySetForm } from "./save-relay-set-form";
function RelayControl({ url }: { url: string }) {
const relay = useMemo(() => relayPoolService.requestRelay(url, false), [url]);
@ -79,29 +84,34 @@ function RelayControl({ url }: { url: string }) {
);
}
function AddRelayForm() {
const { register, handleSubmit, reset } = useForm({
defaultValues: {
url: "",
},
});
const submit = handleSubmit((values) => {
const url = safeRelayUrl(values.url);
if (!url) return;
clientRelaysService.addRelay(url, RelayMode.ALL);
reset();
});
function SelectRelaySet({
value,
onChange,
relaySets,
}: {
relaySets: NostrEvent[];
value?: string;
onChange: (cord: string) => void;
}) {
return (
<Flex as="form" display="flex" gap="2" onSubmit={submit}>
<RelayUrlInput {...register("url")} placeholder="wss://relay.example.com" />
<Button type="submit">Add</Button>
</Flex>
<Select
size="sm"
borderRadius="md"
placeholder="Custom Relays"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{relaySets.map((set) => (
<option key={set.id} value={getEventCoordinate(set)}>
{getListName(set)}
</option>
))}
</Select>
);
}
export default function RelayManagementDrawer({ isOpen, onClose, ...props }: Omit<DrawerProps, "children">) {
const account = useCurrentAccount();
const readRelays = useReadRelays();
const writeRelays = useWriteRelays();
@ -111,8 +121,63 @@ export default function RelayManagementDrawer({ isOpen, onClose, ...props }: Omi
.map((r) => r.url)
.sort();
const save = useDisclosure();
const [selected, setSelected] = useState<string>();
const relaySets = useUserRelaySets(account?.pubkey);
const changeSet = (cord: string) => {
setSelected(cord);
const set = relaySets.find((s) => getEventCoordinate(s) === cord);
if (set) {
clientRelaysService.setRelaysFromRelaySet(set);
}
};
const renderContent = () => {
if (save.isOpen) {
return (
<>
<SaveRelaySetForm
relaySet={relaySets.find((s) => getEventCoordinate(s) === selected)}
onCancel={save.onClose}
onSaved={(set) => {
save.onClose();
setSelected(getEventCoordinate(set));
}}
writeRelays={clientRelaysService.writeRelays.value}
readRelays={clientRelaysService.readRelays.value}
/>
</>
);
}
return (
<>
<Flex gap="2">
<SelectRelaySet relaySets={relaySets} value={selected} onChange={changeSet} />
<Button size="sm" colorScheme="primary" onClick={save.onOpen}>
Save
</Button>
</Flex>
{sorted.map((url) => (
<RelayControl key={url} url={url} />
))}
<AddRelayForm
onSubmit={(url) => {
clientRelaysService.addRelay(url, RelayMode.ALL);
setSelected(undefined);
}}
/>
{/* <Heading size="sm">Other Relays</Heading>
{others.map((url) => (
<RelayControl key={url} url={url} />
))} */}
</>
);
};
return (
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md" {...props}>
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md" closeOnEsc={false} {...props}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
@ -121,14 +186,7 @@ export default function RelayManagementDrawer({ isOpen, onClose, ...props }: Omi
</DrawerHeader>
<DrawerBody px={{ base: 2, md: 4 }} pb="2" pt="0" display="flex" gap="2" flexDir="column">
<AddRelayForm />
{sorted.map((url) => (
<RelayControl key={url} url={url} />
))}
<Heading size="sm">Other Relays</Heading>
{others.map((url) => (
<RelayControl key={url} url={url} />
))}
{renderContent()}
</DrawerBody>
</DrawerContent>
</Drawer>

View File

@ -0,0 +1,73 @@
import { Button, Flex, FormControl, FormLabel, Input, Textarea, useToast } from "@chakra-ui/react";
import { NostrEvent, kinds } from "nostr-tools";
import { useForm } from "react-hook-form";
import { getListDescription, getListName, setListDescription, setListName } from "../../helpers/nostr/lists";
import { isRTag } from "../../types/nostr-event";
import { cloneEvent, ensureDTag } from "../../helpers/nostr/events";
import { createRTagsFromRelaySets } from "../../helpers/nostr/mailbox";
import NostrPublishAction from "../../classes/nostr-publish-action";
import clientRelaysService from "../../services/client-relays";
import { useSigningContext } from "../../providers/global/signing-provider";
export function SaveRelaySetForm({
relaySet,
onCancel,
onSaved,
writeRelays,
readRelays,
}: {
relaySet?: NostrEvent;
onCancel: () => void;
onSaved?: (event: NostrEvent) => void;
writeRelays: Iterable<string>;
readRelays: Iterable<string>;
}) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const { register, formState, handleSubmit } = useForm({
defaultValues: {
name: relaySet ? getListName(relaySet) ?? "" : "",
description: relaySet ? getListDescription(relaySet) ?? "" : "",
},
mode: "all",
resetOptions: { keepDirtyValues: true },
});
const submit = handleSubmit(async (values) => {
try {
const draft = cloneEvent(kinds.Relaysets, relaySet);
ensureDTag(draft);
setListName(draft, values.name);
setListDescription(draft, values.description);
draft.tags = draft.tags.filter((t) => !isRTag(t));
draft.tags.push(...createRTagsFromRelaySets(readRelays, writeRelays));
const signed = await requestSignature(draft);
new NostrPublishAction("Save Relay Set", clientRelaysService.outbox, signed);
if (onSaved) onSaved(signed);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
});
return (
<Flex as="form" onSubmit={submit} direction="column" gap="2">
<FormControl isInvalid={!!formState.errors.name} isRequired>
<FormLabel>Name</FormLabel>
<Input type="text" {...register("name", { required: true })} isRequired autoComplete="off" />
</FormControl>
<FormControl isInvalid={!!formState.errors.description}>
<FormLabel>Description</FormLabel>
<Textarea {...register("description")} />
</FormControl>
<Flex justifyContent="space-between">
<Button onClick={onCancel}>Cancel</Button>
<Button colorScheme="primary" type="submit" isLoading={formState.isSubmitting}>
Save
</Button>
</Flex>
</Flex>
);
}

View File

@ -8,6 +8,7 @@ import { safeDecode } from "../nip19";
import { getEventUID } from "nostr-idb";
import { safeRelayUrls } from "../relay";
import dayjs from "dayjs";
import { nanoid } from "nanoid";
export function truncatedId(str: string, keep = 6) {
if (str.length < keep * 2 + 3) return str;
@ -270,13 +271,29 @@ export function sortByDate(a: NostrEvent, b: NostrEvent) {
return b.created_at - a.created_at;
}
export function cloneEvent(event?: NostrEvent): DraftNostrEvent {
/** create a copy of the event with a new created_at */
export function cloneEvent(kind: number, event?: DraftNostrEvent | NostrEvent): DraftNostrEvent {
return {
kind: kinds.RelayList,
kind: event?.kind ?? kind,
created_at: dayjs().unix(),
content: event?.content || "",
content: event?.content ?? "",
tags: event?.tags ? Array.from(event.tags) : [],
};
}
/** ensure an event has a d tag */
export function ensureDTag(draft: DraftNostrEvent, d: string = nanoid()) {
if (!draft.tags.some(isDTag)) {
draft.tags.push(["d", d]);
}
}
export function replaceOrAddSimpleTag(draft: DraftNostrEvent, tagName: string, value: string) {
if (draft.tags.some((t) => t[0] === tagName)) {
draft.tags = draft.tags.map((t) => (t[0] === tagName ? [tagName, value] : t));
} else {
draft.tags.push([tagName, value]);
}
}
export { getEventUID };

View File

@ -2,7 +2,7 @@ import dayjs from "dayjs";
import { kinds, nip19 } from "nostr-tools";
import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
import { parseCoordinate } from "./events";
import { parseCoordinate, replaceOrAddSimpleTag } from "./events";
import { getRelayVariations, safeRelayUrls } from "../relay";
export const MUTE_LIST_KIND = 10000;
@ -27,9 +27,15 @@ export function getListName(event: NostrEvent) {
event.tags.find(isDTag)?.[1]
);
}
export function setListName(draft: DraftNostrEvent, name: string) {
replaceOrAddSimpleTag(draft, "name", name);
}
export function getListDescription(event: NostrEvent) {
return event.tags.find((t) => t[0] === "description")?.[1];
}
export function setListDescription(draft: DraftNostrEvent, description: string) {
replaceOrAddSimpleTag(draft, "description", description);
}
export function isJunkList(event: NostrEvent) {
const name = event.tags.find(isDTag)?.[1];

View File

@ -1,3 +1,4 @@
import { kinds } from "nostr-tools";
import { RelayMode } from "../../classes/relay";
import { DraftNostrEvent, NostrEvent, RTag, Tag, isRTag } from "../../types/nostr-event";
import { safeRelayUrl } from "../relay";
@ -38,7 +39,7 @@ export function getRelaysFromMailbox(list: NostrEvent | DraftNostrEvent): { url:
}
export function addRelayModeToMailbox(list: NostrEvent | undefined, relay: string, mode: RelayMode): DraftNostrEvent {
let draft = cloneEvent(list);
let draft = cloneEvent(kinds.RelayList, list);
draft.tags = cleanRTags(draft.tags);
const existing = draft.tags.find((t) => t[0] === "r" && t[1] === relay) as RTag;
@ -53,7 +54,7 @@ export function removeRelayModeFromMailbox(
relay: string,
mode: RelayMode,
): DraftNostrEvent {
let draft = cloneEvent(list);
let draft = cloneEvent(kinds.RelayList, list);
draft.tags = cleanRTags(draft.tags);
const existing = draft.tags.find((t) => t[0] === "r" && t[1] === relay) as RTag;
@ -66,3 +67,12 @@ export function removeRelayModeFromMailbox(
}
return draft;
}
export function createRTagsFromRelaySets(readRelays: Iterable<string>, writeRelays: Iterable<string>) {
const relays: Record<string, number> = {};
for (const r of readRelays) relays[r] = (relays[r] ?? 0) | RelayMode.READ;
for (const r of writeRelays) relays[r] = (relays[r] ?? 0) | RelayMode.WRITE;
console.log(relays);
return Object.entries(relays).map(([url, mode]) => createRelayTag(url, mode));
}

View File

@ -69,10 +69,14 @@ class P2PKCashuWallet extends CashuWallet {
JSON.stringify([
"P2PK",
{
nonce: bytesToHex(randomBytes(16)),
// NOTE: the order is very important for the token to work with nutshell
// This can be removed when nutshell no longer re-encodes the secret when checking the sig
data: pubkey,
nonce: bytesToHex(randomBytes(16)),
},
]),
])
.replaceAll(/,/g, ", ")
.replaceAll(/:/g, ": "),
);
secrets.push(secret);
const { B_, r } = blindMessage(secret, deterministicR);

View File

@ -4,6 +4,7 @@ import userMailboxesService from "./user-mailboxes";
import { PersistentSubject } from "../classes/subject";
import { logger } from "../helpers/debug";
import RelaySet from "../classes/relay-set";
import { NostrEvent } from "nostr-tools";
export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
@ -44,6 +45,10 @@ class ClientRelayService {
this.saveRelays();
}
setRelaysFromRelaySet(event: NostrEvent) {
this.writeRelays.next(RelaySet.fromNIP65Event(event, RelayMode.WRITE));
this.readRelays.next(RelaySet.fromNIP65Event(event, RelayMode.READ));
}
saveRelays() {
localStorage.setItem("read-relays", this.readRelays.value.urls.join(","));

View File

@ -4,7 +4,7 @@ export type ETag = ["e", string] | ["e", string, string] | ["e", string, string,
export type ATag = ["a", string] | ["a", string, string] | ["e", string, string, string];
export type PTag = ["p", string] | ["p", string, string] | ["p", string, string, string];
export type RTag = ["r", string] | ["r", string, string];
export type DTag = ["d"] | ["d", string];
export type DTag = ["d", string];
export type ExpirationTag = ["expiration", string];
export type EmojiTag = ["emoji", string, string];
export type Tag = string[] | ETag | PTag | RTag | DTag | ATag | ExpirationTag;
@ -41,7 +41,7 @@ export function isRTag(tag: Tag): tag is RTag {
return tag[0] === "r" && tag[1] !== undefined;
}
export function isDTag(tag: Tag): tag is DTag {
return tag[0] === "d";
return tag[0] === "d" && tag[1] !== undefined;
}
export function isATag(tag: Tag): tag is ATag {
return tag[0] === "a" && tag[1] !== undefined;