create lists with subscriptions

This commit is contained in:
zmeyer44 2023-10-17 14:25:26 -04:00
parent 9a83cdc53e
commit 8434c358ff
12 changed files with 1188 additions and 83 deletions

View File

@ -4,6 +4,7 @@ import { Toaster } from "sonner";
import { ModalProvider } from "./modal/provider";
import useRouteChange from "@/lib/hooks/useRouteChange";
import { NDKProvider } from "./ndk";
import SignerProvider from "./signer";
import { RELAYS } from "@/constants";
export function Providers({ children }: { children: React.ReactNode }) {
@ -16,13 +17,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
useRouteChange(handleRouteChange);
return (
<>
<NDKProvider
relayUrls={RELAYS}
>
<Toaster richColors className="dark:hidden" />
<Toaster theme="dark" className="hidden dark:block" />
<ModalProvider>{children}</ModalProvider>
<NDKProvider relayUrls={RELAYS}>
<SignerProvider>
<Toaster richColors className="dark:hidden" />
<Toaster theme="dark" className="hidden dark:block" />
<ModalProvider>{children}</ModalProvider>
</SignerProvider>
</NDKProvider>
</>
);

View File

@ -0,0 +1,111 @@
"use client";
import {
createContext,
useContext,
useState,
useMemo,
type ReactNode,
} from "react";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { findEphemeralSigner } from "@/lib/actions/ephemeral";
import {
NDKPrivateKeySigner,
NDKUser,
type NDKSigner,
type NDKList,
} from "@nostr-dev-kit/ndk";
import { useNDK } from "../ndk";
export type SignerStoreItem = {
signer: NDKPrivateKeySigner;
user: NDKUser;
saved: boolean;
title?: string;
id: string;
};
type SignerItems = Map<string, SignerStoreItem>;
type SignerContextProps = {
npubSigners: Map<string, NDKSigner>;
signers: SignerItems;
getSigner: (list: NDKList) => Promise<SignerStoreItem>;
};
const SignerContext = createContext<SignerContextProps | undefined>(undefined);
export default function SignerProvider({
children,
}: {
children?: ReactNode | undefined;
}) {
const { currentUser } = useCurrentUser();
const { ndk } = useNDK();
const [signers, setSigners] = useState(new Map());
const npubSigners = useMemo(() => {
const npubs = new Map<string, NDKSigner>();
for (const entry of signers) {
const { user, signer } = entry[1];
npubs.set(user.npub, signer);
}
return npubs;
}, [signers]);
async function getDelegatedSignerName(list: NDKList) {
let name = "";
console.log("getDelegatedSignerName", list);
if (!currentUser?.profile) {
console.log("fetching user profile");
await currentUser?.fetchProfile();
}
name = `${
currentUser?.profile?.displayName ??
currentUser?.profile?.name ??
currentUser?.profile?.nip05 ??
`${currentUser?.npub.slice(0, 9)}...`
}'s `;
return name + list.title ?? "List";
}
async function getSigner(list: NDKList): Promise<SignerStoreItem> {
const id = list.encode();
let item = signers.get(id);
if (item) return item;
let signer = await findEphemeralSigner(ndk!, ndk!.signer!, {
associatedEventNip19: list.encode(),
});
if (signer) {
console.log(`found a signer for list ${list.title}`);
item = {
signer: signer!,
user: await signer.user(),
saved: true,
id,
};
} else {
console.log(`no signer found for list ${list.title}`);
signer = NDKPrivateKeySigner.generate();
console.log(`Signer generated ${JSON.stringify(signer)}`);
item = {
signer,
user: await signer.user(),
saved: false,
title: await getDelegatedSignerName(list),
id,
};
}
item.user.ndk = ndk;
setSigners((prev) => new Map(prev.set(id, item)));
return item;
}
return (
<SignerContext.Provider value={{ signers, npubSigners, getSigner }}>
{children}
</SignerContext.Provider>
);
}
export function useSigner() {
return useContext(SignerContext);
}

View File

@ -0,0 +1,126 @@
import { useState } from "react";
import FormModal from "./FormModal";
import { z } from "zod";
import { useModal } from "@/app/_providers/modal/provider";
import { toast } from "sonner";
import { generateRandomString } from "@/lib/nostr";
import { satsToBtc } from "@/lib/utils";
import useCurrentUser from "@/lib/hooks/useCurrentUser";
import { useNDK } from "@/app/_providers/ndk";
import { useSigner, type SignerStoreItem } from "@/app/_providers/signer";
import { createEvent } from "@/lib/actions/create";
import { getTagValues } from "@/lib/nostr/utils";
import { NDKList } from "@nostr-dev-kit/ndk";
import { saveEphemeralSigner } from "@/lib/actions/ephemeral";
const CreateListSchema = z.object({
title: z.string(),
image: z.string().optional(),
description: z.string().optional(),
subscriptions: z.boolean(),
price: z.number().optional(),
});
type CreateListType = z.infer<typeof CreateListSchema>;
export default function CreateList() {
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
const { currentUser, updateUser } = useCurrentUser();
const { ndk } = useNDK();
const { getSigner } = useSigner()!;
async function handleSubmit(data: CreateListType) {
setIsLoading(true);
const random = generateRandomString();
const tags = [
["title", data.title],
["name", data.title],
["d", random],
];
if (data.description) {
tags.push(["description", data.description]);
tags.push(["summary", data.description]);
}
if (data.image) {
tags.push(["image", data.image]);
tags.push(["picture", data.image]);
}
if (data.subscriptions) {
tags.push(["subscriptions", "true"]);
}
if (data.price) {
tags.push(["price", satsToBtc(data.price).toString(), "btc", "year"]);
}
const event = await createEvent(ndk!, {
content: "",
kind: 30001,
tags: tags,
});
if (event && getTagValues("subscriptions", event.tags)) {
await getSigner(new NDKList(ndk, event.rawEvent()))
.then((delegateSigner) =>
saveEphemeralSigner(ndk!, delegateSigner.signer, {
associatedEvent: event,
keyProfile: {
name: delegateSigner.title,
picture: currentUser?.profile?.image,
lud06: currentUser?.profile?.lud06,
lud16: currentUser?.profile?.lud16,
},
}),
)
.then(
(savedSigner) => savedSigner,
// updateList(ndk!, event.rawEvent(), [
// ["delegate", savedSigner.hexpubkey],
// ]),
)
.catch((err) => console.log("Error creating delegate"));
}
// getLists(currentUser!.hexpubkey);
setIsLoading(false);
toast.success("List Created!");
modal?.hide();
}
return (
<FormModal
title="Create List"
fields={[
{
label: "Title",
type: "input",
slug: "title",
},
{
label: "Description",
type: "text-area",
slug: "description",
},
{
label: "Image",
type: "input",
slug: "image",
},
{
label: "Enable subscriptions",
type: "toggle",
slug: "subscriptions",
},
{
label: "Price",
subtitle: "sats/year",
type: "number",
slug: "price",
condition: "subscriptions",
},
]}
formSchema={CreateListSchema}
onSubmit={handleSubmit}
isSubmitting={isLoading}
cta={{
text: "Create List",
}}
/>
);
}

View File

@ -9,6 +9,7 @@ import {
FieldValues,
DefaultValues,
Path,
PathValue,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import Template from "./Template";
@ -37,6 +38,7 @@ type FieldOptions =
| "toggle"
| "horizontal-tabs"
| "input"
| "number"
| "text-area"
| "custom";
@ -44,11 +46,13 @@ type DefaultFieldType<TSchema> = {
label: string;
slug: keyof z.infer<z.Schema<TSchema>> & string;
placeholder?: string;
subtitle?: string;
description?: string;
lines?: number;
styles?: string;
value?: string | number | boolean;
custom?: ReactNode;
condition?: keyof z.infer<z.Schema<TSchema>> & string;
options?: { label: string; value: string; icon?: ReactNode }[];
};
type FieldType<TSchema> = DefaultFieldType<TSchema> &
@ -91,10 +95,11 @@ export default function FormModal<TSchema extends FieldValues>({
defaultValues: defaultValues as DefaultValues<TSchema>,
mode: "onChange",
});
const { watch, setValue } = form;
return (
<Template title={title} className="md:max-w-[400px]">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.map(
({
label,
@ -102,69 +107,171 @@ export default function FormModal<TSchema extends FieldValues>({
type,
placeholder,
description,
subtitle,
condition,
...fieldProps
}) => (
<FormField
control={form.control}
name={slug as Path<TSchema>}
render={({ field }) => (
<FormItem
className={cn(
type === "toggle" &&
"flex items-center gap-x-3 space-y-0",
)}
>
<FormLabel>{label}</FormLabel>
{type === "input" ? (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
) : type === "text-area" ? (
<FormControl>
<Textarea
placeholder={placeholder}
{...field}
className="auto-sizing"
/>
</FormControl>
) : type === "select" ? (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
}) => {
if (!condition) {
return (
<FormField
control={form.control}
name={slug as Path<TSchema>}
render={({ field }) => (
<FormItem
className={cn(
type === "toggle" &&
"flex items-center gap-x-3 space-y-0",
)}
>
<FormLabel>{label}</FormLabel>
{type === "input" ? (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
) : type === "text-area" ? (
<FormControl>
<Textarea
placeholder={placeholder}
{...field}
className="auto-sizing"
/>
</FormControl>
) : type === "select" ? (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent className="z-modal+">
{fieldProps.options?.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : type === "toggle" ? (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
) : type === "number" ? (
<FormControl>
<Input
placeholder={placeholder}
type="number"
{...field}
/>
</FormControl>
) : (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
)}
{!!description && (
<FormDescription>{description}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
}
const state = watch(condition as Path<TSchema>);
if (!state) return;
return (
<FormField
control={form.control}
name={slug as Path<TSchema>}
render={({ field }) => (
<FormItem
className={cn(
type === "toggle" &&
"flex items-center gap-x-3 space-y-0",
)}
>
<FormLabel>
{label}
{!!subtitle && (
<span className="ml-1.5 text-[0.8rem] font-normal text-muted-foreground ">
{subtitle}
</span>
)}
</FormLabel>
{type === "input" ? (
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<Input placeholder={placeholder} {...field} />
</FormControl>
<SelectContent className="z-modal+">
{fieldProps.options?.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : type === "toggle" ? (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
) : (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
)}
{!!description && (
<FormDescription>{description}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
),
) : type === "text-area" ? (
<FormControl>
<Textarea
placeholder={placeholder}
{...field}
className="auto-sizing"
/>
</FormControl>
) : type === "select" ? (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent className="z-modal+">
{fieldProps.options?.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : type === "toggle" ? (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
) : type === "number" ? (
<FormControl>
<Input
placeholder={placeholder}
type="number"
{...field}
onChange={(e) => {
setValue(
field.name,
parseInt(e.target.value) as PathValue<
TSchema,
Path<TSchema>
>,
);
}}
/>
</FormControl>
) : (
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
)}
{!!description && (
<FormDescription>{description}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
},
)}
<Button type="submit" className="w-full" loading={isSubmitting}>
{cta.text}

View File

@ -16,6 +16,7 @@ import {
import { RiSubtractFill, RiAddFill } from "react-icons/ri";
import { formatCount } from "@/lib/utils";
import LoginModal from "./Login";
import CreateList from "./CreateList";
export default function NewEventModal() {
const modal = useModal();
@ -37,7 +38,12 @@ export default function NewEventModal() {
<HiNewspaper className="h-4 w-4" />
</Button>
</Link>
<Button className="w-full gap-x-1">
<Button
onClick={() => {
modal?.swap(<CreateList />);
}}
className="w-full gap-x-1"
>
<span>Content List</span>
<HiBookmarkSquare className="h-4 w-4" />
</Button>

View File

@ -18,7 +18,7 @@ export default function Template({ title, children, className }: ModalProps) {
return (
<div
className={cn(
"relative w-full grow bg-background p-4 shadow sm:border md:rounded-lg md:p-6",
"relative w-full grow bg-background p-4 shadow md:rounded-lg md:border md:p-6",
className,
)}
>

View File

@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@ -13,12 +13,12 @@ const Avatar = React.forwardRef<
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
className,
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@ -29,8 +29,8 @@ const AvatarImage = React.forwardRef<
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -39,12 +39,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
"flex h-full w-full items-center justify-center rounded-full bg-muted uppercase",
className,
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

267
lib/actions/create.ts Normal file
View File

@ -0,0 +1,267 @@
import { nip19 } from "nostr-tools";
import NDK, {
NDKEvent,
type NDKPrivateKeySigner,
type NDKTag,
type NDKList,
type NostrEvent,
NDKUser,
} from "@nostr-dev-kit/ndk";
import { generateRandomString, encryptMessage } from "@/lib/nostr";
import { unixTimeNowInSeconds } from "@/lib/nostr/dates";
import { getTagsValues } from "@/lib/nostr/utils";
export async function createEvent(
ndk: NDK,
event: {
content: string;
kind: number;
tags: string[][];
},
) {
try {
const pubkey = await window.nostr?.getPublicKey();
if (!pubkey || !window.nostr) {
throw new Error("No public key provided!");
}
const eventToPublish = new NDKEvent(ndk, {
...event,
pubkey,
created_at: unixTimeNowInSeconds(),
} as NostrEvent);
console.log("eventToPublish ", eventToPublish);
const signed = await eventToPublish.sign();
console.log("signed", signed);
await eventToPublish.publish();
return eventToPublish;
} catch (err) {
console.log(err);
alert("An error has occured");
return false;
}
}
export async function createEncryptedEventOnPrivateList(
ndk: NDK,
event: {
content: string;
kind: number;
tags: string[][];
},
list: NDKList,
delegateSigner?: NDKPrivateKeySigner,
) {
const pubkey = await window.nostr?.getPublicKey();
if (!pubkey || !window.nostr) {
throw new Error("No public key provided!");
}
const eventToPublish = new NDKEvent(ndk, {
...event,
tags: [...event.tags, ["client", "ordstr"]],
pubkey,
created_at: unixTimeNowInSeconds(),
} as NostrEvent);
await eventToPublish.sign();
const rawEventString = JSON.stringify(eventToPublish.rawEvent());
const passphrase = generateRandomString();
// const passphrase = "test";
const encryptedRawEventString = await encryptMessage(
rawEventString,
passphrase,
);
const signer = delegateSigner ?? ndk.signer!;
const user = await signer.user();
const newEvent = new NDKEvent(ndk, {
content: encryptedRawEventString,
kind: 3745,
tags: [
["kind", event.kind.toString()],
["client", "ordstr"],
],
pubkey: user.hexpubkey,
} as NostrEvent);
await newEvent.sign(signer);
await newEvent.publish();
const tag = newEvent.tagReference();
if (!tag) return;
// Add event to list
await list.addItem(tag, undefined, false);
await list.sign();
await list.publish();
// Send DMs to subscribers
const subscribers = getTagsValues("p", list.tags);
for (const subscriber of subscribers) {
const messageEvent = new NDKEvent(ndk, {
content: passphrase,
kind: 4,
tags: [
["p", subscriber],
["e", newEvent.id],
["client", "ordstr"],
],
pubkey: user.hexpubkey,
} as NostrEvent);
console.log("message to create", messageEvent);
await messageEvent.encrypt(new NDKUser({ hexpubkey: subscriber }), signer);
console.log("Encrypted message", messageEvent);
await messageEvent.sign(signer);
await messageEvent.publish();
}
return true;
}
export async function createReaction(
ndk: NDK,
content: "+" | "-",
event: {
id: string;
pubkey: string;
},
) {
return createEvent(ndk, {
content,
kind: 7,
tags: [
["e", event.id],
["p", event.pubkey],
],
});
}
export async function createList(
ndk: NDK,
title: string,
description?: string,
) {
return createEvent(ndk, {
content: "",
kind: 30001,
tags: [
["name", title],
["description", description ?? ""],
["d", generateRandomString()],
],
});
}
export async function deleteEvent(
ndk: NDK,
events: [["e", string] | ["a", `${number}:${string}:${string}`]],
reason?: string,
) {
return createEvent(ndk, {
kind: 5,
content: reason ?? "",
tags: events,
});
}
async function generateEvent(
ndk: NDK,
event: {
content: string;
kind: number;
tags: string[][];
},
delegateSigner?: NDKPrivateKeySigner,
): Promise<NDKTag | undefined> {
let _value = event.content.trim();
// if this a relay URL, nrelay-encode it
if (_value.startsWith("wss://")) {
_value = nip19.nrelayEncode(_value);
}
try {
const decode = nip19.decode(_value);
switch (decode.type) {
case "naddr":
case "note":
case "nevent":
// We were able to decode something that looks like an event
// fetch it
const _event = await ndk.fetchEvent(_value);
if (_event) {
// we were able to fetch it, let's return it
return _event.tagReference();
} else {
// TODO: Generate a NDKTag based on the values
return undefined;
}
case "nrelay":
return ["r", decode.data as string];
case "npub":
return ["p", decode.data as string];
case "nprofile":
const d = ["p", decode.data.pubkey];
if (decode.data.relays && decode.data.relays[0])
d.push(decode.data.relays[0]);
return d;
}
} catch (e) {
console.log("at catch", e);
const signer = delegateSigner ?? ndk.signer!;
console.log("Signer", signer);
const user = await signer.user();
console.log("User", user);
const newEvent = new NDKEvent(ndk, {
content: _value || "",
kind: 1,
tags: [...event.tags, ["client", "ordstr"]],
pubkey: user.hexpubkey,
} as NostrEvent);
console.log("Event to create", newEvent);
await newEvent.sign(signer);
await newEvent.publish();
return newEvent.tagReference();
}
}
export async function createEventOnList(
ndk: NDK,
event: {
content: string;
kind: number;
tags: string[][];
},
list: NDKList,
delegateSigner?: NDKPrivateKeySigner,
) {
const tag = await generateEvent(ndk, event, delegateSigner);
if (!tag) return;
await list.addItem(tag, undefined, false);
await list.sign();
await list.publish();
return true;
}
export async function updateList(
ndk: NDK,
list: NostrEvent,
newTags: [string, string][],
) {
let tags = list.tags;
for (const [key, value] of newTags) {
const index = tags.findIndex(([tK]) => tK === key);
if (index !== -1) {
// Replace old
tags[index] = [key, value];
} else {
tags.push([key, value]);
}
}
console.log("updating list", tags);
return createEvent(ndk, {
...list,
kind: list.kind as number,
tags: tags.filter(([_, value]) => value !== undefined),
});
}

161
lib/actions/ephemeral.ts Normal file
View File

@ -0,0 +1,161 @@
import { type Event as NostrEvent } from "nostr-tools";
import NDK, {
NDKEvent,
NDKPrivateKeySigner,
type NDKTag,
type NDKFilter,
type NDKSigner,
type NDKUserProfile,
} from "@nostr-dev-kit/ndk";
import { getHashedKeyName } from "@/lib/nostr";
import { z } from "zod";
const SignerSchema = z.object({
key: z.string(),
});
interface IFindEphemeralSignerLookups {
name?: string;
associatedEventNip19?: string;
}
/**
* Finds a named ephemeral signer from a self-DM.
*/
export async function findEphemeralSigner(
ndk: NDK,
mainSigner: NDKSigner,
opts: IFindEphemeralSignerLookups,
): Promise<NDKPrivateKeySigner | undefined> {
const mainUser = await mainSigner.user();
const filter: NDKFilter = { kinds: [2600 as number] };
if (opts.name) {
const hashedName = await getHashedKeyName(opts.name);
filter["#e"] = [hashedName];
} else if (opts.associatedEventNip19) {
const hashedEventReference = await getHashedKeyName(
opts.associatedEventNip19,
);
filter["#e"] = [hashedEventReference];
}
const event = await ndk.fetchEvent(filter);
if (event) {
const decryptEventFunction = async (event: NDKEvent) => {
await event.decrypt(await mainSigner.user());
const content = SignerSchema.parse(JSON.parse(event.content));
return new NDKPrivateKeySigner(content.key as string);
};
const promise = new Promise<NDKPrivateKeySigner>((resolve, reject) => {
let decryptionAttempts = 0;
try {
decryptionAttempts++;
resolve(decryptEventFunction(event));
} catch (e) {
if (decryptionAttempts > 5) {
console.error(
`Failed to decrypt ephemeral signer event after ${decryptionAttempts} attempts.`,
);
reject(e);
return;
}
setTimeout(() => {
decryptEventFunction(event);
}, 1000 * Math.random());
}
});
return promise;
}
}
interface ISaveOpts {
associatedEvent?: NDKEvent;
name?: string;
metadata?: object;
keyProfile?: NDKUserProfile;
mainSigner?: NDKSigner;
}
type EphemeralKeyEventContent = {
key: string;
event?: string;
version: string;
metadata?: object;
};
function generateContent(
targetSigner: NDKPrivateKeySigner,
opts: ISaveOpts = {},
) {
const content: EphemeralKeyEventContent = {
key: targetSigner.privateKey!,
version: "v1",
...opts.metadata,
};
if (opts.associatedEvent) content.event = opts.associatedEvent.encode();
return JSON.stringify(content);
}
async function generateTags(mainSigner: NDKSigner, opts: ISaveOpts = {}) {
const mainUser = await mainSigner.user();
const tags = [
["p", mainUser.hexpubkey],
["client", "ordstr"],
];
if (opts.associatedEvent) {
// TODO: This is trivially reversable; better to encrypt it or hash it with the hexpubkey
const hashedEventReference = await getHashedKeyName(
opts.associatedEvent.encode(),
);
tags.push(["e", hashedEventReference]);
}
if (opts.name) {
const hashedName = await getHashedKeyName(opts.name);
tags.push(["e", hashedName]);
}
return tags;
}
export async function saveEphemeralSigner(
ndk: NDK,
targetSigner: NDKPrivateKeySigner,
opts: ISaveOpts = {},
) {
// Determine current user signer
const mainSigner = opts.mainSigner || ndk.signer;
if (!mainSigner) throw new Error("No main signer provided");
const mainUser = await mainSigner.user();
// Create 2600 kind which saves encrypted JSON of the ephemeral signer's private key, the associated list, and other metadata
const event = new NDKEvent(ndk, {
kind: 2600,
content: generateContent(targetSigner, opts),
tags: await generateTags(mainSigner, opts),
} as NostrEvent);
event.pubkey = mainUser.hexpubkey;
await event.encrypt(mainUser, mainSigner);
await event.publish();
// Update Ephemeral signers metadata
console.log("Checking keyProfile", opts.keyProfile);
const user = await targetSigner.user();
if (opts.keyProfile) {
const event = new NDKEvent(ndk, {
kind: 0,
content: JSON.stringify(opts.keyProfile),
tags: [] as NDKTag[],
} as NostrEvent);
event.pubkey = user.hexpubkey;
await event.sign(targetSigner);
await event.publish();
}
return user;
}

174
lib/actions/zap.ts Normal file
View File

@ -0,0 +1,174 @@
import NDK, {
NDKEvent,
zapInvoiceFromEvent,
type NostrEvent,
} from "@nostr-dev-kit/ndk";
import { requestProvider } from "webln";
import { bech32 } from "@scure/base";
import { z } from "zod";
import { createZodFetcher } from "zod-fetch";
import { getTagValues, getTagsAllValues } from "../nostr/utils";
import { unixTimeNowInSeconds } from "../nostr/dates";
import { createEvent } from "./create";
const fetchWithZod = createZodFetcher();
const ZapEndpointResponseSchema = z.object({
nostrPubkey: z.string(),
});
export async function sendZap(
ndk: NDK,
amount: number,
_event: NostrEvent,
comment?: string,
) {
console.log("sendzap called", amount);
const event = await new NDKEvent(ndk, _event);
const pr = await event.zap(amount * 1000, comment);
if (!pr) {
console.log("No PR");
return;
}
console.log("PR", pr);
const webln = await requestProvider();
return await webln.sendPayment(pr);
}
export async function checkPayment(
ndk: NDK,
tagId: string,
pubkey: string,
event: NostrEvent,
) {
console.log("Running check payment");
const paymentEvent = await ndk.fetchEvent({
kinds: [9735],
["#a"]: [tagId],
["#p"]: [pubkey],
});
console.log("paymentEvent", paymentEvent);
if (!paymentEvent) return;
const invoice = zapInvoiceFromEvent(paymentEvent);
console.log("invoice", invoice);
if (!invoice) {
console.log("No invoice");
return;
}
const zappedUser = ndk.getUser({
npub: invoice.zappee,
});
await zappedUser.fetchProfile();
if (!zappedUser.profile) {
console.log("No zappedUser profile");
return;
}
const { lud16, lud06 } = zappedUser.profile;
let zapEndpoint: null | string = null;
if (lud16 && !lud16.startsWith("LNURL")) {
const [name, domain] = lud16.split("@");
zapEndpoint = `https://${domain}/.well-known/lnurlp/${name}`;
} else if (lud06) {
const { words } = bech32.decode(lud06, 1e3);
const data = bech32.fromWords(words);
const utf8Decoder = new TextDecoder("utf-8");
zapEndpoint = utf8Decoder.decode(data);
}
if (!zapEndpoint) {
console.log("No zapEndpoint");
return;
}
const { nostrPubkey } = await fetchWithZod(
// The schema you want to validate with
ZapEndpointResponseSchema,
// Any parameters you would usually pass to fetch
zapEndpoint,
{
method: "GET",
},
);
if (!nostrPubkey) return;
console.log("nostrPubkey", nostrPubkey);
console.log("Invoice amount", invoice.amount);
console.log("Price", parseInt(getTagValues("price", event.tags) ?? "0"));
return (
nostrPubkey === paymentEvent.pubkey &&
invoice.amount >= parseInt(getTagValues("price", event.tags) ?? "0")
);
}
export async function updateListUsersFromZaps(
ndk: NDK,
tagId: string,
event: NostrEvent,
) {
const SECONDS_IN_MONTH = 2_628_000;
const paymentEvents = await ndk.fetchEvents({
kinds: [9735],
["#a"]: [tagId],
since: unixTimeNowInSeconds() - SECONDS_IN_MONTH,
});
const paymentInvoices = Array.from(paymentEvents).map((paymentEvent) =>
zapInvoiceFromEvent(paymentEvent),
);
const currentUsers = getTagsAllValues("p", event.tags);
let validUsers: string[][] = currentUsers.filter(
([pubkey, relay, petname, expiryUnix]) =>
parseInt(expiryUnix ?? "0") > unixTimeNowInSeconds(),
);
for (const paymentInvoice of paymentInvoices) {
if (
!paymentInvoice ||
validUsers.find(([pubkey]) => pubkey === paymentInvoice.zappee)
) {
continue;
}
const isValid = await checkPayment(
ndk,
tagId,
paymentInvoice.zappee,
event,
);
console.log("Is valid?", isValid);
if (isValid) {
validUsers.push([
paymentInvoice.zappee,
"",
"",
(unixTimeNowInSeconds() + SECONDS_IN_MONTH).toString(),
]);
}
}
// Add self
console.log("Adding self");
const selfIndex = validUsers.findIndex(([vu]) => vu === event.pubkey);
if (selfIndex !== -1) {
console.log("Already there");
validUsers[selfIndex] = [
event.pubkey,
"",
"self",
(unixTimeNowInSeconds() + SECONDS_IN_MONTH).toString(),
];
} else {
validUsers.push([
event.pubkey,
"",
"self",
(unixTimeNowInSeconds() + SECONDS_IN_MONTH).toString(),
]);
}
console.log("Valid users", validUsers);
return createEvent(ndk, {
...event,
kind: event.kind as number,
tags: [
...event.tags.filter(([key]) => key !== "p"),
...validUsers.map((user) => ["p", ...user]),
],
});
}

150
lib/nostr/index.ts Normal file
View File

@ -0,0 +1,150 @@
// import "websocket-polyfill";
import sha256 from "crypto-js/sha256";
import Hex from "crypto-js/enc-hex";
import { getTagValues } from "./utils";
import { sha256 as SHA256 } from "@noble/hashes/sha256";
import { bytesToHex } from "@noble/hashes/utils";
import crypto from "crypto";
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Reaction = 7,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
ProfileList = 30000,
GenericList = 30001,
}
export type Event = {
id?: string;
sig?: string;
kind: Kind;
tags: string[][];
pubkey: string;
content: string;
created_at: number;
};
export function getHashedKeyName(name: string) {
let eventHash = SHA256(name);
const version = bytesToHex(eventHash);
const alt = sha256(name).toString(Hex);
console.log("VERSION", version, "vs", alt);
console.log("EQUAL?", version === alt);
return version;
}
export namespace NostrService {
export function createEvent(
kind: number,
publicKey: string,
content: string,
tags: string[][],
) {
const event: Event = {
kind: kind,
pubkey: publicKey,
created_at: Math.floor(Date.now() / 1000),
content: content,
tags: tags,
};
event.id = getEventHash(event);
return event;
}
function serializeEvent(evt: Event) {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content,
]);
}
function getEventHash(event: Event): string {
return sha256(serializeEvent(event)).toString(Hex);
}
export async function signEvent(event: Event) {
try {
return await window.nostr?.signEvent(event);
} catch (err: any) {
console.error("signing event failed");
}
return event;
}
export function filterBlogEvents(eventArray: Event[]) {
const filteredEvents = eventArray.filter((e1: Event, index: number) => {
if (e1.content === "") {
return false;
}
const title = getTagValues("title", e1.tags);
if (!title || title === "") {
return false;
}
// return eventArray.findIndex((e2: Event) => e2.id === e1.id) === index;
return true;
});
return filteredEvents;
}
}
export function encryptMessage(message: string, password: string) {
try {
const buffer = create32ByteBuffer(password);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", buffer, iv);
const encrypted = Buffer.concat([
cipher.update(message, "utf-8"),
cipher.final(),
]);
return encrypted.toString("base64") + "?iv=" + iv.toString("base64");
} catch (e) {
console.error(e);
}
}
// Function to decrypt a hashed message using a passphrase
export function decryptMessage(encryptedMessage: string, password: string) {
try {
const buffer = create32ByteBuffer(password);
// Extract IV from the received message
const ivBase64 = encryptedMessage.split("?iv=")[1];
if (!ivBase64) {
return;
}
const iv = Buffer.from(ivBase64, "base64");
const encryptedText = Buffer.from(encryptedMessage, "base64");
const decipher = crypto.createDecipheriv("aes-256-cbc", buffer, iv);
const decrypted = decipher.update(encryptedText);
return Buffer.concat([decrypted, decipher.final()]).toString();
} catch (e) {
console.error(e);
}
}
function create32ByteBuffer(inputString: string) {
const hash = crypto.createHash("sha256").update(inputString).digest("hex");
const buffer = Buffer.from(hash, "hex");
return buffer;
}
export function generateRandomString() {
return crypto.randomBytes(32).toString("hex");
}

View File

@ -101,3 +101,6 @@ export function validateUrl(value: string) {
export function btcToSats(amount: number) {
return parseInt((amount * 100_000_000).toFixed());
}
export function satsToBtc(amount: number) {
return amount / 100_000_000;
}